@syncular/server-workers 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,319 @@
1
+ # @syncular/server-workers
2
+
3
+ The Cloudflare Workers entry for the Syncular v2 sync server (TODO §4.2). It
4
+ wires the runtime-neutral server core to Workers bindings — **D1** for
5
+ storage, **R2** for durable segment/blob bytes, secrets for signing and
6
+ auth — behind a standard Workers module `fetch` handler.
7
+
8
+ This package is deliberately thin. `@syncular/server-hono`'s
9
+ `createSyncularHono` is already Workers-native (it routes with Hono, which
10
+ runs unmodified on `workerd`, and speaks only Web `Request`/`Response`/
11
+ `fetch`/Web-Crypto). So the Workers lane is not a second adapter — it is the
12
+ same HTTP handler wired to `env` bindings.
13
+
14
+ ## What it mounts
15
+
16
+ The HTTP binding (SPEC §1.1):
17
+
18
+ | Route | Method | Purpose |
19
+ |---|---|---|
20
+ | `<mount>/sync` | POST | Combined push+pull (§4, §6) |
21
+ | `<mount>/segments/{id}` | GET | Bootstrap segment download (§5.5) |
22
+ | `<mount>/blobs/{id}` | PUT | Blob upload, content-address verified (§5.9.3) |
23
+ | `<mount>/blobs/{id}` | GET | Blob download, row-derived re-auth (§5.9.5) |
24
+
25
+ **Realtime (`GET <mount>/realtime`, §8) is supported** via a Durable Object —
26
+ see "Workers realtime — the Durable Object" below. It is opt-in: pass a
27
+ `realtime` option to `createWorkersFetchHandler` and add the DO binding to
28
+ `wrangler.toml`. Omit it for an HTTP-only deployment — per SPEC §1.1 that is
29
+ fully conformant: reference clients that cannot open the socket sync over
30
+ `POST /sync`, which carries identical semantics. This is a smaller, complete
31
+ deployment, not a degraded one — no fallback is implied.
32
+
33
+ ## Usage
34
+
35
+ ```ts
36
+ // src/worker.ts
37
+ import {
38
+ D1ServerStorage,
39
+ S3BlobStore,
40
+ S3SegmentStore,
41
+ s3PresignedBlobUrls,
42
+ s3PresignedUrls,
43
+ type SyncServerConfig,
44
+ } from '@syncular/server';
45
+ import { createWorkersFetchHandler } from '@syncular/server-workers';
46
+ import { schema } from './syncular.generated'; // typegen output
47
+
48
+ interface Env {
49
+ DB: D1Database; // wrangler.toml [[d1_databases]] binding = "DB"
50
+ R2_ACCOUNT_ID: string;
51
+ R2_ACCESS_KEY_ID: string;
52
+ R2_SECRET_ACCESS_KEY: string;
53
+ SYNC_JWT_SECRET: string;
54
+ }
55
+
56
+ function syncConfig(env: Env): SyncServerConfig {
57
+ const endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
58
+ const r2 = {
59
+ endpoint,
60
+ region: 'auto' as const,
61
+ accessKeyId: env.R2_ACCESS_KEY_ID,
62
+ secretAccessKey: env.R2_SECRET_ACCESS_KEY,
63
+ };
64
+ const segments = new S3SegmentStore({ ...r2, bucket: 'syncular-segments' });
65
+ // Durable attachment bytes (§5.9) in R2 — no TTL, no lifecycle rule; the
66
+ // host schedules `sweepOrphanBlobs` for GC (see the server README runbook).
67
+ const blobs = new S3BlobStore({ ...r2, bucket: 'syncular-blobs' });
68
+ return {
69
+ schema,
70
+ storage: new D1ServerStorage(env.DB),
71
+ segments,
72
+ blobs,
73
+ // §5.4 delegated presign: R2 mints the segment URL directly.
74
+ signedUrls: s3PresignedUrls(segments, { ttlSeconds: 900 }),
75
+ // §5.9.5 delegated presign for blob downloads (issued post-authz).
76
+ blobSignedUrls: s3PresignedBlobUrls(blobs, { ttlSeconds: 900 }),
77
+ resolveScopes: (args) => resolveScopes(args, env),
78
+ // No `sqliteImageBuilder`: Workers has no SQLite engine, so bit-2
79
+ // clients are served the rows lane (§5.3 support floor). No `realtime`:
80
+ // the DO follow-up.
81
+ };
82
+ }
83
+
84
+ export default {
85
+ fetch: createWorkersFetchHandler<Env>((env) => ({
86
+ config: syncConfig(env),
87
+ authenticate: (request) => authenticate(request, env),
88
+ })),
89
+ };
90
+ ```
91
+
92
+ `createWorkersFetchHandler(factory)` builds the Hono app once per request from
93
+ the factory and delegates. Building per request keeps the handler stateless
94
+ (no module-global mutable server) — the Workers-correct posture, since each
95
+ invocation may run on a fresh isolate.
96
+
97
+ See `wrangler.toml.example` for the binding config.
98
+
99
+ ## Schema migration (D1)
100
+
101
+ `D1ServerStorage` does **not** apply its DDL on construction (a cold request
102
+ must never race a schema apply). Apply it once with wrangler. Generate the
103
+ migration SQL from `sqliteDdlStatements()` (exported from `@syncular/server`)
104
+ into a `migrations/` file, then:
105
+
106
+ ```sh
107
+ wrangler d1 create syncular
108
+ wrangler d1 migrations apply syncular
109
+ ```
110
+
111
+ The schema is plain SQLite DDL (shared with `bun:sqlite` via
112
+ `sqlite-dialect.ts`), so it is portable across the two SQLite-family
113
+ storages.
114
+
115
+ ## Storage: D1 (`D1ServerStorage`)
116
+
117
+ D1 *is* SQLite over an async, statement-at-a-time API, so `D1ServerStorage`
118
+ shares the schema and value codecs with `SqliteServerStorage` (the
119
+ `sqlite-dialect.ts` module) and differs only in execution shape.
120
+
121
+ **Transaction model.** D1 has no interactive transaction — the only atomic
122
+ primitive is `db.batch([...])`. The push handler reads first (conflict
123
+ detection) then writes, so `D1ServerStorage`'s transaction executes reads
124
+ immediately (autocommit) and **buffers** writes, flushing them as one atomic
125
+ `db.batch()` at `commit()` (the §6.4 all-or-nothing commit; a rejected op
126
+ rolls back by never flushing). A read-your-own-writes overlay makes `getRow`
127
+ see buffered writes of the same commit.
128
+
129
+ **Concurrency posture.** The dense per-partition `commitSeq` (§2.1) is
130
+ allocated by reading `max_commit_seq` live and buffering the `+1`. Under one
131
+ Worker request this is exact. For two concurrent pushes to the *same
132
+ partition*, serialize the writes: the DO realtime host (the follow-up) is the
133
+ natural per-partition serialization point; a stateless HTTP-only deployment
134
+ that expects concurrent same-partition writes SHOULD front D1 per-partition
135
+ writes with a coordinating primitive (a DO or a Queue). This mirrors
136
+ Postgres's per-partition row lock, achieved by placement rather than a lock
137
+ D1 does not expose. Cross-partition pushes never contend.
138
+
139
+ ## Workers realtime — the Durable Object
140
+
141
+ The realtime channel (§8) needs a durable, stateful WebSocket host. On Workers
142
+ that is a **Durable Object**: `SyncularRealtimeHost` (`src/realtime-do.ts`)
143
+ hosts the `RealtimeHub`, uses WebSocket hibernation to drive the existing
144
+ `RealtimeSession`, and reads/writes the same D1 binding as the HTTP handler.
145
+
146
+ ### Sharding: one DO per partition
147
+
148
+ The DO id is `idFromName(partition)`, so **all of a partition's sockets and
149
+ its commit fan-out live in one single-threaded DO** — which is also the
150
+ per-partition write-serialization point the D1 storage wants (see "Concurrency
151
+ posture"). Because the hub is the `RealtimeNotifier` (§8.2) *inside* the DO, a
152
+ sync round landing over the socket fans its full delta to the partition's
153
+ other sockets with **no LISTEN/NOTIFY** — writes and sockets are co-located.
154
+
155
+ One-partition-per-DO is the natural §8.2 fan-out boundary and the rung we ship.
156
+ **Many-partitions-per-shard** (one DO fronting a bucket of low-traffic
157
+ partitions, to amortize the DO floor) is a future tuning knob: the hub already
158
+ keys every operation by partition, so a shard DO hosts one hub and routes by
159
+ `partition` — no protocol change, only the id-derivation. Deferred until a
160
+ cost/traffic signal asks for it.
161
+
162
+ ### Hibernation semantics
163
+
164
+ The DO uses the Hibernation API (`state.acceptWebSocket(ws)` +
165
+ `webSocketMessage`/`webSocketClose`/`webSocketError` handlers), so **idle
166
+ connections do not pin the DO in memory or bill wall time** — the cost story
167
+ for realtime on Workers: an idle open socket is ~free, you pay for rounds and
168
+ fan-out, not for connection wall time.
169
+
170
+ A `RealtimeSession` is in-memory only. The honest rule, as built:
171
+
172
+ - **Hibernation only happens between rounds.** An in-flight sync round is an
173
+ async generator draining over `ws.send`; while pending it holds the DO's
174
+ event loop, so the DO cannot be evicted mid-round. (This is the same
175
+ property the §8.7 "one round in flight" rule already relies on.)
176
+ - **On the first message after a wake**, the socket carries a serialized
177
+ attachment (`ws.serializeAttachment` — the minimal `{clientId, actorId,
178
+ partition}` §8.1 identity, written at accept time) but no live session. The
179
+ host rebuilds it via `hub.connect(...)`, which reloads the registration list
180
+ from the client record in D1 (exactly what a fresh upgrade does, §8.1).
181
+ Rehydration is transparent to the client: it was greeted once at the real
182
+ upgrade, so the rehydration `hello` is swallowed. Cursor and registrations
183
+ are the durable truth in D1; nothing in-flight is lost because nothing
184
+ in-flight can be hibernated.
185
+
186
+ So the serialized attachment is deliberately minimal — the three identity
187
+ fields `connect` needs. Everything else is re-derived from D1, which is
188
+ authoritative.
189
+
190
+ ### The wake path (HTTP push → DO)
191
+
192
+ A push landing via the *plain* HTTP handler (a stateless isolate with no
193
+ sockets) wakes the partition's DO. Wire `durableObjectRealtimeNotifier(env.
194
+ REALTIME)` into `SyncServerConfig.realtime`; after a commit lands it
195
+ `stub.fetch`es the DO's internal wake path, and the DO calls `hub.wake(
196
+ partition, 'catchup-required')` — its sockets re-pull the delta from the shared
197
+ D1 (§8.3). This is the Workers in-platform equivalent of Postgres LISTEN/NOTIFY:
198
+ a wake, not a byte re-broadcast, so remote sessions pay one re-pull. The wake
199
+ is fire-and-forget — a DO fetch failure never fails the push (the commit is
200
+ already durable; the client's next pull self-heals). A round landing *on the
201
+ DO itself* skips this entirely: the hub fans the full delta out in-process.
202
+
203
+ ### Wiring
204
+
205
+ `createWorkersFetchHandler` takes a `{ config, realtime }` options object; the
206
+ `realtime` factory resolves the DO namespace + the upgrade auth per request:
207
+
208
+ ```ts
209
+ // src/worker.ts
210
+ import {
211
+ createWorkersFetchHandler,
212
+ durableObjectRealtimeNotifier,
213
+ D1ServerStorage,
214
+ SyncularRealtimeHost,
215
+ type RealtimeDOConfig,
216
+ } from '@syncular/server-workers';
217
+ import { DurableObject } from 'cloudflare:workers';
218
+ import { MemorySegmentStore } from '@syncular/server';
219
+ import { schema } from './syncular.generated';
220
+
221
+ interface Env {
222
+ DB: D1Database;
223
+ REALTIME: DurableObjectNamespace<SyncularRealtimeDO>;
224
+ }
225
+
226
+ const realtimeDOConfig = (env: Env): RealtimeDOConfig => ({
227
+ hubConfig: () => ({
228
+ schema,
229
+ resolveScopes: (args) => resolveScopes(args, env),
230
+ // §8.7: the socket carries sync rounds through the SAME handler + segment
231
+ // store as POST /sync — pass the same segment store the HTTP path uses.
232
+ segments: makeSegments(env),
233
+ }),
234
+ });
235
+
236
+ // The DO class the runtime instantiates. It delegates to SyncularRealtimeHost;
237
+ // the platform bindings (DurableObjectState, WebSocket, D1Database) are the
238
+ // real cloudflare:workers types here.
239
+ export class SyncularRealtimeDO extends DurableObject<Env> {
240
+ #host = new SyncularRealtimeHost(this.ctx, this.env.DB, realtimeDOConfig(this.env));
241
+ fetch(request: Request) { return this.#host.fetch(request); }
242
+ webSocketMessage(ws: WebSocket, msg: ArrayBuffer | string) {
243
+ return this.#host.webSocketMessage(ws, msg);
244
+ }
245
+ webSocketClose(ws: WebSocket) { return this.#host.webSocketClose(ws); }
246
+ webSocketError(ws: WebSocket) { return this.#host.webSocketError(ws); }
247
+ }
248
+
249
+ export default {
250
+ fetch: createWorkersFetchHandler<Env>({
251
+ config: (env) => ({
252
+ config: {
253
+ schema,
254
+ storage: new D1ServerStorage(env.DB),
255
+ segments: makeSegments(env),
256
+ resolveScopes: (args) => resolveScopes(args, env),
257
+ // HTTP pushes wake the partition's DO (the LISTEN/NOTIFY analogue).
258
+ realtime: durableObjectRealtimeNotifier(env.REALTIME),
259
+ },
260
+ authenticate: (request) => authenticate(request, env),
261
+ }),
262
+ realtime: (env) => ({
263
+ namespace: env.REALTIME,
264
+ // The realtime-channel auth seam (analogue of `authenticate`): resolve
265
+ // the §8 upgrade identity; the `partition` selects the DO. Return
266
+ // undefined to reject with a 401.
267
+ authenticate: (request) => authenticateRealtime(request, env),
268
+ }),
269
+ }),
270
+ };
271
+ ```
272
+
273
+ The platform surface (`DurableObjectState`, `WebSocket`, `D1Database`,
274
+ `DurableObjectNamespace`) is typed **structurally** in `realtime-do.ts`, so the
275
+ package takes no `@cloudflare/workers-types` dependency — the same posture
276
+ `d1-storage.ts` takes for the D1 API. Your Worker's own types come from
277
+ `@cloudflare/workers-types` / `cloudflare:workers`; they are structurally
278
+ compatible with the host's declared subset.
279
+
280
+ Add the DO binding + migration to `wrangler.toml` (see `wrangler.toml.example`).
281
+
282
+ ### Real-workerd smoke: a manual recipe (why no automated lane)
283
+
284
+ The hermetic tests (`test/realtime-do.test.ts`) drive the **real**
285
+ `RealtimeSession`/`RealtimeHub`/`D1ServerStorage` code through the real DO class
286
+ over a DO double + the D1 double + the reference codec — connect → hello →
287
+ round-over-socket → delta-on-commit → ack, hibernation rehydration, the
288
+ HTTP-push wake fan-out, and presence. Because the DO is a *deployment adapter*
289
+ (same wire, same handler), that is the conformance bar.
290
+
291
+ An automated `wrangler dev` smoke was **deliberately not added**: `wrangler` as
292
+ a devDependency bundles `workerd` + `esbuild` + `miniflare` — well over 100 MB
293
+ installed, disproportionate for one WebSocket round when the double already
294
+ exercises the real logic. Instead, smoke it manually against real `workerd`:
295
+
296
+ ```sh
297
+ # In a Worker project wired per the "Wiring" example above:
298
+ wrangler d1 create syncular && wrangler d1 migrations apply syncular --local
299
+ wrangler dev
300
+ # Then, against the local dev server, open the socket and run one round:
301
+ # const ws = new WebSocket('ws://localhost:8787/realtime?...')
302
+ # ws.onmessage = (e) => console.log(e.data) // expect a `hello` frame
303
+ # (the demo app's frontend worker is a worked reference client.)
304
+ ```
305
+
306
+ If a signal justifies it later, the automated lane is a small `SYNCULAR_
307
+ WRANGLER_SMOKE=1`-gated test wrapping exactly this recipe.
308
+
309
+ ## Runtime neutrality
310
+
311
+ The server core this entry loads (handler, realtime session, D1 storage,
312
+ memory stores, signed-URL/segment/blob machinery) is free of Bun- and
313
+ Node-only builtins — SigV4 and all hashing use Web Crypto, base64 uses
314
+ `btoa`/`atob`, and the SQLite-family stores that need `bun:sqlite`
315
+ (`SqliteServerStorage`, `SqliteSegmentStore`, `SqliteBlobStore`,
316
+ `SqliteLeaseStore`, `buildSqliteImage`) live in separate modules a Bun/Node
317
+ host opts into and a Workers bundle tree-shakes away. This is enforced by a
318
+ static import-graph scan in
319
+ `packages/server/test/runtime-neutrality.test.ts`.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `@syncular/server-workers` — the Cloudflare Workers entry (TODO §4.2).
3
+ *
4
+ * This is deliberately thin. `createSyncularHono` (server-hono) is already
5
+ * Workers-native: it routes with Hono (which runs unmodified on `workerd`)
6
+ * and speaks only Web `Request`/`Response`/`fetch`/Web-Crypto — nothing
7
+ * Bun- or Node-specific. So the Workers lane is *not* a second adapter; it
8
+ * is the same HTTP handler wired to Workers bindings:
9
+ *
10
+ * - **D1** → `D1ServerStorage` (the sqlite-family storage over the D1
11
+ * binding, §4.2);
12
+ * - **R2 / secrets** → the segment store, blob store, and signed-URL
13
+ * config the host assembles from `env` (R2-as-S3 via `S3SegmentStore` +
14
+ * `s3PresignedUrls`, or a memory store for tests);
15
+ * - **secrets** → whatever `authenticate` needs.
16
+ *
17
+ * ## Realtime (§8): the Durable Object
18
+ *
19
+ * The realtime channel (`GET <mount>/realtime`, §1.1 second binding) needs a
20
+ * durable, stateful WebSocket host. On Workers that is a **Durable Object** —
21
+ * `SyncularRealtimeDO` (`realtime-do.ts`): one DO per partition hosting the
22
+ * `RealtimeHub`, WebSocket hibernation driving the existing `RealtimeSession`,
23
+ * in-DO commit fan-out, storage over the same D1 binding. Pass a
24
+ * `realtime` option to `createWorkersFetchHandler` to mount the `/realtime`
25
+ * upgrade route + the HTTP-push wake path; omit it for an HTTP-only
26
+ * deployment (still fully conformant per §1.1 — clients sync over `POST
27
+ * /sync`, identical semantics). No fallback is implied either way: HTTP-only
28
+ * is a smaller complete deployment, not a degraded one.
29
+ */
30
+ import { type D1Database, D1ServerStorage, type StoredCommit, type SyncServerConfig } from '@syncular/server';
31
+ import { type SyncularHonoOptions } from '@syncular/server-hono';
32
+ import { type RealtimeUpgradeIdentity } from './realtime-do.js';
33
+ export { type D1Database, D1ServerStorage } from '@syncular/server';
34
+ export * from './realtime-do.js';
35
+ /**
36
+ * Build the request-scoped config + auth for one Worker invocation from the
37
+ * Worker's `env` (and `ctx`, e.g. for `waitUntil`). Runs per request so
38
+ * bindings resolved from `env` (D1, R2, secrets) are always the live ones.
39
+ * Return the `SyncServerConfig` the core handler needs plus the host
40
+ * `authenticate` callback (§1.1).
41
+ */
42
+ export type WorkersConfigFactory<Env = unknown> = (env: Env, ctx: ExecutionContextLike) => SyncularHonoOptions | Promise<SyncularHonoOptions>;
43
+ /** The subset of `ExecutionContext` this entry passes through. */
44
+ export interface ExecutionContextLike {
45
+ waitUntil(promise: Promise<unknown>): void;
46
+ passThroughOnException?(): void;
47
+ }
48
+ /** A DO stub — the callable handle to one Durable Object instance. */
49
+ export interface DurableObjectStubLike {
50
+ fetch(request: Request): Promise<Response>;
51
+ }
52
+ /** A DO namespace binding: `idFromName` → `get(id)` → a stub. */
53
+ export interface DurableObjectNamespaceLike {
54
+ idFromName(name: string): DurableObjectIdLike;
55
+ get(id: DurableObjectIdLike): DurableObjectStubLike;
56
+ }
57
+ export interface DurableObjectIdLike {
58
+ toString(): string;
59
+ }
60
+ /**
61
+ * Realtime wiring for `createWorkersFetchHandler`. Supplying it mounts the
62
+ * `GET <mount>/realtime` upgrade route and the HTTP-push wake path (the
63
+ * `RealtimeNotifier` returned by `durableObjectRealtimeNotifier`, which the
64
+ * host spreads into its `SyncServerConfig.realtime` so a push landing in the
65
+ * plain isolate wakes the partition's DO).
66
+ */
67
+ export interface WorkersRealtimeOptions {
68
+ /** The DO namespace binding (wrangler `[[durable_objects.bindings]]`). */
69
+ readonly namespace: DurableObjectNamespaceLike;
70
+ /**
71
+ * Resolve the §8 upgrade identity from the incoming `GET /realtime` request.
72
+ * This is the realtime-channel authentication seam — the analogue of the
73
+ * HTTP handler's `authenticate`. Return `undefined` to reject the upgrade
74
+ * (a 401). The `partition` selects the DO (one DO per partition).
75
+ */
76
+ readonly authenticate: (request: Request) => RealtimeUpgradeIdentity | undefined | Promise<RealtimeUpgradeIdentity | undefined>;
77
+ /** The mount path segment for the upgrade route; default `/realtime`. */
78
+ readonly path?: string;
79
+ }
80
+ /** Resolve the per-env realtime wiring for one Worker invocation. */
81
+ export type WorkersRealtimeFactory<Env = unknown> = (env: Env, ctx: ExecutionContextLike) => WorkersRealtimeOptions | Promise<WorkersRealtimeOptions>;
82
+ export interface WorkersFetchHandlerOptions<Env = unknown> {
83
+ /** Build the HTTP handler config + auth per request (see the type doc). */
84
+ readonly config: WorkersConfigFactory<Env>;
85
+ /**
86
+ * Realtime (§8) over a Durable Object. Omit for an HTTP-only deployment
87
+ * (still fully conformant — clients sync over `POST /sync`).
88
+ */
89
+ readonly realtime?: WorkersRealtimeFactory<Env>;
90
+ }
91
+ /**
92
+ * Wrap a config factory (or a `{ config, realtime }` options object) into a
93
+ * Workers module `fetch` handler:
94
+ *
95
+ * ```ts
96
+ * export default {
97
+ * fetch: createWorkersFetchHandler((env: Env) => ({
98
+ * config: syncConfig(env),
99
+ * authenticate: (req) => authenticate(req, env),
100
+ * })),
101
+ * };
102
+ * ```
103
+ *
104
+ * With realtime over a Durable Object, pass the options form and thread the
105
+ * `durableObjectRealtimeNotifier` into the config's `realtime` so HTTP pushes
106
+ * wake the partition's DO:
107
+ *
108
+ * ```ts
109
+ * export default {
110
+ * fetch: createWorkersFetchHandler<Env>({
111
+ * config: (env) => ({
112
+ * config: {
113
+ * ...syncConfig(env),
114
+ * realtime: durableObjectRealtimeNotifier(env.REALTIME),
115
+ * },
116
+ * authenticate: (req) => authenticate(req, env),
117
+ * }),
118
+ * realtime: (env) => ({
119
+ * namespace: env.REALTIME,
120
+ * authenticate: (req) => authenticateRealtime(req, env),
121
+ * }),
122
+ * }),
123
+ * };
124
+ * export { SyncularRealtimeDO } from './realtime-do-class.js';
125
+ * ```
126
+ *
127
+ * The returned handler builds the Hono app once per request from the factory
128
+ * and delegates to it. Hono is cheap to construct; building per request keeps
129
+ * the handler stateless (no module-global mutable server), which is the
130
+ * Workers-correct posture — each invocation may run on a fresh isolate.
131
+ */
132
+ export declare function createWorkersFetchHandler<Env = unknown>(factoryOrOptions: WorkersConfigFactory<Env> | WorkersFetchHandlerOptions<Env>): (request: Request, env: Env, ctx: ExecutionContextLike) => Promise<Response>;
133
+ /**
134
+ * Forward a `/realtime` upgrade to the partition's DO stub. The identity is
135
+ * carried on internal headers to the DO's upgrade endpoint (the DO trusts the
136
+ * Worker to have authenticated — the DO namespace is private to the Worker).
137
+ * The DO is selected by `idFromName(partition)`: one DO per partition (§8.2).
138
+ */
139
+ export declare function forwardRealtimeUpgrade(request: Request, namespace: DurableObjectNamespaceLike, identity: RealtimeUpgradeIdentity): Promise<Response>;
140
+ /**
141
+ * A `RealtimeNotifier` (§8.2) that wakes the partition's DO after a push lands
142
+ * via the plain HTTP handler (a stateless isolate with no sockets). The DO
143
+ * calls `hub.wake(partition, 'catchup-required')` and its sockets re-pull the
144
+ * delta from the shared D1 (§8.3) — the Workers in-platform analogue of the
145
+ * Postgres LISTEN/NOTIFY fan-out. A wake, not a byte re-broadcast.
146
+ *
147
+ * Spread this into `SyncServerConfig.realtime`. The wake is fire-and-forget:
148
+ * a DO fetch failure never fails the push (the commit is already durable in
149
+ * D1; the client's next pull or reconnect self-heals).
150
+ */
151
+ export declare function durableObjectRealtimeNotifier(namespace: DurableObjectNamespaceLike): {
152
+ notifyCommit: (partition: string, commit: StoredCommit) => Promise<void>;
153
+ };
154
+ /**
155
+ * Convenience: a `D1ServerStorage` over a Worker's D1 binding. `migrate` is
156
+ * NOT called here — apply the schema with `wrangler d1 migrations` (see the
157
+ * README + `wrangler.toml` example) so cold requests never race a DDL apply.
158
+ */
159
+ export declare function d1Storage(binding: D1Database): D1ServerStorage;
160
+ /** Re-export the shared config type for host `configFactory` signatures. */
161
+ export type { SyncServerConfig, SyncularHonoOptions };
package/dist/index.js ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `@syncular/server-workers` — the Cloudflare Workers entry (TODO §4.2).
3
+ *
4
+ * This is deliberately thin. `createSyncularHono` (server-hono) is already
5
+ * Workers-native: it routes with Hono (which runs unmodified on `workerd`)
6
+ * and speaks only Web `Request`/`Response`/`fetch`/Web-Crypto — nothing
7
+ * Bun- or Node-specific. So the Workers lane is *not* a second adapter; it
8
+ * is the same HTTP handler wired to Workers bindings:
9
+ *
10
+ * - **D1** → `D1ServerStorage` (the sqlite-family storage over the D1
11
+ * binding, §4.2);
12
+ * - **R2 / secrets** → the segment store, blob store, and signed-URL
13
+ * config the host assembles from `env` (R2-as-S3 via `S3SegmentStore` +
14
+ * `s3PresignedUrls`, or a memory store for tests);
15
+ * - **secrets** → whatever `authenticate` needs.
16
+ *
17
+ * ## Realtime (§8): the Durable Object
18
+ *
19
+ * The realtime channel (`GET <mount>/realtime`, §1.1 second binding) needs a
20
+ * durable, stateful WebSocket host. On Workers that is a **Durable Object** —
21
+ * `SyncularRealtimeDO` (`realtime-do.ts`): one DO per partition hosting the
22
+ * `RealtimeHub`, WebSocket hibernation driving the existing `RealtimeSession`,
23
+ * in-DO commit fan-out, storage over the same D1 binding. Pass a
24
+ * `realtime` option to `createWorkersFetchHandler` to mount the `/realtime`
25
+ * upgrade route + the HTTP-push wake path; omit it for an HTTP-only
26
+ * deployment (still fully conformant per §1.1 — clients sync over `POST
27
+ * /sync`, identical semantics). No fallback is implied either way: HTTP-only
28
+ * is a smaller complete deployment, not a degraded one.
29
+ */
30
+ import { D1ServerStorage, } from '@syncular/server';
31
+ import { createSyncularHono, } from '@syncular/server-hono';
32
+ import { REALTIME_DO_UPGRADE_PATH, REALTIME_DO_WAKE_PATH, writeIdentityHeaders, } from './realtime-do.js';
33
+ export { D1ServerStorage } from '@syncular/server';
34
+ export * from './realtime-do.js';
35
+ /**
36
+ * Wrap a config factory (or a `{ config, realtime }` options object) into a
37
+ * Workers module `fetch` handler:
38
+ *
39
+ * ```ts
40
+ * export default {
41
+ * fetch: createWorkersFetchHandler((env: Env) => ({
42
+ * config: syncConfig(env),
43
+ * authenticate: (req) => authenticate(req, env),
44
+ * })),
45
+ * };
46
+ * ```
47
+ *
48
+ * With realtime over a Durable Object, pass the options form and thread the
49
+ * `durableObjectRealtimeNotifier` into the config's `realtime` so HTTP pushes
50
+ * wake the partition's DO:
51
+ *
52
+ * ```ts
53
+ * export default {
54
+ * fetch: createWorkersFetchHandler<Env>({
55
+ * config: (env) => ({
56
+ * config: {
57
+ * ...syncConfig(env),
58
+ * realtime: durableObjectRealtimeNotifier(env.REALTIME),
59
+ * },
60
+ * authenticate: (req) => authenticate(req, env),
61
+ * }),
62
+ * realtime: (env) => ({
63
+ * namespace: env.REALTIME,
64
+ * authenticate: (req) => authenticateRealtime(req, env),
65
+ * }),
66
+ * }),
67
+ * };
68
+ * export { SyncularRealtimeDO } from './realtime-do-class.js';
69
+ * ```
70
+ *
71
+ * The returned handler builds the Hono app once per request from the factory
72
+ * and delegates to it. Hono is cheap to construct; building per request keeps
73
+ * the handler stateless (no module-global mutable server), which is the
74
+ * Workers-correct posture — each invocation may run on a fresh isolate.
75
+ */
76
+ export function createWorkersFetchHandler(factoryOrOptions) {
77
+ const options = typeof factoryOrOptions === 'function'
78
+ ? { config: factoryOrOptions }
79
+ : factoryOrOptions;
80
+ return async (request, env, ctx) => {
81
+ // §8 upgrade: GET <mount>/realtime → forward to the partition's DO.
82
+ if (options.realtime !== undefined) {
83
+ const realtime = await options.realtime(env, ctx);
84
+ const upgraded = await handleRealtimeUpgrade(request, realtime);
85
+ if (upgraded !== undefined)
86
+ return upgraded;
87
+ }
88
+ const honoOptions = await options.config(env, ctx);
89
+ const app = createSyncularHono(honoOptions);
90
+ return app.fetch(request);
91
+ };
92
+ }
93
+ /**
94
+ * If `request` is the `GET <mount>/realtime` upgrade, authenticate it and
95
+ * forward it to the partition's DO stub; otherwise return `undefined` so the
96
+ * caller falls through to the HTTP handler.
97
+ */
98
+ async function handleRealtimeUpgrade(request, realtime) {
99
+ const path = realtime.path ?? '/realtime';
100
+ const url = new URL(request.url);
101
+ if (url.pathname !== path && !url.pathname.endsWith(path))
102
+ return undefined;
103
+ if (request.method !== 'GET')
104
+ return undefined;
105
+ if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
106
+ return new Response('expected a websocket upgrade', { status: 426 });
107
+ }
108
+ const identity = await realtime.authenticate(request);
109
+ if (identity === undefined) {
110
+ return new Response('unauthorized', { status: 401 });
111
+ }
112
+ return forwardRealtimeUpgrade(request, realtime.namespace, identity);
113
+ }
114
+ /**
115
+ * Forward a `/realtime` upgrade to the partition's DO stub. The identity is
116
+ * carried on internal headers to the DO's upgrade endpoint (the DO trusts the
117
+ * Worker to have authenticated — the DO namespace is private to the Worker).
118
+ * The DO is selected by `idFromName(partition)`: one DO per partition (§8.2).
119
+ */
120
+ export function forwardRealtimeUpgrade(request, namespace, identity) {
121
+ const stub = namespace.get(namespace.idFromName(identity.partition));
122
+ const forwarded = new Request(new URL(REALTIME_DO_UPGRADE_PATH, request.url), request);
123
+ writeIdentityHeaders(forwarded.headers, identity);
124
+ return stub.fetch(forwarded);
125
+ }
126
+ /**
127
+ * A `RealtimeNotifier` (§8.2) that wakes the partition's DO after a push lands
128
+ * via the plain HTTP handler (a stateless isolate with no sockets). The DO
129
+ * calls `hub.wake(partition, 'catchup-required')` and its sockets re-pull the
130
+ * delta from the shared D1 (§8.3) — the Workers in-platform analogue of the
131
+ * Postgres LISTEN/NOTIFY fan-out. A wake, not a byte re-broadcast.
132
+ *
133
+ * Spread this into `SyncServerConfig.realtime`. The wake is fire-and-forget:
134
+ * a DO fetch failure never fails the push (the commit is already durable in
135
+ * D1; the client's next pull or reconnect self-heals).
136
+ */
137
+ export function durableObjectRealtimeNotifier(namespace) {
138
+ return {
139
+ async notifyCommit(partition) {
140
+ try {
141
+ const stub = namespace.get(namespace.idFromName(partition));
142
+ // A synthetic origin — the DO only reads the path + JSON body.
143
+ await stub.fetch(new Request(`https://do${REALTIME_DO_WAKE_PATH}`, {
144
+ method: 'POST',
145
+ headers: { 'content-type': 'application/json' },
146
+ body: JSON.stringify({ partition }),
147
+ }));
148
+ }
149
+ catch {
150
+ // Fire-and-forget: the commit is durable; the DO wake is best-effort.
151
+ }
152
+ },
153
+ };
154
+ }
155
+ /**
156
+ * Convenience: a `D1ServerStorage` over a Worker's D1 binding. `migrate` is
157
+ * NOT called here — apply the schema with `wrangler d1 migrations` (see the
158
+ * README + `wrangler.toml` example) so cold requests never race a DDL apply.
159
+ */
160
+ export function d1Storage(binding) {
161
+ return new D1ServerStorage(binding);
162
+ }