kroom
0.22indexedReal-time collaborative engine leveraging Server-Sent Events to manage room-based sessions, broadcast events, handle keep-alives, and coordinate structured asynchronous processing for interactive applications.
Real-time collaborative engine leveraging Server-Sent Events to manage room-based sessions, broadcast events, handle keep-alives, and coordinate structured asynchronous processing for interactive applications.
Kotlin multiplatform library for real-time collaborative applications using Server-Sent Events (SSE).
kroom-common multiplatform core types
kroom-view client-side SSE handling (JS/Wasm)
kroom-server server-side Room/Lobby/Actor abstractions
kroom-webapp-core Ktor webapp foundation (routing, API helpers)
kroom-webapp-assets shared client-side JS/CSS
kroom-webapp-velocity Velocity template integration
kroom-webapp-l10n i18n with gettext
kroom-webapp-session encrypted session identity (shared by oauth/auth)
kroom-webapp-oauth OIDC authentication
kroom-webapp-auth email+password identity with OIDC linking
kroom-webapp-push Web Push notifications
// build.gradle.kts
dependencies {
implementation("com.republicate.kroom:kroom-server:0.21")
implementation("com.republicate.kroom:kroom-webapp-assets:0.21")
}
// Define a room
class ChatRoom(id: String) : Room<ChatState>(id) {
state = ChatState()
= state.toJson()
: ActionResult {
}
}
routing {
kroomAssets()
sseRoute() { roomId, actor -> Lobby.join(roomId, actor) }
post() { }
}
SSE connections can die silently — browser killed, network dropped without TCP FIN. Without heartbeat, the server blocks forever on channel.receive(), never discovering the failure.
Ktor's SSE heartbeat solves this: it periodically writes a comment line to the socket. When the write fails on a dead connection, the exception propagates and terminates the SSE handler, triggering cleanup.
This must be configured on each SSE route, not on the Room:
sse("/events/{room}") {
heartbeat { period = 15.seconds }
val actor = Actor(connectionId = "...", name = login)
val channel = room.join(actor)
try {
for (event in channel) {
send(event)
}
} finally {
room.leave(actor)
}
}
The heartbeat block is Ktor's built-in per-connection heartbeat (since Ktor 3.1). It sends SSE comment lines at the configured interval regardless of room activity. A shorter period detects dead connections faster but generates more traffic.
| Period | Detection | Notes |
|---|---|---|
| 2-5s | Fast |
Without heartbeat, dead connections are never detected unless the room happens to send an event that fails. Always configure it.
Ktor webapp foundation with installCore() plugin:
installCore {
logLevel = Level.INFO // CallLogging level
static {
// Default prefixes: css, js, img, fonts, lib, snd
prefixes = listOf("css", "js", "img", "fonts", "lib", "snd")
// Dev mode: serve from filesystem first, fallback to classpath
devMode = true
devDir = File("src/main/resources/static")
}
}
Default prefixes (configurable via prefixes):
Assets served from classpath (production) or filesystem with classpath fallback (dev mode).
Shared client-side libraries for Ktor webapps:
| File | Description |
|---|---|
domhelper.js | Lightweight jQuery-like DOM manipulation |
api.js |
routing {
kroomAssets() // serves at /js/kroom/, /css/kroom/
kroomAssets("/static") // serves at /static/js/kroom/, etc.
}
<script src="/js/kroom/domhelper.js"></script>
<script src="/js/kroom/api.js"></script>
<script src="/js/kroom/store.js"></script>
Or with cache-busting:
// In template context
KroomAssets.coreScripts() // returns script tags with version param
store = (reducer, initialState);
store = (reducer, initialState, (logMiddleware));
store.()
store.({ : })
store.( (store.()))
reducer = ({ : todosReducer, : uiReducer });
logMiddleware
thunkMiddleware
class GameRoom(id: String) : Table<GameState>(id, seatCount = 2) {
override var state = GameState()
override fun handleAction(actor: Actor, action: Json.): ActionResult {
(action.getString()) {
-> {
seat = assignSeat(actor.user!!, actor.name, requestedSeat = )
}
}
}
}
The Table class:
User identity (not connection ID) for multi-tab supportmySeat in state payload so clients know their positionSeat.statusSSE supports automatic reconnection with Last-Event-ID header. kroom can replay missed events selectively:
class MyChatRoom(id: String) : Room<MyState>(id) {
init {
historicizableEvents.add("chat") // Only "chat" events are replayed
}
override fun needsHistory() = true // Enable event buffering
}
historicizableEvents are buffered and replayedhistoryBufferSize (default: 50)Ideal for games with chat: game state is authoritative, chat history is replayed on reconnect.
./gradlew :kroom-server:run # SSE playground at :8080/playground
./gradlew :kroom-examples:chifoumi:run # Rock-paper-scissors at :8081
Apache 2.0
| Good for interactive apps (games, chat) |
| 15s | Moderate | Default in kroom examples |
| 30s | Ktor default | Sufficient for low-frequency updates |
| Route | Source |
|---|
/css/* | static/css/ |
/js/* | static/js/ |
/img/* | static/img/ |
/fonts/* | static/fonts/ |
/lib/* | static/lib/ |
/snd/* | static/snd/ |
| Fetch wrapper for REST APIs |
store.js | Minimal Redux-like state management |
// Selectors
$('#id') // single element or NodeList
$$('.class') // always NodeList
// Chaining
$('#btn').addClass('active').on('click', fn)
// Events
$('.items').on('click', e => { ... })
// Classes
el.addClass('foo bar').removeClass('baz').toggleClass('active')
// Attributes & properties
el.attr('href') // get
el.attr('href', url) // set
el.prop('checked', true)
el.data('id') // data-id attribute
// Content
el.text('hello')
el.html('<b>hi</b>')
el.val() // form value
el.empty()
el.load('/api/frag') // fetch and inject HTML
// Visibility
el.show().hide()
// Forms
form.field('email') // get value
form.field('email', 'a@b.com') // set value
// Misc
el.find('.child')
el.index()
el.busy(true) // toggle .busy class
dialog.showModal()
// Low-level (returns Response)
api.get('users')
api.post('users', { name: 'Jo' })
api.put('users/1', { name: 'Jo' })
api.delete('users/1')
// Helpers (returns parsed data, throws on error)
api.getJson('users') // GET -> JSON
api.getHtml('fragment') // GET -> HTML string
api.postJson('users', data) // POST -> JSON
api.putJson('users/1', data) // PUT -> JSON
api.deleteJson('users/1') // DELETE -> JSON
Surfaced from shared tags and platforms — no rankings paid for.