Basic SSE infrastructure

This commit is contained in:
2026-08-04 22:49:04 +02:00
parent cd8bbe5687
commit 0e57de60b0
7 changed files with 206 additions and 16 deletions
@@ -5,10 +5,14 @@ import io.ktor.client.*
import io.ktor.client.plugins.auth.* import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.* import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.sse.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import io.ktor.utils.io.* import io.ktor.utils.io.*
import kotlinx.atomicfu.locks.ReentrantLock import kotlinx.atomicfu.locks.ReentrantLock
import kotlinx.atomicfu.locks.withLock import kotlinx.atomicfu.locks.withLock
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.seconds
class Client private constructor(private val _auth: AuthProvider) { class Client private constructor(private val _auth: AuthProvider) {
private val _authClient = platformClient { private val _authClient = platformClient {
@@ -17,6 +21,13 @@ class Client private constructor(private val _auth: AuthProvider) {
private val _client = platformClient { private val _client = platformClient {
var tryingRefresh = false var tryingRefresh = false
install(ContentNegotiation) { json() } install(ContentNegotiation) { json() }
install(SSE) {
maxReconnectionAttempts = 10
reconnectionTime = 1.seconds
bufferPolicy = SSEBufferPolicy.LastEvents(5)
}
install(Auth) { install(Auth) {
bearer { bearer {
cacheTokens = false cacheTokens = false
@@ -28,17 +39,16 @@ class Client private constructor(private val _auth: AuthProvider) {
} }
refreshTokens { refreshTokens {
if(tryingRefresh) { if (tryingRefresh) {
_auth.onLogout() _auth.onLogout()
null null
} } else {
else {
tryingRefresh = true tryingRefresh = true
val ref = _auth.refresh.value ?: return@refreshTokens null val ref = _auth.refresh.value ?: return@refreshTokens null
println("Trying to re-authenticate using $ref") println("Trying to re-authenticate using $ref")
val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({ val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({
if(it.msg.startsWith(COROUTINE_CANCELLED)) println("Coro calling refresh was cancelled") if (it.msg.startsWith(COROUTINE_CANCELLED)) println("Coro calling refresh was cancelled")
_auth.onLogout() _auth.onLogout()
null null
}) { }) {
@@ -57,27 +67,44 @@ class Client private constructor(private val _auth: AuthProvider) {
private var _counter = 0 private var _counter = 0
private suspend fun <TReq: Any, TRes: Any> callRoute(using: HttpClient, route: ApiRoute<TReq, TRes>, body: TReq, wasInternal: Boolean = false): Either<ErrorResponse, TRes> { private suspend fun <TReq : Any, TRes : Any> callRoute(
using: HttpClient,
route: ApiRoute<TReq, TRes>,
body: TReq,
wasInternal: Boolean = false
): Either<ErrorResponse, TRes> {
val ctr = _counter++ val ctr = _counter++
return try { return try {
println("Calling route ${route.pattern} with client $using, request ID=$ctr (internal request: $wasInternal)") println("Calling route ${route.pattern} with client $using, request ID=$ctr (internal request: $wasInternal)")
val client = IClient.Default(using, _auth.server.value ?: throw IllegalStateException("No server URL set.")) val client = IClient.Default(using, _auth.server.value ?: throw IllegalStateException("No server URL set."))
val res = route.call(client, body, ctr) val res = route.call(client, body, ctr)
res res
} } catch (e: CancellationException) {
catch(e: CancellationException) {
println("Call to ${route.pattern} [$ctr] was cancelled") println("Call to ${route.pattern} [$ctr] was cancelled")
ErrorResponse(COROUTINE_CANCELLED).error() ErrorResponse(COROUTINE_CANCELLED).error()
} } catch (e: Exception) {
catch(e: Exception) {
println("Call to ${route.pattern} [$ctr] ran into an exception") println("Call to ${route.pattern} [$ctr] ran into an exception")
ErrorResponse(e.message ?: "Unknown error.").error() ErrorResponse(e.message ?: "Unknown error.").error()
} }
} }
suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> = suspend fun <TReq : Any, TRes : Any> callRoute(
route: ApiRoute<TReq, TRes>,
body: TReq
): Either<ErrorResponse, TRes> =
callRoute(_client, route, body) callRoute(_client, route, body)
suspend fun <T> connectSSE(route: String, serializer: KSerializer<T>, onEvent: suspend (T) -> Unit) {
println("Client tries to set up SSE to $route")
val server = _auth.server.value ?: throw IllegalStateException("No server URL set.")
_client.sse(urlString = "$server$route", showCommentEvents = true, showRetryEvents = true) {
incoming.collect {
println("RECEIVE: $it")
if(it.data != null) onEvent(Json.decodeFromString(serializer, it.data!!))
}
}
}
companion object { companion object {
private val _sync = ReentrantLock() private val _sync = ReentrantLock()
private var _instance: Client? = null private var _instance: Client? = null
@@ -85,11 +112,10 @@ class Client private constructor(private val _auth: AuthProvider) {
fun construct(auth: AuthProvider): Client { fun construct(auth: AuthProvider): Client {
_sync.withLock { _sync.withLock {
if(_instance == null) { if (_instance == null) {
_instance = Client(auth) _instance = Client(auth)
return _instance!! return _instance!!
} } else throw IllegalStateException("Client already constructed")
else throw IllegalStateException("Client already constructed")
} }
} }
@@ -14,6 +14,8 @@ import com.jaytux.phoebench.clients.withScope
import com.jaytux.phoebench.common.HomeResponse import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.InviteListResponse import com.jaytux.phoebench.common.InviteListResponse
import com.jaytux.phoebench.common.UserListResponse import com.jaytux.phoebench.common.UserListResponse
import kotlinx.coroutines.Job
import kotlinx.serialization.serializer
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
class HomeVM( class HomeVM(
@@ -42,6 +44,8 @@ class HomeVM(
val invites = _invites.immutable() val invites = _invites.immutable()
val users = _users.immutable() val users = _users.immutable()
private var _listenJob: Job? = null
init { init {
refresh() refresh()
} }
@@ -52,6 +56,7 @@ class HomeVM(
} }
fun reset() { fun reset() {
_listenJob?.cancel()
resetAdmin() resetAdmin()
_username.value = null _username.value = null
_isAdmin.value = false _isAdmin.value = false
@@ -67,6 +72,17 @@ class HomeVM(
} }
fun refresh() { fun refresh() {
_listenJob = withScope {
_client.connectSSE("/rt/project", serializer<HomeResponse.ProjectSummary>()) {
if(it.owner.name == _username.value) {
if(_ownProjects.value.none { p -> p.id == it.id }) _ownProjects.value += it
}
if(it.isPublic) {
if(_publicProjects.value.none { p -> p.id == it.id }) _publicProjects.value += it
}
}
}
withScope { withScope {
resetAdmin() resetAdmin()
_repo.getHome().snackOr { _repo.getHome().snackOr {
+1
View File
@@ -62,6 +62,7 @@ ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" }
ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref = "ktor" } ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref = "ktor" }
ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" } ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" }
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" }
json = { module = "org.json:json", version.ref = "json" } json = { module = "org.json:json", version.ref = "json" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
+1
View File
@@ -35,6 +35,7 @@ dependencies {
implementation(libs.ktor.server.call.logging) implementation(libs.ktor.server.call.logging)
implementation(libs.ktor.server.cors) implementation(libs.ktor.server.cors)
implementation(libs.ktor.server.status.pages) implementation(libs.ktor.server.status.pages)
implementation(libs.ktor.server.sse)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
@@ -1,11 +1,14 @@
package com.jaytux.phoebench.server package com.jaytux.phoebench.server
import com.jaytux.phoebench.common.ErrorResponse import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.fold
import com.jaytux.phoebench.server.db.DB import com.jaytux.phoebench.server.db.DB
import com.jaytux.phoebench.server.db.User import com.jaytux.phoebench.server.db.User
import com.jaytux.phoebench.server.handlers.AuthHandler import com.jaytux.phoebench.server.handlers.AuthHandler
import com.jaytux.phoebench.server.handlers.ProjectHandler import com.jaytux.phoebench.server.handlers.ProjectHandler
import com.jaytux.phoebench.server.handlers.RouteError
import com.jaytux.phoebench.server.handlers.deleteAdmin import com.jaytux.phoebench.server.handlers.deleteAdmin
import com.jaytux.phoebench.server.handlers.deleteAuth import com.jaytux.phoebench.server.handlers.deleteAuth
import com.jaytux.phoebench.server.handlers.get import com.jaytux.phoebench.server.handlers.get
@@ -30,10 +33,18 @@ import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.sse.SSE
import io.ktor.server.sse.sse
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import java.net.URI import java.net.URI
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
import com.jaytux.phoebench.server.handlers.RouteError.Companion.respondJson
import io.ktor.server.sse.heartbeat
import io.ktor.sse.ServerSentEvent
import io.ktor.utils.io.CancellationException
fun main(args: Array<String>) { fun main(args: Array<String>) {
DB.db DB.db
@@ -54,6 +65,10 @@ fun Application.module() {
} }
} }
install(SSE) {
//
}
val allowLocalhost = environment.config.propertyOrNull("ktor.cors.enableLocalhostOn")?.getString() ?: "0" val allowLocalhost = environment.config.propertyOrNull("ktor.cors.enableLocalhostOn")?.getString() ?: "0"
val safeOrigin = environment.config.propertyOrNull("ktor.cors.browserOrigin")?.getString() val safeOrigin = environment.config.propertyOrNull("ktor.cors.browserOrigin")?.getString()
install(CORS) { install(CORS) {
@@ -106,7 +121,6 @@ fun Application.module() {
} }
challenge { defaultScheme, realm -> challenge { defaultScheme, realm ->
// call.respond(HttpStatusCode.Unauthorized, ErrorResponse("Invalid/expired token"))
call.respondText(Json.encodeToString(ErrorResponse("Invalid/expired token")), ContentType.Application.Json, HttpStatusCode.Unauthorized) call.respondText(Json.encodeToString(ErrorResponse("Invalid/expired token")), ContentType.Application.Json, HttpStatusCode.Unauthorized)
} }
} }
@@ -144,6 +158,57 @@ fun Application.module() {
postAuth(Routes.Entry.new, ProjectHandler::createEntry) postAuth(Routes.Entry.new, ProjectHandler::createEntry)
patchAuth(Routes.Entry.update, ProjectHandler::updateEntry) patchAuth(Routes.Entry.update, ProjectHandler::updateEntry)
deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry) deleteAuth(Routes.Entry.delete, ProjectHandler::deleteEntry)
val projectBus = SSEBus.register<HomeResponse.ProjectSummary>("/rt/project")
sse("/rt/project") {
try {
println("Attempt to set up SSE")
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.getClaim(com.jaytux.phoebench.common.Auth.JWT_CLAIM)?.asString()
?: throw RouteError("Missing user claim", HttpStatusCode.Unauthorized)
println(" -- SSE: userId = $userId")
val user = transaction {
User.findById(Uuid.parse(userId)) ?: throw RouteError(
"User not found",
HttpStatusCode.Unauthorized
)
}
println(" -- SSE: user = ${user.username}")
heartbeat {}
val flow = projectBus.register(user.id.value)
try {
flow.collect {
it.fold({
throw Exception() // force end of collecting
}) { event ->
send(ServerSentEvent(data = Json.encodeToString(projectBus.serializer, event)))
}
}
}
catch(e: CancellationException) {
projectBus.disconnect(user.id.value)
throw e
}
}
catch(e: RouteError) {
call.respondText(
status = e.status,
text = Json.encodeToString(ErrorResponse(e.message ?: "Unknown error")),
contentType = ContentType.Application.Json
)
}
println("--- SSE Session ended ---")
}
sse("/rt/users") {}
sse("/rt/invites") {}
sse("/rt/project/{project-id}") {}
} }
get("{...}") { get("{...}") {
@@ -0,0 +1,75 @@
package com.jaytux.phoebench.server
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.value
import io.ktor.util.reflect.instanceOf
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.serialization.KSerializer
import kotlinx.serialization.serializer
import java.util.concurrent.ConcurrentHashMap
import kotlin.reflect.KType
import kotlin.reflect.typeOf
import kotlin.uuid.Uuid
class SSEBus<T> private constructor (private val _containedType: KType, val serializer: KSerializer<T>) {
object Cancellation
private val _flows = ConcurrentHashMap<Uuid, Pair<Int, MutableSharedFlow<Either<Cancellation, T>>>>()
fun register(user: Uuid): SharedFlow<Either<Cancellation, T>> =
_flows.computeIfAbsent(user) { 1 to MutableSharedFlow(extraBufferCapacity = 64) }.second
suspend fun send(user: Uuid, data: T) {
_flows[user]?.second?.emit(data.value())
}
suspend fun sendAll(data: T) {
_flows.forEach { it.value.second.emit(data.value()) }
}
fun disconnect(user: Uuid) {
_flows.compute(user) { _, data ->
if(data == null) null
else {
val (refCount, flow) = data
val newRef = refCount - 1
if(newRef == 0) null
else newRef to flow
}
}
}
suspend fun forceDisconnect(user: Uuid) {
_flows[user]?.second?.emit(Cancellation.error())
_flows.remove(user)
}
companion object {
private val _busCache = mutableMapOf<String, SSEBus<*>>()
fun <T> register(topic: String, contained: KType, serializer: KSerializer<T>): SSEBus<T> {
val bus = SSEBus<T>(contained, serializer)
_busCache.compute(topic) { k, existing ->
if(existing != null) throw IllegalArgumentException("Bus for $topic exists already")
bus
}
return bus
}
inline fun <reified T> register(topic: String) =
register<T>(topic, typeOf<T>(), serializer<T>())
fun <T> getBus(topic: String, contained: KType): SSEBus<T>? {
val bus = _busCache[topic] ?: return null
if(contained != bus._containedType) throw IllegalArgumentException("Type mismatch for bus for $topic")
@Suppress("UNCHECKED_CAST")
return bus as SSEBus<T>
}
inline fun <reified T> getBus(topic: String) =
getBus<T>(topic, typeOf<T>())
}
}
@@ -13,6 +13,7 @@ import com.jaytux.phoebench.common.PartialLabelRequest
import com.jaytux.phoebench.common.PartialProjectRequest import com.jaytux.phoebench.common.PartialProjectRequest
import com.jaytux.phoebench.common.ProjectRequest import com.jaytux.phoebench.common.ProjectRequest
import com.jaytux.phoebench.common.ProjectResponse import com.jaytux.phoebench.common.ProjectResponse
import com.jaytux.phoebench.server.SSEBus
import com.jaytux.phoebench.server.db.Entries import com.jaytux.phoebench.server.db.Entries
import com.jaytux.phoebench.server.db.Entry import com.jaytux.phoebench.server.db.Entry
import com.jaytux.phoebench.server.db.Label import com.jaytux.phoebench.server.db.Label
@@ -64,7 +65,7 @@ object ProjectHandler {
success(HomeResponse(user.username, user.isAdmin, user.projectLimit, own, publics)) success(HomeResponse(user.username, user.isAdmin, user.projectLimit, own, publics))
} }
fun createProject(user: User, req: ProjectRequest) = transaction { suspend fun createProject(user: User, req: ProjectRequest) = transaction {
if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit)) if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit))
throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict) throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict)
@@ -75,6 +76,11 @@ object ProjectHandler {
} }
success(proj.toResponse(user)) success(proj.toResponse(user))
}.also { (_, proj) ->
val bus = SSEBus.getBus<HomeResponse.ProjectSummary>("/rt/project") ?: return@also
val summary = HomeResponse.ProjectSummary(proj.id, proj.name, proj.isPublic, NamedID(user.username, user.id.value))
if(proj.isPublic) bus.sendAll(summary)
else bus.send(user.id.value, summary)
} }
fun getProject(user: User, req: Uuid) = transaction { fun getProject(user: User, req: Uuid) = transaction {