@syncular/server-workers 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +319 -0
- package/dist/index.d.ts +161 -0
- package/dist/index.js +162 -0
- package/dist/realtime-do.d.ts +166 -0
- package/dist/realtime-do.js +333 -0
- package/package.json +55 -0
- package/src/index.ts +280 -0
- package/src/realtime-do.ts +444 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SyncularRealtimeDO` — the Cloudflare Durable Object realtime host (§8, the
|
|
3
|
+
* second binding of §1.1). ROADMAP block 2: the designed-but-deferred half of
|
|
4
|
+
* the Workers deployment matrix, now built.
|
|
5
|
+
*
|
|
6
|
+
* ## The shape
|
|
7
|
+
*
|
|
8
|
+
* One DO instance hosts **one `RealtimeHub`** and serves **one partition**
|
|
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
|
|
13
|
+
* `RealtimeNotifier` (§8.2) *inside* the DO, a sync round that lands over the
|
|
14
|
+
* socket fans its full delta out to the partition's other sockets with no
|
|
15
|
+
* LISTEN/NOTIFY — writes and sockets are co-located.
|
|
16
|
+
*
|
|
17
|
+
* One-partition-per-DO is the natural §8.2 fan-out boundary and the rung we
|
|
18
|
+
* ship. Many-partitions-per-shard (one DO fronting a bucket of low-traffic
|
|
19
|
+
* partitions, to amortize the DO floor) is a future tuning knob: the hub
|
|
20
|
+
* already keys every operation by partition, so a shard DO would host one hub
|
|
21
|
+
* and route by `partition` — no protocol change, only the id-derivation and
|
|
22
|
+
* the fan-out `partition` filter (already present). Deferred until a
|
|
23
|
+
* cost/traffic signal asks for it.
|
|
24
|
+
*
|
|
25
|
+
* ## WebSocket hibernation (`state.acceptWebSocket`)
|
|
26
|
+
*
|
|
27
|
+
* Idle sockets must not pin the DO in memory or bill wall time. We use the
|
|
28
|
+
* Hibernation API: `acceptWebSocket(ws)` hands the socket to the runtime,
|
|
29
|
+
* `webSocketMessage`/`webSocketClose`/`webSocketError` are delivered as
|
|
30
|
+
* class methods, and between deliveries the DO may be evicted from memory
|
|
31
|
+
* while the sockets stay open.
|
|
32
|
+
*
|
|
33
|
+
* The per-connection state machine is the existing `RealtimeSession`
|
|
34
|
+
* (`@syncular/server`) — unchanged, driven from the hibernation callbacks:
|
|
35
|
+
* binary frames → `session.handleBinary` (§8.7 rounds + acks), text frames →
|
|
36
|
+
* `session.handleMessage` (§8.2 acks, §8.6 presence), hub delta/wake sends →
|
|
37
|
+
* `ws.send`.
|
|
38
|
+
*
|
|
39
|
+
* ### Hibernation-safe state: rehydration
|
|
40
|
+
*
|
|
41
|
+
* A `RealtimeSession` is *in-memory only* — on wake from hibernation the DO's
|
|
42
|
+
* `#sessions` map is empty. The honest rule:
|
|
43
|
+
*
|
|
44
|
+
* - **Hibernation only happens between rounds.** An in-flight sync round is
|
|
45
|
+
* an async generator draining over `ws.send`; while it is pending it holds
|
|
46
|
+
* the DO's event loop, so the DO cannot be evicted mid-round. (This is the
|
|
47
|
+
* same property the §8.7 "one round in flight" rule relies on.)
|
|
48
|
+
* - **On the first message after a wake**, the socket has a serialized
|
|
49
|
+
* attachment (`ws.serializeAttachment` — `{clientId, actorId, partition}`,
|
|
50
|
+
* written at accept time) but no live session. We reconstruct the session
|
|
51
|
+
* via `hub.connect(...)`, which reloads the registration list from the
|
|
52
|
+
* client record in D1 (the §8.1 load-at-upgrade rule — exactly what a
|
|
53
|
+
* fresh upgrade does), then dispatch the message into it. Rehydration is
|
|
54
|
+
* transparent to the client: it saw `hello` once at the real upgrade, so
|
|
55
|
+
* the rehydration `hello` is swallowed (a one-shot filter on the send
|
|
56
|
+
* wrapper). Cursor/registration are the durable truth in D1; nothing
|
|
57
|
+
* in-flight is lost because nothing in-flight can be hibernated.
|
|
58
|
+
*
|
|
59
|
+
* So the serialized attachment is deliberately minimal — the three identity
|
|
60
|
+
* fields `connect` needs. Everything else (`cursor`, `registrations`,
|
|
61
|
+
* `lastKnownSeq`) is re-derived from D1 by `connect`, which is authoritative.
|
|
62
|
+
*
|
|
63
|
+
* ## The wake path (HTTP-push fan-out, the LISTEN/NOTIFY analogue)
|
|
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.
|
|
73
|
+
*/
|
|
74
|
+
import { D1ServerStorage, type RealtimeHubConfig } from '@syncular/server';
|
|
75
|
+
export interface DurableObjectStateLike {
|
|
76
|
+
acceptWebSocket(ws: WebSocketLike, tags?: string[]): void;
|
|
77
|
+
getWebSockets(tag?: string): WebSocketLike[];
|
|
78
|
+
}
|
|
79
|
+
export interface WebSocketLike {
|
|
80
|
+
accept?(): void;
|
|
81
|
+
send(data: string | ArrayBuffer | ArrayBufferView): void;
|
|
82
|
+
close(code?: number, reason?: string): void;
|
|
83
|
+
serializeAttachment(value: unknown): void;
|
|
84
|
+
deserializeAttachment(): unknown;
|
|
85
|
+
}
|
|
86
|
+
/** The `WebSocketPair` constructor result: `[client, server]` by index. */
|
|
87
|
+
export type WebSocketPairLike = {
|
|
88
|
+
0: WebSocketLike;
|
|
89
|
+
1: WebSocketLike;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The host env a `SyncularRealtimeDO` reads. Supplied by the DO runtime via
|
|
93
|
+
* 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`.
|
|
95
|
+
*/
|
|
96
|
+
export interface RealtimeDOConfig {
|
|
97
|
+
/**
|
|
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.
|
|
102
|
+
*/
|
|
103
|
+
hubConfig(storage: D1ServerStorage): RealtimeHubConfigInput;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The subset of `RealtimeHubConfig` the DO host supplies (storage is wired by
|
|
107
|
+
* the DO from its D1 binding, so it is omitted here).
|
|
108
|
+
*/
|
|
109
|
+
export type RealtimeHubConfigInput = Omit<RealtimeHubConfig, 'storage'>;
|
|
110
|
+
/** The identity the upgrade request must carry (resolved by the Worker). */
|
|
111
|
+
export interface RealtimeUpgradeIdentity {
|
|
112
|
+
readonly partition: string;
|
|
113
|
+
readonly actorId: string;
|
|
114
|
+
readonly clientId: string;
|
|
115
|
+
}
|
|
116
|
+
/** Internal control-request paths on the DO stub (never client-facing). */
|
|
117
|
+
export declare const REALTIME_DO_WAKE_PATH = "/__syncular_realtime/wake";
|
|
118
|
+
export declare const REALTIME_DO_UPGRADE_PATH = "/__syncular_realtime/upgrade";
|
|
119
|
+
/**
|
|
120
|
+
* The base `SyncularRealtimeDO`. A host subclasses (or instantiates) it with a
|
|
121
|
+
* `RealtimeDOConfig`. The class is platform-shaped: `state.acceptWebSocket` +
|
|
122
|
+
* `webSocket*` handlers are the Cloudflare Durable Object hibernation contract.
|
|
123
|
+
*
|
|
124
|
+
* Because the platform types are declared structurally (no
|
|
125
|
+
* `@cloudflare/workers-types` dependency), a real deployment declares:
|
|
126
|
+
*
|
|
127
|
+
* ```ts
|
|
128
|
+
* export class SyncularRealtimeDO extends DurableObject {
|
|
129
|
+
* #impl = new SyncularRealtimeHost(this.ctx, this.env, config);
|
|
130
|
+
* fetch(req: Request) { return this.#impl.fetch(req); }
|
|
131
|
+
* webSocketMessage(ws: WebSocket, msg: ArrayBuffer | string) {
|
|
132
|
+
* return this.#impl.webSocketMessage(ws, msg);
|
|
133
|
+
* }
|
|
134
|
+
* webSocketClose(ws: WebSocket) { return this.#impl.webSocketClose(ws); }
|
|
135
|
+
* webSocketError(ws: WebSocket) { return this.#impl.webSocketError(ws); }
|
|
136
|
+
* }
|
|
137
|
+
* ```
|
|
138
|
+
*
|
|
139
|
+
* The reference host is `SyncularRealtimeHost` below; the tests drive it
|
|
140
|
+
* directly over the DO double.
|
|
141
|
+
*/
|
|
142
|
+
export declare class SyncularRealtimeHost {
|
|
143
|
+
#private;
|
|
144
|
+
constructor(state: DurableObjectStateLike, db: D1Database, config: RealtimeDOConfig);
|
|
145
|
+
/**
|
|
146
|
+
* The DO `fetch` handler: routes the internal upgrade + wake control paths.
|
|
147
|
+
* The Worker forwards `GET <mount>/realtime` here as an upgrade with the
|
|
148
|
+
* resolved identity in headers (see `forwardRealtimeUpgrade` in `index.ts`),
|
|
149
|
+
* and forwards HTTP-push wakes to `/__syncular_realtime/wake`.
|
|
150
|
+
*/
|
|
151
|
+
fetch(request: Request): Promise<Response>;
|
|
152
|
+
/** Hibernation callback: an inbound frame. */
|
|
153
|
+
webSocketMessage(ws: WebSocketLike, message: ArrayBuffer | string): Promise<void>;
|
|
154
|
+
/** Hibernation callback: the socket closed. */
|
|
155
|
+
webSocketClose(ws: WebSocketLike): Promise<void>;
|
|
156
|
+
/** Hibernation callback: a socket error — treat as a close. */
|
|
157
|
+
webSocketError(ws: WebSocketLike): Promise<void>;
|
|
158
|
+
/** Test/introspection: the number of live sessions on this DO. */
|
|
159
|
+
get sessionCount(): number;
|
|
160
|
+
}
|
|
161
|
+
/** Inject a `WebSocketPair` implementation (hermetic tests). */
|
|
162
|
+
export declare function setWebSocketPair(impl: (new () => WebSocketPairLike) | undefined): void;
|
|
163
|
+
/** Write the resolved identity onto an upgrade request's headers (Worker side). */
|
|
164
|
+
export declare function writeIdentityHeaders(headers: Headers, identity: RealtimeUpgradeIdentity): void;
|
|
165
|
+
/** The D1 binding, re-declared structurally (see `d1-storage.ts`). */
|
|
166
|
+
export type D1Database = ConstructorParameters<typeof D1ServerStorage>[0];
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SyncularRealtimeDO` — the Cloudflare Durable Object realtime host (§8, the
|
|
3
|
+
* second binding of §1.1). ROADMAP block 2: the designed-but-deferred half of
|
|
4
|
+
* the Workers deployment matrix, now built.
|
|
5
|
+
*
|
|
6
|
+
* ## The shape
|
|
7
|
+
*
|
|
8
|
+
* One DO instance hosts **one `RealtimeHub`** and serves **one partition**
|
|
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
|
|
13
|
+
* `RealtimeNotifier` (§8.2) *inside* the DO, a sync round that lands over the
|
|
14
|
+
* socket fans its full delta out to the partition's other sockets with no
|
|
15
|
+
* LISTEN/NOTIFY — writes and sockets are co-located.
|
|
16
|
+
*
|
|
17
|
+
* One-partition-per-DO is the natural §8.2 fan-out boundary and the rung we
|
|
18
|
+
* ship. Many-partitions-per-shard (one DO fronting a bucket of low-traffic
|
|
19
|
+
* partitions, to amortize the DO floor) is a future tuning knob: the hub
|
|
20
|
+
* already keys every operation by partition, so a shard DO would host one hub
|
|
21
|
+
* and route by `partition` — no protocol change, only the id-derivation and
|
|
22
|
+
* the fan-out `partition` filter (already present). Deferred until a
|
|
23
|
+
* cost/traffic signal asks for it.
|
|
24
|
+
*
|
|
25
|
+
* ## WebSocket hibernation (`state.acceptWebSocket`)
|
|
26
|
+
*
|
|
27
|
+
* Idle sockets must not pin the DO in memory or bill wall time. We use the
|
|
28
|
+
* Hibernation API: `acceptWebSocket(ws)` hands the socket to the runtime,
|
|
29
|
+
* `webSocketMessage`/`webSocketClose`/`webSocketError` are delivered as
|
|
30
|
+
* class methods, and between deliveries the DO may be evicted from memory
|
|
31
|
+
* while the sockets stay open.
|
|
32
|
+
*
|
|
33
|
+
* The per-connection state machine is the existing `RealtimeSession`
|
|
34
|
+
* (`@syncular/server`) — unchanged, driven from the hibernation callbacks:
|
|
35
|
+
* binary frames → `session.handleBinary` (§8.7 rounds + acks), text frames →
|
|
36
|
+
* `session.handleMessage` (§8.2 acks, §8.6 presence), hub delta/wake sends →
|
|
37
|
+
* `ws.send`.
|
|
38
|
+
*
|
|
39
|
+
* ### Hibernation-safe state: rehydration
|
|
40
|
+
*
|
|
41
|
+
* A `RealtimeSession` is *in-memory only* — on wake from hibernation the DO's
|
|
42
|
+
* `#sessions` map is empty. The honest rule:
|
|
43
|
+
*
|
|
44
|
+
* - **Hibernation only happens between rounds.** An in-flight sync round is
|
|
45
|
+
* an async generator draining over `ws.send`; while it is pending it holds
|
|
46
|
+
* the DO's event loop, so the DO cannot be evicted mid-round. (This is the
|
|
47
|
+
* same property the §8.7 "one round in flight" rule relies on.)
|
|
48
|
+
* - **On the first message after a wake**, the socket has a serialized
|
|
49
|
+
* attachment (`ws.serializeAttachment` — `{clientId, actorId, partition}`,
|
|
50
|
+
* written at accept time) but no live session. We reconstruct the session
|
|
51
|
+
* via `hub.connect(...)`, which reloads the registration list from the
|
|
52
|
+
* client record in D1 (the §8.1 load-at-upgrade rule — exactly what a
|
|
53
|
+
* fresh upgrade does), then dispatch the message into it. Rehydration is
|
|
54
|
+
* transparent to the client: it saw `hello` once at the real upgrade, so
|
|
55
|
+
* the rehydration `hello` is swallowed (a one-shot filter on the send
|
|
56
|
+
* wrapper). Cursor/registration are the durable truth in D1; nothing
|
|
57
|
+
* in-flight is lost because nothing in-flight can be hibernated.
|
|
58
|
+
*
|
|
59
|
+
* So the serialized attachment is deliberately minimal — the three identity
|
|
60
|
+
* fields `connect` needs. Everything else (`cursor`, `registrations`,
|
|
61
|
+
* `lastKnownSeq`) is re-derived from D1 by `connect`, which is authoritative.
|
|
62
|
+
*
|
|
63
|
+
* ## The wake path (HTTP-push fan-out, the LISTEN/NOTIFY analogue)
|
|
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.
|
|
73
|
+
*/
|
|
74
|
+
import { createRealtimeHub, D1ServerStorage, } from '@syncular/server';
|
|
75
|
+
function isAttachment(value) {
|
|
76
|
+
return (typeof value === 'object' &&
|
|
77
|
+
value !== null &&
|
|
78
|
+
typeof value.partition === 'string' &&
|
|
79
|
+
typeof value.actorId === 'string' &&
|
|
80
|
+
typeof value.clientId === 'string');
|
|
81
|
+
}
|
|
82
|
+
/** Internal control-request paths on the DO stub (never client-facing). */
|
|
83
|
+
export const REALTIME_DO_WAKE_PATH = '/__syncular_realtime/wake';
|
|
84
|
+
export const REALTIME_DO_UPGRADE_PATH = '/__syncular_realtime/upgrade';
|
|
85
|
+
/**
|
|
86
|
+
* The base `SyncularRealtimeDO`. A host subclasses (or instantiates) it with a
|
|
87
|
+
* `RealtimeDOConfig`. The class is platform-shaped: `state.acceptWebSocket` +
|
|
88
|
+
* `webSocket*` handlers are the Cloudflare Durable Object hibernation contract.
|
|
89
|
+
*
|
|
90
|
+
* Because the platform types are declared structurally (no
|
|
91
|
+
* `@cloudflare/workers-types` dependency), a real deployment declares:
|
|
92
|
+
*
|
|
93
|
+
* ```ts
|
|
94
|
+
* export class SyncularRealtimeDO extends DurableObject {
|
|
95
|
+
* #impl = new SyncularRealtimeHost(this.ctx, this.env, config);
|
|
96
|
+
* fetch(req: Request) { return this.#impl.fetch(req); }
|
|
97
|
+
* webSocketMessage(ws: WebSocket, msg: ArrayBuffer | string) {
|
|
98
|
+
* return this.#impl.webSocketMessage(ws, msg);
|
|
99
|
+
* }
|
|
100
|
+
* webSocketClose(ws: WebSocket) { return this.#impl.webSocketClose(ws); }
|
|
101
|
+
* webSocketError(ws: WebSocket) { return this.#impl.webSocketError(ws); }
|
|
102
|
+
* }
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* The reference host is `SyncularRealtimeHost` below; the tests drive it
|
|
106
|
+
* directly over the DO double.
|
|
107
|
+
*/
|
|
108
|
+
export class SyncularRealtimeHost {
|
|
109
|
+
#state;
|
|
110
|
+
#storage;
|
|
111
|
+
#config;
|
|
112
|
+
#hub;
|
|
113
|
+
/** Live sessions, keyed by their socket. Empty after a hibernation wake;
|
|
114
|
+
* lazily rehydrated on the first message per socket (see the class doc). */
|
|
115
|
+
#sessions = new Map();
|
|
116
|
+
/** Sockets whose rehydration `hello` must be swallowed (already greeted at
|
|
117
|
+
* the real upgrade — rehydration is transparent to the client). */
|
|
118
|
+
#swallowHello = new Set();
|
|
119
|
+
constructor(state, db, config) {
|
|
120
|
+
this.#state = state;
|
|
121
|
+
this.#storage = new D1ServerStorage(db);
|
|
122
|
+
this.#config = config;
|
|
123
|
+
}
|
|
124
|
+
#getHub() {
|
|
125
|
+
if (this.#hub === undefined) {
|
|
126
|
+
this.#hub = createRealtimeHub({
|
|
127
|
+
...this.#config.hubConfig(this.#storage),
|
|
128
|
+
storage: this.#storage,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return this.#hub;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The DO `fetch` handler: routes the internal upgrade + wake control paths.
|
|
135
|
+
* The Worker forwards `GET <mount>/realtime` here as an upgrade with the
|
|
136
|
+
* resolved identity in headers (see `forwardRealtimeUpgrade` in `index.ts`),
|
|
137
|
+
* and forwards HTTP-push wakes to `/__syncular_realtime/wake`.
|
|
138
|
+
*/
|
|
139
|
+
async fetch(request) {
|
|
140
|
+
const url = new URL(request.url);
|
|
141
|
+
if (url.pathname === REALTIME_DO_WAKE_PATH) {
|
|
142
|
+
return this.#handleWake(request);
|
|
143
|
+
}
|
|
144
|
+
if (url.pathname === REALTIME_DO_UPGRADE_PATH) {
|
|
145
|
+
return this.#handleUpgrade(request);
|
|
146
|
+
}
|
|
147
|
+
return new Response('not found', { status: 404 });
|
|
148
|
+
}
|
|
149
|
+
/** §8.3 wake: an HTTP push landed in a plain isolate; re-pull the delta. */
|
|
150
|
+
async #handleWake(request) {
|
|
151
|
+
const body = (await request.json().catch(() => null));
|
|
152
|
+
const partition = body?.partition;
|
|
153
|
+
if (typeof partition !== 'string') {
|
|
154
|
+
return new Response('bad wake', { status: 400 });
|
|
155
|
+
}
|
|
156
|
+
// Wake only if this DO actually holds sockets (a cold DO has none — the
|
|
157
|
+
// socket that would care is on this DO by construction, so a wake to a
|
|
158
|
+
// DO with no sockets is simply a no-op, not an error).
|
|
159
|
+
this.#getHub().wake(partition, 'catchup-required');
|
|
160
|
+
return new Response(null, { status: 204 });
|
|
161
|
+
}
|
|
162
|
+
/** The WebSocket upgrade: accept a hibernatable socket, connect the hub. */
|
|
163
|
+
async #handleUpgrade(request) {
|
|
164
|
+
const identity = readIdentityHeaders(request);
|
|
165
|
+
if (identity === undefined) {
|
|
166
|
+
return new Response('missing realtime identity', { status: 400 });
|
|
167
|
+
}
|
|
168
|
+
const pair = newWebSocketPair();
|
|
169
|
+
const client = pair[0];
|
|
170
|
+
const server = pair[1];
|
|
171
|
+
// Hand the server socket to the runtime for hibernation. The attachment
|
|
172
|
+
// is the minimal §8.1 identity `connect` needs to rehydrate.
|
|
173
|
+
const attachment = {
|
|
174
|
+
partition: identity.partition,
|
|
175
|
+
actorId: identity.actorId,
|
|
176
|
+
clientId: identity.clientId,
|
|
177
|
+
};
|
|
178
|
+
server.serializeAttachment(attachment);
|
|
179
|
+
this.#state.acceptWebSocket(server);
|
|
180
|
+
// Build the session now (first connect: hello IS delivered). Any failure
|
|
181
|
+
// closes the socket loudly — never a half-open realtime connection.
|
|
182
|
+
try {
|
|
183
|
+
const session = await this.#connectSession(server, attachment, false);
|
|
184
|
+
this.#sessions.set(server, session);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
server.close(1011, 'realtime connect failed');
|
|
188
|
+
return new Response(error instanceof Error ? error.message : 'connect failed', { status: 400 });
|
|
189
|
+
}
|
|
190
|
+
// `webSocket` is a workerd-only ResponseInit field (the 101 upgrade
|
|
191
|
+
// handshake); the standard lib type has no slot for it.
|
|
192
|
+
const init = { status: 101, webSocket: client };
|
|
193
|
+
return new Response(null, init);
|
|
194
|
+
}
|
|
195
|
+
/** Build a `RealtimeSession` for a socket via the hub (§8.1). */
|
|
196
|
+
async #connectSession(ws, attachment, rehydrate) {
|
|
197
|
+
if (rehydrate)
|
|
198
|
+
this.#swallowHello.add(ws);
|
|
199
|
+
const session = await this.#getHub().connect({
|
|
200
|
+
partition: attachment.partition,
|
|
201
|
+
actorId: attachment.actorId,
|
|
202
|
+
clientId: attachment.clientId,
|
|
203
|
+
send: (data) => {
|
|
204
|
+
// Swallow exactly one hello on a rehydrated socket — the client was
|
|
205
|
+
// greeted at the real upgrade; rehydration is transparent (§8.1).
|
|
206
|
+
if (this.#swallowHello.has(ws)) {
|
|
207
|
+
this.#swallowHello.delete(ws);
|
|
208
|
+
if (isHello(data))
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
sendOverSocket(ws, data);
|
|
212
|
+
},
|
|
213
|
+
closeSocket: () => ws.close(1008, 'protocol violation (§8.7)'),
|
|
214
|
+
});
|
|
215
|
+
return session;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Resolve the session for a socket, rehydrating from the attachment + D1 if
|
|
219
|
+
* the DO woke from hibernation and lost its in-memory map.
|
|
220
|
+
*/
|
|
221
|
+
async #sessionFor(ws) {
|
|
222
|
+
const existing = this.#sessions.get(ws);
|
|
223
|
+
if (existing !== undefined)
|
|
224
|
+
return existing;
|
|
225
|
+
const attachment = ws.deserializeAttachment();
|
|
226
|
+
if (!isAttachment(attachment))
|
|
227
|
+
return undefined;
|
|
228
|
+
const session = await this.#connectSession(ws, attachment, true);
|
|
229
|
+
this.#sessions.set(ws, session);
|
|
230
|
+
return session;
|
|
231
|
+
}
|
|
232
|
+
/** Hibernation callback: an inbound frame. */
|
|
233
|
+
async webSocketMessage(ws, message) {
|
|
234
|
+
const session = await this.#sessionFor(ws);
|
|
235
|
+
if (session === undefined)
|
|
236
|
+
return;
|
|
237
|
+
if (typeof message === 'string') {
|
|
238
|
+
session.handleMessage(message);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
// §8.7: tagged binary — sync-round request chunks / acks.
|
|
242
|
+
session.handleBinary(new Uint8Array(message));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** Hibernation callback: the socket closed. */
|
|
246
|
+
async webSocketClose(ws) {
|
|
247
|
+
const session = this.#sessions.get(ws);
|
|
248
|
+
if (session !== undefined) {
|
|
249
|
+
session.close();
|
|
250
|
+
this.#sessions.delete(ws);
|
|
251
|
+
}
|
|
252
|
+
this.#swallowHello.delete(ws);
|
|
253
|
+
}
|
|
254
|
+
/** Hibernation callback: a socket error — treat as a close. */
|
|
255
|
+
async webSocketError(ws) {
|
|
256
|
+
await this.webSocketClose(ws);
|
|
257
|
+
}
|
|
258
|
+
/** Test/introspection: the number of live sessions on this DO. */
|
|
259
|
+
get sessionCount() {
|
|
260
|
+
return this.#sessions.size;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// -- Platform helpers -------------------------------------------------------
|
|
264
|
+
/**
|
|
265
|
+
* `WebSocketPair` is a runtime global on `workerd`. We reference it through a
|
|
266
|
+
* lazily-resolved binding so this module imports cleanly under Bun/Node for
|
|
267
|
+
* the hermetic tests (which inject a double via `setWebSocketPair`).
|
|
268
|
+
*/
|
|
269
|
+
let WebSocketPairImpl;
|
|
270
|
+
/** Construct a `WebSocketPair`: the injected double in tests, else the
|
|
271
|
+
* `workerd` global. */
|
|
272
|
+
function newWebSocketPair() {
|
|
273
|
+
const impl = WebSocketPairImpl ??
|
|
274
|
+
globalThis
|
|
275
|
+
.WebSocketPair;
|
|
276
|
+
if (impl === undefined) {
|
|
277
|
+
throw new Error('WebSocketPair is not available; on workerd it is a global, in tests inject one with setWebSocketPair()');
|
|
278
|
+
}
|
|
279
|
+
return new impl();
|
|
280
|
+
}
|
|
281
|
+
/** Inject a `WebSocketPair` implementation (hermetic tests). */
|
|
282
|
+
export function setWebSocketPair(impl) {
|
|
283
|
+
WebSocketPairImpl = impl;
|
|
284
|
+
}
|
|
285
|
+
/** Send a session frame over a socket, normalizing `Uint8Array` → the
|
|
286
|
+
* `ArrayBuffer` the WS API wants (a view over a shared buffer is copied so a
|
|
287
|
+
* later mutation of the source cannot corrupt an in-flight frame). */
|
|
288
|
+
function sendOverSocket(ws, data) {
|
|
289
|
+
if (typeof data === 'string') {
|
|
290
|
+
ws.send(data);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// Copy into a fresh (non-shared) ArrayBuffer so a later mutation of the
|
|
294
|
+
// source cannot corrupt an in-flight frame.
|
|
295
|
+
const copy = new Uint8Array(data.byteLength);
|
|
296
|
+
copy.set(data);
|
|
297
|
+
ws.send(copy.buffer);
|
|
298
|
+
}
|
|
299
|
+
/** A `hello` control frame (text JSON) — used by the rehydration swallow. */
|
|
300
|
+
function isHello(data) {
|
|
301
|
+
if (typeof data !== 'string')
|
|
302
|
+
return false;
|
|
303
|
+
try {
|
|
304
|
+
return JSON.parse(data).event === 'hello';
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const REALTIME_ID_HEADER = {
|
|
311
|
+
partition: 'x-syncular-partition',
|
|
312
|
+
actorId: 'x-syncular-actor',
|
|
313
|
+
clientId: 'x-syncular-client',
|
|
314
|
+
};
|
|
315
|
+
/** Write the resolved identity onto an upgrade request's headers (Worker side). */
|
|
316
|
+
export function writeIdentityHeaders(headers, identity) {
|
|
317
|
+
headers.set(REALTIME_ID_HEADER.partition, identity.partition);
|
|
318
|
+
headers.set(REALTIME_ID_HEADER.actorId, identity.actorId);
|
|
319
|
+
headers.set(REALTIME_ID_HEADER.clientId, identity.clientId);
|
|
320
|
+
}
|
|
321
|
+
function readIdentityHeaders(request) {
|
|
322
|
+
const partition = request.headers.get(REALTIME_ID_HEADER.partition);
|
|
323
|
+
const actorId = request.headers.get(REALTIME_ID_HEADER.actorId);
|
|
324
|
+
const clientId = request.headers.get(REALTIME_ID_HEADER.clientId);
|
|
325
|
+
if (partition === null ||
|
|
326
|
+
partition === '' ||
|
|
327
|
+
actorId === null ||
|
|
328
|
+
clientId === null ||
|
|
329
|
+
clientId === '') {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
return { partition, actorId, clientId };
|
|
333
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@syncular/server-workers",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Cloudflare Workers adapter for the Syncular sync server",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Benjamin Kniffler",
|
|
7
|
+
"homepage": "https://syncular.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/syncular/syncular.git",
|
|
11
|
+
"directory": "packages/server-workers"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/syncular/syncular/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"sync",
|
|
18
|
+
"offline-first",
|
|
19
|
+
"realtime",
|
|
20
|
+
"database",
|
|
21
|
+
"typescript"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"bun": "./src/index.ts",
|
|
31
|
+
"browser": "./src/index.ts",
|
|
32
|
+
"import": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src",
|
|
41
|
+
"README.md",
|
|
42
|
+
"!src/**/*.test.ts",
|
|
43
|
+
"!src/**/*.test.tsx",
|
|
44
|
+
"!dist/**/*.test.js",
|
|
45
|
+
"!dist/**/*.test.d.ts"
|
|
46
|
+
],
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@syncular/server": "0.2.0",
|
|
49
|
+
"@syncular/server-hono": "0.2.0",
|
|
50
|
+
"hono": "^4.11.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@syncular/core": "0.2.0"
|
|
54
|
+
}
|
|
55
|
+
}
|