@junejs/server 0.1.3 → 0.2.1

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 (131) hide show
  1. package/assets/icon-latin.LICENSE.txt +92 -0
  2. package/assets/icon-latin.ttf +0 -0
  3. package/dist/adapter-D0UnYBuN.d.ts +64 -0
  4. package/dist/adapter.d.ts +2 -56
  5. package/dist/adapter.js +107 -6
  6. package/dist/agent-compile.d.ts +38 -0
  7. package/dist/agent-compile.js +145 -0
  8. package/dist/agent-discover.d.ts +4 -4
  9. package/dist/agent-discover.js +30 -44
  10. package/dist/agent-durable.d.ts +94 -19
  11. package/dist/agent-durable.js +647 -32
  12. package/dist/agent-native.d.ts +44 -13
  13. package/dist/agent-native.js +264 -27
  14. package/dist/app.d.ts +5 -5
  15. package/dist/app.js +42 -10
  16. package/dist/assets.d.ts +23 -0
  17. package/dist/assets.js +123 -0
  18. package/dist/blob.d.ts +4 -5
  19. package/dist/build.d.ts +8 -30
  20. package/dist/build.js +143 -298
  21. package/dist/client-bundle.d.ts +8 -9
  22. package/dist/client-bundle.js +1 -1
  23. package/dist/config-freeze-r5p-vTWv.d.ts +17 -0
  24. package/dist/config-freeze.d.ts +2 -0
  25. package/dist/config-freeze.js +54 -0
  26. package/dist/config-loader.d.ts +3 -4
  27. package/dist/connection-auth.d.ts +38 -0
  28. package/dist/connection-auth.js +31 -0
  29. package/dist/content-freeze-CyiW2fRK.d.ts +4 -0
  30. package/dist/content-freeze.d.ts +2 -0
  31. package/dist/content-freeze.js +83 -0
  32. package/dist/content.d.ts +22 -9
  33. package/dist/content.js +43 -3
  34. package/dist/core-version.d.ts +4 -0
  35. package/dist/core-version.js +12 -0
  36. package/dist/css-modules-ambient.d.ts +2 -1
  37. package/dist/css-modules.d.ts +10 -11
  38. package/dist/css.d.ts +9 -10
  39. package/dist/d1-migrate.d.ts +6 -7
  40. package/dist/d1-migrate.js +3 -2
  41. package/dist/db.d.ts +2 -2
  42. package/dist/deploy.d.ts +3 -4
  43. package/dist/deploy.js +3 -2
  44. package/dist/dev-reload.d.ts +3 -4
  45. package/dist/dev.d.ts +8 -5
  46. package/dist/dev.js +40 -9
  47. package/dist/favicon.d.ts +37 -0
  48. package/dist/favicon.js +248 -0
  49. package/dist/host.d.ts +6 -5
  50. package/dist/host.js +2 -1
  51. package/dist/icon-letter.d.ts +3 -0
  52. package/dist/icon-letter.js +8 -0
  53. package/dist/index.d.ts +11 -5
  54. package/dist/index.js +9 -3
  55. package/dist/instrumentation.d.ts +2 -3
  56. package/dist/instrumentation.js +2 -1
  57. package/dist/island-registry.d.ts +6 -7
  58. package/dist/isolate-local.d.ts +5 -0
  59. package/dist/isolate-local.js +12 -0
  60. package/dist/kv.d.ts +3 -4
  61. package/dist/llms-links.d.ts +9 -0
  62. package/dist/llms-links.js +65 -0
  63. package/dist/manifest-CwTT1zVs.d.ts +11 -0
  64. package/dist/manifest.d.ts +2 -0
  65. package/dist/manifest.js +113 -0
  66. package/dist/negotiate.d.ts +4 -3
  67. package/dist/negotiate.js +24 -14
  68. package/dist/{pipeline-CCp9idQ4.d.ts → pipeline-BHUjBO0G.d.ts} +13 -1
  69. package/dist/pipeline.d.ts +2 -2
  70. package/dist/pipeline.js +286 -35
  71. package/dist/resources.d.ts +5 -6
  72. package/dist/route-rank.d.ts +6 -7
  73. package/dist/route-scan-BMbBzPIB.d.ts +20 -0
  74. package/dist/route-scan.d.ts +2 -0
  75. package/dist/route-scan.js +100 -0
  76. package/dist/router.d.ts +12 -13
  77. package/dist/router.js +51 -38
  78. package/dist/rsc-build.d.ts +6 -7
  79. package/dist/rsc-bundle.d.ts +6 -7
  80. package/dist/rsc-manifest.d.ts +5 -6
  81. package/dist/rsc-runtime/flight-render-routes.d.ts +3 -4
  82. package/dist/rsc-runtime/flight-render.d.ts +2 -3
  83. package/dist/rsc-runtime/flight-to-html.d.ts +2 -3
  84. package/dist/rsc-runtime/flight-to-html.js +3 -2
  85. package/dist/rsc-runtime/rsc-worker.js +11 -8
  86. package/dist/rsc-runtime/rsdw-shims.d.ts +2 -1
  87. package/dist/segment.d.ts +4 -5
  88. package/dist/sqlite-driver.d.ts +11 -11
  89. package/dist/sqlite-driver.js +17 -4
  90. package/dist/static-files.d.ts +4 -5
  91. package/dist/tsconfig-jsx.d.ts +4 -4
  92. package/dist/tsconfig-jsx.js +1 -1
  93. package/dist/turn-events.d.ts +4 -0
  94. package/dist/turn-events.js +46 -0
  95. package/dist/web-manifest.d.ts +5 -0
  96. package/dist/web-manifest.js +42 -0
  97. package/dist/worker-2qbQjj2c.d.ts +64 -0
  98. package/dist/worker.d.ts +2 -57
  99. package/dist/worker.js +69 -27
  100. package/package.json +28 -9
  101. package/src/adapter.ts +149 -4
  102. package/src/agent-compile.ts +271 -0
  103. package/src/agent-discover.ts +77 -63
  104. package/src/agent-durable.ts +998 -30
  105. package/src/agent-native.ts +370 -35
  106. package/src/app.ts +75 -6
  107. package/src/assets.ts +200 -0
  108. package/src/build.ts +241 -449
  109. package/src/config-freeze.ts +109 -0
  110. package/src/connection-auth.ts +101 -0
  111. package/src/content-freeze.ts +151 -0
  112. package/src/content.ts +55 -4
  113. package/src/core-version.ts +34 -0
  114. package/src/dev.ts +41 -8
  115. package/src/favicon.ts +325 -0
  116. package/src/host.ts +4 -3
  117. package/src/icon-letter.ts +14 -0
  118. package/src/index.ts +22 -0
  119. package/src/isolate-local.ts +33 -0
  120. package/src/llms-links.ts +96 -0
  121. package/src/manifest.ts +130 -0
  122. package/src/negotiate.ts +39 -15
  123. package/src/pipeline.ts +494 -44
  124. package/src/route-scan.ts +161 -0
  125. package/src/router.ts +66 -54
  126. package/src/rsc-runtime/rsc-worker.tsx +5 -4
  127. package/src/sqlite-driver.ts +36 -6
  128. package/src/tsconfig-jsx.ts +2 -2
  129. package/src/turn-events.ts +43 -0
  130. package/src/web-manifest.ts +38 -0
  131. package/src/worker.ts +131 -35
package/src/app.ts CHANGED
@@ -18,21 +18,25 @@ import type { ComponentType } from "react";
18
18
  import { routeFromModule } from "@junejs/core/route";
19
19
  import {
20
20
  resolveAgent,
21
+ deployOrigin,
21
22
  resolveClientRouter,
22
23
  resolveSpeculationRules,
23
24
  type JuneConfig,
24
25
  } from "@junejs/core/config";
25
26
  import type { DocumentConfig } from "@junejs/core/document";
26
27
  import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
28
+ import { blobFileStore, memoryFileStore } from "@junejs/core/agent-files";
29
+ import type { JuneBlob } from "@junejs/core/resources";
27
30
 
28
31
  import { findMiddlewareFile, isResourceFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles, type SegmentMatch } from "./router";
32
+ import { routeProblems, scanRoutes } from "./route-scan";
29
33
  import { createPipeline, type ExtraHandler, type LayoutComponent, type MiddlewareHandler, type Pipeline, type Resolved, type ResourceHandler } from "./pipeline";
30
34
  import { discoverAgent } from "./agent-discover";
31
- import { createAgentRuntime, mountAgent } from "./agent-native";
32
- import { buildSystemPrompt } from "@junejs/core/agent-config";
35
+ import { createAgentRuntime, mountAgent, toAgentDef, type InProcessRuntime } from "./agent-native";
33
36
  import { anthropic } from "@junejs/core/agent-models";
34
37
  import { resolveBoundary } from "./segment";
35
38
  import { memoizeResources } from "./resources";
39
+ import { localBlob } from "./blob";
36
40
  import {
37
41
  findClientEntry,
38
42
  bundleClientSplit,
@@ -43,6 +47,15 @@ import { generateIslandRegistry } from "./island-registry";
43
47
  import { findGlobalCss, globalCssUsesTailwind, processCssCached, STYLES_URL } from "./css";
44
48
  import { buildModuleCss, registerCssModules, MODULE_STYLES_URL, type ModuleMaps } from "./css-modules";
45
49
  import { contentTypeFor, RESERVED_PREFIX, safeRelativePath } from "./static-files";
50
+ import {
51
+ GENERATED_ICONS,
52
+ googleFontFetcher,
53
+ publicFileCheck,
54
+ renderIcons,
55
+ resolveIcons,
56
+ type GeneratedIconFile,
57
+ type GeneratedIcons,
58
+ } from "./favicon";
46
59
 
47
60
  export type CreateAppOptions = {
48
61
  appDir: string;
@@ -56,6 +69,10 @@ export type JuneApp = {
56
69
  warmup(): Promise<void>;
57
70
  routePaths(): Promise<string[]>;
58
71
  earlyHints(): string[];
72
+ // Shut down what the app started (#317): the agent runtime built for an agent/
73
+ // directory — its pending announcement retries, its in-flight turns, its SQLite
74
+ // handle. Resolves once they finish. A no-op when no runtime was built.
75
+ close(): Promise<void>;
59
76
  };
60
77
 
61
78
  // Import a layout file's default export as a layout component (memoized), plus
@@ -133,6 +150,19 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
133
150
  } catch {
134
151
  /* no public/ dir → nothing to serve */
135
152
  }
153
+ // Default PNG icons: linked by the document, rendered once on first request
154
+ // (a failed render is retried next time rather than memoized).
155
+ const iconPlan = resolveIcons(config.site ?? {}, publicFileCheck(publicDir));
156
+ let iconsPromise: Promise<GeneratedIcons> | undefined;
157
+ const getIcons = () =>
158
+ (iconsPromise ??= renderIcons({
159
+ site: config.site ?? {},
160
+ fetchFont: googleFontFetcher(join(dirname(appDir), "node_modules", ".cache", "june", "fonts")),
161
+ warn: (msg) => console.warn(msg),
162
+ }).catch((err) => {
163
+ iconsPromise = undefined;
164
+ throw err;
165
+ }));
136
166
  const agent = resolveAgent(config.agent);
137
167
  const speculation = config.speculation;
138
168
  // app/_client.* present → the dev document loads /client.js and we serve it
@@ -144,6 +174,7 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
144
174
  const cssEntry = findGlobalCss(appDir);
145
175
  const docConfig: DocumentConfig = {
146
176
  site: config.site ?? {},
177
+ deployOrigin: deployOrigin(config),
147
178
  speculationRules: resolveSpeculationRules(speculation ?? undefined),
148
179
  speculationDelivery: speculation ? speculation.delivery ?? "inline" : "inline",
149
180
  viewTransitions: config.viewTransitions ?? true,
@@ -152,6 +183,7 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
152
183
  clientRouter: resolveClientRouter(config.clientRouter),
153
184
  clientScript: clientEntry ? CLIENT_SCRIPT_URL : null,
154
185
  styles: cssEntry ? STYLES_URL : null,
186
+ icons: iconPlan.icons,
155
187
  };
156
188
 
157
189
  // CSS Modules: glob + transform app/**/*.module.css ONCE (memoized) → the maps
@@ -202,6 +234,8 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
202
234
  // pipeline is built lazily on first fetch, memoized after.
203
235
  let pipelinePromise: Promise<Pipeline> | undefined;
204
236
  const getPipeline = (): Promise<Pipeline> => (pipelinePromise ??= buildPipeline());
237
+ // The agent runtime buildPipeline creates, kept so close() can shut it down (#317).
238
+ let agentRuntime: InProcessRuntime | undefined;
205
239
  async function buildPipeline(): Promise<Pipeline> {
206
240
  // Wire CSS Modules BEFORE any route import: the runtime interceptor must be
207
241
  // active so `import "x.module.css"` in a route resolves to the scoped map,
@@ -237,10 +271,18 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
237
271
  // instructions live on the def (single source); the runtime injects them
238
272
  // into the model per turn via withSystem — no need to bake into anthropic().
239
273
  const model = anthropic({ model: def.model });
240
- const rt = await createAgentRuntime(
241
- { [def.name]: { model, tools: def.tools, instructions: buildSystemPrompt(def) } },
242
- { backend },
243
- );
274
+ // toAgentDef: tools, prompt AND the per-surface policies (#149) from the one
275
+ // definition — channelInstructions was previously dropped on this path (#173).
276
+ // Agent files (docs/rfc-agent-files.md) live in the app's blob resource; an app that
277
+ // declares none gets the local default directory. The memory backend keeps them in
278
+ // memory, like everything else it holds.
279
+ let blobOpened: Promise<JuneBlob> | undefined;
280
+ const files =
281
+ backend === "memory"
282
+ ? memoryFileStore()
283
+ : blobFileStore(() => (blobOpened ??= resources().then(async (r) => r.blob ?? (await localBlob().open()))));
284
+ const rt = await createAgentRuntime({ [def.name]: toAgentDef(def, model) }, { backend, files });
285
+ agentRuntime = rt;
244
286
  const mounted = mountAgent(def, rt, { chatPath: agent.runtime.chat.path, channels: agent.runtime.channels });
245
287
  agentSurface = (req) => mounted.surface(req);
246
288
  await mounted.startAll();
@@ -403,9 +445,30 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
403
445
  }
404
446
  }
405
447
  }
448
+ // June's default PNG icons (the app has none of its own): rendered on first
449
+ // request with the same code `june build` writes them with.
450
+ if (iconPlan.generate && (request.method === "GET" || request.method === "HEAD")) {
451
+ const name = new URL(request.url).pathname.slice(1);
452
+ if ((GENERATED_ICONS as readonly string[]).includes(name)) {
453
+ return getIcons().then(
454
+ (icons) =>
455
+ new Response(request.method === "HEAD" ? null : icons[name as GeneratedIconFile], {
456
+ headers: { "content-type": contentTypeFor(name), "cache-control": "public, max-age=0, must-revalidate" },
457
+ }),
458
+ () => toPipeline(),
459
+ );
460
+ }
461
+ }
406
462
  return toPipeline();
407
463
  },
408
464
  async warmup() {
465
+ // What `june build` refuses (same-path files, a segment after an optional
466
+ // or catch-all), dev reports up front — it still serves what it can.
467
+ const problems = routeProblems(
468
+ [await scanRoutes(appDir), hasJuneRoutes ? await scanRoutes(juneRoutesDir) : []],
469
+ dirname(appDir),
470
+ );
471
+ if (problems) console.error(problems);
409
472
  const files = await routeFiles(appDir, { pageConvention: true });
410
473
  if (hasJuneRoutes) files.push(...(await routeFiles(juneRoutesDir, { pageConvention: true })));
411
474
  for (const file of files) {
@@ -416,5 +479,11 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
416
479
  },
417
480
  routePaths,
418
481
  earlyHints: () => config.earlyHints ?? [],
482
+ async close() {
483
+ // A pipeline still being built may be about to create the runtime: let it finish
484
+ // (its failure is the fetch's to report, not close()'s).
485
+ await pipelinePromise?.catch(() => {});
486
+ await agentRuntime?.close();
487
+ },
419
488
  };
420
489
  }
package/src/assets.ts ADDED
@@ -0,0 +1,200 @@
1
+ // assetCache — a loader for the large files an `unbundled` entry leaves out of a build (#383):
2
+ // the PDF engine, a CJK font. Given a directory the app owns (a data volume), it serves each
3
+ // file from there and fetches it the first time, so a single binary doesn't carry 40 MiB it
4
+ // may never use:
5
+ //
6
+ // import { assetCache } from "@junejs/server/assets";
7
+ // const load = assetCache({ dir: "/data/june-assets" });
8
+ // typstWasmRenderer({ load, fonts: [notoSansTC({ load })] });
9
+ //
10
+ // What it guarantees, so it can sit under a production app:
11
+ // • a file is returned only when its size and SHA-256 match the ref — on disk or downloaded;
12
+ // a stale or corrupt file is replaced, never used (the packages check again regardless);
13
+ // • a download stops at the ref's size, so a wrong URL can't fill the disk;
14
+ // • the file lands by rename from a temp file in the same directory, so a crash leaves no
15
+ // half-written file under the real name;
16
+ // • one download per file per cache, however many renders ask at once (share one `load`;
17
+ // two caches over the same dir each download, and the rename keeps that safe);
18
+ // • `offline: true` never touches the network: for hosts without egress, the files are
19
+ // copied in beforehand (`dir/<name>`, e.g. dir/typst.wasm), and a missing one is an error
20
+ // that says so.
21
+ //
22
+ // The request is a fixed URL: no document data leaves. `egress(refs)` says which hosts a
23
+ // call would still contact, for a setup check.
24
+ //
25
+ // AssetRef is the shape @junejs/pdf-typst-wasm defines; it is repeated here, so the server
26
+ // depends on no PDF package.
27
+
28
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
29
+ import { join } from "node:path";
30
+
31
+ export type AssetRef = { name: string; sha256: string; size: number; urls: string[] };
32
+
33
+ export type AssetCacheOptions = {
34
+ // Where the files live: one per ref, at dir/<name>.
35
+ dir: string;
36
+ // Never download: serve what is in `dir`, and fail naming the file to copy in when it's
37
+ // missing or doesn't match.
38
+ offline?: boolean;
39
+ // Where to fetch a ref from, in order (default: ref.urls) — a mirror the app controls.
40
+ urls?: (ref: AssetRef) => string[];
41
+ // How to fetch (default: global fetch).
42
+ fetch?: typeof fetch;
43
+ };
44
+
45
+ export interface AssetCache {
46
+ // The file's bytes, from `dir` or downloaded into it; always matching the ref.
47
+ (ref: AssetRef): Promise<Uint8Array>;
48
+ // Whether `dir` already holds a file matching the ref.
49
+ cached(ref: AssetRef): Promise<boolean>;
50
+ // The hosts a call for these refs would contact: [] when all are cached, or when offline.
51
+ egress(refs: AssetRef[]): Promise<{ host: string; sends: "nothing" }[]>;
52
+ }
53
+
54
+ // A temp file older than this is a crashed download; younger ones may be another process's.
55
+ const STALE_TEMP_MS = 60 * 60 * 1000;
56
+ // Temp files carry this prefix, and no asset may: the sweep removes nothing else.
57
+ const TEMP_PREFIX = ".june-tmp-";
58
+
59
+ export function assetCache(opts: AssetCacheOptions): AssetCache {
60
+ const fetcher = opts.fetch ?? fetch;
61
+ const urlsFor = opts.urls ?? ((ref: AssetRef) => ref.urls);
62
+ const inflight = new Map<string, Promise<Uint8Array>>();
63
+ let swept: Promise<void> | undefined;
64
+
65
+ const pathOf = (ref: AssetRef) => join(opts.dir, safeName(ref.name));
66
+
67
+ // The file at dir/<name> when it matches the ref; undefined when it's missing or doesn't.
68
+ // Sized first, so a large wrong file is never read; a read error other than "missing" (a
69
+ // directory in its place, no permission) is the caller's problem, not a cache miss.
70
+ async function readMatching(ref: AssetRef): Promise<Uint8Array | undefined> {
71
+ const path = pathOf(ref);
72
+ let info: Awaited<ReturnType<typeof stat>>;
73
+ try {
74
+ info = await stat(path);
75
+ } catch (err) {
76
+ if ((err as { code?: string }).code === "ENOENT") return undefined;
77
+ throw err;
78
+ }
79
+ if (!info.isFile()) throw new Error(`${path} is in the way of ${ref.name}: it isn't a file`);
80
+ if (info.size !== ref.size) return undefined;
81
+ const bytes = new Uint8Array(await readFile(path));
82
+ return (await sha256(bytes)) === ref.sha256 ? bytes : undefined;
83
+ }
84
+
85
+ async function get(ref: AssetRef): Promise<Uint8Array> {
86
+ pathOf(ref); // an unsafe name fails here, before any read or fetch
87
+ const hit = await readMatching(ref);
88
+ if (hit) return hit;
89
+ if (opts.offline) {
90
+ const there = await stat(pathOf(ref)).then(() => true, () => false);
91
+ const state = there ? `in ${opts.dir} but doesn't match` : `not in ${opts.dir}`;
92
+ throw new Error(
93
+ `${ref.name} is ${state}, and this cache is offline: ` +
94
+ `copy the file with sha256 ${ref.sha256} (${ref.size} bytes) to ${pathOf(ref)} — it is at ${ref.urls.join(" or ")}`,
95
+ );
96
+ }
97
+ // Housekeeping, best effort: a temp it can't remove must not fail this download or any later.
98
+ await (swept ??= sweepTemps(opts.dir).catch(() => {}));
99
+ const bytes = await download(ref, urlsFor(ref), fetcher);
100
+ await mkdir(opts.dir, { recursive: true });
101
+ const temp = join(opts.dir, `${TEMP_PREFIX}${crypto.randomUUID()}-${safeName(ref.name)}`);
102
+ try {
103
+ await writeFile(temp, bytes);
104
+ await rename(temp, pathOf(ref)); // atomic on one filesystem: never a half-written file
105
+ } catch (err) {
106
+ await rm(temp, { force: true }).catch(() => {}); // don't leave it for the next sweep
107
+ throw err;
108
+ }
109
+ return bytes;
110
+ }
111
+
112
+ // Keyed by the ref's whole identity (name, content, size): only identical refs share a
113
+ // download, so each call gets bytes that match its own ref.
114
+ const cache = ((ref: AssetRef) => {
115
+ const key = `${ref.name}\0${ref.sha256}\0${ref.size}`;
116
+ const running = inflight.get(key);
117
+ if (running) return running;
118
+ const p = get(ref).finally(() => inflight.delete(key));
119
+ inflight.set(key, p);
120
+ return p;
121
+ }) as AssetCache;
122
+ cache.cached = async (ref) => (await readMatching(ref)) !== undefined;
123
+ cache.egress = async (refs) => {
124
+ if (opts.offline) return [];
125
+ const missing = (await Promise.all(refs.map(async (r) => ((await cache.cached(r)) ? [] : urlsFor(r))))).flat();
126
+ return [...new Set(missing.map((u) => new URL(u).host))].map((host) => ({ host, sends: "nothing" as const }));
127
+ };
128
+ return cache;
129
+ }
130
+
131
+ // Each URL in turn; the first whose bytes match wins. The error lists every attempt.
132
+ async function download(ref: AssetRef, urls: string[], fetcher: typeof fetch): Promise<Uint8Array> {
133
+ const failures: string[] = [];
134
+ for (const url of urls) {
135
+ try {
136
+ const res = await fetcher(url);
137
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
138
+ const declared = Number(res.headers.get("content-length") ?? NaN);
139
+ if (declared > ref.size) throw new Error(`${declared} bytes declared, expected ${ref.size}`);
140
+ const bytes = await readCapped(res, ref.size);
141
+ const digest = await sha256(bytes);
142
+ if (bytes.length !== ref.size || digest !== ref.sha256) throw new Error(`got ${bytes.length} bytes with sha256 ${digest}, expected ${ref.size} with ${ref.sha256}`);
143
+ return bytes;
144
+ } catch (err) {
145
+ failures.push(`${url}: ${err instanceof Error ? err.message : String(err)}`);
146
+ }
147
+ }
148
+ throw new Error(`${ref.name} could not be fetched (${failures.join("; ") || "no URLs"}). To run without the network, copy the file into the cache directory and pass offline: true`);
149
+ }
150
+
151
+ // The body, cut off one byte past the expected size: enough to know it's wrong, never more.
152
+ async function readCapped(res: Response, size: number): Promise<Uint8Array> {
153
+ if (!res.body) return new Uint8Array(await res.arrayBuffer());
154
+ const out = new Uint8Array(size + 1);
155
+ let length = 0;
156
+ const reader = res.body.getReader();
157
+ for (;;) {
158
+ const { done, value } = await reader.read();
159
+ if (done) break;
160
+ if (length + value.length >= out.length) {
161
+ out.set(value.subarray(0, out.length - length), length);
162
+ length = out.length;
163
+ await reader.cancel();
164
+ break;
165
+ }
166
+ out.set(value, length);
167
+ length += value.length;
168
+ }
169
+ return out.subarray(0, length);
170
+ }
171
+
172
+ async function sha256(bytes: Uint8Array): Promise<string> {
173
+ const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>);
174
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
175
+ }
176
+
177
+ // A ref's name becomes a file name: one path segment, nothing that climbs out of `dir`.
178
+ function safeName(name: string): string {
179
+ if (!name || name === "." || name === ".." || /[\\/]/.test(name) || name.startsWith(TEMP_PREFIX)) throw new Error(`unsafe asset name: ${JSON.stringify(name)}`);
180
+ return name;
181
+ }
182
+
183
+ // Temp files a crashed download left behind; ones still young may belong to another process.
184
+ async function sweepTemps(dir: string): Promise<void> {
185
+ let names: string[];
186
+ try {
187
+ names = await readdir(dir);
188
+ } catch {
189
+ return;
190
+ }
191
+ const now = Date.now();
192
+ await Promise.all(
193
+ names
194
+ .filter((n) => n.startsWith(TEMP_PREFIX))
195
+ .map(async (n) => {
196
+ const s = await stat(join(dir, n)).catch(() => undefined);
197
+ if (s && now - s.mtimeMs > STALE_TEMP_MS) await rm(join(dir, n), { force: true });
198
+ }),
199
+ );
200
+ }