arcway 0.1.17 → 0.1.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,10 @@
1
+ import cluster from 'node:cluster';
2
+
3
+ function isLikelyClustered() {
4
+ if (process.env.pm_id) return true;
5
+ if (process.env.NODE_APP_INSTANCE) return true;
6
+ if (cluster.isWorker) return true;
7
+ return false;
8
+ }
9
+
10
+ export { isLikelyClustered };
@@ -12,6 +12,7 @@ import { PagesRouter } from '../pages/pages-router.js';
12
12
  import { SystemRouter } from '../system-routes/index.js';
13
13
  import { StaticRouter } from '../static/index.js';
14
14
  import { WsRouter } from '../ws/ws-router.js';
15
+ import { createWsBackplane } from '../ws/backplane.js';
15
16
  import path from 'node:path';
16
17
 
17
18
  async function boot(options) {
@@ -89,10 +90,12 @@ async function boot(options) {
89
90
  publicDir: path.join(rootDir, 'public'),
90
91
  });
91
92
 
93
+ const wsBackplane = createWsBackplane(config, { redis, log });
92
94
  const wsRouter = new WsRouter(config.websocket, {
93
95
  apiRouter,
94
96
  log,
95
97
  sessionConfig: config.session,
98
+ backplane: wsBackplane,
96
99
  });
97
100
 
98
101
  const webServer = new WebServer(config, { log });
@@ -108,7 +111,7 @@ async function boot(options) {
108
111
 
109
112
  await webServer.listen();
110
113
 
111
- wsRouter.attachToServer(webServer.server);
114
+ await wsRouter.attachToServer(webServer.server);
112
115
 
113
116
  jobRunner.start();
114
117
 
@@ -1,10 +1,18 @@
1
1
  const DEFAULTS = {
2
2
  path: '/ws',
3
3
  pingIntervalMs: 30000,
4
+ driver: 'memory',
4
5
  };
5
6
 
7
+ const VALID_DRIVERS = new Set(['memory', 'redis']);
8
+
6
9
  function resolve(config) {
7
10
  const websocket = { ...DEFAULTS, ...config.websocket };
11
+ if (!VALID_DRIVERS.has(websocket.driver)) {
12
+ throw new Error(
13
+ `Invalid config: websocket.driver="${websocket.driver}" must be one of: ${[...VALID_DRIVERS].join(', ')}`,
14
+ );
15
+ }
8
16
  return { ...config, websocket };
9
17
  }
10
18
 
@@ -166,7 +166,7 @@ class WebServer {
166
166
  // Close WS handlers before shutting down the HTTP server
167
167
  for (const { handler } of this.handlers) {
168
168
  if (typeof handler.handleUpgrade === 'function' && typeof handler.close === 'function') {
169
- try { handler.close(); } catch {}
169
+ try { await handler.close(); } catch {}
170
170
  }
171
171
  }
172
172
  if (this.server) {
@@ -0,0 +1,131 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { isLikelyClustered } from '../boot/cluster-detect.js';
3
+
4
+ class WsBackplane {
5
+ constructor({ redis, log, channel = null }) {
6
+ this.workerId = randomUUID();
7
+ this.pub = redis.createClient();
8
+ this.sub = redis.createClient();
9
+ this.channel = channel ?? `${redis.keyPrefix}ws:broadcast`;
10
+ this._log = log?.extend ? log.extend({ logger: 'ws:backplane' }) : log;
11
+ this._onMessage = null;
12
+ this._started = false;
13
+ this._stopped = false;
14
+ this._subMessageHandler = null;
15
+ this._subReadyHandler = null;
16
+ }
17
+
18
+ async start({ onMessage }) {
19
+ if (this._started) return;
20
+ this._started = true;
21
+ this._onMessage = onMessage;
22
+
23
+ this._subMessageHandler = (channel, raw) => {
24
+ if (channel !== this.channel) return;
25
+ let msg;
26
+ try {
27
+ msg = JSON.parse(raw);
28
+ } catch {
29
+ this._log?.warn?.('WsBackplane: malformed message, skipping');
30
+ return;
31
+ }
32
+ if (msg.workerId === this.workerId) return;
33
+ try {
34
+ this._onMessage?.(msg);
35
+ } catch (err) {
36
+ this._log?.error?.(`WsBackplane: onMessage handler threw: ${err}`);
37
+ }
38
+ };
39
+ this.sub.on('message', this._subMessageHandler);
40
+
41
+ this._subReadyHandler = () => {
42
+ this.sub.subscribe(this.channel).catch((err) => {
43
+ this._log?.error?.(`WsBackplane: resubscribe failed: ${err}`);
44
+ });
45
+ };
46
+ this.sub.on('ready', this._subReadyHandler);
47
+
48
+ try {
49
+ await this.sub.subscribe(this.channel);
50
+ } catch (err) {
51
+ this._log?.error?.(`WsBackplane: subscribe failed: ${err}`);
52
+ }
53
+ }
54
+
55
+ publishBroadcast(path, response) {
56
+ this._publish({
57
+ workerId: this.workerId,
58
+ kind: 'broadcast',
59
+ path,
60
+ response,
61
+ });
62
+ }
63
+
64
+ publishSend(socketId, path, response) {
65
+ this._publish({
66
+ workerId: this.workerId,
67
+ kind: 'send',
68
+ socketId,
69
+ path,
70
+ response,
71
+ });
72
+ }
73
+
74
+ _publish(payload) {
75
+ if (this._stopped) return;
76
+ const raw = JSON.stringify(payload);
77
+ Promise.resolve()
78
+ .then(() => this.pub.publish(this.channel, raw))
79
+ .catch((err) => {
80
+ this._log?.warn?.(`WsBackplane: publish failed: ${err}`);
81
+ });
82
+ }
83
+
84
+ async stop() {
85
+ if (this._stopped) return;
86
+ this._stopped = true;
87
+ if (this._subMessageHandler) {
88
+ this.sub.off('message', this._subMessageHandler);
89
+ this._subMessageHandler = null;
90
+ }
91
+ if (this._subReadyHandler) {
92
+ this.sub.off('ready', this._subReadyHandler);
93
+ this._subReadyHandler = null;
94
+ }
95
+ try {
96
+ if (this._started) await this.sub.unsubscribe(this.channel);
97
+ } catch {}
98
+ try {
99
+ this.sub.disconnect();
100
+ } catch {}
101
+ try {
102
+ this.pub.disconnect();
103
+ } catch {}
104
+ this._onMessage = null;
105
+ }
106
+ }
107
+
108
+ function createWsBackplane(config, { redis, log }) {
109
+ const driver = config?.websocket?.driver ?? 'memory';
110
+ if (driver === 'memory') {
111
+ if (isLikelyClustered()) {
112
+ log?.warn?.(
113
+ 'websocket.driver=memory in a detected cluster environment — cross-worker broadcasts will be lost. Set websocket.driver=redis.',
114
+ );
115
+ }
116
+ return null;
117
+ }
118
+ if (driver === 'redis') {
119
+ if (!redis || redis.inMemory || !redis.connected) {
120
+ log?.warn?.(
121
+ 'websocket.driver=redis but no real redis connection is available — falling back to in-process broadcasts. Configure redis.url for cluster-safe broadcasts.',
122
+ );
123
+ return null;
124
+ }
125
+ return new WsBackplane({ redis, log });
126
+ }
127
+ return null;
128
+ }
129
+
130
+ export default WsBackplane;
131
+ export { WsBackplane, createWsBackplane };
@@ -14,13 +14,15 @@ class WsRouter {
14
14
  _path;
15
15
  _enabled;
16
16
  _sessionConfig;
17
+ _backplane;
17
18
 
18
- constructor(config, { apiRouter, log, sessionConfig }) {
19
+ constructor(config, { apiRouter, log, sessionConfig, backplane = null }) {
19
20
  this._apiRouter = apiRouter;
20
21
  this._log = log.extend({ logger: 'ws' });
21
22
  this._pingIntervalMs = config?.pingIntervalMs ?? 3e4;
22
23
  this._path = config?.path ?? '/ws';
23
24
  this._sessionConfig = sessionConfig ?? null;
25
+ this._backplane = backplane;
24
26
 
25
27
  const wsRoutes = apiRouter.routes.filter((r) => r.wsEnabled);
26
28
  this._enabled = wsRoutes.length > 0;
@@ -30,9 +32,15 @@ class WsRouter {
30
32
  }
31
33
  }
32
34
 
33
- attachToServer(httpServer) {
35
+ async attachToServer(httpServer) {
34
36
  if (!this._enabled) return;
35
37
 
38
+ if (this._backplane) {
39
+ await this._backplane.start({
40
+ onMessage: (msg) => this._onBackplaneMessage(msg),
41
+ });
42
+ }
43
+
36
44
  this._io = new SocketIOServer(httpServer, {
37
45
  path: this._path,
38
46
  serveClient: false,
@@ -73,7 +81,7 @@ class WsRouter {
73
81
  return false;
74
82
  }
75
83
 
76
- close() {
84
+ async close() {
77
85
  if (!this._enabled || !this._io) return;
78
86
 
79
87
  unregisterWsServer();
@@ -82,10 +90,17 @@ class WsRouter {
82
90
  for (const client of this._clients.values()) {
83
91
  cleanupPromises.push(this._cleanupClient(client));
84
92
  }
93
+ await Promise.allSettled(cleanupPromises);
85
94
 
86
- Promise.allSettled(cleanupPromises).then(() => {
87
- this._io.close();
88
- });
95
+ if (this._backplane) {
96
+ try {
97
+ await this._backplane.stop();
98
+ } catch (err) {
99
+ this._log.error('WsBackplane stop error', { error: String(err) });
100
+ }
101
+ }
102
+
103
+ this._io.close();
89
104
  }
90
105
 
91
106
  // ── Connection lifecycle ──
@@ -289,6 +304,17 @@ class WsRouter {
289
304
  }
290
305
 
291
306
  _sendToSocket(socketId, path, response) {
307
+ const client = this._clientsBySocketId.get(socketId);
308
+ if (client) {
309
+ this._localSendToSocket(socketId, path, response);
310
+ return;
311
+ }
312
+ if (this._backplane) {
313
+ this._backplane.publishSend(socketId, path, response);
314
+ }
315
+ }
316
+
317
+ _localSendToSocket(socketId, path, response) {
292
318
  const client = this._clientsBySocketId.get(socketId);
293
319
  if (!client) return;
294
320
  this._send(client, {
@@ -300,6 +326,13 @@ class WsRouter {
300
326
  }
301
327
 
302
328
  _broadcastToPath(path, response) {
329
+ if (this._backplane) {
330
+ this._backplane.publishBroadcast(path, response);
331
+ }
332
+ this._localBroadcastToPath(path, response);
333
+ }
334
+
335
+ _localBroadcastToPath(path, response) {
303
336
  const msg = {
304
337
  path,
305
338
  data: response.data,
@@ -313,6 +346,15 @@ class WsRouter {
313
346
  }
314
347
  }
315
348
 
349
+ _onBackplaneMessage(msg) {
350
+ if (!msg || typeof msg !== 'object') return;
351
+ if (msg.kind === 'broadcast') {
352
+ this._localBroadcastToPath(msg.path, msg.response);
353
+ } else if (msg.kind === 'send') {
354
+ this._localSendToSocket(msg.socketId, msg.path, msg.response);
355
+ }
356
+ }
357
+
316
358
  async _cleanupClient(client) {
317
359
  for (const [path, sub] of client.subscriptions) {
318
360
  if (sub.cleanup) {