@camstack/system 1.2.63 → 1.2.65
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/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +10 -4
- package/dist/builtins/device-manager/device-manager.addon.mjs +10 -4
- package/dist/builtins/device-manager/device-state-mirror.d.ts +7 -1
- 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/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +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 +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -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/sqlite-settings.addon.js +29 -3
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +29 -3
- 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-LDrOFrd-.js → dist-CIkGOAcz.js} +96 -2
- package/dist/{dist-BJ2w3uoN.mjs → dist-DqPyKwVB.mjs} +96 -2
- package/dist/http/asset-compression.d.ts +93 -0
- package/dist/index.js +63 -9
- package/dist/index.mjs +63 -9
- package/dist/kernel/heap-watch.d.ts +55 -0
- 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 +1 -1
- package/dist/{manifest-python-deps-DNfmBu20.js → manifest-python-deps-BE6l8Y4W.js} +1 -1
- package/dist/{manifest-python-deps-CopL1_5C.mjs → manifest-python-deps-Dz1ryHIN.mjs} +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM$1, r as __exportAll, t as __commonJSMin$1 } from "./chunk-CNf5ZN-e.mjs";
|
|
2
|
-
import { Ct as asString$1, Dt as expandCapMethods, F as extractNestedAddonId, Ft as scopeKey, H as kebabToCamel, K as logLevelAtMost, L as isArrayOutputSchema, Lt as EventCategory$1, Mt as parseJsonUnknown$1, Nt as readinessKey, Pt as resolveCapMount, R as isCollectionArrayMethod, St as asNumber, Tt as emitDownForOwnedCaps, U as lifecycleJobSchema, V as isVoidInput, Z as objectInputDeclaresAddonId, et as procedureAuthKey, ft as DATAPLANE_SECRET_HEADER$1, g as addonSettingsCapability, jt as parseJsonObject, l as METHOD_ACCESS_MAP, mt as DEVICE_STATUS_METHOD, pt as DEVICE_SETTINGS_CONTRIBUTION_METHODS, q as looseSchema, t as ALL_CAPABILITY_DEFINITIONS, u as RUNTIME_DEFAULTS, ut as errMsg$1, vt as ReadinessRegistry, wt as createEvent, xt as asJsonObject$1, yt as ReadinessTimeoutError, z as isObjectInput } from "./dist-
|
|
3
|
-
import { a as downloadModel, c as getModelFilePath, d as contentTypeFor, f as createAuthenticatedFileServer, h as resolveFilePath, i as downloadFile, l as isModelDownloaded, m as parseTokenizedUrl, n as collectModelFiles, o as ensureModel, p as parseRangeHeader, r as deleteModelFromDisk, s as fetchJson, t as ModelDownloadService, u as createFileDataPlaneHandler } from "./model-download-service-
|
|
4
|
-
import { $ as buildNativeCapProxy, A as createHubCapForwardService, B as AGENT_CAP_FWD_ACTION, C as brokerTransportLink, Ct as resolveAddonClass, D as localProviderLink, E as ipcParentLink, F as createUdsLogger, G as callWithServiceDiscovery, H as CapRouteResolver, I as createUdsLoggerWithControl, J as UdsLocalTransportServer, K as createLocalTransport, L as LocalChildClient, M as createUdsEventBridge, N as createUdsEventBus, O as HUB_CAP_FWD_ACTION, P as udsChildLogToWorkerEntry, Q as encodeFrame, R as LocalChildRegistry, S as brokerCallForCap, St as runNpm, T as ipcChildLink, U as CapRouteError, V as AGENT_CAP_FWD_SERVICE, W as classifyCapRoute, X as localEndpointPath, Y as SocketChannel, Z as FrameDecoder, _ as createKernelHwAccel, _t as CapabilityHandle, a as getWorkerDeviceRegistry, b as __resetCapUsageRegistryForTests, bt as installManifestNativeDeps, c as setHubConnected, ct as NATIVE_PROVIDER_SERVICE_INFIX, d as getBrokerEventBus, dt as capBareAction, et as buildUdsNativeCapProxy, f as getMoleculerEventStats, ft as capServiceName, g as AddonDepsManager, gt as DeviceRegistry, h as subscribePassthrough, ht as serializeTypedArrays, i as createUdsAddonContext, it as mountNativeCapService, j as createParentUnownedCallHandler, k as HUB_CAP_FWD_SERVICE, l as EVENT_TOPIC_PREFIX, lt as capActionName, m as setNodeEventInterest, mt as deserializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as createAddonService, p as registerEventBusService, pt as parseCapAction, q as UdsLocalTransportClient, r as createAddonContext, s as getOrInitReadinessRegistryForClient, st as validateProviderRegistrations, t as installManifestPythonDeps, tt as createBrokerDeviceManagerApi, u as clusterEventTopic, ut as capActionSuffix, v as resolveHwAccel, vt as CapabilityUnavailableError, w as buildLinkChain, wt as createAddonDataPlaneFacility, x as getCapUsageRegistry, xt as resolveNpmInvocation, y as CapUsageRegistry, yt as copyBundledNativeModules, z as UDS_NO_ROUTE_PREFIX } from "./manifest-python-deps-
|
|
2
|
+
import { Ct as asString$1, Dt as expandCapMethods, F as extractNestedAddonId, Ft as scopeKey, H as kebabToCamel, K as logLevelAtMost, L as isArrayOutputSchema, Lt as EventCategory$1, Mt as parseJsonUnknown$1, Nt as readinessKey, Pt as resolveCapMount, R as isCollectionArrayMethod, St as asNumber, Tt as emitDownForOwnedCaps, U as lifecycleJobSchema, V as isVoidInput, Z as objectInputDeclaresAddonId, et as procedureAuthKey, ft as DATAPLANE_SECRET_HEADER$1, g as addonSettingsCapability, jt as parseJsonObject, l as METHOD_ACCESS_MAP, mt as DEVICE_STATUS_METHOD, pt as DEVICE_SETTINGS_CONTRIBUTION_METHODS, q as looseSchema, t as ALL_CAPABILITY_DEFINITIONS, u as RUNTIME_DEFAULTS, ut as errMsg$1, vt as ReadinessRegistry, wt as createEvent, xt as asJsonObject$1, yt as ReadinessTimeoutError, z as isObjectInput } from "./dist-DqPyKwVB.mjs";
|
|
3
|
+
import { a as downloadModel, c as getModelFilePath, d as contentTypeFor, f as createAuthenticatedFileServer, h as resolveFilePath, i as downloadFile, l as isModelDownloaded, m as parseTokenizedUrl, n as collectModelFiles, o as ensureModel, p as parseRangeHeader, r as deleteModelFromDisk, s as fetchJson, t as ModelDownloadService, u as createFileDataPlaneHandler } from "./model-download-service-CTBHzORJ.mjs";
|
|
4
|
+
import { $ as buildNativeCapProxy, A as createHubCapForwardService, B as AGENT_CAP_FWD_ACTION, C as brokerTransportLink, Ct as resolveAddonClass, D as localProviderLink, E as ipcParentLink, F as createUdsLogger, G as callWithServiceDiscovery, H as CapRouteResolver, I as createUdsLoggerWithControl, J as UdsLocalTransportServer, K as createLocalTransport, L as LocalChildClient, M as createUdsEventBridge, N as createUdsEventBus, O as HUB_CAP_FWD_ACTION, P as udsChildLogToWorkerEntry, Q as encodeFrame, R as LocalChildRegistry, S as brokerCallForCap, St as runNpm, T as ipcChildLink, U as CapRouteError, V as AGENT_CAP_FWD_SERVICE, W as classifyCapRoute, X as localEndpointPath, Y as SocketChannel, Z as FrameDecoder, _ as createKernelHwAccel, _t as CapabilityHandle, a as getWorkerDeviceRegistry, b as __resetCapUsageRegistryForTests, bt as installManifestNativeDeps, c as setHubConnected, ct as NATIVE_PROVIDER_SERVICE_INFIX, d as getBrokerEventBus, dt as capBareAction, et as buildUdsNativeCapProxy, f as getMoleculerEventStats, ft as capServiceName, g as AddonDepsManager, gt as DeviceRegistry, h as subscribePassthrough, ht as serializeTypedArrays, i as createUdsAddonContext, it as mountNativeCapService, j as createParentUnownedCallHandler, k as HUB_CAP_FWD_SERVICE, l as EVENT_TOPIC_PREFIX, lt as capActionName, m as setNodeEventInterest, mt as deserializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as createAddonService, p as registerEventBusService, pt as parseCapAction, q as UdsLocalTransportClient, r as createAddonContext, s as getOrInitReadinessRegistryForClient, st as validateProviderRegistrations, t as installManifestPythonDeps, tt as createBrokerDeviceManagerApi, u as clusterEventTopic, ut as capActionSuffix, v as resolveHwAccel, vt as CapabilityUnavailableError, w as buildLinkChain, wt as createAddonDataPlaneFacility, x as getCapUsageRegistry, xt as resolveNpmInvocation, y as CapUsageRegistry, yt as copyBundledNativeModules, z as UDS_NO_ROUTE_PREFIX } from "./manifest-python-deps-Dz1ryHIN.mjs";
|
|
5
5
|
import { n as getSinglePidStats, t as getPidStats } from "./resource-monitor-BkP504Vq.mjs";
|
|
6
6
|
import { FilesystemStorageAddon, t as FilesystemStorageProvider } from "./builtins/sqlite-storage/filesystem-storage.addon.mjs";
|
|
7
7
|
import { SqliteSettingsAddon, o as SqliteSettingsBackend } from "./builtins/sqlite-storage/sqlite-settings.addon.mjs";
|
|
@@ -37,10 +37,10 @@ import * as fs$17 from "node:fs";
|
|
|
37
37
|
import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
38
38
|
import * as path$39 from "node:path";
|
|
39
39
|
import { dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
|
|
40
|
-
import { asJsonObject, errMsg, parseJsonUnknown } from "@camstack/types/addon";
|
|
41
|
-
import { execFile, spawn } from "node:child_process";
|
|
42
40
|
import * as util$10 from "node:util";
|
|
43
41
|
import { promisify } from "node:util";
|
|
42
|
+
import { asJsonObject, errMsg, parseJsonUnknown } from "@camstack/types/addon";
|
|
43
|
+
import { execFile, spawn } from "node:child_process";
|
|
44
44
|
import * as os$18 from "node:os";
|
|
45
45
|
import { lstat, readdir, readlink, realpath } from "node:fs/promises";
|
|
46
46
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -131,6 +131,30 @@ var HEAP_WATCH_INTERVAL_MS = 6e4;
|
|
|
131
131
|
* the fatal by more than one interval under realistic growth. */
|
|
132
132
|
var HEAP_WATCH_WARN_RATIO = .8;
|
|
133
133
|
/**
|
|
134
|
+
* Escalate above this share of the ceiling.
|
|
135
|
+
*
|
|
136
|
+
* 0.70 is 3002MB: clear of the measured routine max (2847MB) so steady state
|
|
137
|
+
* never escalates, and below {@link HEAP_WATCH_WARN_RATIO} so the fast cadence
|
|
138
|
+
* is already running by the time the warn is reachable.
|
|
139
|
+
*/
|
|
140
|
+
var HEAP_WATCH_ESCALATE_RATIO = .7;
|
|
141
|
+
/**
|
|
142
|
+
* Release escalation below this share.
|
|
143
|
+
*
|
|
144
|
+
* Hysteresis, not a second threshold: hub-main's heap sawtooths continuously,
|
|
145
|
+
* so a single ratio would flap the mode on every GC. 0.60 is 2573MB — a real
|
|
146
|
+
* retreat rather than the trough of one collection.
|
|
147
|
+
*/
|
|
148
|
+
var HEAP_WATCH_DEESCALATE_RATIO = .6;
|
|
149
|
+
/**
|
|
150
|
+
* The cadence state machine, pure so the thresholds are testable without
|
|
151
|
+
* allocating three gigabytes to reach them.
|
|
152
|
+
*/
|
|
153
|
+
function nextMode(current, usedRatio, escalateRatio = HEAP_WATCH_ESCALATE_RATIO, deescalateRatio = HEAP_WATCH_DEESCALATE_RATIO) {
|
|
154
|
+
if (current === "escalated") return usedRatio < deescalateRatio ? "steady" : "escalated";
|
|
155
|
+
return usedRatio >= escalateRatio ? "escalated" : "steady";
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
134
158
|
* Reclaim once the process holds this many MB beyond what it is USING.
|
|
135
159
|
*
|
|
136
160
|
* Live calibration, both readings from the same hub minutes apart: 2491MB
|
|
@@ -221,6 +245,9 @@ function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEA
|
|
|
221
245
|
const now = reclaimOptions?.now ?? (() => Date.now());
|
|
222
246
|
const triggerMb = reclaimOptions?.triggerMb ?? 1536;
|
|
223
247
|
const minIntervalMs = reclaimOptions?.minIntervalMs ?? 6e5;
|
|
248
|
+
const fastIntervalMs = reclaimOptions?.fastIntervalMs ?? 2e3;
|
|
249
|
+
const escalateRatio = reclaimOptions?.escalateRatio ?? .7;
|
|
250
|
+
const deescalateRatio = reclaimOptions?.deescalateRatio ?? .6;
|
|
224
251
|
let lastReclaimAt = Number.NEGATIVE_INFINITY;
|
|
225
252
|
const read = () => {
|
|
226
253
|
const limit = reclaimOptions?.heapLimitBytes ?? v8.getHeapStatistics().heap_size_limit;
|
|
@@ -241,16 +268,43 @@ function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEA
|
|
|
241
268
|
const after = read();
|
|
242
269
|
sink.info(`[mem] reclaim ${label} stranded=${strandedMb(sample)}MB rss=${sample.rssMb}MB→${after.rssMb}MB freed=${sample.rssMb - after.rssMb}MB arrayBuffers=${sample.arrayBuffersMb}MB→${after.arrayBuffersMb}MB took=${now() - startedAt}ms`);
|
|
243
270
|
};
|
|
271
|
+
let mode = "steady";
|
|
272
|
+
let lastLoggedAt = Number.NEGATIVE_INFINITY;
|
|
273
|
+
/**
|
|
274
|
+
* PROBING and LOGGING are separate cadences, and that separation is the whole
|
|
275
|
+
* point.
|
|
276
|
+
*
|
|
277
|
+
* The 2026-08-08 fatal escaped a 60s heartbeat because the burst began and
|
|
278
|
+
* ended between two samples. Deciding "is the heap elevated?" only when a
|
|
279
|
+
* line is due would reproduce that exactly: the process would still be read
|
|
280
|
+
* once a minute, and a 62-second climb would still be invisible. So the timer
|
|
281
|
+
* runs at the FAST period always — `process.memoryUsage()` is tens of
|
|
282
|
+
* microseconds, i.e. free at this rate — and the slow period governs only how
|
|
283
|
+
* often a steady process is allowed to say so.
|
|
284
|
+
*
|
|
285
|
+
* Never probe slower than the log cadence, or a caller passing a short
|
|
286
|
+
* `intervalMs` would silently get the long one.
|
|
287
|
+
*/
|
|
288
|
+
const probeIntervalMs = Math.min(fastIntervalMs, intervalMs);
|
|
244
289
|
const tick = () => {
|
|
245
290
|
try {
|
|
246
291
|
const sample = read();
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
292
|
+
const previous = mode;
|
|
293
|
+
mode = nextMode(previous, sample.usedRatio, escalateRatio, deescalateRatio);
|
|
294
|
+
const at = now();
|
|
295
|
+
const due = at - lastLoggedAt >= intervalMs;
|
|
296
|
+
if (mode === "escalated" || due) {
|
|
297
|
+
lastLoggedAt = at;
|
|
298
|
+
const line = format$1(label, sample);
|
|
299
|
+
if (sample.nearLimit) sink.warn(`${line} — APPROACHING HEAP LIMIT`);
|
|
300
|
+
else if (mode === "escalated") sink.warn(`${line} — heap elevated, sampling every ${probeIntervalMs}ms`);
|
|
301
|
+
else sink.info(line);
|
|
302
|
+
}
|
|
303
|
+
if (previous === "escalated" && mode === "steady") sink.info(`[mem] ${label} heap back to routine — logging every ${intervalMs}ms`);
|
|
250
304
|
maybeReclaim(sample);
|
|
251
305
|
} catch {}
|
|
252
306
|
};
|
|
253
|
-
const timer = setInterval(tick,
|
|
307
|
+
const timer = setInterval(tick, probeIntervalMs);
|
|
254
308
|
timer.unref?.();
|
|
255
309
|
tick();
|
|
256
310
|
let stopped = false;
|
|
@@ -4,6 +4,55 @@ export declare const HEAP_WATCH_INTERVAL_MS = 60000;
|
|
|
4
4
|
/** Warn above this share of the heap ceiling. Chosen so the warning precedes
|
|
5
5
|
* the fatal by more than one interval under realistic growth. */
|
|
6
6
|
export declare const HEAP_WATCH_WARN_RATIO = 0.8;
|
|
7
|
+
/**
|
|
8
|
+
* ## The heartbeat could not see the thing it was built to see
|
|
9
|
+
*
|
|
10
|
+
* 2026-08-08 18:28:32Z, hub-main (pid 8) died on `Reached heap limit`. The last
|
|
11
|
+
* `[mem]` line was 18:27:30.433 reading `heapUsed=1086MB` — 25%, unremarkable —
|
|
12
|
+
* and V8's own trace 62 seconds later read 4083MB with a Mark-Compact that
|
|
13
|
+
* reclaimed 4MB of 4090MB. Between those two facts there is not one sample.
|
|
14
|
+
*
|
|
15
|
+
* At `HEAP_WATCH_INTERVAL_MS` the whole event fits inside a single gap, so
|
|
16
|
+
* `HEAP_WATCH_WARN_RATIO` never had a reading to fire on. The docblock above
|
|
17
|
+
* promises "the line that will exist BEFORE the next fatal instead of after
|
|
18
|
+
* it"; on the only occasion that mattered, it did not exist.
|
|
19
|
+
*
|
|
20
|
+
* Measured the same day over 397 consecutive live samples: p50 1266MB, p95
|
|
21
|
+
* 1995MB, **max 2847MB** — 0.66 of the 4288MB ceiling. So the routine sawtooth
|
|
22
|
+
* never approaches 0.8, and the only path to 0.8 is a burst that outruns the
|
|
23
|
+
* sampler. A threshold reachable only through a blind spot is decoration.
|
|
24
|
+
*
|
|
25
|
+
* The fix is not a faster heartbeat — 30 lines a minute forever, to describe a
|
|
26
|
+
* process that is fine, is how a log stops being read. It is to sample fast
|
|
27
|
+
* only while the heap is somewhere the routine sawtooth does not go.
|
|
28
|
+
*/
|
|
29
|
+
/** Sampling period once escalated. Bounded below by log volume, above by the
|
|
30
|
+
* 62s window the 2026-08-08 fatal hid in: ~30 readings across that window is
|
|
31
|
+
* a trajectory, and a trajectory separates a burst from a climb. */
|
|
32
|
+
export declare const HEAP_WATCH_FAST_INTERVAL_MS = 2000;
|
|
33
|
+
/**
|
|
34
|
+
* Escalate above this share of the ceiling.
|
|
35
|
+
*
|
|
36
|
+
* 0.70 is 3002MB: clear of the measured routine max (2847MB) so steady state
|
|
37
|
+
* never escalates, and below {@link HEAP_WATCH_WARN_RATIO} so the fast cadence
|
|
38
|
+
* is already running by the time the warn is reachable.
|
|
39
|
+
*/
|
|
40
|
+
export declare const HEAP_WATCH_ESCALATE_RATIO = 0.7;
|
|
41
|
+
/**
|
|
42
|
+
* Release escalation below this share.
|
|
43
|
+
*
|
|
44
|
+
* Hysteresis, not a second threshold: hub-main's heap sawtooths continuously,
|
|
45
|
+
* so a single ratio would flap the mode on every GC. 0.60 is 2573MB — a real
|
|
46
|
+
* retreat rather than the trough of one collection.
|
|
47
|
+
*/
|
|
48
|
+
export declare const HEAP_WATCH_DEESCALATE_RATIO = 0.6;
|
|
49
|
+
/** Sampling cadence. `escalated` costs log lines and buys resolution. */
|
|
50
|
+
export type HeapWatchMode = 'steady' | 'escalated';
|
|
51
|
+
/**
|
|
52
|
+
* The cadence state machine, pure so the thresholds are testable without
|
|
53
|
+
* allocating three gigabytes to reach them.
|
|
54
|
+
*/
|
|
55
|
+
export declare function nextMode(current: HeapWatchMode, usedRatio: number, escalateRatio?: number, deescalateRatio?: number): HeapWatchMode;
|
|
7
56
|
/**
|
|
8
57
|
* Reclaim once the process holds this many MB beyond what it is USING.
|
|
9
58
|
*
|
|
@@ -73,6 +122,12 @@ export interface HeapReclaimOptions {
|
|
|
73
122
|
readonly triggerMb?: number;
|
|
74
123
|
readonly minIntervalMs?: number;
|
|
75
124
|
readonly now?: () => number;
|
|
125
|
+
/** Sampling cadence once escalated. See {@link HEAP_WATCH_FAST_INTERVAL_MS}. */
|
|
126
|
+
readonly fastIntervalMs?: number;
|
|
127
|
+
/** Ratio at which the fast cadence begins. See {@link HEAP_WATCH_ESCALATE_RATIO}. */
|
|
128
|
+
readonly escalateRatio?: number;
|
|
129
|
+
/** Ratio at which it releases. See {@link HEAP_WATCH_DEESCALATE_RATIO}. */
|
|
130
|
+
readonly deescalateRatio?: number;
|
|
76
131
|
}
|
|
77
132
|
/**
|
|
78
133
|
* Start the heartbeat. Returns a stop function.
|
|
@@ -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)
|