Skip to content

Commit

Permalink
Implement the useEventStream() (SSE, HTTP GET only) and `useNewline…
Browse files Browse the repository at this point in the history
…DelimitedJson()` _React_ hooks

### What's done:

 - This change allows live event streaming from the back-end (`Flux<T>` or
   `ParallelFlux<T>`) to the font-end, using either `text/event-stream`
   (arbitrary text data, incl. JSON) or `application/x-ndjson` (JSON only).
 - On the server side, _Spring_ has support for both content types.
 - This is a part of #1096.
  • Loading branch information
0x6675636b796f75676974687562 committed Feb 9, 2023
1 parent 0a057ef commit 404a28f
Show file tree
Hide file tree
Showing 9 changed files with 661 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
@file:JvmName("HttpUtils")
@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE")

package com.saveourtool.save.backend.utils

import org.springframework.http.HttpHeaders
import org.springframework.http.HttpHeaders.CACHE_CONTROL
import org.springframework.http.HttpStatus.OK
import org.springframework.http.ResponseEntity
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind.EXACTLY_ONCE
import kotlin.contracts.contract
import kotlin.system.measureNanoTime
import kotlin.time.Duration.Companion.nanoseconds

private const val SERVER_TIMING = "Server-Timing"

/**
* [`no-transform`](https://www.rfc-editor.org/rfc/rfc7234#section-5.2.2.4)
* is absolutely necessary, so that SSE stream passes through the
* [proxy](https://github.com/chimurai/http-proxy-middleware) without
* [compression](https://github.com/expressjs/compression).
*
* Otherwise, the front-end receives all the events at once, and only
* after the response body is fully written.
*
* See
* [this comment](https://github.com/facebook/create-react-app/issues/7847#issuecomment-544715338)
* for details:
*
* The rest of the `Cache-Control` header is merely what _Spring_ sets by default.
*/
private val cacheControlValues: Array<out String> = arrayOf(
"no-cache",
"no-store",
"no-transform",
"max-age=0",
"must-revalidate",
)

/**
* Lazy HTTP response.
*/
typealias LazyResponse<T> = () -> T

/**
* Lazy HTTP response with timings.
*/
typealias LazyResponseWithTiming<T> = () -> ResponseWithTiming<T>

/**
* Adds support for the
* [`Server-Timing`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing)
* header.
*/
private fun <T : Any> T.withTimings(vararg timings: ServerTiming): ResponseWithTiming<T> =
ResponseWithTiming(this, *timings)

/**
* Adds support for the
* [`Server-Timing`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing)
* header.
*/
private fun <T : Any> LazyResponse<T>.withTimings(vararg timings: ServerTiming): LazyResponseWithTiming<T> =
{
val response: T
val nanos = measureNanoTime {
response = this()
}

when {
timings.isEmpty() -> response.withTimings(
ServerTiming(
"total",
"Total server time",
nanos.nanoseconds
)
)

else -> response.withTimings(*timings)
}
}

/**
* @param timings the server-side timings.
* @param lazyResponse the lazy HTTP response.
* @return [lazyResponse] wrapped with HTTP headers.
*/
@OptIn(ExperimentalContracts::class)
fun <T : Any> withHttpHeaders(
vararg timings: ServerTiming,
lazyResponse: LazyResponse<T>,
): ResponseEntity<T> {
contract {
callsInPlace(lazyResponse, EXACTLY_ONCE)
}

return withHttpHeaders(lazyResponse.withTimings(*timings))
}

/**
* Evaluates lazyResponse and returns an `HTTP 200 OK` with `Cache-Control` and
* optional `Server-Timing` headers.
*
* @return [lazyResponse] wrapped with HTTP headers.
*/
@OptIn(ExperimentalContracts::class)
private fun <T : Any> withHttpHeaders(
lazyResponse: LazyResponseWithTiming<T>,
): ResponseEntity<T> {
contract {
callsInPlace(lazyResponse, EXACTLY_ONCE)
}

val response = lazyResponse()

return ResponseEntity(
response.response,
httpHeaders(*response.timings),
OK,
)
}

/**
* @return HTTP headers with `Cache-Control` and optional `Server-Timing`.
*/
private fun httpHeaders(vararg timings: ServerTiming): HttpHeaders =
httpHeaders { headers ->
headers[CACHE_CONTROL] = cacheControlValues.joinToString()
if (timings.isNotEmpty()) {
headers[SERVER_TIMING] = timings.joinToString()
}
}

/**
* @return HTTP headers initialized with [init].
*/
@OptIn(ExperimentalContracts::class)
private fun httpHeaders(init: (headers: HttpHeaders) -> Unit): HttpHeaders {
contract {
callsInPlace(init, EXACTLY_ONCE)
}

return HttpHeaders().also { headers ->
init(headers)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.saveourtool.save.backend.utils

/**
* Adds support for the
* [`Server-Timing`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing)
* header.
*
* @property response the HTTP response.
* @property timings the server-side timings.
* @see ResponseWithTiming.Companion.invoke
*/
data class ResponseWithTiming<T : Any>(
val response: T,
val timings: Array<out ServerTiming>
) {
init {
require(timings.isNotEmpty()) {
"At least one timing required"
}
}

override fun equals(other: Any?): Boolean =
other is ResponseWithTiming<*> &&
response == other.response &&
timings contentEquals other.timings

override fun hashCode(): Int =
response.hashCode() xor timings.contentHashCode()

companion object {
/**
* Adds support for the
* [`Server-Timing`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing)
* header.
*
* @param response the HTTP response.
* @param timings the server-side timings.
* @return the [response] with [timings] added.
*/
operator fun <T : Any> invoke(
response: T,
vararg timings: ServerTiming,
): ResponseWithTiming<T> =
ResponseWithTiming(response, timings)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.saveourtool.save.backend.utils

import kotlin.time.Duration

/**
* @property id the unique identifier of this metric. Can't contain `';'`, `','`,
* or `' '`.
* @property description the description that will be displayed in the browser's
* _Development Tools_, under Network -> Timing. If the [description] is `null`,
* the _Development Tools_ will display the [id] instead.
* @property duration the server-side duration.
*/
data class ServerTiming(
val id: String,
val description: String? = null,
val duration: Duration
) {
init {
require(id.asSequence().none { char ->
char in sequenceOf(FIELD_SEPARATOR, ',', ' ')
})
}

/**
* @return the string representation of this timing, either
* `id;desc="Description";dur=123.456`, or
* `id;dur=123.456`. The duration is given in milliseconds, with an
* optional fractional part.
*/
@Suppress(
"MagicNumber",
"FLOAT_IN_ACCURATE_CALCULATIONS",
)
override fun toString(): String =
listOf(
id,
description?.let { "desc=\"$it\"" },
"dur=${duration.inWholeMicroseconds / 1e3}"
)
.asSequence()
.filterNotNull()
.joinToString(separator = ";")

private companion object {
private const val FIELD_SEPARATOR = ';'
}
}
2 changes: 2 additions & 0 deletions save-frontend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin
import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest

@Suppress("DSL_SCOPE_VIOLATION", "RUN_IN_SCRIPT") // https://github.com/gradle/gradle/issues/22797
plugins {
kotlin("js")
id("com.saveourtool.save.buildutils.build-frontend-image-configuration")
id("com.saveourtool.save.buildutils.code-quality-convention")
alias(libs.plugins.kotlin.plugin.serialization)
}

rootProject.plugins.withType<NodeJsRootPlugin> {
Expand Down
Loading

0 comments on commit 404a28f

Please sign in to comment.