The worker sub-build fell back to Vite's default asset template ([name]-[hash][extname]) while Astro names client assets [name].[hash][extname], so the 9.8 MB Jazz WASM binary — imported by both the runtime and the jazz worker — landed in dist twice under two names, and the precache manifest shipped both. Matching Astro's template in the generated config collapses the copies: dist drops from 19 MB to 10 MB. The build now fails on byte-identical _astro/ assets (the regression class that caused this), reports the measured shell weight in its summary line, and warns — informationally, not as a gate — when the shell passes 20 MiB. How much an app ships is the author's call. Part of #123 (items A2 + C3).
- commit
- b5db6471c7f0383ba3f5c8acca78817d547dcb3e
- parent
- 841b87c56c6ba28aa1264c065653517b2e11175c
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | package/ | 10 | +++++ |
| M | package/ | 60 | ++++++++++++++++++++++++++---- |
| M | package/ | 45 | +++++++++++++++++++++++ |
| M | package/ | 48 | ++++++++++++++++++++++++- |
| M | package/ | 13 | ++++++- |
@@ -176,6 +176,16 @@ export default defineConfig({
176176 vite: {177177 plugins: [jazzPlugin({ schemaDir, inspector: false, server: managedJazzServer })],178178 server: { fs: { allow: [projectRoot] } },179+ // The worker sub-build falls back to Vite's default asset template180+ // ("[name]-[hash][extname]") while Astro names client assets181+ // "[name].[hash][extname]", so the Jazz WASM binary — imported by both the182+ // runtime and the jazz worker — lands in dist twice under two names.183+ // Matching Astro's template collapses the copies into one file.184+ worker: {185+ rollupOptions: {186+ output: { assetFileNames: "_astro/[name].[hash][extname]" },187+ },188+ },179189 define: {180190 __LOFI_JAZZ_APP_ID__: JSON.stringify(cloudAppId ?? ""),181191 __LOFI_JAZZ_SERVER_URL__: JSON.stringify(cloudServerUrl ?? ""),@@ -12,11 +12,32 @@ import { extname, join, relative } from "node:path";
1212 import { LOFI_VERSION } from "../version.ts";1313 import { prepareLofiAstroConfig } from "../astro/mod.ts";1414 import { loadEnvironment, serverEnvironmentNames } from "../tooling/environment.ts";15-import { precacheUrls, scanSecrets, sourceFingerprint, walkFiles } from "../tooling/project.ts";16-import { productionPwaIssues, screenshotAssetPaths } from "../tooling/pwa-validation.ts";15+import {16+ duplicateBuildAssets,17+ precacheUrls,18+ scanSecrets,19+ shellWeightBytes,20+ sourceFingerprint,21+ walkFiles,22+} from "../tooling/project.ts";23+import {24+ precachePaths,25+ productionPwaIssues,26+ screenshotAssetPaths,27+} from "../tooling/pwa-validation.ts";1728 import { runDeno } from "../tooling/process.ts";1829 import { exitOnFailure, validatedCommandEnvironment } from "./shared.ts";1930 31+// A fresh install downloads the whole precache, so every build reports the32+// shell's weight, and an unusually heavy shell (the lofi runtime with the Jazz33+// WASM engine accounts for ~10 MB of it) earns a warning. How much an app34+// ships is the author's call — the warning is information, not a gate.35+const shellWeightNoticeBytes = 20 * 1024 * 1024;36+37+function megabytes(bytes: number): string {38+ return (bytes / (1024 * 1024)).toFixed(1);39+}40+2041 const environment = await validatedCommandEnvironment();2142 environment.LOFI_SKIP_JAZZ_MANAGED = "1";2243 environment.JAZZ_ADMIN_SECRET = "";@@ -60,12 +81,33 @@ await Deno.writeTextFile(
6081 serviceWorker.replace("__LOFI_BUILD_REVISION__", sourceHash),6182 );6283 const productionManifest = JSON.parse(await Deno.readTextFile("dist/manifest.webmanifest"));63-// One walk serves both the precache manifest and the route count below.84+// One walk serves the precache manifest, the shell checks, and the route85+// count below.6486 const distFiles = await walkFiles("dist", { includeDist: true });65-const precache = precacheUrls(66- distFiles,67- screenshotAssetPaths(productionManifest, environment.LOFI_BASE_PATH),68-);87+const screenshotPaths = screenshotAssetPaths(productionManifest, environment.LOFI_BASE_PATH);88+const shellPaths = precachePaths(distFiles, screenshotPaths);89+90+const duplicateAssets = await duplicateBuildAssets(shellPaths);91+if (duplicateAssets.length > 0) {92+ for (const group of duplicateAssets) {93+ console.error(`error: byte-identical build assets: ${group.join(", ")}`);94+ }95+ console.error(96+ "error: two build contexts emitted the same payload under different names and every copy is precached; unify the asset naming, then rerun `deno task build`",97+ );98+ Deno.exit(1);99+}100+101+const shellBytes = await shellWeightBytes(shellPaths);102+if (shellBytes > shellWeightNoticeBytes) {103+ console.warn(104+ `warning: the app shell weighs ${105+ megabytes(shellBytes)106+ } MB; a fresh install downloads every precached byte, so heavy assets may belong outside the precache (served on demand instead)`,107+ );108+}109+110+const precache = precacheUrls(distFiles, screenshotPaths);69111 await Deno.writeTextFile("dist/lofi-precache.json", `${JSON.stringify(precache.sort())}\n`);70112 71113 const pwaIssues = await productionPwaIssues(Deno.cwd(), environment.LOFI_BASE_PATH);@@ -92,5 +134,7 @@ if (secretResult.leaks.length > 0) {
92134 93135 const routes = distFiles.filter((path) => extname(path) === ".html").length;94136 console.log(95- `lofi build: ${join(Deno.cwd(), "dist")} (${routes} routes, ${sourceHash}, secret scan passed)`,137+ `lofi build: ${join(Deno.cwd(), "dist")} (${routes} routes, ${sourceHash}, ${138+ megabytes(shellBytes)139+ } MB shell, secret scan passed)`,96140 );@@ -150,6 +150,51 @@ export function precacheUrls(
150150 return expectedPrecacheUrls(paths, presentationPaths);151151 }152152 153+/**154+ * Byte-identical groups among build-emitted assets (`_astro/`). The same155+ * payload under two names means two build contexts disagreed on an asset156+ * naming template, and every copy ships and precaches. User-authored files157+ * outside `_astro/` are copied verbatim and are not the build's to judge.158+ */159+export async function duplicateBuildAssets(160+ paths: readonly string[],161+ root = "dist",162+): Promise<string[][]> {163+ const bySize = new Map<number, string[]>();164+ for (const path of paths) {165+ const portable = path.replaceAll("\\", "/");166+ if (!portable.startsWith("_astro/")) continue;167+ const { size } = await Deno.stat(join(root, path));168+ bySize.set(size, [...(bySize.get(size) ?? []), portable]);169+ }170+ const groups: string[][] = [];171+ for (const candidates of bySize.values()) {172+ if (candidates.length < 2) continue;173+ const byDigest = new Map<string, string[]>();174+ for (const path of candidates) {175+ const digest = new Uint8Array(176+ await crypto.subtle.digest("SHA-256", await Deno.readFile(join(root, path))),177+ );178+ const key = Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");179+ byDigest.set(key, [...(byDigest.get(key) ?? []), path]);180+ }181+ for (const group of byDigest.values()) {182+ if (group.length > 1) groups.push(group.sort());183+ }184+ }185+ return groups.sort((left, right) => left[0].localeCompare(right[0]));186+}187+188+/** Total bytes a fresh install downloads for the app shell (the precached set). */189+export async function shellWeightBytes(190+ paths: readonly string[],191+ root = "dist",192+): Promise<number> {193+ let total = 0;194+ for (const path of paths) total += (await Deno.stat(join(root, path))).size;195+ return total;196+}197+153198 function contains(haystack: Uint8Array, needle: Uint8Array): boolean {154199 if (needle.length === 0 || haystack.length < needle.length) return false;155200 outer:@@ -1,5 +1,12 @@
11 import { join } from "node:path";2-import { precacheUrls, scanSecrets, sourceFingerprint, walkFiles } from "./project.ts";2+import {3+ duplicateBuildAssets,4+ precacheUrls,5+ scanSecrets,6+ shellWeightBytes,7+ sourceFingerprint,8+ walkFiles,9+} from "./project.ts";310 411 function assert(condition: unknown, message: string): asserts condition {512 if (!condition) throw new Error(message);@@ -119,3 +126,42 @@ Deno.test("secret scan ignores the local env source but catches source and build
119126 await Deno.remove(root, { recursive: true });120127 }121128 });129+130+Deno.test("duplicate build assets are grouped by content and scoped to _astro", async () => {131+ const root = await makeTestRoot();132+ try {133+ await Deno.mkdir(join(root, "_astro"));134+ await Deno.writeTextFile(join(root, "_astro", "engine_bg.HASH.wasm"), "same payload");135+ await Deno.writeTextFile(join(root, "_astro", "engine_bg-HASH.wasm"), "same payload");136+ await Deno.writeTextFile(join(root, "_astro", "one.js"), "payload one");137+ await Deno.writeTextFile(join(root, "_astro", "two.js"), "payload two");138+ await Deno.writeTextFile(join(root, "copy.txt"), "same payload");139+ const groups = await duplicateBuildAssets([140+ "_astro/engine_bg.HASH.wasm",141+ "_astro/engine_bg-HASH.wasm",142+ "_astro/one.js",143+ "_astro/two.js",144+ "copy.txt",145+ ], root);146+ assert(groups.length === 1, `expected one duplicate group, received ${groups.length}`);147+ assert(148+ groups[0].join(", ") === "_astro/engine_bg-HASH.wasm, _astro/engine_bg.HASH.wasm",149+ `same-size files were grouped by name, not content: ${groups[0].join(", ")}`,150+ );151+ } finally {152+ await Deno.remove(root, { recursive: true });153+ }154+});155+156+Deno.test("shell weight sums the bytes of the precached set", async () => {157+ const root = await makeTestRoot();158+ try {159+ await Deno.writeTextFile(join(root, "index.html"), "12345");160+ await Deno.mkdir(join(root, "_astro"));161+ await Deno.writeTextFile(join(root, "_astro", "client.js"), "1234567");162+ const total = await shellWeightBytes(["index.html", "_astro/client.js"], root);163+ assert(total === 12, `expected 12 bytes, received ${total}`);164+ } finally {165+ await Deno.remove(root, { recursive: true });166+ }167+});@@ -942,8 +942,8 @@ export function screenshotAssetPaths(manifest: unknown, deploymentBase = "/"): s
942942 ].sort();943943 }944944 945-/** Map build output paths to portable URLs expected in the service-worker precache. */946-export function expectedPrecacheUrls(945+/** Build output paths that belong to the app shell (the precached set). */946+export function precachePaths(947947 paths: readonly string[],948948 presentationPaths: readonly string[] = [],949949 ): string[] {@@ -951,6 +951,15 @@ export function expectedPrecacheUrls(
951951 return paths952952 .map((path) => path.replaceAll("\\", "/"))953953 .filter((path) => !precacheExcludedPaths.has(path) && !presentation.has(path))954+ .sort();955+}956+957+/** Map build output paths to portable URLs expected in the service-worker precache. */958+export function expectedPrecacheUrls(959+ paths: readonly string[],960+ presentationPaths: readonly string[] = [],961+): string[] {962+ return precachePaths(paths, presentationPaths)954963 .map((path) => path === "index.html" ? "./" : `./${path}`)955964 .sort();956965 }