@camstack/system 1.2.69 → 1.2.71

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 (70) hide show
  1. package/dist/addon-runner.js +6 -6
  2. package/dist/addon-runner.mjs +3 -3
  3. package/dist/addon-utils.js +11 -11
  4. package/dist/addon-utils.mjs +1 -1
  5. package/dist/auth/auth-manager.d.ts +13 -29
  6. package/dist/auth/scope-matcher.d.ts +6 -5
  7. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +3 -3
  8. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +2 -2
  9. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +3 -3
  10. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +2 -2
  11. package/dist/builtins/alerts/alerts.addon.js +1 -1
  12. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  13. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +8 -8
  14. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +6 -6
  15. package/dist/builtins/console-logging/index.js +1 -1
  16. package/dist/builtins/console-logging/index.mjs +1 -1
  17. package/dist/builtins/core-blocks/core-blocks.addon.js +3 -3
  18. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  19. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  20. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  21. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  22. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  23. package/dist/builtins/hub-forwarder/index.js +1 -1
  24. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  25. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  26. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  27. package/dist/builtins/local-auth/index.d.ts +0 -1
  28. package/dist/builtins/local-auth/index.js +0 -1
  29. package/dist/builtins/local-auth/index.mjs +2 -2
  30. package/dist/builtins/local-auth/local-auth.addon.js +4878 -4883
  31. package/dist/builtins/local-auth/local-auth.addon.mjs +4879 -4883
  32. package/dist/builtins/local-auth/oauth-grants.d.ts +15 -0
  33. package/dist/builtins/local-network/local-network.addon.js +1 -1
  34. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  35. package/dist/builtins/loki-logging/index.js +1 -1
  36. package/dist/builtins/loki-logging/index.mjs +1 -1
  37. package/dist/builtins/native-metrics/native-metrics.addon.js +5 -5
  38. package/dist/builtins/native-metrics/native-metrics.addon.mjs +4 -4
  39. package/dist/builtins/platform-probe/index.js +4 -4
  40. package/dist/builtins/platform-probe/index.mjs +3 -3
  41. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  42. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  43. package/dist/builtins/snapshot/index.js +2 -2
  44. package/dist/builtins/snapshot/index.mjs +2 -2
  45. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +4 -4
  46. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +3 -3
  47. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  48. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  49. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +3 -3
  50. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +2 -2
  51. package/dist/builtins/system-config/system-config.addon.js +1 -1
  52. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  53. package/dist/builtins/winston-logging/index.js +1 -1
  54. package/dist/builtins/winston-logging/index.mjs +1 -1
  55. package/dist/{dist-C-QzFJ8v.mjs → dist-Daxx9CB0.mjs} +139 -5
  56. package/dist/{dist-D7wC-S4p.js → dist-I0WgqQQ-.js} +150 -10
  57. package/dist/{model-download-service-CTBHzORJ.mjs → file-data-plane-CuE_hBli.mjs} +848 -848
  58. package/dist/index.d.ts +73 -81
  59. package/dist/index.js +1302 -1278
  60. package/dist/index.mjs +1283 -1259
  61. package/dist/kernel/capability-registry.d.ts +26 -0
  62. package/dist/kernel/config-schema.d.ts +1 -1
  63. package/dist/kernel/transport/parent-unowned-call.d.ts +27 -0
  64. package/dist/{manifest-python-deps-Dz1ryHIN.mjs → manifest-python-deps-BdWxKNvu.mjs} +49 -47
  65. package/dist/{manifest-python-deps-BE6l8Y4W.js → manifest-python-deps-BqIpg0C7.js} +49 -47
  66. package/package.json +1 -4
  67. package/dist/builtins/local-auth/homeassistant-integration.d.ts +0 -7
  68. package/dist/{model-download-service-Caj1kaSi.js → file-data-plane-DUHPHa-Y.js} +847 -847
  69. package/dist/{resource-monitor-BkP504Vq.mjs → resource-monitor-BWmQ5i-o.mjs} +1 -1
  70. package/dist/{resource-monitor-DNNomR-i.js → resource-monitor-CdnzxBLP.js} +1 -1
@@ -1,943 +1,943 @@
1
1
  const require_chunk = require("./chunk-Cek0wNdY.js");
2
2
  let node_crypto = require("node:crypto");
3
- let node_http = require("node:http");
4
- let node_fs = require("node:fs");
5
- node_fs = require_chunk.__toESM(node_fs);
6
3
  let node_path = require("node:path");
7
4
  node_path = require_chunk.__toESM(node_path);
8
5
  let node_util = require("node:util");
6
+ let node_fs = require("node:fs");
7
+ node_fs = require_chunk.__toESM(node_fs);
8
+ let node_http = require("node:http");
9
9
  let node_zlib = require("node:zlib");
10
- //#region src/http/authenticated-file-server.ts
10
+ //#region src/download/model-downloader.ts
11
+ function isNonEmptyFile(filePath) {
12
+ return node_fs.existsSync(filePath) && node_fs.statSync(filePath).size > 0;
13
+ }
11
14
  /**
12
- * Authenticated data-plane file-server a shared host primitive for addons that
13
- * must serve files (media segments, scrub-frame stores, exported clips) directly
14
- * to browsers/players, node-direct (NOT through the hub tRPC, per the recording
15
- * design's "no media bytes traverse the hub").
16
- *
17
- * It bundles the three things every such server needs so addons don't re-roll
18
- * them: CORS (the data plane is cross-origin from the admin-ui — a native
19
- * `<video src>` is exempt but hls.js / WebCodecs fetch via XHR and are blocked
20
- * without it), HTTP `Range` (seeking / partial fetch), and a SCOPED TOKEN check.
21
- *
22
- * The token rides as the FIRST path segment (`<urlPrefix><token>/<relPath>`) so
23
- * a player resolving a manifest's RELATIVE child URIs carries it on every
24
- * sub-request automatically — no per-URI rewriting, no cookies/headers a dumb
25
- * player can't set. The caller supplies `verifyToken(token, relPath)`; the
26
- * primitive stays auth-scheme-agnostic.
15
+ * Sibling files of a single-file (non-directory) format extra files the
16
+ * format needs, fetched from the same remote directory as `url` and stored
17
+ * flat alongside the main file in `modelsDir`. Catalog-declared via
18
+ * `formatEntry.files`, e.g. OpenVINO IR lists its `.bin` weights next to the
19
+ * `.xml`. The downloader stays format-agnostic; the convention lives in the
20
+ * catalog data (like `MLPACKAGE_FILES` for the directory case).
21
+ */
22
+ function siblingFilesFor(formatEntry) {
23
+ return formatEntry.isDirectory ? [] : formatEntry.files ?? [];
24
+ }
25
+ /** Resolve a sibling's remote URL relative to the main file's directory. */
26
+ function siblingUrl(mainUrl, sibling) {
27
+ return mainUrl.replace(/[^/]+$/, sibling);
28
+ }
29
+ /**
30
+ * The relative filenames a model format occupies under `modelsDir` — the main
31
+ * file (URL basename, or `${id}.${format}` fallback), its declared siblings
32
+ * (e.g. the OpenVINO `.bin` next to the `.xml`), and any `extraFiles`. For a
33
+ * directory bundle the single entry is the bundle dir itself (tar/extract
34
+ * recurse into it).
27
35
  *
28
- * Pure helpers (`parseTokenizedUrl`, `resolveFilePath`, `contentTypeFor`,
29
- * `parseRangeHeader`) are exported for unit testing; `createAuthenticatedFileServer`
30
- * is the thin `node:http` glue.
36
+ * Used by model distribution (P2): the hub tars exactly these names from its
37
+ * `modelsDir` and the agent untars them into its own `modelsDir`, so both ends
38
+ * agree on the on-disk layout without re-deriving it.
31
39
  */
32
- function corsHeaders(origin) {
33
- return {
34
- "access-control-allow-origin": origin,
35
- "access-control-allow-methods": "GET, HEAD, OPTIONS",
36
- "access-control-allow-headers": "range",
37
- "access-control-expose-headers": "content-length, content-range, accept-ranges",
38
- "access-control-max-age": "86400"
39
- };
40
+ function collectModelFiles(entry, format) {
41
+ const formatEntry = entry.formats[format];
42
+ if (!formatEntry) return [];
43
+ const names = [formatEntry.url.split("/").pop() ?? `${entry.id}.${format}`, ...siblingFilesFor(formatEntry)];
44
+ for (const extra of entry.extraFiles ?? []) names.push(extra.filename);
45
+ return names;
46
+ }
47
+ /** Build fetch headers, including HF auth token for huggingface.co URLs */
48
+ function buildHeaders(url) {
49
+ const headers = { "User-Agent": "CamStack/1.0" };
50
+ const hfToken = process.env["HF_TOKEN"] ?? process.env["HUGGING_FACE_HUB_TOKEN"];
51
+ if (hfToken && url.includes("huggingface.co")) headers["Authorization"] = `Bearer ${hfToken}`;
52
+ return headers;
40
53
  }
41
54
  /**
42
- * Split a request URL (`<urlPrefix><token>/<relPath>`) into its token + rel
43
- * path. Returns null for anything outside the prefix or missing either part.
44
- * `relPath` is URL-decoded; a decode failure → null.
55
+ * Download a single file from a URL to a destination path.
56
+ * Uses native fetch() (Node 22+) which handles redirects natively.
57
+ * Streams to disk with optional progress callback.
58
+ * Returns the destination path. Skips download if file already exists.
45
59
  */
46
- function parseTokenizedUrl(urlPrefix, urlPath) {
47
- if (!urlPath.startsWith(urlPrefix)) return null;
48
- const after = urlPath.slice(urlPrefix.length);
49
- const slash = after.indexOf("/");
50
- if (slash <= 0) return null;
51
- const token = after.slice(0, slash);
52
- let rel;
60
+ async function downloadFile(url, destPath, onProgress) {
61
+ if (node_fs.existsSync(destPath)) return destPath;
62
+ node_fs.mkdirSync(node_path.dirname(destPath), { recursive: true });
63
+ const tmpPath = destPath + ".downloading";
53
64
  try {
54
- rel = decodeURIComponent(after.slice(slash + 1));
55
- } catch {
56
- return null;
65
+ const response = await fetch(url, {
66
+ redirect: "follow",
67
+ headers: buildHeaders(url)
68
+ });
69
+ if (!response.ok) throw new Error(`HTTP ${response.status} downloading ${url}`);
70
+ if (!response.body) throw new Error(`No response body from ${url}`);
71
+ const total = parseInt(response.headers.get("content-length") ?? "0", 10);
72
+ let downloaded = 0;
73
+ const fileStream = node_fs.createWriteStream(tmpPath);
74
+ const reader = response.body.getReader();
75
+ try {
76
+ for (;;) {
77
+ const { done, value } = await reader.read();
78
+ if (done || !value) break;
79
+ fileStream.write(value);
80
+ downloaded += value.length;
81
+ onProgress?.(downloaded, total);
82
+ }
83
+ } finally {
84
+ fileStream.end();
85
+ await new Promise((resolve, reject) => {
86
+ fileStream.on("finish", resolve);
87
+ fileStream.on("error", reject);
88
+ });
89
+ }
90
+ node_fs.renameSync(tmpPath, destPath);
91
+ return destPath;
92
+ } catch (err) {
93
+ try {
94
+ node_fs.unlinkSync(tmpPath);
95
+ } catch {}
96
+ throw err;
57
97
  }
58
- if (rel.length === 0) return null;
59
- return {
60
- token,
61
- rel
62
- };
63
98
  }
64
99
  /**
65
- * Map a rel path to one candidate absolute path PER root, keeping only roots the
66
- * path stays within (traversal guard). The handler serves the first candidate
67
- * that exists. Rejects a path that escapes every root.
100
+ * Fetch JSON from a URL using native fetch().
68
101
  */
69
- function resolveFilePath(roots, rel) {
70
- if (rel.length === 0) return { error: "forbidden" };
71
- const candidates = [];
72
- for (const rootDir of roots) {
73
- const root = node_path.default.resolve(rootDir);
74
- const abs = node_path.default.resolve(root, rel);
75
- if (abs === root || abs.startsWith(root + node_path.default.sep)) candidates.push(abs);
76
- }
77
- if (candidates.length === 0) return { error: "forbidden" };
78
- return { candidates };
79
- }
80
- var DEFAULT_CONTENT_TYPES = {
81
- ".m3u8": "application/vnd.apple.mpegurl",
82
- ".m4s": "video/mp4",
83
- ".mp4": "video/mp4",
84
- ".idx": "application/octet-stream",
85
- ".html": "text/html; charset=utf-8",
86
- ".js": "application/javascript; charset=utf-8",
87
- ".mjs": "application/javascript; charset=utf-8",
88
- ".css": "text/css; charset=utf-8",
89
- ".json": "application/json; charset=utf-8",
90
- ".map": "application/json; charset=utf-8",
91
- ".svg": "image/svg+xml",
92
- ".png": "image/png",
93
- ".jpg": "image/jpeg",
94
- ".jpeg": "image/jpeg",
95
- ".ico": "image/x-icon",
96
- ".woff2": "font/woff2",
97
- ".woff": "font/woff",
98
- ".ttf": "font/ttf"
99
- };
100
- function contentTypeFor(filePath, overrides) {
101
- const ext = node_path.default.extname(filePath).toLowerCase();
102
- return overrides?.[ext] ?? DEFAULT_CONTENT_TYPES[ext] ?? "application/octet-stream";
103
- }
104
- /** Parse an HTTP `Range` header against a known size. Null = serve whole file. */
105
- function parseRangeHeader(header, size) {
106
- if (!header) return null;
107
- const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
108
- if (!m) return null;
109
- const [, rawStart, rawEnd] = m;
110
- if (rawStart === "" && rawEnd === "") return null;
111
- let start;
112
- let end;
113
- if (rawStart === "") {
114
- const n = Number(rawEnd);
115
- if (n <= 0) return null;
116
- start = Math.max(0, size - n);
117
- end = size - 1;
118
- } else {
119
- start = Number(rawStart);
120
- end = rawEnd === "" ? size - 1 : Number(rawEnd);
121
- }
122
- if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) return null;
123
- return {
124
- start,
125
- end: Math.min(end, size - 1)
126
- };
102
+ async function fetchJson(url) {
103
+ const response = await fetch(url, {
104
+ redirect: "follow",
105
+ headers: buildHeaders(url)
106
+ });
107
+ if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`);
108
+ return response.json();
127
109
  }
128
110
  /**
129
- * Start an authenticated file-server. Returns the bound port so the caller can
130
- * build URLs. `getRoots`/`verifyToken` are invoked per request, so they always
131
- * reflect the live config.
111
+ * Download a model with fallback URLs and optional SHA256 verification.
112
+ * Legacy API preserved for backward compatibility -- delegates to downloadFile().
132
113
  */
133
- async function createAuthenticatedFileServer(opts) {
134
- const cors = corsHeaders(opts.corsOrigin ?? "*");
135
- const server = (0, node_http.createServer)((req, res) => {
136
- handle(opts, cors, req, res);
137
- });
138
- await new Promise((resolve, reject) => {
139
- server.once("error", reject);
140
- server.listen(opts.port, () => resolve());
141
- });
142
- const addr = server.address();
143
- return {
144
- port: typeof addr === "object" && addr ? addr.port : opts.port,
145
- close: () => new Promise((resolve) => server.close(() => resolve()))
114
+ async function downloadModel(options) {
115
+ const { url, fallbackUrls = [], destDir, filename, expectedSha256, onProgress } = options;
116
+ const fname = filename ?? url.split("/").pop() ?? "model.bin";
117
+ const destPath = node_path.join(destDir, fname);
118
+ if (node_fs.existsSync(destPath)) return {
119
+ filePath: destPath,
120
+ downloadedBytes: 0,
121
+ fromCache: true
146
122
  };
147
- }
148
- async function handle(opts, cors, req, res) {
149
- if (req.method === "OPTIONS") {
150
- res.writeHead(204, cors).end();
151
- return;
152
- }
153
- if (req.method !== "GET" && req.method !== "HEAD") {
154
- res.writeHead(405, cors).end();
155
- return;
123
+ node_fs.mkdirSync(destDir, { recursive: true });
124
+ const urls = [url, ...fallbackUrls];
125
+ let lastError = null;
126
+ for (const tryUrl of urls) try {
127
+ await downloadFile(tryUrl, destPath, onProgress);
128
+ if (expectedSha256) {
129
+ const hash = await computeSha256(destPath);
130
+ if (hash !== expectedSha256) {
131
+ node_fs.unlinkSync(destPath);
132
+ throw new Error(`SHA256 mismatch: expected ${expectedSha256}, got ${hash}`);
133
+ }
134
+ }
135
+ return {
136
+ filePath: destPath,
137
+ downloadedBytes: node_fs.statSync(destPath).size,
138
+ fromCache: false
139
+ };
140
+ } catch (e) {
141
+ lastError = e;
142
+ if (node_fs.existsSync(destPath)) node_fs.unlinkSync(destPath);
156
143
  }
157
- const urlPath = (req.url ?? "").split("?")[0] ?? "";
158
- const parsed = parseTokenizedUrl(opts.urlPrefix, urlPath);
159
- if (!parsed) {
160
- res.writeHead(403, cors).end();
161
- return;
162
- }
163
- if (!opts.verifyToken(parsed.token, parsed.rel)) {
164
- res.writeHead(401, cors).end();
165
- return;
166
- }
167
- const resolved = resolveFilePath(opts.getRoots(), parsed.rel);
168
- if ("error" in resolved) {
169
- res.writeHead(403, cors).end();
170
- return;
171
- }
172
- let absPath = null;
173
- let size = 0;
174
- for (const candidate of resolved.candidates) try {
175
- const st = await node_fs.promises.stat(candidate);
176
- if (st.isFile()) {
177
- absPath = candidate;
178
- size = st.size;
179
- break;
144
+ throw lastError ?? /* @__PURE__ */ new Error(`Failed to download model from ${url}`);
145
+ }
146
+ async function computeSha256(filePath) {
147
+ return new Promise((resolve, reject) => {
148
+ const hash = (0, node_crypto.createHash)("sha256");
149
+ const stream = node_fs.createReadStream(filePath);
150
+ stream.on("data", (chunk) => hash.update(chunk));
151
+ stream.on("end", () => resolve(hash.digest("hex")));
152
+ stream.on("error", reject);
153
+ });
154
+ }
155
+ /**
156
+ * Download every file in a HuggingFace directory bundle (e.g.,
157
+ * `.mlpackage` / OpenVINO IR pair) atomically. `knownFiles` lists the
158
+ * relative paths inside the directory; the function fetches each from
159
+ * `${url}/${file}` and renames the staging directory only on full
160
+ * success. Mirrors `ModelDownloadService.downloadDirectory` but
161
+ * exposed as a standalone for catalog-less callers.
162
+ */
163
+ async function downloadDirectory(url, destDir, knownFiles, onProgress) {
164
+ const match = url.match(/huggingface\.co\/([^/]+\/[^/]+)\/resolve\/main\/(.+)/);
165
+ if (!match) throw new Error(`Cannot parse HuggingFace URL: ${url}`);
166
+ const [, repo, dirPath] = match;
167
+ const files = (knownFiles ?? []).map((f) => ({
168
+ relativePath: f,
169
+ fileUrl: `https://huggingface.co/${repo}/resolve/main/${dirPath}/${f}`
170
+ }));
171
+ if (files.length === 0) throw new Error(`Directory bundle requires explicit \`files\` list (got none for ${url})`);
172
+ const tmpDir = destDir + ".downloading";
173
+ node_fs.rmSync(tmpDir, {
174
+ recursive: true,
175
+ force: true
176
+ });
177
+ node_fs.mkdirSync(tmpDir, { recursive: true });
178
+ let totalDownloaded = 0;
179
+ try {
180
+ for (const file of files) {
181
+ const destPath = node_path.join(tmpDir, file.relativePath);
182
+ node_fs.mkdirSync(node_path.dirname(destPath), { recursive: true });
183
+ await downloadFile(file.fileUrl, destPath, (downloaded, _total) => {
184
+ onProgress?.(totalDownloaded + downloaded, void 0);
185
+ });
186
+ totalDownloaded += node_fs.statSync(destPath).size;
180
187
  }
181
- } catch {}
182
- if (absPath === null) {
183
- res.writeHead(404, cors).end();
184
- return;
185
- }
186
- const baseHeaders = {
187
- ...cors,
188
- "content-type": contentTypeFor(absPath, opts.contentTypes),
189
- "accept-ranges": "bytes",
190
- "cache-control": "no-cache"
191
- };
192
- const range = parseRangeHeader(req.headers.range, size);
193
- if (range) {
194
- res.writeHead(206, {
195
- ...baseHeaders,
196
- "content-range": `bytes ${range.start}-${range.end}/${size}`,
197
- "content-length": String(range.end - range.start + 1)
188
+ node_fs.rmSync(destDir, {
189
+ recursive: true,
190
+ force: true
198
191
  });
199
- if (req.method === "HEAD") {
200
- res.end();
201
- return;
202
- }
203
- (0, node_fs.createReadStream)(absPath, {
204
- start: range.start,
205
- end: range.end
206
- }).pipe(res);
207
- return;
192
+ node_fs.renameSync(tmpDir, destDir);
193
+ } catch (err) {
194
+ node_fs.rmSync(tmpDir, {
195
+ recursive: true,
196
+ force: true
197
+ });
198
+ throw err;
208
199
  }
209
- res.writeHead(200, {
210
- ...baseHeaders,
211
- "content-length": String(size)
200
+ }
201
+ /**
202
+ * Resolve a `ModelCatalogEntry` against `modelsDir`: download model file
203
+ * (or directory bundle) + extra files (labels JSON, charset dict, …),
204
+ * skip if already on disk. Returns the local model path.
205
+ */
206
+ async function ensureModel(modelsDir, entry, format, onProgress) {
207
+ const formatEntry = entry.formats[format];
208
+ if (!formatEntry) throw new Error(`Model "${entry.id}" has no ${format} format. Available: ${Object.keys(entry.formats).join(", ")}`);
209
+ if (entry.extraFiles) for (const extra of entry.extraFiles) await downloadFile(extra.url, node_path.join(modelsDir, extra.filename));
210
+ const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${format}`;
211
+ const modelPath = node_path.join(modelsDir, filename);
212
+ const siblings = siblingFilesFor(formatEntry);
213
+ if (node_fs.existsSync(modelPath)) if (formatEntry.isDirectory && !node_fs.existsSync(node_path.join(modelPath, "Manifest.json"))) node_fs.rmSync(modelPath, {
214
+ recursive: true,
215
+ force: true
212
216
  });
213
- if (req.method === "HEAD") {
214
- res.end();
215
- return;
217
+ else if (siblings.some((f) => !isNonEmptyFile(node_path.join(modelsDir, f)))) {} else return modelPath;
218
+ node_fs.mkdirSync(modelsDir, { recursive: true });
219
+ if (formatEntry.isDirectory) await downloadDirectory(formatEntry.url, modelPath, formatEntry.files, onProgress);
220
+ else {
221
+ await downloadFile(formatEntry.url, modelPath, (downloaded, total) => onProgress?.(downloaded, total === 0 ? void 0 : total));
222
+ for (const sibling of siblings) await downloadFile(siblingUrl(formatEntry.url, sibling), node_path.join(modelsDir, sibling));
216
223
  }
217
- (0, node_fs.createReadStream)(absPath).pipe(res);
224
+ return modelPath;
225
+ }
226
+ /** Compute the on-disk path for a given model + format, even when not yet downloaded. */
227
+ function getModelFilePath(modelsDir, entry, format) {
228
+ const formatEntry = entry.formats[format];
229
+ if (!formatEntry) return null;
230
+ const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${format}`;
231
+ return node_path.join(modelsDir, filename);
232
+ }
233
+ /** True iff the model file (or `Manifest.json` for directory bundles) exists and is non-empty. */
234
+ function isModelDownloaded(modelsDir, entry, format) {
235
+ const formatEntry = entry.formats[format];
236
+ if (!formatEntry) return false;
237
+ const modelPath = getModelFilePath(modelsDir, entry, format);
238
+ if (!modelPath || !node_fs.existsSync(modelPath)) return false;
239
+ if (formatEntry.isDirectory) return node_fs.existsSync(node_path.join(modelPath, "Manifest.json"));
240
+ if (node_fs.statSync(modelPath).size <= 0) return false;
241
+ return siblingFilesFor(formatEntry).every((f) => isNonEmptyFile(node_path.join(modelsDir, f)));
242
+ }
243
+ /** Remove the on-disk model file/directory. Returns true if something was deleted. */
244
+ function deleteModelFromDisk(modelsDir, entry, format) {
245
+ const modelPath = getModelFilePath(modelsDir, entry, format);
246
+ if (!modelPath || !node_fs.existsSync(modelPath)) return false;
247
+ const formatEntry = entry.formats[format];
248
+ if (formatEntry?.isDirectory) node_fs.rmSync(modelPath, {
249
+ recursive: true,
250
+ force: true
251
+ });
252
+ else {
253
+ node_fs.unlinkSync(modelPath);
254
+ if (formatEntry) for (const sibling of siblingFilesFor(formatEntry)) {
255
+ const sibPath = node_path.join(modelsDir, sibling);
256
+ if (node_fs.existsSync(sibPath)) node_fs.unlinkSync(sibPath);
257
+ }
258
+ }
259
+ return true;
218
260
  }
219
261
  //#endregion
220
- //#region src/http/asset-compression.ts
262
+ //#region src/download/model-download-service.ts
221
263
  /**
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.
264
+ * Unified model download service.
249
265
  *
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.
266
+ * Handles downloading model files and extra files (labels, dicts) from a
267
+ * catalog of ModelCatalogEntry items. Supports single-file models and
268
+ * directory bundles (e.g., .mlpackage for CoreML).
254
269
  *
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.
270
+ * Addons use this via `context.models.ensure(modelId, format)`.
271
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;
272
+ var ModelDownloadService = class {
273
+ modelsDir;
274
+ onProgress;
275
+ catalog;
276
+ constructor(modelsDir, catalog, onProgress) {
277
+ this.modelsDir = modelsDir;
278
+ this.onProgress = onProgress;
279
+ const map = /* @__PURE__ */ new Map();
280
+ for (const entry of catalog) map.set(entry.id, entry);
281
+ this.catalog = map;
282
+ }
283
+ /**
284
+ * Ensure a model (and its extra files) is downloaded.
285
+ * Returns the local filesystem path to the model file/directory.
286
+ */
287
+ async ensure(modelId, format) {
288
+ const entry = this.catalog.get(modelId);
289
+ if (!entry) throw new Error(`ModelDownloadService: unknown model "${modelId}"`);
290
+ const selectedFormat = format ?? this.pickDefaultFormat(entry);
291
+ const formatEntry = entry.formats[selectedFormat];
292
+ if (!formatEntry) throw new Error(`ModelDownloadService: model "${modelId}" has no ${selectedFormat} format`);
293
+ await this.ensureExtraFiles(modelId);
294
+ const modelPath = this.modelFilePath(entry, selectedFormat);
295
+ const siblings = siblingFilesFor(formatEntry);
296
+ if (node_fs.existsSync(modelPath)) {
297
+ if (formatEntry.isDirectory) if (!node_fs.existsSync(node_path.join(modelPath, "Manifest.json"))) node_fs.rmSync(modelPath, {
298
+ recursive: true,
299
+ force: true
300
+ });
301
+ else return modelPath;
302
+ else if (siblings.every((f) => isNonEmptyFile(node_path.join(this.modelsDir, f)))) return modelPath;
305
303
  }
306
- out.push({
307
- token,
308
- q
304
+ node_fs.mkdirSync(this.modelsDir, { recursive: true });
305
+ if (formatEntry.isDirectory) await this.downloadDirectory(formatEntry.url, modelPath, formatEntry.files, modelId);
306
+ else {
307
+ await downloadFile(formatEntry.url, modelPath, this.onProgress ? (downloaded, total) => this.onProgress(modelId, downloaded, total) : void 0);
308
+ for (const sibling of siblings) await downloadFile(siblingUrl(formatEntry.url, sibling), node_path.join(this.modelsDir, sibling));
309
+ }
310
+ return modelPath;
311
+ }
312
+ /**
313
+ * Ensure extra files for a model are downloaded.
314
+ * Returns the local paths of all extra files.
315
+ */
316
+ async ensureExtraFiles(modelId) {
317
+ const entry = this.catalog.get(modelId);
318
+ if (!entry) throw new Error(`ModelDownloadService: unknown model "${modelId}"`);
319
+ const extras = entry.extraFiles;
320
+ if (!extras || extras.length === 0) return [];
321
+ const paths = [];
322
+ for (const extra of extras) {
323
+ const destPath = node_path.join(this.modelsDir, extra.filename);
324
+ await downloadFile(extra.url, destPath);
325
+ paths.push(destPath);
326
+ }
327
+ return paths;
328
+ }
329
+ /** Absolute path to the shared models directory. */
330
+ getModelsDir() {
331
+ return this.modelsDir;
332
+ }
333
+ /** Check if a model file is already present on disk. */
334
+ isDownloaded(modelId, format) {
335
+ const entry = this.catalog.get(modelId);
336
+ if (!entry) return false;
337
+ const selectedFormat = format ?? this.pickDefaultFormat(entry);
338
+ const formatEntry = entry.formats[selectedFormat];
339
+ if (!formatEntry) return false;
340
+ const modelPath = this.modelFilePath(entry, selectedFormat);
341
+ if (!node_fs.existsSync(modelPath)) return false;
342
+ if (formatEntry.isDirectory) return node_fs.existsSync(node_path.join(modelPath, "Manifest.json"));
343
+ if (node_fs.statSync(modelPath).size <= 0) return false;
344
+ return siblingFilesFor(formatEntry).every((f) => isNonEmptyFile(node_path.join(this.modelsDir, f)));
345
+ }
346
+ /** Get the catalog entry for a model by ID. */
347
+ getEntry(modelId) {
348
+ return this.catalog.get(modelId);
349
+ }
350
+ pickDefaultFormat(entry) {
351
+ for (const fmt of [
352
+ "onnx",
353
+ "coreml",
354
+ "openvino",
355
+ "tflite",
356
+ "pt"
357
+ ]) if (entry.formats[fmt]) return fmt;
358
+ const first = Object.keys(entry.formats)[0];
359
+ if (first) return first;
360
+ throw new Error(`ModelDownloadService: model "${entry.id}" has no formats`);
361
+ }
362
+ modelFilePath(entry, format) {
363
+ const formatEntry = entry.formats[format];
364
+ if (!formatEntry) throw new Error(`Model ${entry.id} has no ${format} format`);
365
+ const urlParts = formatEntry.url.split("/");
366
+ const filename = urlParts[urlParts.length - 1] ?? `${entry.id}.${format}`;
367
+ return node_path.join(this.modelsDir, filename);
368
+ }
369
+ /**
370
+ * Download a directory bundle (e.g., .mlpackage) from HuggingFace.
371
+ * ATOMIC: downloads to temp dir, renames only on complete success.
372
+ */
373
+ async downloadDirectory(url, destDir, knownFiles, _modelId) {
374
+ const match = url.match(/huggingface\.co\/([^/]+\/[^/]+)\/resolve\/main\/(.+)/);
375
+ if (!match) throw new Error(`Cannot parse HuggingFace URL: ${url}`);
376
+ const [, repo, dirPath] = match;
377
+ let filesToDownload;
378
+ if (knownFiles && knownFiles.length > 0) filesToDownload = knownFiles.map((f) => ({
379
+ relativePath: f,
380
+ fileUrl: `https://huggingface.co/${repo}/resolve/main/${dirPath}/${f}`
381
+ }));
382
+ else {
383
+ const hfFiles = await this.listHfFiles(repo, dirPath);
384
+ if (hfFiles.length === 0) throw new Error(`No files found in HuggingFace directory: ${dirPath}`);
385
+ filesToDownload = hfFiles.map((f) => ({
386
+ relativePath: f.path.substring(dirPath.length + 1),
387
+ fileUrl: `https://huggingface.co/${repo}/resolve/main/${f.path}`
388
+ }));
389
+ }
390
+ const tmpDir = destDir + ".downloading";
391
+ node_fs.rmSync(tmpDir, {
392
+ recursive: true,
393
+ force: true
309
394
  });
395
+ node_fs.mkdirSync(tmpDir, { recursive: true });
396
+ try {
397
+ for (const file of filesToDownload) {
398
+ const destPath = node_path.join(tmpDir, file.relativePath);
399
+ node_fs.mkdirSync(node_path.dirname(destPath), { recursive: true });
400
+ await downloadFile(file.fileUrl, destPath);
401
+ }
402
+ node_fs.rmSync(destDir, {
403
+ recursive: true,
404
+ force: true
405
+ });
406
+ node_fs.renameSync(tmpDir, destDir);
407
+ } catch (err) {
408
+ node_fs.rmSync(tmpDir, {
409
+ recursive: true,
410
+ force: true
411
+ });
412
+ throw err;
413
+ }
310
414
  }
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
- }
415
+ /** Recursively list all files in a HuggingFace directory via API. */
416
+ async listHfFiles(repo, dirPath) {
417
+ const entries = await fetchJson(`https://huggingface.co/api/models/${repo}/tree/main/${dirPath}`);
418
+ const files = [];
419
+ for (const entry of entries) if (entry.type === "file") files.push({
420
+ path: entry.path,
421
+ size: entry.size ?? entry.lfs?.size ?? 0
422
+ });
423
+ else if (entry.type === "directory") {
424
+ const subFiles = await this.listHfFiles(repo, entry.path);
425
+ files.push(...subFiles);
426
+ }
427
+ return files;
428
+ }
429
+ };
430
+ //#endregion
431
+ //#region src/http/authenticated-file-server.ts
319
432
  /**
320
- * Pick a coding for this request: brotli when offered, else gzip, else identity.
433
+ * Authenticated data-plane file-server a shared host primitive for addons that
434
+ * must serve files (media segments, scrub-frame stores, exported clips) directly
435
+ * to browsers/players, node-direct (NOT through the hub tRPC, per the recording
436
+ * design's "no media bytes traverse the hub").
321
437
  *
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.
438
+ * It bundles the three things every such server needs so addons don't re-roll
439
+ * them: CORS (the data plane is cross-origin from the admin-ui a native
440
+ * `<video src>` is exempt but hls.js / WebCodecs fetch via XHR and are blocked
441
+ * without it), HTTP `Range` (seeking / partial fetch), and a SCOPED TOKEN check.
442
+ *
443
+ * The token rides as the FIRST path segment (`<urlPrefix><token>/<relPath>`) so
444
+ * a player resolving a manifest's RELATIVE child URIs carries it on every
445
+ * sub-request automatically — no per-URI rewriting, no cookies/headers a dumb
446
+ * player can't set. The caller supplies `verifyToken(token, relPath)`; the
447
+ * primitive stays auth-scheme-agnostic.
448
+ *
449
+ * Pure helpers (`parseTokenizedUrl`, `resolveFilePath`, `contentTypeFor`,
450
+ * `parseRangeHeader`) are exported for unit testing; `createAuthenticatedFileServer`
451
+ * is the thin `node:http` glue.
326
452
  */
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
- } });
453
+ function corsHeaders(origin) {
454
+ return {
455
+ "access-control-allow-origin": origin,
456
+ "access-control-allow-methods": "GET, HEAD, OPTIONS",
457
+ "access-control-allow-headers": "range",
458
+ "access-control-expose-headers": "content-length, content-range, accept-ranges",
459
+ "access-control-max-age": "86400"
460
+ };
356
461
  }
357
462
  /**
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.
463
+ * Split a request URL (`<urlPrefix><token>/<relPath>`) into its token + rel
464
+ * path. Returns null for anything outside the prefix or missing either part.
465
+ * `relPath` is URL-decoded; a decode failure null.
366
466
  */
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);
467
+ function parseTokenizedUrl(urlPrefix, urlPath) {
468
+ if (!urlPath.startsWith(urlPrefix)) return null;
469
+ const after = urlPath.slice(urlPrefix.length);
470
+ const slash = after.indexOf("/");
471
+ if (slash <= 0) return null;
472
+ const token = after.slice(0, slash);
473
+ let rel;
393
474
  try {
394
- return await pending;
475
+ rel = decodeURIComponent(after.slice(slash + 1));
395
476
  } catch {
396
477
  return null;
397
- } finally {
398
- inFlight.delete(id);
399
478
  }
479
+ if (rel.length === 0) return null;
480
+ return {
481
+ token,
482
+ rel
483
+ };
400
484
  }
401
- //#endregion
402
- //#region src/http/file-data-plane.ts
403
485
  /**
404
- * A ready-made data-plane request handler that serves files from a set of roots
405
- * with HTTP `Range`, for addons whose data-plane is "stream files off disk"
406
- * (recording playback, scrub-frame stores, exported clips).
407
- *
408
- * This is the addon-side handler the addon hands to `ctx.dataPlane.serve({ handler })`.
409
- * It receives the REAL Node `req`/`res`, resolves the request path against the
410
- * addon's roots (traversal-guarded), and streams the file. NO token and NO CORS:
411
- * the hub already authenticated the caller and the data plane is same-origin
412
- * through the hub's port. Reuses the shared Range/content-type/traversal helpers
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.
486
+ * Map a rel path to one candidate absolute path PER root, keeping only roots the
487
+ * path stays within (traversal guard). The handler serves the first candidate
488
+ * that exists. Rejects a path that escapes every root.
419
489
  */
420
- /** Build a `(req, res)` handler that serves `getRoots()` files with Range. */
421
- function createFileDataPlaneHandler(opts) {
422
- return async (req, res) => {
423
- if (req.method !== "GET" && req.method !== "HEAD") {
424
- res.writeHead(405).end();
425
- return;
426
- }
427
- const urlPath = (req.url ?? "/").split("?")[0] ?? "/";
428
- let rel;
429
- try {
430
- rel = decodeURIComponent(urlPath.replace(/^\/+/, ""));
431
- } catch {
432
- res.writeHead(400).end();
433
- return;
434
- }
435
- if (rel.length === 0) {
436
- res.writeHead(404).end();
437
- return;
438
- }
439
- const resolved = resolveFilePath(opts.getRoots(), rel);
440
- if ("error" in resolved) {
441
- res.writeHead(403).end();
442
- return;
443
- }
444
- let absPath = null;
445
- let size = 0;
446
- let mtimeMs = 0;
447
- for (const candidate of resolved.candidates) try {
448
- const st = await node_fs.promises.stat(candidate);
449
- if (st.isFile()) {
450
- absPath = candidate;
451
- size = st.size;
452
- mtimeMs = st.mtimeMs;
453
- break;
454
- }
455
- } catch {}
456
- if (absPath === null) {
457
- res.writeHead(404).end();
458
- return;
459
- }
460
- const contentType = contentTypeFor(absPath, opts.contentTypes);
461
- const compressible = isCompressibleContentType(contentType);
462
- const baseHeaders = {
463
- "content-type": contentType,
464
- "accept-ranges": "bytes",
465
- "cache-control": opts.cacheControl?.(rel) ?? "no-cache",
466
- ...compressible ? { vary: "accept-encoding" } : {}
467
- };
468
- const range = parseRangeHeader(req.headers.range, size);
469
- if (range) {
470
- res.writeHead(206, {
471
- ...baseHeaders,
472
- "content-range": `bytes ${range.start}-${range.end}/${size}`,
473
- "content-length": String(range.end - range.start + 1)
474
- });
475
- if (req.method === "HEAD") {
476
- res.end();
477
- return;
478
- }
479
- (0, node_fs.createReadStream)(absPath, {
480
- start: range.start,
481
- end: range.end
482
- }).pipe(res);
483
- return;
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
- }
490
+ function resolveFilePath(roots, rel) {
491
+ if (rel.length === 0) return { error: "forbidden" };
492
+ const candidates = [];
493
+ for (const rootDir of roots) {
494
+ const root = node_path.default.resolve(rootDir);
495
+ const abs = node_path.default.resolve(root, rel);
496
+ if (abs === root || abs.startsWith(root + node_path.default.sep)) candidates.push(abs);
497
+ }
498
+ if (candidates.length === 0) return { error: "forbidden" };
499
+ return { candidates };
500
+ }
501
+ var DEFAULT_CONTENT_TYPES = {
502
+ ".m3u8": "application/vnd.apple.mpegurl",
503
+ ".m4s": "video/mp4",
504
+ ".mp4": "video/mp4",
505
+ ".idx": "application/octet-stream",
506
+ ".html": "text/html; charset=utf-8",
507
+ ".js": "application/javascript; charset=utf-8",
508
+ ".mjs": "application/javascript; charset=utf-8",
509
+ ".css": "text/css; charset=utf-8",
510
+ ".json": "application/json; charset=utf-8",
511
+ ".map": "application/json; charset=utf-8",
512
+ ".svg": "image/svg+xml",
513
+ ".png": "image/png",
514
+ ".jpg": "image/jpeg",
515
+ ".jpeg": "image/jpeg",
516
+ ".ico": "image/x-icon",
517
+ ".woff2": "font/woff2",
518
+ ".woff": "font/woff",
519
+ ".ttf": "font/ttf"
520
+ };
521
+ function contentTypeFor(filePath, overrides) {
522
+ const ext = node_path.default.extname(filePath).toLowerCase();
523
+ return overrides?.[ext] ?? DEFAULT_CONTENT_TYPES[ext] ?? "application/octet-stream";
524
+ }
525
+ /** Parse an HTTP `Range` header against a known size. Null = serve whole file. */
526
+ function parseRangeHeader(header, size) {
527
+ if (!header) return null;
528
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
529
+ if (!m) return null;
530
+ const [, rawStart, rawEnd] = m;
531
+ if (rawStart === "" && rawEnd === "") return null;
532
+ let start;
533
+ let end;
534
+ if (rawStart === "") {
535
+ const n = Number(rawEnd);
536
+ if (n <= 0) return null;
537
+ start = Math.max(0, size - n);
538
+ end = size - 1;
539
+ } else {
540
+ start = Number(rawStart);
541
+ end = rawEnd === "" ? size - 1 : Number(rawEnd);
542
+ }
543
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) return null;
544
+ return {
545
+ start,
546
+ end: Math.min(end, size - 1)
547
+ };
548
+ }
549
+ /**
550
+ * Start an authenticated file-server. Returns the bound port so the caller can
551
+ * build URLs. `getRoots`/`verifyToken` are invoked per request, so they always
552
+ * reflect the live config.
553
+ */
554
+ async function createAuthenticatedFileServer(opts) {
555
+ const cors = corsHeaders(opts.corsOrigin ?? "*");
556
+ const server = (0, node_http.createServer)((req, res) => {
557
+ handle(opts, cors, req, res);
558
+ });
559
+ await new Promise((resolve, reject) => {
560
+ server.once("error", reject);
561
+ server.listen(opts.port, () => resolve());
562
+ });
563
+ const addr = server.address();
564
+ return {
565
+ port: typeof addr === "object" && addr ? addr.port : opts.port,
566
+ close: () => new Promise((resolve) => server.close(() => resolve()))
567
+ };
568
+ }
569
+ async function handle(opts, cors, req, res) {
570
+ if (req.method === "OPTIONS") {
571
+ res.writeHead(204, cors).end();
572
+ return;
573
+ }
574
+ if (req.method !== "GET" && req.method !== "HEAD") {
575
+ res.writeHead(405, cors).end();
576
+ return;
577
+ }
578
+ const urlPath = (req.url ?? "").split("?")[0] ?? "";
579
+ const parsed = parseTokenizedUrl(opts.urlPrefix, urlPath);
580
+ if (!parsed) {
581
+ res.writeHead(403, cors).end();
582
+ return;
583
+ }
584
+ if (!opts.verifyToken(parsed.token, parsed.rel)) {
585
+ res.writeHead(401, cors).end();
586
+ return;
587
+ }
588
+ const resolved = resolveFilePath(opts.getRoots(), parsed.rel);
589
+ if ("error" in resolved) {
590
+ res.writeHead(403, cors).end();
591
+ return;
592
+ }
593
+ let absPath = null;
594
+ let size = 0;
595
+ for (const candidate of resolved.candidates) try {
596
+ const st = await node_fs.promises.stat(candidate);
597
+ if (st.isFile()) {
598
+ absPath = candidate;
599
+ size = st.size;
600
+ break;
508
601
  }
509
- res.writeHead(200, {
602
+ } catch {}
603
+ if (absPath === null) {
604
+ res.writeHead(404, cors).end();
605
+ return;
606
+ }
607
+ const baseHeaders = {
608
+ ...cors,
609
+ "content-type": contentTypeFor(absPath, opts.contentTypes),
610
+ "accept-ranges": "bytes",
611
+ "cache-control": "no-cache"
612
+ };
613
+ const range = parseRangeHeader(req.headers.range, size);
614
+ if (range) {
615
+ res.writeHead(206, {
510
616
  ...baseHeaders,
511
- "content-length": String(size)
617
+ "content-range": `bytes ${range.start}-${range.end}/${size}`,
618
+ "content-length": String(range.end - range.start + 1)
512
619
  });
513
620
  if (req.method === "HEAD") {
514
621
  res.end();
515
622
  return;
516
623
  }
517
- (0, node_fs.createReadStream)(absPath).pipe(res);
518
- };
624
+ (0, node_fs.createReadStream)(absPath, {
625
+ start: range.start,
626
+ end: range.end
627
+ }).pipe(res);
628
+ return;
629
+ }
630
+ res.writeHead(200, {
631
+ ...baseHeaders,
632
+ "content-length": String(size)
633
+ });
634
+ if (req.method === "HEAD") {
635
+ res.end();
636
+ return;
637
+ }
638
+ (0, node_fs.createReadStream)(absPath).pipe(res);
519
639
  }
520
640
  //#endregion
521
- //#region src/download/model-downloader.ts
522
- function isNonEmptyFile(filePath) {
523
- return node_fs.existsSync(filePath) && node_fs.statSync(filePath).size > 0;
524
- }
525
- /**
526
- * Sibling files of a single-file (non-directory) format — extra files the
527
- * format needs, fetched from the same remote directory as `url` and stored
528
- * flat alongside the main file in `modelsDir`. Catalog-declared via
529
- * `formatEntry.files`, e.g. OpenVINO IR lists its `.bin` weights next to the
530
- * `.xml`. The downloader stays format-agnostic; the convention lives in the
531
- * catalog data (like `MLPACKAGE_FILES` for the directory case).
532
- */
533
- function siblingFilesFor(formatEntry) {
534
- return formatEntry.isDirectory ? [] : formatEntry.files ?? [];
535
- }
536
- /** Resolve a sibling's remote URL relative to the main file's directory. */
537
- function siblingUrl(mainUrl, sibling) {
538
- return mainUrl.replace(/[^/]+$/, sibling);
539
- }
641
+ //#region src/http/asset-compression.ts
540
642
  /**
541
- * The relative filenames a model format occupies under `modelsDir` — the main
542
- * file (URL basename, or `${id}.${format}` fallback), its declared siblings
543
- * (e.g. the OpenVINO `.bin` next to the `.xml`), and any `extraFiles`. For a
544
- * directory bundle the single entry is the bundle dir itself (tar/extract
545
- * recurse into it).
643
+ * Content-coding for addon-served static assets: `Accept-Encoding` negotiation,
644
+ * an eligibility rule, and a BOUNDED cache of the compressed bytes.
546
645
  *
547
- * Used by model distribution (P2): the hub tars exactly these names from its
548
- * `modelsDir` and the agent untars them into its own `modelsDir`, so both ends
549
- * agree on the on-disk layout without re-deriving it.
646
+ * ## Why the ADDON owns the encoding
647
+ *
648
+ * The hub's `/addon/:addonId/*` bridge is registered `{ compress: false }`. That
649
+ * is not an oversight — on 2026-07-16 the global `@fastify/compress` was found
650
+ * emitting an EMPTY brotli stream (`content-encoding: br`, `content-length: 0`)
651
+ * for any compressible body over its 1 KiB threshold once the body had crossed
652
+ * the UDS route bridge of a FORKED addon. It silently broke the notifier SVG
653
+ * icons; the deploy-bundle pull route had already dodged the same class with
654
+ * `compress: false`. The resolution then, and the contract now, is that an addon
655
+ * route owns its content-coding END TO END: it negotiates, it compresses, it
656
+ * stamps `content-encoding` + `Vary`, and the hub pipes those bytes through
657
+ * untouched (`proxyToUpstream` replays the upstream headers verbatim).
658
+ *
659
+ * So compression belongs HERE, in the shared file data-plane, and nowhere above
660
+ * it. Anything that re-compresses on top of this reintroduces the empty-brotli
661
+ * bug rather than doubling the ratio.
662
+ *
663
+ * ## Why the bytes are cached, and why the cache is bounded
664
+ *
665
+ * Measured on the stream-broker embed bundle (`index-<hash>.js`, 1,443,142 B):
666
+ * gzip-6 → 360,868 B, brotli-5 → 327,419 B in 27 ms, brotli-11 → 293,677 B in
667
+ * 2,233 ms. Quality 5 is the request-path choice: 77% off the wire for 27 ms
668
+ * paid ONCE. Quality 11 buys another 2.3% for eighty times the CPU, on a path
669
+ * where a real client is waiting.
670
+ *
671
+ * The cache is an LRU bounded by BOTH entry count and total bytes, and it
672
+ * refuses assets over {@link COMPRESSION_MAX_ASSET_BYTES} outright. An unbounded
673
+ * `Map` keyed by URL is how a long-lived process walks into an OOM, and this
674
+ * repo has already spent a session on one.
675
+ *
676
+ * The key carries `mtimeMs` and `size`, so a rebuild that rewrites an asset in
677
+ * place under an UNCHANGED name (an SPA shell, a service worker) can never be
678
+ * served from the previous build's bytes.
550
679
  */
551
- function collectModelFiles(entry, format) {
552
- const formatEntry = entry.formats[format];
553
- if (!formatEntry) return [];
554
- const names = [formatEntry.url.split("/").pop() ?? `${entry.id}.${format}`, ...siblingFilesFor(formatEntry)];
555
- for (const extra of entry.extraFiles ?? []) names.push(extra.filename);
556
- return names;
557
- }
558
- /** Build fetch headers, including HF auth token for huggingface.co URLs */
559
- function buildHeaders(url) {
560
- const headers = { "User-Agent": "CamStack/1.0" };
561
- const hfToken = process.env["HF_TOKEN"] ?? process.env["HUGGING_FACE_HUB_TOKEN"];
562
- if (hfToken && url.includes("huggingface.co")) headers["Authorization"] = `Bearer ${hfToken}`;
563
- return headers;
564
- }
680
+ var brotliAsync = (0, node_util.promisify)(node_zlib.brotliCompress);
681
+ var gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
682
+ /** LRU bounds. Both are enforced; whichever binds first evicts. */
683
+ var MAX_CACHE_ENTRIES = 64;
684
+ var MAX_CACHE_BYTES = 32 * 1024 * 1024;
685
+ /** Brotli quality — see the file header for the measured trade. */
686
+ var BROTLI_QUALITY = 5;
565
687
  /**
566
- * Download a single file from a URL to a destination path.
567
- * Uses native fetch() (Node 22+) which handles redirects natively.
568
- * Streams to disk with optional progress callback.
569
- * Returns the destination path. Skips download if file already exists.
688
+ * Content types worth compressing. An ALLOW-list, not a deny-list: an unknown
689
+ * type (`application/octet-stream`) is assumed to be opaque binary. This is what
690
+ * keeps jpeg/png/webp/mp4/m4s/woff2 out running brotli over them burns CPU to
691
+ * make the body marginally BIGGER.
570
692
  */
571
- async function downloadFile(url, destPath, onProgress) {
572
- if (node_fs.existsSync(destPath)) return destPath;
573
- node_fs.mkdirSync(node_path.dirname(destPath), { recursive: true });
574
- const tmpPath = destPath + ".downloading";
575
- try {
576
- const response = await fetch(url, {
577
- redirect: "follow",
578
- headers: buildHeaders(url)
579
- });
580
- if (!response.ok) throw new Error(`HTTP ${response.status} downloading ${url}`);
581
- if (!response.body) throw new Error(`No response body from ${url}`);
582
- const total = parseInt(response.headers.get("content-length") ?? "0", 10);
583
- let downloaded = 0;
584
- const fileStream = node_fs.createWriteStream(tmpPath);
585
- const reader = response.body.getReader();
586
- try {
587
- for (;;) {
588
- const { done, value } = await reader.read();
589
- if (done || !value) break;
590
- fileStream.write(value);
591
- downloaded += value.length;
592
- onProgress?.(downloaded, total);
593
- }
594
- } finally {
595
- fileStream.end();
596
- await new Promise((resolve, reject) => {
597
- fileStream.on("finish", resolve);
598
- fileStream.on("error", reject);
599
- });
693
+ var COMPRESSIBLE_EXACT = new Set([
694
+ "application/javascript",
695
+ "application/ecmascript",
696
+ "application/json",
697
+ "application/manifest+json",
698
+ "application/wasm",
699
+ "application/xml",
700
+ "application/xhtml+xml",
701
+ "application/vnd.apple.mpegurl",
702
+ "image/svg+xml"
703
+ ]);
704
+ /** Is a body of this content type worth compressing at all? */
705
+ function isCompressibleContentType(contentType) {
706
+ const base = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
707
+ if (base.length === 0) return false;
708
+ if (base.startsWith("text/")) return true;
709
+ if (base.endsWith("+json") || base.endsWith("+xml")) return true;
710
+ return COMPRESSIBLE_EXACT.has(base);
711
+ }
712
+ /** Parse `Accept-Encoding` into tokens with their q-values (default 1). */
713
+ function parseAcceptEncoding(header) {
714
+ if (header === void 0 || header.trim().length === 0) return [];
715
+ const out = [];
716
+ for (const part of header.split(",")) {
717
+ const [rawToken, ...params] = part.split(";");
718
+ const token = rawToken?.trim().toLowerCase() ?? "";
719
+ if (token.length === 0) continue;
720
+ let q = 1;
721
+ for (const param of params) {
722
+ const [name, value] = param.split("=");
723
+ if (name?.trim().toLowerCase() !== "q") continue;
724
+ const parsed = Number(value?.trim());
725
+ q = Number.isFinite(parsed) ? parsed : 1;
600
726
  }
601
- node_fs.renameSync(tmpPath, destPath);
602
- return destPath;
603
- } catch (err) {
604
- try {
605
- node_fs.unlinkSync(tmpPath);
606
- } catch {}
607
- throw err;
727
+ out.push({
728
+ token,
729
+ q
730
+ });
608
731
  }
732
+ return out;
609
733
  }
610
- /**
611
- * Fetch JSON from a URL using native fetch().
612
- */
613
- async function fetchJson(url) {
614
- const response = await fetch(url, {
615
- redirect: "follow",
616
- headers: buildHeaders(url)
617
- });
618
- if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`);
619
- return response.json();
734
+ function accepts(prefs, token) {
735
+ const exact = prefs.find((p) => p.token === token);
736
+ if (exact) return exact.q > 0;
737
+ const wildcard = prefs.find((p) => p.token === "*");
738
+ return wildcard !== void 0 && wildcard.q > 0;
620
739
  }
621
740
  /**
622
- * Download a model with fallback URLs and optional SHA256 verification.
623
- * Legacy API preserved for backward compatibility -- delegates to downloadFile().
741
+ * Pick a coding for this request: brotli when offered, else gzip, else identity.
742
+ *
743
+ * Returns `null` for identity — including when the header is ABSENT. A client
744
+ * that offers nothing gets the raw bytes; guessing on its behalf is how a
745
+ * non-negotiating consumer (a native fetch, a probe, an OTA puller) ends up with
746
+ * a body it cannot read.
624
747
  */
625
- async function downloadModel(options) {
626
- const { url, fallbackUrls = [], destDir, filename, expectedSha256, onProgress } = options;
627
- const fname = filename ?? url.split("/").pop() ?? "model.bin";
628
- const destPath = node_path.join(destDir, fname);
629
- if (node_fs.existsSync(destPath)) return {
630
- filePath: destPath,
631
- downloadedBytes: 0,
632
- fromCache: true
633
- };
634
- node_fs.mkdirSync(destDir, { recursive: true });
635
- const urls = [url, ...fallbackUrls];
636
- let lastError = null;
637
- for (const tryUrl of urls) try {
638
- await downloadFile(tryUrl, destPath, onProgress);
639
- if (expectedSha256) {
640
- const hash = await computeSha256(destPath);
641
- if (hash !== expectedSha256) {
642
- node_fs.unlinkSync(destPath);
643
- throw new Error(`SHA256 mismatch: expected ${expectedSha256}, got ${hash}`);
644
- }
645
- }
646
- return {
647
- filePath: destPath,
648
- downloadedBytes: node_fs.statSync(destPath).size,
649
- fromCache: false
650
- };
651
- } catch (e) {
652
- lastError = e;
653
- if (node_fs.existsSync(destPath)) node_fs.unlinkSync(destPath);
654
- }
655
- throw lastError ?? /* @__PURE__ */ new Error(`Failed to download model from ${url}`);
748
+ function negotiateContentEncoding(acceptEncoding) {
749
+ const prefs = parseAcceptEncoding(acceptEncoding);
750
+ if (prefs.length === 0) return null;
751
+ if (accepts(prefs, "br")) return "br";
752
+ if (accepts(prefs, "gzip")) return "gzip";
753
+ return null;
656
754
  }
657
- async function computeSha256(filePath) {
658
- return new Promise((resolve, reject) => {
659
- const hash = (0, node_crypto.createHash)("sha256");
660
- const stream = node_fs.createReadStream(filePath);
661
- stream.on("data", (chunk) => hash.update(chunk));
662
- stream.on("end", () => resolve(hash.digest("hex")));
663
- stream.on("error", reject);
664
- });
755
+ var cache = /* @__PURE__ */ new Map();
756
+ var inFlight = /* @__PURE__ */ new Map();
757
+ var compressions = 0;
758
+ var hits = 0;
759
+ var cachedBytes = 0;
760
+ function cacheKey(key) {
761
+ return `${key.encoding}|${String(key.mtimeMs)}|${String(key.size)}|${key.path}`;
665
762
  }
666
- /**
667
- * Download every file in a HuggingFace directory bundle (e.g.,
668
- * `.mlpackage` / OpenVINO IR pair) atomically. `knownFiles` lists the
669
- * relative paths inside the directory; the function fetches each from
670
- * `${url}/${file}` and renames the staging directory only on full
671
- * success. Mirrors `ModelDownloadService.downloadDirectory` but
672
- * exposed as a standalone for catalog-less callers.
673
- */
674
- async function downloadDirectory(url, destDir, knownFiles, onProgress) {
675
- const match = url.match(/huggingface\.co\/([^/]+\/[^/]+)\/resolve\/main\/(.+)/);
676
- if (!match) throw new Error(`Cannot parse HuggingFace URL: ${url}`);
677
- const [, repo, dirPath] = match;
678
- const files = (knownFiles ?? []).map((f) => ({
679
- relativePath: f,
680
- fileUrl: `https://huggingface.co/${repo}/resolve/main/${dirPath}/${f}`
681
- }));
682
- if (files.length === 0) throw new Error(`Directory bundle requires explicit \`files\` list (got none for ${url})`);
683
- const tmpDir = destDir + ".downloading";
684
- node_fs.rmSync(tmpDir, {
685
- recursive: true,
686
- force: true
687
- });
688
- node_fs.mkdirSync(tmpDir, { recursive: true });
689
- let totalDownloaded = 0;
690
- try {
691
- for (const file of files) {
692
- const destPath = node_path.join(tmpDir, file.relativePath);
693
- node_fs.mkdirSync(node_path.dirname(destPath), { recursive: true });
694
- await downloadFile(file.fileUrl, destPath, (downloaded, _total) => {
695
- onProgress?.(totalDownloaded + downloaded, void 0);
696
- });
697
- totalDownloaded += node_fs.statSync(destPath).size;
698
- }
699
- node_fs.rmSync(destDir, {
700
- recursive: true,
701
- force: true
702
- });
703
- node_fs.renameSync(tmpDir, destDir);
704
- } catch (err) {
705
- node_fs.rmSync(tmpDir, {
706
- recursive: true,
707
- force: true
708
- });
709
- throw err;
763
+ /** Evict least-recently-used entries until both bounds hold. */
764
+ function evictToBounds() {
765
+ for (const [k, buf] of cache) {
766
+ if (cache.size <= MAX_CACHE_ENTRIES && cachedBytes <= MAX_CACHE_BYTES) return;
767
+ cache.delete(k);
768
+ cachedBytes -= buf.byteLength;
710
769
  }
711
770
  }
771
+ async function compressBody(raw, encoding) {
772
+ if (encoding === "gzip") return await gzipAsync(raw);
773
+ return await brotliAsync(raw, { params: {
774
+ [node_zlib.constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
775
+ [node_zlib.constants.BROTLI_PARAM_SIZE_HINT]: raw.byteLength
776
+ } });
777
+ }
712
778
  /**
713
- * Resolve a `ModelCatalogEntry` against `modelsDir`: download model file
714
- * (or directory bundle) + extra files (labels JSON, charset dict, …),
715
- * skip if already on disk. Returns the local model path.
779
+ * Compressed bytes for one asset, compressing at most once per key.
780
+ *
781
+ * `null` means "send the original" the asset is too large to buffer, the
782
+ * source could not be read, or compression did not actually shrink it. Every
783
+ * failure degrades to identity, so this can only ever be an optimisation.
784
+ *
785
+ * Concurrent callers for the same key share ONE compression: a cold cache hit by
786
+ * a dozen parallel asset requests must not run zlib a dozen times.
716
787
  */
717
- async function ensureModel(modelsDir, entry, format, onProgress) {
718
- const formatEntry = entry.formats[format];
719
- if (!formatEntry) throw new Error(`Model "${entry.id}" has no ${format} format. Available: ${Object.keys(entry.formats).join(", ")}`);
720
- if (entry.extraFiles) for (const extra of entry.extraFiles) await downloadFile(extra.url, node_path.join(modelsDir, extra.filename));
721
- const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${format}`;
722
- const modelPath = node_path.join(modelsDir, filename);
723
- const siblings = siblingFilesFor(formatEntry);
724
- if (node_fs.existsSync(modelPath)) if (formatEntry.isDirectory && !node_fs.existsSync(node_path.join(modelPath, "Manifest.json"))) node_fs.rmSync(modelPath, {
725
- recursive: true,
726
- force: true
727
- });
728
- else if (siblings.some((f) => !isNonEmptyFile(node_path.join(modelsDir, f)))) {} else return modelPath;
729
- node_fs.mkdirSync(modelsDir, { recursive: true });
730
- if (formatEntry.isDirectory) await downloadDirectory(formatEntry.url, modelPath, formatEntry.files, onProgress);
731
- else {
732
- await downloadFile(formatEntry.url, modelPath, (downloaded, total) => onProgress?.(downloaded, total === 0 ? void 0 : total));
733
- for (const sibling of siblings) await downloadFile(siblingUrl(formatEntry.url, sibling), node_path.join(modelsDir, sibling));
788
+ async function getCompressedAsset(key, readSource) {
789
+ if (key.size > 8388608) return null;
790
+ const id = cacheKey(key);
791
+ const cached = cache.get(id);
792
+ if (cached) {
793
+ cache.delete(id);
794
+ cache.set(id, cached);
795
+ hits += 1;
796
+ return cached;
734
797
  }
735
- return modelPath;
736
- }
737
- /** Compute the on-disk path for a given model + format, even when not yet downloaded. */
738
- function getModelFilePath(modelsDir, entry, format) {
739
- const formatEntry = entry.formats[format];
740
- if (!formatEntry) return null;
741
- const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${format}`;
742
- return node_path.join(modelsDir, filename);
743
- }
744
- /** True iff the model file (or `Manifest.json` for directory bundles) exists and is non-empty. */
745
- function isModelDownloaded(modelsDir, entry, format) {
746
- const formatEntry = entry.formats[format];
747
- if (!formatEntry) return false;
748
- const modelPath = getModelFilePath(modelsDir, entry, format);
749
- if (!modelPath || !node_fs.existsSync(modelPath)) return false;
750
- if (formatEntry.isDirectory) return node_fs.existsSync(node_path.join(modelPath, "Manifest.json"));
751
- if (node_fs.statSync(modelPath).size <= 0) return false;
752
- return siblingFilesFor(formatEntry).every((f) => isNonEmptyFile(node_path.join(modelsDir, f)));
753
- }
754
- /** Remove the on-disk model file/directory. Returns true if something was deleted. */
755
- function deleteModelFromDisk(modelsDir, entry, format) {
756
- const modelPath = getModelFilePath(modelsDir, entry, format);
757
- if (!modelPath || !node_fs.existsSync(modelPath)) return false;
758
- const formatEntry = entry.formats[format];
759
- if (formatEntry?.isDirectory) node_fs.rmSync(modelPath, {
760
- recursive: true,
761
- force: true
762
- });
763
- else {
764
- node_fs.unlinkSync(modelPath);
765
- if (formatEntry) for (const sibling of siblingFilesFor(formatEntry)) {
766
- const sibPath = node_path.join(modelsDir, sibling);
767
- if (node_fs.existsSync(sibPath)) node_fs.unlinkSync(sibPath);
798
+ const pending = inFlight.get(id) ?? (async () => {
799
+ let raw;
800
+ try {
801
+ raw = await readSource();
802
+ } catch {
803
+ return null;
768
804
  }
805
+ const out = await compressBody(raw, key.encoding);
806
+ compressions += 1;
807
+ if (out.byteLength >= raw.byteLength) return null;
808
+ cache.set(id, out);
809
+ cachedBytes += out.byteLength;
810
+ evictToBounds();
811
+ return out;
812
+ })();
813
+ inFlight.set(id, pending);
814
+ try {
815
+ return await pending;
816
+ } catch {
817
+ return null;
818
+ } finally {
819
+ inFlight.delete(id);
769
820
  }
770
- return true;
771
821
  }
772
822
  //#endregion
773
- //#region src/download/model-download-service.ts
823
+ //#region src/http/file-data-plane.ts
774
824
  /**
775
- * Unified model download service.
825
+ * A ready-made data-plane request handler that serves files from a set of roots
826
+ * with HTTP `Range`, for addons whose data-plane is "stream files off disk"
827
+ * (recording playback, scrub-frame stores, exported clips).
776
828
  *
777
- * Handles downloading model files and extra files (labels, dicts) from a
778
- * catalog of ModelCatalogEntry items. Supports single-file models and
779
- * directory bundles (e.g., .mlpackage for CoreML).
829
+ * This is the addon-side handler the addon hands to `ctx.dataPlane.serve({ handler })`.
830
+ * It receives the REAL Node `req`/`res`, resolves the request path against the
831
+ * addon's roots (traversal-guarded), and streams the file. NO token and NO CORS:
832
+ * the hub already authenticated the caller and the data plane is same-origin
833
+ * through the hub's port. Reuses the shared Range/content-type/traversal helpers
834
+ * so there is one implementation across the standalone file-server and this.
780
835
  *
781
- * Addons use this via `context.models.ensure(modelId, format)`.
836
+ * It also owns its own CONTENT-CODING. The hub bridge is `{ compress: false }`
837
+ * because the global compressor emits an empty brotli stream over the forked-
838
+ * addon route bridge, so nothing above this layer will ever compress an addon
839
+ * body — see `asset-compression.ts` for the incident and the contract.
782
840
  */
783
- var ModelDownloadService = class {
784
- modelsDir;
785
- onProgress;
786
- catalog;
787
- constructor(modelsDir, catalog, onProgress) {
788
- this.modelsDir = modelsDir;
789
- this.onProgress = onProgress;
790
- const map = /* @__PURE__ */ new Map();
791
- for (const entry of catalog) map.set(entry.id, entry);
792
- this.catalog = map;
793
- }
794
- /**
795
- * Ensure a model (and its extra files) is downloaded.
796
- * Returns the local filesystem path to the model file/directory.
797
- */
798
- async ensure(modelId, format) {
799
- const entry = this.catalog.get(modelId);
800
- if (!entry) throw new Error(`ModelDownloadService: unknown model "${modelId}"`);
801
- const selectedFormat = format ?? this.pickDefaultFormat(entry);
802
- const formatEntry = entry.formats[selectedFormat];
803
- if (!formatEntry) throw new Error(`ModelDownloadService: model "${modelId}" has no ${selectedFormat} format`);
804
- await this.ensureExtraFiles(modelId);
805
- const modelPath = this.modelFilePath(entry, selectedFormat);
806
- const siblings = siblingFilesFor(formatEntry);
807
- if (node_fs.existsSync(modelPath)) {
808
- if (formatEntry.isDirectory) if (!node_fs.existsSync(node_path.join(modelPath, "Manifest.json"))) node_fs.rmSync(modelPath, {
809
- recursive: true,
810
- force: true
811
- });
812
- else return modelPath;
813
- else if (siblings.every((f) => isNonEmptyFile(node_path.join(this.modelsDir, f)))) return modelPath;
841
+ /** Build a `(req, res)` handler that serves `getRoots()` files with Range. */
842
+ function createFileDataPlaneHandler(opts) {
843
+ return async (req, res) => {
844
+ if (req.method !== "GET" && req.method !== "HEAD") {
845
+ res.writeHead(405).end();
846
+ return;
814
847
  }
815
- node_fs.mkdirSync(this.modelsDir, { recursive: true });
816
- if (formatEntry.isDirectory) await this.downloadDirectory(formatEntry.url, modelPath, formatEntry.files, modelId);
817
- else {
818
- await downloadFile(formatEntry.url, modelPath, this.onProgress ? (downloaded, total) => this.onProgress(modelId, downloaded, total) : void 0);
819
- for (const sibling of siblings) await downloadFile(siblingUrl(formatEntry.url, sibling), node_path.join(this.modelsDir, sibling));
848
+ const urlPath = (req.url ?? "/").split("?")[0] ?? "/";
849
+ let rel;
850
+ try {
851
+ rel = decodeURIComponent(urlPath.replace(/^\/+/, ""));
852
+ } catch {
853
+ res.writeHead(400).end();
854
+ return;
820
855
  }
821
- return modelPath;
822
- }
823
- /**
824
- * Ensure extra files for a model are downloaded.
825
- * Returns the local paths of all extra files.
826
- */
827
- async ensureExtraFiles(modelId) {
828
- const entry = this.catalog.get(modelId);
829
- if (!entry) throw new Error(`ModelDownloadService: unknown model "${modelId}"`);
830
- const extras = entry.extraFiles;
831
- if (!extras || extras.length === 0) return [];
832
- const paths = [];
833
- for (const extra of extras) {
834
- const destPath = node_path.join(this.modelsDir, extra.filename);
835
- await downloadFile(extra.url, destPath);
836
- paths.push(destPath);
856
+ if (rel.length === 0) {
857
+ res.writeHead(404).end();
858
+ return;
837
859
  }
838
- return paths;
839
- }
840
- /** Absolute path to the shared models directory. */
841
- getModelsDir() {
842
- return this.modelsDir;
843
- }
844
- /** Check if a model file is already present on disk. */
845
- isDownloaded(modelId, format) {
846
- const entry = this.catalog.get(modelId);
847
- if (!entry) return false;
848
- const selectedFormat = format ?? this.pickDefaultFormat(entry);
849
- const formatEntry = entry.formats[selectedFormat];
850
- if (!formatEntry) return false;
851
- const modelPath = this.modelFilePath(entry, selectedFormat);
852
- if (!node_fs.existsSync(modelPath)) return false;
853
- if (formatEntry.isDirectory) return node_fs.existsSync(node_path.join(modelPath, "Manifest.json"));
854
- if (node_fs.statSync(modelPath).size <= 0) return false;
855
- return siblingFilesFor(formatEntry).every((f) => isNonEmptyFile(node_path.join(this.modelsDir, f)));
856
- }
857
- /** Get the catalog entry for a model by ID. */
858
- getEntry(modelId) {
859
- return this.catalog.get(modelId);
860
- }
861
- pickDefaultFormat(entry) {
862
- for (const fmt of [
863
- "onnx",
864
- "coreml",
865
- "openvino",
866
- "tflite",
867
- "pt"
868
- ]) if (entry.formats[fmt]) return fmt;
869
- const first = Object.keys(entry.formats)[0];
870
- if (first) return first;
871
- throw new Error(`ModelDownloadService: model "${entry.id}" has no formats`);
872
- }
873
- modelFilePath(entry, format) {
874
- const formatEntry = entry.formats[format];
875
- if (!formatEntry) throw new Error(`Model ${entry.id} has no ${format} format`);
876
- const urlParts = formatEntry.url.split("/");
877
- const filename = urlParts[urlParts.length - 1] ?? `${entry.id}.${format}`;
878
- return node_path.join(this.modelsDir, filename);
879
- }
880
- /**
881
- * Download a directory bundle (e.g., .mlpackage) from HuggingFace.
882
- * ATOMIC: downloads to temp dir, renames only on complete success.
883
- */
884
- async downloadDirectory(url, destDir, knownFiles, _modelId) {
885
- const match = url.match(/huggingface\.co\/([^/]+\/[^/]+)\/resolve\/main\/(.+)/);
886
- if (!match) throw new Error(`Cannot parse HuggingFace URL: ${url}`);
887
- const [, repo, dirPath] = match;
888
- let filesToDownload;
889
- if (knownFiles && knownFiles.length > 0) filesToDownload = knownFiles.map((f) => ({
890
- relativePath: f,
891
- fileUrl: `https://huggingface.co/${repo}/resolve/main/${dirPath}/${f}`
892
- }));
893
- else {
894
- const hfFiles = await this.listHfFiles(repo, dirPath);
895
- if (hfFiles.length === 0) throw new Error(`No files found in HuggingFace directory: ${dirPath}`);
896
- filesToDownload = hfFiles.map((f) => ({
897
- relativePath: f.path.substring(dirPath.length + 1),
898
- fileUrl: `https://huggingface.co/${repo}/resolve/main/${f.path}`
899
- }));
860
+ const resolved = resolveFilePath(opts.getRoots(), rel);
861
+ if ("error" in resolved) {
862
+ res.writeHead(403).end();
863
+ return;
900
864
  }
901
- const tmpDir = destDir + ".downloading";
902
- node_fs.rmSync(tmpDir, {
903
- recursive: true,
904
- force: true
905
- });
906
- node_fs.mkdirSync(tmpDir, { recursive: true });
907
- try {
908
- for (const file of filesToDownload) {
909
- const destPath = node_path.join(tmpDir, file.relativePath);
910
- node_fs.mkdirSync(node_path.dirname(destPath), { recursive: true });
911
- await downloadFile(file.fileUrl, destPath);
865
+ let absPath = null;
866
+ let size = 0;
867
+ let mtimeMs = 0;
868
+ for (const candidate of resolved.candidates) try {
869
+ const st = await node_fs.promises.stat(candidate);
870
+ if (st.isFile()) {
871
+ absPath = candidate;
872
+ size = st.size;
873
+ mtimeMs = st.mtimeMs;
874
+ break;
912
875
  }
913
- node_fs.rmSync(destDir, {
914
- recursive: true,
915
- force: true
916
- });
917
- node_fs.renameSync(tmpDir, destDir);
918
- } catch (err) {
919
- node_fs.rmSync(tmpDir, {
920
- recursive: true,
921
- force: true
876
+ } catch {}
877
+ if (absPath === null) {
878
+ res.writeHead(404).end();
879
+ return;
880
+ }
881
+ const contentType = contentTypeFor(absPath, opts.contentTypes);
882
+ const compressible = isCompressibleContentType(contentType);
883
+ const baseHeaders = {
884
+ "content-type": contentType,
885
+ "accept-ranges": "bytes",
886
+ "cache-control": opts.cacheControl?.(rel) ?? "no-cache",
887
+ ...compressible ? { vary: "accept-encoding" } : {}
888
+ };
889
+ const range = parseRangeHeader(req.headers.range, size);
890
+ if (range) {
891
+ res.writeHead(206, {
892
+ ...baseHeaders,
893
+ "content-range": `bytes ${range.start}-${range.end}/${size}`,
894
+ "content-length": String(range.end - range.start + 1)
922
895
  });
923
- throw err;
896
+ if (req.method === "HEAD") {
897
+ res.end();
898
+ return;
899
+ }
900
+ (0, node_fs.createReadStream)(absPath, {
901
+ start: range.start,
902
+ end: range.end
903
+ }).pipe(res);
904
+ return;
924
905
  }
925
- }
926
- /** Recursively list all files in a HuggingFace directory via API. */
927
- async listHfFiles(repo, dirPath) {
928
- const entries = await fetchJson(`https://huggingface.co/api/models/${repo}/tree/main/${dirPath}`);
929
- const files = [];
930
- for (const entry of entries) if (entry.type === "file") files.push({
931
- path: entry.path,
932
- size: entry.size ?? entry.lfs?.size ?? 0
906
+ const encoding = compressible ? negotiateContentEncoding(req.headers["accept-encoding"]) : null;
907
+ if (encoding !== null && size >= 1024) {
908
+ const filePath = absPath;
909
+ const body = await getCompressedAsset({
910
+ path: filePath,
911
+ mtimeMs,
912
+ size,
913
+ encoding
914
+ }, () => node_fs.promises.readFile(filePath));
915
+ if (body !== null) {
916
+ res.writeHead(200, {
917
+ ...baseHeaders,
918
+ "content-encoding": encoding,
919
+ "accept-ranges": "none",
920
+ "content-length": String(body.byteLength)
921
+ });
922
+ if (req.method === "HEAD") {
923
+ res.end();
924
+ return;
925
+ }
926
+ res.end(body);
927
+ return;
928
+ }
929
+ }
930
+ res.writeHead(200, {
931
+ ...baseHeaders,
932
+ "content-length": String(size)
933
933
  });
934
- else if (entry.type === "directory") {
935
- const subFiles = await this.listHfFiles(repo, entry.path);
936
- files.push(...subFiles);
934
+ if (req.method === "HEAD") {
935
+ res.end();
936
+ return;
937
937
  }
938
- return files;
939
- }
940
- };
938
+ (0, node_fs.createReadStream)(absPath).pipe(res);
939
+ };
940
+ }
941
941
  //#endregion
942
942
  Object.defineProperty(exports, "ModelDownloadService", {
943
943
  enumerable: true,