@syncular/server-workers 0.15.23 → 0.15.25

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 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
- **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.
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). No `realtime`:
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>((env) => ({
86
- config: syncConfig(env),
87
- authenticate: (request) => authenticate(request, env),
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.** 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
- **Whole-commit validation fails closed without that coordinator.** SPEC §6.8
140
- must hold serialization from before candidate reads through commit. A plain
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 single-threaded DO** — which is also the
161
- per-partition write-serialization point the D1 storage wants (see "Concurrency
162
- posture"). Because the hub is the `RealtimeNotifier` (§8.2) *inside* the DO, a
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
- ### The wake path (HTTP push DO)
197
+ ### HTTP sync and fanout
202
198
 
203
- A push landing via the *plain* HTTP handler (a stateless isolate with no
204
- sockets) wakes the partition's DO. Wire `durableObjectRealtimeNotifier(env.
205
- REALTIME)` into `SyncServerConfig.realtime`; after a commit lands it
206
- `stub.fetch`es the DO's internal wake path, and the DO calls `hub.wake(
207
- partition, 'catchup-required')` its sockets re-pull the delta from the shared
208
- D1 (§8.3). This is the Workers in-platform equivalent of Postgres LISTEN/NOTIFY:
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 { MemorySegmentStore } from '@syncular/server';
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 realtimeDOConfig = (env: Env): RealtimeDOConfig => ({
238
- hubConfig: () => ({
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
- schema,
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
- // The realtime-channel auth seam (analogue of `authenticate`): resolve
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 wake fan-out, and presence. Because the DO is a *deployment adapter*
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 `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.
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 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).
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) that wakes the partition's DO after a push lands
142
- * via the plain HTTP handler (a stateless isolate with no sockets). The DO
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
- * Spread this into `SyncServerConfig.realtime`. The wake is fire-and-forget:
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 `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.
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
- const realtime = await options.realtime(env, ctx);
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) that wakes the partition's DO after a push lands
128
- * via the plain HTTP handler (a stateless isolate with no sockets). The DO
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
- * Spread this into `SyncServerConfig.realtime`. The wake is fire-and-forget:
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
  */
@@ -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 its commit fan-out live in that single-threaded DO —
11
- * which is also the per-partition write-serialization point the D1 storage
12
- * wants (see `d1-storage.ts` "Concurrency posture"). Because the hub is the
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 (HTTP-push fan-out, the LISTEN/NOTIFY analogue)
63
+ * ## The wake path (external-command fan-out)
64
64
  *
65
- * A push landing via the *plain* Workers `fetch` handler (a stateless isolate,
66
- * not the DO) has applied a commit to D1 but has no in-memory sockets. It
67
- * wakes the partition's DO by `stub.fetch`-ing the internal `/__wake` endpoint
68
- * with the partition + commitSeq; the DO calls `hub.wake(partition,
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
- * plain HTTP handler uses); `configFactory` builds the hub config from `env`.
90
+ * outer Worker config uses); `configFactory` builds the hub config from `env`.
95
91
  */
96
- export interface RealtimeDOConfig {
92
+ export type RealtimeDOConfig = {
97
93
  /**
98
- * Build the realtime hub config for this DO from its D1 storage. Mirrors the
99
- * HTTP handler's config: same schema, same `resolveScopes`, same segment
100
- * store (§8.7 socket rounds need it) so a socket round and a `POST /sync`
101
- * round are the SAME handler over the SAME storage.
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 forwards HTTP-push wakes to `/__syncular_realtime/wake`.
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`). */
@@ -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 its commit fan-out live in that single-threaded DO —
11
- * which is also the per-partition write-serialization point the D1 storage
12
- * wants (see `d1-storage.ts` "Concurrency posture"). Because the hub is the
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 (HTTP-push fan-out, the LISTEN/NOTIFY analogue)
63
+ * ## The wake path (external-command fan-out)
64
64
  *
65
- * A push landing via the *plain* Workers `fetch` handler (a stateless isolate,
66
- * not the DO) has applied a commit to D1 but has no in-memory sockets. It
67
- * wakes the partition's DO by `stub.fetch`-ing the internal `/__wake` endpoint
68
- * with the partition + commitSeq; the DO calls `hub.wake(partition,
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
- // This host is one Durable Object per partition, so the §6.8 storage
123
- // precondition is true for socket rounds handled inside the object.
124
- commitValidationSerialized: true,
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.#hub = createRealtimeHub({
131
- ...this.#config.hubConfig(this.#storage),
132
- storage: this.#storage,
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 forwards HTTP-push wakes to `/__syncular_realtime/wake`.
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
- /** §8.3 wake: an HTTP push landed in a plain isolate; re-pull the delta. */
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
- const session = await this.#sessionFor(ws);
239
- if (session === undefined)
240
- return;
241
- if (typeof message === 'string') {
242
- session.handleMessage(message);
243
- }
244
- else {
245
- // §8.7: tagged binary — sync-round request chunks / acks.
246
- session.handleBinary(new Uint8Array(message));
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.23",
3
+ "version": "0.15.25",
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.23",
49
- "@syncular/server-hono": "0.15.23",
48
+ "@syncular/server": "0.15.25",
49
+ "@syncular/server-hono": "0.15.25",
50
50
  "hono": "^4.11.0"
51
51
  },
52
52
  "devDependencies": {
53
- "@syncular/core": "0.15.23"
53
+ "@syncular/core": "0.15.25"
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 `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.
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 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).
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
- const realtime = await options.realtime(env, ctx);
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) that wakes the partition's DO after a push lands
237
- * via the plain HTTP handler (a stateless isolate with no sockets). The DO
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
- * Spread this into `SyncServerConfig.realtime`. The wake is fire-and-forget:
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
  */
@@ -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 its commit fan-out live in that single-threaded DO —
11
- * which is also the per-partition write-serialization point the D1 storage
12
- * wants (see `d1-storage.ts` "Concurrency posture"). Because the hub is the
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 (HTTP-push fan-out, the LISTEN/NOTIFY analogue)
63
+ * ## The wake path (external-command fan-out)
64
64
  *
65
- * A push landing via the *plain* Workers `fetch` handler (a stateless isolate,
66
- * not the DO) has applied a commit to D1 but has no in-memory sockets. It
67
- * wakes the partition's DO by `stub.fetch`-ing the internal `/__wake` endpoint
68
- * with the partition + commitSeq; the DO calls `hub.wake(partition,
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
- * plain HTTP handler uses); `configFactory` builds the hub config from `env`.
106
+ * outer Worker config uses); `configFactory` builds the hub config from `env`.
107
107
  */
108
- export interface RealtimeDOConfig {
109
- /**
110
- * Build the realtime hub config for this DO from its D1 storage. Mirrors the
111
- * HTTP handler's config: same schema, same `resolveScopes`, same segment
112
- * store (§8.7 socket rounds need it) so a socket round and a `POST /sync`
113
- * round are the SAME handler over the SAME storage.
114
- */
115
- hubConfig(storage: D1ServerStorage): RealtimeHubConfigInput;
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
- // This host is one Durable Object per partition, so the §6.8 storage
195
- // precondition is true for socket rounds handled inside the object.
196
- commitValidationSerialized: true,
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
- this.#hub = createRealtimeHub({
204
- ...this.#config.hubConfig(this.#storage),
205
- storage: this.#storage,
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 forwards HTTP-push wakes to `/__syncular_realtime/wake`.
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
- /** §8.3 wake: an HTTP push landed in a plain isolate; re-pull the delta. */
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
- const session = await this.#sessionFor(ws);
325
- if (session === undefined) return;
326
- if (typeof message === 'string') {
327
- session.handleMessage(message);
328
- } else {
329
- // §8.7: tagged binary — sync-round request chunks / acks.
330
- session.handleBinary(new Uint8Array(message));
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`). */