@camstack/system 1.2.62 → 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.
- package/dist/addon-runner.js +1 -1
- package/dist/addon-runner.mjs +1 -1
- package/dist/addon-utils.js +1 -1
- package/dist/addon-utils.mjs +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +3 -3
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/block-package.d.ts +9 -27
- package/dist/builtins/core-blocks/block-supervisor.d.ts +15 -0
- package/dist/builtins/core-blocks/core-blocks.addon.js +91 -24
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +91 -25
- package/dist/builtins/device-manager/device-bindings-store.d.ts +28 -5
- package/dist/builtins/device-manager/device-linked-devices.d.ts +2 -2
- package/dist/builtins/device-manager/device-manager.addon.d.ts +3 -45
- package/dist/builtins/device-manager/device-manager.addon.js +123 -884
- package/dist/builtins/device-manager/device-manager.addon.mjs +123 -884
- package/dist/builtins/device-manager/device-meta-actions.d.ts +0 -8
- package/dist/builtins/device-manager/device-meta-store.d.ts +1 -28
- package/dist/builtins/device-manager/device-meta-types.d.ts +2 -6
- package/dist/builtins/device-manager/device-projection.d.ts +1 -2
- package/dist/builtins/device-manager/device-provider-context.d.ts +2 -13
- package/dist/builtins/device-manager/device-queries.d.ts +1 -1
- package/dist/builtins/device-manager/device-state-mirror.d.ts +8 -49
- package/dist/builtins/device-manager/device-sync-resolvers.d.ts +0 -11
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/homeassistant-integration.d.ts +7 -0
- package/dist/builtins/local-auth/index.d.ts +1 -0
- package/dist/builtins/local-auth/index.js +1 -0
- package/dist/builtins/local-auth/index.mjs +2 -2
- package/dist/builtins/local-auth/local-auth.addon.js +561 -497
- package/dist/builtins/local-auth/local-auth.addon.mjs +580 -517
- package/dist/builtins/local-auth/oauth-grants.d.ts +11 -0
- package/dist/builtins/local-auth/oauth-session-manager.d.ts +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +2 -2
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +2 -2
- package/dist/builtins/platform-probe/index.js +2 -2
- package/dist/builtins/platform-probe/index.mjs +2 -2
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +319 -20
- package/dist/builtins/snapshot/index.mjs +319 -20
- package/dist/builtins/snapshot/snapshot-keep-warm.d.ts +86 -0
- package/dist/builtins/snapshot/snapshot-media-handler.d.ts +48 -1
- package/dist/builtins/snapshot/snapshot.addon.d.ts +65 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/retired-settings-keys.d.ts +15 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +73 -7
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +73 -7
- package/dist/builtins/sqlite-storage/vector-index-vec.d.ts +19 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-DnKpFaP1.mjs → dist-CMb7aKS0.mjs} +1227 -1330
- package/dist/{dist-DgPUarcs.js → dist-DP3nHlNM.js} +1251 -1366
- package/dist/http/asset-compression.d.ts +93 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +64 -9
- package/dist/index.mjs +65 -11
- package/dist/kernel/heap-watch.d.ts +55 -0
- package/dist/kernel/moleculer/device-cap-proxy.d.ts +2 -2
- package/dist/{manifest-python-deps-d4acYXVV.js → manifest-python-deps-BE6l8Y4W.js} +21 -15
- package/dist/{manifest-python-deps-CX5tGRqf.mjs → manifest-python-deps-Dz1ryHIN.mjs} +21 -15
- package/dist/{model-download-service-D8B-4ktF.mjs → model-download-service-CTBHzORJ.mjs} +220 -2
- package/dist/{model-download-service-Cpc6SWzV.js → model-download-service-Caj1kaSi.js} +220 -2
- package/package.json +5 -1
- package/dist/builtins/device-manager/device-link-cycle.d.ts +0 -18
- package/dist/builtins/device-manager/device-link-overlay.d.ts +0 -33
- package/dist/builtins/device-manager/device-link-resolver.d.ts +0 -44
- package/dist/main-CVLq8IzA.js +0 -2356
- package/dist/main-CzH19GkU.mjs +0 -2353
|
@@ -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":
|
|
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":
|
|
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.
|
|
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",
|
|
@@ -267,6 +267,9 @@
|
|
|
267
267
|
},
|
|
268
268
|
{
|
|
269
269
|
"name": "user-management"
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
"name": "oauth-integration"
|
|
270
273
|
}
|
|
271
274
|
]
|
|
272
275
|
},
|
|
@@ -349,6 +352,7 @@
|
|
|
349
352
|
"bcryptjs": "^3.0.3",
|
|
350
353
|
"better-sqlite3": "^12.8.0",
|
|
351
354
|
"croner": "^10.0.1",
|
|
355
|
+
"esbuild": "^0.27.7",
|
|
352
356
|
"js-yaml": "^4.1.0",
|
|
353
357
|
"jsonwebtoken": "^9.0.0",
|
|
354
358
|
"moleculer": "^0.15.0",
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { DeviceLink } from '@camstack/types';
|
|
2
|
-
/** The slice of a persisted meta row cycle detection needs. Structural subset
|
|
3
|
-
* of `PersistedDeviceMeta` so tests (and future callers) can pass plain rows. */
|
|
4
|
-
export interface LinkCycleMetaRow {
|
|
5
|
-
readonly id: number;
|
|
6
|
-
readonly stableId: string;
|
|
7
|
-
readonly parentDeviceId: number | null;
|
|
8
|
-
readonly deviceLinks?: readonly DeviceLink[];
|
|
9
|
-
}
|
|
10
|
-
/** Node key of the dependency graph: `${deviceId}:${cap}`. */
|
|
11
|
-
type NodeKey = string;
|
|
12
|
-
/**
|
|
13
|
-
* Detect whether replacing `editedDeviceId`'s links with `editedLinks` closes
|
|
14
|
-
* a dependency cycle. Returns the cycle as an ordered list of node keys
|
|
15
|
-
* (first node repeated at the end) or null when the set is safe.
|
|
16
|
-
*/
|
|
17
|
-
export declare function findDeviceLinkCycle(allMeta: Record<string, LinkCycleMetaRow>, editedDeviceId: number, editedLinks: readonly DeviceLink[]): readonly NodeKey[] | null;
|
|
18
|
-
export {};
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { DeviceLink } from '@camstack/types';
|
|
2
|
-
/** A target link with its source already resolved to a live numeric id. */
|
|
3
|
-
export interface ResolvedTargetLink {
|
|
4
|
-
readonly link: DeviceLink;
|
|
5
|
-
/** Resolved source device id for scalar sources; `-1` sentinel for a literal
|
|
6
|
-
* source AND for an expression source (whose per-binding ids live below). */
|
|
7
|
-
readonly sourceDeviceId: number;
|
|
8
|
-
/** Expression links only: binding name → resolved source device id. Literal
|
|
9
|
-
* bindings and unresolved bindings are ABSENT from the map. */
|
|
10
|
-
readonly bindingSourceIds?: Readonly<Record<string, number>>;
|
|
11
|
-
}
|
|
12
|
-
/** A target that depends on a given source slice (for reactive re-emit). */
|
|
13
|
-
export interface DependentLink {
|
|
14
|
-
readonly targetDeviceId: number;
|
|
15
|
-
readonly targetCap: string;
|
|
16
|
-
}
|
|
17
|
-
export interface LinkIndexes {
|
|
18
|
-
/** `${targetDeviceId}:${targetCap}` → the resolved links feeding it. */
|
|
19
|
-
readonly targets: Map<string, ResolvedTargetLink[]>;
|
|
20
|
-
/** `${sourceDeviceId}:${sourceCap}` → targets to recompute on a source change. */
|
|
21
|
-
readonly dependents: Map<string, DependentLink[]>;
|
|
22
|
-
}
|
|
23
|
-
/** One resolved link entry fed to `buildLinkIndexes`. */
|
|
24
|
-
export interface LinkIndexEntry {
|
|
25
|
-
readonly targetDeviceId: number;
|
|
26
|
-
readonly link: DeviceLink;
|
|
27
|
-
/** Resolved scalar source id; `-1` for literal/expression sources. */
|
|
28
|
-
readonly sourceDeviceId: number;
|
|
29
|
-
/** Expression links: binding name → resolved source device id. */
|
|
30
|
-
readonly bindingSourceIds?: Readonly<Record<string, number>>;
|
|
31
|
-
}
|
|
32
|
-
/** Build both lookup maps from resolved link entries. Pure — order-preserving. */
|
|
33
|
-
export declare function buildLinkIndexes(entries: ReadonlyArray<LinkIndexEntry>): LinkIndexes;
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
import { CapabilityStatusItemArray, DeviceLink } from '@camstack/types';
|
|
3
|
-
import { ResolvedTargetLink } from './device-link-overlay.js';
|
|
4
|
-
/** One resolved source reading paired with the link that produced it. The
|
|
5
|
-
* `sourceValue` is already transformed; `undefined` skips the overlay. */
|
|
6
|
-
export interface ResolvedLinkValue {
|
|
7
|
-
readonly link: DeviceLink;
|
|
8
|
-
readonly sourceValue: unknown;
|
|
9
|
-
}
|
|
10
|
-
/**
|
|
11
|
-
* Resolve one link's transformed value. Field sources read via `readField`
|
|
12
|
-
* (the caller supplies the sibling lookup — sync, e.g. the in-hub state
|
|
13
|
-
* mirror); literal sources use their per-device constant and never consult
|
|
14
|
-
* the reader. Returns `undefined` to skip the overlay (unreadable field).
|
|
15
|
-
* Pure. Async source reads (`resolveLinkedStatus`'s provider `getStatus`
|
|
16
|
-
* path) cannot use this helper for field sources — they branch on
|
|
17
|
-
* `source.kind` directly.
|
|
18
|
-
*/
|
|
19
|
-
export declare function resolveLinkValue(link: DeviceLink, readField: (cap: string, fieldPath: string) => unknown): unknown;
|
|
20
|
-
/**
|
|
21
|
-
* Sync-channel expression resolution: bindings read from the mirror via
|
|
22
|
-
* `readField(deviceId, cap, fieldPath)` using the pre-resolved per-binding ids
|
|
23
|
-
* on the `ResolvedTargetLink`. A literal binding uses its constant; an absent
|
|
24
|
-
* binding id (dangling source) or a non-primitive read resolves to `null`.
|
|
25
|
-
* Returns `undefined` on parse/eval failure (skip overlay) — silent by design:
|
|
26
|
-
* the mirror path is hot and churn-free; the async channel logs. Compile
|
|
27
|
-
* failures cost one parse ever thanks to the negative LRU compile cache. Pure.
|
|
28
|
-
*/
|
|
29
|
-
export declare function resolveExpressionLinkValue(rl: ResolvedTargetLink, readField: (deviceId: number, cap: string, fieldPath: string) => unknown, now: number): unknown;
|
|
30
|
-
/**
|
|
31
|
-
* Overlay transformed source values onto `base` by dot-path, then validate the
|
|
32
|
-
* result against the target cap's `statusSchema`. On validation failure the
|
|
33
|
-
* overlay is discarded and `base` is returned unchanged (a misconfigured link
|
|
34
|
-
* must never corrupt a cap response). Pure — all I/O happens in the caller.
|
|
35
|
-
*
|
|
36
|
-
* Item-array grouping (P2b): when the cap declares `status.itemArray`, links
|
|
37
|
-
* carrying a `target.itemKey` are grouped per key and upserted as items into
|
|
38
|
-
* the array (see `upsertItemArrayLinks`); their `fieldPath` is relative to
|
|
39
|
-
* ONE item ('level', 'status', 'label'). Links WITHOUT an itemKey keep the
|
|
40
|
-
* scalar dot-path behavior unchanged. A link with an itemKey on a cap that
|
|
41
|
-
* declares NO `itemArray` is dropped (its item-relative path must never be
|
|
42
|
-
* scalar-applied to the status root).
|
|
43
|
-
*/
|
|
44
|
-
export declare function mergeLinkedStatus(base: Record<string, unknown>, resolved: readonly ResolvedLinkValue[], statusSchema?: z.ZodType, itemArray?: CapabilityStatusItemArray): Record<string, unknown>;
|