@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.
- package/CHANGELOG.md +230 -0
- package/LICENSE +201 -0
- package/LTS-CALENDAR.md +29 -0
- package/MIGRATING.md +7 -0
- package/NOTICE +59 -0
- package/README.md +100 -0
- package/bin/blamejs.js +13 -0
- package/index.js +253 -0
- package/lib/api-key.js +705 -0
- package/lib/api-snapshot.js +335 -0
- package/lib/app-shutdown.js +381 -0
- package/lib/app.js +364 -0
- package/lib/atomic-file.js +525 -0
- package/lib/audit-chain.js +168 -0
- package/lib/audit-sign.js +319 -0
- package/lib/audit-tools.js +682 -0
- package/lib/audit.js +753 -0
- package/lib/auth/jwt.js +280 -0
- package/lib/auth/oauth.js +691 -0
- package/lib/auth/passkey.js +185 -0
- package/lib/auth/password.js +139 -0
- package/lib/auth/totp.js +17 -0
- package/lib/auth-header.js +81 -0
- package/lib/backup/bundle.js +219 -0
- package/lib/backup/crypto.js +174 -0
- package/lib/backup/index.js +490 -0
- package/lib/backup/manifest.js +275 -0
- package/lib/bundler.js +295 -0
- package/lib/cache.js +819 -0
- package/lib/chain-writer.js +234 -0
- package/lib/cli-helpers.js +201 -0
- package/lib/cli.js +1377 -0
- package/lib/cluster-provider-db.js +245 -0
- package/lib/cluster-storage.js +166 -0
- package/lib/cluster.js +691 -0
- package/lib/consent.js +222 -0
- package/lib/constants.js +186 -0
- package/lib/cookies.js +293 -0
- package/lib/credential-hash.js +303 -0
- package/lib/crypto-field.js +159 -0
- package/lib/crypto.js +250 -0
- package/lib/db-query.js +297 -0
- package/lib/db-schema.js +250 -0
- package/lib/db.js +1054 -0
- package/lib/deprecate.js +226 -0
- package/lib/dev.js +324 -0
- package/lib/error-page.js +424 -0
- package/lib/events.js +135 -0
- package/lib/external-db.js +422 -0
- package/lib/forms.js +378 -0
- package/lib/framework-error.js +189 -0
- package/lib/framework-schema.js +604 -0
- package/lib/handlers.js +350 -0
- package/lib/html-balance.js +227 -0
- package/lib/http-client.js +615 -0
- package/lib/i18n.js +780 -0
- package/lib/jobs.js +181 -0
- package/lib/lazy-require.js +48 -0
- package/lib/log-stream-local.js +137 -0
- package/lib/log-stream-webhook.js +170 -0
- package/lib/log-stream.js +211 -0
- package/lib/log.js +355 -0
- package/lib/mail-bounce.js +507 -0
- package/lib/mail.js +701 -0
- package/lib/metrics.js +647 -0
- package/lib/middleware/api-encrypt.js +553 -0
- package/lib/middleware/attach-user.js +156 -0
- package/lib/middleware/body-parser.js +883 -0
- package/lib/middleware/bot-guard.js +148 -0
- package/lib/middleware/compression.js +436 -0
- package/lib/middleware/cors.js +236 -0
- package/lib/middleware/csp-nonce.js +332 -0
- package/lib/middleware/csrf-protect.js +275 -0
- package/lib/middleware/error-handler.js +46 -0
- package/lib/middleware/health.js +358 -0
- package/lib/middleware/index.js +52 -0
- package/lib/middleware/rate-limit.js +319 -0
- package/lib/middleware/request-id.js +53 -0
- package/lib/middleware/require-auth.js +95 -0
- package/lib/middleware/security-headers.js +91 -0
- package/lib/migrations.js +353 -0
- package/lib/mtls-ca.js +333 -0
- package/lib/mtls-engine-default.js +285 -0
- package/lib/nonce-store.js +177 -0
- package/lib/notify.js +643 -0
- package/lib/ntp-check.js +178 -0
- package/lib/object-store/azure-blob.js +467 -0
- package/lib/object-store/gcs.js +469 -0
- package/lib/object-store/http-put.js +153 -0
- package/lib/object-store/index.js +140 -0
- package/lib/object-store/local.js +163 -0
- package/lib/object-store/retry.js +15 -0
- package/lib/object-store/sigv4.js +535 -0
- package/lib/observability.js +114 -0
- package/lib/pagination.js +371 -0
- package/lib/parsers/index.js +64 -0
- package/lib/parsers/safe-csv.js +224 -0
- package/lib/parsers/safe-env.js +614 -0
- package/lib/parsers/safe-toml.js +745 -0
- package/lib/parsers/safe-xml.js +379 -0
- package/lib/parsers/safe-yaml.js +977 -0
- package/lib/permissions.js +430 -0
- package/lib/pqc-agent.js +85 -0
- package/lib/pqc-gate.js +266 -0
- package/lib/protocol-dispatcher.js +144 -0
- package/lib/queue-local.js +327 -0
- package/lib/queue.js +430 -0
- package/lib/redact.js +192 -0
- package/lib/render.js +193 -0
- package/lib/request-helpers.js +178 -0
- package/lib/restore-bundle.js +239 -0
- package/lib/restore-rollback.js +254 -0
- package/lib/restore.js +301 -0
- package/lib/retry.js +329 -0
- package/lib/router.js +437 -0
- package/lib/safe-async.js +520 -0
- package/lib/safe-buffer.js +162 -0
- package/lib/safe-json.js +532 -0
- package/lib/safe-schema.js +1176 -0
- package/lib/safe-sql.js +157 -0
- package/lib/safe-url.js +109 -0
- package/lib/scheduler.js +680 -0
- package/lib/seeders.js +622 -0
- package/lib/session.js +304 -0
- package/lib/slug.js +243 -0
- package/lib/static.js +268 -0
- package/lib/storage.js +470 -0
- package/lib/subject.js +281 -0
- package/lib/template.js +781 -0
- package/lib/testing.js +621 -0
- package/lib/totp.js +285 -0
- package/lib/tracing.js +484 -0
- package/lib/validate-opts.js +56 -0
- package/lib/vault/index.js +299 -0
- package/lib/vault/passphrase-ops.js +311 -0
- package/lib/vault/passphrase-source.js +198 -0
- package/lib/vault/rotate.js +761 -0
- package/lib/vault/wrap.js +289 -0
- package/lib/vendor/MANIFEST.json +84 -0
- package/lib/vendor/argon2/argon2.cjs +466 -0
- package/lib/vendor/argon2/argon2.d.cts +62 -0
- package/lib/vendor/argon2/package.json +1 -0
- package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
- package/lib/vendor/noble-ciphers.cjs +9 -0
- package/lib/vendor/pki.cjs +181 -0
- package/lib/vendor/simplewebauthn-server.cjs +328 -0
- package/lib/webhook.js +632 -0
- package/lib/websocket-channels.js +413 -0
- package/lib/websocket.js +833 -0
- 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
|
+
};
|