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
package/server/boot/index.js
CHANGED
|
@@ -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
|
|
package/server/web-server.js
CHANGED
|
@@ -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 };
|
package/server/ws/ws-router.js
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
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) {
|