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);