@blamejs/core 0.6.32 → 0.6.34

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.
@@ -4,27 +4,39 @@
4
4
  *
5
5
  * `lib/websocket.js` owns the wire protocol (RFC 6455 + 8441 frame
6
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.
7
+ * subscription bookkeeping: connections subscribe to named channels,
8
+ * publish() fans out a payload to every subscriber. Cross-node fan-out
9
+ * is delegated to `b.pubsub` so cache-cluster invalidation, websocket
10
+ * channels, and any future cross-node primitive share the same
11
+ * transport (cluster table, Redis PUB/SUB, or operator-supplied).
10
12
  *
11
13
  * Public API:
12
14
  *
13
15
  * 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
16
+ * // Pub/sub backend opts passed through to b.pubsub.create.
17
+ * backend: 'local' | 'cluster' | 'redis' | { custom },
18
+ * // cluster opts:
19
+ * cluster: clusterInstance,
20
+ * pollIntervalMs: C.TIME.ms? — default 100ms
21
+ * retentionMs: C.TIME.ms? — default 60s
22
+ * // redis opts:
23
+ * redisUrl: string?
24
+ * redisPassword: string?
25
+ * redisUsername: string?
26
+ * redisTls: boolean?
27
+ * redisCa: string|Buffer?
28
+ * // common:
29
+ * topicPrefix: string? — default "ws" so independent
30
+ * pubsub primitives sharing a
31
+ * backend (cache, etc.) don't
32
+ * collide on channel names.
33
+ * audit: boolean — emit system.ws.publish on each publish
18
34
  * });
19
35
  *
20
36
  * r.ws("/socket", function (conn) {
21
37
  * hub.attach(conn); // tracks lifecycle, auto-detach on close
22
38
  * hub.subscribe(conn, "chat:room-1");
23
39
  * 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
40
  * });
29
41
  *
30
42
  * await hub.publish("chat:room-1", { user: "alice", text: "hi" });
@@ -34,29 +46,11 @@
34
46
  * hub.channels(); // → ["chat:room-1", ...]
35
47
  * hub.connectionChannels(conn); // → ["chat:room-1", ...]
36
48
  *
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.
49
+ * Backend semantics live in `lib/pubsub.js`. The hub here is responsible
50
+ * for the WebSocket-specific connection lifecycle (attach / detach),
51
+ * the channel-to-conn map, and serialization of payloads for the local
52
+ * dispatch path; the cross-node delivery is one pubsub.subscribe per
53
+ * channel the hub joins, with the hub's `_localDispatch` as the handler.
60
54
  *
61
55
  * Error policy: a connection's send() that throws (closed socket, peer
62
56
  * gone) does NOT break dispatch to other subscribers. The throwing
@@ -65,9 +59,7 @@
65
59
  */
66
60
 
67
61
  var lazyRequire = require("./lazy-require");
68
- var clusterStorage = require("./cluster-storage");
69
- var C = require("./constants");
70
- var safeJson = require("./safe-json");
62
+ var pubsub = require("./pubsub");
71
63
  var validateOpts = require("./validate-opts");
72
64
  var { defineClass } = require("./framework-error");
73
65
 
@@ -76,157 +68,10 @@ var logger = lazyRequire(function () { return require("./log").boot("websocket-c
76
68
 
77
69
  var WebSocketChannelsError = defineClass("WebSocketChannelsError");
78
70
 
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
71
  function _err(code, message) {
84
72
  return new WebSocketChannelsError(code, message, true);
85
73
  }
86
74
 
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
75
  // ---- Hub ----
231
76
 
232
77
  function create(opts) {
@@ -234,19 +79,47 @@ function create(opts) {
234
79
  validateOpts(opts, [
235
80
  "backend", "audit", "cluster",
236
81
  "pollIntervalMs", "retentionMs", "pruneEveryMs",
82
+ "redisUrl", "redisPassword", "redisUsername", "redisTls",
83
+ "redisCa", "redisServername",
84
+ "topicPrefix",
237
85
  ], "b.websocket");
238
86
  var auditOn = !!opts.audit;
239
- var backend = _resolveBackend(opts);
87
+
88
+ // Pub/sub fan-out is delegated to b.pubsub. The hub owns one pubsub
89
+ // instance per WebSocket-channels primitive; topicPrefix defaults to
90
+ // "ws" so independent pubsub primitives sharing the same backend
91
+ // (cache cluster invalidation, app-level pubsub) don't collide on
92
+ // channel names.
93
+ var ps = pubsub.create({
94
+ backend: opts.backend,
95
+ cluster: opts.cluster,
96
+ pollIntervalMs: opts.pollIntervalMs,
97
+ retentionMs: opts.retentionMs,
98
+ pruneEveryMs: opts.pruneEveryMs,
99
+ redisUrl: opts.redisUrl,
100
+ redisPassword: opts.redisPassword,
101
+ redisUsername: opts.redisUsername,
102
+ redisTls: opts.redisTls,
103
+ redisCa: opts.redisCa,
104
+ redisServername: opts.redisServername,
105
+ // Default empty so pre-existing operators / tests that publish on
106
+ // raw channel names keep working unchanged. Operators sharing one
107
+ // pubsub backend across cache + websockets + custom primitives
108
+ // pass a topicPrefix to isolate.
109
+ topicPrefix: opts.topicPrefix || "",
110
+ });
111
+ var backendName = ps.backend();
240
112
 
241
113
  // channel -> Set<connection>
242
114
  var channelToConns = new Map();
115
+ // channel -> pubsub-token (one subscribe per channel the hub
116
+ // currently has any local listener on; refcounted via the local
117
+ // map's size).
118
+ var channelToToken = new Map();
243
119
  // connection -> Set<channel> (WeakMap so dropped conns don't leak)
244
120
  var connToChannels = new WeakMap();
245
121
  // 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.
122
+ // connections (e.g. close-all-on-shutdown).
250
123
  var attachedConns = new Set();
251
124
 
252
125
  function _localDispatch(channel, payload) {
@@ -266,13 +139,17 @@ function create(opts) {
266
139
  return sent;
267
140
  }
268
141
 
269
- // Cluster backend invokes this when a remote node's row arrives.
270
- function _onRemoteMessage(channel, payload) {
271
- _localDispatch(channel, payload);
142
+ // Pubsub message arrivals dispatch to local WebSocket connections.
143
+ // For local-backend pubsub the handler runs synchronously inside
144
+ // ps.publish(), so we accumulate the per-publish delivery count via
145
+ // the `dispatchTallies` map keyed by channel + sequence id — the
146
+ // hub's publish() reads it back after ps.publish() resolves.
147
+ var lastDispatchCount = 0;
148
+ function _onPubsubMessage(payload, ev) {
149
+ var n = _localDispatch(ev.channel, payload);
150
+ lastDispatchCount += n;
272
151
  }
273
152
 
274
- if (typeof backend.start === "function") backend.start(_onRemoteMessage);
275
-
276
153
  function attach(conn) {
277
154
  if (!conn || typeof conn.send !== "function") {
278
155
  throw _err("INVALID_CONN", "attach(conn) requires a connection with .send()");
@@ -292,7 +169,14 @@ function create(opts) {
292
169
  var subs = channelToConns.get(c);
293
170
  if (subs) {
294
171
  subs.delete(conn);
295
- if (subs.size === 0) channelToConns.delete(c);
172
+ if (subs.size === 0) {
173
+ channelToConns.delete(c);
174
+ var token = channelToToken.get(c);
175
+ if (token) {
176
+ ps.unsubscribe(token);
177
+ channelToToken.delete(c);
178
+ }
179
+ }
296
180
  }
297
181
  }
298
182
  connToChannels.delete(conn);
@@ -306,7 +190,13 @@ function create(opts) {
306
190
  if (!connToChannels.has(conn)) {
307
191
  throw _err("NOT_ATTACHED", "subscribe: connection must be attach()-ed first");
308
192
  }
309
- if (!channelToConns.has(channel)) channelToConns.set(channel, new Set());
193
+ if (!channelToConns.has(channel)) {
194
+ channelToConns.set(channel, new Set());
195
+ // First local listener for this channel — open a pubsub
196
+ // subscription so cross-node fan-out reaches us.
197
+ var token = ps.subscribe(channel, _onPubsubMessage);
198
+ channelToToken.set(channel, token);
199
+ }
310
200
  channelToConns.get(channel).add(conn);
311
201
  connToChannels.get(conn).add(channel);
312
202
  }
@@ -315,7 +205,14 @@ function create(opts) {
315
205
  var subs = channelToConns.get(channel);
316
206
  if (subs) {
317
207
  subs.delete(conn);
318
- if (subs.size === 0) channelToConns.delete(channel);
208
+ if (subs.size === 0) {
209
+ channelToConns.delete(channel);
210
+ var token = channelToToken.get(channel);
211
+ if (token) {
212
+ ps.unsubscribe(token);
213
+ channelToToken.delete(channel);
214
+ }
215
+ }
319
216
  }
320
217
  var chans = connToChannels.get(conn);
321
218
  if (chans) chans.delete(channel);
@@ -325,25 +222,41 @@ function create(opts) {
325
222
  if (typeof channel !== "string" || channel.length === 0) {
326
223
  throw _err("INVALID_CHANNEL", "publish: channel must be a non-empty string");
327
224
  }
328
- var localCount = _localDispatch(channel, payload);
225
+ // Pre-validate JSON serializability so circular / unserializable
226
+ // payloads throw INVALID_PAYLOAD on the operator's await rather
227
+ // than disappearing into the pubsub dispatcher's per-handler
228
+ // try/catch (where they'd surface only as a warn-level log).
229
+ try { JSON.stringify(payload); }
230
+ catch (e) {
231
+ throw _err("INVALID_PAYLOAD",
232
+ "publish payload is not JSON-serializable: " + (e && e.message));
233
+ }
234
+ lastDispatchCount = 0;
329
235
  var remoteSent = false;
330
- if (typeof backend.publishRemote === "function") {
236
+ var localCount = 0;
237
+ try {
238
+ var rv = await ps.publish(channel, payload);
239
+ // ps.publish invokes _onPubsubMessage synchronously for the
240
+ // local-backend; lastDispatchCount holds the conn-level count.
241
+ // For remote-only backends the handler doesn't fire until a poll
242
+ // tick / push event arrives — at that point the message goes to
243
+ // every node including this one (publishedBy filter excluded for
244
+ // pubsub-cluster, redis sees its own publish via its subscribe
245
+ // socket too). So lastDispatchCount captures the local fan-out.
246
+ localCount = lastDispatchCount;
247
+ remoteSent = (rv && rv.remote > 0);
248
+ } catch (e) {
331
249
  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
- }
250
+ logger().error("publishRemote failed for channel '" + channel + "': " +
251
+ ((e && e.message) || String(e)));
252
+ } catch (_e) { /* logger best-effort */ }
340
253
  }
341
254
  if (auditOn) {
342
255
  audit().safeEmit({
343
256
  action: "system.ws.publish",
344
257
  metadata: {
345
258
  channel: channel,
346
- backend: backend.name,
259
+ backend: backendName,
347
260
  localDelivered: localCount,
348
261
  remoteSent: remoteSent,
349
262
  },
@@ -375,18 +288,20 @@ function create(opts) {
375
288
  return attachedConns.size;
376
289
  }
377
290
 
378
- function close() {
379
- if (typeof backend.stop === "function") backend.stop();
291
+ async function close() {
380
292
  // Drop all subscriptions. Connections are not closed — that's the
381
293
  // operator's call (`router.closeWebSockets()` is the framework's
382
294
  // hook for graceful shutdown).
295
+ for (var token of channelToToken.values()) ps.unsubscribe(token);
296
+ channelToToken.clear();
383
297
  channelToConns.clear();
384
298
  for (var conn of attachedConns) connToChannels.delete(conn);
385
299
  attachedConns.clear();
300
+ await ps.close();
386
301
  }
387
302
 
388
303
  return {
389
- backend: backend.name,
304
+ backend: backendName,
390
305
  attach: attach,
391
306
  detach: detach,
392
307
  subscribe: subscribe,
@@ -398,16 +313,15 @@ function create(opts) {
398
313
  connectionChannels: connectionChannels,
399
314
  attachedCount: attachedCount,
400
315
  close: close,
401
- // Test hook — directly inject a remote message as if the cluster
402
- // backend's poll just received it.
403
- _injectRemoteMessage: _onRemoteMessage,
316
+ // Test hook — directly inject a remote message as if the pubsub
317
+ // backend's transport just received it.
318
+ _injectRemoteMessage: function (channel, payload) {
319
+ _localDispatch(channel, payload);
320
+ },
404
321
  };
405
322
  }
406
323
 
407
324
  module.exports = {
408
325
  create: create,
409
326
  WebSocketChannelsError: WebSocketChannelsError,
410
- // Backend factories exported for tests + advanced operator wiring.
411
- _localBackend: _localBackend,
412
- _clusterBackend: _clusterBackend,
413
327
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.6.32",
3
+ "version": "0.6.34",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:bf737d55-37d5-49a4-95fb-c1149d627468",
5
+ "serialNumber": "urn:uuid:731bee30-9e20-4cc1-87e9-2ca1cf7a6e31",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-02T19:10:22.247Z",
8
+ "timestamp": "2026-05-02T20:14:03.424Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.6.32",
22
+ "bom-ref": "@blamejs/core@0.6.34",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.6.32",
25
+ "version": "0.6.34",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.6.32",
29
+ "purl": "pkg:npm/%40blamejs/core@0.6.34",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.6.32",
57
+ "ref": "@blamejs/core@0.6.34",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]