clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat(pwa): graceful first load — engine preload, boot progress, single-download precache (#148)

FelineStateMachine committed 73c6045
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>
15 files changed, +485 -25
Mapps/reference/src/islands/TaskList.tsx20++++-
Mdocs/deployment.md26++++-
Mpackage/astro/mod.ts2+
Mpackage/commands/build.ts31++++++-
Mpackage/preact/mod.ts4+-
Apackage/preact/use-boot-progress.ts39++++++++
Apackage/runtime/boot-progress.ts145++++++++++++++++++++++++++++++
Apackage/runtime/boot-progress_test.ts93+++++++++++++++++++
Mpackage/runtime/mod.ts6+
Mpackage/runtime/runtime.ts9++
Mpackage/runtime/sw.js20+++-
Mpackage/runtime/sw_test.ts30+++++-
Mpackage/testdata/starter.snapshot.json2+-
Mpackage/tooling/project.ts43+++++++++
Mpackage/tooling/project_test.ts40++++++++
M apps/reference/src/islands/TaskList.tsx +18 -2
@@ -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) · ${
M docs/deployment.md +20 -6
@@ -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
M package/astro/mod.ts +2 -0
M package/commands/build.ts +30 -1
@@ -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 );
M package/preact/mod.ts +3 -1
A package/preact/use-boot-progress.ts +39 -0
A package/runtime/boot-progress.ts +145 -0
@@ -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+}
A package/runtime/boot-progress_test.ts +93 -0
@@ -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+});
M package/runtime/mod.ts +6 -0
M package/runtime/runtime.ts +9 -0
@@ -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);
M package/runtime/sw.js +13 -7
@@ -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(
M package/runtime/sw_test.ts +23 -7
@@ -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();
M package/testdata/starter.snapshot.json +1 -1
M package/tooling/project.ts +43 -0
@@ -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:
M package/tooling/project_test.ts +40 -0
@@ -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+});