Sync / foldkit-sync
Serving from a Durable Object
On Cloudflare, one object per document is the single writer the journal
wants. defineDocumentHost from foldkit-sync/do defines that class: each
accepted socket exchanges against the same journal through serveJournal,
and the replica side is the socket transport it already speaks:
ts
import { DurableObject } from 'cloudflare:workers'
import { defineDocumentHost } from 'foldkit-sync/do'
export class SyncHost extends defineDocumentHost(DurableObject, {
sync: TodoSync,
// A D1 journal as the durable D1 guide opens one.
openJournal: (_ctx, env) => openTodosJournal(env.DB),
resolvePrincipal: request => Effect.succeed(request.headers.get('x-actor') ?? 'anon'),
}) {}ts
// The worker: one object per document.
export default {
fetch: (request: Request, env: Env): Promise<Response> =>
env.SYNC_HOST.get(env.SYNC_HOST.idFromName('todos')).fetch(request),
}ts
// A replica: the reconnecting client it already speaks.
Sync.transport.socket({ url: 'wss://example.com/sync' })- The base class is a parameter, so the module imports no worker runtime.
openJournalruns once, on the first upgrade: over D1 (d1: true, see the durable D1 guide), or the object's SQLite storage throughctx. - Each upgrade resolves its own principal, an Effect that refuses the caller
by failing with a
RemoteUnauthenticated(RemoteServer.unauthenticated()makes one): a 401 carrying its message. Non-upgrades are 404s that open nothing. A resolver that breaks, alayerthat fails to build, or a journal that never opened, is a 500 that says nothing of it. - A resolver that reads a service (a session store) takes it from
layer: env => Layer, required exactly when the resolver needs one. It is built per upgrade and kept until the socket closes, sorevalidatereads an open store; a refused upgrade closes it at once. revalidate: { every, key? }asks the resolver again, of the upgrade request, while the socket is open: a refusal closes it with 4401, and a principal whosekey(Policy.keyOf) changed with 1012, which the replica reconnects after. Without it a socket serves its first principal until it closes.settle: (env, journal) => Effectruns after each exchange's appends, before the reply is read, to apply what committed elsewhere — the Remote tables a reader then queries.journal.recovermakes it crash-safe and idempotent;envand the journal are handed over because the store they reach lives outside the exchange.- Off Cloudflare,
documentHostfromfoldkit-sync/journalis the same host without the class:documentHost({ sync, journal, resolvePrincipal, layer?, revalidate?, opens?, admit?, settle? })answers each upgrade request before a socket opens, refused with theResponseto send, or accepted withserve, for the socket once the runtime has opened it (aHostSocket: Sync's socket, pluscloseWith(code, reason)), andabandon, for when it never will (the client left, the handshake was refused). One of the two must follow: until then the upgrade holds what itslayerbuilt.defineDocumentHostis built on it. - A server socket is not a live stream: another request cannot wake the fiber of an open SSE response in a worker, not even from the same isolate. What crosses requests there is the store they share (D1 here), and the live side re-reads it from inside its own request. See the Cloudflare example.
Sync.transport.workerSocketwraps the accepted end of aWebSocketPairforserveJournal, anywhere else a platform hands one over.