@syncular/server-workers 0.15.23 → 0.15.24
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 +57 -63
- package/dist/index.d.ts +29 -12
- package/dist/index.js +45 -11
- package/dist/realtime-do.d.ts +28 -20
- package/dist/realtime-do.js +91 -32
- package/package.json +4 -4
- package/src/index.ts +74 -13
- package/src/realtime-do.ts +134 -39
package/README.md
CHANGED
|
@@ -22,13 +22,11 @@ The HTTP binding (SPEC §1.1):
|
|
|
22
22
|
| `<mount>/blobs/{id}` | PUT | Blob upload, content-address verified (§5.9.3) |
|
|
23
23
|
| `<mount>/blobs/{id}` | GET | Blob download, row-derived re-auth (§5.9.5) |
|
|
24
24
|
|
|
25
|
-
**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
`POST /sync`, which carries identical semantics. This is a smaller, complete
|
|
31
|
-
deployment, not a degraded one — no fallback is implied.
|
|
25
|
+
**Every D1 `/sync` round that may push traverses a per-partition Durable
|
|
26
|
+
Object queue.** Pass `coordinator` for HTTP-only transport, or `realtime` to
|
|
27
|
+
reuse the same namespace while also mounting `GET <mount>/realtime` (§8).
|
|
28
|
+
WebSockets are optional; D1 write coordination is not. A plain stateless D1
|
|
29
|
+
writer fails closed before app-row mutation.
|
|
32
30
|
|
|
33
31
|
## Usage
|
|
34
32
|
|
|
@@ -47,6 +45,7 @@ import { schema } from './syncular.generated'; // typegen output
|
|
|
47
45
|
|
|
48
46
|
interface Env {
|
|
49
47
|
DB: D1Database; // wrangler.toml [[d1_databases]] binding = "DB"
|
|
48
|
+
SYNC_COORDINATOR: DurableObjectNamespace<SyncularRealtimeDO>;
|
|
50
49
|
R2_ACCOUNT_ID: string;
|
|
51
50
|
R2_ACCESS_KEY_ID: string;
|
|
52
51
|
R2_SECRET_ACCESS_KEY: string;
|
|
@@ -76,23 +75,27 @@ function syncConfig(env: Env): SyncServerConfig {
|
|
|
76
75
|
blobSignedUrls: s3PresignedBlobUrls(blobs, { ttlSeconds: 900 }),
|
|
77
76
|
resolveScopes: (args) => resolveScopes(args, env),
|
|
78
77
|
// No `sqliteImageBuilder`: Workers has no SQLite engine, so bit-2
|
|
79
|
-
// clients are served the rows lane (§5.3 support floor).
|
|
80
|
-
// the DO follow-up.
|
|
78
|
+
// clients are served the rows lane (§5.3 support floor).
|
|
81
79
|
};
|
|
82
80
|
}
|
|
83
81
|
|
|
84
82
|
export default {
|
|
85
|
-
fetch: createWorkersFetchHandler<Env>(
|
|
86
|
-
config:
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
fetch: createWorkersFetchHandler<Env>({
|
|
84
|
+
config: (env) => ({
|
|
85
|
+
config: syncConfig(env),
|
|
86
|
+
authenticate: (request) => authenticate(request, env),
|
|
87
|
+
}),
|
|
88
|
+
coordinator: (env) => ({ namespace: env.SYNC_COORDINATOR }),
|
|
89
|
+
}),
|
|
89
90
|
};
|
|
90
91
|
```
|
|
91
92
|
|
|
92
93
|
`createWorkersFetchHandler(factory)` builds the Hono app once per request from
|
|
93
94
|
the factory and delegates. Building per request keeps the handler stateless
|
|
94
95
|
(no module-global mutable server) — the Workers-correct posture, since each
|
|
95
|
-
invocation may run on a fresh isolate.
|
|
96
|
+
invocation may run on a fresh isolate. The coordinator forwards authenticated
|
|
97
|
+
`/sync` bodies to the partition DO; segments and blob endpoints remain direct.
|
|
98
|
+
The complete DO class and canonical config factory are shown under "Wiring".
|
|
96
99
|
|
|
97
100
|
See `wrangler.toml.example` for the binding config.
|
|
98
101
|
|
|
@@ -126,26 +129,18 @@ immediately (autocommit) and **buffers** writes, flushing them as one atomic
|
|
|
126
129
|
rolls back by never flushing). A read-your-own-writes overlay makes `getRow`
|
|
127
130
|
see buffered writes of the same commit.
|
|
128
131
|
|
|
129
|
-
**Concurrency posture.**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
partition
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
`new D1ServerStorage(env.DB)` therefore rejects a push whenever
|
|
142
|
-
`commitValidator` is configured. The storage created inside
|
|
143
|
-
`SyncularRealtimeHost` opts in because that host is already one
|
|
144
|
-
single-threaded DO per partition. A custom coordinated host may construct
|
|
145
|
-
`new D1ServerStorage(env.DB, { commitValidationSerialized: true })`, but MUST
|
|
146
|
-
never set that assertion in a stateless HTTP Worker. A realtime notifier wakes
|
|
147
|
-
sockets after an HTTP commit; it does not serialize that HTTP write and is not
|
|
148
|
-
sufficient for §6.8 by itself.
|
|
132
|
+
**Concurrency posture.** Every push, not only whole-commit validation, must
|
|
133
|
+
serialize before operation reads and re-check idempotency under that boundary.
|
|
134
|
+
`createWorkersFetchHandler` forwards `/sync` to one DO per authenticated
|
|
135
|
+
partition; the host uses an explicit FIFO because Durable Object events can
|
|
136
|
+
interleave at `await`. Cross-partition pushes still use different DOs.
|
|
137
|
+
|
|
138
|
+
A plain `new D1ServerStorage(env.DB)` fails closed before every push. Only an
|
|
139
|
+
actual coordinator may construct
|
|
140
|
+
`new D1ServerStorage(env.DB, { pushApplySerialized: true })`; never set that
|
|
141
|
+
assertion in a stateless Worker. The deprecated
|
|
142
|
+
`commitValidationSerialized` alias exists only for coordinated hosts upgrading
|
|
143
|
+
from an older release and has the same every-push meaning.
|
|
149
144
|
|
|
150
145
|
## Workers realtime — the Durable Object
|
|
151
146
|
|
|
@@ -157,9 +152,10 @@ hosts the `RealtimeHub`, uses WebSocket hibernation to drive the existing
|
|
|
157
152
|
### Sharding: one DO per partition
|
|
158
153
|
|
|
159
154
|
The DO id is `idFromName(partition)`, so **all of a partition's sockets and
|
|
160
|
-
its commit fan-out live in one
|
|
161
|
-
per-partition write-serialization point the D1 storage wants (see
|
|
162
|
-
posture"). Because the hub is the `RealtimeNotifier` (§8.2)
|
|
155
|
+
its commit fan-out live in one DO with an explicit sync-round FIFO** — which is
|
|
156
|
+
also the per-partition write-serialization point the D1 storage wants (see
|
|
157
|
+
"Concurrency posture"). Because the hub is the `RealtimeNotifier` (§8.2)
|
|
158
|
+
*inside* the DO, a
|
|
163
159
|
sync round landing over the socket fans its full delta to the partition's
|
|
164
160
|
other sockets with **no LISTEN/NOTIFY** — writes and sockets are co-located.
|
|
165
161
|
|
|
@@ -198,18 +194,14 @@ So the serialized attachment is deliberately minimal — the three identity
|
|
|
198
194
|
fields `connect` needs. Everything else is re-derived from D1, which is
|
|
199
195
|
authoritative.
|
|
200
196
|
|
|
201
|
-
###
|
|
197
|
+
### HTTP sync and fanout
|
|
202
198
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
partition
|
|
208
|
-
|
|
209
|
-
a wake, not a byte re-broadcast, so remote sessions pay one re-pull. The wake
|
|
210
|
-
is fire-and-forget — a DO fetch failure never fails the push (the commit is
|
|
211
|
-
already durable; the client's next pull self-heals). A round landing *on the
|
|
212
|
-
DO itself* skips this entirely: the hub fans the full delta out in-process.
|
|
199
|
+
Authenticated HTTP `/sync` rounds are forwarded into the same partition DO as
|
|
200
|
+
socket rounds. Applied commits therefore fan out through the in-DO hub without
|
|
201
|
+
a post-commit wake race. `durableObjectRealtimeNotifier` remains available for
|
|
202
|
+
an external authoritative command host that already provides equally strong
|
|
203
|
+
partition serialization and needs to wake the DO after its own commit; it is
|
|
204
|
+
not a substitute for the `/sync` coordinator.
|
|
213
205
|
|
|
214
206
|
### Wiring
|
|
215
207
|
|
|
@@ -220,13 +212,12 @@ DO itself* skips this entirely: the hub fans the full delta out in-process.
|
|
|
220
212
|
// src/worker.ts
|
|
221
213
|
import {
|
|
222
214
|
createWorkersFetchHandler,
|
|
223
|
-
durableObjectRealtimeNotifier,
|
|
224
215
|
D1ServerStorage,
|
|
225
216
|
SyncularRealtimeHost,
|
|
226
217
|
type RealtimeDOConfig,
|
|
227
218
|
} from '@syncular/server-workers';
|
|
228
219
|
import { DurableObject } from 'cloudflare:workers';
|
|
229
|
-
import {
|
|
220
|
+
import type { RealtimeHubConfig } from '@syncular/server';
|
|
230
221
|
import { schema } from './syncular.generated';
|
|
231
222
|
|
|
232
223
|
interface Env {
|
|
@@ -234,14 +225,21 @@ interface Env {
|
|
|
234
225
|
REALTIME: DurableObjectNamespace<SyncularRealtimeDO>;
|
|
235
226
|
}
|
|
236
227
|
|
|
237
|
-
const
|
|
238
|
-
|
|
228
|
+
const canonicalSyncConfig = (
|
|
229
|
+
env: Env,
|
|
230
|
+
storage: D1ServerStorage,
|
|
231
|
+
) => ({
|
|
239
232
|
schema,
|
|
233
|
+
storage,
|
|
240
234
|
resolveScopes: (args) => resolveScopes(args, env),
|
|
241
|
-
// §8.7: the socket carries sync rounds through the SAME handler + segment
|
|
242
|
-
// store as POST /sync — pass the same segment store the HTTP path uses.
|
|
243
235
|
segments: makeSegments(env),
|
|
244
|
-
|
|
236
|
+
blobs: makeBlobs(env),
|
|
237
|
+
crdtMergers: makeCrdtMergers(env),
|
|
238
|
+
} satisfies RealtimeHubConfig);
|
|
239
|
+
|
|
240
|
+
const realtimeDOConfig = (env: Env): RealtimeDOConfig => ({
|
|
241
|
+
// One factory owns HTTP-forwarded and socket-round sync capabilities.
|
|
242
|
+
syncConfig: (storage) => canonicalSyncConfig(env, storage),
|
|
245
243
|
});
|
|
246
244
|
|
|
247
245
|
// The DO class the runtime instantiates. It delegates to SyncularRealtimeHost;
|
|
@@ -261,18 +259,14 @@ export default {
|
|
|
261
259
|
fetch: createWorkersFetchHandler<Env>({
|
|
262
260
|
config: (env) => ({
|
|
263
261
|
config: {
|
|
264
|
-
|
|
265
|
-
storage: new D1ServerStorage(env.DB),
|
|
266
|
-
segments: makeSegments(env),
|
|
267
|
-
resolveScopes: (args) => resolveScopes(args, env),
|
|
268
|
-
// HTTP pushes wake the partition's DO (the LISTEN/NOTIFY analogue).
|
|
269
|
-
realtime: durableObjectRealtimeNotifier(env.REALTIME),
|
|
262
|
+
...canonicalSyncConfig(env, new D1ServerStorage(env.DB)),
|
|
270
263
|
},
|
|
271
264
|
authenticate: (request) => authenticate(request, env),
|
|
272
265
|
}),
|
|
273
266
|
realtime: (env) => ({
|
|
274
267
|
namespace: env.REALTIME,
|
|
275
|
-
//
|
|
268
|
+
// This namespace also coordinates authenticated HTTP /sync rounds.
|
|
269
|
+
// The realtime-channel auth seam resolves
|
|
276
270
|
// the §8 upgrade identity; the `partition` selects the DO. Return
|
|
277
271
|
// undefined to reject with a 401.
|
|
278
272
|
authenticate: (request) => authenticateRealtime(request, env),
|
|
@@ -296,7 +290,7 @@ The hermetic tests (`test/realtime-do.test.ts`) drive the **real**
|
|
|
296
290
|
`RealtimeSession`/`RealtimeHub`/`D1ServerStorage` code through the real DO class
|
|
297
291
|
over a DO double + the D1 double + the reference codec — connect → hello →
|
|
298
292
|
round-over-socket → delta-on-commit → ack, hibernation rehydration, the
|
|
299
|
-
HTTP-push
|
|
293
|
+
HTTP-forwarded push fan-out, and presence. Because the DO is a *deployment adapter*
|
|
300
294
|
(same wire, same handler), that is the conformance bar.
|
|
301
295
|
|
|
302
296
|
An automated `wrangler dev` smoke was **deliberately not added**: `wrangler` as
|
package/dist/index.d.ts
CHANGED
|
@@ -21,11 +21,9 @@
|
|
|
21
21
|
* `SyncularRealtimeDO` (`realtime-do.ts`): one DO per partition hosting the
|
|
22
22
|
* `RealtimeHub`, WebSocket hibernation driving the existing `RealtimeSession`,
|
|
23
23
|
* in-DO commit fan-out, storage over the same D1 binding. Pass a
|
|
24
|
-
* `realtime` option to
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* /sync`, identical semantics). No fallback is implied either way: HTTP-only
|
|
28
|
-
* is a smaller complete deployment, not a degraded one.
|
|
24
|
+
* `realtime` option to mount `/realtime`; its namespace also coordinates D1
|
|
25
|
+
* `/sync`. An HTTP-only D1 deployment uses the `coordinator` option instead:
|
|
26
|
+
* WebSockets are optional, the per-partition push queue is not.
|
|
29
27
|
*/
|
|
30
28
|
import { type D1Database, D1ServerStorage, type StoredCommit, type SyncServerConfig } from '@syncular/server';
|
|
31
29
|
import { type SyncularHonoOptions } from '@syncular/server-hono';
|
|
@@ -59,10 +57,8 @@ export interface DurableObjectIdLike {
|
|
|
59
57
|
}
|
|
60
58
|
/**
|
|
61
59
|
* Realtime wiring for `createWorkersFetchHandler`. Supplying it mounts the
|
|
62
|
-
* `GET <mount>/realtime` upgrade route and the
|
|
63
|
-
* `
|
|
64
|
-
* host spreads into its `SyncServerConfig.realtime` so a push landing in the
|
|
65
|
-
* plain isolate wakes the partition's DO).
|
|
60
|
+
* `GET <mount>/realtime` upgrade route and uses the same namespace as the D1
|
|
61
|
+
* `/sync` coordinator.
|
|
66
62
|
*/
|
|
67
63
|
export interface WorkersRealtimeOptions {
|
|
68
64
|
/** The DO namespace binding (wrangler `[[durable_objects.bindings]]`). */
|
|
@@ -79,9 +75,20 @@ export interface WorkersRealtimeOptions {
|
|
|
79
75
|
}
|
|
80
76
|
/** Resolve the per-env realtime wiring for one Worker invocation. */
|
|
81
77
|
export type WorkersRealtimeFactory<Env = unknown> = (env: Env, ctx: ExecutionContextLike) => WorkersRealtimeOptions | Promise<WorkersRealtimeOptions>;
|
|
78
|
+
/** Per-partition Durable Object boundary for D1 sync rounds without WS. */
|
|
79
|
+
export interface WorkersCoordinatorOptions {
|
|
80
|
+
readonly namespace: DurableObjectNamespaceLike;
|
|
81
|
+
}
|
|
82
|
+
export type WorkersCoordinatorFactory<Env = unknown> = (env: Env, ctx: ExecutionContextLike) => WorkersCoordinatorOptions | Promise<WorkersCoordinatorOptions>;
|
|
82
83
|
export interface WorkersFetchHandlerOptions<Env = unknown> {
|
|
83
84
|
/** Build the HTTP handler config + auth per request (see the type doc). */
|
|
84
85
|
readonly config: WorkersConfigFactory<Env>;
|
|
86
|
+
/**
|
|
87
|
+
* Serialize D1 `/sync` rounds through one Durable Object per partition.
|
|
88
|
+
* Required for D1 pushes when `realtime` is omitted. If `realtime` is
|
|
89
|
+
* present its namespace is the coordinator automatically.
|
|
90
|
+
*/
|
|
91
|
+
readonly coordinator?: WorkersCoordinatorFactory<Env>;
|
|
85
92
|
/**
|
|
86
93
|
* Realtime (§8) over a Durable Object. Omit for an HTTP-only deployment
|
|
87
94
|
* (still fully conformant — clients sync over `POST /sync`).
|
|
@@ -130,6 +137,15 @@ export interface WorkersFetchHandlerOptions<Env = unknown> {
|
|
|
130
137
|
* Workers-correct posture — each invocation may run on a fresh isolate.
|
|
131
138
|
*/
|
|
132
139
|
export declare function createWorkersFetchHandler<Env = unknown>(factoryOrOptions: WorkersConfigFactory<Env> | WorkersFetchHandlerOptions<Env>): (request: Request, env: Env, ctx: ExecutionContextLike) => Promise<Response>;
|
|
140
|
+
/**
|
|
141
|
+
* Forward an authenticated HTTP sync round to the partition's Durable Object.
|
|
142
|
+
* Pulls and pushes share this path so client-record updates and push apply use
|
|
143
|
+
* one ordered partition boundary. Other HTTP routes remain direct.
|
|
144
|
+
*/
|
|
145
|
+
export declare function forwardSyncRequest(request: Request, namespace: DurableObjectNamespaceLike, identity: {
|
|
146
|
+
readonly partition: string;
|
|
147
|
+
readonly actorId: string;
|
|
148
|
+
}): Promise<Response>;
|
|
133
149
|
/**
|
|
134
150
|
* Forward a `/realtime` upgrade to the partition's DO stub. The identity is
|
|
135
151
|
* carried on internal headers to the DO's upgrade endpoint (the DO trusts the
|
|
@@ -138,13 +154,14 @@ export declare function createWorkersFetchHandler<Env = unknown>(factoryOrOption
|
|
|
138
154
|
*/
|
|
139
155
|
export declare function forwardRealtimeUpgrade(request: Request, namespace: DurableObjectNamespaceLike, identity: RealtimeUpgradeIdentity): Promise<Response>;
|
|
140
156
|
/**
|
|
141
|
-
* A `RealtimeNotifier` (§8.2)
|
|
142
|
-
*
|
|
157
|
+
* A `RealtimeNotifier` (§8.2) for an external authoritative command host that
|
|
158
|
+
* already serializes its D1 writes and must wake the partition's DO. The DO
|
|
143
159
|
* calls `hub.wake(partition, 'catchup-required')` and its sockets re-pull the
|
|
144
160
|
* delta from the shared D1 (§8.3) — the Workers in-platform analogue of the
|
|
145
161
|
* Postgres LISTEN/NOTIFY fan-out. A wake, not a byte re-broadcast.
|
|
146
162
|
*
|
|
147
|
-
*
|
|
163
|
+
* Ordinary Workers `/sync` does not need this: it already lands on the DO and
|
|
164
|
+
* fans out in-process. This wake is fire-and-forget:
|
|
148
165
|
* a DO fetch failure never fails the push (the commit is already durable in
|
|
149
166
|
* D1; the client's next pull or reconnect self-heals).
|
|
150
167
|
*/
|
package/dist/index.js
CHANGED
|
@@ -21,15 +21,13 @@
|
|
|
21
21
|
* `SyncularRealtimeDO` (`realtime-do.ts`): one DO per partition hosting the
|
|
22
22
|
* `RealtimeHub`, WebSocket hibernation driving the existing `RealtimeSession`,
|
|
23
23
|
* in-DO commit fan-out, storage over the same D1 binding. Pass a
|
|
24
|
-
* `realtime` option to
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* /sync`, identical semantics). No fallback is implied either way: HTTP-only
|
|
28
|
-
* is a smaller complete deployment, not a degraded one.
|
|
24
|
+
* `realtime` option to mount `/realtime`; its namespace also coordinates D1
|
|
25
|
+
* `/sync`. An HTTP-only D1 deployment uses the `coordinator` option instead:
|
|
26
|
+
* WebSockets are optional, the per-partition push queue is not.
|
|
29
27
|
*/
|
|
30
|
-
import { D1ServerStorage, } from '@syncular/server';
|
|
28
|
+
import { D1ServerStorage, errorBody, SSP2_CONTENT_TYPE, SyncError, } from '@syncular/server';
|
|
31
29
|
import { createSyncularHono, } from '@syncular/server-hono';
|
|
32
|
-
import { REALTIME_DO_UPGRADE_PATH, REALTIME_DO_WAKE_PATH, writeIdentityHeaders, } from './realtime-do.js';
|
|
30
|
+
import { REALTIME_DO_UPGRADE_PATH, REALTIME_DO_WAKE_PATH, SYNC_DO_REQUEST_PATH, writeIdentityHeaders, writeRequestIdentityHeaders, } from './realtime-do.js';
|
|
33
31
|
export { D1ServerStorage } from '@syncular/server';
|
|
34
32
|
export * from './realtime-do.js';
|
|
35
33
|
/**
|
|
@@ -79,17 +77,52 @@ export function createWorkersFetchHandler(factoryOrOptions) {
|
|
|
79
77
|
: factoryOrOptions;
|
|
80
78
|
return async (request, env, ctx) => {
|
|
81
79
|
// §8 upgrade: GET <mount>/realtime → forward to the partition's DO.
|
|
80
|
+
let realtime;
|
|
82
81
|
if (options.realtime !== undefined) {
|
|
83
|
-
|
|
82
|
+
realtime = await options.realtime(env, ctx);
|
|
84
83
|
const upgraded = await handleRealtimeUpgrade(request, realtime);
|
|
85
84
|
if (upgraded !== undefined)
|
|
86
85
|
return upgraded;
|
|
87
86
|
}
|
|
88
87
|
const honoOptions = await options.config(env, ctx);
|
|
88
|
+
const coordinator = options.coordinator !== undefined
|
|
89
|
+
? await options.coordinator(env, ctx)
|
|
90
|
+
: realtime;
|
|
91
|
+
if (coordinator !== undefined && isSyncPost(request)) {
|
|
92
|
+
const auth = await honoOptions.authenticate(request);
|
|
93
|
+
if (auth === null) {
|
|
94
|
+
const error = new SyncError('sync.auth_required');
|
|
95
|
+
return Response.json(errorBody(error), { status: error.httpStatus });
|
|
96
|
+
}
|
|
97
|
+
return forwardSyncRequest(request, coordinator.namespace, auth);
|
|
98
|
+
}
|
|
89
99
|
const app = createSyncularHono(honoOptions);
|
|
90
100
|
return app.fetch(request);
|
|
91
101
|
};
|
|
92
102
|
}
|
|
103
|
+
function isSyncPost(request) {
|
|
104
|
+
if (request.method !== 'POST')
|
|
105
|
+
return false;
|
|
106
|
+
const contentType = request.headers
|
|
107
|
+
.get('content-type')
|
|
108
|
+
?.split(';')[0]
|
|
109
|
+
?.trim();
|
|
110
|
+
if (contentType !== SSP2_CONTENT_TYPE)
|
|
111
|
+
return false;
|
|
112
|
+
const pathname = new URL(request.url).pathname;
|
|
113
|
+
return pathname === '/sync' || pathname.endsWith('/sync');
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Forward an authenticated HTTP sync round to the partition's Durable Object.
|
|
117
|
+
* Pulls and pushes share this path so client-record updates and push apply use
|
|
118
|
+
* one ordered partition boundary. Other HTTP routes remain direct.
|
|
119
|
+
*/
|
|
120
|
+
export function forwardSyncRequest(request, namespace, identity) {
|
|
121
|
+
const stub = namespace.get(namespace.idFromName(identity.partition));
|
|
122
|
+
const forwarded = new Request(new URL(SYNC_DO_REQUEST_PATH, request.url), request);
|
|
123
|
+
writeRequestIdentityHeaders(forwarded.headers, identity);
|
|
124
|
+
return stub.fetch(forwarded);
|
|
125
|
+
}
|
|
93
126
|
/**
|
|
94
127
|
* If `request` is the `GET <mount>/realtime` upgrade, authenticate it and
|
|
95
128
|
* forward it to the partition's DO stub; otherwise return `undefined` so the
|
|
@@ -124,13 +157,14 @@ export function forwardRealtimeUpgrade(request, namespace, identity) {
|
|
|
124
157
|
return stub.fetch(forwarded);
|
|
125
158
|
}
|
|
126
159
|
/**
|
|
127
|
-
* A `RealtimeNotifier` (§8.2)
|
|
128
|
-
*
|
|
160
|
+
* A `RealtimeNotifier` (§8.2) for an external authoritative command host that
|
|
161
|
+
* already serializes its D1 writes and must wake the partition's DO. The DO
|
|
129
162
|
* calls `hub.wake(partition, 'catchup-required')` and its sockets re-pull the
|
|
130
163
|
* delta from the shared D1 (§8.3) — the Workers in-platform analogue of the
|
|
131
164
|
* Postgres LISTEN/NOTIFY fan-out. A wake, not a byte re-broadcast.
|
|
132
165
|
*
|
|
133
|
-
*
|
|
166
|
+
* Ordinary Workers `/sync` does not need this: it already lands on the DO and
|
|
167
|
+
* fans out in-process. This wake is fire-and-forget:
|
|
134
168
|
* a DO fetch failure never fails the push (the commit is already durable in
|
|
135
169
|
* D1; the client's next pull or reconnect self-heals).
|
|
136
170
|
*/
|
package/dist/realtime-do.d.ts
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* One DO instance hosts **one `RealtimeHub`** and serves **one partition**
|
|
9
9
|
* (the DO id is `idFromName(partition)`, see `realtimeStubFor`). All of a
|
|
10
|
-
* partition's sockets and
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* partition's sync rounds, sockets, and commit fan-out live behind its
|
|
11
|
+
* explicit FIFO — the per-partition serialization point D1 requires. Because
|
|
12
|
+
* the hub is the
|
|
13
13
|
* `RealtimeNotifier` (§8.2) *inside* the DO, a sync round that lands over the
|
|
14
14
|
* socket fans its full delta out to the partition's other sockets with no
|
|
15
15
|
* LISTEN/NOTIFY — writes and sockets are co-located.
|
|
@@ -60,16 +60,12 @@
|
|
|
60
60
|
* fields `connect` needs. Everything else (`cursor`, `registrations`,
|
|
61
61
|
* `lastKnownSeq`) is re-derived from D1 by `connect`, which is authoritative.
|
|
62
62
|
*
|
|
63
|
-
* ## The wake path (
|
|
63
|
+
* ## The wake path (external-command fan-out)
|
|
64
64
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* 'catchup-required')` and its sockets re-pull the delta from the shared D1
|
|
70
|
-
* (§8.3). This is the Workers in-platform equivalent of Postgres LISTEN/NOTIFY
|
|
71
|
-
* — a wake, not a byte re-broadcast, so remote sessions pay one re-pull. See
|
|
72
|
-
* `durableObjectRealtimeNotifier` in `index.ts` for the caller side.
|
|
65
|
+
* Ordinary HTTP `/sync` is forwarded into this DO and fans out in-process. An
|
|
66
|
+
* external authoritative command host that already provides equivalent D1
|
|
67
|
+
* partition serialization may call `/__wake` after its own commit so sockets
|
|
68
|
+
* re-pull. See `durableObjectRealtimeNotifier` for that caller side.
|
|
73
69
|
*/
|
|
74
70
|
import { D1ServerStorage, type RealtimeHubConfig } from '@syncular/server';
|
|
75
71
|
export interface DurableObjectStateLike {
|
|
@@ -91,17 +87,24 @@ export type WebSocketPairLike = {
|
|
|
91
87
|
/**
|
|
92
88
|
* The host env a `SyncularRealtimeDO` reads. Supplied by the DO runtime via
|
|
93
89
|
* the class constructor's second arg. `DB` is the D1 binding (the same one the
|
|
94
|
-
*
|
|
90
|
+
* outer Worker config uses); `configFactory` builds the hub config from `env`.
|
|
95
91
|
*/
|
|
96
|
-
export
|
|
92
|
+
export type RealtimeDOConfig = {
|
|
97
93
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
|
|
94
|
+
* Preferred: build the complete canonical sync config around the DO's
|
|
95
|
+
* coordinated D1 storage. Reuse this factory for the outer HTTP adapter
|
|
96
|
+
* so HTTP-forwarded and socket rounds cannot drift by capability.
|
|
97
|
+
*/
|
|
98
|
+
syncConfig(storage: D1ServerStorage): RealtimeHubConfig;
|
|
99
|
+
readonly hubConfig?: never;
|
|
100
|
+
} | {
|
|
101
|
+
/**
|
|
102
|
+
* @deprecated Use `syncConfig`. This compatibility shape predates the
|
|
103
|
+
* canonical HTTP/realtime capability contract.
|
|
102
104
|
*/
|
|
103
105
|
hubConfig(storage: D1ServerStorage): RealtimeHubConfigInput;
|
|
104
|
-
|
|
106
|
+
readonly syncConfig?: never;
|
|
107
|
+
};
|
|
105
108
|
/**
|
|
106
109
|
* The subset of `RealtimeHubConfig` the DO host supplies (storage is wired by
|
|
107
110
|
* the DO from its D1 binding, so it is omitted here).
|
|
@@ -116,6 +119,7 @@ export interface RealtimeUpgradeIdentity {
|
|
|
116
119
|
/** Internal control-request paths on the DO stub (never client-facing). */
|
|
117
120
|
export declare const REALTIME_DO_WAKE_PATH = "/__syncular_realtime/wake";
|
|
118
121
|
export declare const REALTIME_DO_UPGRADE_PATH = "/__syncular_realtime/upgrade";
|
|
122
|
+
export declare const SYNC_DO_REQUEST_PATH = "/__syncular_realtime/sync";
|
|
119
123
|
/**
|
|
120
124
|
* The base `SyncularRealtimeDO`. A host subclasses (or instantiates) it with a
|
|
121
125
|
* `RealtimeDOConfig`. The class is platform-shaped: `state.acceptWebSocket` +
|
|
@@ -146,7 +150,7 @@ export declare class SyncularRealtimeHost {
|
|
|
146
150
|
* The DO `fetch` handler: routes the internal upgrade + wake control paths.
|
|
147
151
|
* The Worker forwards `GET <mount>/realtime` here as an upgrade with the
|
|
148
152
|
* resolved identity in headers (see `forwardRealtimeUpgrade` in `index.ts`),
|
|
149
|
-
* and
|
|
153
|
+
* and accepts external-command wakes at `/__syncular_realtime/wake`.
|
|
150
154
|
*/
|
|
151
155
|
fetch(request: Request): Promise<Response>;
|
|
152
156
|
/** Hibernation callback: an inbound frame. */
|
|
@@ -160,6 +164,10 @@ export declare class SyncularRealtimeHost {
|
|
|
160
164
|
}
|
|
161
165
|
/** Inject a `WebSocketPair` implementation (hermetic tests). */
|
|
162
166
|
export declare function setWebSocketPair(impl: (new () => WebSocketPairLike) | undefined): void;
|
|
167
|
+
export declare function writeRequestIdentityHeaders(headers: Headers, identity: {
|
|
168
|
+
readonly partition: string;
|
|
169
|
+
readonly actorId: string;
|
|
170
|
+
}): void;
|
|
163
171
|
/** Write the resolved identity onto an upgrade request's headers (Worker side). */
|
|
164
172
|
export declare function writeIdentityHeaders(headers: Headers, identity: RealtimeUpgradeIdentity): void;
|
|
165
173
|
/** The D1 binding, re-declared structurally (see `d1-storage.ts`). */
|
package/dist/realtime-do.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* One DO instance hosts **one `RealtimeHub`** and serves **one partition**
|
|
9
9
|
* (the DO id is `idFromName(partition)`, see `realtimeStubFor`). All of a
|
|
10
|
-
* partition's sockets and
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* partition's sync rounds, sockets, and commit fan-out live behind its
|
|
11
|
+
* explicit FIFO — the per-partition serialization point D1 requires. Because
|
|
12
|
+
* the hub is the
|
|
13
13
|
* `RealtimeNotifier` (§8.2) *inside* the DO, a sync round that lands over the
|
|
14
14
|
* socket fans its full delta out to the partition's other sockets with no
|
|
15
15
|
* LISTEN/NOTIFY — writes and sockets are co-located.
|
|
@@ -60,18 +60,14 @@
|
|
|
60
60
|
* fields `connect` needs. Everything else (`cursor`, `registrations`,
|
|
61
61
|
* `lastKnownSeq`) is re-derived from D1 by `connect`, which is authoritative.
|
|
62
62
|
*
|
|
63
|
-
* ## The wake path (
|
|
63
|
+
* ## The wake path (external-command fan-out)
|
|
64
64
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* 'catchup-required')` and its sockets re-pull the delta from the shared D1
|
|
70
|
-
* (§8.3). This is the Workers in-platform equivalent of Postgres LISTEN/NOTIFY
|
|
71
|
-
* — a wake, not a byte re-broadcast, so remote sessions pay one re-pull. See
|
|
72
|
-
* `durableObjectRealtimeNotifier` in `index.ts` for the caller side.
|
|
65
|
+
* Ordinary HTTP `/sync` is forwarded into this DO and fans out in-process. An
|
|
66
|
+
* external authoritative command host that already provides equivalent D1
|
|
67
|
+
* partition serialization may call `/__wake` after its own commit so sockets
|
|
68
|
+
* re-pull. See `durableObjectRealtimeNotifier` for that caller side.
|
|
73
69
|
*/
|
|
74
|
-
import { createRealtimeHub, D1ServerStorage, } from '@syncular/server';
|
|
70
|
+
import { createRealtimeHub, D1ServerStorage, errorBody, handleSyncRequest, SSP2_CONTENT_TYPE, SyncError, } from '@syncular/server';
|
|
75
71
|
function isAttachment(value) {
|
|
76
72
|
return (typeof value === 'object' &&
|
|
77
73
|
value !== null &&
|
|
@@ -82,6 +78,7 @@ function isAttachment(value) {
|
|
|
82
78
|
/** Internal control-request paths on the DO stub (never client-facing). */
|
|
83
79
|
export const REALTIME_DO_WAKE_PATH = '/__syncular_realtime/wake';
|
|
84
80
|
export const REALTIME_DO_UPGRADE_PATH = '/__syncular_realtime/upgrade';
|
|
81
|
+
export const SYNC_DO_REQUEST_PATH = '/__syncular_realtime/sync';
|
|
85
82
|
/**
|
|
86
83
|
* The base `SyncularRealtimeDO`. A host subclasses (or instantiates) it with a
|
|
87
84
|
* `RealtimeDOConfig`. The class is platform-shaped: `state.acceptWebSocket` +
|
|
@@ -116,21 +113,29 @@ export class SyncularRealtimeHost {
|
|
|
116
113
|
/** Sockets whose rehydration `hello` must be swallowed (already greeted at
|
|
117
114
|
* the real upgrade — rehydration is transparent to the client). */
|
|
118
115
|
#swallowHello = new Set();
|
|
116
|
+
/** Explicit FIFO: Durable Object events may interleave at `await`. */
|
|
117
|
+
#partitionTail = Promise.resolve();
|
|
119
118
|
constructor(state, db, config) {
|
|
120
119
|
this.#state = state;
|
|
121
120
|
this.#storage = new D1ServerStorage(db, {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
121
|
+
// Every HTTP/socket sync round enters #serializePartition before this
|
|
122
|
+
// storage is used. One DO is selected per partition.
|
|
123
|
+
pushApplySerialized: true,
|
|
125
124
|
});
|
|
126
125
|
this.#config = config;
|
|
127
126
|
}
|
|
128
127
|
#getHub() {
|
|
129
128
|
if (this.#hub === undefined) {
|
|
130
|
-
this.#
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
const config = this.#config.syncConfig !== undefined
|
|
130
|
+
? this.#config.syncConfig(this.#storage)
|
|
131
|
+
: {
|
|
132
|
+
...this.#config.hubConfig(this.#storage),
|
|
133
|
+
storage: this.#storage,
|
|
134
|
+
};
|
|
135
|
+
if (config.storage !== this.#storage) {
|
|
136
|
+
throw new Error('RealtimeDOConfig.syncConfig must use the coordinated storage argument');
|
|
137
|
+
}
|
|
138
|
+
this.#hub = createRealtimeHub(config);
|
|
134
139
|
}
|
|
135
140
|
return this.#hub;
|
|
136
141
|
}
|
|
@@ -138,10 +143,13 @@ export class SyncularRealtimeHost {
|
|
|
138
143
|
* The DO `fetch` handler: routes the internal upgrade + wake control paths.
|
|
139
144
|
* The Worker forwards `GET <mount>/realtime` here as an upgrade with the
|
|
140
145
|
* resolved identity in headers (see `forwardRealtimeUpgrade` in `index.ts`),
|
|
141
|
-
* and
|
|
146
|
+
* and accepts external-command wakes at `/__syncular_realtime/wake`.
|
|
142
147
|
*/
|
|
143
148
|
async fetch(request) {
|
|
144
149
|
const url = new URL(request.url);
|
|
150
|
+
if (url.pathname === SYNC_DO_REQUEST_PATH) {
|
|
151
|
+
return this.#serializePartition(() => this.#handleSync(request));
|
|
152
|
+
}
|
|
145
153
|
if (url.pathname === REALTIME_DO_WAKE_PATH) {
|
|
146
154
|
return this.#handleWake(request);
|
|
147
155
|
}
|
|
@@ -150,7 +158,41 @@ export class SyncularRealtimeHost {
|
|
|
150
158
|
}
|
|
151
159
|
return new Response('not found', { status: 404 });
|
|
152
160
|
}
|
|
153
|
-
|
|
161
|
+
#serializePartition(operation) {
|
|
162
|
+
const result = this.#partitionTail.then(operation, operation);
|
|
163
|
+
this.#partitionTail = result.then(() => undefined, () => undefined);
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
async #handleSync(request) {
|
|
167
|
+
const contentType = request.headers
|
|
168
|
+
.get('content-type')
|
|
169
|
+
?.split(';')[0]
|
|
170
|
+
?.trim();
|
|
171
|
+
if (contentType !== SSP2_CONTENT_TYPE) {
|
|
172
|
+
const error = new SyncError('sync.invalid_request', 'unsupported content type');
|
|
173
|
+
return Response.json(errorBody(error), { status: 415 });
|
|
174
|
+
}
|
|
175
|
+
const identity = readRequestIdentityHeaders(request);
|
|
176
|
+
if (identity === undefined) {
|
|
177
|
+
const error = new SyncError('sync.auth_required');
|
|
178
|
+
return Response.json(errorBody(error), { status: error.httpStatus });
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
182
|
+
const out = await handleSyncRequest(bytes, this.#getHub().requestContextFor(identity));
|
|
183
|
+
return new Response(out.slice().buffer, {
|
|
184
|
+
status: 200,
|
|
185
|
+
headers: { 'content-type': SSP2_CONTENT_TYPE },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
const sync = error instanceof SyncError
|
|
190
|
+
? error
|
|
191
|
+
: new SyncError('sync.invalid_request', String(error));
|
|
192
|
+
return Response.json(errorBody(sync), { status: sync.httpStatus });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** §8.3 wake: an external coordinated command landed; re-pull the delta. */
|
|
154
196
|
async #handleWake(request) {
|
|
155
197
|
const body = (await request.json().catch(() => null));
|
|
156
198
|
const partition = body?.partition;
|
|
@@ -235,16 +277,18 @@ export class SyncularRealtimeHost {
|
|
|
235
277
|
}
|
|
236
278
|
/** Hibernation callback: an inbound frame. */
|
|
237
279
|
async webSocketMessage(ws, message) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
280
|
+
await this.#serializePartition(async () => {
|
|
281
|
+
const session = await this.#sessionFor(ws);
|
|
282
|
+
if (session === undefined)
|
|
283
|
+
return;
|
|
284
|
+
if (typeof message === 'string') {
|
|
285
|
+
await session.handleMessage(message);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
// §8.7: tagged binary — sync-round request chunks / acks.
|
|
289
|
+
await session.handleBinary(new Uint8Array(message));
|
|
290
|
+
}
|
|
291
|
+
});
|
|
248
292
|
}
|
|
249
293
|
/** Hibernation callback: the socket closed. */
|
|
250
294
|
async webSocketClose(ws) {
|
|
@@ -316,6 +360,10 @@ const REALTIME_ID_HEADER = {
|
|
|
316
360
|
actorId: 'x-syncular-actor',
|
|
317
361
|
clientId: 'x-syncular-client',
|
|
318
362
|
};
|
|
363
|
+
export function writeRequestIdentityHeaders(headers, identity) {
|
|
364
|
+
headers.set(REALTIME_ID_HEADER.partition, identity.partition);
|
|
365
|
+
headers.set(REALTIME_ID_HEADER.actorId, identity.actorId);
|
|
366
|
+
}
|
|
319
367
|
/** Write the resolved identity onto an upgrade request's headers (Worker side). */
|
|
320
368
|
export function writeIdentityHeaders(headers, identity) {
|
|
321
369
|
headers.set(REALTIME_ID_HEADER.partition, identity.partition);
|
|
@@ -335,3 +383,14 @@ function readIdentityHeaders(request) {
|
|
|
335
383
|
}
|
|
336
384
|
return { partition, actorId, clientId };
|
|
337
385
|
}
|
|
386
|
+
function readRequestIdentityHeaders(request) {
|
|
387
|
+
const partition = request.headers.get(REALTIME_ID_HEADER.partition);
|
|
388
|
+
const actorId = request.headers.get(REALTIME_ID_HEADER.actorId);
|
|
389
|
+
if (partition === null ||
|
|
390
|
+
partition === '' ||
|
|
391
|
+
actorId === null ||
|
|
392
|
+
actorId === '') {
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
return { partition, actorId };
|
|
396
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server-workers",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.24",
|
|
4
4
|
"description": "Cloudflare Workers adapter for the Syncular sync server",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -45,11 +45,11 @@
|
|
|
45
45
|
"!dist/**/*.test.d.ts"
|
|
46
46
|
],
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@syncular/server": "0.15.
|
|
49
|
-
"@syncular/server-hono": "0.15.
|
|
48
|
+
"@syncular/server": "0.15.24",
|
|
49
|
+
"@syncular/server-hono": "0.15.24",
|
|
50
50
|
"hono": "^4.11.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@syncular/core": "0.15.
|
|
53
|
+
"@syncular/core": "0.15.24"
|
|
54
54
|
}
|
|
55
55
|
}
|
package/src/index.ts
CHANGED
|
@@ -21,16 +21,17 @@
|
|
|
21
21
|
* `SyncularRealtimeDO` (`realtime-do.ts`): one DO per partition hosting the
|
|
22
22
|
* `RealtimeHub`, WebSocket hibernation driving the existing `RealtimeSession`,
|
|
23
23
|
* in-DO commit fan-out, storage over the same D1 binding. Pass a
|
|
24
|
-
* `realtime` option to
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* /sync`, identical semantics). No fallback is implied either way: HTTP-only
|
|
28
|
-
* is a smaller complete deployment, not a degraded one.
|
|
24
|
+
* `realtime` option to mount `/realtime`; its namespace also coordinates D1
|
|
25
|
+
* `/sync`. An HTTP-only D1 deployment uses the `coordinator` option instead:
|
|
26
|
+
* WebSockets are optional, the per-partition push queue is not.
|
|
29
27
|
*/
|
|
30
28
|
import {
|
|
31
29
|
type D1Database,
|
|
32
30
|
D1ServerStorage,
|
|
31
|
+
errorBody,
|
|
32
|
+
SSP2_CONTENT_TYPE,
|
|
33
33
|
type StoredCommit,
|
|
34
|
+
SyncError,
|
|
34
35
|
type SyncServerConfig,
|
|
35
36
|
} from '@syncular/server';
|
|
36
37
|
import {
|
|
@@ -41,7 +42,9 @@ import {
|
|
|
41
42
|
REALTIME_DO_UPGRADE_PATH,
|
|
42
43
|
REALTIME_DO_WAKE_PATH,
|
|
43
44
|
type RealtimeUpgradeIdentity,
|
|
45
|
+
SYNC_DO_REQUEST_PATH,
|
|
44
46
|
writeIdentityHeaders,
|
|
47
|
+
writeRequestIdentityHeaders,
|
|
45
48
|
} from './realtime-do';
|
|
46
49
|
|
|
47
50
|
export { type D1Database, D1ServerStorage } from '@syncular/server';
|
|
@@ -84,10 +87,8 @@ export interface DurableObjectIdLike {
|
|
|
84
87
|
|
|
85
88
|
/**
|
|
86
89
|
* Realtime wiring for `createWorkersFetchHandler`. Supplying it mounts the
|
|
87
|
-
* `GET <mount>/realtime` upgrade route and the
|
|
88
|
-
* `
|
|
89
|
-
* host spreads into its `SyncServerConfig.realtime` so a push landing in the
|
|
90
|
-
* plain isolate wakes the partition's DO).
|
|
90
|
+
* `GET <mount>/realtime` upgrade route and uses the same namespace as the D1
|
|
91
|
+
* `/sync` coordinator.
|
|
91
92
|
*/
|
|
92
93
|
export interface WorkersRealtimeOptions {
|
|
93
94
|
/** The DO namespace binding (wrangler `[[durable_objects.bindings]]`). */
|
|
@@ -114,9 +115,25 @@ export type WorkersRealtimeFactory<Env = unknown> = (
|
|
|
114
115
|
ctx: ExecutionContextLike,
|
|
115
116
|
) => WorkersRealtimeOptions | Promise<WorkersRealtimeOptions>;
|
|
116
117
|
|
|
118
|
+
/** Per-partition Durable Object boundary for D1 sync rounds without WS. */
|
|
119
|
+
export interface WorkersCoordinatorOptions {
|
|
120
|
+
readonly namespace: DurableObjectNamespaceLike;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type WorkersCoordinatorFactory<Env = unknown> = (
|
|
124
|
+
env: Env,
|
|
125
|
+
ctx: ExecutionContextLike,
|
|
126
|
+
) => WorkersCoordinatorOptions | Promise<WorkersCoordinatorOptions>;
|
|
127
|
+
|
|
117
128
|
export interface WorkersFetchHandlerOptions<Env = unknown> {
|
|
118
129
|
/** Build the HTTP handler config + auth per request (see the type doc). */
|
|
119
130
|
readonly config: WorkersConfigFactory<Env>;
|
|
131
|
+
/**
|
|
132
|
+
* Serialize D1 `/sync` rounds through one Durable Object per partition.
|
|
133
|
+
* Required for D1 pushes when `realtime` is omitted. If `realtime` is
|
|
134
|
+
* present its namespace is the coordinator automatically.
|
|
135
|
+
*/
|
|
136
|
+
readonly coordinator?: WorkersCoordinatorFactory<Env>;
|
|
120
137
|
/**
|
|
121
138
|
* Realtime (§8) over a Durable Object. Omit for an HTTP-only deployment
|
|
122
139
|
* (still fully conformant — clients sync over `POST /sync`).
|
|
@@ -178,17 +195,60 @@ export function createWorkersFetchHandler<Env = unknown>(
|
|
|
178
195
|
: factoryOrOptions;
|
|
179
196
|
return async (request, env, ctx) => {
|
|
180
197
|
// §8 upgrade: GET <mount>/realtime → forward to the partition's DO.
|
|
198
|
+
let realtime: WorkersRealtimeOptions | undefined;
|
|
181
199
|
if (options.realtime !== undefined) {
|
|
182
|
-
|
|
200
|
+
realtime = await options.realtime(env, ctx);
|
|
183
201
|
const upgraded = await handleRealtimeUpgrade(request, realtime);
|
|
184
202
|
if (upgraded !== undefined) return upgraded;
|
|
185
203
|
}
|
|
186
204
|
const honoOptions = await options.config(env, ctx);
|
|
205
|
+
const coordinator =
|
|
206
|
+
options.coordinator !== undefined
|
|
207
|
+
? await options.coordinator(env, ctx)
|
|
208
|
+
: realtime;
|
|
209
|
+
if (coordinator !== undefined && isSyncPost(request)) {
|
|
210
|
+
const auth = await honoOptions.authenticate(request);
|
|
211
|
+
if (auth === null) {
|
|
212
|
+
const error = new SyncError('sync.auth_required');
|
|
213
|
+
return Response.json(errorBody(error), { status: error.httpStatus });
|
|
214
|
+
}
|
|
215
|
+
return forwardSyncRequest(request, coordinator.namespace, auth);
|
|
216
|
+
}
|
|
187
217
|
const app = createSyncularHono(honoOptions);
|
|
188
218
|
return app.fetch(request);
|
|
189
219
|
};
|
|
190
220
|
}
|
|
191
221
|
|
|
222
|
+
function isSyncPost(request: Request): boolean {
|
|
223
|
+
if (request.method !== 'POST') return false;
|
|
224
|
+
const contentType = request.headers
|
|
225
|
+
.get('content-type')
|
|
226
|
+
?.split(';')[0]
|
|
227
|
+
?.trim();
|
|
228
|
+
if (contentType !== SSP2_CONTENT_TYPE) return false;
|
|
229
|
+
const pathname = new URL(request.url).pathname;
|
|
230
|
+
return pathname === '/sync' || pathname.endsWith('/sync');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Forward an authenticated HTTP sync round to the partition's Durable Object.
|
|
235
|
+
* Pulls and pushes share this path so client-record updates and push apply use
|
|
236
|
+
* one ordered partition boundary. Other HTTP routes remain direct.
|
|
237
|
+
*/
|
|
238
|
+
export function forwardSyncRequest(
|
|
239
|
+
request: Request,
|
|
240
|
+
namespace: DurableObjectNamespaceLike,
|
|
241
|
+
identity: { readonly partition: string; readonly actorId: string },
|
|
242
|
+
): Promise<Response> {
|
|
243
|
+
const stub = namespace.get(namespace.idFromName(identity.partition));
|
|
244
|
+
const forwarded = new Request(
|
|
245
|
+
new URL(SYNC_DO_REQUEST_PATH, request.url),
|
|
246
|
+
request,
|
|
247
|
+
);
|
|
248
|
+
writeRequestIdentityHeaders(forwarded.headers, identity);
|
|
249
|
+
return stub.fetch(forwarded);
|
|
250
|
+
}
|
|
251
|
+
|
|
192
252
|
/**
|
|
193
253
|
* If `request` is the `GET <mount>/realtime` upgrade, authenticate it and
|
|
194
254
|
* forward it to the partition's DO stub; otherwise return `undefined` so the
|
|
@@ -233,13 +293,14 @@ export function forwardRealtimeUpgrade(
|
|
|
233
293
|
}
|
|
234
294
|
|
|
235
295
|
/**
|
|
236
|
-
* A `RealtimeNotifier` (§8.2)
|
|
237
|
-
*
|
|
296
|
+
* A `RealtimeNotifier` (§8.2) for an external authoritative command host that
|
|
297
|
+
* already serializes its D1 writes and must wake the partition's DO. The DO
|
|
238
298
|
* calls `hub.wake(partition, 'catchup-required')` and its sockets re-pull the
|
|
239
299
|
* delta from the shared D1 (§8.3) — the Workers in-platform analogue of the
|
|
240
300
|
* Postgres LISTEN/NOTIFY fan-out. A wake, not a byte re-broadcast.
|
|
241
301
|
*
|
|
242
|
-
*
|
|
302
|
+
* Ordinary Workers `/sync` does not need this: it already lands on the DO and
|
|
303
|
+
* fans out in-process. This wake is fire-and-forget:
|
|
243
304
|
* a DO fetch failure never fails the push (the commit is already durable in
|
|
244
305
|
* D1; the client's next pull or reconnect self-heals).
|
|
245
306
|
*/
|
package/src/realtime-do.ts
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* One DO instance hosts **one `RealtimeHub`** and serves **one partition**
|
|
9
9
|
* (the DO id is `idFromName(partition)`, see `realtimeStubFor`). All of a
|
|
10
|
-
* partition's sockets and
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* partition's sync rounds, sockets, and commit fan-out live behind its
|
|
11
|
+
* explicit FIFO — the per-partition serialization point D1 requires. Because
|
|
12
|
+
* the hub is the
|
|
13
13
|
* `RealtimeNotifier` (§8.2) *inside* the DO, a sync round that lands over the
|
|
14
14
|
* socket fans its full delta out to the partition's other sockets with no
|
|
15
15
|
* LISTEN/NOTIFY — writes and sockets are co-located.
|
|
@@ -60,23 +60,23 @@
|
|
|
60
60
|
* fields `connect` needs. Everything else (`cursor`, `registrations`,
|
|
61
61
|
* `lastKnownSeq`) is re-derived from D1 by `connect`, which is authoritative.
|
|
62
62
|
*
|
|
63
|
-
* ## The wake path (
|
|
63
|
+
* ## The wake path (external-command fan-out)
|
|
64
64
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* 'catchup-required')` and its sockets re-pull the delta from the shared D1
|
|
70
|
-
* (§8.3). This is the Workers in-platform equivalent of Postgres LISTEN/NOTIFY
|
|
71
|
-
* — a wake, not a byte re-broadcast, so remote sessions pay one re-pull. See
|
|
72
|
-
* `durableObjectRealtimeNotifier` in `index.ts` for the caller side.
|
|
65
|
+
* Ordinary HTTP `/sync` is forwarded into this DO and fans out in-process. An
|
|
66
|
+
* external authoritative command host that already provides equivalent D1
|
|
67
|
+
* partition serialization may call `/__wake` after its own commit so sockets
|
|
68
|
+
* re-pull. See `durableObjectRealtimeNotifier` for that caller side.
|
|
73
69
|
*/
|
|
74
70
|
import {
|
|
75
71
|
createRealtimeHub,
|
|
76
72
|
D1ServerStorage,
|
|
73
|
+
errorBody,
|
|
74
|
+
handleSyncRequest,
|
|
77
75
|
type RealtimeHub,
|
|
78
76
|
type RealtimeHubConfig,
|
|
79
77
|
type RealtimeSession,
|
|
78
|
+
SSP2_CONTENT_TYPE,
|
|
79
|
+
SyncError,
|
|
80
80
|
} from '@syncular/server';
|
|
81
81
|
|
|
82
82
|
// -- The Durable Object platform surface this class uses (structural) -------
|
|
@@ -103,17 +103,26 @@ export type WebSocketPairLike = { 0: WebSocketLike; 1: WebSocketLike };
|
|
|
103
103
|
/**
|
|
104
104
|
* The host env a `SyncularRealtimeDO` reads. Supplied by the DO runtime via
|
|
105
105
|
* the class constructor's second arg. `DB` is the D1 binding (the same one the
|
|
106
|
-
*
|
|
106
|
+
* outer Worker config uses); `configFactory` builds the hub config from `env`.
|
|
107
107
|
*/
|
|
108
|
-
export
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
108
|
+
export type RealtimeDOConfig =
|
|
109
|
+
| {
|
|
110
|
+
/**
|
|
111
|
+
* Preferred: build the complete canonical sync config around the DO's
|
|
112
|
+
* coordinated D1 storage. Reuse this factory for the outer HTTP adapter
|
|
113
|
+
* so HTTP-forwarded and socket rounds cannot drift by capability.
|
|
114
|
+
*/
|
|
115
|
+
syncConfig(storage: D1ServerStorage): RealtimeHubConfig;
|
|
116
|
+
readonly hubConfig?: never;
|
|
117
|
+
}
|
|
118
|
+
| {
|
|
119
|
+
/**
|
|
120
|
+
* @deprecated Use `syncConfig`. This compatibility shape predates the
|
|
121
|
+
* canonical HTTP/realtime capability contract.
|
|
122
|
+
*/
|
|
123
|
+
hubConfig(storage: D1ServerStorage): RealtimeHubConfigInput;
|
|
124
|
+
readonly syncConfig?: never;
|
|
125
|
+
};
|
|
117
126
|
|
|
118
127
|
/**
|
|
119
128
|
* The subset of `RealtimeHubConfig` the DO host supplies (storage is wired by
|
|
@@ -148,6 +157,7 @@ export interface RealtimeUpgradeIdentity {
|
|
|
148
157
|
/** Internal control-request paths on the DO stub (never client-facing). */
|
|
149
158
|
export const REALTIME_DO_WAKE_PATH = '/__syncular_realtime/wake';
|
|
150
159
|
export const REALTIME_DO_UPGRADE_PATH = '/__syncular_realtime/upgrade';
|
|
160
|
+
export const SYNC_DO_REQUEST_PATH = '/__syncular_realtime/sync';
|
|
151
161
|
|
|
152
162
|
/**
|
|
153
163
|
* The base `SyncularRealtimeDO`. A host subclasses (or instantiates) it with a
|
|
@@ -183,6 +193,8 @@ export class SyncularRealtimeHost {
|
|
|
183
193
|
/** Sockets whose rehydration `hello` must be swallowed (already greeted at
|
|
184
194
|
* the real upgrade — rehydration is transparent to the client). */
|
|
185
195
|
readonly #swallowHello = new Set<WebSocketLike>();
|
|
196
|
+
/** Explicit FIFO: Durable Object events may interleave at `await`. */
|
|
197
|
+
#partitionTail: Promise<void> = Promise.resolve();
|
|
186
198
|
|
|
187
199
|
constructor(
|
|
188
200
|
state: DurableObjectStateLike,
|
|
@@ -191,19 +203,28 @@ export class SyncularRealtimeHost {
|
|
|
191
203
|
) {
|
|
192
204
|
this.#state = state;
|
|
193
205
|
this.#storage = new D1ServerStorage(db, {
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
206
|
+
// Every HTTP/socket sync round enters #serializePartition before this
|
|
207
|
+
// storage is used. One DO is selected per partition.
|
|
208
|
+
pushApplySerialized: true,
|
|
197
209
|
});
|
|
198
210
|
this.#config = config;
|
|
199
211
|
}
|
|
200
212
|
|
|
201
213
|
#getHub(): RealtimeHub {
|
|
202
214
|
if (this.#hub === undefined) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
215
|
+
const config =
|
|
216
|
+
this.#config.syncConfig !== undefined
|
|
217
|
+
? this.#config.syncConfig(this.#storage)
|
|
218
|
+
: {
|
|
219
|
+
...this.#config.hubConfig(this.#storage),
|
|
220
|
+
storage: this.#storage,
|
|
221
|
+
};
|
|
222
|
+
if (config.storage !== this.#storage) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
'RealtimeDOConfig.syncConfig must use the coordinated storage argument',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
this.#hub = createRealtimeHub(config);
|
|
207
228
|
}
|
|
208
229
|
return this.#hub;
|
|
209
230
|
}
|
|
@@ -212,10 +233,13 @@ export class SyncularRealtimeHost {
|
|
|
212
233
|
* The DO `fetch` handler: routes the internal upgrade + wake control paths.
|
|
213
234
|
* The Worker forwards `GET <mount>/realtime` here as an upgrade with the
|
|
214
235
|
* resolved identity in headers (see `forwardRealtimeUpgrade` in `index.ts`),
|
|
215
|
-
* and
|
|
236
|
+
* and accepts external-command wakes at `/__syncular_realtime/wake`.
|
|
216
237
|
*/
|
|
217
238
|
async fetch(request: Request): Promise<Response> {
|
|
218
239
|
const url = new URL(request.url);
|
|
240
|
+
if (url.pathname === SYNC_DO_REQUEST_PATH) {
|
|
241
|
+
return this.#serializePartition(() => this.#handleSync(request));
|
|
242
|
+
}
|
|
219
243
|
if (url.pathname === REALTIME_DO_WAKE_PATH) {
|
|
220
244
|
return this.#handleWake(request);
|
|
221
245
|
}
|
|
@@ -225,7 +249,52 @@ export class SyncularRealtimeHost {
|
|
|
225
249
|
return new Response('not found', { status: 404 });
|
|
226
250
|
}
|
|
227
251
|
|
|
228
|
-
|
|
252
|
+
#serializePartition<T>(operation: () => Promise<T>): Promise<T> {
|
|
253
|
+
const result = this.#partitionTail.then(operation, operation);
|
|
254
|
+
this.#partitionTail = result.then(
|
|
255
|
+
() => undefined,
|
|
256
|
+
() => undefined,
|
|
257
|
+
);
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async #handleSync(request: Request): Promise<Response> {
|
|
262
|
+
const contentType = request.headers
|
|
263
|
+
.get('content-type')
|
|
264
|
+
?.split(';')[0]
|
|
265
|
+
?.trim();
|
|
266
|
+
if (contentType !== SSP2_CONTENT_TYPE) {
|
|
267
|
+
const error = new SyncError(
|
|
268
|
+
'sync.invalid_request',
|
|
269
|
+
'unsupported content type',
|
|
270
|
+
);
|
|
271
|
+
return Response.json(errorBody(error), { status: 415 });
|
|
272
|
+
}
|
|
273
|
+
const identity = readRequestIdentityHeaders(request);
|
|
274
|
+
if (identity === undefined) {
|
|
275
|
+
const error = new SyncError('sync.auth_required');
|
|
276
|
+
return Response.json(errorBody(error), { status: error.httpStatus });
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
280
|
+
const out = await handleSyncRequest(
|
|
281
|
+
bytes,
|
|
282
|
+
this.#getHub().requestContextFor(identity),
|
|
283
|
+
);
|
|
284
|
+
return new Response(out.slice().buffer as ArrayBuffer, {
|
|
285
|
+
status: 200,
|
|
286
|
+
headers: { 'content-type': SSP2_CONTENT_TYPE },
|
|
287
|
+
});
|
|
288
|
+
} catch (error) {
|
|
289
|
+
const sync =
|
|
290
|
+
error instanceof SyncError
|
|
291
|
+
? error
|
|
292
|
+
: new SyncError('sync.invalid_request', String(error));
|
|
293
|
+
return Response.json(errorBody(sync), { status: sync.httpStatus });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** §8.3 wake: an external coordinated command landed; re-pull the delta. */
|
|
229
298
|
async #handleWake(request: Request): Promise<Response> {
|
|
230
299
|
const body = (await request.json().catch(() => null)) as {
|
|
231
300
|
partition?: unknown;
|
|
@@ -321,14 +390,16 @@ export class SyncularRealtimeHost {
|
|
|
321
390
|
ws: WebSocketLike,
|
|
322
391
|
message: ArrayBuffer | string,
|
|
323
392
|
): Promise<void> {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
393
|
+
await this.#serializePartition(async () => {
|
|
394
|
+
const session = await this.#sessionFor(ws);
|
|
395
|
+
if (session === undefined) return;
|
|
396
|
+
if (typeof message === 'string') {
|
|
397
|
+
await session.handleMessage(message);
|
|
398
|
+
} else {
|
|
399
|
+
// §8.7: tagged binary — sync-round request chunks / acks.
|
|
400
|
+
await session.handleBinary(new Uint8Array(message));
|
|
401
|
+
}
|
|
402
|
+
});
|
|
332
403
|
}
|
|
333
404
|
|
|
334
405
|
/** Hibernation callback: the socket closed. */
|
|
@@ -414,6 +485,14 @@ const REALTIME_ID_HEADER = {
|
|
|
414
485
|
clientId: 'x-syncular-client',
|
|
415
486
|
} as const;
|
|
416
487
|
|
|
488
|
+
export function writeRequestIdentityHeaders(
|
|
489
|
+
headers: Headers,
|
|
490
|
+
identity: { readonly partition: string; readonly actorId: string },
|
|
491
|
+
): void {
|
|
492
|
+
headers.set(REALTIME_ID_HEADER.partition, identity.partition);
|
|
493
|
+
headers.set(REALTIME_ID_HEADER.actorId, identity.actorId);
|
|
494
|
+
}
|
|
495
|
+
|
|
417
496
|
/** Write the resolved identity onto an upgrade request's headers (Worker side). */
|
|
418
497
|
export function writeIdentityHeaders(
|
|
419
498
|
headers: Headers,
|
|
@@ -442,6 +521,22 @@ function readIdentityHeaders(
|
|
|
442
521
|
return { partition, actorId, clientId };
|
|
443
522
|
}
|
|
444
523
|
|
|
524
|
+
function readRequestIdentityHeaders(
|
|
525
|
+
request: Request,
|
|
526
|
+
): { readonly partition: string; readonly actorId: string } | undefined {
|
|
527
|
+
const partition = request.headers.get(REALTIME_ID_HEADER.partition);
|
|
528
|
+
const actorId = request.headers.get(REALTIME_ID_HEADER.actorId);
|
|
529
|
+
if (
|
|
530
|
+
partition === null ||
|
|
531
|
+
partition === '' ||
|
|
532
|
+
actorId === null ||
|
|
533
|
+
actorId === ''
|
|
534
|
+
) {
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
return { partition, actorId };
|
|
538
|
+
}
|
|
539
|
+
|
|
445
540
|
// -- Minimal ambient types (structural; not the real workers-types) ---------
|
|
446
541
|
|
|
447
542
|
/** The D1 binding, re-declared structurally (see `d1-storage.ts`). */
|