clone

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

feat(tooling): validate generated PWA artifacts

Dami committed ee92194
commit
ee921941418bb89c9863d64424903e8a13a3f387
parent
7b79ba6333c61777fe5c4faeb0c901d54916f94d
author
Dami <dami.etoile@gmail.com>
11 files changed, +965 -48
Mdocs/deployment.md6+-
Mdocs/getting-started.md7+-
Mdocs/reference/commands.md28+-
Mdocs/troubleshooting.md13+
Mpackage/commands/build.ts8+
Mpackage/commands/preview.ts16+-
Mpackage/tooling/diagnostics.ts2+-
Mpackage/tooling/diagnostics_test.ts28+
Mpackage/tooling/project.ts40+-
Apackage/tooling/pwa-validation.ts611++++++++++++++++++++++++++++++
Apackage/tooling/pwa-validation_test.ts254++++++++++++
M docs/deployment.md +4 -2
M docs/getting-started.md +4 -3
M docs/reference/commands.md +14 -14
@@ -2,20 +2,20 @@
22 33 Run these from a generated project as `deno task <name>`.44 5-| Task | Purpose |6-| ------------------- | ----------------------------------------------------------------- |7-| `dev` | Validate configuration and run the Astro development server |8-| `doctor` | Print a secret-free project and environment readiness report |9-| `test` | Run the deterministic generated-project test suite |10-| `build` | Build the static PWA, fingerprint it, and scan for leaked secrets |11-| `preview` | Serve an existing production build locally |12-| `jazz:provision` | Create a managed Jazz app and write its configuration to `.env` |13-| `schema:validate` | Validate the Jazz schema and permission declarations |14-| `schema:deploy` | Publish the schema, migrations, and permissions |15-| `migrations:create` | Create a migration under `src/migrations/` |16-| `migrations:push` | Push migrations using the managed Jazz configuration |17-| `deploy:create` | Build and create a static Deno Deploy application |18-| `deploy` | Build and deploy a new production version to the configured app |5+| Task | Purpose |6+| ------------------- | ------------------------------------------------------------------- |7+| `dev` | Validate configuration and run the Astro development server |8+| `doctor` | Validate source PWA metadata and print secret-free readiness |9+| `test` | Run the deterministic generated-project test suite |10+| `build` | Build and validate the static PWA, fingerprint it, and scan secrets |11+| `preview` | Serve an existing production build locally |12+| `jazz:provision` | Create a managed Jazz app and write its configuration to `.env` |13+| `schema:validate` | Validate the Jazz schema and permission declarations |14+| `schema:deploy` | Publish the schema, migrations, and permissions |15+| `migrations:create` | Create a migration under `src/migrations/` |16+| `migrations:push` | Push migrations using the managed Jazz configuration |17+| `deploy:create` | Build and create a static Deno Deploy application |18+| `deploy` | Build and deploy a new production version to the configured app |1919 2020 ## Arguments2121
M docs/troubleshooting.md +13 -0
M package/commands/build.ts +8 -0
M package/commands/preview.ts +3 -13
@@ -7,8 +7,9 @@
77 * @module88 */99 10-import { extname, join, normalize } from "node:path";10+import { join, normalize } from "node:path";1111 import { normalizeDeploymentBase, pathWithinDeploymentBase } from "../tooling/base-path.ts";12+import { productionContentType } from "../tooling/pwa-validation.ts";1213 1314 const portFlag = Deno.args.findIndex((argument) => argument === "--port");1415 const port = portFlag >= 0 ? Number(Deno.args[portFlag + 1]) : 4321;
@@ -30,17 +31,6 @@ if (!identity.sourceHash) {
3031 }3132 const basePath = normalizeDeploymentBase(identity.basePath);3233 33-const contentTypes: Record<string, string> = {34- ".css": "text/css; charset=utf-8",35- ".html": "text/html; charset=utf-8",36- ".js": "text/javascript; charset=utf-8",37- ".json": "application/json; charset=utf-8",38- ".png": "image/png",39- ".svg": "image/svg+xml",40- ".wasm": "application/wasm",41- ".webmanifest": "application/manifest+json",42-};43-4434 console.log(4535 `lofi preview: http://127.0.0.1:${port}${basePath} (build ${identity.sourceHash}, @nzip/lofi ${identity.lofiVersion})`,4636 );
@@ -59,7 +49,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
5949 try {6050 const body = await Deno.readFile(path);6151 return new Response(request.method === "HEAD" ? null : body, {62- headers: { "content-type": contentTypes[extname(path)] ?? "application/octet-stream" },52+ headers: { "content-type": productionContentType(path) },6353 });6454 } catch (error) {6555 if (error instanceof Deno.errors.NotFound) return new Response("Not found", { status: 404 });
M package/tooling/diagnostics.ts +1 -1
M package/tooling/diagnostics_test.ts +28 -0
M package/tooling/project.ts +25 -15
@@ -1,4 +1,5 @@
11 import { extname, join, relative, resolve } from "node:path";2+import { expectedPrecacheUrls, sourcePwaIssues } from "./pwa-validation.ts";23 34 const ignoredDirectories = new Set([45 ".astro",
@@ -19,7 +20,10 @@ export type ProjectCheck = {
1920 remediation?: string;2021 };2122 22-export async function projectChecks(root = Deno.cwd()): Promise<ProjectCheck[]> {23+export async function projectChecks(24+ root = Deno.cwd(),25+ deploymentBase = "/",26+): Promise<ProjectCheck[]> {2327 // The generic local-first scaffold every lofi project carries, independent of2428 // the app's domain. Islands, schema tables, and stores are author content and2529 // are intentionally not required here — a project stays valid after the starter
@@ -42,14 +46,30 @@ export async function projectChecks(root = Deno.cwd()): Promise<ProjectCheck[]>
4246 else throw error;4347 }4448 }45- return missing.length === 046- ? [{ name: "Project", status: "ok", detail: "generated layout complete" }]47- : [{49+ if (missing.length > 0) {50+ return [{4851 name: "Project",4952 status: "blocker",5053 detail: `missing ${missing.join(", ")}`,5154 remediation: "restore the generated files or create a fresh project and move author files",5255 }];56+ }57+ const pwaIssues = await sourcePwaIssues(root, deploymentBase);58+ return [59+ { name: "Project", status: "ok", detail: "generated layout complete" },60+ ...(pwaIssues.length === 061+ ? [{62+ name: "PWA source",63+ status: "ok" as const,64+ detail: "manifest and assets are installable",65+ }]66+ : pwaIssues.map((pwaIssue) => ({67+ name: "PWA source",68+ status: "blocker" as const,69+ detail: pwaIssue.detail,70+ remediation: pwaIssue.remediation,71+ }))),72+ ];5373 }5474 5575 export async function walkFiles(
@@ -123,18 +143,8 @@ export async function sourceFingerprint(root = Deno.cwd()): Promise<string> {
123143 return Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");124144 }125145 126-const precacheExcludedPaths = new Set([127- "lofi-build.json",128- "lofi-precache.json",129- "sw.js",130-]);131-132146 export function precacheUrls(paths: readonly string[]): string[] {133- return paths134- .map((path) => path.replaceAll("\\", "/"))135- .filter((path) => !precacheExcludedPaths.has(path))136- .map((path) => path === "index.html" ? "./" : `./${path}`)137- .sort();147+ return expectedPrecacheUrls(paths);138148 }139149 140150 function contains(haystack: Uint8Array, needle: Uint8Array): boolean {
A package/tooling/pwa-validation.ts +611 -0
@@ -0,0 +1,611 @@
1+import { extname, join, relative } from "node:path";2+import { normalizeDeploymentBase } from "./base-path.ts";3+4+export type PwaValidationIssue = {5+ detail: string;6+ remediation: string;7+};8+9+type JsonObject = Record<string, unknown>;10+type ImageResource = {11+ src: string;12+ sizes?: string;13+ type?: string;14+ purpose?: string;15+};16+17+const manifestFile = "manifest.webmanifest";18+const syntheticOrigin = "https://lofi.invalid";19+const displayModes = new Set(["browser", "fullscreen", "minimal-ui", "standalone"]);20+const imageTypes = new Map([21+ ["image/jpeg", new Set([".jpeg", ".jpg"])],22+ ["image/png", new Set([".png"])],23+ ["image/svg+xml", new Set([".svg"])],24+ ["image/webp", new Set([".webp"])],25+]);26+27+function issue(detail: string, remediation: string): PwaValidationIssue {28+ return { detail, remediation };29+}30+31+function isObject(value: unknown): value is JsonObject {32+ return value !== null && typeof value === "object" && !Array.isArray(value);33+}34+35+function nonEmptyString(value: unknown): value is string {36+ return typeof value === "string" && value.trim().length > 0;37+}38+39+function isLikelyCssColor(value: unknown): boolean {40+ if (!nonEmptyString(value) || /[;{}]/.test(value)) return false;41+ const color = value.trim();42+ return /^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(color) ||43+ /^[a-z]+$/i.test(color) ||44+ /^(?:color|hsl|hsla|hwb|lab|lch|oklab|oklch|rgb|rgba)\([^\n]+\)$/i.test(color);45+}46+47+function withinScope(target: URL, scope: URL): boolean {48+ return target.origin === scope.origin && target.pathname.startsWith(scope.pathname);49+}50+51+function assetPath(url: URL, basePath: string): string | undefined {52+ if (url.origin !== syntheticOrigin || !url.pathname.startsWith(basePath)) return undefined;53+ let path: string;54+ try {55+ path = decodeURIComponent(url.pathname.slice(basePath.length));56+ } catch {57+ return undefined;58+ }59+ if (!path || path.startsWith("/") || path.split("/").some((part) => part === "..")) {60+ return undefined;61+ }62+ return path;63+}64+65+function pngDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {66+ const signature = [137, 80, 78, 71, 13, 10, 26, 10];67+ if (!signature.every((byte, index) => bytes[index] === byte)) return undefined;68+ if (new TextDecoder().decode(bytes.subarray(12, 16)) !== "IHDR") return undefined;69+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);70+ return { width: view.getUint32(16), height: view.getUint32(20) };71+}72+73+function jpegDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {74+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return undefined;75+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);76+ let offset = 2;77+ while (offset + 8 < bytes.length) {78+ if (bytes[offset] !== 0xff) return undefined;79+ const marker = bytes[offset + 1];80+ offset += 2;81+ if (marker === 0xd8 || marker === 0xd9) continue;82+ const length = view.getUint16(offset);83+ if (length < 2 || offset + length > bytes.length) return undefined;84+ if (85+ (marker >= 0xc0 && marker <= 0xc3) ||86+ (marker >= 0xc5 && marker <= 0xc7) ||87+ (marker >= 0xc9 && marker <= 0xcb) ||88+ (marker >= 0xcd && marker <= 0xcf)89+ ) {90+ return { width: view.getUint16(offset + 5), height: view.getUint16(offset + 3) };91+ }92+ offset += length;93+ }94+ return undefined;95+}96+97+function webpDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {98+ const ascii = (start: number, end: number) =>99+ new TextDecoder().decode(bytes.subarray(start, end));100+ if (ascii(0, 4) !== "RIFF" || ascii(8, 12) !== "WEBP") return undefined;101+ const kind = ascii(12, 16);102+ if (kind === "VP8X" && bytes.length >= 30) {103+ const width = 1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16);104+ const height = 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16);105+ return { width, height };106+ }107+ if (108+ kind === "VP8 " && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 &&109+ bytes[25] === 0x2a110+ ) {111+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);112+ return { width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff };113+ }114+ if (kind === "VP8L" && bytes.length >= 25 && bytes[20] === 0x2f) {115+ const bits = bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24);116+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };117+ }118+ return undefined;119+}120+121+async function imageDimensions(122+ path: string,123+ type: string,124+): Promise<{ width: number; height: number } | undefined> {125+ const bytes = await Deno.readFile(path);126+ if (type === "image/png") return pngDimensions(bytes);127+ if (type === "image/jpeg") return jpegDimensions(bytes);128+ if (type === "image/webp") return webpDimensions(bytes);129+ if (type === "image/svg+xml") {130+ const source = new TextDecoder().decode(bytes);131+ if (!/<svg\b/i.test(source)) return undefined;132+ const viewBox = source.match(133+ /\bviewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)\s*["']/i,134+ );135+ if (viewBox) return { width: Number(viewBox[1]), height: Number(viewBox[2]) };136+ const width = source.match(/\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/i);137+ const height = source.match(/\bheight\s*=\s*["']([\d.]+)(?:px)?["']/i);138+ if (width && height) return { width: Number(width[1]), height: Number(height[1]) };139+ }140+ return undefined;141+}142+143+function declaredSizes(144+ value: unknown,145+): Array<{ width: number; height: number }> | "any" | undefined {146+ if (!nonEmptyString(value)) return undefined;147+ if (value.trim() === "any") return "any";148+ const sizes: Array<{ width: number; height: number }> = [];149+ for (const token of value.trim().split(/\s+/)) {150+ const match = token.match(/^(\d+)x(\d+)$/i);151+ if (!match) return undefined;152+ sizes.push({ width: Number(match[1]), height: Number(match[2]) });153+ }154+ return sizes;155+}156+157+async function validateImages(158+ value: unknown,159+ member: string,160+ assetRoot: string,161+ manifestUrl: URL,162+ scope: URL,163+ basePath: string,164+ remediation: string,165+): Promise<{ issues: PwaValidationIssue[]; images: ImageResource[] }> {166+ const issues: PwaValidationIssue[] = [];167+ const images: ImageResource[] = [];168+ if (!Array.isArray(value) || value.length === 0) {169+ return {170+ issues: [issue(`${manifestFile}: ${member} must be a non-empty array`, remediation)],171+ images,172+ };173+ }174+ for (const [index, raw] of value.entries()) {175+ const label = `${manifestFile}: ${member}[${index}]`;176+ if (!isObject(raw) || !nonEmptyString(raw.src)) {177+ issues.push(issue(`${label}.src must be a non-empty URL`, remediation));178+ continue;179+ }180+ let url: URL;181+ try {182+ url = new URL(raw.src, manifestUrl);183+ } catch {184+ issues.push(issue(`${label}.src is not a valid URL`, remediation));185+ continue;186+ }187+ const path = assetPath(url, basePath);188+ if (!path || !withinScope(url, scope)) {189+ issues.push(issue(`${label}.src must stay inside deployment scope ${basePath}`, remediation));190+ continue;191+ }192+ if (!nonEmptyString(raw.type) || !imageTypes.has(raw.type)) {193+ issues.push(issue(`${label}.type must name a supported image MIME type`, remediation));194+ continue;195+ }196+ const extensions = imageTypes.get(raw.type)!;197+ if (!extensions.has(extname(path).toLowerCase())) {198+ issues.push(issue(`${label}.type does not match ${path}`, remediation));199+ continue;200+ }201+ const sizes = declaredSizes(raw.sizes);202+ if (!sizes) {203+ issues.push(issue(`${label}.sizes must be "any" or WIDTHxHEIGHT tokens`, remediation));204+ continue;205+ }206+ const fullPath = join(assetRoot, path);207+ try {208+ if (!(await Deno.stat(fullPath)).isFile) throw new Deno.errors.NotFound();209+ } catch (error) {210+ if (error instanceof Deno.errors.NotFound) {211+ issues.push(issue(`${label}.src references missing asset ${path}`, remediation));212+ continue;213+ }214+ throw error;215+ }216+ const dimensions = await imageDimensions(fullPath, raw.type);217+ if (!dimensions) {218+ issues.push(issue(`${label}.src is not decodable as ${raw.type}`, remediation));219+ continue;220+ }221+ if (222+ sizes !== "any" &&223+ !sizes.some((size) => size.width === dimensions.width && size.height === dimensions.height)224+ ) {225+ issues.push(226+ issue(227+ `${label}.sizes does not match ${path} (${dimensions.width}x${dimensions.height})`,228+ remediation,229+ ),230+ );231+ continue;232+ }233+ images.push({234+ src: raw.src,235+ sizes: raw.sizes as string,236+ type: raw.type,237+ purpose: nonEmptyString(raw.purpose) ? raw.purpose : undefined,238+ });239+ }240+ return { issues, images };241+}242+243+async function parseAndValidateManifest(244+ root: string,245+ basePath: string,246+ location: "public" | "dist",247+): Promise<{ issues: PwaValidationIssue[]; manifest?: JsonObject; scope?: URL }> {248+ const path = join(root, location, manifestFile);249+ const remediation = location === "public"250+ ? "edit public/manifest.webmanifest and its referenced assets, then rerun `deno task doctor`"251+ : "fix the author-owned PWA source, then rerun `deno task build`";252+ let manifest: unknown;253+ try {254+ manifest = JSON.parse(await Deno.readTextFile(path));255+ } catch (error) {256+ if (error instanceof Deno.errors.NotFound) {257+ return { issues: [issue(`${location}/${manifestFile}: file is missing`, remediation)] };258+ }259+ if (error instanceof SyntaxError) {260+ return { issues: [issue(`${location}/${manifestFile}: malformed JSON`, remediation)] };261+ }262+ throw error;263+ }264+ if (!isObject(manifest)) {265+ return {266+ issues: [issue(`${location}/${manifestFile}: root must be a JSON object`, remediation)],267+ };268+ }269+270+ const issues: PwaValidationIssue[] = [];271+ for (const member of ["id", "name", "short_name", "start_url", "scope"] as const) {272+ if (!nonEmptyString(manifest[member])) {273+ issues.push(issue(`${manifestFile}: ${member} must be a non-empty string`, remediation));274+ }275+ }276+ if (!displayModes.has(String(manifest.display))) {277+ issues.push(278+ issue(279+ `${manifestFile}: display must be browser, fullscreen, minimal-ui, or standalone`,280+ remediation,281+ ),282+ );283+ }284+ for (const member of ["background_color", "theme_color"] as const) {285+ if (!isLikelyCssColor(manifest[member])) {286+ issues.push(issue(`${manifestFile}: ${member} must be a CSS color`, remediation));287+ }288+ }289+290+ const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);291+ let startUrl: URL | undefined;292+ let scope: URL | undefined;293+ let identity: URL | undefined;294+ for (295+ const [member, value] of [296+ ["id", manifest.id],297+ ["start_url", manifest.start_url],298+ ["scope", manifest.scope],299+ ] as const300+ ) {301+ if (!nonEmptyString(value)) continue;302+ try {303+ const url = new URL(value, manifestUrl);304+ if (url.origin !== syntheticOrigin) {305+ issues.push(issue(`${manifestFile}: ${member} must be same-origin`, remediation));306+ }307+ if (member === "id") identity = url;308+ else if (member === "start_url") startUrl = url;309+ else scope = url;310+ } catch {311+ issues.push(issue(`${manifestFile}: ${member} is not a valid URL`, remediation));312+ }313+ }314+ if (identity?.hash) {315+ issues.push(issue(`${manifestFile}: id must not contain a fragment`, remediation));316+ }317+ if (scope && (scope.pathname !== basePath || scope.search || scope.hash)) {318+ issues.push(319+ issue(`${manifestFile}: scope must resolve to deployment base ${basePath}`, remediation),320+ );321+ }322+ if (startUrl && scope && !withinScope(startUrl, scope)) {323+ issues.push(issue(`${manifestFile}: start_url must stay inside scope`, remediation));324+ }325+326+ if (scope) {327+ const icons = await validateImages(328+ manifest.icons,329+ "icons",330+ join(root, location),331+ manifestUrl,332+ scope,333+ basePath,334+ remediation,335+ );336+ issues.push(...icons.issues);337+ const hasPurposeSize = (purpose: string, size: number | "any") =>338+ icons.images.some((image) => {339+ const purposes = (image.purpose ?? "any").split(/\s+/);340+ const sizes = declaredSizes(image.sizes);341+ return purposes.includes(purpose) &&342+ (size === "any" ? sizes === "any" : sizes === "any" ||343+ sizes?.some((entry) => entry.width === size && entry.height === size));344+ });345+ for (const size of [192, 512]) {346+ if (!hasPurposeSize("any", size)) {347+ issues.push(348+ issue(`${manifestFile}: icons need an any-purpose ${size}x${size} image`, remediation),349+ );350+ }351+ }352+ if (!hasPurposeSize("maskable", 512)) {353+ issues.push(issue(`${manifestFile}: icons need a maskable 512x512 image`, remediation));354+ }355+ if (!hasPurposeSize("monochrome", "any")) {356+ issues.push(issue(`${manifestFile}: icons need a scalable monochrome image`, remediation));357+ }358+359+ if (manifest.shortcuts !== undefined) {360+ if (!Array.isArray(manifest.shortcuts)) {361+ issues.push(issue(`${manifestFile}: shortcuts must be an array`, remediation));362+ } else {363+ for (const [index, raw] of manifest.shortcuts.entries()) {364+ const label = `${manifestFile}: shortcuts[${index}]`;365+ if (!isObject(raw) || !nonEmptyString(raw.name) || !nonEmptyString(raw.url)) {366+ issues.push(issue(`${label} needs non-empty name and url strings`, remediation));367+ continue;368+ }369+ try {370+ const url = new URL(raw.url, manifestUrl);371+ if (!withinScope(url, scope)) {372+ issues.push(issue(`${label}.url must stay inside manifest scope`, remediation));373+ }374+ } catch {375+ issues.push(issue(`${label}.url is not a valid URL`, remediation));376+ }377+ if (raw.icons !== undefined) {378+ const shortcutIcons = await validateImages(379+ raw.icons,380+ `shortcuts[${index}].icons`,381+ join(root, location),382+ manifestUrl,383+ scope,384+ basePath,385+ remediation,386+ );387+ issues.push(...shortcutIcons.issues);388+ }389+ }390+ }391+ }392+393+ if (manifest.screenshots !== undefined) {394+ const screenshots = await validateImages(395+ manifest.screenshots,396+ "screenshots",397+ join(root, location),398+ manifestUrl,399+ scope,400+ basePath,401+ remediation,402+ );403+ issues.push(...screenshots.issues);404+ }405+ }406+ return { issues, manifest, scope };407+}408+409+/** Validate author-owned manifest and referenced public assets before boot or build. */410+export async function sourcePwaIssues(root: string, deploymentBase = "/") {411+ return (await parseAndValidateManifest(root, normalizeDeploymentBase(deploymentBase), "public"))412+ .issues;413+}414+415+function attribute(tag: string, name: string): string | undefined {416+ const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));417+ return match?.[1] ?? match?.[2] ?? match?.[3];418+}419+420+function links(html: string): Array<{ rel: string; href: string }> {421+ return [...html.matchAll(/<link\b[^>]*>/gi)].flatMap((match) => {422+ const rel = attribute(match[0], "rel");423+ const href = attribute(match[0], "href");424+ return rel && href ? [{ rel, href }] : [];425+ });426+}427+428+async function filesUnder(root: string): Promise<string[]> {429+ const files: string[] = [];430+ async function visit(path: string) {431+ for await (const entry of Deno.readDir(path)) {432+ const child = join(path, entry.name);433+ if (entry.isDirectory) await visit(child);434+ else if (entry.isFile) files.push(relative(root, child).replaceAll("\\", "/"));435+ }436+ }437+ await visit(root);438+ return files.sort();439+}440+441+const precacheExcludedPaths = new Set(["lofi-build.json", "lofi-precache.json", "sw.js"]);442+443+/** Map build output paths to portable URLs expected in the service-worker precache. */444+export function expectedPrecacheUrls(paths: readonly string[]): string[] {445+ return paths446+ .map((path) => path.replaceAll("\\", "/"))447+ .filter((path) => !precacheExcludedPaths.has(path))448+ .map((path) => path === "index.html" ? "./" : `./${path}`)449+ .sort();450+}451+452+/** MIME type used by the package preview for install-critical production files. */453+export function productionContentType(path: string): string {454+ const types: Record<string, string> = {455+ ".css": "text/css; charset=utf-8",456+ ".html": "text/html; charset=utf-8",457+ ".js": "text/javascript; charset=utf-8",458+ ".json": "application/json; charset=utf-8",459+ ".jpeg": "image/jpeg",460+ ".jpg": "image/jpeg",461+ ".png": "image/png",462+ ".svg": "image/svg+xml",463+ ".wasm": "application/wasm",464+ ".webmanifest": "application/manifest+json",465+ ".webp": "image/webp",466+ };467+ return types[extname(path).toLowerCase()] ?? "application/octet-stream";468+}469+470+/** Validate the emitted manifest, routes, worker, build identity, and precache as one artifact. */471+export async function productionPwaIssues(root: string, deploymentBase = "/") {472+ const basePath = normalizeDeploymentBase(deploymentBase);473+ const dist = join(root, "dist");474+ const remediation = "fix the author-owned PWA source, then rerun `deno task build`";475+ const result = await parseAndValidateManifest(root, basePath, "dist");476+ const issues = [...result.issues];477+ let files: string[];478+ try {479+ files = await filesUnder(dist);480+ } catch (error) {481+ if (error instanceof Deno.errors.NotFound) {482+ return [issue("dist/: production output is missing", remediation)];483+ }484+ throw error;485+ }486+487+ let identity: JsonObject | undefined;488+ try {489+ const value = JSON.parse(await Deno.readTextFile(join(dist, "lofi-build.json")));490+ if (isObject(value)) identity = value;491+ } catch {492+ // Report one stable error below.493+ }494+ if (495+ !identity || !nonEmptyString(identity.sourceHash) || identity.basePath !== basePath496+ ) {497+ issues.push(498+ issue("dist/lofi-build.json: build identity or deployment base is invalid", remediation),499+ );500+ }501+502+ const htmlFiles = files.filter((path) => path.endsWith(".html"));503+ if (htmlFiles.length === 0) {504+ issues.push(issue("dist/: no prerendered HTML routes were emitted", remediation));505+ }506+ const expectedManifest = new URL(`${syntheticOrigin}${basePath}${manifestFile}`).href;507+ const appleIcons = new Set<string>();508+ for (const path of htmlFiles) {509+ const html = await Deno.readTextFile(join(dist, path));510+ const route = path === "index.html"511+ ? basePath512+ : `${basePath}${path.endsWith("/index.html") ? path.slice(0, -"index.html".length) : path}`;513+ const documentUrl = new URL(route, syntheticOrigin);514+ const pageLinks = links(html);515+ const manifestLinks = pageLinks.filter((link) =>516+ link.rel.toLowerCase().split(/\s+/).includes("manifest")517+ );518+ let linkedManifest = "";519+ try {520+ linkedManifest = new URL(manifestLinks[0]?.href ?? "", documentUrl).href;521+ } catch {522+ // The stable mismatch below names the route and intended manifest URL.523+ }524+ if (manifestLinks.length !== 1 || linkedManifest !== expectedManifest) {525+ issues.push(526+ issue(`dist/${path}: must link ${basePath}${manifestFile} exactly once`, remediation),527+ );528+ }529+ const apple = pageLinks.find((link) =>530+ link.rel.toLowerCase().split(/\s+/).includes("apple-touch-icon")531+ );532+ if (!apple) {533+ issues.push(issue(`dist/${path}: Apple touch icon link is missing`, remediation));534+ } else {535+ try {536+ const url = new URL(apple.href, documentUrl);537+ const applePath = assetPath(url, basePath);538+ if (!applePath) {539+ issues.push(540+ issue(`dist/${path}: Apple touch icon escapes deployment scope`, remediation),541+ );542+ } else appleIcons.add(applePath);543+ } catch {544+ issues.push(issue(`dist/${path}: Apple touch icon URL is invalid`, remediation));545+ }546+ }547+ }548+ for (const path of appleIcons) {549+ try {550+ const dimensions = await imageDimensions(join(dist, path), "image/png");551+ if (!dimensions || dimensions.width !== 180 || dimensions.height !== 180) {552+ issues.push(issue(`dist/${path}: Apple touch icon must be a 180x180 PNG`, remediation));553+ }554+ } catch (error) {555+ if (error instanceof Deno.errors.NotFound) {556+ issues.push(issue(`dist/${path}: linked Apple touch icon is missing`, remediation));557+ } else throw error;558+ }559+ }560+561+ let precache: unknown;562+ try {563+ precache = JSON.parse(await Deno.readTextFile(join(dist, "lofi-precache.json")));564+ } catch {565+ issues.push(566+ issue("dist/lofi-precache.json: missing or malformed precache manifest", remediation),567+ );568+ }569+ const expectedPrecache = expectedPrecacheUrls(files);570+ if (571+ !Array.isArray(precache) || !precache.every((entry) => typeof entry === "string") ||572+ JSON.stringify([...precache].sort()) !== JSON.stringify(expectedPrecache)573+ ) {574+ issues.push(575+ issue("dist/lofi-precache.json: entries do not match the emitted shell", remediation),576+ );577+ } else {578+ for (const entry of precache) {579+ let url: URL | undefined;580+ try {581+ url = new URL(entry, `${syntheticOrigin}${basePath}`);582+ } catch {583+ // The scope failure below also covers malformed URLs.584+ }585+ if (!url || !withinScope(url, new URL(`${syntheticOrigin}${basePath}`))) {586+ issues.push(587+ issue(`dist/lofi-precache.json: ${entry} escapes deployment scope`, remediation),588+ );589+ }590+ }591+ }592+593+ let worker = "";594+ try {595+ worker = await Deno.readTextFile(join(dist, "sw.js"));596+ } catch {597+ issues.push(issue("dist/sw.js: service worker is missing", remediation));598+ }599+ if (worker) {600+ if (601+ worker.includes("__LOFI_BUILD_REVISION__") ||602+ !worker.includes(String(identity?.sourceHash ?? ""))603+ ) {604+ issues.push(issue("dist/sw.js: build revision does not match lofi-build.json", remediation));605+ }606+ if (!worker.includes('new URL("./lofi-precache.json", self.registration.scope)')) {607+ issues.push(issue("dist/sw.js: precache URL is not relative to worker scope", remediation));608+ }609+ }610+ return issues;611+}
A package/tooling/pwa-validation_test.ts +254 -0
@@ -0,0 +1,254 @@
1+import { join } from "node:path";2+import { createProject } from "../create_core.ts";3+import {4+ expectedPrecacheUrls,5+ productionContentType,6+ productionPwaIssues,7+ sourcePwaIssues,8+} from "./pwa-validation.ts";9+10+function assert(condition: unknown, message: string): asserts condition {11+ if (!condition) throw new Error(message);12+}13+14+async function makeProject(): Promise<{ root: string; project: string }> {15+ const root = await Deno.makeTempDir({ dir: ".", prefix: ".lofi-pwa-validation-test-" });16+ const result = await createProject({ cwd: root, name: "app" });17+ return { root, project: result.destination };18+}19+20+async function readManifest(project: string): Promise<Record<string, unknown>> {21+ return JSON.parse(await Deno.readTextFile(join(project, "public", "manifest.webmanifest")));22+}23+24+async function writeManifest(project: string, manifest: Record<string, unknown>): Promise<void> {25+ await Deno.writeTextFile(26+ join(project, "public", "manifest.webmanifest"),27+ `${JSON.stringify(manifest, null, 2)}\n`,28+ );29+}30+31+function hasIssue(issues: readonly { detail: string }[], text: string): boolean {32+ return issues.some((entry) => entry.detail.includes(text));33+}34+35+Deno.test("source PWA validation accepts the generated root and subpath contracts", async () => {36+ const { root, project } = await makeProject();37+ try {38+ assert((await sourcePwaIssues(project)).length === 0, "root manifest was rejected");39+ assert(40+ (await sourcePwaIssues(project, "/field-notes/")).length === 0,41+ "subpath manifest was rejected",42+ );43+ } finally {44+ await Deno.remove(root, { recursive: true });45+ }46+});47+48+Deno.test("source PWA validation names malformed identity and scope failures", async () => {49+ const { root, project } = await makeProject();50+ const path = join(project, "public", "manifest.webmanifest");51+ try {52+ await Deno.writeTextFile(path, "{\n");53+ const malformed = await sourcePwaIssues(project);54+ assert(55+ hasIssue(malformed, "public/manifest.webmanifest: malformed JSON"),56+ "JSON failure was vague",57+ );58+59+ const manifest = await readManifest(join(import.meta.dirname!, "../../apps/reference"));60+ delete manifest.id;61+ manifest.start_url = "../outside";62+ manifest.scope = "./";63+ await writeManifest(project, manifest);64+ const invalid = await sourcePwaIssues(project, "/field-notes/");65+ assert(hasIssue(invalid, "id must be a non-empty string"), "missing id was accepted");66+ assert(hasIssue(invalid, "start_url must stay inside scope"), "escaped start_url was accepted");67+ } finally {68+ await Deno.remove(root, { recursive: true });69+ }70+});71+72+Deno.test("build preflight rejects a malformed manifest before Astro runs", async () => {73+ const root = await Deno.makeTempDir({ dir: ".", prefix: ".lofi-pwa-build-test-" });74+ try {75+ const result = await createProject({76+ cwd: root,77+ name: "app",78+ packagePrefix: new URL("../", import.meta.url).href,79+ });80+ await Deno.writeTextFile(join(result.destination, "public", "manifest.webmanifest"), "{\n");81+ const command = await new Deno.Command(Deno.execPath(), {82+ args: ["task", "build"],83+ cwd: result.destination,84+ stdout: "piped",85+ stderr: "piped",86+ }).output();87+ const output = `${new TextDecoder().decode(command.stdout)}\n${88+ new TextDecoder().decode(command.stderr)89+ }`;90+ assert(!command.success, "build accepted a malformed manifest");91+ assert(92+ output.includes("public/manifest.webmanifest: malformed JSON"),93+ "build failure was vague",94+ );95+ assert(96+ !output.includes("Building static entrypoints"),97+ "build reached Astro after failed preflight",98+ );99+ } finally {100+ await Deno.remove(root, { recursive: true });101+ }102+});103+104+Deno.test("source PWA validation detects missing and incorrectly sized icons", async () => {105+ const { root, project } = await makeProject();106+ try {107+ await Deno.copyFile(108+ join(project, "public", "apple-touch-icon.png"),109+ join(project, "public", "icon-192.png"),110+ );111+ await Deno.remove(join(project, "public", "icon-monochrome.svg"));112+ const issues = await sourcePwaIssues(project);113+ assert(hasIssue(issues, "icon-192.png (180x180)"), "incorrect raster dimensions were accepted");114+ assert(hasIssue(issues, "missing asset icon-monochrome.svg"), "missing icon was accepted");115+ assert(hasIssue(issues, "scalable monochrome image"), "missing icon role was not reported");116+ } finally {117+ await Deno.remove(root, { recursive: true });118+ }119+});120+121+Deno.test("source PWA validation checks optional shortcuts and screenshots", async () => {122+ const { root, project } = await makeProject();123+ try {124+ const manifest = await readManifest(project);125+ manifest.shortcuts = [{ name: "Escape", url: "../outside" }];126+ manifest.screenshots = [{127+ src: "./missing-shot.png",128+ sizes: "1200x800",129+ type: "image/png",130+ }];131+ await writeManifest(project, manifest);132+ const issues = await sourcePwaIssues(project, "/field-notes/");133+ assert(hasIssue(issues, "shortcuts[0].url must stay inside"), "escaped shortcut was accepted");134+ assert(hasIssue(issues, "missing asset missing-shot.png"), "missing screenshot was accepted");135+ } finally {136+ await Deno.remove(root, { recursive: true });137+ }138+});139+140+Deno.test("source PWA validation permits replacement branding and optional members", async () => {141+ const { root, project } = await makeProject();142+ try {143+ for (144+ const [before, after] of [145+ ["icon-192.png", "acme-192.png"],146+ ["icon-512.png", "acme-512.png"],147+ ["icon-maskable-512.png", "acme-maskable.png"],148+ ["icon-monochrome.svg", "acme-symbol.svg"],149+ ]150+ ) {151+ await Deno.rename(join(project, "public", before), join(project, "public", after));152+ }153+ const manifest = await readManifest(project);154+ manifest.name = "Acme Field Notes";155+ manifest.categories = ["productivity"];156+ manifest.acme_optional_member = { enabled: true };157+ const icons = manifest.icons as Array<Record<string, unknown>>;158+ for (const icon of icons) {159+ icon.src = String(icon.src)160+ .replace("icon-192.png", "acme-192.png")161+ .replace("icon-512.png", "acme-512.png")162+ .replace("icon-maskable-512.png", "acme-maskable.png")163+ .replace("icon-monochrome.svg", "acme-symbol.svg");164+ }165+ const monochrome = icons.find((icon) => icon.purpose === "monochrome");166+ assert(monochrome, "starter fixture lost its monochrome icon");167+ const shortcuts = manifest.shortcuts as Array<{ icons: Array<Record<string, unknown>> }>;168+ shortcuts[0].icons[0].src = "./acme-192.png";169+ await writeManifest(project, manifest);170+ const issues = await sourcePwaIssues(project);171+ assert(issues.length === 0, `replacement branding was rejected: ${JSON.stringify(issues)}`);172+ } finally {173+ await Deno.remove(root, { recursive: true });174+ }175+});176+177+async function makeProductionFixture(): Promise<string> {178+ const root = await Deno.makeTempDir({ dir: ".", prefix: ".lofi-pwa-production-test-" });179+ const dist = join(root, "dist");180+ await Deno.mkdir(join(dist, "settings"), { recursive: true });181+ const publicRoot = join(import.meta.dirname!, "../../apps/reference/public");182+ const files: string[] = [];183+ for await (const entry of Deno.readDir(publicRoot)) {184+ if (!entry.isFile) continue;185+ await Deno.copyFile(join(publicRoot, entry.name), join(dist, entry.name));186+ files.push(entry.name);187+ }188+ const html =189+ `<!doctype html><link rel="manifest" href="/field-notes/manifest.webmanifest"><link rel="apple-touch-icon" href="/field-notes/apple-touch-icon.png">`;190+ await Deno.writeTextFile(join(dist, "index.html"), html);191+ await Deno.writeTextFile(join(dist, "settings", "index.html"), html);192+ await Deno.writeTextFile(193+ join(dist, "lofi-build.json"),194+ `${JSON.stringify({ sourceHash: "fixture-hash", basePath: "/field-notes/" })}\n`,195+ );196+ await Deno.writeTextFile(197+ join(dist, "sw.js"),198+ `const revision = "fixture-hash"; new URL("./lofi-precache.json", self.registration.scope);\n`,199+ );200+ files.push("index.html", "settings/index.html", "lofi-build.json", "sw.js");201+ await Deno.writeTextFile(202+ join(dist, "lofi-precache.json"),203+ `${JSON.stringify(expectedPrecacheUrls(files))}\n`,204+ );205+ return root;206+}207+208+Deno.test("production PWA validation accepts one base-aware artifact set", async () => {209+ const root = await makeProductionFixture();210+ try {211+ const issues = await productionPwaIssues(root, "/field-notes/");212+ assert(issues.length === 0, `valid production fixture was rejected: ${JSON.stringify(issues)}`);213+ } finally {214+ await Deno.remove(root, { recursive: true });215+ }216+});217+218+Deno.test("production PWA validation catches nested links, precache drift, and worker drift", async () => {219+ const root = await makeProductionFixture();220+ try {221+ const dist = join(root, "dist");222+ await Deno.writeTextFile(223+ join(dist, "settings", "index.html"),224+ `<!doctype html><link rel="manifest" href="/manifest.webmanifest"><link rel="apple-touch-icon" href="/field-notes/apple-touch-icon.png">`,225+ );226+ await Deno.writeTextFile(join(dist, "lofi-precache.json"), `["./"]\n`);227+ await Deno.writeTextFile(228+ join(dist, "sw.js"),229+ `new URL("./lofi-precache.json", self.registration.scope);\n`,230+ );231+ const issues = await productionPwaIssues(root, "/field-notes/");232+ assert(hasIssue(issues, "settings/index.html: must link"), "nested manifest link drift passed");233+ assert(hasIssue(issues, "entries do not match"), "precache drift passed");234+ assert(hasIssue(issues, "build revision does not match"), "worker revision drift passed");235+ } finally {236+ await Deno.remove(root, { recursive: true });237+ }238+});239+240+Deno.test("production preview maps install-critical content types", () => {241+ assert(242+ productionContentType("manifest.webmanifest") === "application/manifest+json",243+ "manifest MIME drifted",244+ );245+ assert(246+ productionContentType("sw.js") === "text/javascript; charset=utf-8",247+ "worker MIME drifted",248+ );249+ assert(250+ productionContentType("lofi-precache.json") === "application/json; charset=utf-8",251+ "precache MIME drifted",252+ );253+ assert(productionContentType("icon.webp") === "image/webp", "WebP MIME drifted");254+});