@fieldnotes/sync-server 0.5.0 → 0.7.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 +48 -2
- package/dist/index.cjs +94 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +95 -26
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -152,7 +152,53 @@ createSyncServer({
|
|
|
152
152
|
low-write use.
|
|
153
153
|
- Reads / visibility (a player not **receiving** hidden content) are a separate, upcoming
|
|
154
154
|
concern (D3). `authorize` gates writes only.
|
|
155
|
-
-
|
|
156
|
-
|
|
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
|
+
## Read filtering
|
|
162
|
+
|
|
163
|
+
Pass a `canRead` hook to gate **reads** — what each viewer **receives**. Unlike client-side
|
|
164
|
+
hiding, the hub filters ops before they leave the server, so hidden elements never reach an
|
|
165
|
+
unauthorized client on either path: the live broadcast **and** the snapshot a client gets on join.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
canRead({ userId, role, room, audience }) => boolean
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`audience` is the opaque tag the client stamps on its own outgoing upserts via `@fieldnotes/sync`'s
|
|
172
|
+
`resolveAudience` (e.g. `'dm'` / `'shared'`, derived from the app's layers). With **no hook**,
|
|
173
|
+
everyone sees everything.
|
|
174
|
+
|
|
175
|
+
A two-tier DM / player policy — the relay's `canRead` paired with the client's `resolveAudience`:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// relay
|
|
179
|
+
createSyncServer({
|
|
180
|
+
port: 8080,
|
|
181
|
+
authenticate: /* … supplies userId + role … */,
|
|
182
|
+
canRead: ({ role, audience }) => audience !== 'dm' || role === 'dm',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// client (@fieldnotes/sync)
|
|
186
|
+
new SyncClient({ store, transport, resolveAudience: (el) => layerOf(el) }); // → 'dm' | 'shared'
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Moving an element between audiences re-evaluates visibility per viewer: a viewer who loses access
|
|
190
|
+
gets a synthetic **remove**, one who gains it gets an **add**.
|
|
191
|
+
|
|
192
|
+
**Preconditions:**
|
|
193
|
+
|
|
194
|
+
- **Stable identity.** Meaningful policy needs a stable `userId`/`role` from `authenticate`; the
|
|
195
|
+
no-hook anonymous default is per-connection (`userId = connId`) and changes on reconnect.
|
|
196
|
+
- **Same `canRead` on every instance.** Unlike `authorize` (which runs on the write's origin
|
|
197
|
+
instance only), `canRead` runs on **every** instance — each re-filters fanned-out ops for its own
|
|
198
|
+
local members — so all relay instances must inject the **same** `canRead`, alongside the existing
|
|
199
|
+
shared-backend + shared-fanout requirement.
|
|
200
|
+
- **Labeling integrity.** `audience` is client-asserted, so read secrecy is only as trustworthy as
|
|
201
|
+
the `authorize` (write) policy that stops a player from stamping `dm` on content they shouldn't
|
|
202
|
+
control.
|
|
157
203
|
|
|
158
204
|
A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).
|
package/dist/index.cjs
CHANGED
|
@@ -82,6 +82,13 @@ function generateInstanceId() {
|
|
|
82
82
|
return crypto.randomUUID();
|
|
83
83
|
return `i-${Math.random().toString(36).slice(2)}`;
|
|
84
84
|
}
|
|
85
|
+
function isFanoutOp(op) {
|
|
86
|
+
if (typeof op !== "object" || op === null) return false;
|
|
87
|
+
const o = op;
|
|
88
|
+
if (o.kind === "upsert") return (0, import_sync2.isValidElement)(o.element);
|
|
89
|
+
if (o.kind === "remove") return typeof o.id === "string";
|
|
90
|
+
return o.kind === "clear";
|
|
91
|
+
}
|
|
85
92
|
var SyncHub = class {
|
|
86
93
|
backend;
|
|
87
94
|
conns = /* @__PURE__ */ new Map();
|
|
@@ -93,11 +100,13 @@ var SyncHub = class {
|
|
|
93
100
|
fanout;
|
|
94
101
|
fanoutUnsub;
|
|
95
102
|
authorize;
|
|
103
|
+
canRead;
|
|
96
104
|
constructor(options = {}) {
|
|
97
105
|
this.backend = options.backend ?? new MemoryHubBackend();
|
|
98
106
|
this.instanceId = options.instanceId ?? generateInstanceId();
|
|
99
107
|
this.fanout = options.fanout ?? new InMemoryHubFanout();
|
|
100
108
|
this.authorize = options.authorize;
|
|
109
|
+
this.canRead = options.canRead;
|
|
101
110
|
this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
|
|
102
111
|
}
|
|
103
112
|
addConnection(conn) {
|
|
@@ -140,16 +149,17 @@ var SyncHub = class {
|
|
|
140
149
|
if (!env) return;
|
|
141
150
|
const op = env.op;
|
|
142
151
|
if (op.kind === "request-snapshot") {
|
|
143
|
-
const
|
|
152
|
+
const all = await this.backend.snapshot(conn.room);
|
|
153
|
+
const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
|
|
144
154
|
conn.send(
|
|
145
155
|
JSON.stringify({ from: HUB_FROM, op: { kind: "snapshot", to: env.from, elements } })
|
|
146
156
|
);
|
|
147
157
|
} else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
|
|
158
|
+
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
159
|
+
const needCurrent = (this.authorize || this.canRead) && id !== void 0;
|
|
160
|
+
const current = needCurrent ? await this.backend.get(conn.room, id) : void 0;
|
|
148
161
|
let outboundOp = op;
|
|
149
|
-
let outboundMessage = message;
|
|
150
162
|
if (this.authorize) {
|
|
151
|
-
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
152
|
-
const current = id !== void 0 ? await this.backend.get(conn.room, id) : void 0;
|
|
153
163
|
const allowed = await this.authorize({
|
|
154
164
|
userId: conn.userId,
|
|
155
165
|
role: conn.role,
|
|
@@ -157,27 +167,89 @@ var SyncHub = class {
|
|
|
157
167
|
op,
|
|
158
168
|
currentElement: current
|
|
159
169
|
});
|
|
160
|
-
if (!allowed)
|
|
170
|
+
if (!allowed) {
|
|
171
|
+
await this.sendCorrection(conn, env.from, op, current);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
161
174
|
if (op.kind === "upsert") {
|
|
162
175
|
const ownerId = current?.ownerId ?? conn.userId;
|
|
163
176
|
const stampedElement = { ...op.element, ownerId };
|
|
164
177
|
outboundOp = { kind: "upsert", element: stampedElement };
|
|
165
|
-
outboundMessage = JSON.stringify({ from: env.from, op: outboundOp });
|
|
166
178
|
}
|
|
167
179
|
}
|
|
168
180
|
await this.backend.apply(conn.room, outboundOp);
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (id === conn.id) continue;
|
|
173
|
-
this.conns.get(id)?.send(outboundMessage);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
181
|
+
const prevExisted = current !== void 0;
|
|
182
|
+
const prevAudience = current?.audience;
|
|
183
|
+
this.deliverToRoom(conn.room, conn.id, env.from, outboundOp, prevAudience, prevExisted);
|
|
176
184
|
this.fanout.publish(
|
|
177
|
-
JSON.stringify({
|
|
185
|
+
JSON.stringify({
|
|
186
|
+
o: this.instanceId,
|
|
187
|
+
room: conn.room,
|
|
188
|
+
from: env.from,
|
|
189
|
+
op: outboundOp,
|
|
190
|
+
prev: prevAudience,
|
|
191
|
+
existed: prevExisted
|
|
192
|
+
})
|
|
178
193
|
);
|
|
179
194
|
}
|
|
180
195
|
}
|
|
196
|
+
mayRead(conn, audience) {
|
|
197
|
+
if (!this.canRead) return true;
|
|
198
|
+
return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
|
|
199
|
+
}
|
|
200
|
+
deliverToRoom(room, excludeId, from, op, prevAudience, prevExisted) {
|
|
201
|
+
const members = this.rooms.get(room);
|
|
202
|
+
if (!members) return;
|
|
203
|
+
const send = (conn, msg) => {
|
|
204
|
+
try {
|
|
205
|
+
conn.send(msg);
|
|
206
|
+
} catch {
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
if (op.kind === "upsert") {
|
|
210
|
+
const audience = op.element.audience;
|
|
211
|
+
const upsertMsg = JSON.stringify({ from, op });
|
|
212
|
+
const removeMsg = JSON.stringify({
|
|
213
|
+
from: HUB_FROM,
|
|
214
|
+
op: { kind: "remove", id: op.element.id }
|
|
215
|
+
});
|
|
216
|
+
for (const cid of members) {
|
|
217
|
+
if (cid === excludeId) continue;
|
|
218
|
+
const conn = this.conns.get(cid);
|
|
219
|
+
if (!conn) continue;
|
|
220
|
+
if (this.mayRead(conn, audience)) send(conn, upsertMsg);
|
|
221
|
+
else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);
|
|
222
|
+
}
|
|
223
|
+
} else if (op.kind === "remove") {
|
|
224
|
+
const removeMsg = JSON.stringify({ from, op });
|
|
225
|
+
for (const cid of members) {
|
|
226
|
+
if (cid === excludeId) continue;
|
|
227
|
+
const conn = this.conns.get(cid);
|
|
228
|
+
if (!conn) continue;
|
|
229
|
+
const wasVisible = !this.canRead || prevExisted && this.mayRead(conn, prevAudience);
|
|
230
|
+
if (wasVisible) send(conn, removeMsg);
|
|
231
|
+
}
|
|
232
|
+
} else if (op.kind === "clear") {
|
|
233
|
+
const clearMsg = JSON.stringify({ from, op });
|
|
234
|
+
for (const cid of members) {
|
|
235
|
+
if (cid === excludeId) continue;
|
|
236
|
+
const conn = this.conns.get(cid);
|
|
237
|
+
if (conn) send(conn, clearMsg);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async sendCorrection(conn, from, op, current) {
|
|
242
|
+
let correction;
|
|
243
|
+
if (op.kind === "upsert") {
|
|
244
|
+
correction = current ? { kind: "upsert", element: current } : { kind: "remove", id: op.element.id };
|
|
245
|
+
} else if (op.kind === "remove") {
|
|
246
|
+
correction = current ? { kind: "upsert", element: current } : void 0;
|
|
247
|
+
} else if (op.kind === "clear") {
|
|
248
|
+
const elements = await this.backend.snapshot(conn.room);
|
|
249
|
+
correction = { kind: "snapshot", to: from, elements };
|
|
250
|
+
}
|
|
251
|
+
if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
|
|
252
|
+
}
|
|
181
253
|
onFanout(payload) {
|
|
182
254
|
let env;
|
|
183
255
|
try {
|
|
@@ -185,18 +257,14 @@ var SyncHub = class {
|
|
|
185
257
|
} catch {
|
|
186
258
|
return;
|
|
187
259
|
}
|
|
188
|
-
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.
|
|
260
|
+
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.from !== "string")
|
|
189
261
|
return;
|
|
190
262
|
if (env.o === this.instanceId) return;
|
|
191
|
-
const
|
|
192
|
-
if (!
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
this.conns.get(id)?.send(m);
|
|
197
|
-
} catch {
|
|
198
|
-
}
|
|
199
|
-
}
|
|
263
|
+
const op = env.op;
|
|
264
|
+
if (!isFanoutOp(op)) return;
|
|
265
|
+
const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
|
|
266
|
+
const prevExisted = env.existed === true;
|
|
267
|
+
this.deliverToRoom(env.room, void 0, env.from, op, prevAudience, prevExisted);
|
|
200
268
|
}
|
|
201
269
|
close() {
|
|
202
270
|
this.fanoutUnsub();
|
|
@@ -243,7 +311,8 @@ function createSyncServer(options = {}) {
|
|
|
243
311
|
backend: options.backend,
|
|
244
312
|
fanout: options.fanout,
|
|
245
313
|
instanceId: options.instanceId,
|
|
246
|
-
authorize: options.authorize
|
|
314
|
+
authorize: options.authorize,
|
|
315
|
+
canRead: options.canRead
|
|
247
316
|
});
|
|
248
317
|
const wss = options.server ? new import_ws.WebSocketServer({ server: options.server }) : new import_ws.WebSocketServer({ port: options.port ?? 0 });
|
|
249
318
|
const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
|
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","../src/heartbeat.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';\nexport { startHeartbeat } from './heartbeat';\nexport type { Heartbeat, HeartbeatSocket, HeartbeatServer } from './heartbeat';\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) return;\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 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';\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 if (op.kind === 'clear') {\n this.rooms.delete(room);\n return;\n }\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';\nimport { startHeartbeat } from './heartbeat';\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 heartbeatIntervalMs?: number;\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 const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\n let counter = 0;\n wss.on('connection', (ws, req) => {\n heartbeat.track(ws);\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(4400, '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 heartbeat.stop();\n hub.close();\n wss.close(() => resolve());\n }),\n };\n}\n","export interface HeartbeatSocket {\n ping(): void;\n terminate(): void;\n on(event: 'pong', listener: () => void): void;\n}\nexport interface HeartbeatServer {\n clients: Set<HeartbeatSocket>;\n}\nexport interface Heartbeat {\n track(ws: HeartbeatSocket): void;\n stop(): void;\n}\n\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\n if (intervalMs <= 0) {\n return {\n track: () => {\n /* disabled */\n },\n stop: () => {\n /* disabled */\n },\n };\n }\n const alive = new WeakMap<HeartbeatSocket, boolean>();\n const track = (ws: HeartbeatSocket): void => {\n alive.set(ws, true);\n ws.on('pong', () => alive.set(ws, true));\n };\n const interval = setInterval(() => {\n for (const ws of wss.clients) {\n try {\n if (alive.get(ws) === false) {\n ws.terminate();\n continue;\n }\n alive.set(ws, false);\n ws.ping();\n } catch {\n /* socket dying mid-tick — skip it */\n }\n }\n }, intervalMs);\n return { track, stop: () => clearInterval(interval) };\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';\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, ReadContext, CanRead } from './authorize';\nexport { startHeartbeat } from './heartbeat';\nexport type { Heartbeat, HeartbeatSocket, HeartbeatServer } from './heartbeat';\n","import { parseEnvelope, isValidElement, 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, CanRead, 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 canRead?: CanRead;\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\nfunction isFanoutOp(op: unknown): op is Extract<SyncOp, { kind: 'upsert' | 'remove' | 'clear' }> {\n if (typeof op !== 'object' || op === null) return false;\n const o = op as { kind?: unknown; element?: unknown; id?: unknown };\n if (o.kind === 'upsert') return isValidElement(o.element);\n if (o.kind === 'remove') return typeof o.id === 'string';\n return o.kind === 'clear';\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 private readonly canRead?: CanRead;\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.canRead = options.canRead;\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 // The per-room serial queue is the single total-order authority: ops apply in arrival order\n // (arrival-order LWW — no per-element seq; see D3 / TD-12). Different rooms run independently.\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 all = (await this.backend.snapshot(conn.room)) as OwnedElement[];\n const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;\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 const id = op.kind === 'upsert' ? op.element.id : op.kind === 'remove' ? op.id : undefined;\n const needCurrent = (this.authorize || this.canRead) && id !== undefined;\n const current: OwnedElement | undefined = needCurrent\n ? await this.backend.get(conn.room, id)\n : undefined;\n\n let outboundOp: SyncOp = op;\n if (this.authorize) {\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) {\n await this.sendCorrection(conn, env.from, op, current);\n return;\n }\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 }\n }\n\n await this.backend.apply(conn.room, outboundOp);\n\n const prevExisted = current !== undefined;\n const prevAudience = current?.audience;\n\n this.deliverToRoom(conn.room, conn.id, env.from, outboundOp, prevAudience, prevExisted);\n\n this.fanout.publish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: env.from,\n op: outboundOp,\n prev: prevAudience,\n existed: prevExisted,\n }),\n );\n }\n // 'snapshot' from a client → ignored\n }\n\n private mayRead(conn: Connection, audience: string | undefined): boolean {\n if (!this.canRead) return true;\n return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });\n }\n\n private deliverToRoom(\n room: string,\n excludeId: string | undefined,\n from: string,\n op: SyncOp,\n prevAudience: string | undefined,\n prevExisted: boolean,\n ): void {\n const members = this.rooms.get(room);\n if (!members) return;\n const send = (conn: Connection, msg: string): void => {\n try {\n conn.send(msg);\n } catch {\n /* a throwing socket must not break the delivery loop */\n }\n };\n if (op.kind === 'upsert') {\n const audience = (op.element as OwnedElement).audience;\n const upsertMsg = JSON.stringify({ from, op });\n const removeMsg = JSON.stringify({\n from: HUB_FROM,\n op: { kind: 'remove', id: op.element.id },\n });\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (!conn) continue;\n if (this.mayRead(conn, audience)) send(conn, upsertMsg);\n else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);\n }\n } else if (op.kind === 'remove') {\n const removeMsg = JSON.stringify({ from, op });\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (!conn) continue;\n // No read filter → forward to all (today's behavior; current/prevExisted aren't fetched without\n // a hook). With canRead, only recipients who could see the removed element get it.\n const wasVisible = !this.canRead || (prevExisted && this.mayRead(conn, prevAudience));\n if (wasVisible) send(conn, removeMsg);\n }\n } else if (op.kind === 'clear') {\n const clearMsg = JSON.stringify({ from, op });\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (conn) send(conn, clearMsg);\n }\n }\n }\n\n private async sendCorrection(\n conn: Connection,\n from: string,\n op: SyncOp,\n current: OwnedElement | undefined,\n ): Promise<void> {\n let correction: SyncOp | undefined;\n if (op.kind === 'upsert') {\n correction = current\n ? { kind: 'upsert', element: current }\n : { kind: 'remove', id: op.element.id };\n } else if (op.kind === 'remove') {\n correction = current ? { kind: 'upsert', element: current } : undefined;\n } else if (op.kind === 'clear') {\n const elements = await this.backend.snapshot(conn.room);\n correction = { kind: 'snapshot', to: from, elements };\n }\n if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));\n }\n\n private onFanout(payload: string): void {\n // Off the serial queue on purpose: forward-only (the origin already applied to the SHARED backend),\n // and delivery is already ordered. Re-filter per local member (canRead runs on EVERY instance).\n let env: {\n o?: unknown;\n room?: unknown;\n from?: unknown;\n op?: unknown;\n prev?: unknown;\n existed?: unknown;\n };\n try {\n env = JSON.parse(payload);\n } catch {\n return;\n }\n if (typeof env.o !== 'string' || typeof env.room !== 'string' || typeof env.from !== 'string')\n return;\n if (env.o === this.instanceId) return; // our own publish — already delivered locally\n const op = env.op;\n if (!isFanoutOp(op)) return;\n const prevAudience = typeof env.prev === 'string' ? env.prev : undefined;\n const prevExisted = env.existed === true;\n this.deliverToRoom(env.room, undefined, env.from, op, prevAudience, prevExisted);\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 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';\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, CanRead } from './authorize';\nimport { startHeartbeat } from './heartbeat';\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 canRead?: CanRead;\n heartbeatIntervalMs?: number;\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 canRead: options.canRead,\n });\n const wss = options.server\n ? new WebSocketServer({ server: options.server })\n : new WebSocketServer({ port: options.port ?? 0 });\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\n let counter = 0;\n wss.on('connection', (ws, req) => {\n heartbeat.track(ws);\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(4400, '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 heartbeat.stop();\n hub.close();\n wss.close(() => resolve());\n }),\n };\n}\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
|
@@ -22,6 +22,7 @@ interface HubBackend {
|
|
|
22
22
|
|
|
23
23
|
type OwnedElement = CanvasElement & {
|
|
24
24
|
ownerId?: string;
|
|
25
|
+
audience?: string;
|
|
25
26
|
};
|
|
26
27
|
interface AuthorizeContext {
|
|
27
28
|
userId?: string;
|
|
@@ -31,6 +32,13 @@ interface AuthorizeContext {
|
|
|
31
32
|
currentElement?: OwnedElement;
|
|
32
33
|
}
|
|
33
34
|
type Authorize = (ctx: AuthorizeContext) => boolean | Promise<boolean>;
|
|
35
|
+
interface ReadContext {
|
|
36
|
+
userId?: string;
|
|
37
|
+
role?: string;
|
|
38
|
+
room: string;
|
|
39
|
+
audience: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
type CanRead = (ctx: ReadContext) => boolean;
|
|
34
42
|
|
|
35
43
|
interface Connection {
|
|
36
44
|
id: string;
|
|
@@ -44,6 +52,7 @@ interface SyncHubOptions {
|
|
|
44
52
|
fanout?: HubFanout;
|
|
45
53
|
instanceId?: string;
|
|
46
54
|
authorize?: Authorize;
|
|
55
|
+
canRead?: CanRead;
|
|
47
56
|
}
|
|
48
57
|
declare class SyncHub {
|
|
49
58
|
private readonly backend;
|
|
@@ -54,12 +63,16 @@ declare class SyncHub {
|
|
|
54
63
|
private readonly fanout;
|
|
55
64
|
private readonly fanoutUnsub;
|
|
56
65
|
private readonly authorize?;
|
|
66
|
+
private readonly canRead?;
|
|
57
67
|
constructor(options?: SyncHubOptions);
|
|
58
68
|
addConnection(conn: Connection): void;
|
|
59
69
|
removeConnection(connId: string): void;
|
|
60
70
|
roomCount(): number;
|
|
61
71
|
handleMessage(connId: string, message: string): Promise<void>;
|
|
62
72
|
private process;
|
|
73
|
+
private mayRead;
|
|
74
|
+
private deliverToRoom;
|
|
75
|
+
private sendCorrection;
|
|
63
76
|
private onFanout;
|
|
64
77
|
close(): void;
|
|
65
78
|
}
|
|
@@ -90,6 +103,7 @@ interface CreateSyncServerOptions {
|
|
|
90
103
|
instanceId?: string;
|
|
91
104
|
authenticate?: Authenticate;
|
|
92
105
|
authorize?: Authorize;
|
|
106
|
+
canRead?: CanRead;
|
|
93
107
|
heartbeatIntervalMs?: number;
|
|
94
108
|
}
|
|
95
109
|
declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
@@ -112,4 +126,4 @@ interface Heartbeat {
|
|
|
112
126
|
}
|
|
113
127
|
declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
|
|
114
128
|
|
|
115
|
-
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 };
|
|
129
|
+
export { type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type CanRead, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type ReadContext, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
|
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ interface HubBackend {
|
|
|
22
22
|
|
|
23
23
|
type OwnedElement = CanvasElement & {
|
|
24
24
|
ownerId?: string;
|
|
25
|
+
audience?: string;
|
|
25
26
|
};
|
|
26
27
|
interface AuthorizeContext {
|
|
27
28
|
userId?: string;
|
|
@@ -31,6 +32,13 @@ interface AuthorizeContext {
|
|
|
31
32
|
currentElement?: OwnedElement;
|
|
32
33
|
}
|
|
33
34
|
type Authorize = (ctx: AuthorizeContext) => boolean | Promise<boolean>;
|
|
35
|
+
interface ReadContext {
|
|
36
|
+
userId?: string;
|
|
37
|
+
role?: string;
|
|
38
|
+
room: string;
|
|
39
|
+
audience: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
type CanRead = (ctx: ReadContext) => boolean;
|
|
34
42
|
|
|
35
43
|
interface Connection {
|
|
36
44
|
id: string;
|
|
@@ -44,6 +52,7 @@ interface SyncHubOptions {
|
|
|
44
52
|
fanout?: HubFanout;
|
|
45
53
|
instanceId?: string;
|
|
46
54
|
authorize?: Authorize;
|
|
55
|
+
canRead?: CanRead;
|
|
47
56
|
}
|
|
48
57
|
declare class SyncHub {
|
|
49
58
|
private readonly backend;
|
|
@@ -54,12 +63,16 @@ declare class SyncHub {
|
|
|
54
63
|
private readonly fanout;
|
|
55
64
|
private readonly fanoutUnsub;
|
|
56
65
|
private readonly authorize?;
|
|
66
|
+
private readonly canRead?;
|
|
57
67
|
constructor(options?: SyncHubOptions);
|
|
58
68
|
addConnection(conn: Connection): void;
|
|
59
69
|
removeConnection(connId: string): void;
|
|
60
70
|
roomCount(): number;
|
|
61
71
|
handleMessage(connId: string, message: string): Promise<void>;
|
|
62
72
|
private process;
|
|
73
|
+
private mayRead;
|
|
74
|
+
private deliverToRoom;
|
|
75
|
+
private sendCorrection;
|
|
63
76
|
private onFanout;
|
|
64
77
|
close(): void;
|
|
65
78
|
}
|
|
@@ -90,6 +103,7 @@ interface CreateSyncServerOptions {
|
|
|
90
103
|
instanceId?: string;
|
|
91
104
|
authenticate?: Authenticate;
|
|
92
105
|
authorize?: Authorize;
|
|
106
|
+
canRead?: CanRead;
|
|
93
107
|
heartbeatIntervalMs?: number;
|
|
94
108
|
}
|
|
95
109
|
declare function createSyncServer(options?: CreateSyncServerOptions): {
|
|
@@ -112,4 +126,4 @@ interface Heartbeat {
|
|
|
112
126
|
}
|
|
113
127
|
declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
|
|
114
128
|
|
|
115
|
-
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 };
|
|
129
|
+
export { type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type CanRead, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type ReadContext, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/sync-hub.ts
|
|
2
|
-
import { parseEnvelope } from "@fieldnotes/sync";
|
|
2
|
+
import { parseEnvelope, isValidElement } from "@fieldnotes/sync";
|
|
3
3
|
|
|
4
4
|
// src/memory-hub-backend.ts
|
|
5
5
|
import { applyOpToMap } from "@fieldnotes/sync";
|
|
@@ -52,6 +52,13 @@ function generateInstanceId() {
|
|
|
52
52
|
return crypto.randomUUID();
|
|
53
53
|
return `i-${Math.random().toString(36).slice(2)}`;
|
|
54
54
|
}
|
|
55
|
+
function isFanoutOp(op) {
|
|
56
|
+
if (typeof op !== "object" || op === null) return false;
|
|
57
|
+
const o = op;
|
|
58
|
+
if (o.kind === "upsert") return isValidElement(o.element);
|
|
59
|
+
if (o.kind === "remove") return typeof o.id === "string";
|
|
60
|
+
return o.kind === "clear";
|
|
61
|
+
}
|
|
55
62
|
var SyncHub = class {
|
|
56
63
|
backend;
|
|
57
64
|
conns = /* @__PURE__ */ new Map();
|
|
@@ -63,11 +70,13 @@ var SyncHub = class {
|
|
|
63
70
|
fanout;
|
|
64
71
|
fanoutUnsub;
|
|
65
72
|
authorize;
|
|
73
|
+
canRead;
|
|
66
74
|
constructor(options = {}) {
|
|
67
75
|
this.backend = options.backend ?? new MemoryHubBackend();
|
|
68
76
|
this.instanceId = options.instanceId ?? generateInstanceId();
|
|
69
77
|
this.fanout = options.fanout ?? new InMemoryHubFanout();
|
|
70
78
|
this.authorize = options.authorize;
|
|
79
|
+
this.canRead = options.canRead;
|
|
71
80
|
this.fanoutUnsub = this.fanout.subscribe((payload) => this.onFanout(payload));
|
|
72
81
|
}
|
|
73
82
|
addConnection(conn) {
|
|
@@ -110,16 +119,17 @@ var SyncHub = class {
|
|
|
110
119
|
if (!env) return;
|
|
111
120
|
const op = env.op;
|
|
112
121
|
if (op.kind === "request-snapshot") {
|
|
113
|
-
const
|
|
122
|
+
const all = await this.backend.snapshot(conn.room);
|
|
123
|
+
const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
|
|
114
124
|
conn.send(
|
|
115
125
|
JSON.stringify({ from: HUB_FROM, op: { kind: "snapshot", to: env.from, elements } })
|
|
116
126
|
);
|
|
117
127
|
} else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
|
|
128
|
+
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
129
|
+
const needCurrent = (this.authorize || this.canRead) && id !== void 0;
|
|
130
|
+
const current = needCurrent ? await this.backend.get(conn.room, id) : void 0;
|
|
118
131
|
let outboundOp = op;
|
|
119
|
-
let outboundMessage = message;
|
|
120
132
|
if (this.authorize) {
|
|
121
|
-
const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
|
|
122
|
-
const current = id !== void 0 ? await this.backend.get(conn.room, id) : void 0;
|
|
123
133
|
const allowed = await this.authorize({
|
|
124
134
|
userId: conn.userId,
|
|
125
135
|
role: conn.role,
|
|
@@ -127,27 +137,89 @@ var SyncHub = class {
|
|
|
127
137
|
op,
|
|
128
138
|
currentElement: current
|
|
129
139
|
});
|
|
130
|
-
if (!allowed)
|
|
140
|
+
if (!allowed) {
|
|
141
|
+
await this.sendCorrection(conn, env.from, op, current);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
131
144
|
if (op.kind === "upsert") {
|
|
132
145
|
const ownerId = current?.ownerId ?? conn.userId;
|
|
133
146
|
const stampedElement = { ...op.element, ownerId };
|
|
134
147
|
outboundOp = { kind: "upsert", element: stampedElement };
|
|
135
|
-
outboundMessage = JSON.stringify({ from: env.from, op: outboundOp });
|
|
136
148
|
}
|
|
137
149
|
}
|
|
138
150
|
await this.backend.apply(conn.room, outboundOp);
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (id === conn.id) continue;
|
|
143
|
-
this.conns.get(id)?.send(outboundMessage);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
151
|
+
const prevExisted = current !== void 0;
|
|
152
|
+
const prevAudience = current?.audience;
|
|
153
|
+
this.deliverToRoom(conn.room, conn.id, env.from, outboundOp, prevAudience, prevExisted);
|
|
146
154
|
this.fanout.publish(
|
|
147
|
-
JSON.stringify({
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
o: this.instanceId,
|
|
157
|
+
room: conn.room,
|
|
158
|
+
from: env.from,
|
|
159
|
+
op: outboundOp,
|
|
160
|
+
prev: prevAudience,
|
|
161
|
+
existed: prevExisted
|
|
162
|
+
})
|
|
148
163
|
);
|
|
149
164
|
}
|
|
150
165
|
}
|
|
166
|
+
mayRead(conn, audience) {
|
|
167
|
+
if (!this.canRead) return true;
|
|
168
|
+
return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
|
|
169
|
+
}
|
|
170
|
+
deliverToRoom(room, excludeId, from, op, prevAudience, prevExisted) {
|
|
171
|
+
const members = this.rooms.get(room);
|
|
172
|
+
if (!members) return;
|
|
173
|
+
const send = (conn, msg) => {
|
|
174
|
+
try {
|
|
175
|
+
conn.send(msg);
|
|
176
|
+
} catch {
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
if (op.kind === "upsert") {
|
|
180
|
+
const audience = op.element.audience;
|
|
181
|
+
const upsertMsg = JSON.stringify({ from, op });
|
|
182
|
+
const removeMsg = JSON.stringify({
|
|
183
|
+
from: HUB_FROM,
|
|
184
|
+
op: { kind: "remove", id: op.element.id }
|
|
185
|
+
});
|
|
186
|
+
for (const cid of members) {
|
|
187
|
+
if (cid === excludeId) continue;
|
|
188
|
+
const conn = this.conns.get(cid);
|
|
189
|
+
if (!conn) continue;
|
|
190
|
+
if (this.mayRead(conn, audience)) send(conn, upsertMsg);
|
|
191
|
+
else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);
|
|
192
|
+
}
|
|
193
|
+
} else if (op.kind === "remove") {
|
|
194
|
+
const removeMsg = JSON.stringify({ from, op });
|
|
195
|
+
for (const cid of members) {
|
|
196
|
+
if (cid === excludeId) continue;
|
|
197
|
+
const conn = this.conns.get(cid);
|
|
198
|
+
if (!conn) continue;
|
|
199
|
+
const wasVisible = !this.canRead || prevExisted && this.mayRead(conn, prevAudience);
|
|
200
|
+
if (wasVisible) send(conn, removeMsg);
|
|
201
|
+
}
|
|
202
|
+
} else if (op.kind === "clear") {
|
|
203
|
+
const clearMsg = JSON.stringify({ from, op });
|
|
204
|
+
for (const cid of members) {
|
|
205
|
+
if (cid === excludeId) continue;
|
|
206
|
+
const conn = this.conns.get(cid);
|
|
207
|
+
if (conn) send(conn, clearMsg);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async sendCorrection(conn, from, op, current) {
|
|
212
|
+
let correction;
|
|
213
|
+
if (op.kind === "upsert") {
|
|
214
|
+
correction = current ? { kind: "upsert", element: current } : { kind: "remove", id: op.element.id };
|
|
215
|
+
} else if (op.kind === "remove") {
|
|
216
|
+
correction = current ? { kind: "upsert", element: current } : void 0;
|
|
217
|
+
} else if (op.kind === "clear") {
|
|
218
|
+
const elements = await this.backend.snapshot(conn.room);
|
|
219
|
+
correction = { kind: "snapshot", to: from, elements };
|
|
220
|
+
}
|
|
221
|
+
if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));
|
|
222
|
+
}
|
|
151
223
|
onFanout(payload) {
|
|
152
224
|
let env;
|
|
153
225
|
try {
|
|
@@ -155,18 +227,14 @@ var SyncHub = class {
|
|
|
155
227
|
} catch {
|
|
156
228
|
return;
|
|
157
229
|
}
|
|
158
|
-
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.
|
|
230
|
+
if (typeof env.o !== "string" || typeof env.room !== "string" || typeof env.from !== "string")
|
|
159
231
|
return;
|
|
160
232
|
if (env.o === this.instanceId) return;
|
|
161
|
-
const
|
|
162
|
-
if (!
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
this.conns.get(id)?.send(m);
|
|
167
|
-
} catch {
|
|
168
|
-
}
|
|
169
|
-
}
|
|
233
|
+
const op = env.op;
|
|
234
|
+
if (!isFanoutOp(op)) return;
|
|
235
|
+
const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
|
|
236
|
+
const prevExisted = env.existed === true;
|
|
237
|
+
this.deliverToRoom(env.room, void 0, env.from, op, prevAudience, prevExisted);
|
|
170
238
|
}
|
|
171
239
|
close() {
|
|
172
240
|
this.fanoutUnsub();
|
|
@@ -213,7 +281,8 @@ function createSyncServer(options = {}) {
|
|
|
213
281
|
backend: options.backend,
|
|
214
282
|
fanout: options.fanout,
|
|
215
283
|
instanceId: options.instanceId,
|
|
216
|
-
authorize: options.authorize
|
|
284
|
+
authorize: options.authorize,
|
|
285
|
+
canRead: options.canRead
|
|
217
286
|
});
|
|
218
287
|
const wss = options.server ? new WebSocketServer({ server: options.server }) : new WebSocketServer({ port: options.port ?? 0 });
|
|
219
288
|
const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
|
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","../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) return;\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 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';\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 if (op.kind === 'clear') {\n this.rooms.delete(room);\n return;\n }\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';\nimport { startHeartbeat } from './heartbeat';\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 heartbeatIntervalMs?: number;\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 const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\n let counter = 0;\n wss.on('connection', (ws, req) => {\n heartbeat.track(ws);\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(4400, '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 heartbeat.stop();\n hub.close();\n wss.close(() => resolve());\n }),\n };\n}\n","export interface HeartbeatSocket {\n ping(): void;\n terminate(): void;\n on(event: 'pong', listener: () => void): void;\n}\nexport interface HeartbeatServer {\n clients: Set<HeartbeatSocket>;\n}\nexport interface Heartbeat {\n track(ws: HeartbeatSocket): void;\n stop(): void;\n}\n\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\n if (intervalMs <= 0) {\n return {\n track: () => {\n /* disabled */\n },\n stop: () => {\n /* disabled */\n },\n };\n }\n const alive = new WeakMap<HeartbeatSocket, boolean>();\n const track = (ws: HeartbeatSocket): void => {\n alive.set(ws, true);\n ws.on('pong', () => alive.set(ws, true));\n };\n const interval = setInterval(() => {\n for (const ws of wss.clients) {\n try {\n if (alive.get(ws) === false) {\n ws.terminate();\n continue;\n }\n alive.set(ws, false);\n ws.ping();\n } catch {\n /* socket dying mid-tick — skip it */\n }\n }\n }, intervalMs);\n return { track, stop: () => clearInterval(interval) };\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, isValidElement, 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, CanRead, 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 canRead?: CanRead;\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\nfunction isFanoutOp(op: unknown): op is Extract<SyncOp, { kind: 'upsert' | 'remove' | 'clear' }> {\n if (typeof op !== 'object' || op === null) return false;\n const o = op as { kind?: unknown; element?: unknown; id?: unknown };\n if (o.kind === 'upsert') return isValidElement(o.element);\n if (o.kind === 'remove') return typeof o.id === 'string';\n return o.kind === 'clear';\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 private readonly canRead?: CanRead;\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.canRead = options.canRead;\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 // The per-room serial queue is the single total-order authority: ops apply in arrival order\n // (arrival-order LWW — no per-element seq; see D3 / TD-12). Different rooms run independently.\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 all = (await this.backend.snapshot(conn.room)) as OwnedElement[];\n const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;\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 const id = op.kind === 'upsert' ? op.element.id : op.kind === 'remove' ? op.id : undefined;\n const needCurrent = (this.authorize || this.canRead) && id !== undefined;\n const current: OwnedElement | undefined = needCurrent\n ? await this.backend.get(conn.room, id)\n : undefined;\n\n let outboundOp: SyncOp = op;\n if (this.authorize) {\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) {\n await this.sendCorrection(conn, env.from, op, current);\n return;\n }\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 }\n }\n\n await this.backend.apply(conn.room, outboundOp);\n\n const prevExisted = current !== undefined;\n const prevAudience = current?.audience;\n\n this.deliverToRoom(conn.room, conn.id, env.from, outboundOp, prevAudience, prevExisted);\n\n this.fanout.publish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: env.from,\n op: outboundOp,\n prev: prevAudience,\n existed: prevExisted,\n }),\n );\n }\n // 'snapshot' from a client → ignored\n }\n\n private mayRead(conn: Connection, audience: string | undefined): boolean {\n if (!this.canRead) return true;\n return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });\n }\n\n private deliverToRoom(\n room: string,\n excludeId: string | undefined,\n from: string,\n op: SyncOp,\n prevAudience: string | undefined,\n prevExisted: boolean,\n ): void {\n const members = this.rooms.get(room);\n if (!members) return;\n const send = (conn: Connection, msg: string): void => {\n try {\n conn.send(msg);\n } catch {\n /* a throwing socket must not break the delivery loop */\n }\n };\n if (op.kind === 'upsert') {\n const audience = (op.element as OwnedElement).audience;\n const upsertMsg = JSON.stringify({ from, op });\n const removeMsg = JSON.stringify({\n from: HUB_FROM,\n op: { kind: 'remove', id: op.element.id },\n });\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (!conn) continue;\n if (this.mayRead(conn, audience)) send(conn, upsertMsg);\n else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);\n }\n } else if (op.kind === 'remove') {\n const removeMsg = JSON.stringify({ from, op });\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (!conn) continue;\n // No read filter → forward to all (today's behavior; current/prevExisted aren't fetched without\n // a hook). With canRead, only recipients who could see the removed element get it.\n const wasVisible = !this.canRead || (prevExisted && this.mayRead(conn, prevAudience));\n if (wasVisible) send(conn, removeMsg);\n }\n } else if (op.kind === 'clear') {\n const clearMsg = JSON.stringify({ from, op });\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (conn) send(conn, clearMsg);\n }\n }\n }\n\n private async sendCorrection(\n conn: Connection,\n from: string,\n op: SyncOp,\n current: OwnedElement | undefined,\n ): Promise<void> {\n let correction: SyncOp | undefined;\n if (op.kind === 'upsert') {\n correction = current\n ? { kind: 'upsert', element: current }\n : { kind: 'remove', id: op.element.id };\n } else if (op.kind === 'remove') {\n correction = current ? { kind: 'upsert', element: current } : undefined;\n } else if (op.kind === 'clear') {\n const elements = await this.backend.snapshot(conn.room);\n correction = { kind: 'snapshot', to: from, elements };\n }\n if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));\n }\n\n private onFanout(payload: string): void {\n // Off the serial queue on purpose: forward-only (the origin already applied to the SHARED backend),\n // and delivery is already ordered. Re-filter per local member (canRead runs on EVERY instance).\n let env: {\n o?: unknown;\n room?: unknown;\n from?: unknown;\n op?: unknown;\n prev?: unknown;\n existed?: unknown;\n };\n try {\n env = JSON.parse(payload);\n } catch {\n return;\n }\n if (typeof env.o !== 'string' || typeof env.room !== 'string' || typeof env.from !== 'string')\n return;\n if (env.o === this.instanceId) return; // our own publish — already delivered locally\n const op = env.op;\n if (!isFanoutOp(op)) return;\n const prevAudience = typeof env.prev === 'string' ? env.prev : undefined;\n const prevExisted = env.existed === true;\n this.deliverToRoom(env.room, undefined, env.from, op, prevAudience, prevExisted);\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 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';\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, CanRead } from './authorize';\nimport { startHeartbeat } from './heartbeat';\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 canRead?: CanRead;\n heartbeatIntervalMs?: number;\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 canRead: options.canRead,\n });\n const wss = options.server\n ? new WebSocketServer({ server: options.server })\n : new WebSocketServer({ port: options.port ?? 0 });\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\n let counter = 0;\n wss.on('connection', (ws, req) => {\n heartbeat.track(ws);\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(4400, '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 heartbeat.stop();\n hub.close();\n wss.close(() => resolve());\n }),\n };\n}\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.7.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.
|
|
41
|
+
"@fieldnotes/sync": "0.6.0"
|
|
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",
|