@fieldnotes/sync-server 0.1.0 → 0.3.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 +47 -1
- package/dist/index.cjs +102 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -2
- package/dist/index.d.ts +35 -2
- package/dist/index.js +101 -13
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,6 +17,13 @@ applies and forwards.
|
|
|
17
17
|
authenticated backends are planned.
|
|
18
18
|
- **`createSyncServer`** — a runnable `ws` reference server. Connect with
|
|
19
19
|
`?room=<id>` in the query string; missing room closes the socket.
|
|
20
|
+
- **`HubFanout`** — cross-instance live fan-out seam. `SyncHub` publishes each live
|
|
21
|
+
op to the fanout and forwards ops it receives from other instances to its local
|
|
22
|
+
connections. The default `InMemoryHubFanout` is in-process (a no-op for a single
|
|
23
|
+
instance). For multiple relay instances to share live ops, pass a shared fanout
|
|
24
|
+
via `createSyncServer({ fanout })` (e.g. `RedisHubFanout` from
|
|
25
|
+
[`@fieldnotes/sync-redis`](../sync-redis)) **together with a shared backend** — a
|
|
26
|
+
shared fanout alone leaves a new joiner's snapshot stale.
|
|
20
27
|
|
|
21
28
|
## Usage
|
|
22
29
|
|
|
@@ -27,4 +34,43 @@ const { close } = createSyncServer({ port: 8080 });
|
|
|
27
34
|
// ws://localhost:8080?room=my-room
|
|
28
35
|
```
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
## Authentication
|
|
38
|
+
|
|
39
|
+
Pass an `authenticate` hook to gate connections:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createSyncServer } from '@fieldnotes/sync-server';
|
|
43
|
+
|
|
44
|
+
createSyncServer({
|
|
45
|
+
port: 8080,
|
|
46
|
+
authenticate: async ({ req, room }) => {
|
|
47
|
+
const token = new URL(req.url ?? '', 'http://x').searchParams.get('token');
|
|
48
|
+
const member = await myCampaign.verify(token, room); // your app's check (Redis/DB/JWT)
|
|
49
|
+
return member ? { userId: member.id, role: member.isDM ? 'dm' : 'player' } : null;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`authenticate({ req, room }) → { userId, role? } | null` runs on each connection and
|
|
55
|
+
may be sync or async. Returning `null` (or throwing) **rejects** the connection: the
|
|
56
|
+
socket is closed with WS code `4401` and the connection is never admitted to the room —
|
|
57
|
+
no membership, no snapshot. A resolved result admits the connection carrying its
|
|
58
|
+
`userId` and optional `role`.
|
|
59
|
+
|
|
60
|
+
With **no hook**, rooms stay open: every connection is admitted anonymously with
|
|
61
|
+
`userId = connId`. `role` is captured now and enforced in an upcoming release
|
|
62
|
+
(role-based authorization and per-viewer visibility filtering).
|
|
63
|
+
|
|
64
|
+
Messages that arrive during an async auth (notably the client's initial
|
|
65
|
+
`request-snapshot`) are queued and replayed once the connection is admitted, so the
|
|
66
|
+
first snapshot is never lost to the auth round-trip.
|
|
67
|
+
|
|
68
|
+
### Passing a token
|
|
69
|
+
|
|
70
|
+
A browser `WebSocket` can't set request headers, so pass the token as a URL query
|
|
71
|
+
param (`ws://relay?room=R&token=…`) and read it from `req.url` in `authenticate`. URLs
|
|
72
|
+
land in access/proxy logs, so prefer **short-lived / single-use** tokens. Non-browser
|
|
73
|
+
clients can instead put the token in `req.headers` (e.g. `Authorization`), which
|
|
74
|
+
`authenticate` reads directly.
|
|
75
|
+
|
|
76
|
+
A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).
|
package/dist/index.cjs
CHANGED
|
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
InMemoryHubFanout: () => InMemoryHubFanout,
|
|
23
24
|
MemoryHubBackend: () => MemoryHubBackend,
|
|
24
25
|
SyncHub: () => SyncHub,
|
|
25
26
|
createSyncServer: () => createSyncServer
|
|
@@ -49,8 +50,30 @@ var MemoryHubBackend = class {
|
|
|
49
50
|
}
|
|
50
51
|
};
|
|
51
52
|
|
|
53
|
+
// src/hub-fanout.ts
|
|
54
|
+
var InMemoryHubFanout = class {
|
|
55
|
+
handlers = /* @__PURE__ */ new Set();
|
|
56
|
+
publish(payload) {
|
|
57
|
+
for (const h of this.handlers) {
|
|
58
|
+
try {
|
|
59
|
+
h(payload);
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
subscribe(handler) {
|
|
65
|
+
this.handlers.add(handler);
|
|
66
|
+
return () => this.handlers.delete(handler);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
52
70
|
// src/sync-hub.ts
|
|
53
71
|
var HUB_FROM = "hub";
|
|
72
|
+
function generateInstanceId() {
|
|
73
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function")
|
|
74
|
+
return crypto.randomUUID();
|
|
75
|
+
return `i-${Math.random().toString(36).slice(2)}`;
|
|
76
|
+
}
|
|
54
77
|
var SyncHub = class {
|
|
55
78
|
backend;
|
|
56
79
|
conns = /* @__PURE__ */ new Map();
|
|
@@ -58,8 +81,14 @@ var SyncHub = class {
|
|
|
58
81
|
// room → connIds
|
|
59
82
|
roomQueues = /* @__PURE__ */ new Map();
|
|
60
83
|
// room → serial tail
|
|
84
|
+
instanceId;
|
|
85
|
+
fanout;
|
|
86
|
+
fanoutUnsub;
|
|
61
87
|
constructor(options = {}) {
|
|
62
88
|
this.backend = options.backend ?? new MemoryHubBackend();
|
|
89
|
+
this.instanceId = options.instanceId ?? generateInstanceId();
|
|
90
|
+
this.fanout = options.fanout ?? new InMemoryHubFanout();
|
|
91
|
+
this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
|
|
63
92
|
}
|
|
64
93
|
addConnection(conn) {
|
|
65
94
|
this.conns.set(conn.id, conn);
|
|
@@ -114,14 +143,42 @@ var SyncHub = class {
|
|
|
114
143
|
this.conns.get(id)?.send(message);
|
|
115
144
|
}
|
|
116
145
|
}
|
|
146
|
+
this.fanout.publish(JSON.stringify({ o: this.instanceId, room: conn.room, m: message }));
|
|
117
147
|
}
|
|
118
148
|
}
|
|
149
|
+
onFanout(payload) {
|
|
150
|
+
let env;
|
|
151
|
+
try {
|
|
152
|
+
env = JSON.parse(payload);
|
|
153
|
+
} catch {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.m !== "string")
|
|
157
|
+
return;
|
|
158
|
+
if (env.o === this.instanceId) return;
|
|
159
|
+
const members = this.rooms.get(env.room);
|
|
160
|
+
if (!members) return;
|
|
161
|
+
const m = env.m;
|
|
162
|
+
for (const id of members) {
|
|
163
|
+
try {
|
|
164
|
+
this.conns.get(id)?.send(m);
|
|
165
|
+
} catch {
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
close() {
|
|
170
|
+
this.fanoutUnsub();
|
|
171
|
+
}
|
|
119
172
|
};
|
|
120
173
|
|
|
121
174
|
// src/create-sync-server.ts
|
|
122
175
|
var import_ws = require("ws");
|
|
123
176
|
function createSyncServer(options = {}) {
|
|
124
|
-
const hub = new SyncHub({
|
|
177
|
+
const hub = new SyncHub({
|
|
178
|
+
backend: options.backend,
|
|
179
|
+
fanout: options.fanout,
|
|
180
|
+
instanceId: options.instanceId
|
|
181
|
+
});
|
|
125
182
|
const wss = options.server ? new import_ws.WebSocketServer({ server: options.server }) : new import_ws.WebSocketServer({ port: options.port ?? 0 });
|
|
126
183
|
let counter = 0;
|
|
127
184
|
wss.on("connection", (ws, req) => {
|
|
@@ -132,29 +189,61 @@ function createSyncServer(options = {}) {
|
|
|
132
189
|
return;
|
|
133
190
|
}
|
|
134
191
|
const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
192
|
+
let state = "pending";
|
|
193
|
+
let closed = false;
|
|
194
|
+
const queue = [];
|
|
195
|
+
const MAX_QUEUE = 100;
|
|
196
|
+
const send = (m) => {
|
|
197
|
+
try {
|
|
198
|
+
ws.send(m);
|
|
199
|
+
} catch {
|
|
143
200
|
}
|
|
144
|
-
}
|
|
201
|
+
};
|
|
145
202
|
ws.on("message", (data) => {
|
|
146
|
-
|
|
203
|
+
const msg = String(data);
|
|
204
|
+
if (state === "rejected") return;
|
|
205
|
+
if (state === "pending") {
|
|
206
|
+
if (queue.length < MAX_QUEUE) queue.push(msg);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
void hub.handleMessage(connId, msg).catch((err) => console.error("[sync-server]", err));
|
|
210
|
+
});
|
|
211
|
+
ws.on("close", () => {
|
|
212
|
+
closed = true;
|
|
213
|
+
if (state === "ready") hub.removeConnection(connId);
|
|
214
|
+
});
|
|
215
|
+
Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId }).then((result) => {
|
|
216
|
+
if (closed) return;
|
|
217
|
+
if (!result) {
|
|
218
|
+
state = "rejected";
|
|
219
|
+
ws.close(4401, "unauthorized");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
state = "ready";
|
|
223
|
+
hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });
|
|
224
|
+
for (const m of queue) {
|
|
225
|
+
void hub.handleMessage(connId, m).catch((err) => console.error("[sync-server]", err));
|
|
226
|
+
}
|
|
227
|
+
queue.length = 0;
|
|
228
|
+
}).catch(() => {
|
|
229
|
+
if (!closed) {
|
|
230
|
+
state = "rejected";
|
|
231
|
+
ws.close(4401, "unauthorized");
|
|
232
|
+
}
|
|
147
233
|
});
|
|
148
|
-
ws.on("close", () => hub.removeConnection(connId));
|
|
149
234
|
});
|
|
150
235
|
return {
|
|
151
236
|
hub,
|
|
152
237
|
wss,
|
|
153
|
-
close: () => new Promise((resolve) =>
|
|
238
|
+
close: () => new Promise((resolve) => {
|
|
239
|
+
hub.close();
|
|
240
|
+
wss.close(() => resolve());
|
|
241
|
+
})
|
|
154
242
|
};
|
|
155
243
|
}
|
|
156
244
|
// Annotate the CommonJS export names for ESM import in node:
|
|
157
245
|
0 && (module.exports = {
|
|
246
|
+
InMemoryHubFanout,
|
|
158
247
|
MemoryHubBackend,
|
|
159
248
|
SyncHub,
|
|
160
249
|
createSyncServer
|
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/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';\n","import { parseEnvelope } from '@fieldnotes/sync';\nimport { MemoryHubBackend } from './memory-hub-backend';\nimport type { HubBackend } from './hub-backend';\n\nexport interface Connection {\n id: string;\n room: string;\n send(message: string): void;\n}\n\nexport interface SyncHubOptions {\n backend?: HubBackend;\n}\n\nconst HUB_FROM = 'hub';\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\n constructor(options: SyncHubOptions = {}) {\n this.backend = options.backend ?? new MemoryHubBackend();\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 await this.backend.apply(conn.room, op);\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(message);\n }\n }\n }\n // 'snapshot' from a client → ignored\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 apply(room: string, op: SyncOp): Promise<void> {\n applyOpToMap(this.room(room), op);\n }\n}\n","import { WebSocketServer } from 'ws';\nimport type { Server } from 'http';\nimport { SyncHub } from './sync-hub';\nimport type { HubBackend } from './hub-backend';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n}\n\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\n hub: SyncHub;\n wss: WebSocketServer;\n close: () => Promise<void>;\n} {\n const hub = new SyncHub({ backend: options.backend });\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 hub.addConnection({\n id: connId,\n room,\n send: (m) => {\n try {\n ws.send(m);\n } catch {\n /* socket closed mid-send */\n }\n },\n });\n ws.on('message', (data) => {\n void hub\n .handleMessage(connId, String(data))\n .catch((err) => console.error('[sync-server]', err));\n });\n ws.on('close', () => hub.removeConnection(connId));\n });\n return {\n hub,\n wss,\n close: () => new Promise<void>((resolve) => wss.close(() => resolve())),\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"],"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';\n","import { parseEnvelope } from '@fieldnotes/sync';\nimport { MemoryHubBackend } from './memory-hub-backend';\nimport { InMemoryHubFanout, type HubFanout } from './hub-fanout';\nimport type { HubBackend } from './hub-backend';\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}\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\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.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 await this.backend.apply(conn.room, op);\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(message);\n }\n }\n this.fanout.publish(JSON.stringify({ o: this.instanceId, room: conn.room, m: message }));\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';\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 apply(room: string, op: SyncOp): Promise<void> {\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';\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';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authenticate?: Authenticate;\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 });\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"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { CanvasElement } from '@fieldnotes/core';
|
|
2
2
|
import { SyncOp } from '@fieldnotes/sync';
|
|
3
3
|
import { WebSocketServer } from 'ws';
|
|
4
|
-
import { Server } from 'http';
|
|
4
|
+
import { IncomingMessage, Server } from 'http';
|
|
5
|
+
|
|
6
|
+
interface HubFanout {
|
|
7
|
+
publish(payload: string): void;
|
|
8
|
+
subscribe(handler: (payload: string) => void): () => void;
|
|
9
|
+
close?(): void;
|
|
10
|
+
}
|
|
11
|
+
declare class InMemoryHubFanout implements HubFanout {
|
|
12
|
+
private readonly handlers;
|
|
13
|
+
publish(payload: string): void;
|
|
14
|
+
subscribe(handler: (payload: string) => void): () => void;
|
|
15
|
+
}
|
|
5
16
|
|
|
6
17
|
interface HubBackend {
|
|
7
18
|
snapshot(room: string): Promise<CanvasElement[]>;
|
|
@@ -11,22 +22,31 @@ interface HubBackend {
|
|
|
11
22
|
interface Connection {
|
|
12
23
|
id: string;
|
|
13
24
|
room: string;
|
|
25
|
+
userId?: string;
|
|
26
|
+
role?: string;
|
|
14
27
|
send(message: string): void;
|
|
15
28
|
}
|
|
16
29
|
interface SyncHubOptions {
|
|
17
30
|
backend?: HubBackend;
|
|
31
|
+
fanout?: HubFanout;
|
|
32
|
+
instanceId?: string;
|
|
18
33
|
}
|
|
19
34
|
declare class SyncHub {
|
|
20
35
|
private readonly backend;
|
|
21
36
|
private readonly conns;
|
|
22
37
|
private readonly rooms;
|
|
23
38
|
private readonly roomQueues;
|
|
39
|
+
private readonly instanceId;
|
|
40
|
+
private readonly fanout;
|
|
41
|
+
private readonly fanoutUnsub;
|
|
24
42
|
constructor(options?: SyncHubOptions);
|
|
25
43
|
addConnection(conn: Connection): void;
|
|
26
44
|
removeConnection(connId: string): void;
|
|
27
45
|
roomCount(): number;
|
|
28
46
|
handleMessage(connId: string, message: string): Promise<void>;
|
|
29
47
|
private process;
|
|
48
|
+
private onFanout;
|
|
49
|
+
close(): void;
|
|
30
50
|
}
|
|
31
51
|
|
|
32
52
|
declare class MemoryHubBackend implements HubBackend {
|
|
@@ -36,10 +56,23 @@ declare class MemoryHubBackend implements HubBackend {
|
|
|
36
56
|
apply(room: string, op: SyncOp): Promise<void>;
|
|
37
57
|
}
|
|
38
58
|
|
|
59
|
+
interface AuthInfo {
|
|
60
|
+
req: IncomingMessage;
|
|
61
|
+
room: string;
|
|
62
|
+
}
|
|
63
|
+
interface AuthResult {
|
|
64
|
+
userId: string;
|
|
65
|
+
role?: string;
|
|
66
|
+
}
|
|
67
|
+
type Authenticate = (info: AuthInfo) => AuthResult | null | Promise<AuthResult | null>;
|
|
68
|
+
|
|
39
69
|
interface CreateSyncServerOptions {
|
|
40
70
|
port?: number;
|
|
41
71
|
server?: Server;
|
|
42
72
|
backend?: HubBackend;
|
|
73
|
+
fanout?: HubFanout;
|
|
74
|
+
instanceId?: string;
|
|
75
|
+
authenticate?: Authenticate;
|
|
43
76
|
}
|
|
44
77
|
declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
45
78
|
hub: SyncHub;
|
|
@@ -47,4 +80,4 @@ declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
|
47
80
|
close: () => Promise<void>;
|
|
48
81
|
};
|
|
49
82
|
|
|
50
|
-
export { type Connection, type CreateSyncServerOptions, type HubBackend, MemoryHubBackend, SyncHub, type SyncHubOptions, createSyncServer };
|
|
83
|
+
export { type AuthInfo, type AuthResult, type Authenticate, type Connection, type CreateSyncServerOptions, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, SyncHub, type SyncHubOptions, createSyncServer };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { CanvasElement } from '@fieldnotes/core';
|
|
2
2
|
import { SyncOp } from '@fieldnotes/sync';
|
|
3
3
|
import { WebSocketServer } from 'ws';
|
|
4
|
-
import { Server } from 'http';
|
|
4
|
+
import { IncomingMessage, Server } from 'http';
|
|
5
|
+
|
|
6
|
+
interface HubFanout {
|
|
7
|
+
publish(payload: string): void;
|
|
8
|
+
subscribe(handler: (payload: string) => void): () => void;
|
|
9
|
+
close?(): void;
|
|
10
|
+
}
|
|
11
|
+
declare class InMemoryHubFanout implements HubFanout {
|
|
12
|
+
private readonly handlers;
|
|
13
|
+
publish(payload: string): void;
|
|
14
|
+
subscribe(handler: (payload: string) => void): () => void;
|
|
15
|
+
}
|
|
5
16
|
|
|
6
17
|
interface HubBackend {
|
|
7
18
|
snapshot(room: string): Promise<CanvasElement[]>;
|
|
@@ -11,22 +22,31 @@ interface HubBackend {
|
|
|
11
22
|
interface Connection {
|
|
12
23
|
id: string;
|
|
13
24
|
room: string;
|
|
25
|
+
userId?: string;
|
|
26
|
+
role?: string;
|
|
14
27
|
send(message: string): void;
|
|
15
28
|
}
|
|
16
29
|
interface SyncHubOptions {
|
|
17
30
|
backend?: HubBackend;
|
|
31
|
+
fanout?: HubFanout;
|
|
32
|
+
instanceId?: string;
|
|
18
33
|
}
|
|
19
34
|
declare class SyncHub {
|
|
20
35
|
private readonly backend;
|
|
21
36
|
private readonly conns;
|
|
22
37
|
private readonly rooms;
|
|
23
38
|
private readonly roomQueues;
|
|
39
|
+
private readonly instanceId;
|
|
40
|
+
private readonly fanout;
|
|
41
|
+
private readonly fanoutUnsub;
|
|
24
42
|
constructor(options?: SyncHubOptions);
|
|
25
43
|
addConnection(conn: Connection): void;
|
|
26
44
|
removeConnection(connId: string): void;
|
|
27
45
|
roomCount(): number;
|
|
28
46
|
handleMessage(connId: string, message: string): Promise<void>;
|
|
29
47
|
private process;
|
|
48
|
+
private onFanout;
|
|
49
|
+
close(): void;
|
|
30
50
|
}
|
|
31
51
|
|
|
32
52
|
declare class MemoryHubBackend implements HubBackend {
|
|
@@ -36,10 +56,23 @@ declare class MemoryHubBackend implements HubBackend {
|
|
|
36
56
|
apply(room: string, op: SyncOp): Promise<void>;
|
|
37
57
|
}
|
|
38
58
|
|
|
59
|
+
interface AuthInfo {
|
|
60
|
+
req: IncomingMessage;
|
|
61
|
+
room: string;
|
|
62
|
+
}
|
|
63
|
+
interface AuthResult {
|
|
64
|
+
userId: string;
|
|
65
|
+
role?: string;
|
|
66
|
+
}
|
|
67
|
+
type Authenticate = (info: AuthInfo) => AuthResult | null | Promise<AuthResult | null>;
|
|
68
|
+
|
|
39
69
|
interface CreateSyncServerOptions {
|
|
40
70
|
port?: number;
|
|
41
71
|
server?: Server;
|
|
42
72
|
backend?: HubBackend;
|
|
73
|
+
fanout?: HubFanout;
|
|
74
|
+
instanceId?: string;
|
|
75
|
+
authenticate?: Authenticate;
|
|
43
76
|
}
|
|
44
77
|
declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
45
78
|
hub: SyncHub;
|
|
@@ -47,4 +80,4 @@ declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
|
47
80
|
close: () => Promise<void>;
|
|
48
81
|
};
|
|
49
82
|
|
|
50
|
-
export { type Connection, type CreateSyncServerOptions, type HubBackend, MemoryHubBackend, SyncHub, type SyncHubOptions, createSyncServer };
|
|
83
|
+
export { type AuthInfo, type AuthResult, type Authenticate, type Connection, type CreateSyncServerOptions, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, SyncHub, type SyncHubOptions, createSyncServer };
|
package/dist/index.js
CHANGED
|
@@ -21,8 +21,30 @@ var MemoryHubBackend = class {
|
|
|
21
21
|
}
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// src/hub-fanout.ts
|
|
25
|
+
var InMemoryHubFanout = class {
|
|
26
|
+
handlers = /* @__PURE__ */ new Set();
|
|
27
|
+
publish(payload) {
|
|
28
|
+
for (const h of this.handlers) {
|
|
29
|
+
try {
|
|
30
|
+
h(payload);
|
|
31
|
+
} catch {
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
subscribe(handler) {
|
|
36
|
+
this.handlers.add(handler);
|
|
37
|
+
return () => this.handlers.delete(handler);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
24
41
|
// src/sync-hub.ts
|
|
25
42
|
var HUB_FROM = "hub";
|
|
43
|
+
function generateInstanceId() {
|
|
44
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function")
|
|
45
|
+
return crypto.randomUUID();
|
|
46
|
+
return `i-${Math.random().toString(36).slice(2)}`;
|
|
47
|
+
}
|
|
26
48
|
var SyncHub = class {
|
|
27
49
|
backend;
|
|
28
50
|
conns = /* @__PURE__ */ new Map();
|
|
@@ -30,8 +52,14 @@ var SyncHub = class {
|
|
|
30
52
|
// room → connIds
|
|
31
53
|
roomQueues = /* @__PURE__ */ new Map();
|
|
32
54
|
// room → serial tail
|
|
55
|
+
instanceId;
|
|
56
|
+
fanout;
|
|
57
|
+
fanoutUnsub;
|
|
33
58
|
constructor(options = {}) {
|
|
34
59
|
this.backend = options.backend ?? new MemoryHubBackend();
|
|
60
|
+
this.instanceId = options.instanceId ?? generateInstanceId();
|
|
61
|
+
this.fanout = options.fanout ?? new InMemoryHubFanout();
|
|
62
|
+
this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
|
|
35
63
|
}
|
|
36
64
|
addConnection(conn) {
|
|
37
65
|
this.conns.set(conn.id, conn);
|
|
@@ -86,14 +114,42 @@ var SyncHub = class {
|
|
|
86
114
|
this.conns.get(id)?.send(message);
|
|
87
115
|
}
|
|
88
116
|
}
|
|
117
|
+
this.fanout.publish(JSON.stringify({ o: this.instanceId, room: conn.room, m: message }));
|
|
89
118
|
}
|
|
90
119
|
}
|
|
120
|
+
onFanout(payload) {
|
|
121
|
+
let env;
|
|
122
|
+
try {
|
|
123
|
+
env = JSON.parse(payload);
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.m !== "string")
|
|
128
|
+
return;
|
|
129
|
+
if (env.o === this.instanceId) return;
|
|
130
|
+
const members = this.rooms.get(env.room);
|
|
131
|
+
if (!members) return;
|
|
132
|
+
const m = env.m;
|
|
133
|
+
for (const id of members) {
|
|
134
|
+
try {
|
|
135
|
+
this.conns.get(id)?.send(m);
|
|
136
|
+
} catch {
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
close() {
|
|
141
|
+
this.fanoutUnsub();
|
|
142
|
+
}
|
|
91
143
|
};
|
|
92
144
|
|
|
93
145
|
// src/create-sync-server.ts
|
|
94
146
|
import { WebSocketServer } from "ws";
|
|
95
147
|
function createSyncServer(options = {}) {
|
|
96
|
-
const hub = new SyncHub({
|
|
148
|
+
const hub = new SyncHub({
|
|
149
|
+
backend: options.backend,
|
|
150
|
+
fanout: options.fanout,
|
|
151
|
+
instanceId: options.instanceId
|
|
152
|
+
});
|
|
97
153
|
const wss = options.server ? new WebSocketServer({ server: options.server }) : new WebSocketServer({ port: options.port ?? 0 });
|
|
98
154
|
let counter = 0;
|
|
99
155
|
wss.on("connection", (ws, req) => {
|
|
@@ -104,28 +160,60 @@ function createSyncServer(options = {}) {
|
|
|
104
160
|
return;
|
|
105
161
|
}
|
|
106
162
|
const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
163
|
+
let state = "pending";
|
|
164
|
+
let closed = false;
|
|
165
|
+
const queue = [];
|
|
166
|
+
const MAX_QUEUE = 100;
|
|
167
|
+
const send = (m) => {
|
|
168
|
+
try {
|
|
169
|
+
ws.send(m);
|
|
170
|
+
} catch {
|
|
115
171
|
}
|
|
116
|
-
}
|
|
172
|
+
};
|
|
117
173
|
ws.on("message", (data) => {
|
|
118
|
-
|
|
174
|
+
const msg = String(data);
|
|
175
|
+
if (state === "rejected") return;
|
|
176
|
+
if (state === "pending") {
|
|
177
|
+
if (queue.length < MAX_QUEUE) queue.push(msg);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
void hub.handleMessage(connId, msg).catch((err) => console.error("[sync-server]", err));
|
|
181
|
+
});
|
|
182
|
+
ws.on("close", () => {
|
|
183
|
+
closed = true;
|
|
184
|
+
if (state === "ready") hub.removeConnection(connId);
|
|
185
|
+
});
|
|
186
|
+
Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId }).then((result) => {
|
|
187
|
+
if (closed) return;
|
|
188
|
+
if (!result) {
|
|
189
|
+
state = "rejected";
|
|
190
|
+
ws.close(4401, "unauthorized");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
state = "ready";
|
|
194
|
+
hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });
|
|
195
|
+
for (const m of queue) {
|
|
196
|
+
void hub.handleMessage(connId, m).catch((err) => console.error("[sync-server]", err));
|
|
197
|
+
}
|
|
198
|
+
queue.length = 0;
|
|
199
|
+
}).catch(() => {
|
|
200
|
+
if (!closed) {
|
|
201
|
+
state = "rejected";
|
|
202
|
+
ws.close(4401, "unauthorized");
|
|
203
|
+
}
|
|
119
204
|
});
|
|
120
|
-
ws.on("close", () => hub.removeConnection(connId));
|
|
121
205
|
});
|
|
122
206
|
return {
|
|
123
207
|
hub,
|
|
124
208
|
wss,
|
|
125
|
-
close: () => new Promise((resolve) =>
|
|
209
|
+
close: () => new Promise((resolve) => {
|
|
210
|
+
hub.close();
|
|
211
|
+
wss.close(() => resolve());
|
|
212
|
+
})
|
|
126
213
|
};
|
|
127
214
|
}
|
|
128
215
|
export {
|
|
216
|
+
InMemoryHubFanout,
|
|
129
217
|
MemoryHubBackend,
|
|
130
218
|
SyncHub,
|
|
131
219
|
createSyncServer
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/create-sync-server.ts"],"sourcesContent":["import { parseEnvelope } from '@fieldnotes/sync';\nimport { MemoryHubBackend } from './memory-hub-backend';\nimport type { HubBackend } from './hub-backend';\n\nexport interface Connection {\n id: string;\n room: string;\n send(message: string): void;\n}\n\nexport interface SyncHubOptions {\n backend?: HubBackend;\n}\n\nconst HUB_FROM = 'hub';\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\n constructor(options: SyncHubOptions = {}) {\n this.backend = options.backend ?? new MemoryHubBackend();\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 await this.backend.apply(conn.room, op);\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(message);\n }\n }\n }\n // 'snapshot' from a client → ignored\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 apply(room: string, op: SyncOp): Promise<void> {\n applyOpToMap(this.room(room), op);\n }\n}\n","import { WebSocketServer } from 'ws';\nimport type { Server } from 'http';\nimport { SyncHub } from './sync-hub';\nimport type { HubBackend } from './hub-backend';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n}\n\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\n hub: SyncHub;\n wss: WebSocketServer;\n close: () => Promise<void>;\n} {\n const hub = new SyncHub({ backend: options.backend });\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 hub.addConnection({\n id: connId,\n room,\n send: (m) => {\n try {\n ws.send(m);\n } catch {\n /* socket closed mid-send */\n }\n },\n });\n ws.on('message', (data) => {\n void hub\n .handleMessage(connId, String(data))\n .catch((err) => console.error('[sync-server]', err));\n });\n ws.on('close', () => hub.removeConnection(connId));\n });\n return {\n hub,\n wss,\n close: () => new Promise<void>((resolve) => wss.close(() => resolve())),\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"],"sourcesContent":["import { parseEnvelope } from '@fieldnotes/sync';\nimport { MemoryHubBackend } from './memory-hub-backend';\nimport { InMemoryHubFanout, type HubFanout } from './hub-fanout';\nimport type { HubBackend } from './hub-backend';\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}\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\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.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 await this.backend.apply(conn.room, op);\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(message);\n }\n }\n this.fanout.publish(JSON.stringify({ o: this.instanceId, room: conn.room, m: message }));\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';\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 apply(room: string, op: SyncOp): Promise<void> {\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';\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';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authenticate?: Authenticate;\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 });\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":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fieldnotes/sync-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Authoritative WebSocket relay server for Field Notes real-time sync",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"ws": "^8.18.0",
|
|
41
|
-
"@fieldnotes/sync": "0.
|
|
41
|
+
"@fieldnotes/sync": "0.4.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/ws": "^8.5.12",
|