@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 +319 -0
- package/dist/index.d.ts +161 -0
- package/dist/index.js +162 -0
- package/dist/realtime-do.d.ts +166 -0
- package/dist/realtime-do.js +333 -0
- package/package.json +55 -0
- package/src/index.ts +280 -0
- package/src/realtime-do.ts +444 -0
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`.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|