-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement the
useEventStream()
(SSE, HTTP GET only) and `useNewline…
…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
1 parent
0a057ef
commit 404a28f
Showing
9 changed files
with
661 additions
and
18 deletions.
There are no files selected for viewing
147 changes: 147 additions & 0 deletions
147
save-backend/src/main/kotlin/com/saveourtool/save/backend/utils/HttpUtils.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
46 changes: 46 additions & 0 deletions
46
save-backend/src/main/kotlin/com/saveourtool/save/backend/utils/ResponseWithTiming.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
47 changes: 47 additions & 0 deletions
47
save-backend/src/main/kotlin/com/saveourtool/save/backend/utils/ServerTiming.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 = ';' | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.