clone

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

feat/141-effect-library
lofi / package / commands / build.ts
build.ts 8.2K
1#!/usr/bin/env -S deno run -A23/**4 * The `deno task build` command: builds the static PWA into `dist/` with a5 * source-hash build id, a service-worker precache manifest, and a scan that6 * fails the build if server secrets leak into client output.7 *8 * @module9 */1011import { extname, join, relative } from "node:path";12import { LOFI_VERSION } from "../version.ts";13import { prepareLofiAstroConfig } from "../astro/mod.ts";14import { loadEnvironment, serverEnvironmentNames } from "../tooling/environment.ts";15import {16 duplicateBuildAssets,17 engineWasmAssets,18 engineWasmPreloadTag,19 heaviestShellAsset,20 injectHeadTags,21 precacheUrls,22 scanSecrets,23 shellWeightBytes,24 sourceFingerprint,25 walkFiles,26} from "../tooling/project.ts";27import {28 precachePaths,29 productionPwaIssues,30 screenshotAssetPaths,31} from "../tooling/pwa-validation.ts";32import { generateSchemaCompatManifest } from "../tooling/schema-manifest.ts";33import type { LofiBuildInfo } from "../tooling/build-info.ts";34import {35 cspPolicyWarnings,36 mergeCspPolicies,37 parseCspMeta,38 renderHeadersSnippet,39} from "../tooling/csp.ts";40import { runDeno } from "../tooling/process.ts";41import { exitOnFailure, validatedCommandEnvironment } from "./shared.ts";4243// A fresh install downloads the whole precache, so every build reports the44// shell's weight, and an unusually heavy shell (the lofi runtime with the Jazz45// WASM engine accounts for ~10 MB of it) earns a warning. How much an app46// ships is the author's call — the warning is information, not a gate.47const shellWeightNoticeBytes = 20 * 1024 * 1024;4849function megabytes(bytes: number): string {50 return (bytes / (1024 * 1024)).toFixed(1);51}5253const environment = await validatedCommandEnvironment();54environment.LOFI_SKIP_JAZZ_MANAGED = "1";55environment.JAZZ_ADMIN_SECRET = "";56environment.BACKEND_SECRET = "";57const sourceHash = (await sourceFingerprint()).slice(0, 12);58const astroConfig = relative(Deno.cwd(), await prepareLofiAstroConfig());5960async function readPackageAsset(path: string): Promise<string> {61 const url = new URL(path, import.meta.url);62 if (url.protocol === "file:") return await Deno.readTextFile(url);63 const response = await fetch(url);64 if (!response.ok) {65 throw new Error(`failed to read package asset ${path}: HTTP ${response.status}`);66 }67 return await response.text();68}6970exitOnFailure(71 await runDeno(72 ["run", "-A", "npm:astro@7.0.9", "build", "--config", astroConfig],73 environment,74 ),75 "production build",76);7778const buildInfo: LofiBuildInfo = {79 lofiVersion: LOFI_VERSION,80 sourceHash,81 basePath: environment.LOFI_BASE_PATH,82 builtAt: new Date().toISOString(),83};84await Deno.writeTextFile("dist/lofi-build.json", `${JSON.stringify(buildInfo)}\n`);85const serviceWorkerPath = "dist/sw.js";86const serviceWorker = await readPackageAsset("../runtime/sw.js");87await Deno.writeTextFile(88 serviceWorkerPath,89 serviceWorker.replace("__LOFI_BUILD_REVISION__", sourceHash),90);91// The revision↔schema compatibility manifest. Written before the dist walk so92// it joins the precache: an offline shell must still know its own schema93// range for the boot compatibility gate.94let schemaManifest;95try {96 schemaManifest = await generateSchemaCompatManifest({ revision: sourceHash });97} catch (error) {98 console.error(`error: ${error instanceof Error ? error.message : String(error)}`);99 console.error(100 "error: the schema compatibility manifest could not be generated; fix src/schema.ts, then rerun `deno task build`",101 );102 Deno.exit(1);103}104await Deno.writeTextFile("dist/lofi-schema.json", `${JSON.stringify(schemaManifest)}\n`);105const productionManifest = JSON.parse(await Deno.readTextFile("dist/manifest.webmanifest"));106// One walk serves the precache manifest, the shell checks, and the route107// count below.108const distFiles = await walkFiles("dist", { includeDist: true });109110// The engine binary dominates a cold first visit. Every prerendered page111// preloads it so the download overlaps the module graph instead of queueing112// behind it, and the tag carries the byte size the runtime's boot-progress113// surface reports while the download runs.114const engineTags = await Promise.all(115 engineWasmAssets(distFiles).map(async (asset) =>116 engineWasmPreloadTag(117 asset,118 environment.LOFI_BASE_PATH || "/",119 (await Deno.stat(join("dist", asset))).size,120 )121 ),122);123const cspPolicies: string[] = [];124const cspMissing: string[] = [];125for (const path of distFiles.filter((file) => extname(file) === ".html")) {126 const html = await Deno.readTextFile(join("dist", path));127 const injected = injectHeadTags(html, engineTags);128 if (injected !== html) await Deno.writeTextFile(join("dist", path), injected);129 const policy = parseCspMeta(injected);130 if (policy === null) cspMissing.push(path);131 else cspPolicies.push(policy);132}133134// One deployable policy for the whole site: the per-page meta tags enforce135// it already; the union feeds the preview server's real header and the136// host-header snippet. Reported, never gated.137const cspUnion = cspPolicies.length > 0 ? mergeCspPolicies(cspPolicies) : null;138if (cspUnion !== null) {139 buildInfo.csp = cspUnion;140 await Deno.writeTextFile("dist/lofi-build.json", `${JSON.stringify(buildInfo)}\n`);141 await Deno.writeTextFile("dist/_headers.example", renderHeadersSnippet(cspUnion));142 const hashCount = (cspUnion.match(/'sha\d{3}-/g) ?? []).length;143 console.log(144 `lofi build: CSP script-src 'self' 'wasm-unsafe-eval' + ${hashCount} hashes ` +145 "(meta-enforced; mirror as a header where your host supports it — see dist/_headers.example)",146 );147 for (const finding of cspPolicyWarnings(cspUnion)) console.warn(`warning: ${finding}`);148 if (cspMissing.length > 0) {149 console.warn(150 `warning: ${cspMissing.length} built page(s) carry no CSP meta tag: ${cspMissing.join(", ")}`,151 );152 }153} else {154 console.warn(155 "warning: the build carries no Content-Security-Policy (LOFI_CSP=off or no pages); " +156 "same-origin script is the threat model's shaping attacker, so prefer the default policy",157 );158}159160const screenshotPaths = screenshotAssetPaths(productionManifest, environment.LOFI_BASE_PATH);161const shellPaths = precachePaths(distFiles, screenshotPaths);162163const duplicateAssets = await duplicateBuildAssets(shellPaths);164if (duplicateAssets.length > 0) {165 for (const group of duplicateAssets) {166 console.error(`error: byte-identical build assets: ${group.join(", ")}`);167 }168 console.error(169 "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`",170 );171 Deno.exit(1);172}173174const shellBytes = await shellWeightBytes(shellPaths);175const heaviestAsset = await heaviestShellAsset(shellPaths);176if (shellBytes > shellWeightNoticeBytes) {177 console.warn(178 `warning: the app shell weighs ${179 megabytes(shellBytes)180 } MB; a fresh install downloads every precached byte, so heavy assets may belong outside the precache (served on demand instead)`,181 );182}183184const precache = precacheUrls(distFiles, screenshotPaths);185await Deno.writeTextFile("dist/lofi-precache.json", `${JSON.stringify(precache.sort())}\n`);186187const pwaIssues = await productionPwaIssues(Deno.cwd(), environment.LOFI_BASE_PATH);188if (pwaIssues.length > 0) {189 for (const pwaIssue of pwaIssues) console.error(`error: ${pwaIssue.detail}`);190 console.error(`error: ${pwaIssues[0].remediation}`);191 Deno.exit(1);192}193194const loadedEnvironment = await loadEnvironment();195const serverSecrets = Object.fromEntries(196 serverEnvironmentNames.map((name) => [name, loadedEnvironment[name] ?? ""]),197);198const secretResult = await scanSecrets(serverSecrets);199if (secretResult.leaks.length > 0) {200 for (const leak of secretResult.leaks) {201 console.error(`error: ${leak.name} value found in ${leak.path}`);202 }203 console.error(204 "error: build output may expose a server secret; remove it and rerun `deno task build`",205 );206 Deno.exit(1);207}208209const routes = distFiles.filter((path) => extname(path) === ".html").length;210console.log(211 `lofi build: ${join(Deno.cwd(), "dist")} (${routes} routes, ${sourceHash}, ${212 megabytes(shellBytes)213 } MB shell${214 heaviestAsset215 ? `, heaviest asset ${heaviestAsset.path} at ${megabytes(heaviestAsset.bytes)} MB`216 : ""217 }, secret scan passed)`,218);