clone

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

main
lofi / package / tooling / pwa-validation.ts
pwa-validation.ts 44.1K
1import { extname, join, relative } from "node:path";2import { normalizeDeploymentBase } from "./base-path.ts";3import {4 isBoundedManifestString,5 relatedApplicationPlatforms,6} from "../recipes/related-app-discovery.ts";78export type PwaValidationIssue = {9 detail: string;10 remediation: string;11};1213type JsonObject = Record<string, unknown>;14type ImageResource = {15 src: string;16 sizes?: string;17 type?: string;18 purpose?: string;19};2021const manifestFile = "manifest.webmanifest";22const syntheticOrigin = "https://lofi.invalid";23const displayModes = new Set(["browser", "fullscreen", "minimal-ui", "standalone"]);24const displayOverrideModes = new Set([25 "browser",26 "fullscreen",27 "minimal-ui",28 "standalone",29 "window-controls-overlay",30]);31const launchClientModes = new Set([32 "auto",33 "focus-existing",34 "navigate-existing",35 "navigate-new",36]);37// `relatedApplicationPlatforms` and `isBoundedManifestString` come from the38// runtime recipe so validation can never accept a declaration the recipe39// rejects (the two grammars had already diverged once).40const imageTypes = new Map([41 ["image/jpeg", new Set([".jpeg", ".jpg"])],42 ["image/png", new Set([".png"])],43 ["image/svg+xml", new Set([".svg"])],44 ["image/webp", new Set([".webp"])],45]);46const protocolPlaceholderValue = "web+lofi:placeholder";4748function protocolHandlerUrl(value: string, manifestUrl: URL): URL {49 return new URL(value.replace("%s", encodeURIComponent(protocolPlaceholderValue)), manifestUrl);50}5152function issue(detail: string, remediation: string): PwaValidationIssue {53 return { detail, remediation };54}5556function isObject(value: unknown): value is JsonObject {57 return value !== null && typeof value === "object" && !Array.isArray(value);58}5960function nonEmptyString(value: unknown): value is string {61 return typeof value === "string" && value.trim().length > 0;62}6364function isLikelyCssColor(value: unknown): boolean {65 if (!nonEmptyString(value) || /[;{}]/.test(value)) return false;66 const color = value.trim();67 return /^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(color) ||68 /^[a-z]+$/i.test(color) ||69 /^(?:color|hsl|hsla|hwb|lab|lch|oklab|oklch|rgb|rgba)\([^\n]+\)$/i.test(color);70}7172function withinScope(target: URL, scope: URL): boolean {73 return target.origin === scope.origin && target.pathname.startsWith(scope.pathname);74}7576// One shared rule for every manifest action/URL member that must resolve to a77// clean route inside manifest scope (file handlers, share targets, ...).78function validateScopedActionUrl(79 issues: PwaValidationIssue[],80 label: string,81 action: unknown,82 manifestUrl: URL,83 scope: URL,84 remediation: string,85): void {86 if (!nonEmptyString(action)) {87 issues.push(issue(`${label} must be a non-empty URL`, remediation));88 return;89 }90 try {91 const url = new URL(action, manifestUrl);92 if (!withinScope(url, scope)) {93 issues.push(issue(`${label} must stay inside manifest scope`, remediation));94 }95 if (url.search || url.hash) {96 issues.push(issue(`${label} must not contain a query or fragment`, remediation));97 }98 } catch {99 issues.push(issue(`${label} is not a valid URL`, remediation));100 }101}102103// One shared rule for every manifest entry point that must cold-start offline.104function requireEmittedRoute(105 issues: PwaValidationIssue[],106 htmlFiles: readonly string[],107 basePath: string,108 label: string,109 remediation: string,110 resolveUrl: () => URL,111): void {112 try {113 const route = emittedRoutePath(resolveUrl(), basePath);114 if (!route || !htmlFiles.includes(route)) {115 issues.push(issue(`${label} has no emitted offline route`, remediation));116 }117 } catch {118 // Source validation already reports the malformed URL.119 }120}121122function assetPath(url: URL, basePath: string): string | undefined {123 if (url.origin !== syntheticOrigin || !url.pathname.startsWith(basePath)) return undefined;124 let path: string;125 try {126 path = decodeURIComponent(url.pathname.slice(basePath.length));127 } catch {128 return undefined;129 }130 // Percent-encoded backslashes survive URL parsing and act as separators131 // under Windows path semantics, so they are rejected outright rather than132 // treated as ordinary characters a `/`-split traversal check would miss.133 if (!path || path.startsWith("/") || path.includes("\\")) return undefined;134 if (path.split("/").some((part) => part === "." || part === "..")) return undefined;135 return path;136}137138function pngDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {139 // A truncated file with a valid signature/IHDR prefix must report "not140 // decodable" like any other malformed image, not throw a RangeError.141 if (bytes.length < 24) return undefined;142 const signature = [137, 80, 78, 71, 13, 10, 26, 10];143 if (!signature.every((byte, index) => bytes[index] === byte)) return undefined;144 if (new TextDecoder().decode(bytes.subarray(12, 16)) !== "IHDR") return undefined;145 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);146 return { width: view.getUint32(16), height: view.getUint32(20) };147}148149function jpegDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {150 if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return undefined;151 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);152 let offset = 2;153 while (offset + 8 < bytes.length) {154 if (bytes[offset] !== 0xff) return undefined;155 const marker = bytes[offset + 1];156 offset += 2;157 if (marker === 0xd8 || marker === 0xd9) continue;158 const length = view.getUint16(offset);159 if (length < 2 || offset + length > bytes.length) return undefined;160 if (161 (marker >= 0xc0 && marker <= 0xc3) ||162 (marker >= 0xc5 && marker <= 0xc7) ||163 (marker >= 0xc9 && marker <= 0xcb) ||164 (marker >= 0xcd && marker <= 0xcf)165 ) {166 return { width: view.getUint16(offset + 5), height: view.getUint16(offset + 3) };167 }168 offset += length;169 }170 return undefined;171}172173function webpDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {174 const ascii = (start: number, end: number) =>175 new TextDecoder().decode(bytes.subarray(start, end));176 if (ascii(0, 4) !== "RIFF" || ascii(8, 12) !== "WEBP") return undefined;177 const kind = ascii(12, 16);178 if (kind === "VP8X" && bytes.length >= 30) {179 const width = 1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16);180 const height = 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16);181 return { width, height };182 }183 if (184 kind === "VP8 " && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 &&185 bytes[25] === 0x2a186 ) {187 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);188 return { width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff };189 }190 if (kind === "VP8L" && bytes.length >= 25 && bytes[20] === 0x2f) {191 const bits = bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24);192 return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };193 }194 return undefined;195}196197async function imageDimensions(198 path: string,199 type: string,200): Promise<{ width: number; height: number } | undefined> {201 const bytes = await Deno.readFile(path);202 if (type === "image/png") return pngDimensions(bytes);203 if (type === "image/jpeg") return jpegDimensions(bytes);204 if (type === "image/webp") return webpDimensions(bytes);205 if (type === "image/svg+xml") {206 const source = new TextDecoder().decode(bytes);207 if (!/<svg\b/i.test(source)) return undefined;208 const viewBox = source.match(209 /\bviewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)\s*["']/i,210 );211 if (viewBox) return { width: Number(viewBox[1]), height: Number(viewBox[2]) };212 const width = source.match(/\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/i);213 const height = source.match(/\bheight\s*=\s*["']([\d.]+)(?:px)?["']/i);214 if (width && height) return { width: Number(width[1]), height: Number(height[1]) };215 }216 return undefined;217}218219function declaredSizes(220 value: unknown,221): Array<{ width: number; height: number }> | "any" | undefined {222 if (!nonEmptyString(value)) return undefined;223 if (value.trim() === "any") return "any";224 const sizes: Array<{ width: number; height: number }> = [];225 for (const token of value.trim().split(/\s+/)) {226 const match = token.match(/^(\d+)x(\d+)$/i);227 if (!match) return undefined;228 sizes.push({ width: Number(match[1]), height: Number(match[2]) });229 }230 return sizes;231}232233async function validateImages(234 value: unknown,235 member: string,236 assetRoot: string,237 manifestUrl: URL,238 scope: URL,239 basePath: string,240 remediation: string,241): Promise<{ issues: PwaValidationIssue[]; images: ImageResource[] }> {242 const issues: PwaValidationIssue[] = [];243 const images: ImageResource[] = [];244 if (!Array.isArray(value) || value.length === 0) {245 return {246 issues: [issue(`${manifestFile}: ${member} must be a non-empty array`, remediation)],247 images,248 };249 }250 for (const [index, raw] of value.entries()) {251 const label = `${manifestFile}: ${member}[${index}]`;252 if (!isObject(raw) || !nonEmptyString(raw.src)) {253 issues.push(issue(`${label}.src must be a non-empty URL`, remediation));254 continue;255 }256 let url: URL;257 try {258 url = new URL(raw.src, manifestUrl);259 } catch {260 issues.push(issue(`${label}.src is not a valid URL`, remediation));261 continue;262 }263 const path = assetPath(url, basePath);264 if (!path || !withinScope(url, scope)) {265 issues.push(issue(`${label}.src must stay inside deployment scope ${basePath}`, remediation));266 continue;267 }268 if (!nonEmptyString(raw.type) || !imageTypes.has(raw.type)) {269 issues.push(issue(`${label}.type must name a supported image MIME type`, remediation));270 continue;271 }272 const extensions = imageTypes.get(raw.type)!;273 if (!extensions.has(extname(path).toLowerCase())) {274 issues.push(issue(`${label}.type does not match ${path}`, remediation));275 continue;276 }277 const sizes = declaredSizes(raw.sizes);278 if (!sizes) {279 issues.push(issue(`${label}.sizes must be "any" or WIDTHxHEIGHT tokens`, remediation));280 continue;281 }282 const fullPath = join(assetRoot, path);283 try {284 if (!(await Deno.stat(fullPath)).isFile) throw new Deno.errors.NotFound();285 } catch (error) {286 if (error instanceof Deno.errors.NotFound) {287 issues.push(issue(`${label}.src references missing asset ${path}`, remediation));288 continue;289 }290 throw error;291 }292 const dimensions = await imageDimensions(fullPath, raw.type);293 if (!dimensions) {294 issues.push(issue(`${label}.src is not decodable as ${raw.type}`, remediation));295 continue;296 }297 if (298 sizes !== "any" &&299 !sizes.some((size) => size.width === dimensions.width && size.height === dimensions.height)300 ) {301 issues.push(302 issue(303 `${label}.sizes does not match ${path} (${dimensions.width}x${dimensions.height})`,304 remediation,305 ),306 );307 continue;308 }309 images.push({310 src: raw.src,311 sizes: raw.sizes as string,312 type: raw.type,313 purpose: nonEmptyString(raw.purpose) ? raw.purpose : undefined,314 });315 }316 return { issues, images };317}318319async function parseAndValidateManifest(320 root: string,321 basePath: string,322 location: "public" | "dist",323): Promise<{ issues: PwaValidationIssue[]; manifest?: JsonObject; scope?: URL }> {324 const path = join(root, location, manifestFile);325 const remediation = location === "public"326 ? "edit public/manifest.webmanifest and its referenced assets, then rerun `deno task doctor`"327 : "fix the author-owned PWA source, then rerun `deno task build`";328 let manifest: unknown;329 try {330 manifest = JSON.parse(await Deno.readTextFile(path));331 } catch (error) {332 if (error instanceof Deno.errors.NotFound) {333 return { issues: [issue(`${location}/${manifestFile}: file is missing`, remediation)] };334 }335 if (error instanceof SyntaxError) {336 return { issues: [issue(`${location}/${manifestFile}: malformed JSON`, remediation)] };337 }338 throw error;339 }340 if (!isObject(manifest)) {341 return {342 issues: [issue(`${location}/${manifestFile}: root must be a JSON object`, remediation)],343 };344 }345346 const issues: PwaValidationIssue[] = [];347 for (const member of ["id", "name", "short_name", "start_url", "scope"] as const) {348 if (!nonEmptyString(manifest[member])) {349 issues.push(issue(`${manifestFile}: ${member} must be a non-empty string`, remediation));350 }351 }352 if (!displayModes.has(String(manifest.display))) {353 issues.push(354 issue(355 `${manifestFile}: display must be browser, fullscreen, minimal-ui, or standalone`,356 remediation,357 ),358 );359 }360 if (manifest.display_override !== undefined) {361 if (!Array.isArray(manifest.display_override) || manifest.display_override.length === 0) {362 issues.push(363 issue(`${manifestFile}: display_override must be a non-empty array`, remediation),364 );365 } else {366 if (367 !manifest.display_override.every((mode) =>368 typeof mode === "string" && displayOverrideModes.has(mode)369 )370 ) {371 issues.push(372 issue(373 `${manifestFile}: display_override contains a mode without a tested lofi recipe`,374 remediation,375 ),376 );377 }378 if (new Set(manifest.display_override).size !== manifest.display_override.length) {379 issues.push(issue(`${manifestFile}: display_override must not repeat modes`, remediation));380 }381 }382 }383 if (manifest.prefer_related_applications !== undefined) {384 if (typeof manifest.prefer_related_applications !== "boolean") {385 issues.push(386 issue(`${manifestFile}: prefer_related_applications must be boolean`, remediation),387 );388 } else if (manifest.prefer_related_applications) {389 issues.push(390 issue(391 `${manifestFile}: prefer_related_applications must stay false to preserve PWA installability`,392 remediation,393 ),394 );395 }396 }397398 if (manifest.related_applications !== undefined) {399 if (400 !Array.isArray(manifest.related_applications) || manifest.related_applications.length === 0401 ) {402 issues.push(403 issue(`${manifestFile}: related_applications must be a non-empty array`, remediation),404 );405 } else {406 const declarations = new Set<string>();407 for (const [index, raw] of manifest.related_applications.entries()) {408 const label = `${manifestFile}: related_applications[${index}]`;409 if (!isObject(raw)) {410 issues.push(issue(`${label} must be an object`, remediation));411 continue;412 }413 const unknown = Object.keys(raw).filter((member) =>414 !["platform", "id", "url"].includes(member)415 );416 if (unknown.length > 0) {417 issues.push(issue(`${label} contains unsupported members`, remediation));418 }419 if (!nonEmptyString(raw.platform) || !relatedApplicationPlatforms.has(raw.platform)) {420 issues.push(issue(`${label}.platform is not supported by this recipe`, remediation));421 }422 const hasId = isBoundedManifestString(raw.id);423 const hasUrl = isBoundedManifestString(raw.url);424 if (!hasId && !hasUrl) {425 issues.push(issue(`${label} needs a bounded id or HTTPS url`, remediation));426 }427 let normalizedUrl = "";428 if (raw.url !== undefined) {429 if (!hasUrl) {430 issues.push(issue(`${label}.url must be a bounded non-empty string`, remediation));431 } else {432 try {433 const url = new URL(raw.url as string);434 if (url.protocol !== "https:" || url.username || url.password) {435 issues.push(issue(`${label}.url must be credential-free HTTPS`, remediation));436 } else normalizedUrl = url.href;437 } catch {438 issues.push(issue(`${label}.url is not a valid URL`, remediation));439 }440 }441 }442 if (raw.id !== undefined && !hasId) {443 issues.push(issue(`${label}.id must be a bounded non-empty string`, remediation));444 }445 const key = JSON.stringify([raw.platform, hasId ? raw.id : "", normalizedUrl]);446 if (declarations.has(key)) issues.push(issue(`${label} must not repeat`, remediation));447 declarations.add(key);448 }449 }450 }451 if (manifest.scope_extensions !== undefined) {452 if (!Array.isArray(manifest.scope_extensions) || manifest.scope_extensions.length === 0) {453 issues.push(454 issue(`${manifestFile}: scope_extensions must be a non-empty array`, remediation),455 );456 } else {457 const origins = new Set<string>();458 for (const [index, raw] of manifest.scope_extensions.entries()) {459 const label = `${manifestFile}: scope_extensions[${index}]`;460 if (!isObject(raw)) {461 issues.push(issue(`${label} must be an object`, remediation));462 continue;463 }464 if (Object.keys(raw).some((member) => !["type", "origin"].includes(member))) {465 issues.push(issue(`${label} contains unsupported members`, remediation));466 }467 if (raw.type !== "origin") {468 issues.push(issue(`${label}.type must be origin`, remediation));469 }470 if (!nonEmptyString(raw.origin)) {471 issues.push(issue(`${label}.origin must be an HTTPS origin`, remediation));472 continue;473 }474 try {475 const origin = new URL(raw.origin);476 if (477 origin.protocol !== "https:" || origin.username || origin.password ||478 origin.pathname !== "/" || origin.search || origin.hash || origin.origin !== raw.origin479 ) {480 issues.push(481 issue(`${label}.origin must be an exact credential-free HTTPS origin`, remediation),482 );483 }484 if (origins.has(origin.origin)) {485 issues.push(issue(`${label}.origin must not repeat`, remediation));486 }487 origins.add(origin.origin);488 } catch {489 issues.push(issue(`${label}.origin is not a valid URL`, remediation));490 }491 }492 }493 }494 for (const member of ["background_color", "theme_color"] as const) {495 if (!isLikelyCssColor(manifest[member])) {496 issues.push(issue(`${manifestFile}: ${member} must be a CSS color`, remediation));497 }498 }499500 if (manifest.launch_handler !== undefined) {501 const label = `${manifestFile}: launch_handler`;502 const handler = manifest.launch_handler;503 if (!isObject(handler)) {504 issues.push(issue(`${label} must be an object`, remediation));505 } else {506 const unknown = Object.keys(handler).filter((member) => member !== "client_mode");507 if (unknown.length > 0) {508 issues.push(issue(`${label} contains unsupported members`, remediation));509 }510 const modes = typeof handler.client_mode === "string"511 ? [handler.client_mode]512 : Array.isArray(handler.client_mode)513 ? handler.client_mode514 : undefined;515 if (!modes || modes.length === 0) {516 issues.push(517 issue(`${label}.client_mode must be a mode or non-empty mode array`, remediation),518 );519 } else {520 if (!modes.every((mode) => typeof mode === "string" && launchClientModes.has(mode))) {521 issues.push(522 issue(523 `${label}.client_mode supports auto, focus-existing, navigate-existing, or navigate-new`,524 remediation,525 ),526 );527 }528 if (new Set(modes).size !== modes.length) {529 issues.push(issue(`${label}.client_mode must not repeat modes`, remediation));530 }531 }532 }533 }534535 const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);536 let startUrl: URL | undefined;537 let scope: URL | undefined;538 let identity: URL | undefined;539 for (540 const [member, value] of [541 ["id", manifest.id],542 ["start_url", manifest.start_url],543 ["scope", manifest.scope],544 ] as const545 ) {546 if (!nonEmptyString(value)) continue;547 try {548 const url = new URL(value, manifestUrl);549 if (url.origin !== syntheticOrigin) {550 issues.push(issue(`${manifestFile}: ${member} must be same-origin`, remediation));551 }552 if (member === "id") identity = url;553 else if (member === "start_url") startUrl = url;554 else scope = url;555 } catch {556 issues.push(issue(`${manifestFile}: ${member} is not a valid URL`, remediation));557 }558 }559 if (identity?.hash) {560 issues.push(issue(`${manifestFile}: id must not contain a fragment`, remediation));561 }562 if (scope && (scope.pathname !== basePath || scope.search || scope.hash)) {563 issues.push(564 issue(`${manifestFile}: scope must resolve to deployment base ${basePath}`, remediation),565 );566 }567 if (startUrl && scope && !withinScope(startUrl, scope)) {568 issues.push(issue(`${manifestFile}: start_url must stay inside scope`, remediation));569 }570571 if (scope) {572 const icons = await validateImages(573 manifest.icons,574 "icons",575 join(root, location),576 manifestUrl,577 scope,578 basePath,579 remediation,580 );581 issues.push(...icons.issues);582 const hasPurposeSize = (purpose: string, size: number | "any") =>583 icons.images.some((image) => {584 const purposes = (image.purpose ?? "any").split(/\s+/);585 const sizes = declaredSizes(image.sizes);586 return purposes.includes(purpose) &&587 (size === "any" ? sizes === "any" : sizes === "any" ||588 sizes?.some((entry) => entry.width === size && entry.height === size));589 });590 for (const size of [192, 512]) {591 if (!hasPurposeSize("any", size)) {592 issues.push(593 issue(`${manifestFile}: icons need an any-purpose ${size}x${size} image`, remediation),594 );595 }596 }597 if (!hasPurposeSize("maskable", 512)) {598 issues.push(issue(`${manifestFile}: icons need a maskable 512x512 image`, remediation));599 }600 if (!hasPurposeSize("monochrome", "any")) {601 issues.push(issue(`${manifestFile}: icons need a scalable monochrome image`, remediation));602 }603604 if (manifest.shortcuts !== undefined) {605 if (!Array.isArray(manifest.shortcuts)) {606 issues.push(issue(`${manifestFile}: shortcuts must be an array`, remediation));607 } else {608 for (const [index, raw] of manifest.shortcuts.entries()) {609 const label = `${manifestFile}: shortcuts[${index}]`;610 if (!isObject(raw) || !nonEmptyString(raw.name) || !nonEmptyString(raw.url)) {611 issues.push(issue(`${label} needs non-empty name and url strings`, remediation));612 continue;613 }614 try {615 const url = new URL(raw.url, manifestUrl);616 if (!withinScope(url, scope)) {617 issues.push(issue(`${label}.url must stay inside manifest scope`, remediation));618 }619 } catch {620 issues.push(issue(`${label}.url is not a valid URL`, remediation));621 }622 if (raw.icons !== undefined) {623 const shortcutIcons = await validateImages(624 raw.icons,625 `shortcuts[${index}].icons`,626 join(root, location),627 manifestUrl,628 scope,629 basePath,630 remediation,631 );632 issues.push(...shortcutIcons.issues);633 }634 }635 }636 }637638 if (manifest.screenshots !== undefined) {639 const screenshots = await validateImages(640 manifest.screenshots,641 "screenshots",642 join(root, location),643 manifestUrl,644 scope,645 basePath,646 remediation,647 );648 issues.push(...screenshots.issues);649 const validSources = new Set(screenshots.images.map((image) => image.src));650 const formFactors = new Set<string>();651 if (Array.isArray(manifest.screenshots)) {652 for (const [index, raw] of manifest.screenshots.entries()) {653 if (!isObject(raw)) continue;654 const label = `${manifestFile}: screenshots[${index}]`;655 if (!nonEmptyString(raw.label)) {656 issues.push(issue(`${label}.label must describe the screenshot`, remediation));657 }658 if (raw.form_factor !== "narrow" && raw.form_factor !== "wide") {659 issues.push(660 issue(`${label}.form_factor must be narrow or wide`, remediation),661 );662 } else if (nonEmptyString(raw.src) && validSources.has(raw.src)) {663 formFactors.add(raw.form_factor);664 }665 }666 }667 for (const formFactor of ["narrow", "wide"]) {668 if (!formFactors.has(formFactor)) {669 issues.push(670 issue(671 `${manifestFile}: screenshots need a valid labeled ${formFactor} image`,672 remediation,673 ),674 );675 }676 }677 }678679 if (manifest.file_handlers !== undefined) {680 if (!Array.isArray(manifest.file_handlers) || manifest.file_handlers.length === 0) {681 issues.push(issue(`${manifestFile}: file_handlers must be a non-empty array`, remediation));682 } else {683 for (const [index, raw] of manifest.file_handlers.entries()) {684 const label = `${manifestFile}: file_handlers[${index}]`;685 if (!isObject(raw)) {686 issues.push(issue(`${label} must be an object`, remediation));687 continue;688 }689 const unknown = Object.keys(raw).filter((member) =>690 !["action", "accept", "icons"].includes(member)691 );692 if (unknown.length > 0) {693 issues.push(issue(`${label} contains unsupported members`, remediation));694 }695 validateScopedActionUrl(696 issues,697 `${label}.action`,698 raw.action,699 manifestUrl,700 scope,701 remediation,702 );703 if (!isObject(raw.accept) || Object.keys(raw.accept).length === 0) {704 issues.push(issue(`${label}.accept must be a non-empty object`, remediation));705 } else {706 for (const [type, extensions] of Object.entries(raw.accept)) {707 if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(type)) {708 issues.push(709 issue(`${label}.accept keys must be explicit lowercase MIME types`, remediation),710 );711 }712 if (713 !Array.isArray(extensions) || extensions.length === 0 ||714 !extensions.every((extension) =>715 typeof extension === "string" && /^\.[a-z0-9][a-z0-9.+_-]*$/.test(extension)716 )717 ) {718 issues.push(719 issue(720 `${label}.accept values must be non-empty lowercase dot-extension arrays`,721 remediation,722 ),723 );724 continue;725 }726 if (new Set(extensions).size !== extensions.length) {727 issues.push(728 issue(729 `${label}.accept extensions must not repeat within a MIME type`,730 remediation,731 ),732 );733 }734 }735 }736 if (raw.icons !== undefined) {737 const handlerIcons = await validateImages(738 raw.icons,739 `file_handlers[${index}].icons`,740 join(root, location),741 manifestUrl,742 scope,743 basePath,744 remediation,745 );746 issues.push(...handlerIcons.issues);747 }748 }749 }750 }751752 if (manifest.protocol_handlers !== undefined) {753 if (!Array.isArray(manifest.protocol_handlers) || manifest.protocol_handlers.length === 0) {754 issues.push(755 issue(`${manifestFile}: protocol_handlers must be a non-empty array`, remediation),756 );757 } else {758 const protocols = new Set<string>();759 for (const [index, raw] of manifest.protocol_handlers.entries()) {760 const label = `${manifestFile}: protocol_handlers[${index}]`;761 if (!isObject(raw)) {762 issues.push(issue(`${label} must be an object`, remediation));763 continue;764 }765 const unknown = Object.keys(raw).filter((member) =>766 !["protocol", "url"].includes(member)767 );768 if (unknown.length > 0) {769 issues.push(issue(`${label} contains unsupported members`, remediation));770 }771 if (!nonEmptyString(raw.protocol) || !/^web\+[a-z]+$/.test(raw.protocol)) {772 issues.push(773 issue(`${label}.protocol must be a custom lowercase web+ scheme`, remediation),774 );775 } else if (protocols.has(raw.protocol)) {776 issues.push(issue(`${label}.protocol must not repeat`, remediation));777 } else {778 protocols.add(raw.protocol);779 }780 if (!nonEmptyString(raw.url)) {781 issues.push(issue(`${label}.url must be a non-empty URL template`, remediation));782 continue;783 }784 if ((raw.url.match(/%s/g) ?? []).length !== 1) {785 issues.push(issue(`${label}.url must contain exactly one %s placeholder`, remediation));786 continue;787 }788 try {789 const url = protocolHandlerUrl(raw.url, manifestUrl);790 if (!withinScope(url, scope)) {791 issues.push(issue(`${label}.url must stay inside manifest scope`, remediation));792 }793 if (url.hash) {794 issues.push(issue(`${label}.url must not contain a fragment`, remediation));795 }796 const values = [...url.searchParams.values()];797 if (values.length !== 1 || values[0] !== protocolPlaceholderValue) {798 issues.push(799 issue(800 `${label}.url must put %s as the complete value of one query parameter`,801 remediation,802 ),803 );804 }805 } catch {806 issues.push(issue(`${label}.url is not a valid URL template`, remediation));807 }808 }809 }810 }811812 if (manifest.share_target !== undefined) {813 const label = `${manifestFile}: share_target`;814 const target = manifest.share_target;815 if (!isObject(target)) {816 issues.push(issue(`${label} must be an object`, remediation));817 } else {818 validateScopedActionUrl(819 issues,820 `${label}.action`,821 target.action,822 manifestUrl,823 scope,824 remediation,825 );826 if (target.method !== undefined && target.method !== "GET") {827 issues.push(828 issue(829 `${label}.method must be GET; POST and file shares require a custom worker recipe`,830 remediation,831 ),832 );833 }834 if (835 target.enctype !== undefined &&836 target.enctype !== "application/x-www-form-urlencoded"837 ) {838 issues.push(839 issue(`${label}.enctype must be application/x-www-form-urlencoded`, remediation),840 );841 }842 if (!isObject(target.params)) {843 issues.push(issue(`${label}.params must be an object`, remediation));844 } else {845 const names: string[] = [];846 for (const member of ["title", "text", "url"] as const) {847 const value = target.params[member];848 if (value === undefined) continue;849 if (!nonEmptyString(value)) {850 issues.push(851 issue(`${label}.params.${member} must be a non-empty string`, remediation),852 );853 } else names.push(value);854 }855 if (target.params.files !== undefined) {856 issues.push(857 issue(`${label}.params.files requires a POST/file-share worker recipe`, remediation),858 );859 }860 const unknown = Object.keys(target.params).filter((member) =>861 !["title", "text", "url", "files"].includes(member)862 );863 if (unknown.length > 0) {864 issues.push(issue(`${label}.params contains unsupported members`, remediation));865 }866 if (names.length === 0) {867 issues.push(issue(`${label}.params needs title, text, or url`, remediation));868 }869 if (new Set(names).size !== names.length) {870 issues.push(issue(`${label}.params names must be unique`, remediation));871 }872 }873 }874 }875 }876 return { issues, manifest, scope };877}878879/** Validate author-owned manifest and referenced public assets before boot or build. */880export async function sourcePwaIssues(root: string, deploymentBase = "/") {881 return (await parseAndValidateManifest(root, normalizeDeploymentBase(deploymentBase), "public"))882 .issues;883}884885function attribute(tag: string, name: string): string | undefined {886 const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));887 return match?.[1] ?? match?.[2] ?? match?.[3];888}889890function links(html: string): Array<{ rel: string; href: string }> {891 return [...html.matchAll(/<link\b[^>]*>/gi)].flatMap((match) => {892 const rel = attribute(match[0], "rel");893 const href = attribute(match[0], "href");894 return rel && href ? [{ rel, href }] : [];895 });896}897898function emittedRoutePath(url: URL, basePath: string): string | undefined {899 if (url.origin !== syntheticOrigin || !url.pathname.startsWith(basePath)) return undefined;900 let path: string;901 try {902 path = decodeURIComponent(url.pathname.slice(basePath.length));903 } catch {904 return undefined;905 }906 if (path.split("/").some((part) => part === "..")) return undefined;907 if (!path) return "index.html";908 if (path.endsWith("/")) return `${path}index.html`;909 return path.endsWith(".html") ? path : `${path}/index.html`;910}911912async function filesUnder(root: string): Promise<string[]> {913 const files: string[] = [];914 async function visit(path: string) {915 for await (const entry of Deno.readDir(path)) {916 const child = join(path, entry.name);917 if (entry.isDirectory) await visit(child);918 else if (entry.isFile) files.push(relative(root, child).replaceAll("\\", "/"));919 }920 }921 await visit(root);922 return files.sort();923}924925// _headers.example is deployment documentation, not a shell resource.926const precacheExcludedPaths = new Set([927 "_headers.example",928 "lofi-build.json",929 "lofi-precache.json",930 "sw.js",931]);932933/** Returns manifest screenshot paths, which are install presentation rather than shell resources. */934export function screenshotAssetPaths(manifest: unknown, deploymentBase = "/"): string[] {935 if (!isObject(manifest) || !Array.isArray(manifest.screenshots)) return [];936 const basePath = normalizeDeploymentBase(deploymentBase);937 const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);938 return [939 ...new Set(manifest.screenshots.flatMap((raw) => {940 if (!isObject(raw) || !nonEmptyString(raw.src)) return [];941 try {942 const path = assetPath(new URL(raw.src, manifestUrl), basePath);943 return path ? [path] : [];944 } catch {945 return [];946 }947 })),948 ].sort();949}950951/** Build output paths that belong to the app shell (the precached set). */952export function precachePaths(953 paths: readonly string[],954 presentationPaths: readonly string[] = [],955): string[] {956 const presentation = new Set(presentationPaths.map((path) => path.replaceAll("\\", "/")));957 return paths958 .map((path) => path.replaceAll("\\", "/"))959 .filter((path) => !precacheExcludedPaths.has(path) && !presentation.has(path))960 .sort();961}962963/** Map build output paths to portable URLs expected in the service-worker precache. */964export function expectedPrecacheUrls(965 paths: readonly string[],966 presentationPaths: readonly string[] = [],967): string[] {968 return precachePaths(paths, presentationPaths)969 .map((path) => path === "index.html" ? "./" : `./${path}`)970 .sort();971}972973/** MIME type used by the package preview for install-critical production files. */974export function productionContentType(path: string): string {975 const types: Record<string, string> = {976 ".css": "text/css; charset=utf-8",977 ".html": "text/html; charset=utf-8",978 ".js": "text/javascript; charset=utf-8",979 ".json": "application/json; charset=utf-8",980 ".jpeg": "image/jpeg",981 ".jpg": "image/jpeg",982 ".png": "image/png",983 ".svg": "image/svg+xml",984 ".wasm": "application/wasm",985 ".webmanifest": "application/manifest+json",986 ".webp": "image/webp",987 };988 return types[extname(path).toLowerCase()] ?? "application/octet-stream";989}990991/** Validate the emitted manifest, routes, worker, build identity, and precache as one artifact. */992export async function productionPwaIssues(root: string, deploymentBase = "/") {993 const basePath = normalizeDeploymentBase(deploymentBase);994 const dist = join(root, "dist");995 const remediation = "fix the author-owned PWA source, then rerun `deno task build`";996 const result = await parseAndValidateManifest(root, basePath, "dist");997 const issues = [...result.issues];998 let files: string[];999 try {1000 files = await filesUnder(dist);1001 } catch (error) {1002 if (error instanceof Deno.errors.NotFound) {1003 return [issue("dist/: production output is missing", remediation)];1004 }1005 throw error;1006 }10071008 let identity: JsonObject | undefined;1009 try {1010 const value = JSON.parse(await Deno.readTextFile(join(dist, "lofi-build.json")));1011 if (isObject(value)) identity = value;1012 } catch {1013 // Report one stable error below.1014 }1015 if (1016 !identity || !nonEmptyString(identity.sourceHash) || identity.basePath !== basePath1017 ) {1018 issues.push(1019 issue("dist/lofi-build.json: build identity or deployment base is invalid", remediation),1020 );1021 }10221023 // The revision↔schema manifest ships in the precache and must belong to this1024 // build: the boot compatibility gate trusts it as the shell's own range.1025 let schemaManifest: JsonObject | undefined;1026 try {1027 const value = JSON.parse(await Deno.readTextFile(join(dist, "lofi-schema.json")));1028 if (isObject(value)) schemaManifest = value;1029 } catch {1030 // Report one stable error below.1031 }1032 if (1033 !schemaManifest || schemaManifest.v !== 1 ||1034 (identity && schemaManifest.revision !== identity.sourceHash) ||1035 !nonEmptyString(schemaManifest.head) || !Array.isArray(schemaManifest.lineage) ||1036 !schemaManifest.lineage.includes(schemaManifest.head)1037 ) {1038 issues.push(1039 issue(1040 "dist/lofi-schema.json: schema compatibility manifest is missing or does not match the build",1041 remediation,1042 ),1043 );1044 }10451046 const htmlFiles = files.filter((path) => path.endsWith(".html"));1047 if (htmlFiles.length === 0) {1048 issues.push(issue("dist/: no prerendered HTML routes were emitted", remediation));1049 }1050 if (Array.isArray(result.manifest?.shortcuts)) {1051 const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);1052 for (const [index, raw] of result.manifest.shortcuts.entries()) {1053 if (!isObject(raw) || !nonEmptyString(raw.url)) continue;1054 requireEmittedRoute(1055 issues,1056 htmlFiles,1057 basePath,1058 `dist/${manifestFile}: shortcuts[${index}].url`,1059 remediation,1060 () => new URL(raw.url as string, manifestUrl),1061 );1062 }1063 }1064 if (isObject(result.manifest?.share_target)) {1065 const target = result.manifest.share_target;1066 if (nonEmptyString(target.action)) {1067 const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);1068 requireEmittedRoute(1069 issues,1070 htmlFiles,1071 basePath,1072 `dist/${manifestFile}: share_target.action`,1073 remediation,1074 () => new URL(target.action as string, manifestUrl),1075 );1076 }1077 }1078 if (Array.isArray(result.manifest?.file_handlers)) {1079 const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);1080 for (const [index, raw] of result.manifest.file_handlers.entries()) {1081 if (!isObject(raw) || !nonEmptyString(raw.action)) continue;1082 requireEmittedRoute(1083 issues,1084 htmlFiles,1085 basePath,1086 `dist/${manifestFile}: file_handlers[${index}].action`,1087 remediation,1088 () => new URL(raw.action as string, manifestUrl),1089 );1090 }1091 }1092 if (Array.isArray(result.manifest?.protocol_handlers)) {1093 const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);1094 for (const [index, raw] of result.manifest.protocol_handlers.entries()) {1095 if (!isObject(raw) || !nonEmptyString(raw.url) || !raw.url.includes("%s")) continue;1096 requireEmittedRoute(1097 issues,1098 htmlFiles,1099 basePath,1100 `dist/${manifestFile}: protocol_handlers[${index}].url`,1101 remediation,1102 () => protocolHandlerUrl(raw.url as string, manifestUrl),1103 );1104 }1105 }1106 const expectedManifest = new URL(`${syntheticOrigin}${basePath}${manifestFile}`).href;1107 const appleIcons = new Set<string>();1108 for (const path of htmlFiles) {1109 const html = await Deno.readTextFile(join(dist, path));1110 const route = path === "index.html"1111 ? basePath1112 : `${basePath}${path.endsWith("/index.html") ? path.slice(0, -"index.html".length) : path}`;1113 const documentUrl = new URL(route, syntheticOrigin);1114 const pageLinks = links(html);1115 const manifestLinks = pageLinks.filter((link) =>1116 link.rel.toLowerCase().split(/\s+/).includes("manifest")1117 );1118 let linkedManifest = "";1119 try {1120 linkedManifest = new URL(manifestLinks[0]?.href ?? "", documentUrl).href;1121 } catch {1122 // The stable mismatch below names the route and intended manifest URL.1123 }1124 if (manifestLinks.length !== 1 || linkedManifest !== expectedManifest) {1125 issues.push(1126 issue(`dist/${path}: must link ${basePath}${manifestFile} exactly once`, remediation),1127 );1128 }1129 const apple = pageLinks.find((link) =>1130 link.rel.toLowerCase().split(/\s+/).includes("apple-touch-icon")1131 );1132 if (!apple) {1133 issues.push(issue(`dist/${path}: Apple touch icon link is missing`, remediation));1134 } else {1135 try {1136 const url = new URL(apple.href, documentUrl);1137 const applePath = assetPath(url, basePath);1138 if (!applePath) {1139 issues.push(1140 issue(`dist/${path}: Apple touch icon escapes deployment scope`, remediation),1141 );1142 } else appleIcons.add(applePath);1143 } catch {1144 issues.push(issue(`dist/${path}: Apple touch icon URL is invalid`, remediation));1145 }1146 }1147 }1148 for (const path of appleIcons) {1149 try {1150 const dimensions = await imageDimensions(join(dist, path), "image/png");1151 if (!dimensions || dimensions.width !== 180 || dimensions.height !== 180) {1152 issues.push(issue(`dist/${path}: Apple touch icon must be a 180x180 PNG`, remediation));1153 }1154 } catch (error) {1155 if (error instanceof Deno.errors.NotFound) {1156 issues.push(issue(`dist/${path}: linked Apple touch icon is missing`, remediation));1157 } else throw error;1158 }1159 }11601161 let precache: unknown;1162 try {1163 precache = JSON.parse(await Deno.readTextFile(join(dist, "lofi-precache.json")));1164 } catch {1165 issues.push(1166 issue("dist/lofi-precache.json: missing or malformed precache manifest", remediation),1167 );1168 }1169 const expectedPrecache = expectedPrecacheUrls(1170 files,1171 screenshotAssetPaths(result.manifest, basePath),1172 );1173 if (1174 !Array.isArray(precache) || !precache.every((entry) => typeof entry === "string") ||1175 JSON.stringify([...precache].sort()) !== JSON.stringify(expectedPrecache)1176 ) {1177 issues.push(1178 issue("dist/lofi-precache.json: entries do not match the emitted shell", remediation),1179 );1180 } else {1181 for (const entry of precache) {1182 let url: URL | undefined;1183 try {1184 url = new URL(entry, `${syntheticOrigin}${basePath}`);1185 } catch {1186 // The scope failure below also covers malformed URLs.1187 }1188 if (!url || !withinScope(url, new URL(`${syntheticOrigin}${basePath}`))) {1189 issues.push(1190 issue(`dist/lofi-precache.json: ${entry} escapes deployment scope`, remediation),1191 );1192 }1193 }1194 }11951196 let worker = "";1197 try {1198 worker = await Deno.readTextFile(join(dist, "sw.js"));1199 } catch {1200 issues.push(issue("dist/sw.js: service worker is missing", remediation));1201 }1202 if (worker) {1203 if (1204 worker.includes("__LOFI_BUILD_REVISION__") ||1205 !worker.includes(String(identity?.sourceHash ?? ""))1206 ) {1207 issues.push(issue("dist/sw.js: build revision does not match lofi-build.json", remediation));1208 }1209 if (!worker.includes('new URL("./lofi-precache.json", self.registration.scope)')) {1210 issues.push(issue("dist/sw.js: precache URL is not relative to worker scope", remediation));1211 }1212 }1213 return issues;1214}