clone

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

fix/175-template-quality-pass
lofi / package / commands / preview.ts
preview.ts 3.0K
1#!/usr/bin/env -S deno run -A23/**4 * The `deno task preview` command: serves the built `dist/` output locally over5 * HTTP for previewing the production PWA.6 *7 * @module8 */910import { join, normalize } from "node:path";11import { normalizeDeploymentBase, pathWithinDeploymentBase } from "../tooling/base-path.ts";12import type { LofiBuildInfo } from "../tooling/build-info.ts";13import { productionContentType } from "../tooling/pwa-validation.ts";1415// Preview serves the already-built static dist/ and reads no environment, so16// the command environment preflight does not apply here.1718const portFlag = Deno.args.findIndex((argument) => argument === "--port");19const port = portFlag >= 0 ? Number(Deno.args[portFlag + 1]) : 4321;20if (!Number.isInteger(port) || port < 1 || port > 65_535) {21 console.error("error: preview --port must be an integer from 1 to 65535");22 console.error("usage: deno task preview [--port <1-65535>]");23 Deno.exit(2);24}2526let identity: Partial<LofiBuildInfo>;27try {28 identity = JSON.parse(await Deno.readTextFile("dist/lofi-build.json"));29} catch {30 console.error("error: production build is missing or stale; run `deno task build` first");31 Deno.exit(1);32}33if (!identity.sourceHash) {34 console.error("error: production build identity is invalid; run `deno task build` again");35 Deno.exit(1);36}37const basePath = normalizeDeploymentBase(identity.basePath);3839console.log(40 `lofi preview: http://127.0.0.1:${port}${basePath} (build ${identity.sourceHash}, @nzip/lofi ${identity.lofiVersion})`,41);42Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {43 const url = new URL(request.url);44 if (basePath !== "/" && url.pathname === basePath.slice(0, -1)) {45 return Response.redirect(new URL(basePath, url.origin), 308);46 }47 const scopedPath = pathWithinDeploymentBase(url.pathname, basePath);48 if (scopedPath === null) return new Response("Not found", { status: 404 });49 let pathname = decodeURIComponent(scopedPath);50 if (pathname === "" || pathname.endsWith("/")) pathname += "index.html";51 const relative = normalize(pathname).replace(/^[/\\]+/, "");52 if (relative.startsWith("..")) return new Response("Not found", { status: 404 });53 const path = join("dist", relative);54 try {55 const body = await Deno.readFile(path);56 const contentType = productionContentType(path);57 const headers: Record<string, string> = { "content-type": contentType };58 // The built pages enforce the policy via meta; preview also sends the59 // real header so the header path — including frame-ancestors and the60 // service worker's own execution — is exercised before deployment.61 if (identity.csp && (contentType.startsWith("text/html") || relative === "sw.js")) {62 headers["content-security-policy"] = `${identity.csp}; frame-ancestors 'none'`;63 }64 return new Response(request.method === "HEAD" ? null : body, { headers });65 } catch (error) {66 if (error instanceof Deno.errors.NotFound) return new Response("Not found", { status: 404 });67 throw error;68 }69});