@camstack/system 1.2.63 → 1.2.64

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.
Files changed (60) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/addon-utils.js +1 -1
  4. package/dist/addon-utils.mjs +1 -1
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  9. package/dist/builtins/alerts/alerts.addon.js +1 -1
  10. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +3 -3
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  13. package/dist/builtins/console-logging/index.js +1 -1
  14. package/dist/builtins/console-logging/index.mjs +1 -1
  15. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  16. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  17. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  18. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  19. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  20. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  21. package/dist/builtins/hub-forwarder/index.js +1 -1
  22. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  23. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  24. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  25. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  26. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  27. package/dist/builtins/local-network/local-network.addon.js +1 -1
  28. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  29. package/dist/builtins/loki-logging/index.js +1 -1
  30. package/dist/builtins/loki-logging/index.mjs +1 -1
  31. package/dist/builtins/native-metrics/native-metrics.addon.js +2 -2
  32. package/dist/builtins/native-metrics/native-metrics.addon.mjs +2 -2
  33. package/dist/builtins/platform-probe/index.js +2 -2
  34. package/dist/builtins/platform-probe/index.mjs +2 -2
  35. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  36. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  37. package/dist/builtins/snapshot/index.js +1 -1
  38. package/dist/builtins/snapshot/index.mjs +1 -1
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  40. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +29 -3
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +29 -3
  43. package/dist/builtins/sqlite-storage/vector-index-vec.d.ts +19 -0
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  46. package/dist/builtins/system-config/system-config.addon.js +1 -1
  47. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  48. package/dist/builtins/winston-logging/index.js +1 -1
  49. package/dist/builtins/winston-logging/index.mjs +1 -1
  50. package/dist/{dist-BJ2w3uoN.mjs → dist-CMb7aKS0.mjs} +39 -1
  51. package/dist/{dist-LDrOFrd-.js → dist-DP3nHlNM.js} +39 -1
  52. package/dist/http/asset-compression.d.ts +93 -0
  53. package/dist/index.js +63 -9
  54. package/dist/index.mjs +63 -9
  55. package/dist/kernel/heap-watch.d.ts +55 -0
  56. package/dist/{model-download-service-D8B-4ktF.mjs → model-download-service-CTBHzORJ.mjs} +220 -2
  57. package/dist/{model-download-service-Cpc6SWzV.js → model-download-service-Caj1kaSi.js} +220 -2
  58. package/package.json +1 -1
  59. package/dist/{manifest-python-deps-DNfmBu20.js → manifest-python-deps-BE6l8Y4W.js} +1 -1
  60. package/dist/{manifest-python-deps-CopL1_5C.mjs → manifest-python-deps-Dz1ryHIN.mjs} +1 -1
@@ -4,6 +4,8 @@ import * as fs from "node:fs";
4
4
  import { createReadStream, promises } from "node:fs";
5
5
  import * as path$1 from "node:path";
6
6
  import path from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { brotliCompress, constants as constants$1, gzip } from "node:zlib";
7
9
  //#region src/http/authenticated-file-server.ts
8
10
  /**
9
11
  * Authenticated data-plane file-server — a shared host primitive for addons that
@@ -214,6 +216,188 @@ async function handle(opts, cors, req, res) {
214
216
  createReadStream(absPath).pipe(res);
215
217
  }
216
218
  //#endregion
219
+ //#region src/http/asset-compression.ts
220
+ /**
221
+ * Content-coding for addon-served static assets: `Accept-Encoding` negotiation,
222
+ * an eligibility rule, and a BOUNDED cache of the compressed bytes.
223
+ *
224
+ * ## Why the ADDON owns the encoding
225
+ *
226
+ * The hub's `/addon/:addonId/*` bridge is registered `{ compress: false }`. That
227
+ * is not an oversight — on 2026-07-16 the global `@fastify/compress` was found
228
+ * emitting an EMPTY brotli stream (`content-encoding: br`, `content-length: 0`)
229
+ * for any compressible body over its 1 KiB threshold once the body had crossed
230
+ * the UDS route bridge of a FORKED addon. It silently broke the notifier SVG
231
+ * icons; the deploy-bundle pull route had already dodged the same class with
232
+ * `compress: false`. The resolution then, and the contract now, is that an addon
233
+ * route owns its content-coding END TO END: it negotiates, it compresses, it
234
+ * stamps `content-encoding` + `Vary`, and the hub pipes those bytes through
235
+ * untouched (`proxyToUpstream` replays the upstream headers verbatim).
236
+ *
237
+ * So compression belongs HERE, in the shared file data-plane, and nowhere above
238
+ * it. Anything that re-compresses on top of this reintroduces the empty-brotli
239
+ * bug rather than doubling the ratio.
240
+ *
241
+ * ## Why the bytes are cached, and why the cache is bounded
242
+ *
243
+ * Measured on the stream-broker embed bundle (`index-<hash>.js`, 1,443,142 B):
244
+ * gzip-6 → 360,868 B, brotli-5 → 327,419 B in 27 ms, brotli-11 → 293,677 B in
245
+ * 2,233 ms. Quality 5 is the request-path choice: 77% off the wire for 27 ms
246
+ * paid ONCE. Quality 11 buys another 2.3% for eighty times the CPU, on a path
247
+ * where a real client is waiting.
248
+ *
249
+ * The cache is an LRU bounded by BOTH entry count and total bytes, and it
250
+ * refuses assets over {@link COMPRESSION_MAX_ASSET_BYTES} outright. An unbounded
251
+ * `Map` keyed by URL is how a long-lived process walks into an OOM, and this
252
+ * repo has already spent a session on one.
253
+ *
254
+ * The key carries `mtimeMs` and `size`, so a rebuild that rewrites an asset in
255
+ * place under an UNCHANGED name (an SPA shell, a service worker) can never be
256
+ * served from the previous build's bytes.
257
+ */
258
+ var brotliAsync = promisify(brotliCompress);
259
+ var gzipAsync = promisify(gzip);
260
+ /** LRU bounds. Both are enforced; whichever binds first evicts. */
261
+ var MAX_CACHE_ENTRIES = 64;
262
+ var MAX_CACHE_BYTES = 32 * 1024 * 1024;
263
+ /** Brotli quality — see the file header for the measured trade. */
264
+ var BROTLI_QUALITY = 5;
265
+ /**
266
+ * Content types worth compressing. An ALLOW-list, not a deny-list: an unknown
267
+ * type (`application/octet-stream`) is assumed to be opaque binary. This is what
268
+ * keeps jpeg/png/webp/mp4/m4s/woff2 out — running brotli over them burns CPU to
269
+ * make the body marginally BIGGER.
270
+ */
271
+ var COMPRESSIBLE_EXACT = new Set([
272
+ "application/javascript",
273
+ "application/ecmascript",
274
+ "application/json",
275
+ "application/manifest+json",
276
+ "application/wasm",
277
+ "application/xml",
278
+ "application/xhtml+xml",
279
+ "application/vnd.apple.mpegurl",
280
+ "image/svg+xml"
281
+ ]);
282
+ /** Is a body of this content type worth compressing at all? */
283
+ function isCompressibleContentType(contentType) {
284
+ const base = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
285
+ if (base.length === 0) return false;
286
+ if (base.startsWith("text/")) return true;
287
+ if (base.endsWith("+json") || base.endsWith("+xml")) return true;
288
+ return COMPRESSIBLE_EXACT.has(base);
289
+ }
290
+ /** Parse `Accept-Encoding` into tokens with their q-values (default 1). */
291
+ function parseAcceptEncoding(header) {
292
+ if (header === void 0 || header.trim().length === 0) return [];
293
+ const out = [];
294
+ for (const part of header.split(",")) {
295
+ const [rawToken, ...params] = part.split(";");
296
+ const token = rawToken?.trim().toLowerCase() ?? "";
297
+ if (token.length === 0) continue;
298
+ let q = 1;
299
+ for (const param of params) {
300
+ const [name, value] = param.split("=");
301
+ if (name?.trim().toLowerCase() !== "q") continue;
302
+ const parsed = Number(value?.trim());
303
+ q = Number.isFinite(parsed) ? parsed : 1;
304
+ }
305
+ out.push({
306
+ token,
307
+ q
308
+ });
309
+ }
310
+ return out;
311
+ }
312
+ function accepts(prefs, token) {
313
+ const exact = prefs.find((p) => p.token === token);
314
+ if (exact) return exact.q > 0;
315
+ const wildcard = prefs.find((p) => p.token === "*");
316
+ return wildcard !== void 0 && wildcard.q > 0;
317
+ }
318
+ /**
319
+ * Pick a coding for this request: brotli when offered, else gzip, else identity.
320
+ *
321
+ * Returns `null` for identity — including when the header is ABSENT. A client
322
+ * that offers nothing gets the raw bytes; guessing on its behalf is how a
323
+ * non-negotiating consumer (a native fetch, a probe, an OTA puller) ends up with
324
+ * a body it cannot read.
325
+ */
326
+ function negotiateContentEncoding(acceptEncoding) {
327
+ const prefs = parseAcceptEncoding(acceptEncoding);
328
+ if (prefs.length === 0) return null;
329
+ if (accepts(prefs, "br")) return "br";
330
+ if (accepts(prefs, "gzip")) return "gzip";
331
+ return null;
332
+ }
333
+ var cache = /* @__PURE__ */ new Map();
334
+ var inFlight = /* @__PURE__ */ new Map();
335
+ var compressions = 0;
336
+ var hits = 0;
337
+ var cachedBytes = 0;
338
+ function cacheKey(key) {
339
+ return `${key.encoding}|${String(key.mtimeMs)}|${String(key.size)}|${key.path}`;
340
+ }
341
+ /** Evict least-recently-used entries until both bounds hold. */
342
+ function evictToBounds() {
343
+ for (const [k, buf] of cache) {
344
+ if (cache.size <= MAX_CACHE_ENTRIES && cachedBytes <= MAX_CACHE_BYTES) return;
345
+ cache.delete(k);
346
+ cachedBytes -= buf.byteLength;
347
+ }
348
+ }
349
+ async function compressBody(raw, encoding) {
350
+ if (encoding === "gzip") return await gzipAsync(raw);
351
+ return await brotliAsync(raw, { params: {
352
+ [constants$1.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
353
+ [constants$1.BROTLI_PARAM_SIZE_HINT]: raw.byteLength
354
+ } });
355
+ }
356
+ /**
357
+ * Compressed bytes for one asset, compressing at most once per key.
358
+ *
359
+ * `null` means "send the original" — the asset is too large to buffer, the
360
+ * source could not be read, or compression did not actually shrink it. Every
361
+ * failure degrades to identity, so this can only ever be an optimisation.
362
+ *
363
+ * Concurrent callers for the same key share ONE compression: a cold cache hit by
364
+ * a dozen parallel asset requests must not run zlib a dozen times.
365
+ */
366
+ async function getCompressedAsset(key, readSource) {
367
+ if (key.size > 8388608) return null;
368
+ const id = cacheKey(key);
369
+ const cached = cache.get(id);
370
+ if (cached) {
371
+ cache.delete(id);
372
+ cache.set(id, cached);
373
+ hits += 1;
374
+ return cached;
375
+ }
376
+ const pending = inFlight.get(id) ?? (async () => {
377
+ let raw;
378
+ try {
379
+ raw = await readSource();
380
+ } catch {
381
+ return null;
382
+ }
383
+ const out = await compressBody(raw, key.encoding);
384
+ compressions += 1;
385
+ if (out.byteLength >= raw.byteLength) return null;
386
+ cache.set(id, out);
387
+ cachedBytes += out.byteLength;
388
+ evictToBounds();
389
+ return out;
390
+ })();
391
+ inFlight.set(id, pending);
392
+ try {
393
+ return await pending;
394
+ } catch {
395
+ return null;
396
+ } finally {
397
+ inFlight.delete(id);
398
+ }
399
+ }
400
+ //#endregion
217
401
  //#region src/http/file-data-plane.ts
218
402
  /**
219
403
  * A ready-made data-plane request handler that serves files from a set of roots
@@ -226,6 +410,11 @@ async function handle(opts, cors, req, res) {
226
410
  * the hub already authenticated the caller and the data plane is same-origin
227
411
  * through the hub's port. Reuses the shared Range/content-type/traversal helpers
228
412
  * so there is one implementation across the standalone file-server and this.
413
+ *
414
+ * It also owns its own CONTENT-CODING. The hub bridge is `{ compress: false }`
415
+ * because the global compressor emits an empty brotli stream over the forked-
416
+ * addon route bridge, so nothing above this layer will ever compress an addon
417
+ * body — see `asset-compression.ts` for the incident and the contract.
229
418
  */
230
419
  /** Build a `(req, res)` handler that serves `getRoots()` files with Range. */
231
420
  function createFileDataPlaneHandler(opts) {
@@ -253,11 +442,13 @@ function createFileDataPlaneHandler(opts) {
253
442
  }
254
443
  let absPath = null;
255
444
  let size = 0;
445
+ let mtimeMs = 0;
256
446
  for (const candidate of resolved.candidates) try {
257
447
  const st = await promises.stat(candidate);
258
448
  if (st.isFile()) {
259
449
  absPath = candidate;
260
450
  size = st.size;
451
+ mtimeMs = st.mtimeMs;
261
452
  break;
262
453
  }
263
454
  } catch {}
@@ -265,10 +456,13 @@ function createFileDataPlaneHandler(opts) {
265
456
  res.writeHead(404).end();
266
457
  return;
267
458
  }
459
+ const contentType = contentTypeFor(absPath, opts.contentTypes);
460
+ const compressible = isCompressibleContentType(contentType);
268
461
  const baseHeaders = {
269
- "content-type": contentTypeFor(absPath, opts.contentTypes),
462
+ "content-type": contentType,
270
463
  "accept-ranges": "bytes",
271
- "cache-control": opts.cacheControl?.(rel) ?? "no-cache"
464
+ "cache-control": opts.cacheControl?.(rel) ?? "no-cache",
465
+ ...compressible ? { vary: "accept-encoding" } : {}
272
466
  };
273
467
  const range = parseRangeHeader(req.headers.range, size);
274
468
  if (range) {
@@ -287,6 +481,30 @@ function createFileDataPlaneHandler(opts) {
287
481
  }).pipe(res);
288
482
  return;
289
483
  }
484
+ const encoding = compressible ? negotiateContentEncoding(req.headers["accept-encoding"]) : null;
485
+ if (encoding !== null && size >= 1024) {
486
+ const filePath = absPath;
487
+ const body = await getCompressedAsset({
488
+ path: filePath,
489
+ mtimeMs,
490
+ size,
491
+ encoding
492
+ }, () => promises.readFile(filePath));
493
+ if (body !== null) {
494
+ res.writeHead(200, {
495
+ ...baseHeaders,
496
+ "content-encoding": encoding,
497
+ "accept-ranges": "none",
498
+ "content-length": String(body.byteLength)
499
+ });
500
+ if (req.method === "HEAD") {
501
+ res.end();
502
+ return;
503
+ }
504
+ res.end(body);
505
+ return;
506
+ }
507
+ }
290
508
  res.writeHead(200, {
291
509
  ...baseHeaders,
292
510
  "content-length": String(size)
@@ -5,6 +5,8 @@ let node_fs = require("node:fs");
5
5
  node_fs = require_chunk.__toESM(node_fs);
6
6
  let node_path = require("node:path");
7
7
  node_path = require_chunk.__toESM(node_path);
8
+ let node_util = require("node:util");
9
+ let node_zlib = require("node:zlib");
8
10
  //#region src/http/authenticated-file-server.ts
9
11
  /**
10
12
  * Authenticated data-plane file-server — a shared host primitive for addons that
@@ -215,6 +217,188 @@ async function handle(opts, cors, req, res) {
215
217
  (0, node_fs.createReadStream)(absPath).pipe(res);
216
218
  }
217
219
  //#endregion
220
+ //#region src/http/asset-compression.ts
221
+ /**
222
+ * Content-coding for addon-served static assets: `Accept-Encoding` negotiation,
223
+ * an eligibility rule, and a BOUNDED cache of the compressed bytes.
224
+ *
225
+ * ## Why the ADDON owns the encoding
226
+ *
227
+ * The hub's `/addon/:addonId/*` bridge is registered `{ compress: false }`. That
228
+ * is not an oversight — on 2026-07-16 the global `@fastify/compress` was found
229
+ * emitting an EMPTY brotli stream (`content-encoding: br`, `content-length: 0`)
230
+ * for any compressible body over its 1 KiB threshold once the body had crossed
231
+ * the UDS route bridge of a FORKED addon. It silently broke the notifier SVG
232
+ * icons; the deploy-bundle pull route had already dodged the same class with
233
+ * `compress: false`. The resolution then, and the contract now, is that an addon
234
+ * route owns its content-coding END TO END: it negotiates, it compresses, it
235
+ * stamps `content-encoding` + `Vary`, and the hub pipes those bytes through
236
+ * untouched (`proxyToUpstream` replays the upstream headers verbatim).
237
+ *
238
+ * So compression belongs HERE, in the shared file data-plane, and nowhere above
239
+ * it. Anything that re-compresses on top of this reintroduces the empty-brotli
240
+ * bug rather than doubling the ratio.
241
+ *
242
+ * ## Why the bytes are cached, and why the cache is bounded
243
+ *
244
+ * Measured on the stream-broker embed bundle (`index-<hash>.js`, 1,443,142 B):
245
+ * gzip-6 → 360,868 B, brotli-5 → 327,419 B in 27 ms, brotli-11 → 293,677 B in
246
+ * 2,233 ms. Quality 5 is the request-path choice: 77% off the wire for 27 ms
247
+ * paid ONCE. Quality 11 buys another 2.3% for eighty times the CPU, on a path
248
+ * where a real client is waiting.
249
+ *
250
+ * The cache is an LRU bounded by BOTH entry count and total bytes, and it
251
+ * refuses assets over {@link COMPRESSION_MAX_ASSET_BYTES} outright. An unbounded
252
+ * `Map` keyed by URL is how a long-lived process walks into an OOM, and this
253
+ * repo has already spent a session on one.
254
+ *
255
+ * The key carries `mtimeMs` and `size`, so a rebuild that rewrites an asset in
256
+ * place under an UNCHANGED name (an SPA shell, a service worker) can never be
257
+ * served from the previous build's bytes.
258
+ */
259
+ var brotliAsync = (0, node_util.promisify)(node_zlib.brotliCompress);
260
+ var gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
261
+ /** LRU bounds. Both are enforced; whichever binds first evicts. */
262
+ var MAX_CACHE_ENTRIES = 64;
263
+ var MAX_CACHE_BYTES = 32 * 1024 * 1024;
264
+ /** Brotli quality — see the file header for the measured trade. */
265
+ var BROTLI_QUALITY = 5;
266
+ /**
267
+ * Content types worth compressing. An ALLOW-list, not a deny-list: an unknown
268
+ * type (`application/octet-stream`) is assumed to be opaque binary. This is what
269
+ * keeps jpeg/png/webp/mp4/m4s/woff2 out — running brotli over them burns CPU to
270
+ * make the body marginally BIGGER.
271
+ */
272
+ var COMPRESSIBLE_EXACT = new Set([
273
+ "application/javascript",
274
+ "application/ecmascript",
275
+ "application/json",
276
+ "application/manifest+json",
277
+ "application/wasm",
278
+ "application/xml",
279
+ "application/xhtml+xml",
280
+ "application/vnd.apple.mpegurl",
281
+ "image/svg+xml"
282
+ ]);
283
+ /** Is a body of this content type worth compressing at all? */
284
+ function isCompressibleContentType(contentType) {
285
+ const base = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
286
+ if (base.length === 0) return false;
287
+ if (base.startsWith("text/")) return true;
288
+ if (base.endsWith("+json") || base.endsWith("+xml")) return true;
289
+ return COMPRESSIBLE_EXACT.has(base);
290
+ }
291
+ /** Parse `Accept-Encoding` into tokens with their q-values (default 1). */
292
+ function parseAcceptEncoding(header) {
293
+ if (header === void 0 || header.trim().length === 0) return [];
294
+ const out = [];
295
+ for (const part of header.split(",")) {
296
+ const [rawToken, ...params] = part.split(";");
297
+ const token = rawToken?.trim().toLowerCase() ?? "";
298
+ if (token.length === 0) continue;
299
+ let q = 1;
300
+ for (const param of params) {
301
+ const [name, value] = param.split("=");
302
+ if (name?.trim().toLowerCase() !== "q") continue;
303
+ const parsed = Number(value?.trim());
304
+ q = Number.isFinite(parsed) ? parsed : 1;
305
+ }
306
+ out.push({
307
+ token,
308
+ q
309
+ });
310
+ }
311
+ return out;
312
+ }
313
+ function accepts(prefs, token) {
314
+ const exact = prefs.find((p) => p.token === token);
315
+ if (exact) return exact.q > 0;
316
+ const wildcard = prefs.find((p) => p.token === "*");
317
+ return wildcard !== void 0 && wildcard.q > 0;
318
+ }
319
+ /**
320
+ * Pick a coding for this request: brotli when offered, else gzip, else identity.
321
+ *
322
+ * Returns `null` for identity — including when the header is ABSENT. A client
323
+ * that offers nothing gets the raw bytes; guessing on its behalf is how a
324
+ * non-negotiating consumer (a native fetch, a probe, an OTA puller) ends up with
325
+ * a body it cannot read.
326
+ */
327
+ function negotiateContentEncoding(acceptEncoding) {
328
+ const prefs = parseAcceptEncoding(acceptEncoding);
329
+ if (prefs.length === 0) return null;
330
+ if (accepts(prefs, "br")) return "br";
331
+ if (accepts(prefs, "gzip")) return "gzip";
332
+ return null;
333
+ }
334
+ var cache = /* @__PURE__ */ new Map();
335
+ var inFlight = /* @__PURE__ */ new Map();
336
+ var compressions = 0;
337
+ var hits = 0;
338
+ var cachedBytes = 0;
339
+ function cacheKey(key) {
340
+ return `${key.encoding}|${String(key.mtimeMs)}|${String(key.size)}|${key.path}`;
341
+ }
342
+ /** Evict least-recently-used entries until both bounds hold. */
343
+ function evictToBounds() {
344
+ for (const [k, buf] of cache) {
345
+ if (cache.size <= MAX_CACHE_ENTRIES && cachedBytes <= MAX_CACHE_BYTES) return;
346
+ cache.delete(k);
347
+ cachedBytes -= buf.byteLength;
348
+ }
349
+ }
350
+ async function compressBody(raw, encoding) {
351
+ if (encoding === "gzip") return await gzipAsync(raw);
352
+ return await brotliAsync(raw, { params: {
353
+ [node_zlib.constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
354
+ [node_zlib.constants.BROTLI_PARAM_SIZE_HINT]: raw.byteLength
355
+ } });
356
+ }
357
+ /**
358
+ * Compressed bytes for one asset, compressing at most once per key.
359
+ *
360
+ * `null` means "send the original" — the asset is too large to buffer, the
361
+ * source could not be read, or compression did not actually shrink it. Every
362
+ * failure degrades to identity, so this can only ever be an optimisation.
363
+ *
364
+ * Concurrent callers for the same key share ONE compression: a cold cache hit by
365
+ * a dozen parallel asset requests must not run zlib a dozen times.
366
+ */
367
+ async function getCompressedAsset(key, readSource) {
368
+ if (key.size > 8388608) return null;
369
+ const id = cacheKey(key);
370
+ const cached = cache.get(id);
371
+ if (cached) {
372
+ cache.delete(id);
373
+ cache.set(id, cached);
374
+ hits += 1;
375
+ return cached;
376
+ }
377
+ const pending = inFlight.get(id) ?? (async () => {
378
+ let raw;
379
+ try {
380
+ raw = await readSource();
381
+ } catch {
382
+ return null;
383
+ }
384
+ const out = await compressBody(raw, key.encoding);
385
+ compressions += 1;
386
+ if (out.byteLength >= raw.byteLength) return null;
387
+ cache.set(id, out);
388
+ cachedBytes += out.byteLength;
389
+ evictToBounds();
390
+ return out;
391
+ })();
392
+ inFlight.set(id, pending);
393
+ try {
394
+ return await pending;
395
+ } catch {
396
+ return null;
397
+ } finally {
398
+ inFlight.delete(id);
399
+ }
400
+ }
401
+ //#endregion
218
402
  //#region src/http/file-data-plane.ts
219
403
  /**
220
404
  * A ready-made data-plane request handler that serves files from a set of roots
@@ -227,6 +411,11 @@ async function handle(opts, cors, req, res) {
227
411
  * the hub already authenticated the caller and the data plane is same-origin
228
412
  * through the hub's port. Reuses the shared Range/content-type/traversal helpers
229
413
  * so there is one implementation across the standalone file-server and this.
414
+ *
415
+ * It also owns its own CONTENT-CODING. The hub bridge is `{ compress: false }`
416
+ * because the global compressor emits an empty brotli stream over the forked-
417
+ * addon route bridge, so nothing above this layer will ever compress an addon
418
+ * body — see `asset-compression.ts` for the incident and the contract.
230
419
  */
231
420
  /** Build a `(req, res)` handler that serves `getRoots()` files with Range. */
232
421
  function createFileDataPlaneHandler(opts) {
@@ -254,11 +443,13 @@ function createFileDataPlaneHandler(opts) {
254
443
  }
255
444
  let absPath = null;
256
445
  let size = 0;
446
+ let mtimeMs = 0;
257
447
  for (const candidate of resolved.candidates) try {
258
448
  const st = await node_fs.promises.stat(candidate);
259
449
  if (st.isFile()) {
260
450
  absPath = candidate;
261
451
  size = st.size;
452
+ mtimeMs = st.mtimeMs;
262
453
  break;
263
454
  }
264
455
  } catch {}
@@ -266,10 +457,13 @@ function createFileDataPlaneHandler(opts) {
266
457
  res.writeHead(404).end();
267
458
  return;
268
459
  }
460
+ const contentType = contentTypeFor(absPath, opts.contentTypes);
461
+ const compressible = isCompressibleContentType(contentType);
269
462
  const baseHeaders = {
270
- "content-type": contentTypeFor(absPath, opts.contentTypes),
463
+ "content-type": contentType,
271
464
  "accept-ranges": "bytes",
272
- "cache-control": opts.cacheControl?.(rel) ?? "no-cache"
465
+ "cache-control": opts.cacheControl?.(rel) ?? "no-cache",
466
+ ...compressible ? { vary: "accept-encoding" } : {}
273
467
  };
274
468
  const range = parseRangeHeader(req.headers.range, size);
275
469
  if (range) {
@@ -288,6 +482,30 @@ function createFileDataPlaneHandler(opts) {
288
482
  }).pipe(res);
289
483
  return;
290
484
  }
485
+ const encoding = compressible ? negotiateContentEncoding(req.headers["accept-encoding"]) : null;
486
+ if (encoding !== null && size >= 1024) {
487
+ const filePath = absPath;
488
+ const body = await getCompressedAsset({
489
+ path: filePath,
490
+ mtimeMs,
491
+ size,
492
+ encoding
493
+ }, () => node_fs.promises.readFile(filePath));
494
+ if (body !== null) {
495
+ res.writeHead(200, {
496
+ ...baseHeaders,
497
+ "content-encoding": encoding,
498
+ "accept-ranges": "none",
499
+ "content-length": String(body.byteLength)
500
+ });
501
+ if (req.method === "HEAD") {
502
+ res.end();
503
+ return;
504
+ }
505
+ res.end(body);
506
+ return;
507
+ }
508
+ }
291
509
  res.writeHead(200, {
292
510
  ...baseHeaders,
293
511
  "content-length": String(size)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.2.63",
3
+ "version": "1.2.64",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",
@@ -7,9 +7,9 @@ let node_fs = require("node:fs");
7
7
  node_fs = require_chunk.__toESM(node_fs);
8
8
  let node_path = require("node:path");
9
9
  node_path = require_chunk.__toESM(node_path);
10
+ let node_util = require("node:util");
10
11
  let _camstack_types_addon = require("@camstack/types/addon");
11
12
  let node_child_process = require("node:child_process");
12
- let node_util = require("node:util");
13
13
  let node_os = require("node:os");
14
14
  node_os = require_chunk.__toESM(node_os);
15
15
  let node_fs_promises = require("node:fs/promises");
@@ -5,9 +5,9 @@ import { createServer } from "node:http";
5
5
  import * as fs from "node:fs";
6
6
  import * as path$1 from "node:path";
7
7
  import { isAbsolute, join } from "node:path";
8
+ import { promisify } from "node:util";
8
9
  import { DATAPLANE_SECRET_HEADER, DeviceType, DisposerChain, EventCategory, ReadinessRegistry, asJsonObject, asString, createDeviceProxy, deviceOpsCapability, emitReadiness, errMsg, expandCapMethods, readNodePin, scopeKey, sleep, withCapInputDefaults } from "@camstack/types/addon";
9
10
  import { execFile } from "node:child_process";
10
- import { promisify } from "node:util";
11
11
  import * as os from "node:os";
12
12
  import { tmpdir } from "node:os";
13
13
  import { unlink } from "node:fs/promises";