@blamejs/core 0.4.1

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 (160) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/LICENSE +201 -0
  3. package/LTS-CALENDAR.md +29 -0
  4. package/MIGRATING.md +7 -0
  5. package/NOTICE +59 -0
  6. package/README.md +100 -0
  7. package/bin/blamejs.js +13 -0
  8. package/index.js +253 -0
  9. package/lib/api-key.js +705 -0
  10. package/lib/api-snapshot.js +335 -0
  11. package/lib/app-shutdown.js +381 -0
  12. package/lib/app.js +364 -0
  13. package/lib/atomic-file.js +525 -0
  14. package/lib/audit-chain.js +168 -0
  15. package/lib/audit-sign.js +319 -0
  16. package/lib/audit-tools.js +682 -0
  17. package/lib/audit.js +753 -0
  18. package/lib/auth/jwt.js +280 -0
  19. package/lib/auth/oauth.js +691 -0
  20. package/lib/auth/passkey.js +185 -0
  21. package/lib/auth/password.js +139 -0
  22. package/lib/auth/totp.js +17 -0
  23. package/lib/auth-header.js +81 -0
  24. package/lib/backup/bundle.js +219 -0
  25. package/lib/backup/crypto.js +174 -0
  26. package/lib/backup/index.js +490 -0
  27. package/lib/backup/manifest.js +275 -0
  28. package/lib/bundler.js +295 -0
  29. package/lib/cache.js +819 -0
  30. package/lib/chain-writer.js +234 -0
  31. package/lib/cli-helpers.js +201 -0
  32. package/lib/cli.js +1377 -0
  33. package/lib/cluster-provider-db.js +245 -0
  34. package/lib/cluster-storage.js +166 -0
  35. package/lib/cluster.js +691 -0
  36. package/lib/consent.js +222 -0
  37. package/lib/constants.js +186 -0
  38. package/lib/cookies.js +293 -0
  39. package/lib/credential-hash.js +303 -0
  40. package/lib/crypto-field.js +159 -0
  41. package/lib/crypto.js +250 -0
  42. package/lib/db-query.js +297 -0
  43. package/lib/db-schema.js +250 -0
  44. package/lib/db.js +1054 -0
  45. package/lib/deprecate.js +226 -0
  46. package/lib/dev.js +324 -0
  47. package/lib/error-page.js +424 -0
  48. package/lib/events.js +135 -0
  49. package/lib/external-db.js +422 -0
  50. package/lib/forms.js +378 -0
  51. package/lib/framework-error.js +189 -0
  52. package/lib/framework-schema.js +604 -0
  53. package/lib/handlers.js +350 -0
  54. package/lib/html-balance.js +227 -0
  55. package/lib/http-client.js +615 -0
  56. package/lib/i18n.js +780 -0
  57. package/lib/jobs.js +181 -0
  58. package/lib/lazy-require.js +48 -0
  59. package/lib/log-stream-local.js +137 -0
  60. package/lib/log-stream-webhook.js +170 -0
  61. package/lib/log-stream.js +211 -0
  62. package/lib/log.js +355 -0
  63. package/lib/mail-bounce.js +507 -0
  64. package/lib/mail.js +701 -0
  65. package/lib/metrics.js +647 -0
  66. package/lib/middleware/api-encrypt.js +553 -0
  67. package/lib/middleware/attach-user.js +156 -0
  68. package/lib/middleware/body-parser.js +883 -0
  69. package/lib/middleware/bot-guard.js +148 -0
  70. package/lib/middleware/compression.js +436 -0
  71. package/lib/middleware/cors.js +236 -0
  72. package/lib/middleware/csp-nonce.js +332 -0
  73. package/lib/middleware/csrf-protect.js +275 -0
  74. package/lib/middleware/error-handler.js +46 -0
  75. package/lib/middleware/health.js +358 -0
  76. package/lib/middleware/index.js +52 -0
  77. package/lib/middleware/rate-limit.js +319 -0
  78. package/lib/middleware/request-id.js +53 -0
  79. package/lib/middleware/require-auth.js +95 -0
  80. package/lib/middleware/security-headers.js +91 -0
  81. package/lib/migrations.js +353 -0
  82. package/lib/mtls-ca.js +333 -0
  83. package/lib/mtls-engine-default.js +285 -0
  84. package/lib/nonce-store.js +177 -0
  85. package/lib/notify.js +643 -0
  86. package/lib/ntp-check.js +178 -0
  87. package/lib/object-store/azure-blob.js +467 -0
  88. package/lib/object-store/gcs.js +469 -0
  89. package/lib/object-store/http-put.js +153 -0
  90. package/lib/object-store/index.js +140 -0
  91. package/lib/object-store/local.js +163 -0
  92. package/lib/object-store/retry.js +15 -0
  93. package/lib/object-store/sigv4.js +535 -0
  94. package/lib/observability.js +114 -0
  95. package/lib/pagination.js +371 -0
  96. package/lib/parsers/index.js +64 -0
  97. package/lib/parsers/safe-csv.js +224 -0
  98. package/lib/parsers/safe-env.js +614 -0
  99. package/lib/parsers/safe-toml.js +745 -0
  100. package/lib/parsers/safe-xml.js +379 -0
  101. package/lib/parsers/safe-yaml.js +977 -0
  102. package/lib/permissions.js +430 -0
  103. package/lib/pqc-agent.js +85 -0
  104. package/lib/pqc-gate.js +266 -0
  105. package/lib/protocol-dispatcher.js +144 -0
  106. package/lib/queue-local.js +327 -0
  107. package/lib/queue.js +430 -0
  108. package/lib/redact.js +192 -0
  109. package/lib/render.js +193 -0
  110. package/lib/request-helpers.js +178 -0
  111. package/lib/restore-bundle.js +239 -0
  112. package/lib/restore-rollback.js +254 -0
  113. package/lib/restore.js +301 -0
  114. package/lib/retry.js +329 -0
  115. package/lib/router.js +437 -0
  116. package/lib/safe-async.js +520 -0
  117. package/lib/safe-buffer.js +162 -0
  118. package/lib/safe-json.js +532 -0
  119. package/lib/safe-schema.js +1176 -0
  120. package/lib/safe-sql.js +157 -0
  121. package/lib/safe-url.js +109 -0
  122. package/lib/scheduler.js +680 -0
  123. package/lib/seeders.js +622 -0
  124. package/lib/session.js +304 -0
  125. package/lib/slug.js +243 -0
  126. package/lib/static.js +268 -0
  127. package/lib/storage.js +470 -0
  128. package/lib/subject.js +281 -0
  129. package/lib/template.js +781 -0
  130. package/lib/testing.js +621 -0
  131. package/lib/totp.js +285 -0
  132. package/lib/tracing.js +484 -0
  133. package/lib/validate-opts.js +56 -0
  134. package/lib/vault/index.js +299 -0
  135. package/lib/vault/passphrase-ops.js +311 -0
  136. package/lib/vault/passphrase-source.js +198 -0
  137. package/lib/vault/rotate.js +761 -0
  138. package/lib/vault/wrap.js +289 -0
  139. package/lib/vendor/MANIFEST.json +84 -0
  140. package/lib/vendor/argon2/argon2.cjs +466 -0
  141. package/lib/vendor/argon2/argon2.d.cts +62 -0
  142. package/lib/vendor/argon2/package.json +1 -0
  143. package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
  144. package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
  145. package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
  146. package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
  147. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
  148. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
  149. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
  150. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
  151. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
  152. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
  153. package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
  154. package/lib/vendor/noble-ciphers.cjs +9 -0
  155. package/lib/vendor/pki.cjs +181 -0
  156. package/lib/vendor/simplewebauthn-server.cjs +328 -0
  157. package/lib/webhook.js +632 -0
  158. package/lib/websocket-channels.js +413 -0
  159. package/lib/websocket.js +833 -0
  160. package/package.json +39 -0
@@ -0,0 +1,413 @@
1
+ "use strict";
2
+ /**
3
+ * websocket-channels — channel/room hub layered over `lib/websocket.js`.
4
+ *
5
+ * `lib/websocket.js` owns the wire protocol (RFC 6455 + 8441 frame
6
+ * parsing, masking, control frames). This module owns the higher-level
7
+ * pub/sub: connections subscribe to named channels, publish() fans out
8
+ * a payload to every subscriber, and cluster mode coordinates fan-out
9
+ * across nodes via a shared `_blamejs_ws_messages` table.
10
+ *
11
+ * Public API:
12
+ *
13
+ * var hub = b.websocketChannels.create({
14
+ * backend: 'local' | 'cluster' | { publishRemote, start, stop },
15
+ * pollIntervalMs: C.TIME.ms? — cluster fan-out poll cadence (default 100ms)
16
+ * retentionMs: C.TIME.ms? — fan-out row retention before prune (default 60s)
17
+ * audit: true, — emit system.ws.publish on each publish
18
+ * });
19
+ *
20
+ * r.ws("/socket", function (conn) {
21
+ * hub.attach(conn); // tracks lifecycle, auto-detach on close
22
+ * hub.subscribe(conn, "chat:room-1");
23
+ * hub.subscribe(conn, "presence:user-42");
24
+ *
25
+ * conn.on("message", function (msg) {
26
+ * // Operator-handled inbound; hub doesn't reflect inbound back.
27
+ * });
28
+ * });
29
+ *
30
+ * await hub.publish("chat:room-1", { user: "alice", text: "hi" });
31
+ *
32
+ * hub.localSubscribers("chat:room-1"); // → [conn, ...]
33
+ * hub.localSubscriberCount("chat:room-1"); // → number
34
+ * hub.channels(); // → ["chat:room-1", ...]
35
+ * hub.connectionChannels(conn); // → ["chat:room-1", ...]
36
+ *
37
+ * Cluster fan-out semantics:
38
+ * - Each publish() dispatches to local subscribers SYNCHRONOUSLY
39
+ * before returning. Local subscribers see the message with
40
+ * near-zero latency.
41
+ * - In cluster mode the publish ALSO writes a row to
42
+ * _blamejs_ws_messages. Other nodes poll the table on
43
+ * pollIntervalMs (default 100ms) and dispatch new rows past their
44
+ * last-seen id to their local subscribers.
45
+ * - The publishing node skips its own rows on poll (publishedBy =
46
+ * self) so each subscriber sees the message exactly once.
47
+ * - Latency-sensitive operators bring their own backend (Redis,
48
+ * NATS, MQTT) by passing { publishRemote, start, stop }.
49
+ *
50
+ * Single-node mode:
51
+ * - 'local' backend (default when opts.cluster is not wired). publish
52
+ * synchronously dispatches; no DB writes; no poll loop.
53
+ * - Operators on a single node pay zero coordination overhead.
54
+ *
55
+ * Channel naming is operator-defined — the hub treats names as opaque
56
+ * strings. Common conventions: "chat:room-id", "presence:user-id",
57
+ * "metrics:hostname". For pattern matching subscribe to multiple
58
+ * specific channels rather than a wildcard — wildcards complicate
59
+ * cross-node fan-out and are deferred.
60
+ *
61
+ * Error policy: a connection's send() that throws (closed socket, peer
62
+ * gone) does NOT break dispatch to other subscribers. The throwing
63
+ * subscriber is silently skipped; auto-detach on the connection's
64
+ * 'close' event removes it from future fan-out.
65
+ */
66
+
67
+ var lazyRequire = require("./lazy-require");
68
+ var clusterStorage = require("./cluster-storage");
69
+ var C = require("./constants");
70
+ var safeJson = require("./safe-json");
71
+ var validateOpts = require("./validate-opts");
72
+ var { defineClass } = require("./framework-error");
73
+
74
+ var audit = lazyRequire(function () { return require("./audit"); });
75
+ var logger = lazyRequire(function () { return require("./log").boot("websocket-channels"); });
76
+
77
+ var WebSocketChannelsError = defineClass("WebSocketChannelsError");
78
+
79
+ var DEFAULT_POLL_INTERVAL_MS = 100;
80
+ var DEFAULT_RETENTION_MS = C.TIME.minutes(1);
81
+ var DEFAULT_PRUNE_EVERY_MS = C.TIME.minutes(5);
82
+
83
+ function _err(code, message) {
84
+ return new WebSocketChannelsError(code, message, true);
85
+ }
86
+
87
+ // ---- Backends ----
88
+
89
+ function _localBackend() {
90
+ // Single-node: no remote fan-out. publish() goes only to local subs.
91
+ return {
92
+ name: "local",
93
+ publishRemote: null,
94
+ start: null,
95
+ stop: null,
96
+ };
97
+ }
98
+
99
+ function _clusterBackend(opts) {
100
+ var clusterInstance = opts.cluster;
101
+ var pollIntervalMs = opts.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS;
102
+ var retentionMs = opts.retentionMs || DEFAULT_RETENTION_MS;
103
+ var pruneEveryMs = opts.pruneEveryMs || DEFAULT_PRUNE_EVERY_MS;
104
+ var lastSeenId = 0;
105
+ var primed = false;
106
+ var lastPruneAt = 0;
107
+ var pollTimer = null;
108
+ var stopped = false;
109
+
110
+ function _nodeId() {
111
+ if (clusterInstance && typeof clusterInstance.currentNodeId === "function") {
112
+ return clusterInstance.currentNodeId();
113
+ }
114
+ return "single-node-local";
115
+ }
116
+
117
+ async function publishRemote(channel, payload) {
118
+ var serialized = JSON.stringify(payload);
119
+ await clusterStorage.execute(
120
+ "INSERT INTO _blamejs_ws_messages " +
121
+ "(channel, payload, publishedAt, publishedBy) VALUES (?, ?, ?, ?)",
122
+ [channel, serialized, Date.now(), _nodeId()]
123
+ );
124
+ }
125
+
126
+ async function _poll(onRemoteMessage) {
127
+ if (stopped) return;
128
+ var nodeId = _nodeId();
129
+ try {
130
+ // First poll: prime lastSeenId to the current MAX so we don't
131
+ // re-dispatch every historical row on startup. Tracked via the
132
+ // `primed` flag separately from lastSeenId — when MAX(id) is 0
133
+ // (empty table) the value-equality check would trip on every
134
+ // poll without it.
135
+ if (!primed) {
136
+ var primer = await clusterStorage.execute(
137
+ "SELECT COALESCE(MAX(id), 0) AS maxId FROM _blamejs_ws_messages",
138
+ []
139
+ );
140
+ if (primer.rows && primer.rows[0]) {
141
+ lastSeenId = Number(primer.rows[0].maxId) || 0;
142
+ }
143
+ primed = true;
144
+ return;
145
+ }
146
+ var result = await clusterStorage.execute(
147
+ "SELECT id, channel, payload FROM _blamejs_ws_messages " +
148
+ "WHERE id > ? AND publishedBy <> ? ORDER BY id ASC",
149
+ [lastSeenId, nodeId]
150
+ );
151
+ var rows = result.rows || [];
152
+ for (var i = 0; i < rows.length; i++) {
153
+ var row = rows[i];
154
+ try {
155
+ var payload = safeJson.parse(row.payload);
156
+ onRemoteMessage(row.channel, payload);
157
+ } catch (e) {
158
+ try {
159
+ logger().warn("malformed fan-out row id=" + row.id +
160
+ ": " + ((e && e.message) || String(e)));
161
+ } catch (_e) { /* logger best-effort */ }
162
+ }
163
+ if (Number(row.id) > lastSeenId) lastSeenId = Number(row.id);
164
+ }
165
+
166
+ // Rate-limited prune of expired rows.
167
+ var now = Date.now();
168
+ if (now - lastPruneAt >= pruneEveryMs) {
169
+ lastPruneAt = now;
170
+ await clusterStorage.execute(
171
+ "DELETE FROM _blamejs_ws_messages WHERE publishedAt < ?",
172
+ [now - retentionMs]
173
+ );
174
+ }
175
+ } catch (e) {
176
+ try {
177
+ logger().warn("fan-out poll failed: " + ((e && e.message) || String(e)));
178
+ } catch (_e) { /* logger best-effort */ }
179
+ }
180
+ }
181
+
182
+ function start(onRemoteMessage) {
183
+ if (pollTimer) return;
184
+ stopped = false;
185
+ var tick = function () {
186
+ _poll(onRemoteMessage).then(function () {
187
+ if (stopped) return;
188
+ pollTimer = setTimeout(tick, pollIntervalMs);
189
+ if (typeof pollTimer.unref === "function") pollTimer.unref();
190
+ }, function () {
191
+ if (stopped) return;
192
+ pollTimer = setTimeout(tick, pollIntervalMs);
193
+ if (typeof pollTimer.unref === "function") pollTimer.unref();
194
+ });
195
+ };
196
+ // Prime lastSeenId immediately so the first publish on a brand-new
197
+ // hub doesn't race the first poll-interval tick. Without this,
198
+ // publishes that happen between create() and the first poll would
199
+ // be missed by other nodes that haven't seeded their lastSeenId yet.
200
+ pollTimer = setTimeout(tick, 0);
201
+ if (typeof pollTimer.unref === "function") pollTimer.unref();
202
+ }
203
+
204
+ function stop() {
205
+ stopped = true;
206
+ if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
207
+ }
208
+
209
+ return {
210
+ name: "cluster",
211
+ publishRemote: publishRemote,
212
+ start: start,
213
+ stop: stop,
214
+ };
215
+ }
216
+
217
+ function _resolveBackend(opts) {
218
+ var requested = opts.backend;
219
+ if (requested && typeof requested === "object" &&
220
+ typeof requested.publishRemote === "function") {
221
+ return Object.assign({ name: "custom" }, requested);
222
+ }
223
+ if (requested === "cluster") return _clusterBackend(opts);
224
+ if (!requested || requested === "local") return _localBackend();
225
+ throw _err("UNKNOWN_BACKEND",
226
+ "websocketChannels: unknown backend '" + requested +
227
+ "' (must be 'local', 'cluster', or { publishRemote, start, stop })");
228
+ }
229
+
230
+ // ---- Hub ----
231
+
232
+ function create(opts) {
233
+ opts = opts || {};
234
+ validateOpts(opts, [
235
+ "backend", "audit", "cluster",
236
+ "pollIntervalMs", "retentionMs", "pruneEveryMs",
237
+ ], "b.websocket");
238
+ var auditOn = !!opts.audit;
239
+ var backend = _resolveBackend(opts);
240
+
241
+ // channel -> Set<connection>
242
+ var channelToConns = new Map();
243
+ // connection -> Set<channel> (WeakMap so dropped conns don't leak)
244
+ var connToChannels = new WeakMap();
245
+ // Tracked connection set for surface methods that need to enumerate
246
+ // connections (e.g. close-all-on-shutdown). Connections leave this
247
+ // set on detach. We hold strong refs intentionally — the hub is the
248
+ // owner of this membership for as long as the operator hasn't said
249
+ // detach.
250
+ var attachedConns = new Set();
251
+
252
+ function _localDispatch(channel, payload) {
253
+ var subs = channelToConns.get(channel);
254
+ if (!subs || subs.size === 0) return 0;
255
+ var msg;
256
+ try { msg = JSON.stringify({ channel: channel, payload: payload }); }
257
+ catch (e) {
258
+ throw _err("INVALID_PAYLOAD",
259
+ "publish payload is not JSON-serializable: " + (e && e.message));
260
+ }
261
+ var sent = 0;
262
+ for (var conn of subs) {
263
+ try { conn.send(msg); sent++; }
264
+ catch (_e) { /* dead connection — auto-detach handles cleanup */ }
265
+ }
266
+ return sent;
267
+ }
268
+
269
+ // Cluster backend invokes this when a remote node's row arrives.
270
+ function _onRemoteMessage(channel, payload) {
271
+ _localDispatch(channel, payload);
272
+ }
273
+
274
+ if (typeof backend.start === "function") backend.start(_onRemoteMessage);
275
+
276
+ function attach(conn) {
277
+ if (!conn || typeof conn.send !== "function") {
278
+ throw _err("INVALID_CONN", "attach(conn) requires a connection with .send()");
279
+ }
280
+ if (connToChannels.has(conn)) return; // idempotent
281
+ connToChannels.set(conn, new Set());
282
+ attachedConns.add(conn);
283
+ if (typeof conn.on === "function") {
284
+ conn.on("close", function () { detach(conn); });
285
+ }
286
+ }
287
+
288
+ function detach(conn) {
289
+ var chans = connToChannels.get(conn);
290
+ if (!chans) return;
291
+ for (var c of chans) {
292
+ var subs = channelToConns.get(c);
293
+ if (subs) {
294
+ subs.delete(conn);
295
+ if (subs.size === 0) channelToConns.delete(c);
296
+ }
297
+ }
298
+ connToChannels.delete(conn);
299
+ attachedConns.delete(conn);
300
+ }
301
+
302
+ function subscribe(conn, channel) {
303
+ if (typeof channel !== "string" || channel.length === 0) {
304
+ throw _err("INVALID_CHANNEL", "subscribe: channel must be a non-empty string");
305
+ }
306
+ if (!connToChannels.has(conn)) {
307
+ throw _err("NOT_ATTACHED", "subscribe: connection must be attach()-ed first");
308
+ }
309
+ if (!channelToConns.has(channel)) channelToConns.set(channel, new Set());
310
+ channelToConns.get(channel).add(conn);
311
+ connToChannels.get(conn).add(channel);
312
+ }
313
+
314
+ function unsubscribe(conn, channel) {
315
+ var subs = channelToConns.get(channel);
316
+ if (subs) {
317
+ subs.delete(conn);
318
+ if (subs.size === 0) channelToConns.delete(channel);
319
+ }
320
+ var chans = connToChannels.get(conn);
321
+ if (chans) chans.delete(channel);
322
+ }
323
+
324
+ async function publish(channel, payload) {
325
+ if (typeof channel !== "string" || channel.length === 0) {
326
+ throw _err("INVALID_CHANNEL", "publish: channel must be a non-empty string");
327
+ }
328
+ var localCount = _localDispatch(channel, payload);
329
+ var remoteSent = false;
330
+ if (typeof backend.publishRemote === "function") {
331
+ try {
332
+ await backend.publishRemote(channel, payload);
333
+ remoteSent = true;
334
+ } catch (e) {
335
+ try {
336
+ logger().error("publishRemote failed for channel '" + channel + "': " +
337
+ ((e && e.message) || String(e)));
338
+ } catch (_e) { /* logger best-effort */ }
339
+ }
340
+ }
341
+ if (auditOn) {
342
+ audit().safeEmit({
343
+ action: "system.ws.publish",
344
+ metadata: {
345
+ channel: channel,
346
+ backend: backend.name,
347
+ localDelivered: localCount,
348
+ remoteSent: remoteSent,
349
+ },
350
+ });
351
+ }
352
+ return { localDelivered: localCount, remoteSent: remoteSent };
353
+ }
354
+
355
+ function localSubscribers(channel) {
356
+ var subs = channelToConns.get(channel);
357
+ return subs ? Array.from(subs) : [];
358
+ }
359
+
360
+ function localSubscriberCount(channel) {
361
+ var subs = channelToConns.get(channel);
362
+ return subs ? subs.size : 0;
363
+ }
364
+
365
+ function channels() {
366
+ return Array.from(channelToConns.keys());
367
+ }
368
+
369
+ function connectionChannels(conn) {
370
+ var chans = connToChannels.get(conn);
371
+ return chans ? Array.from(chans) : [];
372
+ }
373
+
374
+ function attachedCount() {
375
+ return attachedConns.size;
376
+ }
377
+
378
+ function close() {
379
+ if (typeof backend.stop === "function") backend.stop();
380
+ // Drop all subscriptions. Connections are not closed — that's the
381
+ // operator's call (`router.closeWebSockets()` is the framework's
382
+ // hook for graceful shutdown).
383
+ channelToConns.clear();
384
+ for (var conn of attachedConns) connToChannels.delete(conn);
385
+ attachedConns.clear();
386
+ }
387
+
388
+ return {
389
+ backend: backend.name,
390
+ attach: attach,
391
+ detach: detach,
392
+ subscribe: subscribe,
393
+ unsubscribe: unsubscribe,
394
+ publish: publish,
395
+ localSubscribers: localSubscribers,
396
+ localSubscriberCount: localSubscriberCount,
397
+ channels: channels,
398
+ connectionChannels: connectionChannels,
399
+ attachedCount: attachedCount,
400
+ close: close,
401
+ // Test hook — directly inject a remote message as if the cluster
402
+ // backend's poll just received it.
403
+ _injectRemoteMessage: _onRemoteMessage,
404
+ };
405
+ }
406
+
407
+ module.exports = {
408
+ create: create,
409
+ WebSocketChannelsError: WebSocketChannelsError,
410
+ // Backend factories exported for tests + advanced operator wiring.
411
+ _localBackend: _localBackend,
412
+ _clusterBackend: _clusterBackend,
413
+ };