From 466ee08ffa540755a430bd54f078a3764ca4b4ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 30 Jun 2026 10:43:20 -0700 Subject: [PATCH] fix(cli): serve project media with HTTP Range so validate reads WAV duration (#1811) * fix(cli): serve project media with HTTP Range so validate reads WAV duration The local static server used by validate/snapshot/layout answered every asset request with a plain 200 and no Accept-Ranges header. Chromium treats such resources as non-seekable, and for WAV that makes the media element report `.duration` as Infinity no matter how long it buffers (readyState reaches HAVE_ENOUGH_DATA but duration never resolves). The duration audit in validate then emitted a spurious "Could not read the duration of N media element(s) within the validate timeout" warning for a perfectly valid local WAV, and a longer --timeout never helped because the value is never going to arrive. MP3/MP4 carry duration in their container metadata so they were unaffected. Serve files with Range support (206 + Content-Range, plus Accept-Ranges on the full 200) so the element is seekable. WAV duration now resolves, the false warning is gone, and the genuine "media shorter than its slot" check works for WAV for the first time. * perf(cli): stream Range responses instead of buffering the whole file serveFileWithRange read the entire asset with readFileSync and then sliced it, so a 1KB Range of a 50MB MP4 still allocated the full 50MB per request. Switch to statSync for the total size and createReadStream(filePath, { start, end }) piped to the response, reading only the requested window. Behavior is unchanged: 206 + Content-Range + Content-Length for a satisfiable range, 416 for an unsatisfiable one, Accept-Ranges advertised on every response, and a plain 200 full-body stream when there is no Range header. writeHead is deferred to the stream's open event so a failed open still answers 500, and the fd closes on end/error. This benefits MP4 seek too, not just WAV duration. Extend staticProjectServer.test.ts with an 8MB-file case that pulls a 4-byte slice from deep inside and asserts the streamed bytes, Content-Range, and Content-Length are correct. --- .../cli/src/utils/staticProjectServer.test.ts | 68 +++++++++++++++++++ packages/cli/src/utils/staticProjectServer.ts | 65 ++++++++++++++++-- 2 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/utils/staticProjectServer.test.ts diff --git a/packages/cli/src/utils/staticProjectServer.test.ts b/packages/cli/src/utils/staticProjectServer.test.ts new file mode 100644 index 000000000..5128ad833 --- /dev/null +++ b/packages/cli/src/utils/staticProjectServer.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serveStaticProjectHtml, type StaticProjectServer } from "./staticProjectServer.js"; + +let server: StaticProjectServer | undefined; +let dir: string | undefined; + +afterEach(async () => { + await server?.close(); + server = undefined; + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; +}); + +async function serveWith(bytes: Buffer): Promise<{ url: string }> { + dir = mkdtempSync(join(tmpdir(), "hf-static-")); + writeFileSync(join(dir, "tone.wav"), bytes); + server = await serveStaticProjectHtml(dir, ""); + return { url: server.url }; +} + +describe("serveStaticProjectHtml range support", () => { + it("answers a Range request with 206 + the requested byte slice", async () => { + // Chromium needs byte-range seekability or WAV `.duration` reports Infinity, + // which makes `hyperframes validate` falsely warn it cannot read the duration. + const body = Buffer.from("0123456789", "utf-8"); + const { url } = await serveWith(body); + + const res = await fetch(`${url}tone.wav`, { headers: { Range: "bytes=2-5" } }); + expect(res.status).toBe(206); + expect(res.headers.get("accept-ranges")).toBe("bytes"); + expect(res.headers.get("content-range")).toBe(`bytes 2-5/${body.length}`); + expect(await res.text()).toBe("2345"); + }); + + it("advertises Accept-Ranges even on a full 200 response", async () => { + const { url } = await serveWith(Buffer.from("abcdef", "utf-8")); + const res = await fetch(`${url}tone.wav`); + expect(res.status).toBe(200); + expect(res.headers.get("accept-ranges")).toBe("bytes"); + expect(await res.text()).toBe("abcdef"); + }); + + it("streams a small slice out of a large file without buffering the whole thing", async () => { + // 8MB file, ask for 4 bytes deep inside it. The handler must createReadStream + // the [start,end] window only, not readFileSync the whole 8MB and slice. + const size = 8 * 1024 * 1024; + const big = Buffer.alloc(size, 0x61); // 'a' everywhere... + big.write("WXYZ", 5_000_000); // ...except a 4-byte marker + const { url } = await serveWith(big); + + const res = await fetch(`${url}tone.wav`, { headers: { Range: "bytes=5000000-5000003" } }); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe(`bytes 5000000-5000003/${size}`); + expect(res.headers.get("content-length")).toBe("4"); + expect(await res.text()).toBe("WXYZ"); + }); + + it("returns 416 for an unsatisfiable range", async () => { + const body = Buffer.from("abc", "utf-8"); + const { url } = await serveWith(body); + const res = await fetch(`${url}tone.wav`, { headers: { Range: "bytes=99-200" } }); + expect(res.status).toBe(416); + expect(res.headers.get("content-range")).toBe(`bytes */${body.length}`); + }); +}); diff --git a/packages/cli/src/utils/staticProjectServer.ts b/packages/cli/src/utils/staticProjectServer.ts index 438ed7c20..2d5aa9a39 100644 --- a/packages/cli/src/utils/staticProjectServer.ts +++ b/packages/cli/src/utils/staticProjectServer.ts @@ -1,5 +1,5 @@ -import { createServer } from "node:http"; -import { existsSync, readFileSync } from "node:fs"; +import { createServer, type ServerResponse } from "node:http"; +import { createReadStream, existsSync, statSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; import { getMimeType } from "@hyperframes/core/studio-api"; @@ -9,6 +9,64 @@ export interface StaticProjectServer { close: () => Promise; } +/** + * Serve a file with HTTP Range support. Chromium needs byte-range seekability + * to determine the duration of formats that carry it in a trailing/implicit + * position (notably WAV, which otherwise reports `.duration` as `Infinity` + * however long it buffers). A plain 200 with no `Accept-Ranges` makes the + * media element non-seekable, so `hyperframes validate` would spuriously warn + * that a perfectly valid local WAV's duration "could not be read". + */ +function serveFileWithRange( + filePath: string, + rangeHeader: string | undefined, + res: ServerResponse, +) { + const size = statSync(filePath).size; + const headers: Record = { + "Content-Type": getMimeType(filePath), + "Accept-Ranges": "bytes", + }; + + // Resolve the requested byte window. Absent/malformed Range serves the + // whole file (200); a valid `bytes=start-end` (including the open-ended + // `start-` and suffix `-N` forms) serves a 206 slice. + const last = size - 1; + let start = 0; + let end = last; + let status = 200; + const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()) : null; + if (match) { + const hasStart = match[1] !== ""; + start = hasStart ? Number(match[1]) : Math.max(0, size - Number(match[2])); + end = !hasStart ? last : match[2] !== "" ? Math.min(Number(match[2]), last) : last; + + if (start > end || start > last) { + res.writeHead(416, { ...headers, "Content-Range": `bytes */${size}` }); + res.end(); + return; + } + status = 206; + headers["Content-Range"] = `bytes ${start}-${end}/${size}`; + } + headers["Content-Length"] = String(end - start + 1); + + // Stream only the requested window instead of buffering the whole file: a + // 1KB Range of a 50MB asset must not allocate 50MB. createReadStream reads + // just `[start, end]` and closes its own fd on end/error. writeHead is + // deferred to `open` so a failed open can still answer 500. + const stream = createReadStream(filePath, { start, end }); + stream.on("open", () => { + res.writeHead(status, headers); + stream.pipe(res); + }); + stream.on("error", () => { + if (!res.headersSent) res.writeHead(500); + res.end(); + stream.destroy(); + }); +} + export async function serveStaticProjectHtml( projectDir: string, html: string, @@ -31,8 +89,7 @@ export async function serveStaticProjectHtml( return; } if (existsSync(filePath)) { - res.writeHead(200, { "Content-Type": getMimeType(filePath) }); - res.end(readFileSync(filePath)); + serveFileWithRange(filePath, req.headers.range, res); return; } res.writeHead(404);