@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/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 };
|