@fieldnotes/sync-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Irakli Iremashvili
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @fieldnotes/sync-server
2
+
3
+ Authoritative WebSocket relay server for [`@fieldnotes/sync`](../sync).
4
+
5
+ The relay holds the canonical per-room canvas state and fans out element
6
+ operations to every other connection in the room. Clients connect, request a
7
+ snapshot to catch up, then stream `upsert` / `remove` / `clear` ops that the hub
8
+ applies and forwards.
9
+
10
+ ## Pieces
11
+
12
+ - **`SyncHub`** — transport-agnostic relay. Per-room canonical state lives behind
13
+ an async `HubBackend`; each room processes messages on its own serial queue so
14
+ concurrent edits to the same room never race, while different rooms run
15
+ independently.
16
+ - **`MemoryHubBackend`** — in-memory `HubBackend` (the default). Redis-backed and
17
+ authenticated backends are planned.
18
+ - **`createSyncServer`** — a runnable `ws` reference server. Connect with
19
+ `?room=<id>` in the query string; missing room closes the socket.
20
+
21
+ ## Usage
22
+
23
+ ```ts
24
+ import { createSyncServer } from '@fieldnotes/sync-server';
25
+
26
+ const { close } = createSyncServer({ port: 8080 });
27
+ // ws://localhost:8080?room=my-room
28
+ ```
29
+
30
+ Auth and a Redis `HubBackend` are upcoming.
package/dist/index.cjs ADDED
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ MemoryHubBackend: () => MemoryHubBackend,
24
+ SyncHub: () => SyncHub,
25
+ createSyncServer: () => createSyncServer
26
+ });
27
+ module.exports = __toCommonJS(index_exports);
28
+
29
+ // src/sync-hub.ts
30
+ var import_sync2 = require("@fieldnotes/sync");
31
+
32
+ // src/memory-hub-backend.ts
33
+ var import_sync = require("@fieldnotes/sync");
34
+ var MemoryHubBackend = class {
35
+ rooms = /* @__PURE__ */ new Map();
36
+ room(id) {
37
+ let r = this.rooms.get(id);
38
+ if (!r) {
39
+ r = /* @__PURE__ */ new Map();
40
+ this.rooms.set(id, r);
41
+ }
42
+ return r;
43
+ }
44
+ async snapshot(room) {
45
+ return [...this.room(room).values()];
46
+ }
47
+ async apply(room, op) {
48
+ (0, import_sync.applyOpToMap)(this.room(room), op);
49
+ }
50
+ };
51
+
52
+ // src/sync-hub.ts
53
+ var HUB_FROM = "hub";
54
+ var SyncHub = class {
55
+ backend;
56
+ conns = /* @__PURE__ */ new Map();
57
+ rooms = /* @__PURE__ */ new Map();
58
+ // room → connIds
59
+ roomQueues = /* @__PURE__ */ new Map();
60
+ // room → serial tail
61
+ constructor(options = {}) {
62
+ this.backend = options.backend ?? new MemoryHubBackend();
63
+ }
64
+ addConnection(conn) {
65
+ this.conns.set(conn.id, conn);
66
+ let set = this.rooms.get(conn.room);
67
+ if (!set) {
68
+ set = /* @__PURE__ */ new Set();
69
+ this.rooms.set(conn.room, set);
70
+ }
71
+ set.add(conn.id);
72
+ }
73
+ removeConnection(connId) {
74
+ const conn = this.conns.get(connId);
75
+ if (!conn) return;
76
+ this.conns.delete(connId);
77
+ const members = this.rooms.get(conn.room);
78
+ if (members) {
79
+ members.delete(connId);
80
+ if (members.size === 0) {
81
+ this.rooms.delete(conn.room);
82
+ this.roomQueues.delete(conn.room);
83
+ }
84
+ }
85
+ }
86
+ roomCount() {
87
+ return this.rooms.size;
88
+ }
89
+ handleMessage(connId, message) {
90
+ const conn = this.conns.get(connId);
91
+ if (!conn) return Promise.resolve();
92
+ const room = conn.room;
93
+ const prev = this.roomQueues.get(room) ?? Promise.resolve();
94
+ const next = prev.then(() => this.process(conn, message)).catch(() => {
95
+ });
96
+ this.roomQueues.set(room, next);
97
+ return next;
98
+ }
99
+ async process(conn, message) {
100
+ const env = (0, import_sync2.parseEnvelope)(message);
101
+ if (!env) return;
102
+ const op = env.op;
103
+ if (op.kind === "request-snapshot") {
104
+ const elements = await this.backend.snapshot(conn.room);
105
+ conn.send(
106
+ JSON.stringify({ from: HUB_FROM, op: { kind: "snapshot", to: env.from, elements } })
107
+ );
108
+ } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
109
+ await this.backend.apply(conn.room, op);
110
+ const members = this.rooms.get(conn.room);
111
+ if (members) {
112
+ for (const id of members) {
113
+ if (id === conn.id) continue;
114
+ this.conns.get(id)?.send(message);
115
+ }
116
+ }
117
+ }
118
+ }
119
+ };
120
+
121
+ // src/create-sync-server.ts
122
+ var import_ws = require("ws");
123
+ function createSyncServer(options = {}) {
124
+ const hub = new SyncHub({ backend: options.backend });
125
+ const wss = options.server ? new import_ws.WebSocketServer({ server: options.server }) : new import_ws.WebSocketServer({ port: options.port ?? 0 });
126
+ let counter = 0;
127
+ wss.on("connection", (ws, req) => {
128
+ const url = new URL(req.url ?? "", "http://localhost");
129
+ const room = url.searchParams.get("room");
130
+ if (!room) {
131
+ ws.close(1008, "room required");
132
+ return;
133
+ }
134
+ const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
135
+ hub.addConnection({
136
+ id: connId,
137
+ room,
138
+ send: (m) => {
139
+ try {
140
+ ws.send(m);
141
+ } catch {
142
+ }
143
+ }
144
+ });
145
+ ws.on("message", (data) => {
146
+ void hub.handleMessage(connId, String(data)).catch((err) => console.error("[sync-server]", err));
147
+ });
148
+ ws.on("close", () => hub.removeConnection(connId));
149
+ });
150
+ return {
151
+ hub,
152
+ wss,
153
+ close: () => new Promise((resolve) => wss.close(() => resolve()))
154
+ };
155
+ }
156
+ // Annotate the CommonJS export names for ESM import in node:
157
+ 0 && (module.exports = {
158
+ MemoryHubBackend,
159
+ SyncHub,
160
+ createSyncServer
161
+ });
162
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/create-sync-server.ts"],"sourcesContent":["export { SyncHub } from './sync-hub';\nexport type { SyncHubOptions, Connection } from './sync-hub';\nexport { MemoryHubBackend } from './memory-hub-backend';\nexport type { HubBackend } from './hub-backend';\nexport { createSyncServer } from './create-sync-server';\nexport type { CreateSyncServerOptions } from './create-sync-server';\n","import { parseEnvelope } from '@fieldnotes/sync';\nimport { MemoryHubBackend } from './memory-hub-backend';\nimport type { HubBackend } from './hub-backend';\n\nexport interface Connection {\n id: string;\n room: string;\n send(message: string): void;\n}\n\nexport interface SyncHubOptions {\n backend?: HubBackend;\n}\n\nconst HUB_FROM = 'hub';\n\nexport class SyncHub {\n private readonly backend: HubBackend;\n private readonly conns = new Map<string, Connection>();\n private readonly rooms = new Map<string, Set<string>>(); // room → connIds\n private readonly roomQueues = new Map<string, Promise<void>>(); // room → serial tail\n\n constructor(options: SyncHubOptions = {}) {\n this.backend = options.backend ?? new MemoryHubBackend();\n }\n\n addConnection(conn: Connection): void {\n this.conns.set(conn.id, conn);\n let set = this.rooms.get(conn.room);\n if (!set) {\n set = new Set();\n this.rooms.set(conn.room, set);\n }\n set.add(conn.id);\n }\n\n removeConnection(connId: string): void {\n const conn = this.conns.get(connId);\n if (!conn) return;\n this.conns.delete(connId);\n const members = this.rooms.get(conn.room);\n if (members) {\n members.delete(connId);\n if (members.size === 0) {\n this.rooms.delete(conn.room);\n this.roomQueues.delete(conn.room);\n }\n }\n }\n\n roomCount(): number {\n return this.rooms.size;\n }\n\n handleMessage(connId: string, message: string): Promise<void> {\n const conn = this.conns.get(connId);\n if (!conn) return Promise.resolve();\n const room = conn.room;\n const prev = this.roomQueues.get(room) ?? Promise.resolve();\n const next = prev\n .then(() => this.process(conn, message))\n .catch(() => {\n // swallow so one failed message never wedges the room's serial queue\n });\n this.roomQueues.set(room, next);\n return next;\n }\n\n private async process(conn: Connection, message: string): Promise<void> {\n const env = parseEnvelope(message);\n if (!env) return;\n const op = env.op;\n if (op.kind === 'request-snapshot') {\n const elements = await this.backend.snapshot(conn.room);\n conn.send(\n JSON.stringify({ from: HUB_FROM, op: { kind: 'snapshot', to: env.from, elements } }),\n );\n } else if (op.kind === 'upsert' || op.kind === 'remove' || op.kind === 'clear') {\n await this.backend.apply(conn.room, op);\n const members = this.rooms.get(conn.room);\n if (members) {\n for (const id of members) {\n if (id === conn.id) continue;\n this.conns.get(id)?.send(message);\n }\n }\n }\n // 'snapshot' from a client → ignored\n }\n}\n","import { applyOpToMap, type SyncOp } from '@fieldnotes/sync';\nimport type { CanvasElement } from '@fieldnotes/core';\nimport type { HubBackend } from './hub-backend';\n\nexport class MemoryHubBackend implements HubBackend {\n private rooms = new Map<string, Map<string, CanvasElement>>();\n\n private room(id: string): Map<string, CanvasElement> {\n let r = this.rooms.get(id);\n if (!r) {\n r = new Map();\n this.rooms.set(id, r);\n }\n return r;\n }\n\n async snapshot(room: string): Promise<CanvasElement[]> {\n return [...this.room(room).values()];\n }\n\n async apply(room: string, op: SyncOp): Promise<void> {\n applyOpToMap(this.room(room), op);\n }\n}\n","import { WebSocketServer } from 'ws';\nimport type { Server } from 'http';\nimport { SyncHub } from './sync-hub';\nimport type { HubBackend } from './hub-backend';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n}\n\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\n hub: SyncHub;\n wss: WebSocketServer;\n close: () => Promise<void>;\n} {\n const hub = new SyncHub({ backend: options.backend });\n const wss = options.server\n ? new WebSocketServer({ server: options.server })\n : new WebSocketServer({ port: options.port ?? 0 });\n let counter = 0;\n wss.on('connection', (ws, req) => {\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(1008, 'room required');\n return;\n }\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\n hub.addConnection({\n id: connId,\n room,\n send: (m) => {\n try {\n ws.send(m);\n } catch {\n /* socket closed mid-send */\n }\n },\n });\n ws.on('message', (data) => {\n void hub\n .handleMessage(connId, String(data))\n .catch((err) => console.error('[sync-server]', err));\n });\n ws.on('close', () => hub.removeConnection(connId));\n });\n return {\n hub,\n wss,\n close: () => new Promise<void>((resolve) => wss.close(() => resolve())),\n };\n}\n"],"mappings":"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","names":["import_sync"]}
@@ -0,0 +1,50 @@
1
+ import { CanvasElement } from '@fieldnotes/core';
2
+ import { SyncOp } from '@fieldnotes/sync';
3
+ import { WebSocketServer } from 'ws';
4
+ import { Server } from 'http';
5
+
6
+ interface HubBackend {
7
+ snapshot(room: string): Promise<CanvasElement[]>;
8
+ apply(room: string, op: SyncOp): Promise<void>;
9
+ }
10
+
11
+ interface Connection {
12
+ id: string;
13
+ room: string;
14
+ send(message: string): void;
15
+ }
16
+ interface SyncHubOptions {
17
+ backend?: HubBackend;
18
+ }
19
+ declare class SyncHub {
20
+ private readonly backend;
21
+ private readonly conns;
22
+ private readonly rooms;
23
+ private readonly roomQueues;
24
+ constructor(options?: SyncHubOptions);
25
+ addConnection(conn: Connection): void;
26
+ removeConnection(connId: string): void;
27
+ roomCount(): number;
28
+ handleMessage(connId: string, message: string): Promise<void>;
29
+ private process;
30
+ }
31
+
32
+ declare class MemoryHubBackend implements HubBackend {
33
+ private rooms;
34
+ private room;
35
+ snapshot(room: string): Promise<CanvasElement[]>;
36
+ apply(room: string, op: SyncOp): Promise<void>;
37
+ }
38
+
39
+ interface CreateSyncServerOptions {
40
+ port?: number;
41
+ server?: Server;
42
+ backend?: HubBackend;
43
+ }
44
+ declare function createSyncServer(options?: CreateSyncServerOptions): {
45
+ hub: SyncHub;
46
+ wss: WebSocketServer;
47
+ close: () => Promise<void>;
48
+ };
49
+
50
+ export { type Connection, type CreateSyncServerOptions, type HubBackend, MemoryHubBackend, SyncHub, type SyncHubOptions, createSyncServer };
@@ -0,0 +1,50 @@
1
+ import { CanvasElement } from '@fieldnotes/core';
2
+ import { SyncOp } from '@fieldnotes/sync';
3
+ import { WebSocketServer } from 'ws';
4
+ import { Server } from 'http';
5
+
6
+ interface HubBackend {
7
+ snapshot(room: string): Promise<CanvasElement[]>;
8
+ apply(room: string, op: SyncOp): Promise<void>;
9
+ }
10
+
11
+ interface Connection {
12
+ id: string;
13
+ room: string;
14
+ send(message: string): void;
15
+ }
16
+ interface SyncHubOptions {
17
+ backend?: HubBackend;
18
+ }
19
+ declare class SyncHub {
20
+ private readonly backend;
21
+ private readonly conns;
22
+ private readonly rooms;
23
+ private readonly roomQueues;
24
+ constructor(options?: SyncHubOptions);
25
+ addConnection(conn: Connection): void;
26
+ removeConnection(connId: string): void;
27
+ roomCount(): number;
28
+ handleMessage(connId: string, message: string): Promise<void>;
29
+ private process;
30
+ }
31
+
32
+ declare class MemoryHubBackend implements HubBackend {
33
+ private rooms;
34
+ private room;
35
+ snapshot(room: string): Promise<CanvasElement[]>;
36
+ apply(room: string, op: SyncOp): Promise<void>;
37
+ }
38
+
39
+ interface CreateSyncServerOptions {
40
+ port?: number;
41
+ server?: Server;
42
+ backend?: HubBackend;
43
+ }
44
+ declare function createSyncServer(options?: CreateSyncServerOptions): {
45
+ hub: SyncHub;
46
+ wss: WebSocketServer;
47
+ close: () => Promise<void>;
48
+ };
49
+
50
+ export { type Connection, type CreateSyncServerOptions, type HubBackend, MemoryHubBackend, SyncHub, type SyncHubOptions, createSyncServer };
package/dist/index.js ADDED
@@ -0,0 +1,133 @@
1
+ // src/sync-hub.ts
2
+ import { parseEnvelope } from "@fieldnotes/sync";
3
+
4
+ // src/memory-hub-backend.ts
5
+ import { applyOpToMap } from "@fieldnotes/sync";
6
+ var MemoryHubBackend = class {
7
+ rooms = /* @__PURE__ */ new Map();
8
+ room(id) {
9
+ let r = this.rooms.get(id);
10
+ if (!r) {
11
+ r = /* @__PURE__ */ new Map();
12
+ this.rooms.set(id, r);
13
+ }
14
+ return r;
15
+ }
16
+ async snapshot(room) {
17
+ return [...this.room(room).values()];
18
+ }
19
+ async apply(room, op) {
20
+ applyOpToMap(this.room(room), op);
21
+ }
22
+ };
23
+
24
+ // src/sync-hub.ts
25
+ var HUB_FROM = "hub";
26
+ var SyncHub = class {
27
+ backend;
28
+ conns = /* @__PURE__ */ new Map();
29
+ rooms = /* @__PURE__ */ new Map();
30
+ // room → connIds
31
+ roomQueues = /* @__PURE__ */ new Map();
32
+ // room → serial tail
33
+ constructor(options = {}) {
34
+ this.backend = options.backend ?? new MemoryHubBackend();
35
+ }
36
+ addConnection(conn) {
37
+ this.conns.set(conn.id, conn);
38
+ let set = this.rooms.get(conn.room);
39
+ if (!set) {
40
+ set = /* @__PURE__ */ new Set();
41
+ this.rooms.set(conn.room, set);
42
+ }
43
+ set.add(conn.id);
44
+ }
45
+ removeConnection(connId) {
46
+ const conn = this.conns.get(connId);
47
+ if (!conn) return;
48
+ this.conns.delete(connId);
49
+ const members = this.rooms.get(conn.room);
50
+ if (members) {
51
+ members.delete(connId);
52
+ if (members.size === 0) {
53
+ this.rooms.delete(conn.room);
54
+ this.roomQueues.delete(conn.room);
55
+ }
56
+ }
57
+ }
58
+ roomCount() {
59
+ return this.rooms.size;
60
+ }
61
+ handleMessage(connId, message) {
62
+ const conn = this.conns.get(connId);
63
+ if (!conn) return Promise.resolve();
64
+ const room = conn.room;
65
+ const prev = this.roomQueues.get(room) ?? Promise.resolve();
66
+ const next = prev.then(() => this.process(conn, message)).catch(() => {
67
+ });
68
+ this.roomQueues.set(room, next);
69
+ return next;
70
+ }
71
+ async process(conn, message) {
72
+ const env = parseEnvelope(message);
73
+ if (!env) return;
74
+ const op = env.op;
75
+ if (op.kind === "request-snapshot") {
76
+ const elements = await this.backend.snapshot(conn.room);
77
+ conn.send(
78
+ JSON.stringify({ from: HUB_FROM, op: { kind: "snapshot", to: env.from, elements } })
79
+ );
80
+ } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
81
+ await this.backend.apply(conn.room, op);
82
+ const members = this.rooms.get(conn.room);
83
+ if (members) {
84
+ for (const id of members) {
85
+ if (id === conn.id) continue;
86
+ this.conns.get(id)?.send(message);
87
+ }
88
+ }
89
+ }
90
+ }
91
+ };
92
+
93
+ // src/create-sync-server.ts
94
+ import { WebSocketServer } from "ws";
95
+ function createSyncServer(options = {}) {
96
+ const hub = new SyncHub({ backend: options.backend });
97
+ const wss = options.server ? new WebSocketServer({ server: options.server }) : new WebSocketServer({ port: options.port ?? 0 });
98
+ let counter = 0;
99
+ wss.on("connection", (ws, req) => {
100
+ const url = new URL(req.url ?? "", "http://localhost");
101
+ const room = url.searchParams.get("room");
102
+ if (!room) {
103
+ ws.close(1008, "room required");
104
+ return;
105
+ }
106
+ const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;
107
+ hub.addConnection({
108
+ id: connId,
109
+ room,
110
+ send: (m) => {
111
+ try {
112
+ ws.send(m);
113
+ } catch {
114
+ }
115
+ }
116
+ });
117
+ ws.on("message", (data) => {
118
+ void hub.handleMessage(connId, String(data)).catch((err) => console.error("[sync-server]", err));
119
+ });
120
+ ws.on("close", () => hub.removeConnection(connId));
121
+ });
122
+ return {
123
+ hub,
124
+ wss,
125
+ close: () => new Promise((resolve) => wss.close(() => resolve()))
126
+ };
127
+ }
128
+ export {
129
+ MemoryHubBackend,
130
+ SyncHub,
131
+ createSyncServer
132
+ };
133
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/create-sync-server.ts"],"sourcesContent":["import { parseEnvelope } from '@fieldnotes/sync';\nimport { MemoryHubBackend } from './memory-hub-backend';\nimport type { HubBackend } from './hub-backend';\n\nexport interface Connection {\n id: string;\n room: string;\n send(message: string): void;\n}\n\nexport interface SyncHubOptions {\n backend?: HubBackend;\n}\n\nconst HUB_FROM = 'hub';\n\nexport class SyncHub {\n private readonly backend: HubBackend;\n private readonly conns = new Map<string, Connection>();\n private readonly rooms = new Map<string, Set<string>>(); // room → connIds\n private readonly roomQueues = new Map<string, Promise<void>>(); // room → serial tail\n\n constructor(options: SyncHubOptions = {}) {\n this.backend = options.backend ?? new MemoryHubBackend();\n }\n\n addConnection(conn: Connection): void {\n this.conns.set(conn.id, conn);\n let set = this.rooms.get(conn.room);\n if (!set) {\n set = new Set();\n this.rooms.set(conn.room, set);\n }\n set.add(conn.id);\n }\n\n removeConnection(connId: string): void {\n const conn = this.conns.get(connId);\n if (!conn) return;\n this.conns.delete(connId);\n const members = this.rooms.get(conn.room);\n if (members) {\n members.delete(connId);\n if (members.size === 0) {\n this.rooms.delete(conn.room);\n this.roomQueues.delete(conn.room);\n }\n }\n }\n\n roomCount(): number {\n return this.rooms.size;\n }\n\n handleMessage(connId: string, message: string): Promise<void> {\n const conn = this.conns.get(connId);\n if (!conn) return Promise.resolve();\n const room = conn.room;\n const prev = this.roomQueues.get(room) ?? Promise.resolve();\n const next = prev\n .then(() => this.process(conn, message))\n .catch(() => {\n // swallow so one failed message never wedges the room's serial queue\n });\n this.roomQueues.set(room, next);\n return next;\n }\n\n private async process(conn: Connection, message: string): Promise<void> {\n const env = parseEnvelope(message);\n if (!env) return;\n const op = env.op;\n if (op.kind === 'request-snapshot') {\n const elements = await this.backend.snapshot(conn.room);\n conn.send(\n JSON.stringify({ from: HUB_FROM, op: { kind: 'snapshot', to: env.from, elements } }),\n );\n } else if (op.kind === 'upsert' || op.kind === 'remove' || op.kind === 'clear') {\n await this.backend.apply(conn.room, op);\n const members = this.rooms.get(conn.room);\n if (members) {\n for (const id of members) {\n if (id === conn.id) continue;\n this.conns.get(id)?.send(message);\n }\n }\n }\n // 'snapshot' from a client → ignored\n }\n}\n","import { applyOpToMap, type SyncOp } from '@fieldnotes/sync';\nimport type { CanvasElement } from '@fieldnotes/core';\nimport type { HubBackend } from './hub-backend';\n\nexport class MemoryHubBackend implements HubBackend {\n private rooms = new Map<string, Map<string, CanvasElement>>();\n\n private room(id: string): Map<string, CanvasElement> {\n let r = this.rooms.get(id);\n if (!r) {\n r = new Map();\n this.rooms.set(id, r);\n }\n return r;\n }\n\n async snapshot(room: string): Promise<CanvasElement[]> {\n return [...this.room(room).values()];\n }\n\n async apply(room: string, op: SyncOp): Promise<void> {\n applyOpToMap(this.room(room), op);\n }\n}\n","import { WebSocketServer } from 'ws';\nimport type { Server } from 'http';\nimport { SyncHub } from './sync-hub';\nimport type { HubBackend } from './hub-backend';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n}\n\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\n hub: SyncHub;\n wss: WebSocketServer;\n close: () => Promise<void>;\n} {\n const hub = new SyncHub({ backend: options.backend });\n const wss = options.server\n ? new WebSocketServer({ server: options.server })\n : new WebSocketServer({ port: options.port ?? 0 });\n let counter = 0;\n wss.on('connection', (ws, req) => {\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(1008, 'room required');\n return;\n }\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\n hub.addConnection({\n id: connId,\n room,\n send: (m) => {\n try {\n ws.send(m);\n } catch {\n /* socket closed mid-send */\n }\n },\n });\n ws.on('message', (data) => {\n void hub\n .handleMessage(connId, String(data))\n .catch((err) => console.error('[sync-server]', err));\n });\n ws.on('close', () => hub.removeConnection(connId));\n });\n return {\n hub,\n wss,\n close: () => new Promise<void>((resolve) => wss.close(() => resolve())),\n };\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@fieldnotes/sync-server",
3
+ "version": "0.1.0",
4
+ "description": "Authoritative WebSocket relay server for Field Notes real-time sync",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "LICENSE"
20
+ ],
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/IrakliDevelop/fieldnotes.git",
25
+ "directory": "packages/sync-server"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/IrakliDevelop/fieldnotes/issues"
29
+ },
30
+ "homepage": "https://github.com/IrakliDevelop/fieldnotes/tree/master/packages/sync-server#readme",
31
+ "keywords": [
32
+ "realtime",
33
+ "sync",
34
+ "websocket",
35
+ "relay",
36
+ "collaboration",
37
+ "fieldnotes"
38
+ ],
39
+ "dependencies": {
40
+ "ws": "^8.18.0",
41
+ "@fieldnotes/sync": "0.3.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/ws": "^8.5.12",
45
+ "@vitest/coverage-v8": "^4.1.0",
46
+ "tsup": "^8.5.1",
47
+ "vitest": "^4.1.0",
48
+ "@fieldnotes/core": "0.46.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsup",
52
+ "test": "vitest run",
53
+ "test:watch": "vitest"
54
+ }
55
+ }