@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.
- package/assets/icon-latin.LICENSE.txt +92 -0
- package/assets/icon-latin.ttf +0 -0
- package/dist/adapter-D0UnYBuN.d.ts +64 -0
- package/dist/adapter.d.ts +2 -56
- package/dist/adapter.js +107 -6
- package/dist/agent-compile.d.ts +38 -0
- package/dist/agent-compile.js +145 -0
- package/dist/agent-discover.d.ts +4 -4
- package/dist/agent-discover.js +30 -44
- package/dist/agent-durable.d.ts +94 -19
- package/dist/agent-durable.js +647 -32
- package/dist/agent-native.d.ts +44 -13
- package/dist/agent-native.js +264 -27
- package/dist/app.d.ts +5 -5
- package/dist/app.js +42 -10
- package/dist/assets.d.ts +23 -0
- package/dist/assets.js +123 -0
- package/dist/blob.d.ts +4 -5
- package/dist/build.d.ts +8 -30
- package/dist/build.js +143 -298
- package/dist/client-bundle.d.ts +8 -9
- package/dist/client-bundle.js +1 -1
- package/dist/config-freeze-r5p-vTWv.d.ts +17 -0
- package/dist/config-freeze.d.ts +2 -0
- package/dist/config-freeze.js +54 -0
- package/dist/config-loader.d.ts +3 -4
- package/dist/connection-auth.d.ts +38 -0
- package/dist/connection-auth.js +31 -0
- package/dist/content-freeze-CyiW2fRK.d.ts +4 -0
- package/dist/content-freeze.d.ts +2 -0
- package/dist/content-freeze.js +83 -0
- package/dist/content.d.ts +22 -9
- package/dist/content.js +43 -3
- package/dist/core-version.d.ts +4 -0
- package/dist/core-version.js +12 -0
- package/dist/css-modules-ambient.d.ts +2 -1
- package/dist/css-modules.d.ts +10 -11
- package/dist/css.d.ts +9 -10
- package/dist/d1-migrate.d.ts +6 -7
- package/dist/d1-migrate.js +3 -2
- package/dist/db.d.ts +2 -2
- package/dist/deploy.d.ts +3 -4
- package/dist/deploy.js +3 -2
- package/dist/dev-reload.d.ts +3 -4
- package/dist/dev.d.ts +8 -5
- package/dist/dev.js +40 -9
- package/dist/favicon.d.ts +37 -0
- package/dist/favicon.js +248 -0
- package/dist/host.d.ts +6 -5
- package/dist/host.js +2 -1
- package/dist/icon-letter.d.ts +3 -0
- package/dist/icon-letter.js +8 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +9 -3
- package/dist/instrumentation.d.ts +2 -3
- package/dist/instrumentation.js +2 -1
- package/dist/island-registry.d.ts +6 -7
- package/dist/isolate-local.d.ts +5 -0
- package/dist/isolate-local.js +12 -0
- package/dist/kv.d.ts +3 -4
- package/dist/llms-links.d.ts +9 -0
- package/dist/llms-links.js +65 -0
- package/dist/manifest-CwTT1zVs.d.ts +11 -0
- package/dist/manifest.d.ts +2 -0
- package/dist/manifest.js +113 -0
- package/dist/negotiate.d.ts +4 -3
- package/dist/negotiate.js +24 -14
- package/dist/{pipeline-CCp9idQ4.d.ts → pipeline-BHUjBO0G.d.ts} +13 -1
- package/dist/pipeline.d.ts +2 -2
- package/dist/pipeline.js +286 -35
- package/dist/resources.d.ts +5 -6
- package/dist/route-rank.d.ts +6 -7
- package/dist/route-scan-BMbBzPIB.d.ts +20 -0
- package/dist/route-scan.d.ts +2 -0
- package/dist/route-scan.js +100 -0
- package/dist/router.d.ts +12 -13
- package/dist/router.js +51 -38
- package/dist/rsc-build.d.ts +6 -7
- package/dist/rsc-bundle.d.ts +6 -7
- package/dist/rsc-manifest.d.ts +5 -6
- package/dist/rsc-runtime/flight-render-routes.d.ts +3 -4
- package/dist/rsc-runtime/flight-render.d.ts +2 -3
- package/dist/rsc-runtime/flight-to-html.d.ts +2 -3
- package/dist/rsc-runtime/flight-to-html.js +3 -2
- package/dist/rsc-runtime/rsc-worker.js +11 -8
- package/dist/rsc-runtime/rsdw-shims.d.ts +2 -1
- package/dist/segment.d.ts +4 -5
- package/dist/sqlite-driver.d.ts +11 -11
- package/dist/sqlite-driver.js +17 -4
- package/dist/static-files.d.ts +4 -5
- package/dist/tsconfig-jsx.d.ts +4 -4
- package/dist/tsconfig-jsx.js +1 -1
- package/dist/turn-events.d.ts +4 -0
- package/dist/turn-events.js +46 -0
- package/dist/web-manifest.d.ts +5 -0
- package/dist/web-manifest.js +42 -0
- package/dist/worker-2qbQjj2c.d.ts +64 -0
- package/dist/worker.d.ts +2 -57
- package/dist/worker.js +69 -27
- package/package.json +28 -9
- package/src/adapter.ts +149 -4
- package/src/agent-compile.ts +271 -0
- package/src/agent-discover.ts +77 -63
- package/src/agent-durable.ts +998 -30
- package/src/agent-native.ts +370 -35
- package/src/app.ts +75 -6
- package/src/assets.ts +200 -0
- package/src/build.ts +241 -449
- package/src/config-freeze.ts +109 -0
- package/src/connection-auth.ts +101 -0
- package/src/content-freeze.ts +151 -0
- package/src/content.ts +55 -4
- package/src/core-version.ts +34 -0
- package/src/dev.ts +41 -8
- package/src/favicon.ts +325 -0
- package/src/host.ts +4 -3
- package/src/icon-letter.ts +14 -0
- package/src/index.ts +22 -0
- package/src/isolate-local.ts +33 -0
- package/src/llms-links.ts +96 -0
- package/src/manifest.ts +130 -0
- package/src/negotiate.ts +39 -15
- package/src/pipeline.ts +494 -44
- package/src/route-scan.ts +161 -0
- package/src/router.ts +66 -54
- package/src/rsc-runtime/rsc-worker.tsx +5 -4
- package/src/sqlite-driver.ts +36 -6
- package/src/tsconfig-jsx.ts +2 -2
- package/src/turn-events.ts +43 -0
- package/src/web-manifest.ts +38 -0
- 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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
+
}
|