@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/src/index.ts ADDED
@@ -0,0 +1,280 @@
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 {
31
+ type D1Database,
32
+ D1ServerStorage,
33
+ type StoredCommit,
34
+ type SyncServerConfig,
35
+ } from '@syncular/server';
36
+ import {
37
+ createSyncularHono,
38
+ type SyncularHonoOptions,
39
+ } from '@syncular/server-hono';
40
+ import {
41
+ REALTIME_DO_UPGRADE_PATH,
42
+ REALTIME_DO_WAKE_PATH,
43
+ type RealtimeUpgradeIdentity,
44
+ writeIdentityHeaders,
45
+ } from './realtime-do';
46
+
47
+ export { type D1Database, D1ServerStorage } from '@syncular/server';
48
+ export * from './realtime-do';
49
+
50
+ /**
51
+ * Build the request-scoped config + auth for one Worker invocation from the
52
+ * Worker's `env` (and `ctx`, e.g. for `waitUntil`). Runs per request so
53
+ * bindings resolved from `env` (D1, R2, secrets) are always the live ones.
54
+ * Return the `SyncServerConfig` the core handler needs plus the host
55
+ * `authenticate` callback (§1.1).
56
+ */
57
+ export type WorkersConfigFactory<Env = unknown> = (
58
+ env: Env,
59
+ ctx: ExecutionContextLike,
60
+ ) => SyncularHonoOptions | Promise<SyncularHonoOptions>;
61
+
62
+ /** The subset of `ExecutionContext` this entry passes through. */
63
+ export interface ExecutionContextLike {
64
+ waitUntil(promise: Promise<unknown>): void;
65
+ passThroughOnException?(): void;
66
+ }
67
+
68
+ // -- Durable Object bindings (structural; no @cloudflare/workers-types dep) --
69
+
70
+ /** A DO stub — the callable handle to one Durable Object instance. */
71
+ export interface DurableObjectStubLike {
72
+ fetch(request: Request): Promise<Response>;
73
+ }
74
+
75
+ /** A DO namespace binding: `idFromName` → `get(id)` → a stub. */
76
+ export interface DurableObjectNamespaceLike {
77
+ idFromName(name: string): DurableObjectIdLike;
78
+ get(id: DurableObjectIdLike): DurableObjectStubLike;
79
+ }
80
+
81
+ export interface DurableObjectIdLike {
82
+ toString(): string;
83
+ }
84
+
85
+ /**
86
+ * Realtime wiring for `createWorkersFetchHandler`. Supplying it mounts the
87
+ * `GET <mount>/realtime` upgrade route and the HTTP-push wake path (the
88
+ * `RealtimeNotifier` returned by `durableObjectRealtimeNotifier`, which the
89
+ * host spreads into its `SyncServerConfig.realtime` so a push landing in the
90
+ * plain isolate wakes the partition's DO).
91
+ */
92
+ export interface WorkersRealtimeOptions {
93
+ /** The DO namespace binding (wrangler `[[durable_objects.bindings]]`). */
94
+ readonly namespace: DurableObjectNamespaceLike;
95
+ /**
96
+ * Resolve the §8 upgrade identity from the incoming `GET /realtime` request.
97
+ * This is the realtime-channel authentication seam — the analogue of the
98
+ * HTTP handler's `authenticate`. Return `undefined` to reject the upgrade
99
+ * (a 401). The `partition` selects the DO (one DO per partition).
100
+ */
101
+ readonly authenticate: (
102
+ request: Request,
103
+ ) =>
104
+ | RealtimeUpgradeIdentity
105
+ | undefined
106
+ | Promise<RealtimeUpgradeIdentity | undefined>;
107
+ /** The mount path segment for the upgrade route; default `/realtime`. */
108
+ readonly path?: string;
109
+ }
110
+
111
+ /** Resolve the per-env realtime wiring for one Worker invocation. */
112
+ export type WorkersRealtimeFactory<Env = unknown> = (
113
+ env: Env,
114
+ ctx: ExecutionContextLike,
115
+ ) => WorkersRealtimeOptions | Promise<WorkersRealtimeOptions>;
116
+
117
+ export interface WorkersFetchHandlerOptions<Env = unknown> {
118
+ /** Build the HTTP handler config + auth per request (see the type doc). */
119
+ readonly config: WorkersConfigFactory<Env>;
120
+ /**
121
+ * Realtime (§8) over a Durable Object. Omit for an HTTP-only deployment
122
+ * (still fully conformant — clients sync over `POST /sync`).
123
+ */
124
+ readonly realtime?: WorkersRealtimeFactory<Env>;
125
+ }
126
+
127
+ /**
128
+ * Wrap a config factory (or a `{ config, realtime }` options object) into a
129
+ * Workers module `fetch` handler:
130
+ *
131
+ * ```ts
132
+ * export default {
133
+ * fetch: createWorkersFetchHandler((env: Env) => ({
134
+ * config: syncConfig(env),
135
+ * authenticate: (req) => authenticate(req, env),
136
+ * })),
137
+ * };
138
+ * ```
139
+ *
140
+ * With realtime over a Durable Object, pass the options form and thread the
141
+ * `durableObjectRealtimeNotifier` into the config's `realtime` so HTTP pushes
142
+ * wake the partition's DO:
143
+ *
144
+ * ```ts
145
+ * export default {
146
+ * fetch: createWorkersFetchHandler<Env>({
147
+ * config: (env) => ({
148
+ * config: {
149
+ * ...syncConfig(env),
150
+ * realtime: durableObjectRealtimeNotifier(env.REALTIME),
151
+ * },
152
+ * authenticate: (req) => authenticate(req, env),
153
+ * }),
154
+ * realtime: (env) => ({
155
+ * namespace: env.REALTIME,
156
+ * authenticate: (req) => authenticateRealtime(req, env),
157
+ * }),
158
+ * }),
159
+ * };
160
+ * export { SyncularRealtimeDO } from './realtime-do-class';
161
+ * ```
162
+ *
163
+ * The returned handler builds the Hono app once per request from the factory
164
+ * and delegates to it. Hono is cheap to construct; building per request keeps
165
+ * the handler stateless (no module-global mutable server), which is the
166
+ * Workers-correct posture — each invocation may run on a fresh isolate.
167
+ */
168
+ export function createWorkersFetchHandler<Env = unknown>(
169
+ factoryOrOptions: WorkersConfigFactory<Env> | WorkersFetchHandlerOptions<Env>,
170
+ ): (
171
+ request: Request,
172
+ env: Env,
173
+ ctx: ExecutionContextLike,
174
+ ) => Promise<Response> {
175
+ const options: WorkersFetchHandlerOptions<Env> =
176
+ typeof factoryOrOptions === 'function'
177
+ ? { config: factoryOrOptions }
178
+ : factoryOrOptions;
179
+ return async (request, env, ctx) => {
180
+ // §8 upgrade: GET <mount>/realtime → forward to the partition's DO.
181
+ if (options.realtime !== undefined) {
182
+ const realtime = await options.realtime(env, ctx);
183
+ const upgraded = await handleRealtimeUpgrade(request, realtime);
184
+ if (upgraded !== undefined) return upgraded;
185
+ }
186
+ const honoOptions = await options.config(env, ctx);
187
+ const app = createSyncularHono(honoOptions);
188
+ return app.fetch(request);
189
+ };
190
+ }
191
+
192
+ /**
193
+ * If `request` is the `GET <mount>/realtime` upgrade, authenticate it and
194
+ * forward it to the partition's DO stub; otherwise return `undefined` so the
195
+ * caller falls through to the HTTP handler.
196
+ */
197
+ async function handleRealtimeUpgrade(
198
+ request: Request,
199
+ realtime: WorkersRealtimeOptions,
200
+ ): Promise<Response | undefined> {
201
+ const path = realtime.path ?? '/realtime';
202
+ const url = new URL(request.url);
203
+ if (url.pathname !== path && !url.pathname.endsWith(path)) return undefined;
204
+ if (request.method !== 'GET') return undefined;
205
+ if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
206
+ return new Response('expected a websocket upgrade', { status: 426 });
207
+ }
208
+ const identity = await realtime.authenticate(request);
209
+ if (identity === undefined) {
210
+ return new Response('unauthorized', { status: 401 });
211
+ }
212
+ return forwardRealtimeUpgrade(request, realtime.namespace, identity);
213
+ }
214
+
215
+ /**
216
+ * Forward a `/realtime` upgrade to the partition's DO stub. The identity is
217
+ * carried on internal headers to the DO's upgrade endpoint (the DO trusts the
218
+ * Worker to have authenticated — the DO namespace is private to the Worker).
219
+ * The DO is selected by `idFromName(partition)`: one DO per partition (§8.2).
220
+ */
221
+ export function forwardRealtimeUpgrade(
222
+ request: Request,
223
+ namespace: DurableObjectNamespaceLike,
224
+ identity: RealtimeUpgradeIdentity,
225
+ ): Promise<Response> {
226
+ const stub = namespace.get(namespace.idFromName(identity.partition));
227
+ const forwarded = new Request(
228
+ new URL(REALTIME_DO_UPGRADE_PATH, request.url),
229
+ request,
230
+ );
231
+ writeIdentityHeaders(forwarded.headers, identity);
232
+ return stub.fetch(forwarded);
233
+ }
234
+
235
+ /**
236
+ * A `RealtimeNotifier` (§8.2) that wakes the partition's DO after a push lands
237
+ * via the plain HTTP handler (a stateless isolate with no sockets). The DO
238
+ * calls `hub.wake(partition, 'catchup-required')` and its sockets re-pull the
239
+ * delta from the shared D1 (§8.3) — the Workers in-platform analogue of the
240
+ * Postgres LISTEN/NOTIFY fan-out. A wake, not a byte re-broadcast.
241
+ *
242
+ * Spread this into `SyncServerConfig.realtime`. The wake is fire-and-forget:
243
+ * a DO fetch failure never fails the push (the commit is already durable in
244
+ * D1; the client's next pull or reconnect self-heals).
245
+ */
246
+ export function durableObjectRealtimeNotifier(
247
+ namespace: DurableObjectNamespaceLike,
248
+ ): {
249
+ notifyCommit: (partition: string, commit: StoredCommit) => Promise<void>;
250
+ } {
251
+ return {
252
+ async notifyCommit(partition: string): Promise<void> {
253
+ try {
254
+ const stub = namespace.get(namespace.idFromName(partition));
255
+ // A synthetic origin — the DO only reads the path + JSON body.
256
+ await stub.fetch(
257
+ new Request(`https://do${REALTIME_DO_WAKE_PATH}`, {
258
+ method: 'POST',
259
+ headers: { 'content-type': 'application/json' },
260
+ body: JSON.stringify({ partition }),
261
+ }),
262
+ );
263
+ } catch {
264
+ // Fire-and-forget: the commit is durable; the DO wake is best-effort.
265
+ }
266
+ },
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Convenience: a `D1ServerStorage` over a Worker's D1 binding. `migrate` is
272
+ * NOT called here — apply the schema with `wrangler d1 migrations` (see the
273
+ * README + `wrangler.toml` example) so cold requests never race a DDL apply.
274
+ */
275
+ export function d1Storage(binding: D1Database): D1ServerStorage {
276
+ return new D1ServerStorage(binding);
277
+ }
278
+
279
+ /** Re-export the shared config type for host `configFactory` signatures. */
280
+ export type { SyncServerConfig, SyncularHonoOptions };