arcway 0.1.16 → 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.16",
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
 
@@ -91,6 +91,18 @@ async function cpAtomic(src, dst) {
91
91
  }
92
92
  }
93
93
 
94
+ // Best-effort LRU timestamp bump. File mtime is what enforceCacheBudget orders
95
+ // by, so this is how we record "recently used" for cache hits. Swallow errors —
96
+ // a concurrent evictor may have removed the file already; that's fine.
97
+ async function touchMeta(metaPath) {
98
+ const now = new Date();
99
+ try {
100
+ await fs.utimes(metaPath, now, now);
101
+ } catch {
102
+ // Ignore: entry may have been evicted concurrently.
103
+ }
104
+ }
105
+
94
106
  async function lookupBundle({ rootDir, entryPath, configHash, kind }) {
95
107
  const bucket = bucketDir(rootDir, kind);
96
108
  const key = entryKey(entryPath);
@@ -113,6 +125,7 @@ async function lookupBundle({ rootDir, entryPath, configHash, kind }) {
113
125
  } catch {
114
126
  return { hit: false, key, bucket, metaPath, jsPath, mapPath };
115
127
  }
128
+ await touchMeta(metaPath);
116
129
  return { hit: true, key, bucket, metaPath, jsPath, mapPath };
117
130
  }
118
131
 
@@ -194,10 +207,26 @@ async function buildWithCache({
194
207
  return { cacheHit: false, metafile: result.metafile };
195
208
  }
196
209
 
210
+ // Canonical, order-independent hash of a virtualInputs list so stored/current
211
+ // sets can be compared without worrying about caller ordering.
212
+ function hashVirtualInputs(virtualInputs) {
213
+ if (!virtualInputs || virtualInputs.length === 0) return '';
214
+ const sorted = [...virtualInputs]
215
+ .map((v) => ({ name: v.name, digest: v.digest }))
216
+ .sort((a, b) => a.name.localeCompare(b.name));
217
+ return sha256(JSON.stringify(sorted));
218
+ }
219
+
197
220
  // Multi-file cache for bundles with several output files (client build with
198
221
  // code-splitting). Each cache entry lives under <bucket>/<coarseKey>/ and the
199
222
  // index is a sidecar <bucket>/<coarseKey>.meta.json.
200
- async function lookupMultiFileCache({ rootDir, kind, coarseKey }) {
223
+ //
224
+ // `virtualInputs` (optional) are caller-computed digests that can't be
225
+ // represented as a file path — e.g. the set of class-name tokens extracted
226
+ // from a source tree. Each entry is `{ name, digest }`. The caller must
227
+ // re-compute and pass current values on every lookup; the stored and current
228
+ // digest sets must match exactly for a hit.
229
+ async function lookupMultiFileCache({ rootDir, kind, coarseKey, virtualInputs }) {
201
230
  const bucket = bucketDir(rootDir, kind);
202
231
  const metaPath = path.join(bucket, `${coarseKey}.meta.json`);
203
232
  const cacheDir = path.join(bucket, coarseKey);
@@ -207,6 +236,14 @@ async function lookupMultiFileCache({ rootDir, kind, coarseKey }) {
207
236
  if (currentHash === null || currentHash !== meta.inputsHash) {
208
237
  return { hit: false, bucket, cacheDir, metaPath };
209
238
  }
239
+ // Compare virtual-input digest sets. If the stored entry has virtualInputs
240
+ // but the caller omitted them, treat as a miss — a silent hit would serve
241
+ // stale output for whatever derived state the virtual digest tracks.
242
+ const storedVirtualHash = meta.virtualInputsHash ?? '';
243
+ const currentVirtualHash = hashVirtualInputs(virtualInputs);
244
+ if (storedVirtualHash !== currentVirtualHash) {
245
+ return { hit: false, bucket, cacheDir, metaPath };
246
+ }
210
247
  // Sanity-check every recorded output file still exists in the cache dir.
211
248
  for (const rel of meta.outputs) {
212
249
  try {
@@ -215,6 +252,7 @@ async function lookupMultiFileCache({ rootDir, kind, coarseKey }) {
215
252
  return { hit: false, bucket, cacheDir, metaPath };
216
253
  }
217
254
  }
255
+ await touchMeta(metaPath);
218
256
  return { hit: true, bucket, cacheDir, metaPath, meta };
219
257
  }
220
258
 
@@ -236,6 +274,7 @@ async function storeMultiFileCache({
236
274
  destDir,
237
275
  outputs,
238
276
  inputs,
277
+ virtualInputs,
239
278
  metadata,
240
279
  }) {
241
280
  const cacheDir = path.join(bucket, coarseKey);
@@ -250,16 +289,166 @@ async function storeMultiFileCache({
250
289
  }),
251
290
  );
252
291
  const inputsHash = await hashInputs(inputs);
292
+ const virtualInputsHash = hashVirtualInputs(virtualInputs);
253
293
  const metaPath = path.join(bucket, `${coarseKey}.meta.json`);
254
294
  await writeJsonAtomic(metaPath, {
255
295
  inputs,
256
296
  inputsHash,
297
+ virtualInputs: virtualInputs ?? [],
298
+ virtualInputsHash,
257
299
  outputs,
258
300
  metadata,
259
301
  mtime: Date.now(),
260
302
  });
261
303
  }
262
304
 
305
+ // Default cap for the on-disk cache. Override with ARCWAY_PAGES_CACHE_MAX_BYTES
306
+ // (bytes as an integer). Anything over the cap is LRU-evicted after each store.
307
+ const DEFAULT_CACHE_MAX_BYTES = 500 * 1024 * 1024;
308
+
309
+ // Newly-written entries are protected from eviction for this many milliseconds
310
+ // to avoid racing with in-flight stores from sibling forks. 5s comfortably
311
+ // exceeds a typical per-entry store (cpAtomic + writeJsonAtomic).
312
+ const DEFAULT_CACHE_GRACE_PERIOD_MS = 5000;
313
+
314
+ function cacheMaxBytes() {
315
+ const raw = Number(process.env.ARCWAY_PAGES_CACHE_MAX_BYTES);
316
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_CACHE_MAX_BYTES;
317
+ }
318
+
319
+ async function statOrNull(filePath) {
320
+ try {
321
+ return await fs.stat(filePath);
322
+ } catch {
323
+ return null;
324
+ }
325
+ }
326
+
327
+ // Recursively measure a directory's total size and the most-recent mtime of any
328
+ // file/subdir inside it. Used for multi-file cache entries whose body lives in a
329
+ // sibling dir. Returns {size: 0, mtime: 0} when the dir is missing.
330
+ async function measureDir(dir) {
331
+ let size = 0;
332
+ let mtime = 0;
333
+ let items;
334
+ try {
335
+ items = await fs.readdir(dir, { withFileTypes: true });
336
+ } catch {
337
+ return { size, mtime };
338
+ }
339
+ for (const item of items) {
340
+ const abs = path.join(dir, item.name);
341
+ if (item.isDirectory()) {
342
+ const sub = await measureDir(abs);
343
+ size += sub.size;
344
+ if (sub.mtime > mtime) mtime = sub.mtime;
345
+ } else {
346
+ const s = await statOrNull(abs);
347
+ if (s) {
348
+ size += s.size;
349
+ if (s.mtimeMs > mtime) mtime = s.mtimeMs;
350
+ }
351
+ }
352
+ }
353
+ return { size, mtime };
354
+ }
355
+
356
+ // Enumerate every cache entry across all kinds. Each entry is identified by its
357
+ // sidecar `<key>.meta.json` file. Single-file entries have an accompanying
358
+ // `<key>.js` (+ optional `.js.map`); multi-file entries have a `<coarseKey>/`
359
+ // dir. We collect paths for both shapes so removal is unconditional.
360
+ async function collectCacheEntries(rootDir) {
361
+ const root = cacheRoot(rootDir);
362
+ let kindDirs;
363
+ try {
364
+ kindDirs = await fs.readdir(root, { withFileTypes: true });
365
+ } catch {
366
+ return [];
367
+ }
368
+ const entries = [];
369
+ for (const kindEnt of kindDirs) {
370
+ if (!kindEnt.isDirectory()) continue;
371
+ const bucket = path.join(root, kindEnt.name);
372
+ let files;
373
+ try {
374
+ files = await fs.readdir(bucket);
375
+ } catch {
376
+ continue;
377
+ }
378
+ for (const file of files) {
379
+ if (!file.endsWith('.meta.json')) continue;
380
+ const baseKey = file.slice(0, -'.meta.json'.length);
381
+ const metaPath = path.join(bucket, file);
382
+ const jsPath = path.join(bucket, `${baseKey}.js`);
383
+ const mapPath = path.join(bucket, `${baseKey}.js.map`);
384
+ const dirPath = path.join(bucket, baseKey);
385
+
386
+ const metaStat = await statOrNull(metaPath);
387
+ if (!metaStat) continue;
388
+ const jsStat = await statOrNull(jsPath);
389
+ const mapStat = await statOrNull(mapPath);
390
+ const dirInfo = await measureDir(dirPath);
391
+
392
+ const size = metaStat.size + (jsStat?.size ?? 0) + (mapStat?.size ?? 0) + dirInfo.size;
393
+ // Use the most recent mtime across meta + dir contents. A concurrent
394
+ // multi-file store rewrites `.meta.json` last but populates the dir
395
+ // first, so dir mtime can be newer than meta mtime during a store.
396
+ const mtime = Math.max(metaStat.mtimeMs, dirInfo.mtime);
397
+ entries.push({ metaPath, jsPath, mapPath, dirPath, size, mtime });
398
+ }
399
+ }
400
+ return entries;
401
+ }
402
+
403
+ async function removeCacheEntry(entry) {
404
+ await Promise.allSettled([
405
+ fs.rm(entry.metaPath, { force: true }),
406
+ fs.rm(entry.jsPath, { force: true }),
407
+ fs.rm(entry.mapPath, { force: true }),
408
+ fs.rm(entry.dirPath, { recursive: true, force: true }),
409
+ ]);
410
+ }
411
+
412
+ // Enforce a byte-budget over the on-disk cache. Oldest (by filesystem mtime)
413
+ // entries are evicted first; entries whose mtime is within `gracePeriodMs` of
414
+ // `now` are protected so an in-flight concurrent store isn't yanked out from
415
+ // under a sibling fork. Lock-free and idempotent: multiple forks may run this
416
+ // concurrently and at worst slightly over-evict, which self-corrects on the
417
+ // next cold build.
418
+ async function enforceCacheBudget({
419
+ rootDir,
420
+ maxBytes = cacheMaxBytes(),
421
+ gracePeriodMs = DEFAULT_CACHE_GRACE_PERIOD_MS,
422
+ now = Date.now(),
423
+ } = {}) {
424
+ const entries = await collectCacheEntries(rootDir);
425
+ const totalSize = entries.reduce((s, e) => s + e.size, 0);
426
+ if (totalSize <= maxBytes) {
427
+ return { evicted: 0, kept: entries.length, bytesFreed: 0, bytesRemaining: totalSize };
428
+ }
429
+
430
+ const candidates = entries
431
+ .filter((e) => now - e.mtime >= gracePeriodMs)
432
+ .sort((a, b) => a.mtime - b.mtime);
433
+
434
+ let remaining = totalSize;
435
+ let freed = 0;
436
+ let evicted = 0;
437
+ for (const cand of candidates) {
438
+ if (remaining <= maxBytes) break;
439
+ await removeCacheEntry(cand);
440
+ remaining -= cand.size;
441
+ freed += cand.size;
442
+ evicted += 1;
443
+ }
444
+ return {
445
+ evicted,
446
+ kept: entries.length - evicted,
447
+ bytesFreed: freed,
448
+ bytesRemaining: remaining,
449
+ };
450
+ }
451
+
263
452
  // Walk every cache entry in <rootDir>/node_modules/.cache/arcway-pages/<kind>
264
453
  // and drop any whose recorded inputs no longer hash to the stored value (or
265
454
  // whose inputs are gone). Used by tests that intentionally mutate fixture
@@ -310,6 +499,8 @@ export {
310
499
  buildWithCache,
311
500
  computeConfigHash,
312
501
  cacheRoot,
502
+ DEFAULT_CACHE_MAX_BYTES,
503
+ enforceCacheBudget,
313
504
  entryKey,
314
505
  ESBUILD_VERSION,
315
506
  sha256,
@@ -11,10 +11,16 @@ import {
11
11
  restoreMultiFileCache,
12
12
  storeMultiFileCache,
13
13
  } from './build-cache.js';
14
+ import { hashClassTokens } from './class-token-scan.js';
14
15
 
15
- // Shallow-walk the pages dir and return every source-shaped file. Used as the
16
- // cache-invalidation input set when tailwind will scan it via @source.
17
- async function collectTailwindSources(pagesDir) {
16
+ // Shallow-walk pagesDir for the CSS files tailwind will scan; these are
17
+ // tracked as real file inputs so any byte-level edit invalidates the cache.
18
+ // JSX/TS/etc. are not included here — they go through hashClassTokens(), which
19
+ // produces a virtual-input digest that only busts the cache when the set of
20
+ // string-literal tokens changes. That's a superset of every class name
21
+ // tailwind could emit, and ignores unrelated edits (logic, whitespace,
22
+ // comments) that can't affect the generated CSS.
23
+ async function collectCssFiles(pagesDir) {
18
24
  const out = [];
19
25
  async function walk(dir) {
20
26
  let entries;
@@ -28,10 +34,8 @@ async function collectTailwindSources(pagesDir) {
28
34
  if (e.isDirectory()) {
29
35
  if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
30
36
  await walk(full);
31
- } else if (e.isFile()) {
32
- if (/\.(jsx?|tsx?|mjs|cjs|css)$/i.test(e.name)) {
33
- out.push(full);
34
- }
37
+ } else if (e.isFile() && /\.css$/i.test(e.name)) {
38
+ out.push(full);
35
39
  }
36
40
  }
37
41
  }
@@ -60,16 +64,26 @@ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss,
60
64
  rootDir = rootDir ?? path.dirname(outDir);
61
65
  const coarseKey = computeCssCoarseKey({ minify, fontFaceCss, stylesPath });
62
66
 
63
- // Inputs that should invalidate the cache on content change.
67
+ // File inputs: stylesPath + every CSS file under pagesDir. These are hashed
68
+ // by full content — any byte change should invalidate the cache because raw
69
+ // CSS ends up in the output verbatim.
64
70
  const inputs = [];
65
- if (stylesPath) {
66
- inputs.push(path.resolve(stylesPath));
67
- } else {
68
- const scanned = await collectTailwindSources(pagesDir);
69
- for (const f of scanned) inputs.push(f);
70
- }
71
+ if (stylesPath) inputs.push(path.resolve(stylesPath));
72
+ const cssFiles = await collectCssFiles(pagesDir);
73
+ for (const f of cssFiles) inputs.push(f);
74
+
75
+ // Virtual input: a digest over every string-literal token found in the
76
+ // JSX/TS source tree under pagesDir. Tailwind's class-name emission depends
77
+ // only on which tokens appear in scanned files — not on surrounding code
78
+ // structure — so the digest captures the only source signal that actually
79
+ // matters for CSS output. Editing handler logic, comments, or whitespace
80
+ // leaves this digest unchanged and the CSS cache is reused.
81
+ const classTokensDigest = await hashClassTokens(pagesDir);
82
+ const virtualInputs = [
83
+ { name: `${path.resolve(pagesDir)}::class-tokens`, digest: classTokensDigest },
84
+ ];
71
85
 
72
- const lookup = await lookupMultiFileCache({ rootDir, kind: 'css', coarseKey });
86
+ const lookup = await lookupMultiFileCache({ rootDir, kind: 'css', coarseKey, virtualInputs });
73
87
  if (lookup.hit) {
74
88
  await restoreMultiFileCache({
75
89
  cacheDir: lookup.cacheDir,
@@ -114,6 +128,7 @@ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss,
114
128
  destDir: clientDir,
115
129
  outputs: [cssFileName],
116
130
  inputs,
131
+ virtualInputs,
117
132
  metadata: { cssBundleRel },
118
133
  });
119
134
 
@@ -20,6 +20,7 @@ import { buildCssBundle } from './build-css.js';
20
20
  import { generateManifest } from './build-manifest.js';
21
21
  import { resolveFonts } from './fonts.js';
22
22
  import { buildHmrRuntimeBundle } from './hmr.js';
23
+ import { enforceCacheBudget } from './build-cache.js';
23
24
  async function buildPages(options) {
24
25
  const { rootDir } = options;
25
26
  const pagesDir = options.pagesDir ?? path.join(rootDir, 'pages');
@@ -107,6 +108,13 @@ async function buildPages(options) {
107
108
  JSON.stringify(manifest, null, 2),
108
109
  );
109
110
  await atomicSwap(outDir, buildDir);
111
+ // Keep the on-disk cache bounded. Fire-and-forget so the build returns
112
+ // immediately; eviction is idempotent across concurrent builds and any
113
+ // failure is non-fatal (worst case the cache grows past the cap until the
114
+ // next build). Tracked via `cacheEvictions` for test quiescence.
115
+ const eviction = enforceCacheBudget({ rootDir }).catch(() => {});
116
+ cacheEvictions.add(eviction);
117
+ eviction.finally(() => cacheEvictions.delete(eviction));
110
118
  const clientMetafile =
111
119
  devMode && clientResult.metafile
112
120
  ? rewriteMetafilePaths(clientResult.metafile, buildDir, outDir)
@@ -136,9 +144,12 @@ async function buildPages(options) {
136
144
  // Tracks fire-and-forget cleanup promises from failed builds so tests can
137
145
  // await `buildPagesIdle()` and see a clean `.build` dir.
138
146
  const pendingCleanups = new Set();
147
+ // Tracks fire-and-forget cache-eviction promises from successful builds so
148
+ // tests can await full quiescence and assert on post-eviction cache state.
149
+ const cacheEvictions = new Set();
139
150
  async function buildPagesIdle() {
140
- while (pendingCleanups.size > 0) {
141
- await Promise.allSettled([...pendingCleanups]);
151
+ while (pendingCleanups.size > 0 || cacheEvictions.size > 0) {
152
+ await Promise.allSettled([...pendingCleanups, ...cacheEvictions]);
142
153
  }
143
154
  }
144
155
  // Serialize concurrent swaps targeting the same outDir. Without this, two
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ const SOURCE_FILE_PATTERN = /\.(jsx?|tsx?|mjs|cjs)$/i;
6
+
7
+ // Match the *content* of any string literal — double-quoted, single-quoted, or
8
+ // backtick template. Escape-aware so `\"` doesn't end a double-quoted string
9
+ // prematurely. Template interpolation `${...}` is captured verbatim inside the
10
+ // backtick group; we don't try to parse it out, so interpolation syntax leaks
11
+ // into tokens. That's a benign false-positive signal — if the expression's
12
+ // source changes, the digest changes; tailwind's own oxide scanner has the
13
+ // same boundary limitation.
14
+ const STRING_LITERAL_RE =
15
+ /"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|`((?:[^`\\]|\\.)*)`/g;
16
+
17
+ // Extract every whitespace-separated token found inside any string literal in
18
+ // `content`. Returns a Set so callers can take a union across files. We don't
19
+ // try to filter to "looks-like-a-tailwind-class" shapes because tailwind v4
20
+ // accepts arbitrary-value classes like `hover:bg-[#fff]` whose superset of
21
+ // allowed characters is broad. Over-inclusion is safe — an extra token only
22
+ // causes unnecessary cache busts on unrelated edits, not stale output.
23
+ function extractClassTokens(content) {
24
+ const tokens = new Set();
25
+ let match;
26
+ while ((match = STRING_LITERAL_RE.exec(content)) !== null) {
27
+ const body = match[1] ?? match[2] ?? match[3] ?? '';
28
+ for (const part of body.split(/\s+/)) {
29
+ if (part.length > 0) tokens.add(part);
30
+ }
31
+ }
32
+ return tokens;
33
+ }
34
+
35
+ async function walkSources(dir, onFile) {
36
+ let entries;
37
+ try {
38
+ entries = await fs.readdir(dir, { withFileTypes: true });
39
+ } catch (err) {
40
+ if (err.code === 'ENOENT') return;
41
+ throw err;
42
+ }
43
+ for (const entry of entries) {
44
+ const full = path.join(dir, entry.name);
45
+ if (entry.isDirectory()) {
46
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
47
+ await walkSources(full, onFile);
48
+ } else if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) {
49
+ await onFile(full);
50
+ }
51
+ }
52
+ }
53
+
54
+ // Union every class-name-shaped token across every JSX/TS source under
55
+ // `pagesDir`, sort, and hash. Sort so the digest is order-independent —
56
+ // adding/removing files only affects the digest via the *set* of tokens, not
57
+ // filesystem enumeration order, which prevents false busts from filesystem
58
+ // reordering across platforms.
59
+ async function hashClassTokens(pagesDir) {
60
+ const tokens = new Set();
61
+ await walkSources(pagesDir, async (filePath) => {
62
+ const content = await fs.readFile(filePath, 'utf-8');
63
+ for (const token of extractClassTokens(content)) {
64
+ tokens.add(token);
65
+ }
66
+ });
67
+ const sorted = [...tokens].sort();
68
+ return crypto.createHash('sha256').update(sorted.join('\n')).digest('hex');
69
+ }
70
+
71
+ export { extractClassTokens, hashClassTokens };
@@ -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) {