A cold first visit no longer downloads the shell twice or waits silently on the engine binary: - the service worker precaches content-hashed _astro/ assets through the HTTP cache (mutable paths keep cache: "reload"), so install reuses the bytes the page already fetched; - the build stamps every prerendered page with a fetch preload for the engine binary carrying its uncompressed size, starting the download while the module graph is still arriving; - the runtime streams that URL with byte progress before opening, exposed as getBootProgress/subscribeBootProgress and the useBootProgress Preact hook (pending → downloading → opening → ready/failed); the starter task list names the wait it is actually in; - every build reports the shell weight and its heaviest asset; the notice above 20 MiB is unchanged.
- commit
- 73c6045ce905ca28a5fecb377ce15f7b1db38362
- parent
- 6b9dd9654eda0e8fa695299c044c2d6e7203a974
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | apps/ | 20 | ++++- |
| M | docs/ | 26 | ++++- |
| M | package/ | 2 | + |
| M | package/ | 31 | ++++++- |
| M | package/ | 4 | +- |
| A | package/ | 39 | ++++++++ |
| A | package/ | 145 | ++++++++++++++++++++++++++++++ |
| A | package/ | 93 | +++++++++++++++++++ |
| M | package/ | 6 | + |
| M | package/ | 9 | ++ |
| M | package/ | 20 | +++- |
| M | package/ | 30 | +++++- |
| M | package/ | 2 | +- |
| M | package/ | 43 | +++++++++ |
| M | package/ | 40 | ++++++++ |
@@ -1,8 +1,23 @@
11 import { useState } from "preact/hooks";22 import { settleUiMutation } from "@nzip/lofi";3-import { usePendingWrites, useSyncStatus } from "@nzip/lofi/preact";3+import {4+ type BootProgress,5+ useBootProgress,6+ usePendingWrites,7+ useSyncStatus,8+} from "@nzip/lofi/preact";49 import { type Task, useTaskNotice, useTasks } from "./use-tasks.ts";510 11+// A cold first visit waits on the engine download, not on storage; name the12+// wait it is actually in, with byte progress while the download runs.13+function loadingLabel(boot: BootProgress): string {14+ if (boot.phase !== "downloading") return "Opening persistent storage…";15+ const mb = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1);16+ return boot.totalBytes === null17+ ? `Downloading the app · ${mb(boot.loadedBytes)} MB…`18+ : `Downloading the app · ${mb(boot.loadedBytes)} of ${mb(boot.totalBytes)} MB…`;19+}20+621 /**722 * Author-owned starter UI. One island over one table: add a row through a823 * verb with effects, list rows with per-row sync badges, and toggle a@@ -13,6 +28,7 @@ export default function TaskList() {
1328 const { status, error, durability, tasks, failureKind, create, setCompleted } = useTasks();1429 const notice = useTaskNotice();1530 const pending = usePendingWrites();31+ const boot = useBootProgress();1632 const [text, setText] = useState("");1733 1834 return (@@ -44,7 +60,7 @@ export default function TaskList() {
4460 </div>4561 </form>4662 <p class="state" role="status">47- {status === "loading" && "Opening persistent storage…"}63+ {status === "loading" && loadingLabel(boot)}4864 {status === "error" &&4965 `${failureKind === "read" ? "Read failed" : "Write failed"}: ${error}`}5066 {status === "ready" && `${tasks.length} item(s) · ${@@ -166,12 +166,14 @@ not change either way.
166166 167167 ### Offline cache policy168168 169-The build's precache manifest contains required shell resources only. Shell assets are fetched170-bypassing the HTTP cache (matching the manifest's own `no-store` fetch), so a new revision can never171-be populated from stale HTTP-cached responses. If any listed response cannot be fetched,172-service-worker installation fails and reports a precache error rather than exposing a worker that173-cannot cold-start the application. Product-specific optional resources do not belong in that174-manifest.169+The build's precache manifest contains required shell resources only. Mutable shell assets (HTML,170+manifests) are fetched bypassing the HTTP cache (matching the manifest's own `no-store` fetch), so a171+new revision can never be populated from stale HTTP-cached responses. Content-hashed build assets172+under `_astro/` cannot go stale under their names, so precaching admits the HTTP cache for them: a173+first visit reuses the bytes the page's own module and engine fetches already downloaded instead of174+downloading the shell a second time. If any listed response cannot be fetched, service-worker175+installation fails and reports a precache error rather than exposing a worker that cannot cold-start176+the application. Product-specific optional resources do not belong in that manifest.175177 176178 Cache names carry the registration scope, and lookups consult only the worker's own caches: apps177179 served from different base paths on one origin never read, shadow, or delete each other's caches,@@ -189,6 +191,18 @@ Navigation preload remains disabled: generated routes and their assets are preca
189191 parallel network request before the normal cache-first lookup would spend bandwidth on the expected190192 offline-ready path. Jazz sync, OPFS storage, background sync, and push remain outside the worker.191193 194+### First-visit download195+196+A repeat visit opens from the precached shell without touching the network. A cold first visit is197+dominated by the Jazz engine binary, so the build stamps every prerendered page with a fetch preload198+for it: the download starts while the module graph is still arriving, and the tag carries the199+binary's uncompressed size. Before the runtime opens it streams that same URL with byte progress,200+and the engine's own fetch then reads the primed HTTP cache. Applications can name the wait with the201+`useBootProgress` hook in `@nzip/lofi/preact` (phases `pending`, `downloading`, `opening`, `ready`,202+`failed`, with `loadedBytes` and `totalBytes` while downloading); the starter task list shows the203+pattern. Serving `_astro/` assets with compression and long-lived caching headers keeps both the204+first visit and the warm-up cheap.205+192206 ### Install and update lifecycle193207 194208 The optional `PwaActions` UI uses a browser prompt only while `beforeinstallprompt` is actually@@ -25,6 +25,7 @@ export type LofiAstroOptions = {
2525 export const runtimeFiles = [2626 "app.ts",2727 "auth.ts",28+ "boot-progress.ts",2829 "boot.ts",2930 "config.ts",3031 "data-sink.ts",@@ -89,6 +90,7 @@ export const preactFiles = [
8990 "PwaActions.tsx",9091 "RuntimeRecovery.tsx",9192 "TicketEnrollForm.tsx",93+ "use-boot-progress.ts",9294 "use-device-capabilities.ts",9395 "use-schema-compat.ts",9496 "write-hooks.ts",@@ -14,6 +14,10 @@ import { prepareLofiAstroConfig } from "../astro/mod.ts";
1414 import { loadEnvironment, serverEnvironmentNames } from "../tooling/environment.ts";1515 import {1616 duplicateBuildAssets,17+ engineWasmAssets,18+ engineWasmPreloadTag,19+ heaviestShellAsset,20+ injectHeadTags,1721 precacheUrls,1822 scanSecrets,1923 shellWeightBytes,@@ -99,6 +103,26 @@ const productionManifest = JSON.parse(await Deno.readTextFile("dist/manifest.web
99103 // One walk serves the precache manifest, the shell checks, and the route100104 // count below.101105 const distFiles = await walkFiles("dist", { includeDist: true });106+107+// The engine binary dominates a cold first visit. Every prerendered page108+// preloads it so the download overlaps the module graph instead of queueing109+// behind it, and the tag carries the byte size the runtime's boot-progress110+// surface reports while the download runs.111+const engineTags = await Promise.all(112+ engineWasmAssets(distFiles).map(async (asset) =>113+ engineWasmPreloadTag(114+ asset,115+ environment.LOFI_BASE_PATH || "/",116+ (await Deno.stat(join("dist", asset))).size,117+ )118+ ),119+);120+for (const path of distFiles.filter((file) => extname(file) === ".html")) {121+ const html = await Deno.readTextFile(join("dist", path));122+ const injected = injectHeadTags(html, engineTags);123+ if (injected !== html) await Deno.writeTextFile(join("dist", path), injected);124+}125+102126 const screenshotPaths = screenshotAssetPaths(productionManifest, environment.LOFI_BASE_PATH);103127 const shellPaths = precachePaths(distFiles, screenshotPaths);104128 @@ -114,6 +138,7 @@ if (duplicateAssets.length > 0) {
114138 }115139 116140 const shellBytes = await shellWeightBytes(shellPaths);141+const heaviestAsset = await heaviestShellAsset(shellPaths);117142 if (shellBytes > shellWeightNoticeBytes) {118143 console.warn(119144 `warning: the app shell weighs ${@@ -151,5 +176,9 @@ const routes = distFiles.filter((path) => extname(path) === ".html").length;
151176 console.log(152177 `lofi build: ${join(Deno.cwd(), "dist")} (${routes} routes, ${sourceHash}, ${153178 megabytes(shellBytes)154- } MB shell, secret scan passed)`,179+ } MB shell${180+ heaviestAsset181+ ? `, heaviest asset ${heaviestAsset.path} at ${megabytes(heaviestAsset.bytes)} MB`182+ : ""183+ }, secret scan passed)`,155184 );@@ -2,7 +2,8 @@
22 * Optional Preact bindings for the lofi runtime: live typed queries33 * ({ useLiveQuery}), table mutations ({ useTableMutations}), the44 * per-write sync lifecycle ({ useWrite}, { usePendingWrites},5- * { useSyncStatus}), and the schema-compatibility gate5+ * { useSyncStatus}), first-load progress ({ useBootProgress}), and6+ * the schema-compatibility gate67 * ({ useSchemaCompat}), plus package-owned example components78 * ({ DeviceStatus}, { PwaActions}, { RuntimeRecovery},89 * { TicketEnrollForm}) that application layouts may compose or replace@@ -42,6 +43,7 @@ export {
4243 type SchemaCompatState,4344 useSchemaCompat,4445 } from "./use-schema-compat.ts";46+export { type BootProgress, type BootProgressPhase, useBootProgress } from "./use-boot-progress.ts";4547 export {4648 type DeviceCapabilitiesHook,4749 type DeviceCapabilityReport,@@ -0,0 +1,39 @@
1+/**2+ * Preact binding for first-load progress: the phases between a painted shell3+ * and an open runtime, for status UI that names the engine download a cold4+ * first visit actually waits on.5+ *6+ * @module7+ */8+9+import { useEffect, useState } from "preact/hooks";10+import {11+ type BootProgress,12+ getBootProgress,13+ subscribeBootProgress,14+} from "../runtime/boot-progress.ts";15+16+export type { BootProgress, BootProgressPhase } from "../runtime/boot-progress.ts";17+18+/**19+ * Subscribes a Preact component to first-load progress, so a loading state can20+ * distinguish the engine download (with byte progress on a cold first visit)21+ * from opening persistent storage.22+ *23+ * @example24+ * ```tsx25+ * import { useBootProgress } from "@nzip/lofi/preact";26+ *27+ * const boot = useBootProgress();28+ * if (boot.phase === "downloading" && boot.totalBytes) {29+ * return <p>Downloading the app · {progressLabel(boot)}</p>;30+ * }31+ * ```32+ *33+ * @returns The current first-load progress, kept live via subscription.34+ */35+export function useBootProgress(): BootProgress {36+ const [progress, setProgress] = useState<BootProgress>(getBootProgress());37+ useEffect(() => subscribeBootProgress(setProgress), []);38+ return progress;39+}@@ -0,0 +1,145 @@
1+/**2+ * Package-owned first-load progress: the phases between a painted shell and an3+ * open runtime, including the engine download that dominates a cold first4+ * visit.5+ *6+ * The build stamps every prerendered page with a preload link for the engine7+ * binary carrying its uncompressed byte size (`data-lofi-engine`). Before the8+ * runtime opens, {@link BootProgressTracker.warmEngineDownload} streams that9+ * URL through the same preload the browser already started, reporting byte10+ * progress as it goes; the engine's own fetch then finds the bytes in the HTTP11+ * cache. On a repeat visit the service worker answers the warm-up from the12+ * precached shell, so the download phase passes in one step. The warm-up only13+ * informs — any failure leaves the engine to resolve its binary exactly as it14+ * would without one.15+ *16+ * @module17+ */18+19+/**20+ * Phases between a painted shell and an open runtime.21+ *22+ * - `pending` — the runtime has not been requested yet.23+ * - `downloading` — the engine binary is downloading; on a cold first visit24+ * this is the long phase, with byte progress in {@link BootProgress}.25+ * - `opening` — the engine is instantiating and persistent storage is opening.26+ * - `ready` — the runtime is open; live queries answer from local data.27+ * - `failed` — the runtime could not open; the cause is in runtime28+ * diagnostics (`startupFailure`).29+ */30+export type BootProgressPhase = "pending" | "downloading" | "opening" | "ready" | "failed";31+32+/** Live first-load progress for application status UI. */33+export type BootProgress = {34+ phase: BootProgressPhase;35+ /** Engine bytes received so far, uncompressed. */36+ loadedBytes: number;37+ /** The engine binary's uncompressed size when the shell declares it, else `null`. */38+ totalBytes: number | null;39+};40+41+/** One engine binary reference declared by the built shell. */42+export type EngineAssetReference = {43+ url: string;44+ bytes: number | null;45+};46+47+/** Injectable surfaces used to test the tracker deterministically. */48+export type BootProgressTrackerDependencies = {49+ /** The shell's engine preload declaration, or `null` outside a built page. */50+ readonly engineAsset?: () => EngineAssetReference | null;51+ readonly fetchImpl?: typeof fetch;52+};53+54+/** Stateful first-load progress shared by the runtime and application UI. */55+export type BootProgressTracker = {56+ /** The current progress snapshot. */57+ get(): BootProgress;58+ /** Subscribes to progress changes; returns an idempotent unsubscribe function. */59+ subscribe(listener: (progress: BootProgress) => void): () => void;60+ /**61+ * Downloads the engine binary once per document with byte progress, priming62+ * the HTTP cache the engine's own fetch reads. Resolves without throwing on63+ * any failure; the engine then resolves its binary as it would without a64+ * warm-up.65+ */66+ warmEngineDownload(): Promise<void>;67+ /** Records a phase transition driven by the runtime lifecycle. */68+ mark(phase: "opening" | "ready" | "failed"): void;69+};70+71+function declaredEngineAsset(): EngineAssetReference | null {72+ if (typeof document === "undefined") return null;73+ const link = document.querySelector('link[rel="preload"][data-lofi-engine]');74+ if (!(link instanceof HTMLLinkElement) || !link.href) return null;75+ const bytes = Number(link.getAttribute("data-lofi-engine"));76+ return { url: link.href, bytes: Number.isFinite(bytes) && bytes > 0 ? bytes : null };77+}78+79+/** Creates an isolated tracker, primarily for tests; the runtime shares one. */80+export function createBootProgressTracker(81+ dependencies: BootProgressTrackerDependencies = {},82+): BootProgressTracker {83+ const listeners = new Set<(progress: BootProgress) => void>();84+ let progress: BootProgress = { phase: "pending", loadedBytes: 0, totalBytes: null };85+ let warmup: Promise<void> | null = null;86+87+ const publish = (next: BootProgress) => {88+ progress = next;89+ for (const listener of listeners) listener(progress);90+ };91+92+ const download = async (): Promise<void> => {93+ const asset = (dependencies.engineAsset ?? declaredEngineAsset)();94+ if (!asset) return;95+ publish({ phase: "downloading", loadedBytes: 0, totalBytes: asset.bytes });96+ const fetchImpl = dependencies.fetchImpl ?? fetch;97+ const response = await fetchImpl(asset.url);98+ if (!response.ok || !response.body) return;99+ const reader = response.body.getReader();100+ let loaded = 0;101+ while (true) {102+ const { done, value } = await reader.read();103+ if (done) break;104+ loaded += value.byteLength;105+ publish({ ...progress, loadedBytes: loaded });106+ }107+ };108+109+ return {110+ get: () => progress,111+ subscribe(listener) {112+ listeners.add(listener);113+ let active = true;114+ return () => {115+ if (!active) return;116+ active = false;117+ listeners.delete(listener);118+ };119+ },120+ warmEngineDownload() {121+ // One warm-up per document: a recreated runtime reopens against bytes122+ // that are already local, so only the phases replay.123+ warmup ??= download().catch(() => undefined);124+ return warmup;125+ },126+ mark(phase) {127+ publish({ ...progress, phase });128+ },129+ };130+}131+132+/** Shared tracker used by the package runtime and the optional Preact bindings. */133+export const bootProgressTracker: BootProgressTracker = createBootProgressTracker();134+135+/** Returns the current first-load progress snapshot. */136+export function getBootProgress(): BootProgress {137+ return bootProgressTracker.get();138+}139+140+/** Subscribes to first-load progress and returns an idempotent unsubscribe function. */141+export function subscribeBootProgress(142+ listener: (progress: BootProgress) => void,143+): () => void {144+ return bootProgressTracker.subscribe(listener);145+}@@ -0,0 +1,93 @@
1+import { createBootProgressTracker } from "./boot-progress.ts";2+3+function assert(condition: unknown, message: string): asserts condition {4+ if (!condition) throw new Error(message);5+}6+7+function streamedResponse(chunks: readonly Uint8Array[]): Response {8+ let index = 0;9+ return new Response(10+ new ReadableStream<Uint8Array>({11+ pull(controller) {12+ if (index < chunks.length) controller.enqueue(chunks[index++]);13+ else controller.close();14+ },15+ }),16+ );17+}18+19+Deno.test("engine warm-up reports byte progress through the declared size", async () => {20+ const tracker = createBootProgressTracker({21+ engineAsset: () => ({ url: "https://example.com/app/_astro/jazz.wasm", bytes: 100 }),22+ fetchImpl: () => Promise.resolve(streamedResponse([new Uint8Array(40), new Uint8Array(60)])),23+ });24+ const observed: Array<{ phase: string; loadedBytes: number; totalBytes: number | null }> = [];25+ tracker.subscribe((progress) => observed.push({ ...progress }));26+ assert(tracker.get().phase === "pending", "tracker must start pending");27+ await tracker.warmEngineDownload();28+ assert(29+ observed.some((entry) =>30+ entry.phase === "downloading" && entry.loadedBytes === 0 && entry.totalBytes === 10031+ ),32+ "download start was not published with the declared size",33+ );34+ assert(35+ tracker.get().phase === "downloading" && tracker.get().loadedBytes === 100,36+ `download completion was not published: ${JSON.stringify(tracker.get())}`,37+ );38+ const loadedSequence = observed.map((entry) => entry.loadedBytes);39+ assert(40+ loadedSequence.some((bytes) => bytes === 40),41+ `intermediate progress was not published: ${JSON.stringify(loadedSequence)}`,42+ );43+ tracker.mark("opening");44+ tracker.mark("ready");45+ assert(46+ tracker.get().phase === "ready" && tracker.get().loadedBytes === 100,47+ "phase transitions must preserve byte progress",48+ );49+});50+51+Deno.test("a shell without an engine declaration skips the download phase", async () => {52+ const tracker = createBootProgressTracker({53+ engineAsset: () => null,54+ fetchImpl: () => {55+ throw new Error("no declaration must mean no fetch");56+ },57+ });58+ await tracker.warmEngineDownload();59+ assert(tracker.get().phase === "pending", "phase must stay pending without a declaration");60+ tracker.mark("opening");61+ tracker.mark("ready");62+ assert(tracker.get().phase === "ready", "lifecycle marks must still apply");63+});64+65+Deno.test("warm-up failures resolve silently and run once per document", async () => {66+ let fetches = 0;67+ const tracker = createBootProgressTracker({68+ engineAsset: () => ({ url: "https://example.com/app/_astro/jazz.wasm", bytes: null }),69+ fetchImpl: () => {70+ fetches += 1;71+ return Promise.reject(new Error("offline"));72+ },73+ });74+ await tracker.warmEngineDownload();75+ await tracker.warmEngineDownload();76+ assert(fetches === 1, `warm-up must be memoized per document, saw ${fetches} fetches`);77+ assert(78+ tracker.get().phase === "downloading" && tracker.get().totalBytes === null,79+ "an undeclared size must publish a null total",80+ );81+});82+83+Deno.test("a non-OK engine response leaves resolution to the engine", async () => {84+ const tracker = createBootProgressTracker({85+ engineAsset: () => ({ url: "https://example.com/app/_astro/jazz.wasm", bytes: 100 }),86+ fetchImpl: () => Promise.resolve(new Response("missing", { status: 404 })),87+ });88+ await tracker.warmEngineDownload();89+ assert(90+ tracker.get().loadedBytes === 0,91+ "a failed download must not report progress",92+ );93+});@@ -41,6 +41,12 @@ export {
4141 type PrfSupport,4242 } from "./auth.ts";4343 export { bootLofi } from "./boot.ts";44+export {45+ type BootProgress,46+ type BootProgressPhase,47+ getBootProgress,48+ subscribeBootProgress,49+} from "./boot-progress.ts";4450 export {4551 clearProvisionCapability,4652 heldProvisionCapability,@@ -2,6 +2,7 @@
22 import { BrowserAuthSecretStore, createDb, type Db } from "jazz-tools";33 // Package-owned Jazz runtime.44 import { getLofiApp } from "./app.ts";5+import { bootProgressTracker } from "./boot-progress.ts";56 import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";67 import { activeSink, appId, databaseConfig, syncing } from "./config.ts";78 import { assertSchemaWritable, schemaCompatGate, subscribeSchemaCompat } from "./schema-compat.ts";@@ -77,6 +78,7 @@ function recordStartupFailure(state: RuntimeSlot, failure: RuntimeStartupFailure
7778 const previous = state.diagnostics.startupFailure;7879 state.diagnostics.storageState = "failed";7980 state.diagnostics.startupFailure = failure;81+ bootProgressTracker.mark("failed");8082 if (8183 previous?.code !== failure.code || previous.runtimeMode !== failure.runtimeMode ||8284 previous.message !== failure.message@@ -227,6 +229,12 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
227229 });228230 return await runRuntimeStartup(runtimeMode, async () => {229231 assertDurableBrowser();232+ // On a cold first visit the engine binary is the wait; download it with233+ // byte progress before the engine's own fetch reads the primed cache. The234+ // warm-up cannot fail boot — it resolves and the engine falls back to235+ // resolving its binary itself.236+ await bootProgressTracker.warmEngineDownload();237+ bootProgressTracker.mark("opening");230238 const secret = await resolveAccountSecret();231239 await installEncryptedColumnKey(secret);232240 const namespace = await accountNamespace(secret);@@ -259,6 +267,7 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
259267 // The runtime is open read-write; the compatibility gate may stamp the260268 // local schema version forward (it never stamps while data is ahead).261269 schemaCompatGate.markRuntimeWritable();270+ bootProgressTracker.mark("ready");262271 notifyDiagnostics(state);263272 return db;264273 }, record);@@ -30,15 +30,21 @@ self.addEventListener("install", (event) => {
3030 const paths = await response.json();3131 // Every emitted path is part of the generated offline shell. Installation3232 // fails as one transaction rather than claiming readiness with a partial33- // shell. `cache: "reload"` bypasses the HTTP cache for every shell asset,34- // matching the manifest's no-store fetch — otherwise a new revision's35- // cache could be populated from stale HTTP-cached responses and serve36- // them cache-first indefinitely.33+ // shell. Mutable paths (HTML, manifests) use `cache: "reload"`, matching34+ // the manifest's no-store fetch — otherwise a new revision's cache could35+ // be populated from stale HTTP-cached responses and serve them36+ // cache-first indefinitely. Build assets under `_astro/` carry a content37+ // hash in their name, so a stale response is impossible; admitting the38+ // HTTP cache for them lets a first visit reuse the bytes the page's own39+ // module and engine fetches just downloaded instead of downloading the40+ // shell a second time.3741 const cache = await caches.open(SHELL_CACHE_NAME);3842 await cache.addAll(39- paths.map((path) =>40- new Request(new URL(path, self.registration.scope), { cache: "reload" })41- ),43+ paths.map((path) => {44+ const url = new URL(path, self.registration.scope);45+ const contentHashed = /(?:^|\/)_astro\//.test(url.pathname);46+ return new Request(url, contentHashed ? {} : { cache: "reload" });47+ }),4248 );4349 } catch (error) {4450 await reportFailure(@@ -380,22 +380,38 @@ Deno.test("lookups never read another cache on the origin", async () => {
380380 }381381 });382382 383-Deno.test("shell precache bypasses the HTTP cache for every asset", async () => {383+Deno.test("shell precache bypasses the HTTP cache only for mutable assets", async () => {384384 const precacheInputs: Array<RequestInfo | URL> = [];385385 const harness = await serviceWorkerHarness((input) => {386386 const url = requestUrl(input);387- if (url.endsWith("lofi-precache.json")) return Promise.resolve(Response.json(["./"]));387+ if (url.endsWith("lofi-precache.json")) {388+ return Promise.resolve(389+ Response.json([390+ "./",391+ "./_astro/runtime.AAAA1111.js",392+ "./_astro/jazz_wasm_bg.BBBB2222.wasm",393+ ]),394+ );395+ }388396 precacheInputs.push(input);389397 return Promise.resolve(new Response("shell"));390398 });391399 try {392400 await harness.dispatchLifecycle("install");393- assert(precacheInputs.length === 1, "expected one precached shell asset");394- const request = precacheInputs[0];395- assert(request instanceof Request, "precache must construct explicit requests");401+ assert(precacheInputs.length === 3, "expected three precached shell assets");402+ const modes = new Map<string, string>();403+ for (const input of precacheInputs) {404+ assert(input instanceof Request, "precache must construct explicit requests");405+ modes.set(new URL(input.url).pathname, input.cache);406+ }407+ assert(408+ modes.get("/app/") === "reload",409+ "mutable shell assets must bypass the HTTP cache, matching the manifest's no-store fetch",410+ );396411 assert(397- request.cache === "reload",398- "shell assets must bypass the HTTP cache, matching the manifest's no-store fetch",412+ modes.get("/app/_astro/runtime.AAAA1111.js") === "default" &&413+ modes.get("/app/_astro/jazz_wasm_bg.BBBB2222.wasm") === "default",414+ "content-hashed assets must admit the HTTP cache so a first visit is not downloaded twice",399415 );400416 } finally {401417 harness.close();@@ -15,7 +15,7 @@
1515 "src/app.ts": "922d9d975d9666dbcdcc35d5de149079fcdab46d2571397df7d897f8e905e84d",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1717 "src/islands/AccountGate.tsx": "502009c583a51a6a4b7fb4402e86ec87c61dbc85d87fa0210969144c328a7d1a",18- "src/islands/TaskList.tsx": "bb595f1fdd310164e259d40b719f3d692728824f7dba3aaa8dda8bdd7bcf151d",18+ "src/islands/TaskList.tsx": "7b0da8830e5fab9c59948eff1cbaa357805ecea9c5d070249dd46157e5e36bdf",1919 "src/islands/use-tasks.ts": "0859b1bbd18a7e9fee54cbe919b4e1c9c4b23665227bffa54bd7f0d910192589",2020 "src/layouts/Shell.astro": "57f5c144813fad7d6c7223b0e68498665f41dcd8b0855f2e8bf020d3bb072711",2121 "src/pages/index.astro": "884afb177743ccef0d996b8f60158f948b8bf38c966281f29564a9b4e767fa4c",@@ -195,6 +195,49 @@ export async function shellWeightBytes(
195195 return total;196196 }197197 198+/** The heaviest shell asset — the one first-visit download time hinges on. */199+export async function heaviestShellAsset(200+ paths: readonly string[],201+ root = "dist",202+): Promise<{ path: string; bytes: number } | null> {203+ let heaviest: { path: string; bytes: number } | null = null;204+ for (const path of paths) {205+ const { size } = await Deno.stat(join(root, path));206+ if (!heaviest || size > heaviest.bytes) {207+ heaviest = { path: path.replaceAll("\\", "/"), bytes: size };208+ }209+ }210+ return heaviest;211+}212+213+/** Build-emitted Jazz engine binaries among dist paths; one after deduplication. */214+export function engineWasmAssets(paths: readonly string[]): string[] {215+ return paths216+ .map((path) => path.replaceAll("\\", "/"))217+ .filter((path) => /^_astro\/jazz_wasm_bg\.[^/]+\.wasm$/.test(path))218+ .sort();219+}220+221+/**222+ * The head tag that starts the engine download while the module graph is still223+ * arriving. `crossorigin` makes the preload match the engine glue's plain224+ * `fetch()` credentials mode; without it the browser fetches the binary twice.225+ * The stamped byte size (uncompressed, unlike a Content-Length over a226+ * compressed transfer) lets the runtime report download progress.227+ */228+export function engineWasmPreloadTag(path: string, basePath: string, bytes: number): string {229+ const base = basePath.endsWith("/") ? basePath : `${basePath}/`;230+ return `<link rel="preload" href="${base}${path}" as="fetch" crossorigin data-lofi-engine="${bytes}">`;231+}232+233+/** Inserts tags before an HTML document's `</head>`; unchanged when it has none. */234+export function injectHeadTags(html: string, tags: readonly string[]): string {235+ if (tags.length === 0) return html;236+ const headEnd = html.indexOf("</head>");237+ if (headEnd === -1) return html;238+ return `${html.slice(0, headEnd)}${tags.join("")}${html.slice(headEnd)}`;239+}240+198241 function contains(haystack: Uint8Array, needle: Uint8Array): boolean {199242 if (needle.length === 0 || haystack.length < needle.length) return false;200243 outer:@@ -1,6 +1,10 @@
11 import { join } from "node:path";22 import {33 duplicateBuildAssets,4+ engineWasmAssets,5+ engineWasmPreloadTag,6+ heaviestShellAsset,7+ injectHeadTags,48 precacheUrls,59 scanSecrets,610 shellWeightBytes,@@ -165,7 +169,43 @@ Deno.test("shell weight sums the bytes of the precached set", async () => {
165169 await Deno.writeTextFile(join(root, "_astro", "client.js"), "1234567");166170 const total = await shellWeightBytes(["index.html", "_astro/client.js"], root);167171 assert(total === 12, `expected 12 bytes, received ${total}`);172+ const heaviest = await heaviestShellAsset(["index.html", "_astro/client.js"], root);173+ assert(174+ heaviest?.path === "_astro/client.js" && heaviest.bytes === 7,175+ `expected _astro/client.js at 7 bytes, received ${JSON.stringify(heaviest)}`,176+ );168177 } finally {169178 await Deno.remove(root, { recursive: true });170179 }171180 });181+182+Deno.test("engine preload tags target the engine binary and land inside head", () => {183+ const distFiles = [184+ "_astro/jazz_wasm_bg.MOpZeXEV.wasm",185+ "_astro/runtime.CCX0HbMH.js",186+ "index.html",187+ "media/jazz_wasm_bg.notes.wasm.txt",188+ ];189+ const assets = engineWasmAssets(distFiles);190+ assert(191+ assets.length === 1 && assets[0] === "_astro/jazz_wasm_bg.MOpZeXEV.wasm",192+ `expected the one engine binary, received ${JSON.stringify(assets)}`,193+ );194+ const tag = engineWasmPreloadTag(assets[0], "/app", 9_300_000);195+ assert(196+ tag ===197+ '<link rel="preload" href="/app/_astro/jazz_wasm_bg.MOpZeXEV.wasm" as="fetch" crossorigin data-lofi-engine="9300000">',198+ `unexpected preload tag: ${tag}`,199+ );200+ const html = "<html><head><title>t</title></head><body></body></html>";201+ const injected = injectHeadTags(html, [tag]);202+ assert(203+ injected === `<html><head><title>t</title>${tag}</head><body></body></html>`,204+ `tag was not injected before </head>: ${injected}`,205+ );206+ assert(207+ injectHeadTags("<body>no head</body>", [tag]) === "<body>no head</body>",208+ "a document without a head must pass through unchanged",209+ );210+ assert(injectHeadTags(html, []) === html, "no tags must leave the document unchanged");211+});