diff --git a/compiler/src/validate/interactionGate.ts b/compiler/src/validate/interactionGate.ts index 50db554..a34305d 100644 --- a/compiler/src/validate/interactionGate.ts +++ b/compiler/src/validate/interactionGate.ts @@ -4,6 +4,7 @@ import type { InteractionCapture } from "../capture/interactions.js"; import { buildRuntimeSpecs, specKey } from "../generate/interactive.js"; import { buildMenuSpecs } from "../generate/menu.js"; import type { GateResult } from "./gates.js"; +import { gotoAndSettle } from "./render.js"; /** * Stage 4 interaction gate. Drives the SAME interactions in the built clone that @@ -61,7 +62,10 @@ export async function driveInteractionGate(opts: { const ctx = await browser.newContext({ viewport: { width: vp, height: vh }, deviceScaleFactor: 1 }); const page = await ctx.newPage(); await page.addInitScript("globalThis.__name = globalThis.__name || ((fn) => fn);"); - await page.goto(url, { waitUntil: "networkidle", timeout: 45000 }); + // `load` + bounded network-quiet wait (not strict `networkidle`) so an autoplaying + // long hero video — which keeps issuing bounded range fetches — can't make navigation + // time out and fail the interaction gate before any interaction is even driven. + await gotoAndSettle(page, url); await page.waitForTimeout(300); const styleOf = (cid: string, props: string[]): Promise | null> => diff --git a/compiler/src/validate/render.ts b/compiler/src/validate/render.ts index 4502418..aabfa57 100644 --- a/compiler/src/validate/render.ts +++ b/compiler/src/validate/render.ts @@ -146,6 +146,50 @@ async function readLimited(p: string): Promise { } } +/** Result of interpreting a `Range` header against a known resource size. + * - `kind: "full"` → no (usable) Range header; answer with a normal 200 full body. + * - `kind: "range"` → a single satisfiable `bytes=start-end`; answer 206 with [start,end]. + * - `kind: "unsatisfiable"` → a syntactically valid range wholly past EOF; answer 416. */ +export type RangeResolution = + | { kind: "full" } + | { kind: "range"; start: number; end: number } + | { kind: "unsatisfiable" }; + +/** Parse a single-range HTTP `Range` header ("bytes=start-end", "bytes=start-", + * "bytes=-suffix") against a resource of `size` bytes. Multi-range ("a-b,c-d") is + * intentionally collapsed to a full 200 (Chromium's media element only ever asks for a + * single range, and RFC 7233 lets a server ignore Range and reply 200). Anything the + * spec calls malformed (missing "bytes=", non-numeric, inverted, both bounds empty) is + * also treated as "full" — the safe fallback. A well-formed range that starts at or past + * EOF is "unsatisfiable" → 416. `end` is inclusive and clamped to size-1. */ +export function parseRangeHeader(header: string | undefined, size: number): RangeResolution { + if (!header) return { kind: "full" }; + const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); + if (!m) return { kind: "full" }; // absent/malformed/multi-range → serve full body + const startStr = m[1]!; + const endStr = m[2]!; + if (startStr === "" && endStr === "") return { kind: "full" }; // "bytes=-" is malformed + if (size <= 0) return { kind: "unsatisfiable" }; + let start: number; + let end: number; + if (startStr === "") { + // Suffix range: "bytes=-N" → last N bytes. + const suffix = Number(endStr); + if (!Number.isFinite(suffix) || suffix <= 0) return { kind: "full" }; + start = Math.max(0, size - suffix); + end = size - 1; + } else { + start = Number(startStr); + if (!Number.isFinite(start)) return { kind: "full" }; + if (start >= size) return { kind: "unsatisfiable" }; // start past EOF + end = endStr === "" ? size - 1 : Number(endStr); + if (!Number.isFinite(end)) return { kind: "full" }; + if (end < start) return { kind: "full" }; // inverted → ignore Range + end = Math.min(end, size - 1); + } + return { kind: "range", start, end }; +} + export function serveStatic(rootDir: string): Promise<{ url: string; close: () => Promise }> { const server: Server = createServer(async (req, res) => { try { @@ -168,8 +212,32 @@ export function serveStatic(rootDir: string): Promise<{ url: string; close: () = if (existsSync(fallback)) { res.writeHead(404, { "content-type": "text/html" }); res.end(await readLimited(fallback)); return; } res.writeHead(404); res.end("not found"); return; } + const contentType = CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream"; + const size = statSync(filePath).size; + // HTTP Range support (RFC 7233). Chromium requests