Generic WebSocket-based sync engine + Ktor server routing for Kotlin Multiplatform
CRDTs. Pairs with com.doppio.syncdo:crdt - bring your own
Delta<D> / DeltaState<S, D> and the engine handles connection lifecycle,
incremental catch-up, broadcast fan-out, and offline buffering.
Targets:
commonMain- protocol + client engine (Android, iOS arm64/simulatorArm64, JVM)jvmMain- generic KtorSyncServer+Route.syncEndpoint(...)helpers
// build.gradle.kts (KMP module, commonMain)
dependencies {
implementation("com.doppio.syncdo:crdt:0.1.0-SNAPSHOT")
implementation("com.doppio.syncdo:sync:0.1.0-SNAPSHOT")
}| Type | Where | Purpose |
|---|---|---|
SyncMessage<D> |
commonMain | Sealed protocol: PushDelta, PullRequest, PullResponse. Serialize with SyncMessage.serializer(deltaSerializer). |
SyncEngine<D> |
commonMain | Client: WebSocket connection, exponential backoff reconnect, pending-delta buffer, local/remote merge callbacks. |
SyncStatus |
commonMain | Synced / Syncing / PendingChanges / Offline / Error - observable on the engine as a StateFlow. |
SyncServer<S, D> |
jvmMain | Server: authoritative state, bounded delta log for incremental catch-up, mutex-guarded merges. |
Route.syncEndpoint(...) |
jvmMain | Ktor extension: installs the /sync WebSocket handler that routes SyncMessage traffic and broadcasts to connected peers. |
import com.doppio.syncdo.sync.SyncEngine
val engine = SyncEngine(
deltaSerializer = CounterDelta.serializer(),
serverHost = "sync.example.com",
serverPort = 8080,
nodeId = myNodeId,
scope = applicationScope,
onRemoteDelta = { delta -> repository.applyRemote(delta) },
getPendingDelta = { repository.drainPendingDelta() },
restorePendingDelta = { delta -> repository.bufferPendingDelta(delta) },
getLocalClock = { repository.currentClock() },
)
engine.start()
// Observe connection state for UI
engine.syncStatus.collect { status -> /* update indicator */ }
// After each local mutation, tell the engine to flush
repository.onMutation { engine.pushPendingDelta() }import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import com.doppio.syncdo.sync.server.SyncServer
import com.doppio.syncdo.sync.server.syncEndpoint
embeddedServer(Netty, port = 8080) {
install(WebSockets)
val syncServer = SyncServer(
initialState = { Counter() },
fullStateAsDelta = { state ->
CounterDelta(clock = state.clock, nodeId = "server", amount = state.value)
},
)
routing {
syncEndpoint(syncServer, CounterDelta.serializer(), path = "/sync")
}
}.start(wait = true) Client A Server Client B
│ │ │
│── PullRequest(clk)────▶│ │
│◀── PullResponse(Δ) ────│ (missed deltas or full state)
│ │ │
│── PushDelta(Δ_A) ─────▶│ │
│ │── PullResponse(Δ_A) ──▶│
│◀── PullResponse(Δ') ───│ (anything A also missed)│
│ │ │
│ │◀──── PushDelta(Δ_B) ───│
│◀── PullResponse(Δ_B) ──│ │
On connect, the client sends PullRequest(clock, nodeId). The server replies with a
PullResponse(delta) containing every delta the client has missed (or a full-state
delta if the client is beyond the bounded log window).
After each local mutation the client sends PushDelta(delta, nodeId). The server
merges it into authoritative state, broadcasts it as PullResponse to every other
connected peer, and replies to the pusher with anything they themselves might have
missed since their clock.
PullResponse is always client-bound; PullRequest and PushDelta are always
server-bound. The engine ignores misdirected variants.
- No hardcoded domain types. Everything is parameterized on
Delta<D>- bring your own CRDT aggregate. - Generic serialization.
SyncMessage<D>uses kotlinx.serialization's type-parameter serializer: callers passMyDelta.serializer()intoSyncMessage.serializer(...)and the engine does the rest. - Server helpers are JVM-only. Ktor server deps live under
jvmMainso Android/iOS consumers never resolve them. A JVM client-only consumer can simply ignore thecom.doppio.syncdo.sync.serverpackage. - Single WebSocket per client. Both push and pull use the same connection; no HTTP long-polling fallback.