Realtime in Keel is opt-in and advisory. The seed is still the only read
model. A typed event says something happened; an invalidate says
rehydrate with a normal visit. Events missed while offline are not
replayed — the next visit always recovers the truth. That is what keeps
reconnect semantics small and the UI honest.
One endpoint, two transports. GET /__keel/events streams SSE when the host
installed the Ktor SSE plugin; the same path upgrades to a WebSocket when
WebSockets is installed. SSE clients POST envelopes to the same path for
client-to-host sends (sid addresses the stream). Both transports speak the
same JSON envelope — see
Wire protocol.
Enable it on the host
Install the plugins before keel { }, then opt in. Endpoints exist only when
the realtime block is present and at least one transport plugin is
installed.
install(WebSockets)
install(SSE)
keel {
realtime {
event<FeedItem>("harbor.message", EventDirection.OUT) // host → pack
on<TypingNotice>("harbor.typing") { input -> // pack → host
call.keelRealtime.publish("page:harbor.home", "harbor.typing", input)
}
events(Board) // @KeelEvent functions
authorize = { call, channel -> // session cookies
channel.startsWith("page:") || call.request.cookies["harbor_uid"] != null
}
heartbeatSeconds = 25
}
}
event<T>(id, direction)declares a typed payload without a handler.on<I>(id) { input -> … }registers a handler for a client-sent event and registers the binding asINwhen it is not declared yet.events(vararg hosts)scans@KeelEventfunctions the wayactions(hosts)scans@KeelAction; anApplicationCall.(Payload) -> Unitextension gets the handshake/POST call asthis.authorizeruns per channel at subscribe time. Channel ids are host-owned strings;page:…anduser:…are conventions, not built-ins.heartbeatSecondssendspingenvelopes (0 disables). Keepalives are how dead SSE connections are noticed and reaped.
Publish from anywhere a call is reachable — actions, page loaders, jobs —
with call.keelRealtime (or application.keelRealtime):
@KeelAction("harbor.postMessage")
fun ApplicationCall.postMessage(input: PostMessageIn): PostMessageOut {
val item = Board.post(input.body)
keelRealtime.publish("page:harbor.home", "harbor.message", item) // flavor (a)
return PostMessageOut(item)
}
@KeelAction("harbor.setName")
fun ApplicationCall.setName(input: SetNameIn): SetNameOut {
val user = Board.rename(input.displayName)
keelRealtime.invalidate("page:harbor.home", path = "/") // flavor (b)
return SetNameOut(user)
}
publish encodes with the registered serializer; unknown or IN-only ids
throw. invalidate(channel, path, only, except) sends the visit-shaped hint.
send(sid, …) targets one connection.
Subscribe in a pack
@KeelEvent on a @Serializable class is the contract: Typegen emits the
payload into <Pack>Events (HarborEvents["harbor.message"]). The client
side is the core realtime singleton plus one lifecycle helper per adapter.
useKeelEvent subscribes for the component’s lifetime and keeps the latest
handler, so inline closures never resubscribe.
import { realtime, useKeelEvent } from "@kolektiv/keel-svelte"
import type { HarborEvents } from "../lib/page-types"
let liveFeed = $state<FeedItem[]>([])
useKeelEvent<HarborEvents["harbor.message"]>("harbor.message", (item) => {
liveFeed = [item, ...liveFeed]
})
function announceTyping(displayName: string) {
void realtime.send("harbor.typing", { displayName })
}import { realtime, useKeelEvent } from "@kolektiv/keel-svelte"
let liveFeed = $state([])
useKeelEvent("harbor.message", (item) => {
liveFeed = [item, ...liveFeed]
})
function announceTyping(displayName) {
void realtime.send("harbor.typing", { displayName })
}In React and Preact, useKeelEvent is a hook; the latest handler wins.
import { useKeelEvent } from "@kolektiv/keel-react"
import type { HarborEvents } from "../lib/page-types"
useKeelEvent<HarborEvents["harbor.message"]>("harbor.message", (item) => {
setItems((current) => [item, ...current])
})In Vue, useKeelEvent subscribes in the component scope and releases on
scope dispose.
<script setup lang="ts">
import { realtime, useKeelEvent } from "@kolektiv/keel-vue"
import type { HarborEvents } from "../lib/page-types"
const items = ref<FeedItem[]>([])
useKeelEvent<HarborEvents["harbor.message"]>("harbor.message", (item) => {
items.value = [item, ...items.value]
})
function announceTyping(displayName: string) {
void realtime.send("harbor.typing", { displayName })
}
</script>Solid’s useKeelEvent registers cleanup with the owner; read signals inside
the handler.
import { useKeelEvent } from "@kolektiv/keel-solid"
import type { HarborEvents } from "../lib/page-types"
const [items, setItems] = createSignal<FeedItem[]>([])
useKeelEvent<HarborEvents["harbor.message"]>("harbor.message", (item) => {
setItems([item, ...items()])
})Lit gets a ReactiveController; useKeelEvent is the convenience wrapper.
import { useKeelEvent, type RealtimeMessage } from "@kolektiv/keel-lit"
import type { HarborEvents } from "../lib/page-types"
export class HarborFeed extends LitElement {
constructor() {
super()
useKeelEvent<HarborEvents["harbor.message"]>(this, "harbor.message", (item) => {
this.items = [item, ...this.items]
})
}
}Angular injects the subscription and releases it with the injector.
import { injectKeelEvent } from "@kolektiv/keel-angular"
import type { HarborEvents } from "../lib/page-types"
injectKeelEvent<HarborEvents["harbor.message"]>("harbor.message", (item) => {
this.items.update((current) => [item, ...current])
})Channels
Channels are how the host targets traffic; the client only asks to listen.
- The client automatically holds
page:<page id>and follows the seed as it changes. Disable withrealtime.configure({ autoChannels: false })or add your own withchannels. realtime.subscribe(channel)returns a release function; ref-counted, so several components can hold the same channel.realtime.configure({ channels: (seed) => [...] })adds seed-derived channels.- Every channel is authorized at subscribe time with the same session cookies as visits. A denied channel means no events, not an error page.
Invalidate
When an invalidate envelope arrives and no handler is registered, the
client reloads the current page with
router.reload({ preserveState: true }) — path, only, and except from
the envelope are honored. Registering realtime.onInvalidate(handler)
takes over, which is how a pack can merge partial state without a visit.
Reconnect
realtime.connect() is idempotent. With transport: "auto" the client tries
WebSocket, falls back to SSE, and reconnects with exponential backoff plus
jitter (minReconnectDelayMs 500, maxReconnectDelayMs 15000). On every
re-open it resubscribes the channel set.
Because there is no replay, a reconnection that followed a live connection
also rehydrates: router.reload({ preserveState: true }). Set
resyncOnReconnect: false to opt out. A first connect never reloads — the
document seed is already fresh.
Client surface
| Member | Effect |
|---|---|
realtime.configure(options) |
transport, url, channels, backoff, resync, reloadOnInvalidate |
realtime.connect() / disconnect() |
open or close the transport |
realtime.subscribe(channel) |
hold a channel; returns a release function |
realtime.on(event, handler) |
typed events; returns a release function |
realtime.onInvalidate(handler) |
take over invalidation handling |
realtime.send(event, data) |
client-to-host event (WebSocket frame or SSE POST) |
realtime.status() / onStatus(fn) |
idle, connecting, open, reconnecting, closed |
realtime.sid() / lastSeen() |
stream id, last envelope timestamp |
realtime is re-exported from every adapter package; Harbor opens two
browser windows to show posts and typing notices streaming in.