@fieldnotes/sync-server 0.4.0 → 0.6.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 +161 -141
- package/dist/index.cjs +61 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +59 -3
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,141 +1,161 @@
|
|
|
1
|
-
# @fieldnotes/sync-server
|
|
2
|
-
|
|
3
|
-
Authoritative WebSocket relay server for [`@fieldnotes/sync`](../sync).
|
|
4
|
-
|
|
5
|
-
The relay holds the canonical per-room canvas state and fans out element
|
|
6
|
-
operations to every other connection in the room. Clients connect, request a
|
|
7
|
-
snapshot to catch up, then stream `upsert` / `remove` / `clear` ops that the hub
|
|
8
|
-
applies and forwards.
|
|
9
|
-
|
|
10
|
-
## Pieces
|
|
11
|
-
|
|
12
|
-
- **`SyncHub`** — transport-agnostic relay. Per-room canonical state lives behind
|
|
13
|
-
an async `HubBackend`; each room processes messages on its own serial queue so
|
|
14
|
-
concurrent edits to the same room never race, while different rooms run
|
|
15
|
-
independently.
|
|
16
|
-
- **`MemoryHubBackend`** — in-memory `HubBackend` (the default). Redis-backed and
|
|
17
|
-
authenticated backends are planned.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
1
|
+
# @fieldnotes/sync-server
|
|
2
|
+
|
|
3
|
+
Authoritative WebSocket relay server for [`@fieldnotes/sync`](../sync).
|
|
4
|
+
|
|
5
|
+
The relay holds the canonical per-room canvas state and fans out element
|
|
6
|
+
operations to every other connection in the room. Clients connect, request a
|
|
7
|
+
snapshot to catch up, then stream `upsert` / `remove` / `clear` ops that the hub
|
|
8
|
+
applies and forwards.
|
|
9
|
+
|
|
10
|
+
## Pieces
|
|
11
|
+
|
|
12
|
+
- **`SyncHub`** — transport-agnostic relay. Per-room canonical state lives behind
|
|
13
|
+
an async `HubBackend`; each room processes messages on its own serial queue so
|
|
14
|
+
concurrent edits to the same room never race, while different rooms run
|
|
15
|
+
independently.
|
|
16
|
+
- **`MemoryHubBackend`** — in-memory `HubBackend` (the default). Redis-backed and
|
|
17
|
+
authenticated backends are planned. It reclaims a room's memory on `clear`, but
|
|
18
|
+
**retains** state for uncleared / abandoned rooms — fine for dev / single-instance
|
|
19
|
+
use. For a long-lived production process, use a Redis backend with a key TTL, or
|
|
20
|
+
`clear` rooms you're done with.
|
|
21
|
+
- **`createSyncServer`** — a runnable `ws` reference server. Connect with
|
|
22
|
+
`?room=<id>` in the query string; a missing room closes the socket with WS code
|
|
23
|
+
`4400`.
|
|
24
|
+
- **`HubFanout`** — cross-instance live fan-out seam. `SyncHub` publishes each live
|
|
25
|
+
op to the fanout and forwards ops it receives from other instances to its local
|
|
26
|
+
connections. The default `InMemoryHubFanout` is in-process (a no-op for a single
|
|
27
|
+
instance). For multiple relay instances to share live ops, pass a shared fanout
|
|
28
|
+
via `createSyncServer({ fanout })` (e.g. `RedisHubFanout` from
|
|
29
|
+
[`@fieldnotes/sync-redis`](../sync-redis)) **together with a shared backend** — a
|
|
30
|
+
shared fanout alone leaves a new joiner's snapshot stale.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { createSyncServer } from '@fieldnotes/sync-server';
|
|
36
|
+
|
|
37
|
+
const { close } = createSyncServer({ port: 8080 });
|
|
38
|
+
// ws://localhost:8080?room=my-room
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Heartbeat
|
|
42
|
+
|
|
43
|
+
The server pings every client on an interval and **terminates** any that miss a pong,
|
|
44
|
+
so half-open sockets (a backgrounded iPad, dropped WiFi) are reaped instead of silently
|
|
45
|
+
leaking room membership. Configure via `heartbeatIntervalMs` (default `30000`; `0`
|
|
46
|
+
disables):
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Browsers auto-pong at the protocol level, so **no client change** is needed.
|
|
53
|
+
|
|
54
|
+
## Authentication
|
|
55
|
+
|
|
56
|
+
Pass an `authenticate` hook to gate connections:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { createSyncServer } from '@fieldnotes/sync-server';
|
|
60
|
+
|
|
61
|
+
createSyncServer({
|
|
62
|
+
port: 8080,
|
|
63
|
+
authenticate: async ({ req, room }) => {
|
|
64
|
+
const token = new URL(req.url ?? '', 'http://x').searchParams.get('token');
|
|
65
|
+
const member = await myCampaign.verify(token, room); // your app's check (Redis/DB/JWT)
|
|
66
|
+
return member ? { userId: member.id, role: member.isDM ? 'dm' : 'player' } : null;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`authenticate({ req, room }) → { userId, role? } | null` runs on each connection and
|
|
72
|
+
may be sync or async. Returning `null` (or throwing) **rejects** the connection: the
|
|
73
|
+
socket is closed with WS code `4401` and the connection is never admitted to the room —
|
|
74
|
+
no membership, no snapshot. A resolved result admits the connection carrying its
|
|
75
|
+
`userId` and optional `role`.
|
|
76
|
+
|
|
77
|
+
With **no hook**, rooms stay open: every connection is admitted anonymously with
|
|
78
|
+
`userId = connId`. `role` is captured now and enforced in an upcoming release
|
|
79
|
+
(role-based authorization and per-viewer visibility filtering).
|
|
80
|
+
|
|
81
|
+
Messages that arrive during an async auth (notably the client's initial
|
|
82
|
+
`request-snapshot`) are queued and replayed once the connection is admitted, so the
|
|
83
|
+
first snapshot is never lost to the auth round-trip.
|
|
84
|
+
|
|
85
|
+
### Passing a token
|
|
86
|
+
|
|
87
|
+
A browser `WebSocket` can't set request headers, so pass the token as a URL query
|
|
88
|
+
param (`ws://relay?room=R&token=…`) and read it from `req.url` in `authenticate`. URLs
|
|
89
|
+
land in access/proxy logs, so prefer **short-lived / single-use** tokens. Non-browser
|
|
90
|
+
clients can instead put the token in `req.headers` (e.g. `Authorization`), which
|
|
91
|
+
`authenticate` reads directly.
|
|
92
|
+
|
|
93
|
+
## Authorization
|
|
94
|
+
|
|
95
|
+
Pass an `authorize` hook to gate **writes**. The hub calls it before applying each data
|
|
96
|
+
op (`upsert` / `remove` / `clear`) and **drops denied ops** — a denied op is not applied
|
|
97
|
+
to room state and not forwarded to any other connection:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
authorize(ctx) => boolean | Promise<boolean>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`ctx` is an `AuthorizeContext`:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
{
|
|
107
|
+
userId?: string; // the connection's authenticated user (from authenticate)
|
|
108
|
+
role?: string; // the connection's role (from authenticate)
|
|
109
|
+
room: string;
|
|
110
|
+
op: SyncOp; // the incoming upsert / remove / clear
|
|
111
|
+
currentElement?: OwnedElement; // the STORED element, if this id already exists
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`currentElement` is the element currently in room state for an `upsert`/`remove` of an
|
|
116
|
+
**existing** id (typed `OwnedElement = CanvasElement & { ownerId?: string }`), and
|
|
117
|
+
`undefined` for a new/absent id.
|
|
118
|
+
|
|
119
|
+
**Ownership is server-stamped and un-forgeable.** A new element's `ownerId` is set to the
|
|
120
|
+
authenticated creator; on edit the stored owner is **preserved**; a client-supplied
|
|
121
|
+
`ownerId` is always **discarded**. A policy can therefore trust `currentElement.ownerId`
|
|
122
|
+
to enforce "own elements only".
|
|
123
|
+
|
|
124
|
+
With **no hook**, rooms are OPEN (allow-all — every op is accepted).
|
|
125
|
+
|
|
126
|
+
A copy-paste DM / player / display policy:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
createSyncServer({
|
|
130
|
+
port: 8080,
|
|
131
|
+
authenticate: /* … supplies userId + role … */,
|
|
132
|
+
authorize: ({ role, op, currentElement, userId }) => {
|
|
133
|
+
if (role === 'dm') return true;
|
|
134
|
+
if (role === 'display') return false; // read-only monitor
|
|
135
|
+
if (role === 'player') { // own elements only, never destructive
|
|
136
|
+
if (op.kind === 'clear') return false;
|
|
137
|
+
if (op.kind === 'upsert') return !currentElement || currentElement.ownerId === userId;
|
|
138
|
+
if (op.kind === 'remove') return currentElement?.ownerId === userId;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Important:**
|
|
147
|
+
|
|
148
|
+
- Ownership-based authz **requires `authenticate` to supply a STABLE `userId`**. The
|
|
149
|
+
no-hook anonymous default is `userId = connId`, which changes on every reconnect — a
|
|
150
|
+
user would lose access to their own elements after reconnecting.
|
|
151
|
+
- The authz path adds one `backend.get` (a Redis `HGET`) per data op — negligible for
|
|
152
|
+
low-write use.
|
|
153
|
+
- Reads / visibility (a player not **receiving** hidden content) are a separate, upcoming
|
|
154
|
+
concern (D3). `authorize` gates writes only.
|
|
155
|
+
- When `authorize` denies an op, the hub sends the offending client a **corrective op** so its
|
|
156
|
+
optimistic local edit self-corrects immediately — no client change, no waiting for reconnect:
|
|
157
|
+
a rejected new element is **removed**, a rejected edit/remove is re-**upserted** from the canonical
|
|
158
|
+
stored element, and a rejected `clear` gets a fresh **snapshot**. The correction reuses existing op
|
|
159
|
+
kinds and is sent only to the offending connection.
|
|
160
|
+
|
|
161
|
+
A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).
|
package/dist/index.cjs
CHANGED
|
@@ -23,7 +23,8 @@ __export(index_exports, {
|
|
|
23
23
|
InMemoryHubFanout: () => InMemoryHubFanout,
|
|
24
24
|
MemoryHubBackend: () => MemoryHubBackend,
|
|
25
25
|
SyncHub: () => SyncHub,
|
|
26
|
-
createSyncServer: () => createSyncServer
|
|
26
|
+
createSyncServer: () => createSyncServer,
|
|
27
|
+
startHeartbeat: () => startHeartbeat
|
|
27
28
|
});
|
|
28
29
|
module.exports = __toCommonJS(index_exports);
|
|
29
30
|
|
|
@@ -49,6 +50,10 @@ var MemoryHubBackend = class {
|
|
|
49
50
|
return this.room(room).get(id);
|
|
50
51
|
}
|
|
51
52
|
async apply(room, op) {
|
|
53
|
+
if (op.kind === "clear") {
|
|
54
|
+
this.rooms.delete(room);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
52
57
|
(0, import_sync.applyOpToMap)(this.room(room), op);
|
|
53
58
|
}
|
|
54
59
|
};
|
|
@@ -152,7 +157,10 @@ var SyncHub = class {
|
|
|
152
157
|
op,
|
|
153
158
|
currentElement: current
|
|
154
159
|
});
|
|
155
|
-
if (!allowed)
|
|
160
|
+
if (!allowed) {
|
|
161
|
+
await this.sendCorrection(conn, env.from, op, current);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
156
164
|
if (op.kind === "upsert") {
|
|
157
165
|
const ownerId = current?.ownerId ?? conn.userId;
|
|
158
166
|
const stampedElement = { ...op.element, ownerId };
|
|
@@ -173,6 +181,18 @@ var SyncHub = class {
|
|
|
173
181
|
);
|
|
174
182
|
}
|
|
175
183
|
}
|
|
184
|
+
async sendCorrection(conn, from, op, current) {
|
|
185
|
+
let correction;
|
|
186
|
+
if (op.kind === "upsert") {
|
|
187
|
+
correction = current ? { kind: "upsert", element: current } : { kind: "remove", id: op.element.id };
|
|
188
|
+
} else if (op.kind === "remove") {
|
|
189
|
+
correction = current ? { kind: "upsert", element: current } : void 0;
|
|
190
|
+
} else if (op.kind === "clear") {
|
|
191
|
+
const elements = await this.backend.snapshot(conn.room);
|
|
192
|
+
correction = { kind: "snapshot", to: from, elements };
|
|
193
|
+
}
|
|
194
|
+
if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
|
|
195
|
+
}
|
|
176
196
|
onFanout(payload) {
|
|
177
197
|
let env;
|
|
178
198
|
try {
|
|
@@ -200,6 +220,39 @@ var SyncHub = class {
|
|
|
200
220
|
|
|
201
221
|
// src/create-sync-server.ts
|
|
202
222
|
var import_ws = require("ws");
|
|
223
|
+
|
|
224
|
+
// src/heartbeat.ts
|
|
225
|
+
function startHeartbeat(wss, intervalMs) {
|
|
226
|
+
if (intervalMs <= 0) {
|
|
227
|
+
return {
|
|
228
|
+
track: () => {
|
|
229
|
+
},
|
|
230
|
+
stop: () => {
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const alive = /* @__PURE__ */ new WeakMap();
|
|
235
|
+
const track = (ws) => {
|
|
236
|
+
alive.set(ws, true);
|
|
237
|
+
ws.on("pong", () => alive.set(ws, true));
|
|
238
|
+
};
|
|
239
|
+
const interval = setInterval(() => {
|
|
240
|
+
for (const ws of wss.clients) {
|
|
241
|
+
try {
|
|
242
|
+
if (alive.get(ws) === false) {
|
|
243
|
+
ws.terminate();
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
alive.set(ws, false);
|
|
247
|
+
ws.ping();
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}, intervalMs);
|
|
252
|
+
return { track, stop: () => clearInterval(interval) };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/create-sync-server.ts
|
|
203
256
|
function createSyncServer(options = {}) {
|
|
204
257
|
const hub = new SyncHub({
|
|
205
258
|
backend: options.backend,
|
|
@@ -208,12 +261,14 @@ function createSyncServer(options = {}) {
|
|
|
208
261
|
authorize: options.authorize
|
|
209
262
|
});
|
|
210
263
|
const wss = options.server ? new import_ws.WebSocketServer({ server: options.server }) : new import_ws.WebSocketServer({ port: options.port ?? 0 });
|
|
264
|
+
const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
|
|
211
265
|
let counter = 0;
|
|
212
266
|
wss.on("connection", (ws, req) => {
|
|
267
|
+
heartbeat.track(ws);
|
|
213
268
|
const url = new URL(req.url ?? "", "http://localhost");
|
|
214
269
|
const room = url.searchParams.get("room");
|
|
215
270
|
if (!room) {
|
|
216
|
-
ws.close(
|
|
271
|
+
ws.close(4400, "room required");
|
|
217
272
|
return;
|
|
218
273
|
}
|
|
219
274
|
const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -264,6 +319,7 @@ function createSyncServer(options = {}) {
|
|
|
264
319
|
hub,
|
|
265
320
|
wss,
|
|
266
321
|
close: () => new Promise((resolve) => {
|
|
322
|
+
heartbeat.stop();
|
|
267
323
|
hub.close();
|
|
268
324
|
wss.close(() => resolve());
|
|
269
325
|
})
|
|
@@ -274,6 +330,7 @@ function createSyncServer(options = {}) {
|
|
|
274
330
|
InMemoryHubFanout,
|
|
275
331
|
MemoryHubBackend,
|
|
276
332
|
SyncHub,
|
|
277
|
-
createSyncServer
|
|
333
|
+
createSyncServer,
|
|
334
|
+
startHeartbeat
|
|
278
335
|
});
|
|
279
336
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/create-sync-server.ts"],"sourcesContent":["export { SyncHub } from './sync-hub';\nexport type { SyncHubOptions, Connection } from './sync-hub';\nexport { MemoryHubBackend } from './memory-hub-backend';\nexport type { HubBackend } from './hub-backend';\nexport { createSyncServer } from './create-sync-server';\nexport type { CreateSyncServerOptions } from './create-sync-server';\nexport { InMemoryHubFanout } from './hub-fanout';\nexport type { HubFanout } from './hub-fanout';\nexport type { AuthInfo, AuthResult, Authenticate } from './authenticate';\nexport type { Authorize, AuthorizeContext, OwnedElement } from './authorize';\n","import { parseEnvelope, type SyncOp } from '@fieldnotes/sync';\nimport { MemoryHubBackend } from './memory-hub-backend';\nimport { InMemoryHubFanout, type HubFanout } from './hub-fanout';\nimport type { HubBackend } from './hub-backend';\nimport type { Authorize, OwnedElement } from './authorize';\n\nexport interface Connection {\n id: string;\n room: string;\n userId?: string;\n role?: string;\n send(message: string): void;\n}\n\nexport interface SyncHubOptions {\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authorize?: Authorize;\n}\n\nconst HUB_FROM = 'hub';\n\nfunction generateInstanceId(): string {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')\n return crypto.randomUUID();\n return `i-${Math.random().toString(36).slice(2)}`;\n}\n\nexport class SyncHub {\n private readonly backend: HubBackend;\n private readonly conns = new Map<string, Connection>();\n private readonly rooms = new Map<string, Set<string>>(); // room → connIds\n private readonly roomQueues = new Map<string, Promise<void>>(); // room → serial tail\n private readonly instanceId: string;\n private readonly fanout: HubFanout;\n private readonly fanoutUnsub: () => void;\n private readonly authorize?: Authorize;\n\n constructor(options: SyncHubOptions = {}) {\n this.backend = options.backend ?? new MemoryHubBackend();\n this.instanceId = options.instanceId ?? generateInstanceId();\n this.fanout = options.fanout ?? new InMemoryHubFanout();\n this.authorize = options.authorize;\n this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));\n }\n\n addConnection(conn: Connection): void {\n this.conns.set(conn.id, conn);\n let set = this.rooms.get(conn.room);\n if (!set) {\n set = new Set();\n this.rooms.set(conn.room, set);\n }\n set.add(conn.id);\n }\n\n removeConnection(connId: string): void {\n const conn = this.conns.get(connId);\n if (!conn) return;\n this.conns.delete(connId);\n const members = this.rooms.get(conn.room);\n if (members) {\n members.delete(connId);\n if (members.size === 0) {\n this.rooms.delete(conn.room);\n this.roomQueues.delete(conn.room);\n }\n }\n }\n\n roomCount(): number {\n return this.rooms.size;\n }\n\n handleMessage(connId: string, message: string): Promise<void> {\n const conn = this.conns.get(connId);\n if (!conn) return Promise.resolve();\n const room = conn.room;\n const prev = this.roomQueues.get(room) ?? Promise.resolve();\n const next = prev\n .then(() => this.process(conn, message))\n .catch(() => {\n // swallow so one failed message never wedges the room's serial queue\n });\n this.roomQueues.set(room, next);\n return next;\n }\n\n private async process(conn: Connection, message: string): Promise<void> {\n const env = parseEnvelope(message);\n if (!env) return;\n const op = env.op;\n if (op.kind === 'request-snapshot') {\n const elements = await this.backend.snapshot(conn.room);\n conn.send(\n JSON.stringify({ from: HUB_FROM, op: { kind: 'snapshot', to: env.from, elements } }),\n );\n } else if (op.kind === 'upsert' || op.kind === 'remove' || op.kind === 'clear') {\n let outboundOp: SyncOp = op;\n let outboundMessage = message;\n if (this.authorize) {\n const id = op.kind === 'upsert' ? op.element.id : op.kind === 'remove' ? op.id : undefined;\n const current: OwnedElement | undefined =\n id !== undefined ? await this.backend.get(conn.room, id) : undefined;\n const allowed = await this.authorize({\n userId: conn.userId,\n role: conn.role,\n room: conn.room,\n op,\n currentElement: current,\n });\n if (!allowed) return;\n if (op.kind === 'upsert') {\n const ownerId = current?.ownerId ?? conn.userId;\n const stampedElement: OwnedElement = { ...op.element, ownerId };\n outboundOp = { kind: 'upsert', element: stampedElement };\n outboundMessage = JSON.stringify({ from: env.from, op: outboundOp });\n }\n }\n await this.backend.apply(conn.room, outboundOp);\n const members = this.rooms.get(conn.room);\n if (members) {\n for (const id of members) {\n if (id === conn.id) continue;\n this.conns.get(id)?.send(outboundMessage);\n }\n }\n this.fanout.publish(\n JSON.stringify({ o: this.instanceId, room: conn.room, m: outboundMessage }),\n );\n }\n // 'snapshot' from a client → ignored\n }\n\n private onFanout(payload: string): void {\n // Off the serial queue on purpose: forward-only (no backend re-apply — the origin already applied to the\n // SHARED backend), and delivery is already ordered. Do not wrap this in roomQueues.\n let env: { o?: unknown; room?: unknown; m?: unknown };\n try {\n env = JSON.parse(payload);\n } catch {\n return;\n }\n if (typeof env.o !== 'string' || typeof env.room !== 'string' || typeof env.m !== 'string')\n return;\n if (env.o === this.instanceId) return; // our own publish — already forwarded locally\n const members = this.rooms.get(env.room);\n if (!members) return;\n const m = env.m;\n for (const id of members) {\n try {\n this.conns.get(id)?.send(m);\n } catch {\n /* a throwing socket must not break the fanout loop */\n }\n }\n }\n\n close(): void {\n this.fanoutUnsub();\n }\n}\n","import { applyOpToMap, type SyncOp } from '@fieldnotes/sync';\nimport type { CanvasElement } from '@fieldnotes/core';\nimport type { HubBackend } from './hub-backend';\n\nexport class MemoryHubBackend implements HubBackend {\n private rooms = new Map<string, Map<string, CanvasElement>>();\n\n private room(id: string): Map<string, CanvasElement> {\n let r = this.rooms.get(id);\n if (!r) {\n r = new Map();\n this.rooms.set(id, r);\n }\n return r;\n }\n\n async snapshot(room: string): Promise<CanvasElement[]> {\n return [...this.room(room).values()];\n }\n\n async get(room: string, id: string): Promise<CanvasElement | undefined> {\n return this.room(room).get(id);\n }\n\n async apply(room: string, op: SyncOp): Promise<void> {\n applyOpToMap(this.room(room), op);\n }\n}\n","export interface HubFanout {\r\n publish(payload: string): void;\r\n subscribe(handler: (payload: string) => void): () => void;\r\n close?(): void;\r\n}\r\n\r\nexport class InMemoryHubFanout implements HubFanout {\r\n private readonly handlers = new Set<(payload: string) => void>();\r\n\r\n publish(payload: string): void {\r\n for (const h of this.handlers) {\r\n try {\r\n h(payload);\r\n } catch {\r\n /* one throwing subscriber must not break the publish loop */\r\n }\r\n }\r\n }\r\n\r\n subscribe(handler: (payload: string) => void): () => void {\r\n this.handlers.add(handler);\r\n return () => this.handlers.delete(handler);\r\n }\r\n}\r\n","import { WebSocketServer } from 'ws';\nimport type { Server } from 'http';\nimport { SyncHub } from './sync-hub';\nimport type { HubBackend } from './hub-backend';\nimport type { HubFanout } from './hub-fanout';\nimport type { Authenticate } from './authenticate';\nimport type { Authorize } from './authorize';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authenticate?: Authenticate;\n authorize?: Authorize;\n}\n\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\n hub: SyncHub;\n wss: WebSocketServer;\n close: () => Promise<void>;\n} {\n const hub = new SyncHub({\n backend: options.backend,\n fanout: options.fanout,\n instanceId: options.instanceId,\n authorize: options.authorize,\n });\n const wss = options.server\n ? new WebSocketServer({ server: options.server })\n : new WebSocketServer({ port: options.port ?? 0 });\n let counter = 0;\n wss.on('connection', (ws, req) => {\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(1008, 'room required');\n return;\n }\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\n\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\n let closed = false;\n const queue: string[] = [];\n const MAX_QUEUE = 100;\n\n const send = (m: string) => {\n try {\n ws.send(m);\n } catch {\n /* socket closed mid-send */\n }\n };\n\n ws.on('message', (data) => {\n const msg = String(data);\n if (state === 'rejected') return;\n if (state === 'pending') {\n if (queue.length < MAX_QUEUE) queue.push(msg);\n return;\n }\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\n });\n ws.on('close', () => {\n closed = true;\n if (state === 'ready') hub.removeConnection(connId);\n });\n\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\n .then((result) => {\n if (closed) return;\n if (!result) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n return;\n }\n state = 'ready';\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\n for (const m of queue) {\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\n }\n queue.length = 0;\n })\n .catch(() => {\n if (!closed) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n }\n });\n });\n return {\n hub,\n wss,\n close: () =>\n new Promise<void>((resolve) => {\n hub.close();\n wss.close(() => resolve());\n }),\n };\n}\n"],"mappings":"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","names":["import_sync"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/create-sync-server.ts","../src/heartbeat.ts"],"sourcesContent":["export { SyncHub } from './sync-hub';\r\nexport type { SyncHubOptions, Connection } from './sync-hub';\r\nexport { MemoryHubBackend } from './memory-hub-backend';\r\nexport type { HubBackend } from './hub-backend';\r\nexport { createSyncServer } from './create-sync-server';\r\nexport type { CreateSyncServerOptions } from './create-sync-server';\r\nexport { InMemoryHubFanout } from './hub-fanout';\r\nexport type { HubFanout } from './hub-fanout';\r\nexport type { AuthInfo, AuthResult, Authenticate } from './authenticate';\r\nexport type { Authorize, AuthorizeContext, OwnedElement } from './authorize';\r\nexport { startHeartbeat } from './heartbeat';\r\nexport type { Heartbeat, HeartbeatSocket, HeartbeatServer } from './heartbeat';\r\n","import { parseEnvelope, type SyncOp } from '@fieldnotes/sync';\r\nimport { MemoryHubBackend } from './memory-hub-backend';\r\nimport { InMemoryHubFanout, type HubFanout } from './hub-fanout';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { Authorize, OwnedElement } from './authorize';\r\n\r\nexport interface Connection {\r\n id: string;\r\n room: string;\r\n userId?: string;\r\n role?: string;\r\n send(message: string): void;\r\n}\r\n\r\nexport interface SyncHubOptions {\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authorize?: Authorize;\r\n}\r\n\r\nconst HUB_FROM = 'hub';\r\n\r\nfunction generateInstanceId(): string {\r\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')\r\n return crypto.randomUUID();\r\n return `i-${Math.random().toString(36).slice(2)}`;\r\n}\r\n\r\nexport class SyncHub {\r\n private readonly backend: HubBackend;\r\n private readonly conns = new Map<string, Connection>();\r\n private readonly rooms = new Map<string, Set<string>>(); // room → connIds\r\n private readonly roomQueues = new Map<string, Promise<void>>(); // room → serial tail\r\n private readonly instanceId: string;\r\n private readonly fanout: HubFanout;\r\n private readonly fanoutUnsub: () => void;\r\n private readonly authorize?: Authorize;\r\n\r\n constructor(options: SyncHubOptions = {}) {\r\n this.backend = options.backend ?? new MemoryHubBackend();\r\n this.instanceId = options.instanceId ?? generateInstanceId();\r\n this.fanout = options.fanout ?? new InMemoryHubFanout();\r\n this.authorize = options.authorize;\r\n this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));\r\n }\r\n\r\n addConnection(conn: Connection): void {\r\n this.conns.set(conn.id, conn);\r\n let set = this.rooms.get(conn.room);\r\n if (!set) {\r\n set = new Set();\r\n this.rooms.set(conn.room, set);\r\n }\r\n set.add(conn.id);\r\n }\r\n\r\n removeConnection(connId: string): void {\r\n const conn = this.conns.get(connId);\r\n if (!conn) return;\r\n this.conns.delete(connId);\r\n const members = this.rooms.get(conn.room);\r\n if (members) {\r\n members.delete(connId);\r\n if (members.size === 0) {\r\n this.rooms.delete(conn.room);\r\n this.roomQueues.delete(conn.room);\r\n }\r\n }\r\n }\r\n\r\n roomCount(): number {\r\n return this.rooms.size;\r\n }\r\n\r\n handleMessage(connId: string, message: string): Promise<void> {\r\n const conn = this.conns.get(connId);\r\n if (!conn) return Promise.resolve();\r\n const room = conn.room;\r\n const prev = this.roomQueues.get(room) ?? Promise.resolve();\r\n const next = prev\r\n .then(() => this.process(conn, message))\r\n .catch(() => {\r\n // swallow so one failed message never wedges the room's serial queue\r\n });\r\n this.roomQueues.set(room, next);\r\n return next;\r\n }\r\n\r\n private async process(conn: Connection, message: string): Promise<void> {\r\n const env = parseEnvelope(message);\r\n if (!env) return;\r\n const op = env.op;\r\n if (op.kind === 'request-snapshot') {\r\n const elements = await this.backend.snapshot(conn.room);\r\n conn.send(\r\n JSON.stringify({ from: HUB_FROM, op: { kind: 'snapshot', to: env.from, elements } }),\r\n );\r\n } else if (op.kind === 'upsert' || op.kind === 'remove' || op.kind === 'clear') {\r\n let outboundOp: SyncOp = op;\r\n let outboundMessage = message;\r\n if (this.authorize) {\r\n const id = op.kind === 'upsert' ? op.element.id : op.kind === 'remove' ? op.id : undefined;\r\n const current: OwnedElement | undefined =\r\n id !== undefined ? await this.backend.get(conn.room, id) : undefined;\r\n const allowed = await this.authorize({\r\n userId: conn.userId,\r\n role: conn.role,\r\n room: conn.room,\r\n op,\r\n currentElement: current,\r\n });\r\n if (!allowed) {\r\n await this.sendCorrection(conn, env.from, op, current);\r\n return;\r\n }\r\n if (op.kind === 'upsert') {\r\n const ownerId = current?.ownerId ?? conn.userId;\r\n const stampedElement: OwnedElement = { ...op.element, ownerId };\r\n outboundOp = { kind: 'upsert', element: stampedElement };\r\n outboundMessage = JSON.stringify({ from: env.from, op: outboundOp });\r\n }\r\n }\r\n await this.backend.apply(conn.room, outboundOp);\r\n const members = this.rooms.get(conn.room);\r\n if (members) {\r\n for (const id of members) {\r\n if (id === conn.id) continue;\r\n this.conns.get(id)?.send(outboundMessage);\r\n }\r\n }\r\n this.fanout.publish(\r\n JSON.stringify({ o: this.instanceId, room: conn.room, m: outboundMessage }),\r\n );\r\n }\r\n // 'snapshot' from a client → ignored\r\n }\r\n\r\n private async sendCorrection(\r\n conn: Connection,\r\n from: string,\r\n op: SyncOp,\r\n current: OwnedElement | undefined,\r\n ): Promise<void> {\r\n let correction: SyncOp | undefined;\r\n if (op.kind === 'upsert') {\r\n correction = current\r\n ? { kind: 'upsert', element: current }\r\n : { kind: 'remove', id: op.element.id };\r\n } else if (op.kind === 'remove') {\r\n correction = current ? { kind: 'upsert', element: current } : undefined;\r\n } else if (op.kind === 'clear') {\r\n const elements = await this.backend.snapshot(conn.room);\r\n correction = { kind: 'snapshot', to: from, elements };\r\n }\r\n if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));\r\n }\r\n\r\n private onFanout(payload: string): void {\r\n // Off the serial queue on purpose: forward-only (no backend re-apply — the origin already applied to the\r\n // SHARED backend), and delivery is already ordered. Do not wrap this in roomQueues.\r\n let env: { o?: unknown; room?: unknown; m?: unknown };\r\n try {\r\n env = JSON.parse(payload);\r\n } catch {\r\n return;\r\n }\r\n if (typeof env.o !== 'string' || typeof env.room !== 'string' || typeof env.m !== 'string')\r\n return;\r\n if (env.o === this.instanceId) return; // our own publish — already forwarded locally\r\n const members = this.rooms.get(env.room);\r\n if (!members) return;\r\n const m = env.m;\r\n for (const id of members) {\r\n try {\r\n this.conns.get(id)?.send(m);\r\n } catch {\r\n /* a throwing socket must not break the fanout loop */\r\n }\r\n }\r\n }\r\n\r\n close(): void {\r\n this.fanoutUnsub();\r\n }\r\n}\r\n","import { applyOpToMap, type SyncOp } from '@fieldnotes/sync';\r\nimport type { CanvasElement } from '@fieldnotes/core';\r\nimport type { HubBackend } from './hub-backend';\r\n\r\nexport class MemoryHubBackend implements HubBackend {\r\n private rooms = new Map<string, Map<string, CanvasElement>>();\r\n\r\n private room(id: string): Map<string, CanvasElement> {\r\n let r = this.rooms.get(id);\r\n if (!r) {\r\n r = new Map();\r\n this.rooms.set(id, r);\r\n }\r\n return r;\r\n }\r\n\r\n async snapshot(room: string): Promise<CanvasElement[]> {\r\n return [...this.room(room).values()];\r\n }\r\n\r\n async get(room: string, id: string): Promise<CanvasElement | undefined> {\r\n return this.room(room).get(id);\r\n }\r\n\r\n async apply(room: string, op: SyncOp): Promise<void> {\r\n if (op.kind === 'clear') {\r\n this.rooms.delete(room);\r\n return;\r\n }\r\n applyOpToMap(this.room(room), op);\r\n }\r\n}\r\n","export interface HubFanout {\r\n publish(payload: string): void;\r\n subscribe(handler: (payload: string) => void): () => void;\r\n close?(): void;\r\n}\r\n\r\nexport class InMemoryHubFanout implements HubFanout {\r\n private readonly handlers = new Set<(payload: string) => void>();\r\n\r\n publish(payload: string): void {\r\n for (const h of this.handlers) {\r\n try {\r\n h(payload);\r\n } catch {\r\n /* one throwing subscriber must not break the publish loop */\r\n }\r\n }\r\n }\r\n\r\n subscribe(handler: (payload: string) => void): () => void {\r\n this.handlers.add(handler);\r\n return () => this.handlers.delete(handler);\r\n }\r\n}\r\n","import { WebSocketServer } from 'ws';\r\nimport type { Server } from 'http';\r\nimport { SyncHub } from './sync-hub';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { HubFanout } from './hub-fanout';\r\nimport type { Authenticate } from './authenticate';\r\nimport type { Authorize } from './authorize';\r\nimport { startHeartbeat } from './heartbeat';\r\n\r\nexport interface CreateSyncServerOptions {\r\n port?: number;\r\n server?: Server;\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authenticate?: Authenticate;\r\n authorize?: Authorize;\r\n heartbeatIntervalMs?: number;\r\n}\r\n\r\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\r\n hub: SyncHub;\r\n wss: WebSocketServer;\r\n close: () => Promise<void>;\r\n} {\r\n const hub = new SyncHub({\r\n backend: options.backend,\r\n fanout: options.fanout,\r\n instanceId: options.instanceId,\r\n authorize: options.authorize,\r\n });\r\n const wss = options.server\r\n ? new WebSocketServer({ server: options.server })\r\n : new WebSocketServer({ port: options.port ?? 0 });\r\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\r\n let counter = 0;\r\n wss.on('connection', (ws, req) => {\r\n heartbeat.track(ws);\r\n const url = new URL(req.url ?? '', 'http://localhost');\r\n const room = url.searchParams.get('room');\r\n if (!room) {\r\n ws.close(4400, 'room required');\r\n return;\r\n }\r\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\r\n\r\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\r\n let closed = false;\r\n const queue: string[] = [];\r\n const MAX_QUEUE = 100;\r\n\r\n const send = (m: string) => {\r\n try {\r\n ws.send(m);\r\n } catch {\r\n /* socket closed mid-send */\r\n }\r\n };\r\n\r\n ws.on('message', (data) => {\r\n const msg = String(data);\r\n if (state === 'rejected') return;\r\n if (state === 'pending') {\r\n if (queue.length < MAX_QUEUE) queue.push(msg);\r\n return;\r\n }\r\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\r\n });\r\n ws.on('close', () => {\r\n closed = true;\r\n if (state === 'ready') hub.removeConnection(connId);\r\n });\r\n\r\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\r\n .then((result) => {\r\n if (closed) return;\r\n if (!result) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n return;\r\n }\r\n state = 'ready';\r\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\r\n for (const m of queue) {\r\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\r\n }\r\n queue.length = 0;\r\n })\r\n .catch(() => {\r\n if (!closed) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n }\r\n });\r\n });\r\n return {\r\n hub,\r\n wss,\r\n close: () =>\r\n new Promise<void>((resolve) => {\r\n heartbeat.stop();\r\n hub.close();\r\n wss.close(() => resolve());\r\n }),\r\n };\r\n}\r\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n"],"mappings":"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","names":["import_sync"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -60,6 +60,7 @@ declare class SyncHub {
|
|
|
60
60
|
roomCount(): number;
|
|
61
61
|
handleMessage(connId: string, message: string): Promise<void>;
|
|
62
62
|
private process;
|
|
63
|
+
private sendCorrection;
|
|
63
64
|
private onFanout;
|
|
64
65
|
close(): void;
|
|
65
66
|
}
|
|
@@ -90,6 +91,7 @@ interface CreateSyncServerOptions {
|
|
|
90
91
|
instanceId?: string;
|
|
91
92
|
authenticate?: Authenticate;
|
|
92
93
|
authorize?: Authorize;
|
|
94
|
+
heartbeatIntervalMs?: number;
|
|
93
95
|
}
|
|
94
96
|
declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
95
97
|
hub: SyncHub;
|
|
@@ -97,4 +99,18 @@ declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
|
97
99
|
close: () => Promise<void>;
|
|
98
100
|
};
|
|
99
101
|
|
|
100
|
-
|
|
102
|
+
interface HeartbeatSocket {
|
|
103
|
+
ping(): void;
|
|
104
|
+
terminate(): void;
|
|
105
|
+
on(event: 'pong', listener: () => void): void;
|
|
106
|
+
}
|
|
107
|
+
interface HeartbeatServer {
|
|
108
|
+
clients: Set<HeartbeatSocket>;
|
|
109
|
+
}
|
|
110
|
+
interface Heartbeat {
|
|
111
|
+
track(ws: HeartbeatSocket): void;
|
|
112
|
+
stop(): void;
|
|
113
|
+
}
|
|
114
|
+
declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
|
|
115
|
+
|
|
116
|
+
export { type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
|
package/dist/index.d.ts
CHANGED
|
@@ -60,6 +60,7 @@ declare class SyncHub {
|
|
|
60
60
|
roomCount(): number;
|
|
61
61
|
handleMessage(connId: string, message: string): Promise<void>;
|
|
62
62
|
private process;
|
|
63
|
+
private sendCorrection;
|
|
63
64
|
private onFanout;
|
|
64
65
|
close(): void;
|
|
65
66
|
}
|
|
@@ -90,6 +91,7 @@ interface CreateSyncServerOptions {
|
|
|
90
91
|
instanceId?: string;
|
|
91
92
|
authenticate?: Authenticate;
|
|
92
93
|
authorize?: Authorize;
|
|
94
|
+
heartbeatIntervalMs?: number;
|
|
93
95
|
}
|
|
94
96
|
declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
95
97
|
hub: SyncHub;
|
|
@@ -97,4 +99,18 @@ declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
|
97
99
|
close: () => Promise<void>;
|
|
98
100
|
};
|
|
99
101
|
|
|
100
|
-
|
|
102
|
+
interface HeartbeatSocket {
|
|
103
|
+
ping(): void;
|
|
104
|
+
terminate(): void;
|
|
105
|
+
on(event: 'pong', listener: () => void): void;
|
|
106
|
+
}
|
|
107
|
+
interface HeartbeatServer {
|
|
108
|
+
clients: Set<HeartbeatSocket>;
|
|
109
|
+
}
|
|
110
|
+
interface Heartbeat {
|
|
111
|
+
track(ws: HeartbeatSocket): void;
|
|
112
|
+
stop(): void;
|
|
113
|
+
}
|
|
114
|
+
declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
|
|
115
|
+
|
|
116
|
+
export { type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,10 @@ var MemoryHubBackend = class {
|
|
|
20
20
|
return this.room(room).get(id);
|
|
21
21
|
}
|
|
22
22
|
async apply(room, op) {
|
|
23
|
+
if (op.kind === "clear") {
|
|
24
|
+
this.rooms.delete(room);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
23
27
|
applyOpToMap(this.room(room), op);
|
|
24
28
|
}
|
|
25
29
|
};
|
|
@@ -123,7 +127,10 @@ var SyncHub = class {
|
|
|
123
127
|
op,
|
|
124
128
|
currentElement: current
|
|
125
129
|
});
|
|
126
|
-
if (!allowed)
|
|
130
|
+
if (!allowed) {
|
|
131
|
+
await this.sendCorrection(conn, env.from, op, current);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
127
134
|
if (op.kind === "upsert") {
|
|
128
135
|
const ownerId = current?.ownerId ?? conn.userId;
|
|
129
136
|
const stampedElement = { ...op.element, ownerId };
|
|
@@ -144,6 +151,18 @@ var SyncHub = class {
|
|
|
144
151
|
);
|
|
145
152
|
}
|
|
146
153
|
}
|
|
154
|
+
async sendCorrection(conn, from, op, current) {
|
|
155
|
+
let correction;
|
|
156
|
+
if (op.kind === "upsert") {
|
|
157
|
+
correction = current ? { kind: "upsert", element: current } : { kind: "remove", id: op.element.id };
|
|
158
|
+
} else if (op.kind === "remove") {
|
|
159
|
+
correction = current ? { kind: "upsert", element: current } : void 0;
|
|
160
|
+
} else if (op.kind === "clear") {
|
|
161
|
+
const elements = await this.backend.snapshot(conn.room);
|
|
162
|
+
correction = { kind: "snapshot", to: from, elements };
|
|
163
|
+
}
|
|
164
|
+
if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
|
|
165
|
+
}
|
|
147
166
|
onFanout(payload) {
|
|
148
167
|
let env;
|
|
149
168
|
try {
|
|
@@ -171,6 +190,39 @@ var SyncHub = class {
|
|
|
171
190
|
|
|
172
191
|
// src/create-sync-server.ts
|
|
173
192
|
import { WebSocketServer } from "ws";
|
|
193
|
+
|
|
194
|
+
// src/heartbeat.ts
|
|
195
|
+
function startHeartbeat(wss, intervalMs) {
|
|
196
|
+
if (intervalMs <= 0) {
|
|
197
|
+
return {
|
|
198
|
+
track: () => {
|
|
199
|
+
},
|
|
200
|
+
stop: () => {
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const alive = /* @__PURE__ */ new WeakMap();
|
|
205
|
+
const track = (ws) => {
|
|
206
|
+
alive.set(ws, true);
|
|
207
|
+
ws.on("pong", () => alive.set(ws, true));
|
|
208
|
+
};
|
|
209
|
+
const interval = setInterval(() => {
|
|
210
|
+
for (const ws of wss.clients) {
|
|
211
|
+
try {
|
|
212
|
+
if (alive.get(ws) === false) {
|
|
213
|
+
ws.terminate();
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
alive.set(ws, false);
|
|
217
|
+
ws.ping();
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}, intervalMs);
|
|
222
|
+
return { track, stop: () => clearInterval(interval) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/create-sync-server.ts
|
|
174
226
|
function createSyncServer(options = {}) {
|
|
175
227
|
const hub = new SyncHub({
|
|
176
228
|
backend: options.backend,
|
|
@@ -179,12 +231,14 @@ function createSyncServer(options = {}) {
|
|
|
179
231
|
authorize: options.authorize
|
|
180
232
|
});
|
|
181
233
|
const wss = options.server ? new WebSocketServer({ server: options.server }) : new WebSocketServer({ port: options.port ?? 0 });
|
|
234
|
+
const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
|
|
182
235
|
let counter = 0;
|
|
183
236
|
wss.on("connection", (ws, req) => {
|
|
237
|
+
heartbeat.track(ws);
|
|
184
238
|
const url = new URL(req.url ?? "", "http://localhost");
|
|
185
239
|
const room = url.searchParams.get("room");
|
|
186
240
|
if (!room) {
|
|
187
|
-
ws.close(
|
|
241
|
+
ws.close(4400, "room required");
|
|
188
242
|
return;
|
|
189
243
|
}
|
|
190
244
|
const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -235,6 +289,7 @@ function createSyncServer(options = {}) {
|
|
|
235
289
|
hub,
|
|
236
290
|
wss,
|
|
237
291
|
close: () => new Promise((resolve) => {
|
|
292
|
+
heartbeat.stop();
|
|
238
293
|
hub.close();
|
|
239
294
|
wss.close(() => resolve());
|
|
240
295
|
})
|
|
@@ -244,6 +299,7 @@ export {
|
|
|
244
299
|
InMemoryHubFanout,
|
|
245
300
|
MemoryHubBackend,
|
|
246
301
|
SyncHub,
|
|
247
|
-
createSyncServer
|
|
302
|
+
createSyncServer,
|
|
303
|
+
startHeartbeat
|
|
248
304
|
};
|
|
249
305
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/create-sync-server.ts"],"sourcesContent":["import { parseEnvelope, type SyncOp } from '@fieldnotes/sync';\nimport { MemoryHubBackend } from './memory-hub-backend';\nimport { InMemoryHubFanout, type HubFanout } from './hub-fanout';\nimport type { HubBackend } from './hub-backend';\nimport type { Authorize, OwnedElement } from './authorize';\n\nexport interface Connection {\n id: string;\n room: string;\n userId?: string;\n role?: string;\n send(message: string): void;\n}\n\nexport interface SyncHubOptions {\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authorize?: Authorize;\n}\n\nconst HUB_FROM = 'hub';\n\nfunction generateInstanceId(): string {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')\n return crypto.randomUUID();\n return `i-${Math.random().toString(36).slice(2)}`;\n}\n\nexport class SyncHub {\n private readonly backend: HubBackend;\n private readonly conns = new Map<string, Connection>();\n private readonly rooms = new Map<string, Set<string>>(); // room → connIds\n private readonly roomQueues = new Map<string, Promise<void>>(); // room → serial tail\n private readonly instanceId: string;\n private readonly fanout: HubFanout;\n private readonly fanoutUnsub: () => void;\n private readonly authorize?: Authorize;\n\n constructor(options: SyncHubOptions = {}) {\n this.backend = options.backend ?? new MemoryHubBackend();\n this.instanceId = options.instanceId ?? generateInstanceId();\n this.fanout = options.fanout ?? new InMemoryHubFanout();\n this.authorize = options.authorize;\n this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));\n }\n\n addConnection(conn: Connection): void {\n this.conns.set(conn.id, conn);\n let set = this.rooms.get(conn.room);\n if (!set) {\n set = new Set();\n this.rooms.set(conn.room, set);\n }\n set.add(conn.id);\n }\n\n removeConnection(connId: string): void {\n const conn = this.conns.get(connId);\n if (!conn) return;\n this.conns.delete(connId);\n const members = this.rooms.get(conn.room);\n if (members) {\n members.delete(connId);\n if (members.size === 0) {\n this.rooms.delete(conn.room);\n this.roomQueues.delete(conn.room);\n }\n }\n }\n\n roomCount(): number {\n return this.rooms.size;\n }\n\n handleMessage(connId: string, message: string): Promise<void> {\n const conn = this.conns.get(connId);\n if (!conn) return Promise.resolve();\n const room = conn.room;\n const prev = this.roomQueues.get(room) ?? Promise.resolve();\n const next = prev\n .then(() => this.process(conn, message))\n .catch(() => {\n // swallow so one failed message never wedges the room's serial queue\n });\n this.roomQueues.set(room, next);\n return next;\n }\n\n private async process(conn: Connection, message: string): Promise<void> {\n const env = parseEnvelope(message);\n if (!env) return;\n const op = env.op;\n if (op.kind === 'request-snapshot') {\n const elements = await this.backend.snapshot(conn.room);\n conn.send(\n JSON.stringify({ from: HUB_FROM, op: { kind: 'snapshot', to: env.from, elements } }),\n );\n } else if (op.kind === 'upsert' || op.kind === 'remove' || op.kind === 'clear') {\n let outboundOp: SyncOp = op;\n let outboundMessage = message;\n if (this.authorize) {\n const id = op.kind === 'upsert' ? op.element.id : op.kind === 'remove' ? op.id : undefined;\n const current: OwnedElement | undefined =\n id !== undefined ? await this.backend.get(conn.room, id) : undefined;\n const allowed = await this.authorize({\n userId: conn.userId,\n role: conn.role,\n room: conn.room,\n op,\n currentElement: current,\n });\n if (!allowed) return;\n if (op.kind === 'upsert') {\n const ownerId = current?.ownerId ?? conn.userId;\n const stampedElement: OwnedElement = { ...op.element, ownerId };\n outboundOp = { kind: 'upsert', element: stampedElement };\n outboundMessage = JSON.stringify({ from: env.from, op: outboundOp });\n }\n }\n await this.backend.apply(conn.room, outboundOp);\n const members = this.rooms.get(conn.room);\n if (members) {\n for (const id of members) {\n if (id === conn.id) continue;\n this.conns.get(id)?.send(outboundMessage);\n }\n }\n this.fanout.publish(\n JSON.stringify({ o: this.instanceId, room: conn.room, m: outboundMessage }),\n );\n }\n // 'snapshot' from a client → ignored\n }\n\n private onFanout(payload: string): void {\n // Off the serial queue on purpose: forward-only (no backend re-apply — the origin already applied to the\n // SHARED backend), and delivery is already ordered. Do not wrap this in roomQueues.\n let env: { o?: unknown; room?: unknown; m?: unknown };\n try {\n env = JSON.parse(payload);\n } catch {\n return;\n }\n if (typeof env.o !== 'string' || typeof env.room !== 'string' || typeof env.m !== 'string')\n return;\n if (env.o === this.instanceId) return; // our own publish — already forwarded locally\n const members = this.rooms.get(env.room);\n if (!members) return;\n const m = env.m;\n for (const id of members) {\n try {\n this.conns.get(id)?.send(m);\n } catch {\n /* a throwing socket must not break the fanout loop */\n }\n }\n }\n\n close(): void {\n this.fanoutUnsub();\n }\n}\n","import { applyOpToMap, type SyncOp } from '@fieldnotes/sync';\nimport type { CanvasElement } from '@fieldnotes/core';\nimport type { HubBackend } from './hub-backend';\n\nexport class MemoryHubBackend implements HubBackend {\n private rooms = new Map<string, Map<string, CanvasElement>>();\n\n private room(id: string): Map<string, CanvasElement> {\n let r = this.rooms.get(id);\n if (!r) {\n r = new Map();\n this.rooms.set(id, r);\n }\n return r;\n }\n\n async snapshot(room: string): Promise<CanvasElement[]> {\n return [...this.room(room).values()];\n }\n\n async get(room: string, id: string): Promise<CanvasElement | undefined> {\n return this.room(room).get(id);\n }\n\n async apply(room: string, op: SyncOp): Promise<void> {\n applyOpToMap(this.room(room), op);\n }\n}\n","export interface HubFanout {\r\n publish(payload: string): void;\r\n subscribe(handler: (payload: string) => void): () => void;\r\n close?(): void;\r\n}\r\n\r\nexport class InMemoryHubFanout implements HubFanout {\r\n private readonly handlers = new Set<(payload: string) => void>();\r\n\r\n publish(payload: string): void {\r\n for (const h of this.handlers) {\r\n try {\r\n h(payload);\r\n } catch {\r\n /* one throwing subscriber must not break the publish loop */\r\n }\r\n }\r\n }\r\n\r\n subscribe(handler: (payload: string) => void): () => void {\r\n this.handlers.add(handler);\r\n return () => this.handlers.delete(handler);\r\n }\r\n}\r\n","import { WebSocketServer } from 'ws';\nimport type { Server } from 'http';\nimport { SyncHub } from './sync-hub';\nimport type { HubBackend } from './hub-backend';\nimport type { HubFanout } from './hub-fanout';\nimport type { Authenticate } from './authenticate';\nimport type { Authorize } from './authorize';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authenticate?: Authenticate;\n authorize?: Authorize;\n}\n\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\n hub: SyncHub;\n wss: WebSocketServer;\n close: () => Promise<void>;\n} {\n const hub = new SyncHub({\n backend: options.backend,\n fanout: options.fanout,\n instanceId: options.instanceId,\n authorize: options.authorize,\n });\n const wss = options.server\n ? new WebSocketServer({ server: options.server })\n : new WebSocketServer({ port: options.port ?? 0 });\n let counter = 0;\n wss.on('connection', (ws, req) => {\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(1008, 'room required');\n return;\n }\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\n\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\n let closed = false;\n const queue: string[] = [];\n const MAX_QUEUE = 100;\n\n const send = (m: string) => {\n try {\n ws.send(m);\n } catch {\n /* socket closed mid-send */\n }\n };\n\n ws.on('message', (data) => {\n const msg = String(data);\n if (state === 'rejected') return;\n if (state === 'pending') {\n if (queue.length < MAX_QUEUE) queue.push(msg);\n return;\n }\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\n });\n ws.on('close', () => {\n closed = true;\n if (state === 'ready') hub.removeConnection(connId);\n });\n\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\n .then((result) => {\n if (closed) return;\n if (!result) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n return;\n }\n state = 'ready';\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\n for (const m of queue) {\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\n }\n queue.length = 0;\n })\n .catch(() => {\n if (!closed) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n }\n });\n });\n return {\n hub,\n wss,\n close: () =>\n new Promise<void>((resolve) => {\n hub.close();\n wss.close(() => resolve());\n }),\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/create-sync-server.ts","../src/heartbeat.ts"],"sourcesContent":["import { parseEnvelope, type SyncOp } from '@fieldnotes/sync';\r\nimport { MemoryHubBackend } from './memory-hub-backend';\r\nimport { InMemoryHubFanout, type HubFanout } from './hub-fanout';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { Authorize, OwnedElement } from './authorize';\r\n\r\nexport interface Connection {\r\n id: string;\r\n room: string;\r\n userId?: string;\r\n role?: string;\r\n send(message: string): void;\r\n}\r\n\r\nexport interface SyncHubOptions {\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authorize?: Authorize;\r\n}\r\n\r\nconst HUB_FROM = 'hub';\r\n\r\nfunction generateInstanceId(): string {\r\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')\r\n return crypto.randomUUID();\r\n return `i-${Math.random().toString(36).slice(2)}`;\r\n}\r\n\r\nexport class SyncHub {\r\n private readonly backend: HubBackend;\r\n private readonly conns = new Map<string, Connection>();\r\n private readonly rooms = new Map<string, Set<string>>(); // room → connIds\r\n private readonly roomQueues = new Map<string, Promise<void>>(); // room → serial tail\r\n private readonly instanceId: string;\r\n private readonly fanout: HubFanout;\r\n private readonly fanoutUnsub: () => void;\r\n private readonly authorize?: Authorize;\r\n\r\n constructor(options: SyncHubOptions = {}) {\r\n this.backend = options.backend ?? new MemoryHubBackend();\r\n this.instanceId = options.instanceId ?? generateInstanceId();\r\n this.fanout = options.fanout ?? new InMemoryHubFanout();\r\n this.authorize = options.authorize;\r\n this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));\r\n }\r\n\r\n addConnection(conn: Connection): void {\r\n this.conns.set(conn.id, conn);\r\n let set = this.rooms.get(conn.room);\r\n if (!set) {\r\n set = new Set();\r\n this.rooms.set(conn.room, set);\r\n }\r\n set.add(conn.id);\r\n }\r\n\r\n removeConnection(connId: string): void {\r\n const conn = this.conns.get(connId);\r\n if (!conn) return;\r\n this.conns.delete(connId);\r\n const members = this.rooms.get(conn.room);\r\n if (members) {\r\n members.delete(connId);\r\n if (members.size === 0) {\r\n this.rooms.delete(conn.room);\r\n this.roomQueues.delete(conn.room);\r\n }\r\n }\r\n }\r\n\r\n roomCount(): number {\r\n return this.rooms.size;\r\n }\r\n\r\n handleMessage(connId: string, message: string): Promise<void> {\r\n const conn = this.conns.get(connId);\r\n if (!conn) return Promise.resolve();\r\n const room = conn.room;\r\n const prev = this.roomQueues.get(room) ?? Promise.resolve();\r\n const next = prev\r\n .then(() => this.process(conn, message))\r\n .catch(() => {\r\n // swallow so one failed message never wedges the room's serial queue\r\n });\r\n this.roomQueues.set(room, next);\r\n return next;\r\n }\r\n\r\n private async process(conn: Connection, message: string): Promise<void> {\r\n const env = parseEnvelope(message);\r\n if (!env) return;\r\n const op = env.op;\r\n if (op.kind === 'request-snapshot') {\r\n const elements = await this.backend.snapshot(conn.room);\r\n conn.send(\r\n JSON.stringify({ from: HUB_FROM, op: { kind: 'snapshot', to: env.from, elements } }),\r\n );\r\n } else if (op.kind === 'upsert' || op.kind === 'remove' || op.kind === 'clear') {\r\n let outboundOp: SyncOp = op;\r\n let outboundMessage = message;\r\n if (this.authorize) {\r\n const id = op.kind === 'upsert' ? op.element.id : op.kind === 'remove' ? op.id : undefined;\r\n const current: OwnedElement | undefined =\r\n id !== undefined ? await this.backend.get(conn.room, id) : undefined;\r\n const allowed = await this.authorize({\r\n userId: conn.userId,\r\n role: conn.role,\r\n room: conn.room,\r\n op,\r\n currentElement: current,\r\n });\r\n if (!allowed) {\r\n await this.sendCorrection(conn, env.from, op, current);\r\n return;\r\n }\r\n if (op.kind === 'upsert') {\r\n const ownerId = current?.ownerId ?? conn.userId;\r\n const stampedElement: OwnedElement = { ...op.element, ownerId };\r\n outboundOp = { kind: 'upsert', element: stampedElement };\r\n outboundMessage = JSON.stringify({ from: env.from, op: outboundOp });\r\n }\r\n }\r\n await this.backend.apply(conn.room, outboundOp);\r\n const members = this.rooms.get(conn.room);\r\n if (members) {\r\n for (const id of members) {\r\n if (id === conn.id) continue;\r\n this.conns.get(id)?.send(outboundMessage);\r\n }\r\n }\r\n this.fanout.publish(\r\n JSON.stringify({ o: this.instanceId, room: conn.room, m: outboundMessage }),\r\n );\r\n }\r\n // 'snapshot' from a client → ignored\r\n }\r\n\r\n private async sendCorrection(\r\n conn: Connection,\r\n from: string,\r\n op: SyncOp,\r\n current: OwnedElement | undefined,\r\n ): Promise<void> {\r\n let correction: SyncOp | undefined;\r\n if (op.kind === 'upsert') {\r\n correction = current\r\n ? { kind: 'upsert', element: current }\r\n : { kind: 'remove', id: op.element.id };\r\n } else if (op.kind === 'remove') {\r\n correction = current ? { kind: 'upsert', element: current } : undefined;\r\n } else if (op.kind === 'clear') {\r\n const elements = await this.backend.snapshot(conn.room);\r\n correction = { kind: 'snapshot', to: from, elements };\r\n }\r\n if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));\r\n }\r\n\r\n private onFanout(payload: string): void {\r\n // Off the serial queue on purpose: forward-only (no backend re-apply — the origin already applied to the\r\n // SHARED backend), and delivery is already ordered. Do not wrap this in roomQueues.\r\n let env: { o?: unknown; room?: unknown; m?: unknown };\r\n try {\r\n env = JSON.parse(payload);\r\n } catch {\r\n return;\r\n }\r\n if (typeof env.o !== 'string' || typeof env.room !== 'string' || typeof env.m !== 'string')\r\n return;\r\n if (env.o === this.instanceId) return; // our own publish — already forwarded locally\r\n const members = this.rooms.get(env.room);\r\n if (!members) return;\r\n const m = env.m;\r\n for (const id of members) {\r\n try {\r\n this.conns.get(id)?.send(m);\r\n } catch {\r\n /* a throwing socket must not break the fanout loop */\r\n }\r\n }\r\n }\r\n\r\n close(): void {\r\n this.fanoutUnsub();\r\n }\r\n}\r\n","import { applyOpToMap, type SyncOp } from '@fieldnotes/sync';\r\nimport type { CanvasElement } from '@fieldnotes/core';\r\nimport type { HubBackend } from './hub-backend';\r\n\r\nexport class MemoryHubBackend implements HubBackend {\r\n private rooms = new Map<string, Map<string, CanvasElement>>();\r\n\r\n private room(id: string): Map<string, CanvasElement> {\r\n let r = this.rooms.get(id);\r\n if (!r) {\r\n r = new Map();\r\n this.rooms.set(id, r);\r\n }\r\n return r;\r\n }\r\n\r\n async snapshot(room: string): Promise<CanvasElement[]> {\r\n return [...this.room(room).values()];\r\n }\r\n\r\n async get(room: string, id: string): Promise<CanvasElement | undefined> {\r\n return this.room(room).get(id);\r\n }\r\n\r\n async apply(room: string, op: SyncOp): Promise<void> {\r\n if (op.kind === 'clear') {\r\n this.rooms.delete(room);\r\n return;\r\n }\r\n applyOpToMap(this.room(room), op);\r\n }\r\n}\r\n","export interface HubFanout {\r\n publish(payload: string): void;\r\n subscribe(handler: (payload: string) => void): () => void;\r\n close?(): void;\r\n}\r\n\r\nexport class InMemoryHubFanout implements HubFanout {\r\n private readonly handlers = new Set<(payload: string) => void>();\r\n\r\n publish(payload: string): void {\r\n for (const h of this.handlers) {\r\n try {\r\n h(payload);\r\n } catch {\r\n /* one throwing subscriber must not break the publish loop */\r\n }\r\n }\r\n }\r\n\r\n subscribe(handler: (payload: string) => void): () => void {\r\n this.handlers.add(handler);\r\n return () => this.handlers.delete(handler);\r\n }\r\n}\r\n","import { WebSocketServer } from 'ws';\r\nimport type { Server } from 'http';\r\nimport { SyncHub } from './sync-hub';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { HubFanout } from './hub-fanout';\r\nimport type { Authenticate } from './authenticate';\r\nimport type { Authorize } from './authorize';\r\nimport { startHeartbeat } from './heartbeat';\r\n\r\nexport interface CreateSyncServerOptions {\r\n port?: number;\r\n server?: Server;\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authenticate?: Authenticate;\r\n authorize?: Authorize;\r\n heartbeatIntervalMs?: number;\r\n}\r\n\r\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\r\n hub: SyncHub;\r\n wss: WebSocketServer;\r\n close: () => Promise<void>;\r\n} {\r\n const hub = new SyncHub({\r\n backend: options.backend,\r\n fanout: options.fanout,\r\n instanceId: options.instanceId,\r\n authorize: options.authorize,\r\n });\r\n const wss = options.server\r\n ? new WebSocketServer({ server: options.server })\r\n : new WebSocketServer({ port: options.port ?? 0 });\r\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\r\n let counter = 0;\r\n wss.on('connection', (ws, req) => {\r\n heartbeat.track(ws);\r\n const url = new URL(req.url ?? '', 'http://localhost');\r\n const room = url.searchParams.get('room');\r\n if (!room) {\r\n ws.close(4400, 'room required');\r\n return;\r\n }\r\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\r\n\r\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\r\n let closed = false;\r\n const queue: string[] = [];\r\n const MAX_QUEUE = 100;\r\n\r\n const send = (m: string) => {\r\n try {\r\n ws.send(m);\r\n } catch {\r\n /* socket closed mid-send */\r\n }\r\n };\r\n\r\n ws.on('message', (data) => {\r\n const msg = String(data);\r\n if (state === 'rejected') return;\r\n if (state === 'pending') {\r\n if (queue.length < MAX_QUEUE) queue.push(msg);\r\n return;\r\n }\r\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\r\n });\r\n ws.on('close', () => {\r\n closed = true;\r\n if (state === 'ready') hub.removeConnection(connId);\r\n });\r\n\r\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\r\n .then((result) => {\r\n if (closed) return;\r\n if (!result) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n return;\r\n }\r\n state = 'ready';\r\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\r\n for (const m of queue) {\r\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\r\n }\r\n queue.length = 0;\r\n })\r\n .catch(() => {\r\n if (!closed) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n }\r\n });\r\n });\r\n return {\r\n hub,\r\n wss,\r\n close: () =>\r\n new Promise<void>((resolve) => {\r\n heartbeat.stop();\r\n hub.close();\r\n wss.close(() => resolve());\r\n }),\r\n };\r\n}\r\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fieldnotes/sync-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Authoritative WebSocket relay server for Field Notes real-time sync",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -38,14 +38,14 @@
|
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"ws": "^8.18.0",
|
|
41
|
-
"@fieldnotes/sync": "0.5.
|
|
41
|
+
"@fieldnotes/sync": "0.5.1"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/ws": "^8.5.12",
|
|
45
45
|
"@vitest/coverage-v8": "^4.1.0",
|
|
46
46
|
"tsup": "^8.5.1",
|
|
47
47
|
"vitest": "^4.1.0",
|
|
48
|
-
"@fieldnotes/core": "0.46.
|
|
48
|
+
"@fieldnotes/core": "0.46.1"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"build": "tsup",
|