@fieldnotes/sync-server 0.8.1 → 0.8.2

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
@@ -1,204 +1,212 @@
1
- # @fieldnotes/sync-server
2
-
3
- Authoritative WebSocket relay server for [`@fieldnotes/sync`](../sync).
4
-
5
- The relay holds the canonical per-room canvas state and fans out element
6
- operations to every other connection in the room. Clients connect, request a
7
- snapshot to catch up, then stream `upsert` / `remove` / `clear` ops that the hub
8
- applies and forwards.
9
-
10
- ## Pieces
11
-
12
- - **`SyncHub`** — transport-agnostic relay. Per-room canonical state lives behind
13
- an async `HubBackend`; each room processes messages on its own serial queue so
14
- concurrent edits to the same room never race, while different rooms run
15
- independently.
16
- - **`MemoryHubBackend`** — in-memory `HubBackend` (the default). Redis-backed and
17
- authenticated backends are planned. It reclaims a room's memory on `clear`, but
18
- **retains** state for uncleared / abandoned rooms — fine for dev / single-instance
19
- use. For a long-lived production process, use a Redis backend with a key TTL, or
20
- `clear` rooms you're done with.
21
- - **`createSyncServer`** — a runnable `ws` reference server. Connect with
22
- `?room=<id>` in the query string; a missing room closes the socket with WS code
23
- `4400`.
24
- - **`HubFanout`** — cross-instance live fan-out seam. `SyncHub` publishes each live
25
- op to the fanout and forwards ops it receives from other instances to its local
26
- connections. The default `InMemoryHubFanout` is in-process (a no-op for a single
27
- instance). For multiple relay instances to share live ops, pass a shared fanout
28
- via `createSyncServer({ fanout })` (e.g. `RedisHubFanout` from
29
- [`@fieldnotes/sync-redis`](../sync-redis)) **together with a shared backend** — a
30
- shared fanout alone leaves a new joiner's snapshot stale.
31
-
32
- ## Usage
33
-
34
- ```ts
35
- import { createSyncServer } from '@fieldnotes/sync-server';
36
-
37
- const { close } = createSyncServer({ port: 8080 });
38
- // ws://localhost:8080?room=my-room
39
- ```
40
-
41
- ## Heartbeat
42
-
43
- The server pings every client on an interval and **terminates** any that miss a pong,
44
- so half-open sockets (a backgrounded iPad, dropped WiFi) are reaped instead of silently
45
- leaking room membership. Configure via `heartbeatIntervalMs` (default `30000`; `0`
46
- disables):
47
-
48
- ```ts
49
- createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });
50
- ```
51
-
52
- Browsers auto-pong at the protocol level, so **no client change** is needed.
53
-
54
- ## Authentication
55
-
56
- Pass an `authenticate` hook to gate connections:
57
-
58
- ```ts
59
- import { createSyncServer } from '@fieldnotes/sync-server';
60
-
61
- createSyncServer({
62
- port: 8080,
63
- authenticate: async ({ req, room }) => {
64
- const token = new URL(req.url ?? '', 'http://x').searchParams.get('token');
65
- const member = await myCampaign.verify(token, room); // your app's check (Redis/DB/JWT)
66
- return member ? { userId: member.id, role: member.isDM ? 'dm' : 'player' } : null;
67
- },
68
- });
69
- ```
70
-
71
- `authenticate({ req, room }) → { userId, role? } | null` runs on each connection and
72
- may be sync or async. Returning `null` (or throwing) **rejects** the connection: the
73
- socket is closed with WS code `4401` and the connection is never admitted to the room —
74
- no membership, no snapshot. A resolved result admits the connection carrying its
75
- `userId` and optional `role`.
76
-
77
- With **no hook**, rooms stay open: every connection is admitted anonymously with
78
- `userId = connId`. `role` is captured now and enforced in an upcoming release
79
- (role-based authorization and per-viewer visibility filtering).
80
-
81
- Messages that arrive during an async auth (notably the client's initial
82
- `request-snapshot`) are queued and replayed once the connection is admitted, so the
83
- first snapshot is never lost to the auth round-trip.
84
-
85
- ### Passing a token
86
-
87
- A browser `WebSocket` can't set request headers, so pass the token as a URL query
88
- param (`ws://relay?room=R&token=…`) and read it from `req.url` in `authenticate`. URLs
89
- land in access/proxy logs, so prefer **short-lived / single-use** tokens. Non-browser
90
- clients can instead put the token in `req.headers` (e.g. `Authorization`), which
91
- `authenticate` reads directly.
92
-
93
- ## Authorization
94
-
95
- Pass an `authorize` hook to gate **writes**. The hub calls it before applying each data
96
- op (`upsert` / `remove` / `clear`) and **drops denied ops** a denied op is not applied
97
- to room state and not forwarded to any other connection:
98
-
99
- ```ts
100
- authorize(ctx) => boolean | Promise<boolean>
101
- ```
102
-
103
- `ctx` is an `AuthorizeContext`:
104
-
105
- ```ts
106
- {
107
- userId?: string; // the connection's authenticated user (from authenticate)
108
- role?: string; // the connection's role (from authenticate)
109
- room: string;
110
- op: SyncOp; // the incoming upsert / remove / clear
111
- currentElement?: OwnedElement; // the STORED element, if this id already exists
112
- }
113
- ```
114
-
115
- `currentElement` is the element currently in room state for an `upsert`/`remove` of an
116
- **existing** id (typed `OwnedElement = CanvasElement & { ownerId?: string }`), and
117
- `undefined` for a new/absent id.
118
-
119
- **Ownership is server-stamped and un-forgeable.** A new element's `ownerId` is set to the
120
- authenticated creator; on edit the stored owner is **preserved**; a client-supplied
121
- `ownerId` is always **discarded**. A policy can therefore trust `currentElement.ownerId`
122
- to enforce "own elements only".
123
-
124
- With **no hook**, rooms are OPEN (allow-all every op is accepted).
125
-
126
- A copy-paste DM / player / display policy:
127
-
128
- ```ts
129
- createSyncServer({
130
- port: 8080,
131
- authenticate: /* … supplies userId + role … */,
132
- authorize: ({ role, op, currentElement, userId }) => {
133
- if (role === 'dm') return true;
134
- if (role === 'display') return false; // read-only monitor
135
- if (role === 'player') { // own elements only, never destructive
136
- if (op.kind === 'clear') return false;
137
- if (op.kind === 'upsert') return !currentElement || currentElement.ownerId === userId;
138
- if (op.kind === 'remove') return currentElement?.ownerId === userId;
139
- return false;
140
- }
141
- return false;
142
- },
143
- });
144
- ```
145
-
146
- **Important:**
147
-
148
- - Ownership-based authz **requires `authenticate` to supply a STABLE `userId`**. The
149
- no-hook anonymous default is `userId = connId`, which changes on every reconnect — a
150
- user would lose access to their own elements after reconnecting.
151
- - The authz path adds one `backend.get` (a Redis `HGET`) per data op — negligible for
152
- low-write use.
153
- - Reads / visibility (a player not **receiving** hidden content) are a separate, upcoming
154
- concern (D3). `authorize` gates writes only.
155
- - When `authorize` denies an op, the hub sends the offending client a **corrective op** so its
156
- optimistic local edit self-corrects immediately no client change, no waiting for reconnect:
157
- a rejected new element is **removed**, a rejected edit/remove is re-**upserted** from the canonical
158
- stored element, and a rejected `clear` gets a fresh **snapshot**. The correction reuses existing op
159
- kinds and is sent only to the offending connection.
160
-
161
- ## 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.
203
-
204
- A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).
1
+ # @fieldnotes/sync-server
2
+
3
+ Authoritative WebSocket relay server for [`@fieldnotes/sync`](../sync).
4
+
5
+ The relay holds the canonical per-room canvas state and fans out element
6
+ operations to every other connection in the room. Clients connect, request a
7
+ snapshot to catch up, then stream `upsert` / `remove` / `clear` ops that the hub
8
+ applies and forwards.
9
+
10
+ ## Pieces
11
+
12
+ - **`SyncHub`** — transport-agnostic relay. Per-room canonical state lives behind
13
+ an async `HubBackend`; each room processes messages on its own serial queue so
14
+ concurrent edits to the same room never race, while different rooms run
15
+ independently.
16
+ - **`MemoryHubBackend`** — in-memory `HubBackend` (the default). Redis-backed and
17
+ authenticated backends are planned. It reclaims a room's memory on `clear`, but
18
+ **retains** state for uncleared / abandoned rooms — fine for dev / single-instance
19
+ use. For a long-lived production process, use a Redis backend with a key TTL, or
20
+ `clear` rooms you're done with.
21
+ - **`createSyncServer`** — a runnable `ws` reference server. Connect with
22
+ `?room=<id>` in the query string; a missing room closes the socket with WS code
23
+ `4400`.
24
+ - **`HubFanout`** — cross-instance live fan-out seam. `SyncHub` publishes each live
25
+ op to the fanout and forwards ops it receives from other instances to its local
26
+ connections. The default `InMemoryHubFanout` is in-process (a no-op for a single
27
+ instance). For multiple relay instances to share live ops, pass a shared fanout
28
+ via `createSyncServer({ fanout })` (e.g. `RedisHubFanout` from
29
+ [`@fieldnotes/sync-redis`](../sync-redis)) **together with a shared backend** — a
30
+ shared fanout alone leaves a new joiner's snapshot stale.
31
+
32
+ ## Usage
33
+
34
+ ```ts
35
+ import { createSyncServer } from '@fieldnotes/sync-server';
36
+
37
+ const { close } = createSyncServer({ port: 8080 });
38
+ // ws://localhost:8080?room=my-room
39
+ ```
40
+
41
+ ## Heartbeat
42
+
43
+ The server pings every client on an interval and **terminates** any that miss a pong,
44
+ so half-open sockets (a backgrounded iPad, dropped WiFi) are reaped instead of silently
45
+ leaking room membership. Configure via `heartbeatIntervalMs` (default `30000`; `0`
46
+ disables):
47
+
48
+ ```ts
49
+ createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });
50
+ ```
51
+
52
+ Browsers auto-pong at the protocol level, so **no client change** is needed.
53
+
54
+ ## Authentication
55
+
56
+ Pass an `authenticate` hook to gate connections:
57
+
58
+ ```ts
59
+ import { createSyncServer } from '@fieldnotes/sync-server';
60
+
61
+ createSyncServer({
62
+ port: 8080,
63
+ authenticate: async ({ req, room }) => {
64
+ const token = new URL(req.url ?? '', 'http://x').searchParams.get('token');
65
+ const member = await myCampaign.verify(token, room); // your app's check (Redis/DB/JWT)
66
+ return member ? { userId: member.id, role: member.isDM ? 'dm' : 'player' } : null;
67
+ },
68
+ });
69
+ ```
70
+
71
+ `authenticate({ req, room }) → { userId, role? } | null` runs on each connection and
72
+ may be sync or async. Returning `null` (or throwing) **rejects** the connection: the
73
+ socket is closed with WS code `4401` and the connection is never admitted to the room —
74
+ no membership, no snapshot. A resolved result admits the connection carrying its
75
+ `userId` and optional `role`.
76
+
77
+ With **no hook**, rooms stay open: every connection is admitted anonymously with
78
+ `userId = connId`. `role` is captured now and enforced in an upcoming release
79
+ (role-based authorization and per-viewer visibility filtering).
80
+
81
+ Messages that arrive during an async auth (notably the client's initial
82
+ `request-snapshot`) are queued and replayed once the connection is admitted, so the
83
+ first snapshot is never lost to the auth round-trip.
84
+
85
+ ### Relay identity
86
+
87
+ The relay assigns every admitted socket an opaque connection identity. That server-owned identity
88
+ is used as `from` on all relayed mutations, presence updates, cross-instance fan-out, and disconnect
89
+ leave events; the client-controlled envelope `from` cannot impersonate another connection. Presence
90
+ identity is intentionally connection-scoped and changes after reconnecting. Application authorization
91
+ continues to use the authenticated `userId` and `role`, not this transport identity.
92
+
93
+ ### Passing a token
94
+
95
+ A browser `WebSocket` can't set request headers, so pass the token as a URL query
96
+ param (`ws://relay?room=R&token=…`) and read it from `req.url` in `authenticate`. URLs
97
+ land in access/proxy logs, so prefer **short-lived / single-use** tokens. Non-browser
98
+ clients can instead put the token in `req.headers` (e.g. `Authorization`), which
99
+ `authenticate` reads directly.
100
+
101
+ ## Authorization
102
+
103
+ Pass an `authorize` hook to gate **writes**. The hub calls it before applying each data
104
+ op (`upsert` / `remove` / `clear`) and **drops denied ops** — a denied op is not applied
105
+ to room state and not forwarded to any other connection:
106
+
107
+ ```ts
108
+ authorize(ctx) => boolean | Promise<boolean>
109
+ ```
110
+
111
+ `ctx` is an `AuthorizeContext`:
112
+
113
+ ```ts
114
+ {
115
+ userId?: string; // the connection's authenticated user (from authenticate)
116
+ role?: string; // the connection's role (from authenticate)
117
+ room: string;
118
+ op: SyncOp; // the incoming upsert / remove / clear
119
+ currentElement?: OwnedElement; // the STORED element, if this id already exists
120
+ }
121
+ ```
122
+
123
+ `currentElement` is the element currently in room state for an `upsert`/`remove` of an
124
+ **existing** id (typed `OwnedElement = CanvasElement & { ownerId?: string }`), and
125
+ `undefined` for a new/absent id.
126
+
127
+ **Ownership is server-stamped and un-forgeable.** A new element's `ownerId` is set to the
128
+ authenticated creator; on edit the stored owner is **preserved**; a client-supplied
129
+ `ownerId` is always **discarded**. A policy can therefore trust `currentElement.ownerId`
130
+ to enforce "own elements only".
131
+
132
+ With **no hook**, rooms are OPEN (allow-all every op is accepted).
133
+
134
+ A copy-paste DM / player / display policy:
135
+
136
+ ```ts
137
+ createSyncServer({
138
+ port: 8080,
139
+ authenticate: /* … supplies userId + role … */,
140
+ authorize: ({ role, op, currentElement, userId }) => {
141
+ if (role === 'dm') return true;
142
+ if (role === 'display') return false; // read-only monitor
143
+ if (role === 'player') { // own elements only, never destructive
144
+ if (op.kind === 'clear') return false;
145
+ if (op.kind === 'upsert') return !currentElement || currentElement.ownerId === userId;
146
+ if (op.kind === 'remove') return currentElement?.ownerId === userId;
147
+ return false;
148
+ }
149
+ return false;
150
+ },
151
+ });
152
+ ```
153
+
154
+ **Important:**
155
+
156
+ - Ownership-based authz **requires `authenticate` to supply a STABLE `userId`**. The
157
+ no-hook anonymous default is `userId = connId`, which changes on every reconnect — a
158
+ user would lose access to their own elements after reconnecting.
159
+ - The authz path adds one `backend.get` (a Redis `HGET`) per data op — negligible for
160
+ low-write use.
161
+ - Reads / visibility (a player not **receiving** hidden content) are a separate, upcoming
162
+ concern (D3). `authorize` gates writes only.
163
+ - When `authorize` denies an op, the hub sends the offending client a **corrective op** so its
164
+ optimistic local edit self-corrects immediately no client change, no waiting for reconnect:
165
+ a rejected new element is **removed**, a rejected edit/remove is re-**upserted** from the canonical
166
+ stored element, and a rejected `clear` gets a fresh **snapshot**. The correction reuses existing op
167
+ kinds and is sent only to the offending connection.
168
+
169
+ ## Read filtering
170
+
171
+ Pass a `canRead` hook to gate **reads** what each viewer **receives**. Unlike client-side
172
+ hiding, the hub filters ops before they leave the server, so hidden elements never reach an
173
+ unauthorized client on either path: the live broadcast **and** the snapshot a client gets on join.
174
+
175
+ ```ts
176
+ canRead({ userId, role, room, audience }) => boolean
177
+ ```
178
+
179
+ `audience` is the opaque tag the client stamps on its own outgoing upserts via `@fieldnotes/sync`'s
180
+ `resolveAudience` (e.g. `'dm'` / `'shared'`, derived from the app's layers). With **no hook**,
181
+ everyone sees everything.
182
+
183
+ A two-tier DM / player policy — the relay's `canRead` paired with the client's `resolveAudience`:
184
+
185
+ ```ts
186
+ // relay
187
+ createSyncServer({
188
+ port: 8080,
189
+ authenticate: /* supplies userId + role */,
190
+ canRead: ({ role, audience }) => audience !== 'dm' || role === 'dm',
191
+ });
192
+
193
+ // client (@fieldnotes/sync)
194
+ new SyncClient({ store, transport, resolveAudience: (el) => layerOf(el) }); // 'dm' | 'shared'
195
+ ```
196
+
197
+ Moving an element between audiences re-evaluates visibility per viewer: a viewer who loses access
198
+ gets a synthetic **remove**, one who gains it gets an **add**.
199
+
200
+ **Preconditions:**
201
+
202
+ - **Stable identity.** Meaningful policy needs a stable `userId`/`role` from `authenticate`; the
203
+ no-hook anonymous default is per-connection (`userId = connId`) and changes on reconnect.
204
+ - **Same `canRead` on every instance.** Unlike `authorize` (which runs on the write's origin
205
+ instance only), `canRead` runs on **every** instance — each re-filters fanned-out ops for its own
206
+ local members — so all relay instances must inject the **same** `canRead`, alongside the existing
207
+ shared-backend + shared-fanout requirement.
208
+ - **Labeling integrity.** `audience` is client-asserted, so read secrecy is only as trustworthy as
209
+ the `authorize` (write) policy that stops a player from stamping `dm` on content they shouldn't
210
+ control.
211
+
212
+ A Redis `HubBackend` and cross-instance fan-out ship in [`@fieldnotes/sync-redis`](../sync-redis).
package/dist/index.cjs CHANGED
@@ -101,8 +101,7 @@ var SyncHub = class {
101
101
  // room → connIds
102
102
  roomQueues = /* @__PURE__ */ new Map();
103
103
  // room → serial tail
104
- presenceIds = /* @__PURE__ */ new Map();
105
- // connId → last clientId that sent presence
104
+ presenceConnections = /* @__PURE__ */ new Set();
106
105
  instanceId;
107
106
  fanout;
108
107
  fanoutUnsub;
@@ -130,8 +129,7 @@ var SyncHub = class {
130
129
  if (!conn) return;
131
130
  this.conns.delete(connId);
132
131
  const room = conn.room;
133
- const clientId = this.presenceIds.get(connId);
134
- this.presenceIds.delete(connId);
132
+ const hadPresence = this.presenceConnections.delete(connId);
135
133
  const members = this.rooms.get(room);
136
134
  if (members) {
137
135
  members.delete(connId);
@@ -140,7 +138,7 @@ var SyncHub = class {
140
138
  this.roomQueues.delete(room);
141
139
  }
142
140
  }
143
- if (clientId !== void 0) this.broadcastLeave(room, clientId);
141
+ if (hadPresence) this.broadcastLeave(room, conn.id);
144
142
  }
145
143
  roomCount() {
146
144
  return this.rooms.size;
@@ -151,7 +149,7 @@ var SyncHub = class {
151
149
  const env = (0, import_sync2.parseEnvelope)(message);
152
150
  if (!env) return Promise.resolve();
153
151
  if (env.op.kind === "presence") {
154
- this.broadcastPresence(conn, env.from, env.op.data);
152
+ this.broadcastPresence(conn, env.op.data);
155
153
  return Promise.resolve();
156
154
  }
157
155
  const room = conn.room;
@@ -167,6 +165,8 @@ var SyncHub = class {
167
165
  const all = await this.backend.snapshot(conn.room);
168
166
  const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
169
167
  conn.send(
168
+ // `to` is a private correlation address for the requesting SyncClient. It is never
169
+ // broadcast; every public sender identity below comes from the server-owned connection.
170
170
  JSON.stringify({ from: HUB_FROM, op: { kind: "snapshot", to: env.from, elements } })
171
171
  );
172
172
  } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
@@ -195,12 +195,12 @@ var SyncHub = class {
195
195
  await this.backend.apply(conn.room, outboundOp);
196
196
  const prevExisted = current !== void 0;
197
197
  const prevAudience = current?.audience;
198
- this.deliverToRoom(conn.room, conn.id, env.from, outboundOp, prevAudience, prevExisted);
198
+ this.deliverToRoom(conn.room, conn.id, conn.id, outboundOp, prevAudience, prevExisted);
199
199
  this.fanout.publish(
200
200
  JSON.stringify({
201
201
  o: this.instanceId,
202
202
  room: conn.room,
203
- from: env.from,
203
+ from: conn.id,
204
204
  op: outboundOp,
205
205
  prev: prevAudience,
206
206
  existed: prevExisted
@@ -231,12 +231,17 @@ var SyncHub = class {
231
231
  }
232
232
  }
233
233
  }
234
- broadcastPresence(conn, from, data) {
235
- this.presenceIds.set(conn.id, from);
236
- const message = JSON.stringify({ from, op: { kind: "presence", data } });
234
+ broadcastPresence(conn, data) {
235
+ this.presenceConnections.add(conn.id);
236
+ const message = JSON.stringify({ from: conn.id, op: { kind: "presence", data } });
237
237
  this.relayToRoom(conn.room, conn.id, message);
238
238
  this.safePublish(
239
- JSON.stringify({ o: this.instanceId, room: conn.room, from, op: { kind: "presence", data } })
239
+ JSON.stringify({
240
+ o: this.instanceId,
241
+ room: conn.room,
242
+ from: conn.id,
243
+ op: { kind: "presence", data }
244
+ })
240
245
  );
241
246
  }
242
247
  broadcastLeave(room, from) {
@@ -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';\r\nexport type { SyncHubOptions, Connection } from './sync-hub';\r\nexport { MemoryHubBackend } from './memory-hub-backend';\r\nexport type { HubBackend } from './hub-backend';\r\nexport { createSyncServer } from './create-sync-server';\r\nexport type { CreateSyncServerOptions } from './create-sync-server';\r\nexport { InMemoryHubFanout } from './hub-fanout';\r\nexport type { HubFanout } from './hub-fanout';\r\nexport type { AuthInfo, AuthResult, Authenticate } from './authenticate';\r\nexport type { Authorize, AuthorizeContext, OwnedElement, ReadContext, CanRead } from './authorize';\r\nexport { startHeartbeat } from './heartbeat';\r\nexport type { Heartbeat, HeartbeatSocket, HeartbeatServer } from './heartbeat';\r\n","import { parseEnvelope, isValidElement, type SyncOp, type SyncEnvelope } 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, CanRead, 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 canRead?: CanRead;\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\nfunction isFanoutOp(op: unknown): op is Extract<SyncOp, { kind: 'upsert' | 'remove' | 'clear' }> {\r\n if (typeof op !== 'object' || op === null) return false;\r\n const o = op as { kind?: unknown; element?: unknown; id?: unknown };\r\n if (o.kind === 'upsert') return isValidElement(o.element);\r\n if (o.kind === 'remove') return typeof o.id === 'string';\r\n return o.kind === 'clear';\r\n}\r\n\r\nfunction isPresenceOp(\r\n op: unknown,\r\n): op is { kind: 'presence'; data: unknown } | { kind: 'presence-leave' } {\r\n if (typeof op !== 'object' || op === null) return false;\r\n const k = (op as { kind?: unknown }).kind;\r\n return k === 'presence' || k === 'presence-leave';\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 presenceIds = new Map<string, string>(); // connId → last clientId that sent presence\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 private readonly canRead?: CanRead;\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.canRead = options.canRead;\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 room = conn.room;\r\n const clientId = this.presenceIds.get(connId);\r\n this.presenceIds.delete(connId);\r\n const members = this.rooms.get(room);\r\n if (members) {\r\n members.delete(connId);\r\n if (members.size === 0) {\r\n this.rooms.delete(room);\r\n this.roomQueues.delete(room);\r\n }\r\n }\r\n if (clientId !== undefined) this.broadcastLeave(room, clientId);\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 env = parseEnvelope(message);\r\n if (!env) return Promise.resolve();\r\n if (env.op.kind === 'presence') {\r\n this.broadcastPresence(conn, env.from, env.op.data); // off-queue, synchronous\r\n return Promise.resolve();\r\n }\r\n const room = conn.room;\r\n // The per-room serial queue is the single total-order authority: ops apply in arrival order\r\n // (arrival-order LWW — no per-element seq; see D3 / TD-12). Different rooms run independently.\r\n const prev = this.roomQueues.get(room) ?? Promise.resolve();\r\n const next = prev\r\n .then(() => this.process(conn, env))\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, env: SyncEnvelope): Promise<void> {\r\n const op = env.op;\r\n if (op.kind === 'request-snapshot') {\r\n const all = (await this.backend.snapshot(conn.room)) as OwnedElement[];\r\n const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;\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 const id = op.kind === 'upsert' ? op.element.id : op.kind === 'remove' ? op.id : undefined;\r\n const needCurrent = (this.authorize || this.canRead) && id !== undefined;\r\n const current: OwnedElement | undefined = needCurrent\r\n ? await this.backend.get(conn.room, id)\r\n : undefined;\r\n\r\n let outboundOp: SyncOp = op;\r\n if (this.authorize) {\r\n const allowed = await this.authorize({\r\n userId: conn.userId,\r\n role: conn.role,\r\n room: conn.room,\r\n op,\r\n currentElement: current,\r\n });\r\n if (!allowed) {\r\n await this.sendCorrection(conn, env.from, op, current);\r\n return;\r\n }\r\n if (op.kind === 'upsert') {\r\n const ownerId = current?.ownerId ?? conn.userId;\r\n const stampedElement: OwnedElement = { ...op.element, ownerId };\r\n outboundOp = { kind: 'upsert', element: stampedElement };\r\n }\r\n }\r\n\r\n await this.backend.apply(conn.room, outboundOp);\r\n\r\n const prevExisted = current !== undefined;\r\n const prevAudience = current?.audience;\r\n\r\n this.deliverToRoom(conn.room, conn.id, env.from, outboundOp, prevAudience, prevExisted);\r\n\r\n this.fanout.publish(\r\n JSON.stringify({\r\n o: this.instanceId,\r\n room: conn.room,\r\n from: env.from,\r\n op: outboundOp,\r\n prev: prevAudience,\r\n existed: prevExisted,\r\n }),\r\n );\r\n }\r\n // 'snapshot' from a client → ignored\r\n }\r\n\r\n private mayRead(conn: Connection, audience: string | undefined): boolean {\r\n if (!this.canRead) return true;\r\n return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });\r\n }\r\n\r\n private safePublish(payload: string): void {\r\n try {\r\n this.fanout.publish(payload);\r\n } catch {\r\n /* a broken fanout publisher must not break the un-queued presence relay */\r\n }\r\n }\r\n\r\n private relayToRoom(room: string, excludeId: string | undefined, message: string): void {\r\n const members = this.rooms.get(room);\r\n if (!members) return;\r\n for (const cid of members) {\r\n if (cid === excludeId) continue;\r\n const conn = this.conns.get(cid);\r\n if (!conn) continue;\r\n try {\r\n conn.send(message);\r\n } catch {\r\n /* a throwing socket must not break the relay loop */\r\n }\r\n }\r\n }\r\n\r\n private broadcastPresence(conn: Connection, from: string, data: unknown): void {\r\n this.presenceIds.set(conn.id, from);\r\n const message = JSON.stringify({ from, op: { kind: 'presence', data } });\r\n this.relayToRoom(conn.room, conn.id, message);\r\n this.safePublish(\r\n JSON.stringify({ o: this.instanceId, room: conn.room, from, op: { kind: 'presence', data } }),\r\n );\r\n }\r\n\r\n private broadcastLeave(room: string, from: string): void {\r\n const message = JSON.stringify({ from, op: { kind: 'presence-leave' } });\r\n this.relayToRoom(room, undefined, message);\r\n this.safePublish(\r\n JSON.stringify({ o: this.instanceId, room, from, op: { kind: 'presence-leave' } }),\r\n );\r\n }\r\n\r\n private deliverToRoom(\r\n room: string,\r\n excludeId: string | undefined,\r\n from: string,\r\n op: SyncOp,\r\n prevAudience: string | undefined,\r\n prevExisted: boolean,\r\n ): void {\r\n const members = this.rooms.get(room);\r\n if (!members) return;\r\n const send = (conn: Connection, msg: string): void => {\r\n try {\r\n conn.send(msg);\r\n } catch {\r\n /* a throwing socket must not break the delivery loop */\r\n }\r\n };\r\n if (op.kind === 'upsert') {\r\n const audience = (op.element as OwnedElement).audience;\r\n const upsertMsg = JSON.stringify({ from, op });\r\n const removeMsg = JSON.stringify({\r\n from: HUB_FROM,\r\n op: { kind: 'remove', id: op.element.id },\r\n });\r\n for (const cid of members) {\r\n if (cid === excludeId) continue;\r\n const conn = this.conns.get(cid);\r\n if (!conn) continue;\r\n if (this.mayRead(conn, audience)) send(conn, upsertMsg);\r\n else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);\r\n }\r\n } else if (op.kind === 'remove') {\r\n const removeMsg = JSON.stringify({ from, op });\r\n for (const cid of members) {\r\n if (cid === excludeId) continue;\r\n const conn = this.conns.get(cid);\r\n if (!conn) continue;\r\n // No read filter → forward to all (today's behavior; current/prevExisted aren't fetched without\r\n // a hook). With canRead, only recipients who could see the removed element get it.\r\n const wasVisible = !this.canRead || (prevExisted && this.mayRead(conn, prevAudience));\r\n if (wasVisible) send(conn, removeMsg);\r\n }\r\n } else if (op.kind === 'clear') {\r\n const clearMsg = JSON.stringify({ from, op });\r\n for (const cid of members) {\r\n if (cid === excludeId) continue;\r\n const conn = this.conns.get(cid);\r\n if (conn) send(conn, clearMsg);\r\n }\r\n }\r\n }\r\n\r\n private async sendCorrection(\r\n conn: Connection,\r\n from: string,\r\n op: SyncOp,\r\n current: OwnedElement | undefined,\r\n ): Promise<void> {\r\n let correction: SyncOp | undefined;\r\n if (op.kind === 'upsert') {\r\n correction = current\r\n ? { kind: 'upsert', element: current }\r\n : { kind: 'remove', id: op.element.id };\r\n } else if (op.kind === 'remove') {\r\n correction = current ? { kind: 'upsert', element: current } : undefined;\r\n } else if (op.kind === 'clear') {\r\n const elements = await this.backend.snapshot(conn.room);\r\n correction = { kind: 'snapshot', to: from, elements };\r\n }\r\n if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));\r\n }\r\n\r\n private onFanout(payload: string): void {\r\n // Off the serial queue on purpose: forward-only (the origin already applied to the SHARED backend),\r\n // and delivery is already ordered. Re-filter per local member (canRead runs on EVERY instance).\r\n let env: {\r\n o?: unknown;\r\n room?: unknown;\r\n from?: unknown;\r\n op?: unknown;\r\n prev?: unknown;\r\n existed?: unknown;\r\n };\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.from !== 'string')\r\n return;\r\n if (env.o === this.instanceId) return; // our own publish — already delivered locally\r\n const op = env.op;\r\n if (isPresenceOp(op)) {\r\n // presence/leave: raw forward to all local members (the sender lives on the origin instance),\r\n // no backend, no canRead filter.\r\n this.relayToRoom(env.room, undefined, JSON.stringify({ from: env.from, op }));\r\n return;\r\n }\r\n if (!isFanoutOp(op)) return;\r\n const prevAudience = typeof env.prev === 'string' ? env.prev : undefined;\r\n const prevExisted = env.existed === true;\r\n this.deliverToRoom(env.room, undefined, env.from, op, prevAudience, prevExisted);\r\n }\r\n\r\n close(): void {\r\n this.fanoutUnsub();\r\n }\r\n}\r\n","import { applyOpToMap, type SyncOp } from '@fieldnotes/sync';\r\nimport type { CanvasElement } from '@fieldnotes/core';\r\nimport type { HubBackend } from './hub-backend';\r\n\r\nexport class MemoryHubBackend implements HubBackend {\r\n private rooms = new Map<string, Map<string, CanvasElement>>();\r\n\r\n private room(id: string): Map<string, CanvasElement> {\r\n let r = this.rooms.get(id);\r\n if (!r) {\r\n r = new Map();\r\n this.rooms.set(id, r);\r\n }\r\n return r;\r\n }\r\n\r\n async snapshot(room: string): Promise<CanvasElement[]> {\r\n return [...this.room(room).values()];\r\n }\r\n\r\n async get(room: string, id: string): Promise<CanvasElement | undefined> {\r\n return this.room(room).get(id);\r\n }\r\n\r\n async apply(room: string, op: SyncOp): Promise<void> {\r\n if (op.kind === 'clear') {\r\n this.rooms.delete(room);\r\n return;\r\n }\r\n applyOpToMap(this.room(room), op);\r\n }\r\n}\r\n","export interface HubFanout {\r\n publish(payload: string): void;\r\n subscribe(handler: (payload: string) => void): () => void;\r\n close?(): void;\r\n}\r\n\r\nexport class InMemoryHubFanout implements HubFanout {\r\n private readonly handlers = new Set<(payload: string) => void>();\r\n\r\n publish(payload: string): void {\r\n for (const h of this.handlers) {\r\n try {\r\n h(payload);\r\n } catch {\r\n /* one throwing subscriber must not break the publish loop */\r\n }\r\n }\r\n }\r\n\r\n subscribe(handler: (payload: string) => void): () => void {\r\n this.handlers.add(handler);\r\n return () => this.handlers.delete(handler);\r\n }\r\n}\r\n","import { WebSocketServer } from 'ws';\r\nimport type { Server } from 'http';\r\nimport { SyncHub } from './sync-hub';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { HubFanout } from './hub-fanout';\r\nimport type { Authenticate } from './authenticate';\r\nimport type { Authorize, CanRead } from './authorize';\r\nimport { startHeartbeat } from './heartbeat';\r\n\r\nexport interface CreateSyncServerOptions {\r\n port?: number;\r\n server?: Server;\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authenticate?: Authenticate;\r\n authorize?: Authorize;\r\n canRead?: CanRead;\r\n heartbeatIntervalMs?: number;\r\n}\r\n\r\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\r\n hub: SyncHub;\r\n wss: WebSocketServer;\r\n close: () => Promise<void>;\r\n} {\r\n const hub = new SyncHub({\r\n backend: options.backend,\r\n fanout: options.fanout,\r\n instanceId: options.instanceId,\r\n authorize: options.authorize,\r\n canRead: options.canRead,\r\n });\r\n const wss = options.server\r\n ? new WebSocketServer({ server: options.server })\r\n : new WebSocketServer({ port: options.port ?? 0 });\r\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\r\n let counter = 0;\r\n wss.on('connection', (ws, req) => {\r\n heartbeat.track(ws);\r\n const url = new URL(req.url ?? '', 'http://localhost');\r\n const room = url.searchParams.get('room');\r\n if (!room) {\r\n ws.close(4400, 'room required');\r\n return;\r\n }\r\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\r\n\r\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\r\n let closed = false;\r\n const queue: string[] = [];\r\n const MAX_QUEUE = 100;\r\n\r\n const send = (m: string) => {\r\n try {\r\n ws.send(m);\r\n } catch {\r\n /* socket closed mid-send */\r\n }\r\n };\r\n\r\n ws.on('message', (data) => {\r\n const msg = String(data);\r\n if (state === 'rejected') return;\r\n if (state === 'pending') {\r\n if (queue.length < MAX_QUEUE) queue.push(msg);\r\n return;\r\n }\r\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\r\n });\r\n ws.on('close', () => {\r\n closed = true;\r\n if (state === 'ready') hub.removeConnection(connId);\r\n });\r\n\r\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\r\n .then((result) => {\r\n if (closed) return;\r\n if (!result) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n return;\r\n }\r\n state = 'ready';\r\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\r\n for (const m of queue) {\r\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\r\n }\r\n queue.length = 0;\r\n })\r\n .catch(() => {\r\n if (!closed) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n }\r\n });\r\n });\r\n return {\r\n hub,\r\n wss,\r\n close: () =>\r\n new Promise<void>((resolve) => {\r\n heartbeat.stop();\r\n hub.close();\r\n wss.close(() => resolve());\r\n }),\r\n };\r\n}\r\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n"],"mappings":"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","names":["import_sync"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/create-sync-server.ts","../src/heartbeat.ts"],"sourcesContent":["export { SyncHub } from './sync-hub';\r\nexport type { SyncHubOptions, Connection } from './sync-hub';\r\nexport { MemoryHubBackend } from './memory-hub-backend';\r\nexport type { HubBackend } from './hub-backend';\r\nexport { createSyncServer } from './create-sync-server';\r\nexport type { CreateSyncServerOptions } from './create-sync-server';\r\nexport { InMemoryHubFanout } from './hub-fanout';\r\nexport type { HubFanout } from './hub-fanout';\r\nexport type { AuthInfo, AuthResult, Authenticate } from './authenticate';\r\nexport type { Authorize, AuthorizeContext, OwnedElement, ReadContext, CanRead } from './authorize';\r\nexport { startHeartbeat } from './heartbeat';\r\nexport type { Heartbeat, HeartbeatSocket, HeartbeatServer } from './heartbeat';\r\n","import { parseEnvelope, isValidElement, type SyncOp, type SyncEnvelope } 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\nfunction isPresenceOp(\n op: unknown,\n): op is { kind: 'presence'; data: unknown } | { kind: 'presence-leave' } {\n if (typeof op !== 'object' || op === null) return false;\n const k = (op as { kind?: unknown }).kind;\n return k === 'presence' || k === 'presence-leave';\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 presenceConnections = new Set<string>();\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 room = conn.room;\n const hadPresence = this.presenceConnections.delete(connId);\n const members = this.rooms.get(room);\n if (members) {\n members.delete(connId);\n if (members.size === 0) {\n this.rooms.delete(room);\n this.roomQueues.delete(room);\n }\n }\n if (hadPresence) this.broadcastLeave(room, conn.id);\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 env = parseEnvelope(message);\n if (!env) return Promise.resolve();\n if (env.op.kind === 'presence') {\n this.broadcastPresence(conn, env.op.data); // off-queue, synchronous\n return Promise.resolve();\n }\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, env))\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, env: SyncEnvelope): Promise<void> {\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 // `to` is a private correlation address for the requesting SyncClient. It is never\n // broadcast; every public sender identity below comes from the server-owned connection.\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, conn.id, outboundOp, prevAudience, prevExisted);\n\n this.fanout.publish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: conn.id,\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 safePublish(payload: string): void {\n try {\n this.fanout.publish(payload);\n } catch {\n /* a broken fanout publisher must not break the un-queued presence relay */\n }\n }\n\n private relayToRoom(room: string, excludeId: string | undefined, message: string): void {\n const members = this.rooms.get(room);\n if (!members) return;\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (!conn) continue;\n try {\n conn.send(message);\n } catch {\n /* a throwing socket must not break the relay loop */\n }\n }\n }\n\n private broadcastPresence(conn: Connection, data: unknown): void {\n this.presenceConnections.add(conn.id);\n const message = JSON.stringify({ from: conn.id, op: { kind: 'presence', data } });\n this.relayToRoom(conn.room, conn.id, message);\n this.safePublish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: conn.id,\n op: { kind: 'presence', data },\n }),\n );\n }\n\n private broadcastLeave(room: string, from: string): void {\n const message = JSON.stringify({ from, op: { kind: 'presence-leave' } });\n this.relayToRoom(room, undefined, message);\n this.safePublish(\n JSON.stringify({ o: this.instanceId, room, from, op: { kind: 'presence-leave' } }),\n );\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 (isPresenceOp(op)) {\n // presence/leave: raw forward to all local members (the sender lives on the origin instance),\n // no backend, no canRead filter.\n this.relayToRoom(env.room, undefined, JSON.stringify({ from: env.from, op }));\n return;\n }\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';\r\nimport type { Server } from 'http';\r\nimport { SyncHub } from './sync-hub';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { HubFanout } from './hub-fanout';\r\nimport type { Authenticate } from './authenticate';\r\nimport type { Authorize, CanRead } from './authorize';\r\nimport { startHeartbeat } from './heartbeat';\r\n\r\nexport interface CreateSyncServerOptions {\r\n port?: number;\r\n server?: Server;\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authenticate?: Authenticate;\r\n authorize?: Authorize;\r\n canRead?: CanRead;\r\n heartbeatIntervalMs?: number;\r\n}\r\n\r\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\r\n hub: SyncHub;\r\n wss: WebSocketServer;\r\n close: () => Promise<void>;\r\n} {\r\n const hub = new SyncHub({\r\n backend: options.backend,\r\n fanout: options.fanout,\r\n instanceId: options.instanceId,\r\n authorize: options.authorize,\r\n canRead: options.canRead,\r\n });\r\n const wss = options.server\r\n ? new WebSocketServer({ server: options.server })\r\n : new WebSocketServer({ port: options.port ?? 0 });\r\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\r\n let counter = 0;\r\n wss.on('connection', (ws, req) => {\r\n heartbeat.track(ws);\r\n const url = new URL(req.url ?? '', 'http://localhost');\r\n const room = url.searchParams.get('room');\r\n if (!room) {\r\n ws.close(4400, 'room required');\r\n return;\r\n }\r\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\r\n\r\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\r\n let closed = false;\r\n const queue: string[] = [];\r\n const MAX_QUEUE = 100;\r\n\r\n const send = (m: string) => {\r\n try {\r\n ws.send(m);\r\n } catch {\r\n /* socket closed mid-send */\r\n }\r\n };\r\n\r\n ws.on('message', (data) => {\r\n const msg = String(data);\r\n if (state === 'rejected') return;\r\n if (state === 'pending') {\r\n if (queue.length < MAX_QUEUE) queue.push(msg);\r\n return;\r\n }\r\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\r\n });\r\n ws.on('close', () => {\r\n closed = true;\r\n if (state === 'ready') hub.removeConnection(connId);\r\n });\r\n\r\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\r\n .then((result) => {\r\n if (closed) return;\r\n if (!result) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n return;\r\n }\r\n state = 'ready';\r\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\r\n for (const m of queue) {\r\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\r\n }\r\n queue.length = 0;\r\n })\r\n .catch(() => {\r\n if (!closed) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n }\r\n });\r\n });\r\n return {\r\n hub,\r\n wss,\r\n close: () =>\r\n new Promise<void>((resolve) => {\r\n heartbeat.stop();\r\n hub.close();\r\n wss.close(() => resolve());\r\n }),\r\n };\r\n}\r\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n"],"mappings":"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","names":["import_sync"]}
package/dist/index.d.cts CHANGED
@@ -59,7 +59,7 @@ declare class SyncHub {
59
59
  private readonly conns;
60
60
  private readonly rooms;
61
61
  private readonly roomQueues;
62
- private readonly presenceIds;
62
+ private readonly presenceConnections;
63
63
  private readonly instanceId;
64
64
  private readonly fanout;
65
65
  private readonly fanoutUnsub;
package/dist/index.d.ts CHANGED
@@ -59,7 +59,7 @@ declare class SyncHub {
59
59
  private readonly conns;
60
60
  private readonly rooms;
61
61
  private readonly roomQueues;
62
- private readonly presenceIds;
62
+ private readonly presenceConnections;
63
63
  private readonly instanceId;
64
64
  private readonly fanout;
65
65
  private readonly fanoutUnsub;
package/dist/index.js CHANGED
@@ -71,8 +71,7 @@ var SyncHub = class {
71
71
  // room → connIds
72
72
  roomQueues = /* @__PURE__ */ new Map();
73
73
  // room → serial tail
74
- presenceIds = /* @__PURE__ */ new Map();
75
- // connId → last clientId that sent presence
74
+ presenceConnections = /* @__PURE__ */ new Set();
76
75
  instanceId;
77
76
  fanout;
78
77
  fanoutUnsub;
@@ -100,8 +99,7 @@ var SyncHub = class {
100
99
  if (!conn) return;
101
100
  this.conns.delete(connId);
102
101
  const room = conn.room;
103
- const clientId = this.presenceIds.get(connId);
104
- this.presenceIds.delete(connId);
102
+ const hadPresence = this.presenceConnections.delete(connId);
105
103
  const members = this.rooms.get(room);
106
104
  if (members) {
107
105
  members.delete(connId);
@@ -110,7 +108,7 @@ var SyncHub = class {
110
108
  this.roomQueues.delete(room);
111
109
  }
112
110
  }
113
- if (clientId !== void 0) this.broadcastLeave(room, clientId);
111
+ if (hadPresence) this.broadcastLeave(room, conn.id);
114
112
  }
115
113
  roomCount() {
116
114
  return this.rooms.size;
@@ -121,7 +119,7 @@ var SyncHub = class {
121
119
  const env = parseEnvelope(message);
122
120
  if (!env) return Promise.resolve();
123
121
  if (env.op.kind === "presence") {
124
- this.broadcastPresence(conn, env.from, env.op.data);
122
+ this.broadcastPresence(conn, env.op.data);
125
123
  return Promise.resolve();
126
124
  }
127
125
  const room = conn.room;
@@ -137,6 +135,8 @@ var SyncHub = class {
137
135
  const all = await this.backend.snapshot(conn.room);
138
136
  const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
139
137
  conn.send(
138
+ // `to` is a private correlation address for the requesting SyncClient. It is never
139
+ // broadcast; every public sender identity below comes from the server-owned connection.
140
140
  JSON.stringify({ from: HUB_FROM, op: { kind: "snapshot", to: env.from, elements } })
141
141
  );
142
142
  } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
@@ -165,12 +165,12 @@ var SyncHub = class {
165
165
  await this.backend.apply(conn.room, outboundOp);
166
166
  const prevExisted = current !== void 0;
167
167
  const prevAudience = current?.audience;
168
- this.deliverToRoom(conn.room, conn.id, env.from, outboundOp, prevAudience, prevExisted);
168
+ this.deliverToRoom(conn.room, conn.id, conn.id, outboundOp, prevAudience, prevExisted);
169
169
  this.fanout.publish(
170
170
  JSON.stringify({
171
171
  o: this.instanceId,
172
172
  room: conn.room,
173
- from: env.from,
173
+ from: conn.id,
174
174
  op: outboundOp,
175
175
  prev: prevAudience,
176
176
  existed: prevExisted
@@ -201,12 +201,17 @@ var SyncHub = class {
201
201
  }
202
202
  }
203
203
  }
204
- broadcastPresence(conn, from, data) {
205
- this.presenceIds.set(conn.id, from);
206
- const message = JSON.stringify({ from, op: { kind: "presence", data } });
204
+ broadcastPresence(conn, data) {
205
+ this.presenceConnections.add(conn.id);
206
+ const message = JSON.stringify({ from: conn.id, op: { kind: "presence", data } });
207
207
  this.relayToRoom(conn.room, conn.id, message);
208
208
  this.safePublish(
209
- JSON.stringify({ o: this.instanceId, room: conn.room, from, op: { kind: "presence", data } })
209
+ JSON.stringify({
210
+ o: this.instanceId,
211
+ room: conn.room,
212
+ from: conn.id,
213
+ op: { kind: "presence", data }
214
+ })
210
215
  );
211
216
  }
212
217
  broadcastLeave(room, from) {
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, isValidElement, type SyncOp, type SyncEnvelope } 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, CanRead, 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 canRead?: CanRead;\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\nfunction isFanoutOp(op: unknown): op is Extract<SyncOp, { kind: 'upsert' | 'remove' | 'clear' }> {\r\n if (typeof op !== 'object' || op === null) return false;\r\n const o = op as { kind?: unknown; element?: unknown; id?: unknown };\r\n if (o.kind === 'upsert') return isValidElement(o.element);\r\n if (o.kind === 'remove') return typeof o.id === 'string';\r\n return o.kind === 'clear';\r\n}\r\n\r\nfunction isPresenceOp(\r\n op: unknown,\r\n): op is { kind: 'presence'; data: unknown } | { kind: 'presence-leave' } {\r\n if (typeof op !== 'object' || op === null) return false;\r\n const k = (op as { kind?: unknown }).kind;\r\n return k === 'presence' || k === 'presence-leave';\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 presenceIds = new Map<string, string>(); // connId → last clientId that sent presence\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 private readonly canRead?: CanRead;\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.canRead = options.canRead;\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 room = conn.room;\r\n const clientId = this.presenceIds.get(connId);\r\n this.presenceIds.delete(connId);\r\n const members = this.rooms.get(room);\r\n if (members) {\r\n members.delete(connId);\r\n if (members.size === 0) {\r\n this.rooms.delete(room);\r\n this.roomQueues.delete(room);\r\n }\r\n }\r\n if (clientId !== undefined) this.broadcastLeave(room, clientId);\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 env = parseEnvelope(message);\r\n if (!env) return Promise.resolve();\r\n if (env.op.kind === 'presence') {\r\n this.broadcastPresence(conn, env.from, env.op.data); // off-queue, synchronous\r\n return Promise.resolve();\r\n }\r\n const room = conn.room;\r\n // The per-room serial queue is the single total-order authority: ops apply in arrival order\r\n // (arrival-order LWW — no per-element seq; see D3 / TD-12). Different rooms run independently.\r\n const prev = this.roomQueues.get(room) ?? Promise.resolve();\r\n const next = prev\r\n .then(() => this.process(conn, env))\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, env: SyncEnvelope): Promise<void> {\r\n const op = env.op;\r\n if (op.kind === 'request-snapshot') {\r\n const all = (await this.backend.snapshot(conn.room)) as OwnedElement[];\r\n const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;\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 const id = op.kind === 'upsert' ? op.element.id : op.kind === 'remove' ? op.id : undefined;\r\n const needCurrent = (this.authorize || this.canRead) && id !== undefined;\r\n const current: OwnedElement | undefined = needCurrent\r\n ? await this.backend.get(conn.room, id)\r\n : undefined;\r\n\r\n let outboundOp: SyncOp = op;\r\n if (this.authorize) {\r\n const allowed = await this.authorize({\r\n userId: conn.userId,\r\n role: conn.role,\r\n room: conn.room,\r\n op,\r\n currentElement: current,\r\n });\r\n if (!allowed) {\r\n await this.sendCorrection(conn, env.from, op, current);\r\n return;\r\n }\r\n if (op.kind === 'upsert') {\r\n const ownerId = current?.ownerId ?? conn.userId;\r\n const stampedElement: OwnedElement = { ...op.element, ownerId };\r\n outboundOp = { kind: 'upsert', element: stampedElement };\r\n }\r\n }\r\n\r\n await this.backend.apply(conn.room, outboundOp);\r\n\r\n const prevExisted = current !== undefined;\r\n const prevAudience = current?.audience;\r\n\r\n this.deliverToRoom(conn.room, conn.id, env.from, outboundOp, prevAudience, prevExisted);\r\n\r\n this.fanout.publish(\r\n JSON.stringify({\r\n o: this.instanceId,\r\n room: conn.room,\r\n from: env.from,\r\n op: outboundOp,\r\n prev: prevAudience,\r\n existed: prevExisted,\r\n }),\r\n );\r\n }\r\n // 'snapshot' from a client → ignored\r\n }\r\n\r\n private mayRead(conn: Connection, audience: string | undefined): boolean {\r\n if (!this.canRead) return true;\r\n return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });\r\n }\r\n\r\n private safePublish(payload: string): void {\r\n try {\r\n this.fanout.publish(payload);\r\n } catch {\r\n /* a broken fanout publisher must not break the un-queued presence relay */\r\n }\r\n }\r\n\r\n private relayToRoom(room: string, excludeId: string | undefined, message: string): void {\r\n const members = this.rooms.get(room);\r\n if (!members) return;\r\n for (const cid of members) {\r\n if (cid === excludeId) continue;\r\n const conn = this.conns.get(cid);\r\n if (!conn) continue;\r\n try {\r\n conn.send(message);\r\n } catch {\r\n /* a throwing socket must not break the relay loop */\r\n }\r\n }\r\n }\r\n\r\n private broadcastPresence(conn: Connection, from: string, data: unknown): void {\r\n this.presenceIds.set(conn.id, from);\r\n const message = JSON.stringify({ from, op: { kind: 'presence', data } });\r\n this.relayToRoom(conn.room, conn.id, message);\r\n this.safePublish(\r\n JSON.stringify({ o: this.instanceId, room: conn.room, from, op: { kind: 'presence', data } }),\r\n );\r\n }\r\n\r\n private broadcastLeave(room: string, from: string): void {\r\n const message = JSON.stringify({ from, op: { kind: 'presence-leave' } });\r\n this.relayToRoom(room, undefined, message);\r\n this.safePublish(\r\n JSON.stringify({ o: this.instanceId, room, from, op: { kind: 'presence-leave' } }),\r\n );\r\n }\r\n\r\n private deliverToRoom(\r\n room: string,\r\n excludeId: string | undefined,\r\n from: string,\r\n op: SyncOp,\r\n prevAudience: string | undefined,\r\n prevExisted: boolean,\r\n ): void {\r\n const members = this.rooms.get(room);\r\n if (!members) return;\r\n const send = (conn: Connection, msg: string): void => {\r\n try {\r\n conn.send(msg);\r\n } catch {\r\n /* a throwing socket must not break the delivery loop */\r\n }\r\n };\r\n if (op.kind === 'upsert') {\r\n const audience = (op.element as OwnedElement).audience;\r\n const upsertMsg = JSON.stringify({ from, op });\r\n const removeMsg = JSON.stringify({\r\n from: HUB_FROM,\r\n op: { kind: 'remove', id: op.element.id },\r\n });\r\n for (const cid of members) {\r\n if (cid === excludeId) continue;\r\n const conn = this.conns.get(cid);\r\n if (!conn) continue;\r\n if (this.mayRead(conn, audience)) send(conn, upsertMsg);\r\n else if (prevExisted && this.mayRead(conn, prevAudience)) send(conn, removeMsg);\r\n }\r\n } else if (op.kind === 'remove') {\r\n const removeMsg = JSON.stringify({ from, op });\r\n for (const cid of members) {\r\n if (cid === excludeId) continue;\r\n const conn = this.conns.get(cid);\r\n if (!conn) continue;\r\n // No read filter → forward to all (today's behavior; current/prevExisted aren't fetched without\r\n // a hook). With canRead, only recipients who could see the removed element get it.\r\n const wasVisible = !this.canRead || (prevExisted && this.mayRead(conn, prevAudience));\r\n if (wasVisible) send(conn, removeMsg);\r\n }\r\n } else if (op.kind === 'clear') {\r\n const clearMsg = JSON.stringify({ from, op });\r\n for (const cid of members) {\r\n if (cid === excludeId) continue;\r\n const conn = this.conns.get(cid);\r\n if (conn) send(conn, clearMsg);\r\n }\r\n }\r\n }\r\n\r\n private async sendCorrection(\r\n conn: Connection,\r\n from: string,\r\n op: SyncOp,\r\n current: OwnedElement | undefined,\r\n ): Promise<void> {\r\n let correction: SyncOp | undefined;\r\n if (op.kind === 'upsert') {\r\n correction = current\r\n ? { kind: 'upsert', element: current }\r\n : { kind: 'remove', id: op.element.id };\r\n } else if (op.kind === 'remove') {\r\n correction = current ? { kind: 'upsert', element: current } : undefined;\r\n } else if (op.kind === 'clear') {\r\n const elements = await this.backend.snapshot(conn.room);\r\n correction = { kind: 'snapshot', to: from, elements };\r\n }\r\n if (correction) conn.send(JSON.stringify({ from: HUB_FROM, op: correction }));\r\n }\r\n\r\n private onFanout(payload: string): void {\r\n // Off the serial queue on purpose: forward-only (the origin already applied to the SHARED backend),\r\n // and delivery is already ordered. Re-filter per local member (canRead runs on EVERY instance).\r\n let env: {\r\n o?: unknown;\r\n room?: unknown;\r\n from?: unknown;\r\n op?: unknown;\r\n prev?: unknown;\r\n existed?: unknown;\r\n };\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.from !== 'string')\r\n return;\r\n if (env.o === this.instanceId) return; // our own publish — already delivered locally\r\n const op = env.op;\r\n if (isPresenceOp(op)) {\r\n // presence/leave: raw forward to all local members (the sender lives on the origin instance),\r\n // no backend, no canRead filter.\r\n this.relayToRoom(env.room, undefined, JSON.stringify({ from: env.from, op }));\r\n return;\r\n }\r\n if (!isFanoutOp(op)) return;\r\n const prevAudience = typeof env.prev === 'string' ? env.prev : undefined;\r\n const prevExisted = env.existed === true;\r\n this.deliverToRoom(env.room, undefined, env.from, op, prevAudience, prevExisted);\r\n }\r\n\r\n close(): void {\r\n this.fanoutUnsub();\r\n }\r\n}\r\n","import { applyOpToMap, type SyncOp } from '@fieldnotes/sync';\r\nimport type { CanvasElement } from '@fieldnotes/core';\r\nimport type { HubBackend } from './hub-backend';\r\n\r\nexport class MemoryHubBackend implements HubBackend {\r\n private rooms = new Map<string, Map<string, CanvasElement>>();\r\n\r\n private room(id: string): Map<string, CanvasElement> {\r\n let r = this.rooms.get(id);\r\n if (!r) {\r\n r = new Map();\r\n this.rooms.set(id, r);\r\n }\r\n return r;\r\n }\r\n\r\n async snapshot(room: string): Promise<CanvasElement[]> {\r\n return [...this.room(room).values()];\r\n }\r\n\r\n async get(room: string, id: string): Promise<CanvasElement | undefined> {\r\n return this.room(room).get(id);\r\n }\r\n\r\n async apply(room: string, op: SyncOp): Promise<void> {\r\n if (op.kind === 'clear') {\r\n this.rooms.delete(room);\r\n return;\r\n }\r\n applyOpToMap(this.room(room), op);\r\n }\r\n}\r\n","export interface HubFanout {\r\n publish(payload: string): void;\r\n subscribe(handler: (payload: string) => void): () => void;\r\n close?(): void;\r\n}\r\n\r\nexport class InMemoryHubFanout implements HubFanout {\r\n private readonly handlers = new Set<(payload: string) => void>();\r\n\r\n publish(payload: string): void {\r\n for (const h of this.handlers) {\r\n try {\r\n h(payload);\r\n } catch {\r\n /* one throwing subscriber must not break the publish loop */\r\n }\r\n }\r\n }\r\n\r\n subscribe(handler: (payload: string) => void): () => void {\r\n this.handlers.add(handler);\r\n return () => this.handlers.delete(handler);\r\n }\r\n}\r\n","import { WebSocketServer } from 'ws';\r\nimport type { Server } from 'http';\r\nimport { SyncHub } from './sync-hub';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { HubFanout } from './hub-fanout';\r\nimport type { Authenticate } from './authenticate';\r\nimport type { Authorize, CanRead } from './authorize';\r\nimport { startHeartbeat } from './heartbeat';\r\n\r\nexport interface CreateSyncServerOptions {\r\n port?: number;\r\n server?: Server;\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authenticate?: Authenticate;\r\n authorize?: Authorize;\r\n canRead?: CanRead;\r\n heartbeatIntervalMs?: number;\r\n}\r\n\r\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\r\n hub: SyncHub;\r\n wss: WebSocketServer;\r\n close: () => Promise<void>;\r\n} {\r\n const hub = new SyncHub({\r\n backend: options.backend,\r\n fanout: options.fanout,\r\n instanceId: options.instanceId,\r\n authorize: options.authorize,\r\n canRead: options.canRead,\r\n });\r\n const wss = options.server\r\n ? new WebSocketServer({ server: options.server })\r\n : new WebSocketServer({ port: options.port ?? 0 });\r\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\r\n let counter = 0;\r\n wss.on('connection', (ws, req) => {\r\n heartbeat.track(ws);\r\n const url = new URL(req.url ?? '', 'http://localhost');\r\n const room = url.searchParams.get('room');\r\n if (!room) {\r\n ws.close(4400, 'room required');\r\n return;\r\n }\r\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\r\n\r\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\r\n let closed = false;\r\n const queue: string[] = [];\r\n const MAX_QUEUE = 100;\r\n\r\n const send = (m: string) => {\r\n try {\r\n ws.send(m);\r\n } catch {\r\n /* socket closed mid-send */\r\n }\r\n };\r\n\r\n ws.on('message', (data) => {\r\n const msg = String(data);\r\n if (state === 'rejected') return;\r\n if (state === 'pending') {\r\n if (queue.length < MAX_QUEUE) queue.push(msg);\r\n return;\r\n }\r\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\r\n });\r\n ws.on('close', () => {\r\n closed = true;\r\n if (state === 'ready') hub.removeConnection(connId);\r\n });\r\n\r\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\r\n .then((result) => {\r\n if (closed) return;\r\n if (!result) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n return;\r\n }\r\n state = 'ready';\r\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\r\n for (const m of queue) {\r\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\r\n }\r\n queue.length = 0;\r\n })\r\n .catch(() => {\r\n if (!closed) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n }\r\n });\r\n });\r\n return {\r\n hub,\r\n wss,\r\n close: () =>\r\n new Promise<void>((resolve) => {\r\n heartbeat.stop();\r\n hub.close();\r\n wss.close(() => resolve());\r\n }),\r\n };\r\n}\r\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n"],"mappings":"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","names":[]}
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, type SyncEnvelope } 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\nfunction isPresenceOp(\n op: unknown,\n): op is { kind: 'presence'; data: unknown } | { kind: 'presence-leave' } {\n if (typeof op !== 'object' || op === null) return false;\n const k = (op as { kind?: unknown }).kind;\n return k === 'presence' || k === 'presence-leave';\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 presenceConnections = new Set<string>();\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 room = conn.room;\n const hadPresence = this.presenceConnections.delete(connId);\n const members = this.rooms.get(room);\n if (members) {\n members.delete(connId);\n if (members.size === 0) {\n this.rooms.delete(room);\n this.roomQueues.delete(room);\n }\n }\n if (hadPresence) this.broadcastLeave(room, conn.id);\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 env = parseEnvelope(message);\n if (!env) return Promise.resolve();\n if (env.op.kind === 'presence') {\n this.broadcastPresence(conn, env.op.data); // off-queue, synchronous\n return Promise.resolve();\n }\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, env))\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, env: SyncEnvelope): Promise<void> {\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 // `to` is a private correlation address for the requesting SyncClient. It is never\n // broadcast; every public sender identity below comes from the server-owned connection.\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, conn.id, outboundOp, prevAudience, prevExisted);\n\n this.fanout.publish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: conn.id,\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 safePublish(payload: string): void {\n try {\n this.fanout.publish(payload);\n } catch {\n /* a broken fanout publisher must not break the un-queued presence relay */\n }\n }\n\n private relayToRoom(room: string, excludeId: string | undefined, message: string): void {\n const members = this.rooms.get(room);\n if (!members) return;\n for (const cid of members) {\n if (cid === excludeId) continue;\n const conn = this.conns.get(cid);\n if (!conn) continue;\n try {\n conn.send(message);\n } catch {\n /* a throwing socket must not break the relay loop */\n }\n }\n }\n\n private broadcastPresence(conn: Connection, data: unknown): void {\n this.presenceConnections.add(conn.id);\n const message = JSON.stringify({ from: conn.id, op: { kind: 'presence', data } });\n this.relayToRoom(conn.room, conn.id, message);\n this.safePublish(\n JSON.stringify({\n o: this.instanceId,\n room: conn.room,\n from: conn.id,\n op: { kind: 'presence', data },\n }),\n );\n }\n\n private broadcastLeave(room: string, from: string): void {\n const message = JSON.stringify({ from, op: { kind: 'presence-leave' } });\n this.relayToRoom(room, undefined, message);\n this.safePublish(\n JSON.stringify({ o: this.instanceId, room, from, op: { kind: 'presence-leave' } }),\n );\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 (isPresenceOp(op)) {\n // presence/leave: raw forward to all local members (the sender lives on the origin instance),\n // no backend, no canRead filter.\n this.relayToRoom(env.room, undefined, JSON.stringify({ from: env.from, op }));\n return;\n }\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';\r\nimport type { Server } from 'http';\r\nimport { SyncHub } from './sync-hub';\r\nimport type { HubBackend } from './hub-backend';\r\nimport type { HubFanout } from './hub-fanout';\r\nimport type { Authenticate } from './authenticate';\r\nimport type { Authorize, CanRead } from './authorize';\r\nimport { startHeartbeat } from './heartbeat';\r\n\r\nexport interface CreateSyncServerOptions {\r\n port?: number;\r\n server?: Server;\r\n backend?: HubBackend;\r\n fanout?: HubFanout;\r\n instanceId?: string;\r\n authenticate?: Authenticate;\r\n authorize?: Authorize;\r\n canRead?: CanRead;\r\n heartbeatIntervalMs?: number;\r\n}\r\n\r\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\r\n hub: SyncHub;\r\n wss: WebSocketServer;\r\n close: () => Promise<void>;\r\n} {\r\n const hub = new SyncHub({\r\n backend: options.backend,\r\n fanout: options.fanout,\r\n instanceId: options.instanceId,\r\n authorize: options.authorize,\r\n canRead: options.canRead,\r\n });\r\n const wss = options.server\r\n ? new WebSocketServer({ server: options.server })\r\n : new WebSocketServer({ port: options.port ?? 0 });\r\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\r\n let counter = 0;\r\n wss.on('connection', (ws, req) => {\r\n heartbeat.track(ws);\r\n const url = new URL(req.url ?? '', 'http://localhost');\r\n const room = url.searchParams.get('room');\r\n if (!room) {\r\n ws.close(4400, 'room required');\r\n return;\r\n }\r\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\r\n\r\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\r\n let closed = false;\r\n const queue: string[] = [];\r\n const MAX_QUEUE = 100;\r\n\r\n const send = (m: string) => {\r\n try {\r\n ws.send(m);\r\n } catch {\r\n /* socket closed mid-send */\r\n }\r\n };\r\n\r\n ws.on('message', (data) => {\r\n const msg = String(data);\r\n if (state === 'rejected') return;\r\n if (state === 'pending') {\r\n if (queue.length < MAX_QUEUE) queue.push(msg);\r\n return;\r\n }\r\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\r\n });\r\n ws.on('close', () => {\r\n closed = true;\r\n if (state === 'ready') hub.removeConnection(connId);\r\n });\r\n\r\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\r\n .then((result) => {\r\n if (closed) return;\r\n if (!result) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n return;\r\n }\r\n state = 'ready';\r\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\r\n for (const m of queue) {\r\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\r\n }\r\n queue.length = 0;\r\n })\r\n .catch(() => {\r\n if (!closed) {\r\n state = 'rejected';\r\n ws.close(4401, 'unauthorized');\r\n }\r\n });\r\n });\r\n return {\r\n hub,\r\n wss,\r\n close: () =>\r\n new Promise<void>((resolve) => {\r\n heartbeat.stop();\r\n hub.close();\r\n wss.close(() => resolve());\r\n }),\r\n };\r\n}\r\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fieldnotes/sync-server",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Authoritative WebSocket relay server for Field Notes real-time sync",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -45,7 +45,7 @@
45
45
  "@vitest/coverage-v8": "^4.1.0",
46
46
  "tsup": "^8.5.1",
47
47
  "vitest": "^4.1.0",
48
- "@fieldnotes/core": "0.50.1"
48
+ "@fieldnotes/core": "0.52.2"
49
49
  },
50
50
  "scripts": {
51
51
  "build": "tsup",