@fieldnotes/sync-server 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,6 +51,17 @@ createSyncServer({ port: 8080, heartbeatIntervalMs: 30000 });
51
51
 
52
52
  Browsers auto-pong at the protocol level, so **no client change** is needed.
53
53
 
54
+ ## Graceful shutdown
55
+
56
+ `close()` stops accepting connections, closes active clients with code `1001`, and waits up to
57
+ `shutdownGraceMs` (default `5000`) before terminating clients that have not completed the close
58
+ handshake. Repeated calls return the same promise, so shutdown hooks can safely converge on it.
59
+
60
+ ```ts
61
+ const server = createSyncServer({ port: 8080, shutdownGraceMs: 5000 });
62
+ await server.close();
63
+ ```
64
+
54
65
  ## Resource limits
55
66
 
56
67
  The reference server bounds work and memory per connection by default. Oversized WebSocket messages
package/dist/index.cjs CHANGED
@@ -454,12 +454,50 @@ function startHeartbeat(wss, intervalMs) {
454
454
  return { track, stop: () => clearInterval(interval) };
455
455
  }
456
456
 
457
+ // src/shutdown.ts
458
+ var DEFAULT_SHUTDOWN_GRACE_MS = 5e3;
459
+ function drainWebSocketServer(wss, graceMs) {
460
+ return new Promise((resolve) => {
461
+ let settled = false;
462
+ const finish = () => {
463
+ if (settled) return;
464
+ settled = true;
465
+ clearTimeout(forceTimer);
466
+ resolve();
467
+ };
468
+ const forceTimer = setTimeout(() => {
469
+ for (const ws of wss.clients) {
470
+ try {
471
+ ws.terminate();
472
+ } catch {
473
+ }
474
+ }
475
+ finish();
476
+ }, graceMs);
477
+ wss.close(() => finish());
478
+ for (const ws of wss.clients) {
479
+ try {
480
+ ws.close(1001, "server shutting down");
481
+ } catch {
482
+ try {
483
+ ws.terminate();
484
+ } catch {
485
+ }
486
+ }
487
+ }
488
+ });
489
+ }
490
+
457
491
  // src/create-sync-server.ts
458
492
  function rawDataByteLength(data) {
459
493
  if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);
460
494
  return data.byteLength;
461
495
  }
462
496
  function createSyncServer(options = {}) {
497
+ const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
498
+ if (!Number.isFinite(shutdownGraceMs) || shutdownGraceMs < 0) {
499
+ throw new RangeError("shutdownGraceMs must be a non-negative finite number");
500
+ }
463
501
  const hub = new SyncHub({
464
502
  backend: options.backend,
465
503
  fanout: options.fanout,
@@ -472,8 +510,14 @@ function createSyncServer(options = {}) {
472
510
  const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
473
511
  const wss = options.server ? new import_ws.WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new import_ws.WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });
474
512
  const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
513
+ let shuttingDown = false;
514
+ let closePromise;
475
515
  let counter = 0;
476
516
  wss.on("connection", (ws, req) => {
517
+ if (shuttingDown) {
518
+ ws.close(1001, "server shutting down");
519
+ return;
520
+ }
477
521
  heartbeat.track(ws);
478
522
  ws.on("error", () => void 0);
479
523
  const url = new URL(req.url ?? "", "http://localhost");
@@ -531,7 +575,7 @@ function createSyncServer(options = {}) {
531
575
  if (admitted) hub.removeConnection(connId);
532
576
  });
533
577
  Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId }).then((result) => {
534
- if (closed || state === "rejected") return;
578
+ if (closed || state === "rejected" || shuttingDown) return;
535
579
  if (!result) {
536
580
  state = "rejected";
537
581
  ws.close(4401, "unauthorized");
@@ -546,20 +590,25 @@ function createSyncServer(options = {}) {
546
590
  queue.length = 0;
547
591
  queuedBytes = 0;
548
592
  }).catch(() => {
549
- if (!closed) {
593
+ if (!closed && !shuttingDown) {
550
594
  state = "rejected";
551
595
  ws.close(4401, "unauthorized");
552
596
  }
553
597
  });
554
598
  });
599
+ const close = () => {
600
+ if (closePromise) return closePromise;
601
+ shuttingDown = true;
602
+ closePromise = drainWebSocketServer(wss, shutdownGraceMs).then(() => {
603
+ heartbeat.stop();
604
+ hub.close();
605
+ });
606
+ return closePromise;
607
+ };
555
608
  return {
556
609
  hub,
557
610
  wss,
558
- close: () => new Promise((resolve) => {
559
- heartbeat.stop();
560
- hub.close();
561
- wss.close(() => resolve());
562
- })
611
+ close
563
612
  };
564
613
  }
565
614
  // Annotate the CommonJS export names for ESM import in node:
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/resource-limits.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';\nimport {\n DEFAULT_MAX_JSON_DEPTH,\n DEFAULT_PRESENCE_THROTTLE_MS,\n hasJsonDepthAtMost,\n} from './resource-limits';\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 maxJsonDepth?: number;\n presenceThrottleMs?: number;\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 private readonly maxJsonDepth: number;\n private readonly presenceThrottleMs: number;\n private readonly lastPresenceAt = new Map<string, number>();\n private readonly pendingPresence = new Map<\n string,\n { data: unknown; timer: ReturnType<typeof setTimeout> }\n >();\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.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;\n this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;\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 this.lastPresenceAt.delete(connId);\n const pendingPresence = this.pendingPresence.get(connId);\n if (pendingPresence) clearTimeout(pendingPresence.timer);\n this.pendingPresence.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 if (!hasJsonDepthAtMost(message, this.maxJsonDepth)) return Promise.resolve();\n const env = parseEnvelope(message);\n if (!env) return Promise.resolve();\n if (env.op.kind === 'presence') {\n this.schedulePresence(conn, env.op.data); // off-queue, throttled independently\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 schedulePresence(conn: Connection, data: unknown): void {\n if (this.presenceThrottleMs <= 0) {\n this.broadcastPresence(conn, data);\n return;\n }\n const now = Date.now();\n const lastSentAt = this.lastPresenceAt.get(conn.id);\n if (lastSentAt === undefined || now - lastSentAt >= this.presenceThrottleMs) {\n this.lastPresenceAt.set(conn.id, now);\n this.broadcastPresence(conn, data);\n return;\n }\n\n const existing = this.pendingPresence.get(conn.id);\n if (existing) {\n existing.data = data;\n return;\n }\n const timer = setTimeout(\n () => {\n const pending = this.pendingPresence.get(conn.id);\n this.pendingPresence.delete(conn.id);\n if (!pending || !this.conns.has(conn.id)) return;\n this.lastPresenceAt.set(conn.id, Date.now());\n this.broadcastPresence(conn, pending.data);\n },\n this.presenceThrottleMs - (now - lastSentAt),\n );\n this.pendingPresence.set(conn.id, { data, timer });\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 for (const pending of this.pendingPresence.values()) clearTimeout(pending.timer);\n this.pendingPresence.clear();\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","export const DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;\nexport const DEFAULT_MAX_JSON_DEPTH = 32;\nexport const DEFAULT_MAX_PENDING_AUTH_MESSAGES = 100;\nexport const DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;\nexport const DEFAULT_MESSAGES_PER_SECOND = 120;\nexport const DEFAULT_MESSAGE_BURST = 240;\nexport const DEFAULT_PRESENCE_THROTTLE_MS = 50;\n\nexport function hasJsonDepthAtMost(message: string, maxDepth: number): boolean {\n let depth = 0;\n let inString = false;\n let escaped = false;\n\n for (const char of message) {\n if (inString) {\n if (escaped) escaped = false;\n else if (char === '\\\\') escaped = true;\n else if (char === '\"') inString = false;\n continue;\n }\n if (char === '\"') inString = true;\n else if (char === '{' || char === '[') {\n depth += 1;\n if (depth > maxDepth) return false;\n } else if (char === '}' || char === ']') {\n depth -= 1;\n if (depth < 0) return false;\n }\n }\n\n return depth === 0 && !inString;\n}\n\nexport class MessageRateLimiter {\n private tokens: number;\n private updatedAt: number;\n\n constructor(\n private readonly ratePerSecond: number,\n private readonly burst: number,\n now = Date.now(),\n ) {\n this.tokens = burst;\n this.updatedAt = now;\n }\n\n take(now = Date.now()): boolean {\n const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1000;\n this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);\n this.updatedAt = now;\n if (this.tokens < 1) return false;\n this.tokens -= 1;\n return true;\n }\n}\n","import { WebSocketServer, type RawData } from 'ws';\nimport type { Server } from 'http';\nimport { SyncHub } from './sync-hub';\nimport type { HubBackend } from './hub-backend';\nimport type { HubFanout } from './hub-fanout';\nimport type { Authenticate } from './authenticate';\nimport type { Authorize, CanRead } from './authorize';\nimport { startHeartbeat } from './heartbeat';\nimport {\n DEFAULT_MAX_JSON_DEPTH,\n DEFAULT_MAX_MESSAGE_BYTES,\n DEFAULT_MAX_PENDING_AUTH_BYTES,\n DEFAULT_MAX_PENDING_AUTH_MESSAGES,\n DEFAULT_MESSAGE_BURST,\n DEFAULT_MESSAGES_PER_SECOND,\n DEFAULT_PRESENCE_THROTTLE_MS,\n MessageRateLimiter,\n} from './resource-limits';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authenticate?: Authenticate;\n authorize?: Authorize;\n canRead?: CanRead;\n heartbeatIntervalMs?: number;\n maxMessageBytes?: number;\n maxJsonDepth?: number;\n maxPendingAuthMessages?: number;\n maxPendingAuthBytes?: number;\n messagesPerSecond?: number;\n messageBurst?: number;\n presenceThrottleMs?: number;\n}\n\nfunction rawDataByteLength(data: RawData): number {\n if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);\n return data.byteLength;\n}\n\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\n hub: SyncHub;\n wss: WebSocketServer;\n close: () => Promise<void>;\n} {\n const hub = new SyncHub({\n backend: options.backend,\n fanout: options.fanout,\n instanceId: options.instanceId,\n authorize: options.authorize,\n canRead: options.canRead,\n maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,\n presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,\n });\n const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;\n const wss = options.server\n ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes })\n : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\n let counter = 0;\n wss.on('connection', (ws, req) => {\n heartbeat.track(ws);\n // Protocol/parser failures (including maxPayload) close the peer. Consuming the socket-level\n // error keeps hostile input from becoming an uncaught process error.\n ws.on('error', () => undefined);\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(4400, 'room required');\n return;\n }\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\n\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\n let closed = false;\n let admitted = false;\n const queue: string[] = [];\n let queuedBytes = 0;\n const maxPendingAuthMessages =\n options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;\n const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;\n const limiter = new MessageRateLimiter(\n options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND,\n options.messageBurst ?? DEFAULT_MESSAGE_BURST,\n );\n\n const send = (m: string) => {\n try {\n ws.send(m);\n } catch {\n /* socket closed mid-send */\n }\n };\n\n ws.on('message', (data) => {\n if (state === 'rejected') return;\n const messageBytes = rawDataByteLength(data);\n if (messageBytes > maxMessageBytes) {\n state = 'rejected';\n ws.close(1009, 'message too large');\n return;\n }\n if (!limiter.take()) {\n state = 'rejected';\n ws.close(4408, 'rate limit exceeded');\n return;\n }\n const msg = String(data);\n if (state === 'pending') {\n if (\n queue.length >= maxPendingAuthMessages ||\n queuedBytes + messageBytes > maxPendingAuthBytes\n ) {\n state = 'rejected';\n ws.close(4408, 'authentication queue limit exceeded');\n return;\n }\n queue.push(msg);\n queuedBytes += messageBytes;\n return;\n }\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\n });\n ws.on('close', () => {\n closed = true;\n if (admitted) hub.removeConnection(connId);\n });\n\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\n .then((result) => {\n if (closed || state === 'rejected') return;\n if (!result) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n return;\n }\n state = 'ready';\n admitted = true;\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\n for (const m of queue) {\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\n }\n queue.length = 0;\n queuedBytes = 0;\n })\n .catch(() => {\n if (!closed) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n }\n });\n });\n return {\n hub,\n wss,\n close: () =>\n new Promise<void>((resolve) => {\n heartbeat.stop();\n hub.close();\n wss.close(() => resolve());\n }),\n };\n}\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n"],"mappings":"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","names":["import_sync"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/resource-limits.ts","../src/create-sync-server.ts","../src/heartbeat.ts","../src/shutdown.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\nimport {\r\n DEFAULT_MAX_JSON_DEPTH,\r\n DEFAULT_PRESENCE_THROTTLE_MS,\r\n hasJsonDepthAtMost,\r\n} from './resource-limits';\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 maxJsonDepth?: number;\r\n presenceThrottleMs?: number;\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 presenceConnections = new Set<string>();\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 private readonly maxJsonDepth: number;\r\n private readonly presenceThrottleMs: number;\r\n private readonly lastPresenceAt = new Map<string, number>();\r\n private readonly pendingPresence = new Map<\r\n string,\r\n { data: unknown; timer: ReturnType<typeof setTimeout> }\r\n >();\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.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;\r\n this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;\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 hadPresence = this.presenceConnections.delete(connId);\r\n this.lastPresenceAt.delete(connId);\r\n const pendingPresence = this.pendingPresence.get(connId);\r\n if (pendingPresence) clearTimeout(pendingPresence.timer);\r\n this.pendingPresence.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 (hadPresence) this.broadcastLeave(room, conn.id);\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 if (!hasJsonDepthAtMost(message, this.maxJsonDepth)) 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.schedulePresence(conn, env.op.data); // off-queue, throttled independently\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 // `to` is a private correlation address for the requesting SyncClient. It is never\r\n // broadcast; every public sender identity below comes from the server-owned connection.\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, conn.id, 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: conn.id,\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, data: unknown): void {\r\n this.presenceConnections.add(conn.id);\r\n const message = JSON.stringify({ from: conn.id, op: { kind: 'presence', data } });\r\n this.relayToRoom(conn.room, conn.id, message);\r\n this.safePublish(\r\n JSON.stringify({\r\n o: this.instanceId,\r\n room: conn.room,\r\n from: conn.id,\r\n op: { kind: 'presence', data },\r\n }),\r\n );\r\n }\r\n\r\n private schedulePresence(conn: Connection, data: unknown): void {\r\n if (this.presenceThrottleMs <= 0) {\r\n this.broadcastPresence(conn, data);\r\n return;\r\n }\r\n const now = Date.now();\r\n const lastSentAt = this.lastPresenceAt.get(conn.id);\r\n if (lastSentAt === undefined || now - lastSentAt >= this.presenceThrottleMs) {\r\n this.lastPresenceAt.set(conn.id, now);\r\n this.broadcastPresence(conn, data);\r\n return;\r\n }\r\n\r\n const existing = this.pendingPresence.get(conn.id);\r\n if (existing) {\r\n existing.data = data;\r\n return;\r\n }\r\n const timer = setTimeout(\r\n () => {\r\n const pending = this.pendingPresence.get(conn.id);\r\n this.pendingPresence.delete(conn.id);\r\n if (!pending || !this.conns.has(conn.id)) return;\r\n this.lastPresenceAt.set(conn.id, Date.now());\r\n this.broadcastPresence(conn, pending.data);\r\n },\r\n this.presenceThrottleMs - (now - lastSentAt),\r\n );\r\n this.pendingPresence.set(conn.id, { data, timer });\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 for (const pending of this.pendingPresence.values()) clearTimeout(pending.timer);\r\n this.pendingPresence.clear();\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","export const DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;\r\nexport const DEFAULT_MAX_JSON_DEPTH = 32;\r\nexport const DEFAULT_MAX_PENDING_AUTH_MESSAGES = 100;\r\nexport const DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;\r\nexport const DEFAULT_MESSAGES_PER_SECOND = 120;\r\nexport const DEFAULT_MESSAGE_BURST = 240;\r\nexport const DEFAULT_PRESENCE_THROTTLE_MS = 50;\r\n\r\nexport function hasJsonDepthAtMost(message: string, maxDepth: number): boolean {\r\n let depth = 0;\r\n let inString = false;\r\n let escaped = false;\r\n\r\n for (const char of message) {\r\n if (inString) {\r\n if (escaped) escaped = false;\r\n else if (char === '\\\\') escaped = true;\r\n else if (char === '\"') inString = false;\r\n continue;\r\n }\r\n if (char === '\"') inString = true;\r\n else if (char === '{' || char === '[') {\r\n depth += 1;\r\n if (depth > maxDepth) return false;\r\n } else if (char === '}' || char === ']') {\r\n depth -= 1;\r\n if (depth < 0) return false;\r\n }\r\n }\r\n\r\n return depth === 0 && !inString;\r\n}\r\n\r\nexport class MessageRateLimiter {\r\n private tokens: number;\r\n private updatedAt: number;\r\n\r\n constructor(\r\n private readonly ratePerSecond: number,\r\n private readonly burst: number,\r\n now = Date.now(),\r\n ) {\r\n this.tokens = burst;\r\n this.updatedAt = now;\r\n }\r\n\r\n take(now = Date.now()): boolean {\r\n const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1000;\r\n this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);\r\n this.updatedAt = now;\r\n if (this.tokens < 1) return false;\r\n this.tokens -= 1;\r\n return true;\r\n }\r\n}\r\n","import { WebSocketServer, type RawData } from 'ws';\nimport type { Server } from 'http';\nimport { SyncHub } from './sync-hub';\nimport type { HubBackend } from './hub-backend';\nimport type { HubFanout } from './hub-fanout';\nimport type { Authenticate } from './authenticate';\nimport type { Authorize, CanRead } from './authorize';\nimport { startHeartbeat } from './heartbeat';\nimport {\n DEFAULT_MAX_JSON_DEPTH,\n DEFAULT_MAX_MESSAGE_BYTES,\n DEFAULT_MAX_PENDING_AUTH_BYTES,\n DEFAULT_MAX_PENDING_AUTH_MESSAGES,\n DEFAULT_MESSAGE_BURST,\n DEFAULT_MESSAGES_PER_SECOND,\n DEFAULT_PRESENCE_THROTTLE_MS,\n MessageRateLimiter,\n} from './resource-limits';\nimport { DEFAULT_SHUTDOWN_GRACE_MS, drainWebSocketServer } from './shutdown';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authenticate?: Authenticate;\n authorize?: Authorize;\n canRead?: CanRead;\n heartbeatIntervalMs?: number;\n maxMessageBytes?: number;\n maxJsonDepth?: number;\n maxPendingAuthMessages?: number;\n maxPendingAuthBytes?: number;\n messagesPerSecond?: number;\n messageBurst?: number;\n presenceThrottleMs?: number;\n shutdownGraceMs?: number;\n}\n\nfunction rawDataByteLength(data: RawData): number {\n if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);\n return data.byteLength;\n}\n\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\n hub: SyncHub;\n wss: WebSocketServer;\n close: () => Promise<void>;\n} {\n const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;\n if (!Number.isFinite(shutdownGraceMs) || shutdownGraceMs < 0) {\n throw new RangeError('shutdownGraceMs must be a non-negative finite number');\n }\n const hub = new SyncHub({\n backend: options.backend,\n fanout: options.fanout,\n instanceId: options.instanceId,\n authorize: options.authorize,\n canRead: options.canRead,\n maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,\n presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,\n });\n const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;\n const wss = options.server\n ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes })\n : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\n let shuttingDown = false;\n let closePromise: Promise<void> | undefined;\n let counter = 0;\n wss.on('connection', (ws, req) => {\n if (shuttingDown) {\n ws.close(1001, 'server shutting down');\n return;\n }\n heartbeat.track(ws);\n // Protocol/parser failures (including maxPayload) close the peer. Consuming the socket-level\n // error keeps hostile input from becoming an uncaught process error.\n ws.on('error', () => undefined);\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(4400, 'room required');\n return;\n }\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\n\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\n let closed = false;\n let admitted = false;\n const queue: string[] = [];\n let queuedBytes = 0;\n const maxPendingAuthMessages =\n options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;\n const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;\n const limiter = new MessageRateLimiter(\n options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND,\n options.messageBurst ?? DEFAULT_MESSAGE_BURST,\n );\n\n const send = (m: string) => {\n try {\n ws.send(m);\n } catch {\n /* socket closed mid-send */\n }\n };\n\n ws.on('message', (data) => {\n if (state === 'rejected') return;\n const messageBytes = rawDataByteLength(data);\n if (messageBytes > maxMessageBytes) {\n state = 'rejected';\n ws.close(1009, 'message too large');\n return;\n }\n if (!limiter.take()) {\n state = 'rejected';\n ws.close(4408, 'rate limit exceeded');\n return;\n }\n const msg = String(data);\n if (state === 'pending') {\n if (\n queue.length >= maxPendingAuthMessages ||\n queuedBytes + messageBytes > maxPendingAuthBytes\n ) {\n state = 'rejected';\n ws.close(4408, 'authentication queue limit exceeded');\n return;\n }\n queue.push(msg);\n queuedBytes += messageBytes;\n return;\n }\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\n });\n ws.on('close', () => {\n closed = true;\n if (admitted) hub.removeConnection(connId);\n });\n\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\n .then((result) => {\n if (closed || state === 'rejected' || shuttingDown) return;\n if (!result) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n return;\n }\n state = 'ready';\n admitted = true;\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\n for (const m of queue) {\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\n }\n queue.length = 0;\n queuedBytes = 0;\n })\n .catch(() => {\n if (!closed && !shuttingDown) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n }\n });\n });\n const close = (): Promise<void> => {\n if (closePromise) return closePromise;\n shuttingDown = true;\n closePromise = drainWebSocketServer(wss, shutdownGraceMs).then(() => {\n heartbeat.stop();\n hub.close();\n });\n return closePromise;\n };\n\n return {\n hub,\n wss,\n close,\n };\n}\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n","export const DEFAULT_SHUTDOWN_GRACE_MS = 5000;\n\nexport interface ShutdownSocket {\n close(code?: number, reason?: string): void;\n terminate(): void;\n}\n\nexport interface ShutdownServer {\n clients: Set<ShutdownSocket>;\n close(callback: (error?: Error) => void): void;\n}\n\n/** Stop admission, ask connected peers to leave, then forcefully reap stragglers. */\nexport function drainWebSocketServer(wss: ShutdownServer, graceMs: number): Promise<void> {\n return new Promise((resolve) => {\n let settled = false;\n const finish = (): void => {\n if (settled) return;\n settled = true;\n clearTimeout(forceTimer);\n resolve();\n };\n const forceTimer = setTimeout(() => {\n for (const ws of wss.clients) {\n try {\n ws.terminate();\n } catch {\n /* socket already failed while shutdown was in progress */\n }\n }\n finish();\n }, graceMs);\n\n // Calling close first prevents new connections. Its callback runs once all clients have left.\n wss.close(() => finish());\n for (const ws of wss.clients) {\n try {\n ws.close(1001, 'server shutting down');\n } catch {\n try {\n ws.terminate();\n } catch {\n /* socket already closed */\n }\n }\n }\n });\n}\n"],"mappings":"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","names":["import_sync"]}
package/dist/index.d.cts CHANGED
@@ -124,6 +124,7 @@ interface CreateSyncServerOptions {
124
124
  messagesPerSecond?: number;
125
125
  messageBurst?: number;
126
126
  presenceThrottleMs?: number;
127
+ shutdownGraceMs?: number;
127
128
  }
128
129
  declare function createSyncServer(options?: CreateSyncServerOptions): {
129
130
  hub: SyncHub;
package/dist/index.d.ts CHANGED
@@ -124,6 +124,7 @@ interface CreateSyncServerOptions {
124
124
  messagesPerSecond?: number;
125
125
  messageBurst?: number;
126
126
  presenceThrottleMs?: number;
127
+ shutdownGraceMs?: number;
127
128
  }
128
129
  declare function createSyncServer(options?: CreateSyncServerOptions): {
129
130
  hub: SyncHub;
package/dist/index.js CHANGED
@@ -424,12 +424,50 @@ function startHeartbeat(wss, intervalMs) {
424
424
  return { track, stop: () => clearInterval(interval) };
425
425
  }
426
426
 
427
+ // src/shutdown.ts
428
+ var DEFAULT_SHUTDOWN_GRACE_MS = 5e3;
429
+ function drainWebSocketServer(wss, graceMs) {
430
+ return new Promise((resolve) => {
431
+ let settled = false;
432
+ const finish = () => {
433
+ if (settled) return;
434
+ settled = true;
435
+ clearTimeout(forceTimer);
436
+ resolve();
437
+ };
438
+ const forceTimer = setTimeout(() => {
439
+ for (const ws of wss.clients) {
440
+ try {
441
+ ws.terminate();
442
+ } catch {
443
+ }
444
+ }
445
+ finish();
446
+ }, graceMs);
447
+ wss.close(() => finish());
448
+ for (const ws of wss.clients) {
449
+ try {
450
+ ws.close(1001, "server shutting down");
451
+ } catch {
452
+ try {
453
+ ws.terminate();
454
+ } catch {
455
+ }
456
+ }
457
+ }
458
+ });
459
+ }
460
+
427
461
  // src/create-sync-server.ts
428
462
  function rawDataByteLength(data) {
429
463
  if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);
430
464
  return data.byteLength;
431
465
  }
432
466
  function createSyncServer(options = {}) {
467
+ const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
468
+ if (!Number.isFinite(shutdownGraceMs) || shutdownGraceMs < 0) {
469
+ throw new RangeError("shutdownGraceMs must be a non-negative finite number");
470
+ }
433
471
  const hub = new SyncHub({
434
472
  backend: options.backend,
435
473
  fanout: options.fanout,
@@ -442,8 +480,14 @@ function createSyncServer(options = {}) {
442
480
  const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
443
481
  const wss = options.server ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes }) : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });
444
482
  const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 3e4);
483
+ let shuttingDown = false;
484
+ let closePromise;
445
485
  let counter = 0;
446
486
  wss.on("connection", (ws, req) => {
487
+ if (shuttingDown) {
488
+ ws.close(1001, "server shutting down");
489
+ return;
490
+ }
447
491
  heartbeat.track(ws);
448
492
  ws.on("error", () => void 0);
449
493
  const url = new URL(req.url ?? "", "http://localhost");
@@ -501,7 +545,7 @@ function createSyncServer(options = {}) {
501
545
  if (admitted) hub.removeConnection(connId);
502
546
  });
503
547
  Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId }).then((result) => {
504
- if (closed || state === "rejected") return;
548
+ if (closed || state === "rejected" || shuttingDown) return;
505
549
  if (!result) {
506
550
  state = "rejected";
507
551
  ws.close(4401, "unauthorized");
@@ -516,20 +560,25 @@ function createSyncServer(options = {}) {
516
560
  queue.length = 0;
517
561
  queuedBytes = 0;
518
562
  }).catch(() => {
519
- if (!closed) {
563
+ if (!closed && !shuttingDown) {
520
564
  state = "rejected";
521
565
  ws.close(4401, "unauthorized");
522
566
  }
523
567
  });
524
568
  });
569
+ const close = () => {
570
+ if (closePromise) return closePromise;
571
+ shuttingDown = true;
572
+ closePromise = drainWebSocketServer(wss, shutdownGraceMs).then(() => {
573
+ heartbeat.stop();
574
+ hub.close();
575
+ });
576
+ return closePromise;
577
+ };
525
578
  return {
526
579
  hub,
527
580
  wss,
528
- close: () => new Promise((resolve) => {
529
- heartbeat.stop();
530
- hub.close();
531
- wss.close(() => resolve());
532
- })
581
+ close
533
582
  };
534
583
  }
535
584
  export {
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/resource-limits.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';\nimport {\n DEFAULT_MAX_JSON_DEPTH,\n DEFAULT_PRESENCE_THROTTLE_MS,\n hasJsonDepthAtMost,\n} from './resource-limits';\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 maxJsonDepth?: number;\n presenceThrottleMs?: number;\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 private readonly maxJsonDepth: number;\n private readonly presenceThrottleMs: number;\n private readonly lastPresenceAt = new Map<string, number>();\n private readonly pendingPresence = new Map<\n string,\n { data: unknown; timer: ReturnType<typeof setTimeout> }\n >();\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.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;\n this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;\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 this.lastPresenceAt.delete(connId);\n const pendingPresence = this.pendingPresence.get(connId);\n if (pendingPresence) clearTimeout(pendingPresence.timer);\n this.pendingPresence.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 if (!hasJsonDepthAtMost(message, this.maxJsonDepth)) return Promise.resolve();\n const env = parseEnvelope(message);\n if (!env) return Promise.resolve();\n if (env.op.kind === 'presence') {\n this.schedulePresence(conn, env.op.data); // off-queue, throttled independently\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 schedulePresence(conn: Connection, data: unknown): void {\n if (this.presenceThrottleMs <= 0) {\n this.broadcastPresence(conn, data);\n return;\n }\n const now = Date.now();\n const lastSentAt = this.lastPresenceAt.get(conn.id);\n if (lastSentAt === undefined || now - lastSentAt >= this.presenceThrottleMs) {\n this.lastPresenceAt.set(conn.id, now);\n this.broadcastPresence(conn, data);\n return;\n }\n\n const existing = this.pendingPresence.get(conn.id);\n if (existing) {\n existing.data = data;\n return;\n }\n const timer = setTimeout(\n () => {\n const pending = this.pendingPresence.get(conn.id);\n this.pendingPresence.delete(conn.id);\n if (!pending || !this.conns.has(conn.id)) return;\n this.lastPresenceAt.set(conn.id, Date.now());\n this.broadcastPresence(conn, pending.data);\n },\n this.presenceThrottleMs - (now - lastSentAt),\n );\n this.pendingPresence.set(conn.id, { data, timer });\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 for (const pending of this.pendingPresence.values()) clearTimeout(pending.timer);\n this.pendingPresence.clear();\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","export const DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;\nexport const DEFAULT_MAX_JSON_DEPTH = 32;\nexport const DEFAULT_MAX_PENDING_AUTH_MESSAGES = 100;\nexport const DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;\nexport const DEFAULT_MESSAGES_PER_SECOND = 120;\nexport const DEFAULT_MESSAGE_BURST = 240;\nexport const DEFAULT_PRESENCE_THROTTLE_MS = 50;\n\nexport function hasJsonDepthAtMost(message: string, maxDepth: number): boolean {\n let depth = 0;\n let inString = false;\n let escaped = false;\n\n for (const char of message) {\n if (inString) {\n if (escaped) escaped = false;\n else if (char === '\\\\') escaped = true;\n else if (char === '\"') inString = false;\n continue;\n }\n if (char === '\"') inString = true;\n else if (char === '{' || char === '[') {\n depth += 1;\n if (depth > maxDepth) return false;\n } else if (char === '}' || char === ']') {\n depth -= 1;\n if (depth < 0) return false;\n }\n }\n\n return depth === 0 && !inString;\n}\n\nexport class MessageRateLimiter {\n private tokens: number;\n private updatedAt: number;\n\n constructor(\n private readonly ratePerSecond: number,\n private readonly burst: number,\n now = Date.now(),\n ) {\n this.tokens = burst;\n this.updatedAt = now;\n }\n\n take(now = Date.now()): boolean {\n const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1000;\n this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);\n this.updatedAt = now;\n if (this.tokens < 1) return false;\n this.tokens -= 1;\n return true;\n }\n}\n","import { WebSocketServer, type RawData } from 'ws';\nimport type { Server } from 'http';\nimport { SyncHub } from './sync-hub';\nimport type { HubBackend } from './hub-backend';\nimport type { HubFanout } from './hub-fanout';\nimport type { Authenticate } from './authenticate';\nimport type { Authorize, CanRead } from './authorize';\nimport { startHeartbeat } from './heartbeat';\nimport {\n DEFAULT_MAX_JSON_DEPTH,\n DEFAULT_MAX_MESSAGE_BYTES,\n DEFAULT_MAX_PENDING_AUTH_BYTES,\n DEFAULT_MAX_PENDING_AUTH_MESSAGES,\n DEFAULT_MESSAGE_BURST,\n DEFAULT_MESSAGES_PER_SECOND,\n DEFAULT_PRESENCE_THROTTLE_MS,\n MessageRateLimiter,\n} from './resource-limits';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authenticate?: Authenticate;\n authorize?: Authorize;\n canRead?: CanRead;\n heartbeatIntervalMs?: number;\n maxMessageBytes?: number;\n maxJsonDepth?: number;\n maxPendingAuthMessages?: number;\n maxPendingAuthBytes?: number;\n messagesPerSecond?: number;\n messageBurst?: number;\n presenceThrottleMs?: number;\n}\n\nfunction rawDataByteLength(data: RawData): number {\n if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);\n return data.byteLength;\n}\n\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\n hub: SyncHub;\n wss: WebSocketServer;\n close: () => Promise<void>;\n} {\n const hub = new SyncHub({\n backend: options.backend,\n fanout: options.fanout,\n instanceId: options.instanceId,\n authorize: options.authorize,\n canRead: options.canRead,\n maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,\n presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,\n });\n const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;\n const wss = options.server\n ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes })\n : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\n let counter = 0;\n wss.on('connection', (ws, req) => {\n heartbeat.track(ws);\n // Protocol/parser failures (including maxPayload) close the peer. Consuming the socket-level\n // error keeps hostile input from becoming an uncaught process error.\n ws.on('error', () => undefined);\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(4400, 'room required');\n return;\n }\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\n\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\n let closed = false;\n let admitted = false;\n const queue: string[] = [];\n let queuedBytes = 0;\n const maxPendingAuthMessages =\n options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;\n const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;\n const limiter = new MessageRateLimiter(\n options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND,\n options.messageBurst ?? DEFAULT_MESSAGE_BURST,\n );\n\n const send = (m: string) => {\n try {\n ws.send(m);\n } catch {\n /* socket closed mid-send */\n }\n };\n\n ws.on('message', (data) => {\n if (state === 'rejected') return;\n const messageBytes = rawDataByteLength(data);\n if (messageBytes > maxMessageBytes) {\n state = 'rejected';\n ws.close(1009, 'message too large');\n return;\n }\n if (!limiter.take()) {\n state = 'rejected';\n ws.close(4408, 'rate limit exceeded');\n return;\n }\n const msg = String(data);\n if (state === 'pending') {\n if (\n queue.length >= maxPendingAuthMessages ||\n queuedBytes + messageBytes > maxPendingAuthBytes\n ) {\n state = 'rejected';\n ws.close(4408, 'authentication queue limit exceeded');\n return;\n }\n queue.push(msg);\n queuedBytes += messageBytes;\n return;\n }\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\n });\n ws.on('close', () => {\n closed = true;\n if (admitted) hub.removeConnection(connId);\n });\n\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\n .then((result) => {\n if (closed || state === 'rejected') return;\n if (!result) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n return;\n }\n state = 'ready';\n admitted = true;\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\n for (const m of queue) {\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\n }\n queue.length = 0;\n queuedBytes = 0;\n })\n .catch(() => {\n if (!closed) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n }\n });\n });\n return {\n hub,\n wss,\n close: () =>\n new Promise<void>((resolve) => {\n heartbeat.stop();\n hub.close();\n wss.close(() => resolve());\n }),\n };\n}\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/sync-hub.ts","../src/memory-hub-backend.ts","../src/hub-fanout.ts","../src/resource-limits.ts","../src/create-sync-server.ts","../src/heartbeat.ts","../src/shutdown.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\nimport {\r\n DEFAULT_MAX_JSON_DEPTH,\r\n DEFAULT_PRESENCE_THROTTLE_MS,\r\n hasJsonDepthAtMost,\r\n} from './resource-limits';\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 maxJsonDepth?: number;\r\n presenceThrottleMs?: number;\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 presenceConnections = new Set<string>();\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 private readonly maxJsonDepth: number;\r\n private readonly presenceThrottleMs: number;\r\n private readonly lastPresenceAt = new Map<string, number>();\r\n private readonly pendingPresence = new Map<\r\n string,\r\n { data: unknown; timer: ReturnType<typeof setTimeout> }\r\n >();\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.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;\r\n this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;\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 hadPresence = this.presenceConnections.delete(connId);\r\n this.lastPresenceAt.delete(connId);\r\n const pendingPresence = this.pendingPresence.get(connId);\r\n if (pendingPresence) clearTimeout(pendingPresence.timer);\r\n this.pendingPresence.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 (hadPresence) this.broadcastLeave(room, conn.id);\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 if (!hasJsonDepthAtMost(message, this.maxJsonDepth)) 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.schedulePresence(conn, env.op.data); // off-queue, throttled independently\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 // `to` is a private correlation address for the requesting SyncClient. It is never\r\n // broadcast; every public sender identity below comes from the server-owned connection.\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, conn.id, 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: conn.id,\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, data: unknown): void {\r\n this.presenceConnections.add(conn.id);\r\n const message = JSON.stringify({ from: conn.id, op: { kind: 'presence', data } });\r\n this.relayToRoom(conn.room, conn.id, message);\r\n this.safePublish(\r\n JSON.stringify({\r\n o: this.instanceId,\r\n room: conn.room,\r\n from: conn.id,\r\n op: { kind: 'presence', data },\r\n }),\r\n );\r\n }\r\n\r\n private schedulePresence(conn: Connection, data: unknown): void {\r\n if (this.presenceThrottleMs <= 0) {\r\n this.broadcastPresence(conn, data);\r\n return;\r\n }\r\n const now = Date.now();\r\n const lastSentAt = this.lastPresenceAt.get(conn.id);\r\n if (lastSentAt === undefined || now - lastSentAt >= this.presenceThrottleMs) {\r\n this.lastPresenceAt.set(conn.id, now);\r\n this.broadcastPresence(conn, data);\r\n return;\r\n }\r\n\r\n const existing = this.pendingPresence.get(conn.id);\r\n if (existing) {\r\n existing.data = data;\r\n return;\r\n }\r\n const timer = setTimeout(\r\n () => {\r\n const pending = this.pendingPresence.get(conn.id);\r\n this.pendingPresence.delete(conn.id);\r\n if (!pending || !this.conns.has(conn.id)) return;\r\n this.lastPresenceAt.set(conn.id, Date.now());\r\n this.broadcastPresence(conn, pending.data);\r\n },\r\n this.presenceThrottleMs - (now - lastSentAt),\r\n );\r\n this.pendingPresence.set(conn.id, { data, timer });\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 for (const pending of this.pendingPresence.values()) clearTimeout(pending.timer);\r\n this.pendingPresence.clear();\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","export const DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;\r\nexport const DEFAULT_MAX_JSON_DEPTH = 32;\r\nexport const DEFAULT_MAX_PENDING_AUTH_MESSAGES = 100;\r\nexport const DEFAULT_MAX_PENDING_AUTH_BYTES = 2 * 1024 * 1024;\r\nexport const DEFAULT_MESSAGES_PER_SECOND = 120;\r\nexport const DEFAULT_MESSAGE_BURST = 240;\r\nexport const DEFAULT_PRESENCE_THROTTLE_MS = 50;\r\n\r\nexport function hasJsonDepthAtMost(message: string, maxDepth: number): boolean {\r\n let depth = 0;\r\n let inString = false;\r\n let escaped = false;\r\n\r\n for (const char of message) {\r\n if (inString) {\r\n if (escaped) escaped = false;\r\n else if (char === '\\\\') escaped = true;\r\n else if (char === '\"') inString = false;\r\n continue;\r\n }\r\n if (char === '\"') inString = true;\r\n else if (char === '{' || char === '[') {\r\n depth += 1;\r\n if (depth > maxDepth) return false;\r\n } else if (char === '}' || char === ']') {\r\n depth -= 1;\r\n if (depth < 0) return false;\r\n }\r\n }\r\n\r\n return depth === 0 && !inString;\r\n}\r\n\r\nexport class MessageRateLimiter {\r\n private tokens: number;\r\n private updatedAt: number;\r\n\r\n constructor(\r\n private readonly ratePerSecond: number,\r\n private readonly burst: number,\r\n now = Date.now(),\r\n ) {\r\n this.tokens = burst;\r\n this.updatedAt = now;\r\n }\r\n\r\n take(now = Date.now()): boolean {\r\n const elapsedSeconds = Math.max(0, now - this.updatedAt) / 1000;\r\n this.tokens = Math.min(this.burst, this.tokens + elapsedSeconds * this.ratePerSecond);\r\n this.updatedAt = now;\r\n if (this.tokens < 1) return false;\r\n this.tokens -= 1;\r\n return true;\r\n }\r\n}\r\n","import { WebSocketServer, type RawData } from 'ws';\nimport type { Server } from 'http';\nimport { SyncHub } from './sync-hub';\nimport type { HubBackend } from './hub-backend';\nimport type { HubFanout } from './hub-fanout';\nimport type { Authenticate } from './authenticate';\nimport type { Authorize, CanRead } from './authorize';\nimport { startHeartbeat } from './heartbeat';\nimport {\n DEFAULT_MAX_JSON_DEPTH,\n DEFAULT_MAX_MESSAGE_BYTES,\n DEFAULT_MAX_PENDING_AUTH_BYTES,\n DEFAULT_MAX_PENDING_AUTH_MESSAGES,\n DEFAULT_MESSAGE_BURST,\n DEFAULT_MESSAGES_PER_SECOND,\n DEFAULT_PRESENCE_THROTTLE_MS,\n MessageRateLimiter,\n} from './resource-limits';\nimport { DEFAULT_SHUTDOWN_GRACE_MS, drainWebSocketServer } from './shutdown';\n\nexport interface CreateSyncServerOptions {\n port?: number;\n server?: Server;\n backend?: HubBackend;\n fanout?: HubFanout;\n instanceId?: string;\n authenticate?: Authenticate;\n authorize?: Authorize;\n canRead?: CanRead;\n heartbeatIntervalMs?: number;\n maxMessageBytes?: number;\n maxJsonDepth?: number;\n maxPendingAuthMessages?: number;\n maxPendingAuthBytes?: number;\n messagesPerSecond?: number;\n messageBurst?: number;\n presenceThrottleMs?: number;\n shutdownGraceMs?: number;\n}\n\nfunction rawDataByteLength(data: RawData): number {\n if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.byteLength, 0);\n return data.byteLength;\n}\n\nexport function createSyncServer(options: CreateSyncServerOptions = {}): {\n hub: SyncHub;\n wss: WebSocketServer;\n close: () => Promise<void>;\n} {\n const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;\n if (!Number.isFinite(shutdownGraceMs) || shutdownGraceMs < 0) {\n throw new RangeError('shutdownGraceMs must be a non-negative finite number');\n }\n const hub = new SyncHub({\n backend: options.backend,\n fanout: options.fanout,\n instanceId: options.instanceId,\n authorize: options.authorize,\n canRead: options.canRead,\n maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,\n presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,\n });\n const maxMessageBytes = options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;\n const wss = options.server\n ? new WebSocketServer({ server: options.server, maxPayload: maxMessageBytes })\n : new WebSocketServer({ port: options.port ?? 0, maxPayload: maxMessageBytes });\n const heartbeat = startHeartbeat(wss, options.heartbeatIntervalMs ?? 30000);\n let shuttingDown = false;\n let closePromise: Promise<void> | undefined;\n let counter = 0;\n wss.on('connection', (ws, req) => {\n if (shuttingDown) {\n ws.close(1001, 'server shutting down');\n return;\n }\n heartbeat.track(ws);\n // Protocol/parser failures (including maxPayload) close the peer. Consuming the socket-level\n // error keeps hostile input from becoming an uncaught process error.\n ws.on('error', () => undefined);\n const url = new URL(req.url ?? '', 'http://localhost');\n const room = url.searchParams.get('room');\n if (!room) {\n ws.close(4400, 'room required');\n return;\n }\n const connId = `c${++counter}-${Math.random().toString(36).slice(2, 8)}`;\n\n let state: 'pending' | 'ready' | 'rejected' = 'pending';\n let closed = false;\n let admitted = false;\n const queue: string[] = [];\n let queuedBytes = 0;\n const maxPendingAuthMessages =\n options.maxPendingAuthMessages ?? DEFAULT_MAX_PENDING_AUTH_MESSAGES;\n const maxPendingAuthBytes = options.maxPendingAuthBytes ?? DEFAULT_MAX_PENDING_AUTH_BYTES;\n const limiter = new MessageRateLimiter(\n options.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND,\n options.messageBurst ?? DEFAULT_MESSAGE_BURST,\n );\n\n const send = (m: string) => {\n try {\n ws.send(m);\n } catch {\n /* socket closed mid-send */\n }\n };\n\n ws.on('message', (data) => {\n if (state === 'rejected') return;\n const messageBytes = rawDataByteLength(data);\n if (messageBytes > maxMessageBytes) {\n state = 'rejected';\n ws.close(1009, 'message too large');\n return;\n }\n if (!limiter.take()) {\n state = 'rejected';\n ws.close(4408, 'rate limit exceeded');\n return;\n }\n const msg = String(data);\n if (state === 'pending') {\n if (\n queue.length >= maxPendingAuthMessages ||\n queuedBytes + messageBytes > maxPendingAuthBytes\n ) {\n state = 'rejected';\n ws.close(4408, 'authentication queue limit exceeded');\n return;\n }\n queue.push(msg);\n queuedBytes += messageBytes;\n return;\n }\n void hub.handleMessage(connId, msg).catch((err) => console.error('[sync-server]', err));\n });\n ws.on('close', () => {\n closed = true;\n if (admitted) hub.removeConnection(connId);\n });\n\n Promise.resolve(options.authenticate ? options.authenticate({ req, room }) : { userId: connId })\n .then((result) => {\n if (closed || state === 'rejected' || shuttingDown) return;\n if (!result) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n return;\n }\n state = 'ready';\n admitted = true;\n hub.addConnection({ id: connId, room, userId: result.userId, role: result.role, send });\n for (const m of queue) {\n void hub.handleMessage(connId, m).catch((err) => console.error('[sync-server]', err));\n }\n queue.length = 0;\n queuedBytes = 0;\n })\n .catch(() => {\n if (!closed && !shuttingDown) {\n state = 'rejected';\n ws.close(4401, 'unauthorized');\n }\n });\n });\n const close = (): Promise<void> => {\n if (closePromise) return closePromise;\n shuttingDown = true;\n closePromise = drainWebSocketServer(wss, shutdownGraceMs).then(() => {\n heartbeat.stop();\n hub.close();\n });\n return closePromise;\n };\n\n return {\n hub,\n wss,\n close,\n };\n}\n","export interface HeartbeatSocket {\r\n ping(): void;\r\n terminate(): void;\r\n on(event: 'pong', listener: () => void): void;\r\n}\r\nexport interface HeartbeatServer {\r\n clients: Set<HeartbeatSocket>;\r\n}\r\nexport interface Heartbeat {\r\n track(ws: HeartbeatSocket): void;\r\n stop(): void;\r\n}\r\n\r\nexport function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat {\r\n if (intervalMs <= 0) {\r\n return {\r\n track: () => {\r\n /* disabled */\r\n },\r\n stop: () => {\r\n /* disabled */\r\n },\r\n };\r\n }\r\n const alive = new WeakMap<HeartbeatSocket, boolean>();\r\n const track = (ws: HeartbeatSocket): void => {\r\n alive.set(ws, true);\r\n ws.on('pong', () => alive.set(ws, true));\r\n };\r\n const interval = setInterval(() => {\r\n for (const ws of wss.clients) {\r\n try {\r\n if (alive.get(ws) === false) {\r\n ws.terminate();\r\n continue;\r\n }\r\n alive.set(ws, false);\r\n ws.ping();\r\n } catch {\r\n /* socket dying mid-tick — skip it */\r\n }\r\n }\r\n }, intervalMs);\r\n return { track, stop: () => clearInterval(interval) };\r\n}\r\n","export const DEFAULT_SHUTDOWN_GRACE_MS = 5000;\n\nexport interface ShutdownSocket {\n close(code?: number, reason?: string): void;\n terminate(): void;\n}\n\nexport interface ShutdownServer {\n clients: Set<ShutdownSocket>;\n close(callback: (error?: Error) => void): void;\n}\n\n/** Stop admission, ask connected peers to leave, then forcefully reap stragglers. */\nexport function drainWebSocketServer(wss: ShutdownServer, graceMs: number): Promise<void> {\n return new Promise((resolve) => {\n let settled = false;\n const finish = (): void => {\n if (settled) return;\n settled = true;\n clearTimeout(forceTimer);\n resolve();\n };\n const forceTimer = setTimeout(() => {\n for (const ws of wss.clients) {\n try {\n ws.terminate();\n } catch {\n /* socket already failed while shutdown was in progress */\n }\n }\n finish();\n }, graceMs);\n\n // Calling close first prevents new connections. Its callback runs once all clients have left.\n wss.close(() => finish());\n for (const ws of wss.clients) {\n try {\n ws.close(1001, 'server shutting down');\n } catch {\n try {\n ws.terminate();\n } catch {\n /* socket already closed */\n }\n }\n }\n });\n}\n"],"mappings":";AAAA,SAAS,eAAe,sBAAsD;;;ACA9E,SAAS,oBAAiC;AAInC,IAAM,mBAAN,MAA6C;AAAA,EAC1C,QAAQ,oBAAI,IAAwC;AAAA,EAEpD,KAAK,IAAwC;AACnD,QAAI,IAAI,KAAK,MAAM,IAAI,EAAE;AACzB,QAAI,CAAC,GAAG;AACN,UAAI,oBAAI,IAAI;AACZ,WAAK,MAAM,IAAI,IAAI,CAAC;AAAA,IACtB;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,SAAS,MAAwC;AACrD,WAAO,CAAC,GAAG,KAAK,KAAK,IAAI,EAAE,OAAO,CAAC;AAAA,EACrC;AAAA,EAEA,MAAM,IAAI,MAAc,IAAgD;AACtE,WAAO,KAAK,KAAK,IAAI,EAAE,IAAI,EAAE;AAAA,EAC/B;AAAA,EAEA,MAAM,MAAM,MAAc,IAA2B;AACnD,QAAI,GAAG,SAAS,SAAS;AACvB,WAAK,MAAM,OAAO,IAAI;AACtB;AAAA,IACF;AACA,iBAAa,KAAK,KAAK,IAAI,GAAG,EAAE;AAAA,EAClC;AACF;;;ACzBO,IAAM,oBAAN,MAA6C;AAAA,EACjC,WAAW,oBAAI,IAA+B;AAAA,EAE/D,QAAQ,SAAuB;AAC7B,eAAW,KAAK,KAAK,UAAU;AAC7B,UAAI;AACF,UAAE,OAAO;AAAA,MACX,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAAA,EAEA,UAAU,SAAgD;AACxD,SAAK,SAAS,IAAI,OAAO;AACzB,WAAO,MAAM,KAAK,SAAS,OAAO,OAAO;AAAA,EAC3C;AACF;;;ACvBO,IAAM,4BAA4B,OAAO;AACzC,IAAM,yBAAyB;AAC/B,IAAM,oCAAoC;AAC1C,IAAM,iCAAiC,IAAI,OAAO;AAClD,IAAM,8BAA8B;AACpC,IAAM,wBAAwB;AAC9B,IAAM,+BAA+B;AAErC,SAAS,mBAAmB,SAAiB,UAA2B;AAC7E,MAAI,QAAQ;AACZ,MAAI,WAAW;AACf,MAAI,UAAU;AAEd,aAAW,QAAQ,SAAS;AAC1B,QAAI,UAAU;AACZ,UAAI,QAAS,WAAU;AAAA,eACd,SAAS,KAAM,WAAU;AAAA,eACzB,SAAS,IAAK,YAAW;AAClC;AAAA,IACF;AACA,QAAI,SAAS,IAAK,YAAW;AAAA,aACpB,SAAS,OAAO,SAAS,KAAK;AACrC,eAAS;AACT,UAAI,QAAQ,SAAU,QAAO;AAAA,IAC/B,WAAW,SAAS,OAAO,SAAS,KAAK;AACvC,eAAS;AACT,UAAI,QAAQ,EAAG,QAAO;AAAA,IACxB;AAAA,EACF;AAEA,SAAO,UAAU,KAAK,CAAC;AACzB;AAEO,IAAM,qBAAN,MAAyB;AAAA,EAI9B,YACmB,eACA,OACjB,MAAM,KAAK,IAAI,GACf;AAHiB;AACA;AAGjB,SAAK,SAAS;AACd,SAAK,YAAY;AAAA,EACnB;AAAA,EAVQ;AAAA,EACA;AAAA,EAWR,KAAK,MAAM,KAAK,IAAI,GAAY;AAC9B,UAAM,iBAAiB,KAAK,IAAI,GAAG,MAAM,KAAK,SAAS,IAAI;AAC3D,SAAK,SAAS,KAAK,IAAI,KAAK,OAAO,KAAK,SAAS,iBAAiB,KAAK,aAAa;AACpF,SAAK,YAAY;AACjB,QAAI,KAAK,SAAS,EAAG,QAAO;AAC5B,SAAK,UAAU;AACf,WAAO;AAAA,EACT;AACF;;;AHzBA,IAAM,WAAW;AAEjB,SAAS,qBAA6B;AACpC,MAAI,OAAO,WAAW,eAAe,OAAO,OAAO,eAAe;AAChE,WAAO,OAAO,WAAW;AAC3B,SAAO,KAAK,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,CAAC,CAAC;AACjD;AAEA,SAAS,WAAW,IAA6E;AAC/F,MAAI,OAAO,OAAO,YAAY,OAAO,KAAM,QAAO;AAClD,QAAM,IAAI;AACV,MAAI,EAAE,SAAS,SAAU,QAAO,eAAe,EAAE,OAAO;AACxD,MAAI,EAAE,SAAS,SAAU,QAAO,OAAO,EAAE,OAAO;AAChD,SAAO,EAAE,SAAS;AACpB;AAEA,SAAS,aACP,IACwE;AACxE,MAAI,OAAO,OAAO,YAAY,OAAO,KAAM,QAAO;AAClD,QAAM,IAAK,GAA0B;AACrC,SAAO,MAAM,cAAc,MAAM;AACnC;AAEO,IAAM,UAAN,MAAc;AAAA,EACF;AAAA,EACA,QAAQ,oBAAI,IAAwB;AAAA,EACpC,QAAQ,oBAAI,IAAyB;AAAA;AAAA,EACrC,aAAa,oBAAI,IAA2B;AAAA;AAAA,EAC5C,sBAAsB,oBAAI,IAAY;AAAA,EACtC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,iBAAiB,oBAAI,IAAoB;AAAA,EACzC,kBAAkB,oBAAI,IAGrC;AAAA,EAEF,YAAY,UAA0B,CAAC,GAAG;AACxC,SAAK,UAAU,QAAQ,WAAW,IAAI,iBAAiB;AACvD,SAAK,aAAa,QAAQ,cAAc,mBAAmB;AAC3D,SAAK,SAAS,QAAQ,UAAU,IAAI,kBAAkB;AACtD,SAAK,YAAY,QAAQ;AACzB,SAAK,UAAU,QAAQ;AACvB,SAAK,eAAe,QAAQ,gBAAgB;AAC5C,SAAK,qBAAqB,QAAQ,sBAAsB;AACxD,SAAK,cAAc,KAAK,OAAO,UAAU,CAAC,YAAY,KAAK,SAAS,OAAO,CAAC;AAAA,EAC9E;AAAA,EAEA,cAAc,MAAwB;AACpC,SAAK,MAAM,IAAI,KAAK,IAAI,IAAI;AAC5B,QAAI,MAAM,KAAK,MAAM,IAAI,KAAK,IAAI;AAClC,QAAI,CAAC,KAAK;AACR,YAAM,oBAAI,IAAI;AACd,WAAK,MAAM,IAAI,KAAK,MAAM,GAAG;AAAA,IAC/B;AACA,QAAI,IAAI,KAAK,EAAE;AAAA,EACjB;AAAA,EAEA,iBAAiB,QAAsB;AACrC,UAAM,OAAO,KAAK,MAAM,IAAI,MAAM;AAClC,QAAI,CAAC,KAAM;AACX,SAAK,MAAM,OAAO,MAAM;AACxB,UAAM,OAAO,KAAK;AAClB,UAAM,cAAc,KAAK,oBAAoB,OAAO,MAAM;AAC1D,SAAK,eAAe,OAAO,MAAM;AACjC,UAAM,kBAAkB,KAAK,gBAAgB,IAAI,MAAM;AACvD,QAAI,gBAAiB,cAAa,gBAAgB,KAAK;AACvD,SAAK,gBAAgB,OAAO,MAAM;AAClC,UAAM,UAAU,KAAK,MAAM,IAAI,IAAI;AACnC,QAAI,SAAS;AACX,cAAQ,OAAO,MAAM;AACrB,UAAI,QAAQ,SAAS,GAAG;AACtB,aAAK,MAAM,OAAO,IAAI;AACtB,aAAK,WAAW,OAAO,IAAI;AAAA,MAC7B;AAAA,IACF;AACA,QAAI,YAAa,MAAK,eAAe,MAAM,KAAK,EAAE;AAAA,EACpD;AAAA,EAEA,YAAoB;AAClB,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA,EAEA,cAAc,QAAgB,SAAgC;AAC5D,UAAM,OAAO,KAAK,MAAM,IAAI,MAAM;AAClC,QAAI,CAAC,KAAM,QAAO,QAAQ,QAAQ;AAClC,QAAI,CAAC,mBAAmB,SAAS,KAAK,YAAY,EAAG,QAAO,QAAQ,QAAQ;AAC5E,UAAM,MAAM,cAAc,OAAO;AACjC,QAAI,CAAC,IAAK,QAAO,QAAQ,QAAQ;AACjC,QAAI,IAAI,GAAG,SAAS,YAAY;AAC9B,WAAK,iBAAiB,MAAM,IAAI,GAAG,IAAI;AACvC,aAAO,QAAQ,QAAQ;AAAA,IACzB;AACA,UAAM,OAAO,KAAK;AAGlB,UAAM,OAAO,KAAK,WAAW,IAAI,IAAI,KAAK,QAAQ,QAAQ;AAC1D,UAAM,OAAO,KACV,KAAK,MAAM,KAAK,QAAQ,MAAM,GAAG,CAAC,EAClC,MAAM,MAAM;AAAA,IAEb,CAAC;AACH,SAAK,WAAW,IAAI,MAAM,IAAI;AAC9B,WAAO;AAAA,EACT;AAAA,EAEA,MAAc,QAAQ,MAAkB,KAAkC;AACxE,UAAM,KAAK,IAAI;AACf,QAAI,GAAG,SAAS,oBAAoB;AAClC,YAAM,MAAO,MAAM,KAAK,QAAQ,SAAS,KAAK,IAAI;AAClD,YAAM,WAAW,KAAK,UAAU,IAAI,OAAO,CAAC,OAAO,KAAK,QAAQ,MAAM,GAAG,QAAQ,CAAC,IAAI;AACtF,WAAK;AAAA;AAAA;AAAA,QAGH,KAAK,UAAU,EAAE,MAAM,UAAU,IAAI,EAAE,MAAM,YAAY,IAAI,IAAI,MAAM,SAAS,EAAE,CAAC;AAAA,MACrF;AAAA,IACF,WAAW,GAAG,SAAS,YAAY,GAAG,SAAS,YAAY,GAAG,SAAS,SAAS;AAC9E,YAAM,KAAK,GAAG,SAAS,WAAW,GAAG,QAAQ,KAAK,GAAG,SAAS,WAAW,GAAG,KAAK;AACjF,YAAM,eAAe,KAAK,aAAa,KAAK,YAAY,OAAO;AAC/D,YAAM,UAAoC,cACtC,MAAM,KAAK,QAAQ,IAAI,KAAK,MAAM,EAAE,IACpC;AAEJ,UAAI,aAAqB;AACzB,UAAI,KAAK,WAAW;AAClB,cAAM,UAAU,MAAM,KAAK,UAAU;AAAA,UACnC,QAAQ,KAAK;AAAA,UACb,MAAM,KAAK;AAAA,UACX,MAAM,KAAK;AAAA,UACX;AAAA,UACA,gBAAgB;AAAA,QAClB,CAAC;AACD,YAAI,CAAC,SAAS;AACZ,gBAAM,KAAK,eAAe,MAAM,IAAI,MAAM,IAAI,OAAO;AACrD;AAAA,QACF;AACA,YAAI,GAAG,SAAS,UAAU;AACxB,gBAAM,UAAU,SAAS,WAAW,KAAK;AACzC,gBAAM,iBAA+B,EAAE,GAAG,GAAG,SAAS,QAAQ;AAC9D,uBAAa,EAAE,MAAM,UAAU,SAAS,eAAe;AAAA,QACzD;AAAA,MACF;AAEA,YAAM,KAAK,QAAQ,MAAM,KAAK,MAAM,UAAU;AAE9C,YAAM,cAAc,YAAY;AAChC,YAAM,eAAe,SAAS;AAE9B,WAAK,cAAc,KAAK,MAAM,KAAK,IAAI,KAAK,IAAI,YAAY,cAAc,WAAW;AAErF,WAAK,OAAO;AAAA,QACV,KAAK,UAAU;AAAA,UACb,GAAG,KAAK;AAAA,UACR,MAAM,KAAK;AAAA,UACX,MAAM,KAAK;AAAA,UACX,IAAI;AAAA,UACJ,MAAM;AAAA,UACN,SAAS;AAAA,QACX,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EAEF;AAAA,EAEQ,QAAQ,MAAkB,UAAuC;AACvE,QAAI,CAAC,KAAK,QAAS,QAAO;AAC1B,WAAO,KAAK,QAAQ,EAAE,QAAQ,KAAK,QAAQ,MAAM,KAAK,MAAM,MAAM,KAAK,MAAM,SAAS,CAAC;AAAA,EACzF;AAAA,EAEQ,YAAY,SAAuB;AACzC,QAAI;AACF,WAAK,OAAO,QAAQ,OAAO;AAAA,IAC7B,QAAQ;AAAA,IAER;AAAA,EACF;AAAA,EAEQ,YAAY,MAAc,WAA+B,SAAuB;AACtF,UAAM,UAAU,KAAK,MAAM,IAAI,IAAI;AACnC,QAAI,CAAC,QAAS;AACd,eAAW,OAAO,SAAS;AACzB,UAAI,QAAQ,UAAW;AACvB,YAAM,OAAO,KAAK,MAAM,IAAI,GAAG;AAC/B,UAAI,CAAC,KAAM;AACX,UAAI;AACF,aAAK,KAAK,OAAO;AAAA,MACnB,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,kBAAkB,MAAkB,MAAqB;AAC/D,SAAK,oBAAoB,IAAI,KAAK,EAAE;AACpC,UAAM,UAAU,KAAK,UAAU,EAAE,MAAM,KAAK,IAAI,IAAI,EAAE,MAAM,YAAY,KAAK,EAAE,CAAC;AAChF,SAAK,YAAY,KAAK,MAAM,KAAK,IAAI,OAAO;AAC5C,SAAK;AAAA,MACH,KAAK,UAAU;AAAA,QACb,GAAG,KAAK;AAAA,QACR,MAAM,KAAK;AAAA,QACX,MAAM,KAAK;AAAA,QACX,IAAI,EAAE,MAAM,YAAY,KAAK;AAAA,MAC/B,CAAC;AAAA,IACH;AAAA,EACF;AAAA,EAEQ,iBAAiB,MAAkB,MAAqB;AAC9D,QAAI,KAAK,sBAAsB,GAAG;AAChC,WAAK,kBAAkB,MAAM,IAAI;AACjC;AAAA,IACF;AACA,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,aAAa,KAAK,eAAe,IAAI,KAAK,EAAE;AAClD,QAAI,eAAe,UAAa,MAAM,cAAc,KAAK,oBAAoB;AAC3E,WAAK,eAAe,IAAI,KAAK,IAAI,GAAG;AACpC,WAAK,kBAAkB,MAAM,IAAI;AACjC;AAAA,IACF;AAEA,UAAM,WAAW,KAAK,gBAAgB,IAAI,KAAK,EAAE;AACjD,QAAI,UAAU;AACZ,eAAS,OAAO;AAChB;AAAA,IACF;AACA,UAAM,QAAQ;AAAA,MACZ,MAAM;AACJ,cAAM,UAAU,KAAK,gBAAgB,IAAI,KAAK,EAAE;AAChD,aAAK,gBAAgB,OAAO,KAAK,EAAE;AACnC,YAAI,CAAC,WAAW,CAAC,KAAK,MAAM,IAAI,KAAK,EAAE,EAAG;AAC1C,aAAK,eAAe,IAAI,KAAK,IAAI,KAAK,IAAI,CAAC;AAC3C,aAAK,kBAAkB,MAAM,QAAQ,IAAI;AAAA,MAC3C;AAAA,MACA,KAAK,sBAAsB,MAAM;AAAA,IACnC;AACA,SAAK,gBAAgB,IAAI,KAAK,IAAI,EAAE,MAAM,MAAM,CAAC;AAAA,EACnD;AAAA,EAEQ,eAAe,MAAc,MAAoB;AACvD,UAAM,UAAU,KAAK,UAAU,EAAE,MAAM,IAAI,EAAE,MAAM,iBAAiB,EAAE,CAAC;AACvE,SAAK,YAAY,MAAM,QAAW,OAAO;AACzC,SAAK;AAAA,MACH,KAAK,UAAU,EAAE,GAAG,KAAK,YAAY,MAAM,MAAM,IAAI,EAAE,MAAM,iBAAiB,EAAE,CAAC;AAAA,IACnF;AAAA,EACF;AAAA,EAEQ,cACN,MACA,WACA,MACA,IACA,cACA,aACM;AACN,UAAM,UAAU,KAAK,MAAM,IAAI,IAAI;AACnC,QAAI,CAAC,QAAS;AACd,UAAM,OAAO,CAAC,MAAkB,QAAsB;AACpD,UAAI;AACF,aAAK,KAAK,GAAG;AAAA,MACf,QAAQ;AAAA,MAER;AAAA,IACF;AACA,QAAI,GAAG,SAAS,UAAU;AACxB,YAAM,WAAY,GAAG,QAAyB;AAC9C,YAAM,YAAY,KAAK,UAAU,EAAE,MAAM,GAAG,CAAC;AAC7C,YAAM,YAAY,KAAK,UAAU;AAAA,QAC/B,MAAM;AAAA,QACN,IAAI,EAAE,MAAM,UAAU,IAAI,GAAG,QAAQ,GAAG;AAAA,MAC1C,CAAC;AACD,iBAAW,OAAO,SAAS;AACzB,YAAI,QAAQ,UAAW;AACvB,cAAM,OAAO,KAAK,MAAM,IAAI,GAAG;AAC/B,YAAI,CAAC,KAAM;AACX,YAAI,KAAK,QAAQ,MAAM,QAAQ,EAAG,MAAK,MAAM,SAAS;AAAA,iBAC7C,eAAe,KAAK,QAAQ,MAAM,YAAY,EAAG,MAAK,MAAM,SAAS;AAAA,MAChF;AAAA,IACF,WAAW,GAAG,SAAS,UAAU;AAC/B,YAAM,YAAY,KAAK,UAAU,EAAE,MAAM,GAAG,CAAC;AAC7C,iBAAW,OAAO,SAAS;AACzB,YAAI,QAAQ,UAAW;AACvB,cAAM,OAAO,KAAK,MAAM,IAAI,GAAG;AAC/B,YAAI,CAAC,KAAM;AAGX,cAAM,aAAa,CAAC,KAAK,WAAY,eAAe,KAAK,QAAQ,MAAM,YAAY;AACnF,YAAI,WAAY,MAAK,MAAM,SAAS;AAAA,MACtC;AAAA,IACF,WAAW,GAAG,SAAS,SAAS;AAC9B,YAAM,WAAW,KAAK,UAAU,EAAE,MAAM,GAAG,CAAC;AAC5C,iBAAW,OAAO,SAAS;AACzB,YAAI,QAAQ,UAAW;AACvB,cAAM,OAAO,KAAK,MAAM,IAAI,GAAG;AAC/B,YAAI,KAAM,MAAK,MAAM,QAAQ;AAAA,MAC/B;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAc,eACZ,MACA,MACA,IACA,SACe;AACf,QAAI;AACJ,QAAI,GAAG,SAAS,UAAU;AACxB,mBAAa,UACT,EAAE,MAAM,UAAU,SAAS,QAAQ,IACnC,EAAE,MAAM,UAAU,IAAI,GAAG,QAAQ,GAAG;AAAA,IAC1C,WAAW,GAAG,SAAS,UAAU;AAC/B,mBAAa,UAAU,EAAE,MAAM,UAAU,SAAS,QAAQ,IAAI;AAAA,IAChE,WAAW,GAAG,SAAS,SAAS;AAC9B,YAAM,WAAW,MAAM,KAAK,QAAQ,SAAS,KAAK,IAAI;AACtD,mBAAa,EAAE,MAAM,YAAY,IAAI,MAAM,SAAS;AAAA,IACtD;AACA,QAAI,WAAY,MAAK,KAAK,KAAK,UAAU,EAAE,MAAM,UAAU,IAAI,WAAW,CAAC,CAAC;AAAA,EAC9E;AAAA,EAEQ,SAAS,SAAuB;AAGtC,QAAI;AAQJ,QAAI;AACF,YAAM,KAAK,MAAM,OAAO;AAAA,IAC1B,QAAQ;AACN;AAAA,IACF;AACA,QAAI,OAAO,IAAI,MAAM,YAAY,OAAO,IAAI,SAAS,YAAY,OAAO,IAAI,SAAS;AACnF;AACF,QAAI,IAAI,MAAM,KAAK,WAAY;AAC/B,UAAM,KAAK,IAAI;AACf,QAAI,aAAa,EAAE,GAAG;AAGpB,WAAK,YAAY,IAAI,MAAM,QAAW,KAAK,UAAU,EAAE,MAAM,IAAI,MAAM,GAAG,CAAC,CAAC;AAC5E;AAAA,IACF;AACA,QAAI,CAAC,WAAW,EAAE,EAAG;AACrB,UAAM,eAAe,OAAO,IAAI,SAAS,WAAW,IAAI,OAAO;AAC/D,UAAM,cAAc,IAAI,YAAY;AACpC,SAAK,cAAc,IAAI,MAAM,QAAW,IAAI,MAAM,IAAI,cAAc,WAAW;AAAA,EACjF;AAAA,EAEA,QAAc;AACZ,eAAW,WAAW,KAAK,gBAAgB,OAAO,EAAG,cAAa,QAAQ,KAAK;AAC/E,SAAK,gBAAgB,MAAM;AAC3B,SAAK,YAAY;AAAA,EACnB;AACF;;;AIrYA,SAAS,uBAAqC;;;ACavC,SAAS,eAAe,KAAsB,YAA+B;AAClF,MAAI,cAAc,GAAG;AACnB,WAAO;AAAA,MACL,OAAO,MAAM;AAAA,MAEb;AAAA,MACA,MAAM,MAAM;AAAA,MAEZ;AAAA,IACF;AAAA,EACF;AACA,QAAM,QAAQ,oBAAI,QAAkC;AACpD,QAAM,QAAQ,CAAC,OAA8B;AAC3C,UAAM,IAAI,IAAI,IAAI;AAClB,OAAG,GAAG,QAAQ,MAAM,MAAM,IAAI,IAAI,IAAI,CAAC;AAAA,EACzC;AACA,QAAM,WAAW,YAAY,MAAM;AACjC,eAAW,MAAM,IAAI,SAAS;AAC5B,UAAI;AACF,YAAI,MAAM,IAAI,EAAE,MAAM,OAAO;AAC3B,aAAG,UAAU;AACb;AAAA,QACF;AACA,cAAM,IAAI,IAAI,KAAK;AACnB,WAAG,KAAK;AAAA,MACV,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF,GAAG,UAAU;AACb,SAAO,EAAE,OAAO,MAAM,MAAM,cAAc,QAAQ,EAAE;AACtD;;;AC5CO,IAAM,4BAA4B;AAalC,SAAS,qBAAqB,KAAqB,SAAgC;AACxF,SAAO,IAAI,QAAQ,CAAC,YAAY;AAC9B,QAAI,UAAU;AACd,UAAM,SAAS,MAAY;AACzB,UAAI,QAAS;AACb,gBAAU;AACV,mBAAa,UAAU;AACvB,cAAQ;AAAA,IACV;AACA,UAAM,aAAa,WAAW,MAAM;AAClC,iBAAW,MAAM,IAAI,SAAS;AAC5B,YAAI;AACF,aAAG,UAAU;AAAA,QACf,QAAQ;AAAA,QAER;AAAA,MACF;AACA,aAAO;AAAA,IACT,GAAG,OAAO;AAGV,QAAI,MAAM,MAAM,OAAO,CAAC;AACxB,eAAW,MAAM,IAAI,SAAS;AAC5B,UAAI;AACF,WAAG,MAAM,MAAM,sBAAsB;AAAA,MACvC,QAAQ;AACN,YAAI;AACF,aAAG,UAAU;AAAA,QACf,QAAQ;AAAA,QAER;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AACH;;;AFPA,SAAS,kBAAkB,MAAuB;AAChD,MAAI,MAAM,QAAQ,IAAI,EAAG,QAAO,KAAK,OAAO,CAAC,OAAO,UAAU,QAAQ,MAAM,YAAY,CAAC;AACzF,SAAO,KAAK;AACd;AAEO,SAAS,iBAAiB,UAAmC,CAAC,GAInE;AACA,QAAM,kBAAkB,QAAQ,mBAAmB;AACnD,MAAI,CAAC,OAAO,SAAS,eAAe,KAAK,kBAAkB,GAAG;AAC5D,UAAM,IAAI,WAAW,sDAAsD;AAAA,EAC7E;AACA,QAAM,MAAM,IAAI,QAAQ;AAAA,IACtB,SAAS,QAAQ;AAAA,IACjB,QAAQ,QAAQ;AAAA,IAChB,YAAY,QAAQ;AAAA,IACpB,WAAW,QAAQ;AAAA,IACnB,SAAS,QAAQ;AAAA,IACjB,cAAc,QAAQ,gBAAgB;AAAA,IACtC,oBAAoB,QAAQ,sBAAsB;AAAA,EACpD,CAAC;AACD,QAAM,kBAAkB,QAAQ,mBAAmB;AACnD,QAAM,MAAM,QAAQ,SAChB,IAAI,gBAAgB,EAAE,QAAQ,QAAQ,QAAQ,YAAY,gBAAgB,CAAC,IAC3E,IAAI,gBAAgB,EAAE,MAAM,QAAQ,QAAQ,GAAG,YAAY,gBAAgB,CAAC;AAChF,QAAM,YAAY,eAAe,KAAK,QAAQ,uBAAuB,GAAK;AAC1E,MAAI,eAAe;AACnB,MAAI;AACJ,MAAI,UAAU;AACd,MAAI,GAAG,cAAc,CAAC,IAAI,QAAQ;AAChC,QAAI,cAAc;AAChB,SAAG,MAAM,MAAM,sBAAsB;AACrC;AAAA,IACF;AACA,cAAU,MAAM,EAAE;AAGlB,OAAG,GAAG,SAAS,MAAM,MAAS;AAC9B,UAAM,MAAM,IAAI,IAAI,IAAI,OAAO,IAAI,kBAAkB;AACrD,UAAM,OAAO,IAAI,aAAa,IAAI,MAAM;AACxC,QAAI,CAAC,MAAM;AACT,SAAG,MAAM,MAAM,eAAe;AAC9B;AAAA,IACF;AACA,UAAM,SAAS,IAAI,EAAE,OAAO,IAAI,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC;AAEtE,QAAI,QAA0C;AAC9C,QAAI,SAAS;AACb,QAAI,WAAW;AACf,UAAM,QAAkB,CAAC;AACzB,QAAI,cAAc;AAClB,UAAM,yBACJ,QAAQ,0BAA0B;AACpC,UAAM,sBAAsB,QAAQ,uBAAuB;AAC3D,UAAM,UAAU,IAAI;AAAA,MAClB,QAAQ,qBAAqB;AAAA,MAC7B,QAAQ,gBAAgB;AAAA,IAC1B;AAEA,UAAM,OAAO,CAAC,MAAc;AAC1B,UAAI;AACF,WAAG,KAAK,CAAC;AAAA,MACX,QAAQ;AAAA,MAER;AAAA,IACF;AAEA,OAAG,GAAG,WAAW,CAAC,SAAS;AACzB,UAAI,UAAU,WAAY;AAC1B,YAAM,eAAe,kBAAkB,IAAI;AAC3C,UAAI,eAAe,iBAAiB;AAClC,gBAAQ;AACR,WAAG,MAAM,MAAM,mBAAmB;AAClC;AAAA,MACF;AACA,UAAI,CAAC,QAAQ,KAAK,GAAG;AACnB,gBAAQ;AACR,WAAG,MAAM,MAAM,qBAAqB;AACpC;AAAA,MACF;AACA,YAAM,MAAM,OAAO,IAAI;AACvB,UAAI,UAAU,WAAW;AACvB,YACE,MAAM,UAAU,0BAChB,cAAc,eAAe,qBAC7B;AACA,kBAAQ;AACR,aAAG,MAAM,MAAM,qCAAqC;AACpD;AAAA,QACF;AACA,cAAM,KAAK,GAAG;AACd,uBAAe;AACf;AAAA,MACF;AACA,WAAK,IAAI,cAAc,QAAQ,GAAG,EAAE,MAAM,CAAC,QAAQ,QAAQ,MAAM,iBAAiB,GAAG,CAAC;AAAA,IACxF,CAAC;AACD,OAAG,GAAG,SAAS,MAAM;AACnB,eAAS;AACT,UAAI,SAAU,KAAI,iBAAiB,MAAM;AAAA,IAC3C,CAAC;AAED,YAAQ,QAAQ,QAAQ,eAAe,QAAQ,aAAa,EAAE,KAAK,KAAK,CAAC,IAAI,EAAE,QAAQ,OAAO,CAAC,EAC5F,KAAK,CAAC,WAAW;AAChB,UAAI,UAAU,UAAU,cAAc,aAAc;AACpD,UAAI,CAAC,QAAQ;AACX,gBAAQ;AACR,WAAG,MAAM,MAAM,cAAc;AAC7B;AAAA,MACF;AACA,cAAQ;AACR,iBAAW;AACX,UAAI,cAAc,EAAE,IAAI,QAAQ,MAAM,QAAQ,OAAO,QAAQ,MAAM,OAAO,MAAM,KAAK,CAAC;AACtF,iBAAW,KAAK,OAAO;AACrB,aAAK,IAAI,cAAc,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,QAAQ,MAAM,iBAAiB,GAAG,CAAC;AAAA,MACtF;AACA,YAAM,SAAS;AACf,oBAAc;AAAA,IAChB,CAAC,EACA,MAAM,MAAM;AACX,UAAI,CAAC,UAAU,CAAC,cAAc;AAC5B,gBAAQ;AACR,WAAG,MAAM,MAAM,cAAc;AAAA,MAC/B;AAAA,IACF,CAAC;AAAA,EACL,CAAC;AACD,QAAM,QAAQ,MAAqB;AACjC,QAAI,aAAc,QAAO;AACzB,mBAAe;AACf,mBAAe,qBAAqB,KAAK,eAAe,EAAE,KAAK,MAAM;AACnE,gBAAU,KAAK;AACf,UAAI,MAAM;AAAA,IACZ,CAAC;AACD,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fieldnotes/sync-server",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Authoritative WebSocket relay server for Field Notes real-time sync",
5
5
  "type": "module",
6
6
  "sideEffects": false,