diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/utils/HttpUtils.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/utils/HttpUtils.kt new file mode 100644 index 0000000000..958fb9e2fc --- /dev/null +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/utils/HttpUtils.kt @@ -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 = arrayOf( + "no-cache", + "no-store", + "no-transform", + "max-age=0", + "must-revalidate", +) + +/** + * Lazy HTTP response. + */ +typealias LazyResponse = () -> T + +/** + * Lazy HTTP response with timings. + */ +typealias LazyResponseWithTiming = () -> ResponseWithTiming + +/** + * Adds support for the + * [`Server-Timing`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing) + * header. + */ +private fun T.withTimings(vararg timings: ServerTiming): ResponseWithTiming = + ResponseWithTiming(this, *timings) + +/** + * Adds support for the + * [`Server-Timing`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing) + * header. + */ +private fun LazyResponse.withTimings(vararg timings: ServerTiming): LazyResponseWithTiming = + { + 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 withHttpHeaders( + vararg timings: ServerTiming, + lazyResponse: LazyResponse, +): ResponseEntity { + 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 withHttpHeaders( + lazyResponse: LazyResponseWithTiming, +): ResponseEntity { + 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) + } +} diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/utils/ResponseWithTiming.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/utils/ResponseWithTiming.kt new file mode 100644 index 0000000000..5b16188ea2 --- /dev/null +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/utils/ResponseWithTiming.kt @@ -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( + val response: T, + val timings: Array +) { + 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 invoke( + response: T, + vararg timings: ServerTiming, + ): ResponseWithTiming = + ResponseWithTiming(response, timings) + } +} diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/utils/ServerTiming.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/utils/ServerTiming.kt new file mode 100644 index 0000000000..b5bef7502b --- /dev/null +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/utils/ServerTiming.kt @@ -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 = ';' + } +} diff --git a/save-frontend/build.gradle.kts b/save-frontend/build.gradle.kts index 36b74ac7c5..a5700a068a 100644 --- a/save-frontend/build.gradle.kts +++ b/save-frontend/build.gradle.kts @@ -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 { diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/CustomHooks.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/CustomHooks.kt index 1ea52d2466..20c43b2a3d 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/CustomHooks.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/CustomHooks.kt @@ -8,13 +8,22 @@ package com.saveourtool.save.frontend.utils import com.saveourtool.save.frontend.components.requestStatusContext +import js.core.jso +import org.w3c.dom.EventSource +import org.w3c.dom.EventSource.Companion.CONNECTING +import org.w3c.dom.MessageEvent +import org.w3c.dom.events.Event +import org.w3c.fetch.Headers import org.w3c.fetch.Response +import react.EffectBuilder import react.useContext import react.useEffect import react.useState import kotlinx.browser.document import kotlinx.coroutines.* +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.onCompletion /** * Runs the provided [action] only once of first render @@ -152,3 +161,153 @@ fun useBackground(colorStyle: Style) { configureTopBar(colorStyle) } } + +/** + * Creates a callback that is run synchronously. + * + * Only works inside functional components. + * + * @param effect the callback body (executed under [useEffect]). + * @return a lambda that triggers the callback. + * @see useEffect + */ +fun useDeferredEffect( + effect: EffectBuilder.() -> Unit, +): () -> Unit { + var isRunning by useState(initialValue = false) + + useEffect(isRunning) { + if (!isRunning) { + return@useEffect + } + + effect() + + isRunning = false + } + + return { + if (!isRunning) { + isRunning = true + } + } +} + +/** + * Reads the response of `text/event-stream` `Content-Type`. + * _Server-Sent Events_ (SSE) are limited to `HTTP GET` method. + * + * Only works inside functional components. + * + * @param url the URL that accepts an `HTTP GET` and can respond with a + * `text/event-stream` `Content-Type`. + * @param withCredentials whether + * [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) + * should be allowed, the default is `false`. + * @param eventType the event type selector. + * The same HTTP endpoint may return events of different types, and this + * selector allows to receive only a certain subset of the whole event volume. + * The default is `message`. + * On the server side, use `org.springframework.http.codec.ServerSentEvent` to + * create an event with a custom type. + * @param init invoked before an event stream is requested. + * Allowed to change the state of components. + * @param onCompletion invoked if the response is `HTTP 200 OK`, and when the + * server closes the connection. + * Allowed to change the state of components. + * @param onError invoked when the event source reports an error. + * Allowed to change the state of components. + * @param onEvent invoked when a new event arrives. + * Allowed to change the state of components. + * @return a lambda that triggers the callback. + * @see useNewlineDelimitedJson + */ +@Suppress( + "LongParameterList", + "TOO_MANY_PARAMETERS", + "TYPE_ALIAS", +) +fun useEventStream( + url: String, + withCredentials: Boolean = false, + eventType: String = "message", + init: EffectBuilder.() -> Unit = {}, + onCompletion: EffectBuilder.() -> Unit = {}, + onError: EffectBuilder.(error: Event, readyState: Short) -> Unit = { _, _ -> }, + onEvent: EffectBuilder.(message: MessageEvent) -> Unit, +): () -> Unit = + useDeferredEffect { + init() + + val source = EventSource( + url = url, + eventSourceInitDict = jso { + this.withCredentials = withCredentials + }, + ) + + source.addEventListener(eventType, { event -> + onEvent(event as MessageEvent) + }) + + source.onerror = { error -> + if (source.readyState == CONNECTING) { + source.close() + onCompletion() + } else { + onError(error, source.readyState) + } + } + } + +/** + * Reads the response of `application/x-ndjson` `Content-Type`. + * + * Only works inside functional components. + * + * @param url the URL that accepts an `HTTP GET` and can respond with a + * `application/x-ndjson` `Content-Type`. + * @param init invoked before an event stream is requested. + * Allowed to change the state of components. + * @param onCompletion invoked if the response is `HTTP 200 OK`, and when the + * server closes the connection. + * Allowed to change the state of components. + * @param onError invoked when the event source reports an error. + * Allowed to change the state of components. + * @param onEvent invoked when a new event arrives. + * Allowed to change the state of components. + * @return a lambda that triggers the callback. + * @see useEventStream + */ +internal fun useNewlineDelimitedJson( + url: String, + init: () -> Unit = {}, + onCompletion: () -> Unit = {}, + onError: suspend (response: Response) -> Unit = { _ -> }, + onEvent: (message: String) -> Unit, +): () -> Unit = + useDeferredRequest { + init() + + val response = get( + url = url, + params = jso(), + headers = Headers(jso { + Accept = "application/x-ndjson" + }), + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + + when { + response.ok -> response + .readLines() + .filter(String::isNotEmpty) + .onCompletion { + onCompletion() + } + .collect(onEvent) + + else -> onError(response) + } + } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/RequestUtils.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/RequestUtils.kt index 9efbfd9c5c..357599da31 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/RequestUtils.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/RequestUtils.kt @@ -11,16 +11,27 @@ import com.saveourtool.save.frontend.components.requestStatusContext import com.saveourtool.save.frontend.http.HttpStatusException import com.saveourtool.save.v1 +import js.buffer.ArrayBuffer import js.core.jso +import js.typedarrays.Int8Array +import js.typedarrays.Uint8Array import org.w3c.dom.url.URLSearchParams import org.w3c.fetch.Headers import org.w3c.fetch.RequestCredentials import org.w3c.fetch.RequestInit import org.w3c.fetch.Response +import web.streams.ReadableStream +import web.streams.ReadableStreamDefaultReadValueResult -import kotlin.js.undefined +import kotlin.js.Promise import kotlinx.browser.window import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.* @@ -531,6 +542,122 @@ suspend fun WithRequestStatusContext.loadingHandler(request: suspend () -> Respo @Suppress("MAGIC_NUMBER") fun Response.isConflict(): Boolean = this.status == 409.toShort() +/** + * Reads the HTTP response body as a flow of strings. + * + * @return the string flow produced from the body of this HTTP response. + * @see Response.inputStream + */ +suspend fun Response.readLines(): Flow = + inputStream().decodeToString() + +/** + * Reads the HTTP response body as a byte flow. + * + * @return the byte flow produced from the body of this HTTP response. + * @see Response.readLines + */ +@OptIn(FlowPreview::class) +suspend fun Response.inputStream(): Flow { + val reader = body.unsafeCast>().getReader() + + return flow { + /* + * Read the response body in byte chunks, emitting each chunk as it's + * available. + */ + while (true) { + @Suppress( + "GENERIC_VARIABLE_WRONG_DECLARATION", + "TYPE_ALIAS", + ) + val resultAsync = reader + .read() + .unsafeCast>>() + + val result = resultAsync.await() + + val jsBytes: Uint8Array = result.value + + if (jsBytes == undefined || result.done) { + break + } + + emit(jsBytes.asByteArray()) + } + } + .flatMapConcat { bytes -> + /* + * Concatenate all chunks into a byte flow. + */ + bytes.asSequence().asFlow() + } + .onCompletion { + /* + * Wait for the stream to get closed. + */ + reader.closed.asDeferred().await() + + /* + * Release the reader's lock on the stream. + * See https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader/releaseLock + */ + reader.releaseLock() + } +} + +/** + * Decodes this byte flow into a flow of strings, assuming UTF-8 encoding. + * + * Malformed byte sequences are replaced with `\uFFFD`. + * + * @see ByteArray.decodeToString + */ +fun Flow.decodeToString(): Flow = + flow { + var accumulator: MutableList = arrayListOf() + + collect { value -> + accumulator = when (value) { + /* + * Ignore. + */ + '\r'.code.toByte() -> accumulator + + '\n'.code.toByte() -> { + emit(accumulator) + arrayListOf() + } + + else -> accumulator.apply { + add(value) + } + } + } + + emit(accumulator) + } + .map(Collection::toByteArray) + .map(ByteArray::decodeToString) + +/** + * Converts this [Uint8Array] (most probably obtained by reading an HTTP + * response body) to the standard [ByteArray]. + * + * Conversion from an `Uint8Array` to an `Int8Array` is necessary — + * otherwise, non-ASCII data will get corrupted. + * + * @return the converted instance. + */ +@Suppress("UnsafeCastFromDynamic") +fun Uint8Array.asByteArray(): ByteArray = + Int8Array( + buffer = buffer.unsafeCast(), + byteOffset = byteOffset, + length = length, + ) + .asDynamic() + /** * If this component has context, set [response] in this context. Otherwise, fallback to redirect. * diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/Utils.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/Utils.kt index ada6923cea..1e2e20437a 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/Utils.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/Utils.kt @@ -15,6 +15,7 @@ import org.w3c.xhr.FormData import react.ChildrenBuilder import react.StateSetter import react.dom.events.ChangeEvent +import react.dom.events.MouseEventHandler import react.dom.html.ReactHTML.br import react.dom.html.ReactHTML.samp import react.dom.html.ReactHTML.small @@ -22,6 +23,7 @@ import react.dom.html.ReactHTML.table import react.dom.html.ReactHTML.tbody import react.dom.html.ReactHTML.td import react.dom.html.ReactHTML.tr +import web.dom.Element import web.html.HTMLInputElement import kotlinx.serialization.encodeToString @@ -32,23 +34,6 @@ import kotlinx.serialization.json.Json */ internal const val AVATAR_PLACEHOLDER = "img/undraw_image_not_found.png" -/** - * An error message. - */ -internal typealias ErrorMessage = String - -/** - * A generic error handler. - */ -internal typealias ErrorHandler = (ErrorMessage) -> Unit - -/** - * The body of a [useDeferredRequest] invocation. - * - * @param T the return type of this action. - */ -internal typealias DeferredRequestActionWithMessage = suspend (WithRequestStatusContext, ErrorHandler) -> T - /** * The body of a [useDeferredRequest] invocation. * @@ -85,6 +70,17 @@ fun String.toRole() = Role.values().find { */ fun (() -> Unit).withUnusedArg(): (T) -> Unit = { this() } +/** + * Converts `this` no-argument function to a [MouseEventHandler]. + * + * @return `this` function as a [MouseEventHandler]. + * @see MouseEventHandler + */ +fun (() -> Unit).asMouseEventHandler(): MouseEventHandler = + { + this() + } + /** * @return lambda which does the same but take value from [HTMLInputElement] */ diff --git a/save-frontend/src/test/kotlin/com/saveourtool/save/frontend/utils/ServerSentEventTest.kt b/save-frontend/src/test/kotlin/com/saveourtool/save/frontend/utils/ServerSentEventTest.kt new file mode 100644 index 0000000000..6d4297d074 --- /dev/null +++ b/save-frontend/src/test/kotlin/com/saveourtool/save/frontend/utils/ServerSentEventTest.kt @@ -0,0 +1,108 @@ +package com.saveourtool.save.frontend.utils + +import com.saveourtool.save.frontend.externals.render +import com.saveourtool.save.frontend.externals.rest +import com.saveourtool.save.frontend.externals.setupWorker +import react.VFC +import react.create +import kotlin.js.Promise +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlinx.browser.window +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +/** + * Tests the way _Server-Sent Events_ (SSE) are parsed. + * + * @see useEventStream + * @see useNewlineDelimitedJson + */ +class ServerSentEventTest { + private fun createWorker(): dynamic = + setupWorker( + rest.get("${window.location.origin}/test") { req, res, _ -> + res { response -> + when (req.headers.get(ACCEPT)) { + APPLICATION_NDJSON_VALUE -> { + response.status = OK + response.headers.set(CONTENT_TYPE, APPLICATION_NDJSON_VALUE) + + /* + * Empty lines in the output should be tolerated. + */ + response.body = """ + |{ "value": "$ASCII" } + |{ "value": "$CYRILLIC" } + | + |{ "value": "$CHINESE" } + """.trimMargin() + } + + else -> response.status = BAD_REQUEST + } + + response + } + } + ) + + /** + * Tests that a response of `application/x-ndjson` `Content-Type` is parsed + * correctly. + * + * @see useNewlineDelimitedJson + */ + @Test + @JsName("newlineDelimitedJson") + fun `newline-delimited JSON`(): Promise { + val messages = mutableListOf() + var responseStatus: Short = 0 + + val testComponent: VFC = VFC { + useNewlineDelimitedJson( + url = "${window.location.origin}/test", + onCompletion = { responseStatus = OK }, + onError = { response -> responseStatus = response.status }, + ) { message -> + messages.add(Json.decodeFromString(message)) + }() + } + + return (createWorker().start() as Promise<*>) + .then { + render( + wrapper.create { + testComponent() + } + ) + }.then { + wait(200) + }.then { + assertEquals(OK, responseStatus, "Request completed with an error") + assertContentEquals( + expected = listOf( + TestMessage(ASCII), + TestMessage(CYRILLIC), + TestMessage(CHINESE), + ), + actual = messages, + ) + } + } + + private companion object { + private const val OK: Short = 200 + private const val BAD_REQUEST: Short = 400 + private const val ACCEPT = "Accept" + private const val CONTENT_TYPE = "Content-Type" + private const val APPLICATION_NDJSON_VALUE = "application/x-ndjson" + private const val ASCII = "The quick brown fox jumps over the lazy dog" + + @Suppress("MaxLineLength") + private const val CYRILLIC = + "\u0421\u044a\u0435\u0448\u044c\u0020\u0436\u0435\u0020\u0435\u0449\u0451\u0020\u044d\u0442\u0438\u0445\u0020\u043c\u044f\u0433\u043a\u0438\u0445\u0020\u0444\u0440\u0430\u043d\u0446\u0443\u0437\u0441\u043a\u0438\u0445\u0020\u0431\u0443\u043b\u043e\u043a\u0020\u0434\u0430\u0020\u0432\u044b\u043f\u0435\u0439\u0020\u0447\u0430\u044e" + private const val CHINESE = "\u542c\u8bf4\u5988\u5988\u5b66\u4e60\u753b\u753b\u540e\uff0c\u4f1a\u7231\u4e71\u4e70\u5356\u8d35\u827a\u672f\u3002" + } +} diff --git a/save-frontend/src/test/kotlin/com/saveourtool/save/frontend/utils/TestMessage.kt b/save-frontend/src/test/kotlin/com/saveourtool/save/frontend/utils/TestMessage.kt new file mode 100644 index 0000000000..3baad02bc2 --- /dev/null +++ b/save-frontend/src/test/kotlin/com/saveourtool/save/frontend/utils/TestMessage.kt @@ -0,0 +1,11 @@ +package com.saveourtool.save.frontend.utils + +import kotlinx.serialization.Serializable + +/** + * A JSON-serializable entity used by [ServerSentEventTest]. + * + * @see ServerSentEventTest + */ +@Serializable +data class TestMessage(val value: String)