clone

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

fix(tooling): harden generated shutdown and builds

Dami committed 53f6531
commit
53f6531c04e356e4ca1849e0625dad3f3e1c539d
parent
7b8b8bd9506b56cdc7caef288146139524daaea0
author
Dami <dami.etoile@gmail.com>
9 files changed, +222 -19
Mpackage/commands/build.ts10+---
Mpackage/commands/dev.ts11+++--
Mpackage/tooling/environment.ts1-
Apackage/tooling/environment_test.ts22++++++++++
Mpackage/tooling/process.ts20++++++++-
Apackage/tooling/process_test.ts68++++++++++++++++++++++++++++++
Mpackage/tooling/project.ts30++++++++++++-
Mpackage/tooling/project_test.ts67+++++++++++++++++++++++++++++-
Mtools/golden_path.ts12+++++
M package/commands/build.ts +3 -7
M package/commands/dev.ts +7 -4
M package/tooling/environment.ts +0 -1
A package/tooling/environment_test.ts +22 -0
M package/tooling/process.ts +17 -3
@@ -1,8 +1,12 @@
1-export async function runDeno(1+export type DenoRunStatus = Deno.CommandStatus & {2+ forwardedSignal: Deno.Signal | null;3+};4+5+export async function runDenoStatus(26 args: readonly string[],37 environment: Readonly<Record<string, string>>,48 cwd = Deno.cwd(),5-): Promise<number> {9+): Promise<DenoRunStatus> {610 const child = new Deno.Command(Deno.execPath(), {711 args: [...args],812 cwd,
@@ -12,6 +16,7 @@ export async function runDeno(
1216 stdout: "piped",1317 stderr: "piped",1418 }).spawn();19+ let forwardedSignal: Deno.Signal | null = null;1520 const forward = (1621 stream: ReadableStream<Uint8Array>,1722 target: WritableStream<Uint8Array>,
@@ -19,6 +24,7 @@ export async function runDeno(
1924 const forwardSignal = (signal: Deno.Signal) => {2025 try {2126 child.kill(signal);27+ forwardedSignal ??= signal;2228 } catch (error) {2329 if (!(error instanceof Deno.errors.NotFound)) throw error;2430 }
@@ -33,9 +39,17 @@ export async function runDeno(
3339 forward(child.stdout, Deno.stdout.writable),3440 forward(child.stderr, Deno.stderr.writable),3541 ]);36- return status.code;42+ return { ...status, forwardedSignal };3743 } finally {3844 Deno.removeSignalListener("SIGINT", onInterrupt);3945 Deno.removeSignalListener("SIGTERM", onTerminate);4046 }4147 }48+49+export async function runDeno(50+ args: readonly string[],51+ environment: Readonly<Record<string, string>>,52+ cwd = Deno.cwd(),53+): Promise<number> {54+ return (await runDenoStatus(args, environment, cwd)).code;55+}
A package/tooling/process_test.ts +68 -0
@@ -0,0 +1,68 @@
1+import { runDenoStatus } from "./process.ts";2+3+function assert(condition: unknown, message: string): asserts condition {4+ if (!condition) throw new Error(message);5+}6+7+Deno.test({8+ name: "runDenoStatus distinguishes a forwarded shutdown signal from a child failure",9+ ignore: Deno.build.os === "windows",10+ async fn() {11+ const moduleUrl = new URL("./process.ts", import.meta.url).href;12+ const wrapperSource = `13+ import { runDenoStatus } from ${JSON.stringify(moduleUrl)};14+ const status = await runDenoStatus(15+ ["eval", "console.log('READY'); await new Promise(() => {})"],16+ {},17+ );18+ console.log("STATUS " + JSON.stringify(status));19+ if (status.forwardedSignal !== "SIGTERM") Deno.exit(1);20+ `;21+ const wrapper = new Deno.Command(Deno.execPath(), {22+ args: ["eval", wrapperSource],23+ clearEnv: true,24+ stdout: "piped",25+ stderr: "piped",26+ }).spawn();27+ const reader = wrapper.stdout.pipeThrough(new TextDecoderStream()).getReader();28+ let stdout = "";29+ try {30+ while (!stdout.includes("READY")) {31+ const next = await Promise.race([32+ reader.read(),33+ new Promise<never>((_, reject) =>34+ setTimeout(() => reject(new Error("signal test child did not become ready")), 5_000)35+ ),36+ ]);37+ if (next.done) throw new Error(`signal test child exited before readiness: ${stdout}`);38+ stdout += next.value;39+ }40+ wrapper.kill("SIGTERM");41+ while (true) {42+ const next = await reader.read();43+ if (next.done) break;44+ stdout += next.value;45+ }46+ const [status, stderr] = await Promise.all([47+ wrapper.status,48+ new Response(wrapper.stderr).text(),49+ ]);50+ assert(status.success, `signal test wrapper failed (${status.code}): ${stderr}`);51+ assert(52+ stdout.includes('"forwardedSignal":"SIGTERM"'),53+ `forwarded signal was not retained: ${stdout}`,54+ );55+ } finally {56+ reader.releaseLock();57+ try {58+ wrapper.kill("SIGKILL");59+ } catch {60+ // The expected path already exited cleanly.61+ }62+ }63+64+ const unexpected = await runDenoStatus(["eval", "Deno.exit(17)"], {});65+ assert(unexpected.code === 17, `unexpected child code changed: ${unexpected.code}`);66+ assert(unexpected.forwardedSignal === null, "ordinary child failure was marked as a shutdown");67+ },68+});
M package/tooling/project.ts +27 -3
@@ -61,6 +61,7 @@ export async function walkFiles(
6161 left.name.localeCompare(right.name)6262 );6363 for (const entry of entries) {64+ const child = join(path, entry.name);6465 if (6566 entry.name === ".env" || (entry.name.startsWith(".env.") && entry.name !== ".env.example")6667 ) {
@@ -72,13 +73,13 @@ export async function walkFiles(
7273 }7374 await visit(join(path, entry.name));7475 } else if (entry.isFile) {75- const child = join(path, entry.name);7676 if (!options.extensions || options.extensions.has(extname(child))) {7777 files.push(relative(absoluteRoot, child));7878 }7979 } else {8080 throw new Error(81- `project contains unsupported symbolic link: ${relative(absoluteRoot, path)}`,81+ `project contains unsupported symbolic link: ${relative(absoluteRoot, child)}; ` +82+ "replace it with a regular file or directory, then rerun the command",8283 );8384 }8485 }
@@ -89,7 +90,16 @@ export async function walkFiles(
8990 9091 export async function sourceFingerprint(root = Deno.cwd()): Promise<string> {9192 const files = await walkFiles(root, {92- extensions: new Set([".astro", ".css", ".json", ".svg", ".ts", ".tsx", ".webmanifest"]),93+ extensions: new Set([94+ ".astro",95+ ".css",96+ ".js",97+ ".json",98+ ".svg",99+ ".ts",100+ ".tsx",101+ ".webmanifest",102+ ]),93103 });94104 const encoder = new TextEncoder();95105 const chunks: Uint8Array[] = [];
@@ -110,6 +120,20 @@ export async function sourceFingerprint(root = Deno.cwd()): Promise<string> {
110120 return Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");111121 }112122 123+const precacheExcludedPaths = new Set([124+ "lofi-build.json",125+ "lofi-precache.json",126+ "sw.js",127+]);128+129+export function precacheUrls(paths: readonly string[]): string[] {130+ return paths131+ .map((path) => path.replaceAll("\\", "/"))132+ .filter((path) => !precacheExcludedPaths.has(path))133+ .map((path) => path === "index.html" ? "./" : `./${path}`)134+ .sort();135+}136+113137 function contains(haystack: Uint8Array, needle: Uint8Array): boolean {114138 if (needle.length === 0 || haystack.length < needle.length) return false;115139 outer:
M package/tooling/project_test.ts +66 -1
@@ -1,5 +1,5 @@
11 import { join } from "node:path";2-import { scanSecrets, sourceFingerprint } from "./project.ts";2+import { precacheUrls, scanSecrets, sourceFingerprint, walkFiles } from "./project.ts";33 44 function assert(condition: unknown, message: string): asserts condition {55 if (!condition) throw new Error(message);
@@ -34,6 +34,71 @@ Deno.test("source fingerprint is stable across file creation order and changes w
3434 }3535 });3636 37+Deno.test("source fingerprint includes authored service worker JavaScript", async () => {38+ const root = await makeTestRoot();39+ try {40+ await Deno.mkdir(join(root, "public"));41+ await Deno.writeTextFile(join(root, "public", "sw.js"), "const revision = 'one';\n");42+ const first = await sourceFingerprint(root);43+ await Deno.writeTextFile(join(root, "public", "sw.js"), "const revision = 'two';\n");44+ assert(first !== await sourceFingerprint(root), "fingerprint ignored service worker source");45+ } finally {46+ await Deno.remove(root, { recursive: true });47+ }48+});49+50+Deno.test("precache URLs are portable app-shell URLs", () => {51+ const urls = precacheUrls([52+ "sw.js",53+ "index.html",54+ "lofi-build.json",55+ "lofi-precache.json",56+ "assets\\client.js",57+ "manifest.webmanifest",58+ ]);59+ assert(60+ JSON.stringify(urls) === JSON.stringify([61+ "./",62+ "./assets/client.js",63+ "./manifest.webmanifest",64+ ]),65+ `unexpected precache URLs: ${JSON.stringify(urls)}`,66+ );67+});68+69+Deno.test({70+ name: "project walking names unsupported symbolic links and the remediation",71+ ignore: Deno.build.os === "windows",72+ async fn() {73+ const root = await makeTestRoot();74+ try {75+ await Deno.writeTextFile(join(root, "target.ts"), "export const target = true;\n");76+ const link = await new Deno.Command("ln", {77+ args: ["-s", "target.ts", join(root, "linked.ts")],78+ stdout: "null",79+ stderr: "piped",80+ }).output();81+ assert(82+ link.success,83+ `failed to create test symlink: ${new TextDecoder().decode(link.stderr)}`,84+ );85+ let message = "";86+ try {87+ await walkFiles(root);88+ } catch (error) {89+ message = error instanceof Error ? error.message : String(error);90+ }91+ assert(message.includes("linked.ts"), `error did not name the symbolic link: ${message}`);92+ assert(93+ message.includes("replace it with a regular file or directory"),94+ `error did not include remediation: ${message}`,95+ );96+ } finally {97+ await Deno.remove(root, { recursive: true });98+ }99+ },100+});101+37102 Deno.test("secret scan ignores the local env source but catches source and build leaks", async () => {38103 const root = await makeTestRoot();39104 const secret = "generated-command-secret-fixture";
M tools/golden_path.ts +12 -0