@fieldnotes/sync-server 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
 
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({ backend: options.backend });
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) => {
@@ -150,11 +207,15 @@ function createSyncServer(options = {}) {
150
207
  return {
151
208
  hub,
152
209
  wss,
153
- close: () => new Promise((resolve) => wss.close(() => resolve()))
210
+ close: () => new Promise((resolve) => {
211
+ hub.close();
212
+ wss.close(() => resolve());
213
+ })
154
214
  };
155
215
  }
156
216
  // Annotate the CommonJS export names for ESM import in node:
157
217
  0 && (module.exports = {
218
+ InMemoryHubFanout,
158
219
  MemoryHubBackend,
159
220
  SyncHub,
160
221
  createSyncServer
@@ -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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,IAAAA,eAA8B;;;ACA9B,kBAA0C;AAInC,IAAM,mBAAN,MAA6C;AAAA,EAC1C,QAAQ,oBAAI,IAAwC;AAAA,EAEpD,KAAK,IAAwC;AACnD,QAAI,IAAI,KAAK,MAAM,IAAI,EAAE;AACzB,QAAI,CAAC,GAAG;AACN,UAAI,oBAAI,IAAI;AACZ,WAAK,MAAM,IAAI,IAAI,CAAC;AAAA,IACtB;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,SAAS,MAAwC;AACrD,WAAO,CAAC,GAAG,KAAK,KAAK,IAAI,EAAE,OAAO,CAAC;AAAA,EACrC;AAAA,EAEA,MAAM,MAAM,MAAc,IAA2B;AACnD,kCAAa,KAAK,KAAK,IAAI,GAAG,EAAE;AAAA,EAClC;AACF;;;ADTA,IAAM,WAAW;AAEV,IAAM,UAAN,MAAc;AAAA,EACF;AAAA,EACA,QAAQ,oBAAI,IAAwB;AAAA,EACpC,QAAQ,oBAAI,IAAyB;AAAA;AAAA,EACrC,aAAa,oBAAI,IAA2B;AAAA;AAAA,EAE7D,YAAY,UAA0B,CAAC,GAAG;AACxC,SAAK,UAAU,QAAQ,WAAW,IAAI,iBAAiB;AAAA,EACzD;AAAA,EAEA,cAAc,MAAwB;AACpC,SAAK,MAAM,IAAI,KAAK,IAAI,IAAI;AAC5B,QAAI,MAAM,KAAK,MAAM,IAAI,KAAK,IAAI;AAClC,QAAI,CAAC,KAAK;AACR,YAAM,oBAAI,IAAI;AACd,WAAK,MAAM,IAAI,KAAK,MAAM,GAAG;AAAA,IAC/B;AACA,QAAI,IAAI,KAAK,EAAE;AAAA,EACjB;AAAA,EAEA,iBAAiB,QAAsB;AACrC,UAAM,OAAO,KAAK,MAAM,IAAI,MAAM;AAClC,QAAI,CAAC,KAAM;AACX,SAAK,MAAM,OAAO,MAAM;AACxB,UAAM,UAAU,KAAK,MAAM,IAAI,KAAK,IAAI;AACxC,QAAI,SAAS;AACX,cAAQ,OAAO,MAAM;AACrB,UAAI,QAAQ,SAAS,GAAG;AACtB,aAAK,MAAM,OAAO,KAAK,IAAI;AAC3B,aAAK,WAAW,OAAO,KAAK,IAAI;AAAA,MAClC;AAAA,IACF;AAAA,EACF;AAAA,EAEA,YAAoB;AAClB,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA,EAEA,cAAc,QAAgB,SAAgC;AAC5D,UAAM,OAAO,KAAK,MAAM,IAAI,MAAM;AAClC,QAAI,CAAC,KAAM,QAAO,QAAQ,QAAQ;AAClC,UAAM,OAAO,KAAK;AAClB,UAAM,OAAO,KAAK,WAAW,IAAI,IAAI,KAAK,QAAQ,QAAQ;AAC1D,UAAM,OAAO,KACV,KAAK,MAAM,KAAK,QAAQ,MAAM,OAAO,CAAC,EACtC,MAAM,MAAM;AAAA,IAEb,CAAC;AACH,SAAK,WAAW,IAAI,MAAM,IAAI;AAC9B,WAAO;AAAA,EACT;AAAA,EAEA,MAAc,QAAQ,MAAkB,SAAgC;AACtE,UAAM,UAAM,4BAAc,OAAO;AACjC,QAAI,CAAC,IAAK;AACV,UAAM,KAAK,IAAI;AACf,QAAI,GAAG,SAAS,oBAAoB;AAClC,YAAM,WAAW,MAAM,KAAK,QAAQ,SAAS,KAAK,IAAI;AACtD,WAAK;AAAA,QACH,KAAK,UAAU,EAAE,MAAM,UAAU,IAAI,EAAE,MAAM,YAAY,IAAI,IAAI,MAAM,SAAS,EAAE,CAAC;AAAA,MACrF;AAAA,IACF,WAAW,GAAG,SAAS,YAAY,GAAG,SAAS,YAAY,GAAG,SAAS,SAAS;AAC9E,YAAM,KAAK,QAAQ,MAAM,KAAK,MAAM,EAAE;AACtC,YAAM,UAAU,KAAK,MAAM,IAAI,KAAK,IAAI;AACxC,UAAI,SAAS;AACX,mBAAW,MAAM,SAAS;AACxB,cAAI,OAAO,KAAK,GAAI;AACpB,eAAK,MAAM,IAAI,EAAE,GAAG,KAAK,OAAO;AAAA,QAClC;AAAA,MACF;AAAA,IACF;AAAA,EAEF;AACF;;;AEzFA,gBAAgC;AAWzB,SAAS,iBAAiB,UAAmC,CAAC,GAInE;AACA,QAAM,MAAM,IAAI,QAAQ,EAAE,SAAS,QAAQ,QAAQ,CAAC;AACpD,QAAM,MAAM,QAAQ,SAChB,IAAI,0BAAgB,EAAE,QAAQ,QAAQ,OAAO,CAAC,IAC9C,IAAI,0BAAgB,EAAE,MAAM,QAAQ,QAAQ,EAAE,CAAC;AACnD,MAAI,UAAU;AACd,MAAI,GAAG,cAAc,CAAC,IAAI,QAAQ;AAChC,UAAM,MAAM,IAAI,IAAI,IAAI,OAAO,IAAI,kBAAkB;AACrD,UAAM,OAAO,IAAI,aAAa,IAAI,MAAM;AACxC,QAAI,CAAC,MAAM;AACT,SAAG,MAAM,MAAM,eAAe;AAC9B;AAAA,IACF;AACA,UAAM,SAAS,IAAI,EAAE,OAAO,IAAI,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC;AACtE,QAAI,cAAc;AAAA,MAChB,IAAI;AAAA,MACJ;AAAA,MACA,MAAM,CAAC,MAAM;AACX,YAAI;AACF,aAAG,KAAK,CAAC;AAAA,QACX,QAAQ;AAAA,QAER;AAAA,MACF;AAAA,IACF,CAAC;AACD,OAAG,GAAG,WAAW,CAAC,SAAS;AACzB,WAAK,IACF,cAAc,QAAQ,OAAO,IAAI,CAAC,EAClC,MAAM,CAAC,QAAQ,QAAQ,MAAM,iBAAiB,GAAG,CAAC;AAAA,IACvD,CAAC;AACD,OAAG,GAAG,SAAS,MAAM,IAAI,iBAAiB,MAAM,CAAC;AAAA,EACnD,CAAC;AACD,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,OAAO,MAAM,IAAI,QAAc,CAAC,YAAY,IAAI,MAAM,MAAM,QAAQ,CAAC,CAAC;AAAA,EACxE;AACF;","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';\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 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 {\n publish(payload: string): void;\n subscribe(handler: (payload: string) => void): () => void;\n close?(): void;\n}\n\nexport class InMemoryHubFanout implements HubFanout {\n private readonly handlers = new Set<(payload: string) => void>();\n\n publish(payload: string): void {\n for (const h of this.handlers) {\n try {\n h(payload);\n } catch {\n /* one throwing subscriber must not break the publish loop */\n }\n }\n }\n\n subscribe(handler: (payload: string) => void): () => void {\n this.handlers.add(handler);\n return () => this.handlers.delete(handler);\n }\n}\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';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\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 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: () =>\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
@@ -3,6 +3,17 @@ import { SyncOp } from '@fieldnotes/sync';
3
3
  import { WebSocketServer } from 'ws';
4
4
  import { Server } from 'http';
5
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
+ }
16
+
6
17
  interface HubBackend {
7
18
  snapshot(room: string): Promise<CanvasElement[]>;
8
19
  apply(room: string, op: SyncOp): Promise<void>;
@@ -15,18 +26,25 @@ interface Connection {
15
26
  }
16
27
  interface SyncHubOptions {
17
28
  backend?: HubBackend;
29
+ fanout?: HubFanout;
30
+ instanceId?: string;
18
31
  }
19
32
  declare class SyncHub {
20
33
  private readonly backend;
21
34
  private readonly conns;
22
35
  private readonly rooms;
23
36
  private readonly roomQueues;
37
+ private readonly instanceId;
38
+ private readonly fanout;
39
+ private readonly fanoutUnsub;
24
40
  constructor(options?: SyncHubOptions);
25
41
  addConnection(conn: Connection): void;
26
42
  removeConnection(connId: string): void;
27
43
  roomCount(): number;
28
44
  handleMessage(connId: string, message: string): Promise<void>;
29
45
  private process;
46
+ private onFanout;
47
+ close(): void;
30
48
  }
31
49
 
32
50
  declare class MemoryHubBackend implements HubBackend {
@@ -40,6 +58,8 @@ interface CreateSyncServerOptions {
40
58
  port?: number;
41
59
  server?: Server;
42
60
  backend?: HubBackend;
61
+ fanout?: HubFanout;
62
+ instanceId?: string;
43
63
  }
44
64
  declare function createSyncServer(options?: CreateSyncServerOptions): {
45
65
  hub: SyncHub;
@@ -47,4 +67,4 @@ declare function createSyncServer(options?: CreateSyncServerOptions): {
47
67
  close: () => Promise<void>;
48
68
  };
49
69
 
50
- export { type Connection, type CreateSyncServerOptions, type HubBackend, MemoryHubBackend, SyncHub, type SyncHubOptions, createSyncServer };
70
+ export { type Connection, type CreateSyncServerOptions, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, SyncHub, type SyncHubOptions, createSyncServer };
package/dist/index.d.ts CHANGED
@@ -3,6 +3,17 @@ import { SyncOp } from '@fieldnotes/sync';
3
3
  import { WebSocketServer } from 'ws';
4
4
  import { Server } from 'http';
5
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
+ }
16
+
6
17
  interface HubBackend {
7
18
  snapshot(room: string): Promise<CanvasElement[]>;
8
19
  apply(room: string, op: SyncOp): Promise<void>;
@@ -15,18 +26,25 @@ interface Connection {
15
26
  }
16
27
  interface SyncHubOptions {
17
28
  backend?: HubBackend;
29
+ fanout?: HubFanout;
30
+ instanceId?: string;
18
31
  }
19
32
  declare class SyncHub {
20
33
  private readonly backend;
21
34
  private readonly conns;
22
35
  private readonly rooms;
23
36
  private readonly roomQueues;
37
+ private readonly instanceId;
38
+ private readonly fanout;
39
+ private readonly fanoutUnsub;
24
40
  constructor(options?: SyncHubOptions);
25
41
  addConnection(conn: Connection): void;
26
42
  removeConnection(connId: string): void;
27
43
  roomCount(): number;
28
44
  handleMessage(connId: string, message: string): Promise<void>;
29
45
  private process;
46
+ private onFanout;
47
+ close(): void;
30
48
  }
31
49
 
32
50
  declare class MemoryHubBackend implements HubBackend {
@@ -40,6 +58,8 @@ interface CreateSyncServerOptions {
40
58
  port?: number;
41
59
  server?: Server;
42
60
  backend?: HubBackend;
61
+ fanout?: HubFanout;
62
+ instanceId?: string;
43
63
  }
44
64
  declare function createSyncServer(options?: CreateSyncServerOptions): {
45
65
  hub: SyncHub;
@@ -47,4 +67,4 @@ declare function createSyncServer(options?: CreateSyncServerOptions): {
47
67
  close: () => Promise<void>;
48
68
  };
49
69
 
50
- export { type Connection, type CreateSyncServerOptions, type HubBackend, MemoryHubBackend, SyncHub, type SyncHubOptions, createSyncServer };
70
+ export { 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({ backend: options.backend });
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) => {
@@ -122,10 +178,14 @@ function createSyncServer(options = {}) {
122
178
  return {
123
179
  hub,
124
180
  wss,
125
- close: () => new Promise((resolve) => wss.close(() => resolve()))
181
+ close: () => new Promise((resolve) => {
182
+ hub.close();
183
+ wss.close(() => resolve());
184
+ })
126
185
  };
127
186
  }
128
187
  export {
188
+ InMemoryHubFanout,
129
189
  MemoryHubBackend,
130
190
  SyncHub,
131
191
  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 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 {\n publish(payload: string): void;\n subscribe(handler: (payload: string) => void): () => void;\n close?(): void;\n}\n\nexport class InMemoryHubFanout implements HubFanout {\n private readonly handlers = new Set<(payload: string) => void>();\n\n publish(payload: string): void {\n for (const h of this.handlers) {\n try {\n h(payload);\n } catch {\n /* one throwing subscriber must not break the publish loop */\n }\n }\n }\n\n subscribe(handler: (payload: string) => void): () => void {\n this.handlers.add(handler);\n return () => this.handlers.delete(handler);\n }\n}\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';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\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 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: () =>\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.1.0",
3
+ "version": "0.2.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.3.0"
41
+ "@fieldnotes/sync": "0.4.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/ws": "^8.5.12",