@ifc-lite/collab-server 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +47 -0
  3. package/dist/audit-log.d.ts +77 -0
  4. package/dist/audit-log.d.ts.map +1 -0
  5. package/dist/audit-log.js +105 -0
  6. package/dist/audit-log.js.map +1 -0
  7. package/dist/auth.d.ts +24 -0
  8. package/dist/auth.d.ts.map +1 -0
  9. package/dist/auth.js +15 -0
  10. package/dist/auth.js.map +1 -0
  11. package/dist/bin.d.ts +3 -0
  12. package/dist/bin.d.ts.map +1 -0
  13. package/dist/bin.js +34 -0
  14. package/dist/bin.js.map +1 -0
  15. package/dist/blob-route.d.ts +58 -0
  16. package/dist/blob-route.d.ts.map +1 -0
  17. package/dist/blob-route.js +132 -0
  18. package/dist/blob-route.js.map +1 -0
  19. package/dist/index.d.ts +18 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +19 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/metrics.d.ts +56 -0
  24. package/dist/metrics.d.ts.map +1 -0
  25. package/dist/metrics.js +161 -0
  26. package/dist/metrics.js.map +1 -0
  27. package/dist/path-locks.d.ts +39 -0
  28. package/dist/path-locks.d.ts.map +1 -0
  29. package/dist/path-locks.js +176 -0
  30. package/dist/path-locks.js.map +1 -0
  31. package/dist/persistence-redis.d.ts +52 -0
  32. package/dist/persistence-redis.d.ts.map +1 -0
  33. package/dist/persistence-redis.js +50 -0
  34. package/dist/persistence-redis.js.map +1 -0
  35. package/dist/persistence-s3.d.ts +81 -0
  36. package/dist/persistence-s3.d.ts.map +1 -0
  37. package/dist/persistence-s3.js +183 -0
  38. package/dist/persistence-s3.js.map +1 -0
  39. package/dist/persistence.d.ts +41 -0
  40. package/dist/persistence.d.ts.map +1 -0
  41. package/dist/persistence.js +108 -0
  42. package/dist/persistence.js.map +1 -0
  43. package/dist/rate-limit.d.ts +29 -0
  44. package/dist/rate-limit.d.ts.map +1 -0
  45. package/dist/rate-limit.js +36 -0
  46. package/dist/rate-limit.js.map +1 -0
  47. package/dist/replay-protect.d.ts +51 -0
  48. package/dist/replay-protect.d.ts.map +1 -0
  49. package/dist/replay-protect.js +134 -0
  50. package/dist/replay-protect.js.map +1 -0
  51. package/dist/retention.d.ts +32 -0
  52. package/dist/retention.d.ts.map +1 -0
  53. package/dist/retention.js +112 -0
  54. package/dist/retention.js.map +1 -0
  55. package/dist/room-manager.d.ts +138 -0
  56. package/dist/room-manager.d.ts.map +1 -0
  57. package/dist/room-manager.js +380 -0
  58. package/dist/room-manager.js.map +1 -0
  59. package/dist/secure-bundle.d.ts +15 -0
  60. package/dist/secure-bundle.d.ts.map +1 -0
  61. package/dist/secure-bundle.js +49 -0
  62. package/dist/secure-bundle.js.map +1 -0
  63. package/dist/secure-server.d.ts +49 -0
  64. package/dist/secure-server.d.ts.map +1 -0
  65. package/dist/secure-server.js +86 -0
  66. package/dist/secure-server.js.map +1 -0
  67. package/dist/server.d.ts +59 -0
  68. package/dist/server.d.ts.map +1 -0
  69. package/dist/server.js +196 -0
  70. package/dist/server.js.map +1 -0
  71. package/dist/snapshot-worker.d.ts +32 -0
  72. package/dist/snapshot-worker.d.ts.map +1 -0
  73. package/dist/snapshot-worker.js +87 -0
  74. package/dist/snapshot-worker.js.map +1 -0
  75. package/package.json +66 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * TLS termination helpers (spec §14, v0.5).
3
+ *
4
+ * In production we recommend terminating TLS at a reverse proxy
5
+ * (nginx, Caddy, ALB, Cloudflare, etc.). For deployments that need
6
+ * TLS in-process — single-binary appliance, edge worker, dev with a
7
+ * self-signed cert — this module ships:
8
+ *
9
+ * - `createSecureHttpServer(opts)` that wraps `node:https`'s
10
+ * `createServer` with strong defaults (TLS 1.2+ minVersion,
11
+ * conservative cipher list, ALPN for HTTP/1.1).
12
+ *
13
+ * - `applySecurityHeaders(res)` that sets the OWASP-recommended
14
+ * baseline response headers (HSTS, no-sniff, frame deny). Drop
15
+ * this into the http handler before writing any response.
16
+ *
17
+ * - `secureHttpHandler(inner)` wrapper that applies the headers and
18
+ * defends against the classic TRACE method.
19
+ *
20
+ * Apps that already terminate TLS upstream can ignore this module —
21
+ * `startCollabServer` accepts a `server` option so they can pass any
22
+ * pre-built `http.Server` (or `https.Server`).
23
+ */
24
+ import * as https from 'node:https';
25
+ import type * as http from 'node:http';
26
+ export interface SecureHttpServerOptions {
27
+ /** Path to the TLS certificate file (PEM). */
28
+ certPath: string;
29
+ /** Path to the TLS private key file (PEM). */
30
+ keyPath: string;
31
+ /** Optional CA bundle for client certificate verification. */
32
+ caPath?: string;
33
+ /** Reject TLS below this version. Default `'TLSv1.2'`. */
34
+ minVersion?: 'TLSv1.2' | 'TLSv1.3';
35
+ /** Override cipher list. Default modern AES-GCM / ChaCha set. */
36
+ ciphers?: string;
37
+ /** Optional request handler. */
38
+ requestListener?: http.RequestListener;
39
+ }
40
+ /** Build a hardened `https.Server`. */
41
+ export declare function createSecureHttpServer(opts: SecureHttpServerOptions): https.Server;
42
+ /**
43
+ * Apply OWASP-baseline security headers to a response. Idempotent —
44
+ * if headers are already set, they're left alone.
45
+ */
46
+ export declare function applySecurityHeaders(res: http.ServerResponse): void;
47
+ /** Wrap a request handler with security-header + TRACE-defence. */
48
+ export declare function secureHttpHandler(inner: http.RequestListener): http.RequestListener;
49
+ //# sourceMappingURL=secure-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secure-server.d.ts","sourceRoot":"","sources":["../src/secure-server.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,KAAK,MAAM,YAAY,CAAC;AAEpC,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AAEvC,MAAM,WAAW,uBAAuB;IACtC,8CAA8C;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,OAAO,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,UAAU,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IACnC,iEAAiE;IACjE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gCAAgC;IAChC,eAAe,CAAC,EAAE,IAAI,CAAC,eAAe,CAAC;CACxC;AAcD,uCAAuC;AACvC,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,uBAAuB,GAAG,KAAK,CAAC,MAAM,CAWlF;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,IAAI,CAAC,cAAc,GAAG,IAAI,CAenE;AAED,mEAAmE;AACnE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,IAAI,CAAC,eAAe,GAAG,IAAI,CAAC,eAAe,CAUnF"}
@@ -0,0 +1,86 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+ /**
5
+ * TLS termination helpers (spec §14, v0.5).
6
+ *
7
+ * In production we recommend terminating TLS at a reverse proxy
8
+ * (nginx, Caddy, ALB, Cloudflare, etc.). For deployments that need
9
+ * TLS in-process — single-binary appliance, edge worker, dev with a
10
+ * self-signed cert — this module ships:
11
+ *
12
+ * - `createSecureHttpServer(opts)` that wraps `node:https`'s
13
+ * `createServer` with strong defaults (TLS 1.2+ minVersion,
14
+ * conservative cipher list, ALPN for HTTP/1.1).
15
+ *
16
+ * - `applySecurityHeaders(res)` that sets the OWASP-recommended
17
+ * baseline response headers (HSTS, no-sniff, frame deny). Drop
18
+ * this into the http handler before writing any response.
19
+ *
20
+ * - `secureHttpHandler(inner)` wrapper that applies the headers and
21
+ * defends against the classic TRACE method.
22
+ *
23
+ * Apps that already terminate TLS upstream can ignore this module —
24
+ * `startCollabServer` accepts a `server` option so they can pass any
25
+ * pre-built `http.Server` (or `https.Server`).
26
+ */
27
+ import * as https from 'node:https';
28
+ import * as fs from 'node:fs';
29
+ const DEFAULT_CIPHERS = [
30
+ 'TLS_AES_128_GCM_SHA256',
31
+ 'TLS_AES_256_GCM_SHA384',
32
+ 'TLS_CHACHA20_POLY1305_SHA256',
33
+ 'ECDHE-ECDSA-AES128-GCM-SHA256',
34
+ 'ECDHE-RSA-AES128-GCM-SHA256',
35
+ 'ECDHE-ECDSA-AES256-GCM-SHA384',
36
+ 'ECDHE-RSA-AES256-GCM-SHA384',
37
+ 'ECDHE-ECDSA-CHACHA20-POLY1305',
38
+ 'ECDHE-RSA-CHACHA20-POLY1305',
39
+ ].join(':');
40
+ /** Build a hardened `https.Server`. */
41
+ export function createSecureHttpServer(opts) {
42
+ const tlsOpts = {
43
+ cert: fs.readFileSync(opts.certPath),
44
+ key: fs.readFileSync(opts.keyPath),
45
+ minVersion: opts.minVersion ?? 'TLSv1.2',
46
+ ciphers: opts.ciphers ?? DEFAULT_CIPHERS,
47
+ honorCipherOrder: true,
48
+ ALPNProtocols: ['http/1.1'],
49
+ };
50
+ if (opts.caPath)
51
+ tlsOpts.ca = fs.readFileSync(opts.caPath);
52
+ return https.createServer(tlsOpts, opts.requestListener);
53
+ }
54
+ /**
55
+ * Apply OWASP-baseline security headers to a response. Idempotent —
56
+ * if headers are already set, they're left alone.
57
+ */
58
+ export function applySecurityHeaders(res) {
59
+ if (!res.headersSent) {
60
+ if (!res.hasHeader('strict-transport-security')) {
61
+ res.setHeader('strict-transport-security', 'max-age=31536000; includeSubDomains');
62
+ }
63
+ if (!res.hasHeader('x-content-type-options')) {
64
+ res.setHeader('x-content-type-options', 'nosniff');
65
+ }
66
+ if (!res.hasHeader('x-frame-options')) {
67
+ res.setHeader('x-frame-options', 'DENY');
68
+ }
69
+ if (!res.hasHeader('referrer-policy')) {
70
+ res.setHeader('referrer-policy', 'no-referrer');
71
+ }
72
+ }
73
+ }
74
+ /** Wrap a request handler with security-header + TRACE-defence. */
75
+ export function secureHttpHandler(inner) {
76
+ return (req, res) => {
77
+ if (req.method === 'TRACE' || req.method === 'TRACK') {
78
+ res.writeHead(405);
79
+ res.end();
80
+ return;
81
+ }
82
+ applySecurityHeaders(res);
83
+ inner(req, res);
84
+ };
85
+ }
86
+ //# sourceMappingURL=secure-server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secure-server.js","sourceRoot":"","sources":["../src/secure-server.ts"],"names":[],"mappings":"AAAA;;+DAE+D;AAE/D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,KAAK,MAAM,YAAY,CAAC;AACpC,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAkB9B,MAAM,eAAe,GAAG;IACtB,wBAAwB;IACxB,wBAAwB;IACxB,8BAA8B;IAC9B,+BAA+B;IAC/B,6BAA6B;IAC7B,+BAA+B;IAC/B,6BAA6B;IAC7B,+BAA+B;IAC/B,6BAA6B;CAC9B,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAEZ,uCAAuC;AACvC,MAAM,UAAU,sBAAsB,CAAC,IAA6B;IAClE,MAAM,OAAO,GAAwB;QACnC,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,QAAQ,CAAC;QACpC,GAAG,EAAE,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC;QAClC,UAAU,EAAE,IAAI,CAAC,UAAU,IAAI,SAAS;QACxC,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,eAAe;QACxC,gBAAgB,EAAE,IAAI;QACtB,aAAa,EAAE,CAAC,UAAU,CAAC;KAC5B,CAAC;IACF,IAAI,IAAI,CAAC,MAAM;QAAE,OAAO,CAAC,EAAE,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC3D,OAAO,KAAK,CAAC,YAAY,CAAC,OAAO,EAAE,IAAI,CAAC,eAAe,CAAC,CAAC;AAC3D,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,GAAwB;IAC3D,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC;QACrB,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,2BAA2B,CAAC,EAAE,CAAC;YAChD,GAAG,CAAC,SAAS,CAAC,2BAA2B,EAAE,qCAAqC,CAAC,CAAC;QACpF,CAAC;QACD,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,wBAAwB,CAAC,EAAE,CAAC;YAC7C,GAAG,CAAC,SAAS,CAAC,wBAAwB,EAAE,SAAS,CAAC,CAAC;QACrD,CAAC;QACD,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,iBAAiB,CAAC,EAAE,CAAC;YACtC,GAAG,CAAC,SAAS,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;QAC3C,CAAC;QACD,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,iBAAiB,CAAC,EAAE,CAAC;YACtC,GAAG,CAAC,SAAS,CAAC,iBAAiB,EAAE,aAAa,CAAC,CAAC;QAClD,CAAC;IACH,CAAC;AACH,CAAC;AAED,mEAAmE;AACnE,MAAM,UAAU,iBAAiB,CAAC,KAA2B;IAC3D,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;QAClB,IAAI,GAAG,CAAC,MAAM,KAAK,OAAO,IAAI,GAAG,CAAC,MAAM,KAAK,OAAO,EAAE,CAAC;YACrD,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;YACnB,GAAG,CAAC,GAAG,EAAE,CAAC;YACV,OAAO;QACT,CAAC;QACD,oBAAoB,CAAC,GAAG,CAAC,CAAC;QAC1B,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAClB,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Websocket sync server entry point.
3
+ */
4
+ import * as http from 'node:http';
5
+ import { WebSocketServer } from 'ws';
6
+ import { RoomManager } from './room-manager.js';
7
+ import { type Persistence } from './persistence.js';
8
+ import { type AuthenticateFn, type Principal } from './auth.js';
9
+ import { type AuditSink } from './audit-log.js';
10
+ import { type RateLimitOptions } from './rate-limit.js';
11
+ import { type VerifyMessageFn } from './room-manager.js';
12
+ import { type ServerBlobStorage } from './blob-route.js';
13
+ import { MetricsRegistry } from './metrics.js';
14
+ export interface StartCollabServerOptions {
15
+ port?: number;
16
+ host?: string;
17
+ persistence?: Persistence;
18
+ authenticate?: AuthenticateFn;
19
+ maxRooms?: number;
20
+ compactEvery?: number;
21
+ /** Pre-built http server to attach to instead of creating one. */
22
+ server?: http.Server;
23
+ /** Append-only audit sink. Default: drop all events. */
24
+ auditSink?: AuditSink;
25
+ /** Per-peer rate limit. Function form lets you tune by role/user. */
26
+ rateLimit?: RateLimitOptions | ((principal: Principal) => RateLimitOptions);
27
+ /**
28
+ * Pluggable blob storage for the `/blobs/...` route. Default:
29
+ * in-memory. Pass a custom `ServerBlobStorage` to back with S3 or
30
+ * filesystem in production.
31
+ */
32
+ blobStorage?: ServerBlobStorage;
33
+ /** Reject blob PUTs above this size (default 100 MB). */
34
+ blobMaxBytes?: number;
35
+ /**
36
+ * Unload rooms that have had zero peers for this many ms (default
37
+ * disabled). Persistence keeps the durable copy; rehydrate on next
38
+ * connect.
39
+ */
40
+ idleUnloadMs?: number;
41
+ /** Metrics registry to publish at `/metrics`. Defaults to the package singleton. */
42
+ metrics?: MetricsRegistry;
43
+ /**
44
+ * Optional per-message verifier (anti-replay HMAC etc.). Runs before
45
+ * rate limit / role check. Returning `{ ok: false }` audits as
46
+ * `reject` with the reason and drops the message.
47
+ */
48
+ verifyMessage?: VerifyMessageFn;
49
+ }
50
+ export interface CollabServerHandle {
51
+ readonly url: string;
52
+ readonly httpServer: http.Server;
53
+ readonly wss: WebSocketServer;
54
+ readonly roomManager: RoomManager;
55
+ stop(): Promise<void>;
56
+ }
57
+ export declare function startCollabServer(opts?: StartCollabServerOptions): Promise<CollabServerHandle>;
58
+ export { FilePersistence, MemoryPersistence } from './persistence.js';
59
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAIA;;GAEG;AAEH,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,EAAE,eAAe,EAAkB,MAAM,IAAI,CAAC;AACrD,OAAO,EAAE,WAAW,EAAuB,MAAM,mBAAmB,CAAC;AACrE,OAAO,EAAsC,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACxF,OAAO,EAAwB,KAAK,cAAc,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AACtF,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxD,OAAO,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAGL,KAAK,iBAAiB,EACvB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAkB,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/D,MAAM,WAAW,wBAAwB;IACvC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,YAAY,CAAC,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kEAAkE;IAClE,MAAM,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC;IACrB,wDAAwD;IACxD,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,qEAAqE;IACrE,SAAS,CAAC,EAAE,gBAAgB,GAAG,CAAC,CAAC,SAAS,EAAE,SAAS,KAAK,gBAAgB,CAAC,CAAC;IAC5E;;;;OAIG;IACH,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC,yDAAyD;IACzD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oFAAoF;IACpF,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B;;;;OAIG;IACH,aAAa,CAAC,EAAE,eAAe,CAAC;CACjC;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,UAAU,EAAE,IAAI,CAAC,MAAM,CAAC;IACjC,QAAQ,CAAC,GAAG,EAAE,eAAe,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC;IAClC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAID,wBAAsB,iBAAiB,CACrC,IAAI,GAAE,wBAA6B,GAClC,OAAO,CAAC,kBAAkB,CAAC,CAmI7B;AAsED,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC"}
package/dist/server.js ADDED
@@ -0,0 +1,196 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+ /**
5
+ * Websocket sync server entry point.
6
+ */
7
+ import * as http from 'node:http';
8
+ import { WebSocketServer } from 'ws';
9
+ import { RoomManager } from './room-manager.js';
10
+ import { MemoryPersistence } from './persistence.js';
11
+ import { allowAnonymousEditor } from './auth.js';
12
+ import { handleBlobRequest, InMemoryBlobStorage, } from './blob-route.js';
13
+ import { defaultMetrics } from './metrics.js';
14
+ const PING_INTERVAL_MS = 30_000;
15
+ export async function startCollabServer(opts = {}) {
16
+ const persistence = opts.persistence ?? new MemoryPersistence();
17
+ const authenticate = opts.authenticate ?? allowAnonymousEditor;
18
+ const roomManager = new RoomManager({
19
+ persistence,
20
+ maxRooms: opts.maxRooms,
21
+ compactEvery: opts.compactEvery,
22
+ auditSink: opts.auditSink,
23
+ rateLimit: opts.rateLimit,
24
+ idleUnloadMs: opts.idleUnloadMs,
25
+ verifyMessage: opts.verifyMessage,
26
+ });
27
+ const blobStorage = opts.blobStorage ?? new InMemoryBlobStorage();
28
+ const metrics = opts.metrics ?? defaultMetrics;
29
+ const peersGauge = metrics.gauge('collab_room_peers', 'Currently connected peers per room');
30
+ const roomsGauge = metrics.gauge('collab_rooms', 'Currently loaded rooms');
31
+ const updatesCounter = metrics.counter('collab_updates_total', 'Y updates accepted by the server');
32
+ const rejectsCounter = metrics.counter('collab_rejects_total', 'Update messages rejected (rate-limit / role / replay)');
33
+ const httpServer = opts.server ??
34
+ http.createServer(async (req, res) => {
35
+ try {
36
+ if (req.url === '/healthz') {
37
+ res.writeHead(200, { 'content-type': 'application/json' });
38
+ res.end(JSON.stringify({ ok: true, rooms: roomManager.list().length }));
39
+ return;
40
+ }
41
+ if (req.url === '/metrics') {
42
+ // Refresh derived gauges before rendering so the snapshot
43
+ // reflects live state, not state-at-last-event.
44
+ const stats = await roomManager.stats();
45
+ roomsGauge.set(stats.length);
46
+ for (const s of stats)
47
+ peersGauge.set(s.peerCount, { room: s.roomId });
48
+ res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4' });
49
+ res.end(metrics.render());
50
+ return;
51
+ }
52
+ // Blob route: PUT / GET / HEAD / DELETE on /blobs/<hash>, GET /blobs.
53
+ if (req.url && req.url.startsWith('/blobs')) {
54
+ const handled = await handleBlobRequest(req, res, {
55
+ storage: blobStorage,
56
+ maxBytes: opts.blobMaxBytes,
57
+ });
58
+ if (handled)
59
+ return;
60
+ }
61
+ res.writeHead(404);
62
+ res.end();
63
+ }
64
+ catch (err) {
65
+ // eslint-disable-next-line no-console
66
+ console.error('[collab-server] http handler error:', err);
67
+ if (!res.headersSent) {
68
+ res.writeHead(500);
69
+ res.end();
70
+ }
71
+ }
72
+ });
73
+ // Surface the counters on the manager so the room can bump them in
74
+ // its update / reject paths. Done as opaque setters to keep
75
+ // RoomManager from importing the metrics module directly.
76
+ roomManager.setCounters({
77
+ update: () => updatesCounter.inc(),
78
+ reject: (reason) => rejectsCounter.inc(1, { reason }),
79
+ });
80
+ const wss = new WebSocketServer({ noServer: true });
81
+ httpServer.on('upgrade', (req, socket, head) => {
82
+ wss.handleUpgrade(req, socket, head, (ws) => {
83
+ // handleConnection runs async setup (room load, auth callback,
84
+ // persistence open). If it rejects — e.g. RoomManager.getOrCreate
85
+ // throws on room-cap or persistence failure — we previously
86
+ // discarded the promise with `void`, leaving the socket open and
87
+ // surfacing as a process-level unhandledRejection. Catch the error,
88
+ // log it, and close the socket with a non-1000 code so the client
89
+ // sees a deterministic shutdown.
90
+ handleConnection(ws, req, { roomManager, authenticate }).catch((err) => {
91
+ // eslint-disable-next-line no-console
92
+ console.error('[collab-server] connection setup failed:', err);
93
+ try {
94
+ if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) {
95
+ // 1011 = server error per RFC 6455
96
+ ws.close(1011, 'connection setup failed');
97
+ }
98
+ }
99
+ catch {
100
+ // ignore close errors
101
+ }
102
+ });
103
+ });
104
+ });
105
+ if (!opts.server) {
106
+ await new Promise((resolve, reject) => {
107
+ httpServer.once('error', reject);
108
+ httpServer.listen(opts.port ?? 1234, opts.host ?? '0.0.0.0', () => {
109
+ httpServer.off('error', reject);
110
+ resolve();
111
+ });
112
+ });
113
+ }
114
+ const address = httpServer.address();
115
+ const url = typeof address === 'object' && address
116
+ ? `ws://${opts.host ?? '127.0.0.1'}:${address.port}`
117
+ : `ws://${opts.host ?? '127.0.0.1'}:${opts.port ?? 1234}`;
118
+ return {
119
+ url,
120
+ httpServer,
121
+ wss,
122
+ roomManager,
123
+ async stop() {
124
+ await new Promise((resolve) => wss.close(() => resolve()));
125
+ if (!opts.server) {
126
+ await new Promise((resolve) => httpServer.close(() => resolve()));
127
+ }
128
+ await roomManager.unloadAll();
129
+ },
130
+ };
131
+ }
132
+ async function handleConnection(ws, req, ctx) {
133
+ ws.binaryType = 'arraybuffer';
134
+ const url = new URL(req.url ?? '/', 'http://localhost');
135
+ // y-websocket convention: room id is the path (e.g. ws://host/project/model)
136
+ const roomId = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
137
+ const token = url.searchParams.get('token') ?? undefined;
138
+ if (!roomId) {
139
+ ws.close(4400, 'missing-room');
140
+ return;
141
+ }
142
+ let principal;
143
+ try {
144
+ principal = await ctx.authenticate(token, roomId);
145
+ }
146
+ catch (err) {
147
+ // eslint-disable-next-line no-console
148
+ console.error('[collab-server] auth threw:', err);
149
+ ws.close(4500, 'auth-error');
150
+ return;
151
+ }
152
+ if (!principal) {
153
+ ws.close(4401, 'unauthorized');
154
+ return;
155
+ }
156
+ const room = await ctx.roomManager.getOrCreate(roomId);
157
+ const conn = {
158
+ ws,
159
+ principal,
160
+ awarenessClients: new Set(),
161
+ };
162
+ room.addConnection(conn);
163
+ let alive = true;
164
+ const ping = setInterval(() => {
165
+ if (!alive) {
166
+ try {
167
+ ws.terminate();
168
+ }
169
+ catch { /* socket already gone */ }
170
+ clearInterval(ping);
171
+ return;
172
+ }
173
+ alive = false;
174
+ try {
175
+ ws.ping();
176
+ }
177
+ catch { /* socket already gone */ }
178
+ }, PING_INTERVAL_MS);
179
+ ws.on('pong', () => { alive = true; });
180
+ ws.on('message', (data) => {
181
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data);
182
+ room.handleMessage(conn, bytes);
183
+ });
184
+ const cleanup = () => {
185
+ clearInterval(ping);
186
+ room.removeConnection(conn);
187
+ };
188
+ ws.on('close', cleanup);
189
+ ws.on('error', (err) => {
190
+ // eslint-disable-next-line no-console
191
+ console.error('[collab-server] ws error:', err);
192
+ cleanup();
193
+ });
194
+ }
195
+ export { FilePersistence, MemoryPersistence } from './persistence.js';
196
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ import type { RoomManager } from './room-manager.js';
2
+ export interface SnapshotWorkerOptions {
3
+ roomManager: RoomManager;
4
+ /** Output directory; created if missing. */
5
+ outputDir: string;
6
+ /** How often to run snapshots, ms. Default 5 minutes. */
7
+ intervalMs?: number;
8
+ /** If true, also emit a per-clientID layer file alongside the composed snapshot. */
9
+ emitLayers?: boolean;
10
+ /** Override clock so tests are deterministic. */
11
+ now?: () => number;
12
+ /** If true, snapshot even rooms with zero peers (default false). */
13
+ includeIdle?: boolean;
14
+ }
15
+ export interface SnapshotResult {
16
+ roomId: string;
17
+ filePath: string;
18
+ byteLength: number;
19
+ durationMs: number;
20
+ }
21
+ export declare class SnapshotWorker {
22
+ private timer;
23
+ private inflight;
24
+ private readonly options;
25
+ constructor(opts: SnapshotWorkerOptions);
26
+ start(): void;
27
+ stop(): void;
28
+ /** Force one snapshot pass across all active rooms. Returns per-room results. */
29
+ runOnce(): Promise<SnapshotResult[]>;
30
+ private now;
31
+ }
32
+ //# sourceMappingURL=snapshot-worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snapshot-worker.d.ts","sourceRoot":"","sources":["../src/snapshot-worker.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,WAAW,CAAC;IACzB,4CAA4C;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,oFAAoF;IACpF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,iDAAiD;IACjD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,oEAAoE;IACpE,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,KAAK,CAA+C;IAC5D,OAAO,CAAC,QAAQ,CAA0C;IAC1D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAC4D;gBAExE,IAAI,EAAE,qBAAqB;IAYvC,KAAK,IAAI,IAAI;IAWb,IAAI,IAAI,IAAI;IAKZ,iFAAiF;IAC3E,OAAO,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IA+B1C,OAAO,CAAC,GAAG;CAGZ"}
@@ -0,0 +1,87 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+ /**
5
+ * Server-driven IFCX snapshot worker (spec §12.3).
6
+ *
7
+ * Periodically exports each active room's Y.Doc as an `.ifcx` file
8
+ * (composed state), optionally with per-user "git blame" layers. Output
9
+ * lives next to the durable update log so a deployment can ship its
10
+ * audit chain alongside its persistence.
11
+ *
12
+ * Runs as a self-contained class — instantiate with a `RoomManager`
13
+ * handle and call `start()`. The worker is stateless across restarts;
14
+ * the room itself is the source of truth.
15
+ */
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ import { snapshotToIfcx, serializeIfcx } from '@ifc-lite/collab';
19
+ export class SnapshotWorker {
20
+ timer = null;
21
+ inflight = null;
22
+ options;
23
+ constructor(opts) {
24
+ this.options = {
25
+ roomManager: opts.roomManager,
26
+ outputDir: opts.outputDir,
27
+ intervalMs: opts.intervalMs ?? 5 * 60_000,
28
+ emitLayers: opts.emitLayers ?? false,
29
+ now: opts.now,
30
+ includeIdle: opts.includeIdle,
31
+ };
32
+ fs.mkdirSync(this.options.outputDir, { recursive: true });
33
+ }
34
+ start() {
35
+ if (this.timer)
36
+ return;
37
+ this.timer = setInterval(() => {
38
+ void this.runOnce().catch((err) => {
39
+ // eslint-disable-next-line no-console
40
+ console.error('[snapshot-worker] run failed:', err);
41
+ });
42
+ }, this.options.intervalMs);
43
+ this.timer.unref?.();
44
+ }
45
+ stop() {
46
+ if (this.timer)
47
+ clearInterval(this.timer);
48
+ this.timer = null;
49
+ }
50
+ /** Force one snapshot pass across all active rooms. Returns per-room results. */
51
+ async runOnce() {
52
+ if (this.inflight)
53
+ return this.inflight;
54
+ this.inflight = (async () => {
55
+ const stats = await this.options.roomManager.stats();
56
+ const targets = stats.filter((s) => s.peerCount > 0 || this.options.includeIdle);
57
+ const results = [];
58
+ for (const t of targets) {
59
+ const room = await this.options.roomManager.getOrCreate(t.roomId);
60
+ const start = this.now();
61
+ const ifcx = snapshotToIfcx(room.doc);
62
+ const content = serializeIfcx(ifcx, false);
63
+ const stamp = new Date(this.now()).toISOString().replace(/[:.]/g, '-');
64
+ const safe = t.roomId.replace(/[^a-zA-Z0-9._-]/g, '_');
65
+ const filePath = path.join(this.options.outputDir, `${safe}.${stamp}.ifcx`);
66
+ await fs.promises.writeFile(filePath, content);
67
+ results.push({
68
+ roomId: t.roomId,
69
+ filePath,
70
+ byteLength: Buffer.byteLength(content, 'utf8'),
71
+ durationMs: this.now() - start,
72
+ });
73
+ }
74
+ return results;
75
+ })();
76
+ try {
77
+ return await this.inflight;
78
+ }
79
+ finally {
80
+ this.inflight = null;
81
+ }
82
+ }
83
+ now() {
84
+ return this.options.now ? this.options.now() : Date.now();
85
+ }
86
+ }
87
+ //# sourceMappingURL=snapshot-worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snapshot-worker.js","sourceRoot":"","sources":["../src/snapshot-worker.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@ifc-lite/collab-server",
3
+ "version": "0.2.0",
4
+ "description": "Reference websocket sync server for @ifc-lite/collab",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "ifc-lite-collab-server": "./dist/bin.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.js",
14
+ "types": "./dist/index.d.ts"
15
+ },
16
+ "./room-manager": {
17
+ "import": "./dist/room-manager.js",
18
+ "types": "./dist/room-manager.d.ts"
19
+ }
20
+ },
21
+ "dependencies": {
22
+ "lib0": "^0.2.99",
23
+ "ws": "^8.18.0",
24
+ "y-protocols": "^1.0.6",
25
+ "yjs": "^13.6.20",
26
+ "@ifc-lite/collab": "^0.2.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20.0.0",
30
+ "@types/ws": "^8.5.13",
31
+ "typescript": "^5.3.0",
32
+ "vitest": "^1.6.0",
33
+ "y-websocket": "^2.0.4"
34
+ },
35
+ "license": "MPL-2.0",
36
+ "author": "Louis True",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/louistrue/ifc-lite.git",
40
+ "directory": "packages/collab-server"
41
+ },
42
+ "homepage": "https://louistrue.github.io/ifc-lite/",
43
+ "bugs": "https://github.com/louistrue/ifc-lite/issues",
44
+ "keywords": [
45
+ "ifc",
46
+ "ifcx",
47
+ "bim",
48
+ "collaboration",
49
+ "crdt",
50
+ "yjs",
51
+ "websocket"
52
+ ],
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "files": [
57
+ "dist",
58
+ "README.md"
59
+ ],
60
+ "scripts": {
61
+ "build": "pnpm exec tsc",
62
+ "dev": "pnpm exec tsc --watch",
63
+ "start": "node ./dist/bin.js",
64
+ "test": "vitest run"
65
+ }
66
+ }