fix(cli): pin Studio bundle, signature and runtime file reads (#3728)

* fix(cli): read Studio bundle files through checked descriptors

* fix(cli): pin Studio signature and runtime artifact reads
This commit is contained in:
James Russo
2026-09-05 22:23:15 -04:00
committed by GitHub
parent 7a07ea9ac3
commit 97fde27df7
5 changed files with 442 additions and 13 deletions
+26
View File
@@ -0,0 +1,26 @@
import { openSync, fstatSync, closeSync, statSync, readFileSync, constants } from "node:fs";
export function readBundleFile(filePath: string): Buffer<ArrayBuffer> | null {
let fd: number;
try {
// Check named pipes without waiting for a writer to connect.
fd = openSync(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ENOTDIR")
)
return null;
// Classify platform-specific directory/socket errors after a failed open.
// No pathname read follows this check.
if (!statSync(filePath, { throwIfNoEntry: false })?.isFile()) return null;
throw error;
}
try {
if (!fstatSync(fd).isFile()) return null;
return readFileSync(fd);
} finally {
closeSync(fd);
}
}
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import { tmpdir } from "node:os";
import { loadRuntimeSource } from "./runtimeSource.js";
const hooks = vi.hoisted(() => ({
dir: "",
source: "",
inlined: "",
checked: () => {},
beforeRead: () => {},
active: new Set<number>(),
opened: 0,
}));
vi.mock("@hyperframes/core", () => ({
loadHyperframeRuntimeSource: () => hooks.source,
getHyperframeRuntimeScript: () => hooks.inlined || null,
}));
vi.mock("node:path", async (importOriginal) => {
const actual = await importOriginal<typeof path>();
return {
...actual,
resolve: (...parts: string[]) => {
const name = parts.at(-1) ?? "";
if (hooks.dir && ["hyperframe-runtime.js", "hyperframe.runtime.iife.js"].includes(name))
return actual.join(hooks.dir, name);
return actual.resolve(...parts);
},
};
});
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof fs>();
return {
...actual,
existsSync: (file: fs.PathLike) => {
if (String(file).endsWith("entry.ts")) return true;
const exists = actual.existsSync(file);
if (exists) hooks.checked();
return exists;
},
openSync: (file: fs.PathLike, flags: string | number) => {
const fd = actual.openSync(file, flags);
hooks.active.add(fd);
hooks.opened++;
return fd;
},
fstatSync: (fd: number) => {
const stat = actual.fstatSync(fd);
hooks.checked();
return stat;
},
closeSync: (fd: number) => {
actual.closeSync(fd);
hooks.active.delete(fd);
},
readFileSync: (file: fs.PathOrFileDescriptor, encoding?: BufferEncoding) => {
hooks.beforeRead();
return encoding ? actual.readFileSync(file, encoding) : actual.readFileSync(file);
},
};
});
describe("prebuilt runtime file reads", () => {
beforeEach(() => {
hooks.dir = fs.mkdtempSync(path.join(tmpdir(), "hf-runtime-read-"));
hooks.opened = 0;
hooks.active.clear();
});
afterEach(() => {
hooks.checked = () => {};
hooks.beforeRead = () => {};
hooks.source = "";
hooks.inlined = "";
fs.rmSync(hooks.dir, { recursive: true, force: true });
hooks.dir = "";
});
function expectClosed() {
expect(hooks.opened).toBeGreaterThan(0);
expect(hooks.active.size).toBe(0);
}
it.each(["hyperframe-runtime.js", "hyperframe.runtime.iife.js"])(
"reads checked %s despite replacement",
async (name) => {
const file = path.join(hooks.dir, name);
fs.writeFileSync(file, "checked runtime");
hooks.checked = () => {
hooks.checked = () => {};
fs.renameSync(file, path.join(hooks.dir, "original"));
fs.writeFileSync(file, "replacement runtime");
};
expect(await loadRuntimeSource()).toBe("checked runtime");
expectClosed();
},
);
it("preserves source, inline and artifact priority", async () => {
fs.writeFileSync(path.join(hooks.dir, "hyperframe-runtime.js"), "first artifact");
fs.writeFileSync(path.join(hooks.dir, "hyperframe.runtime.iife.js"), "second artifact");
hooks.source = "source";
hooks.inlined = "inline";
expect(await loadRuntimeSource()).toBe("source");
hooks.source = "";
expect(await loadRuntimeSource()).toBe("inline");
expect(hooks.opened).toBe(0);
hooks.inlined = "";
expect(await loadRuntimeSource()).toBe("first artifact");
expectClosed();
});
it("preserves an empty first artifact", async () => {
fs.writeFileSync(path.join(hooks.dir, "hyperframe-runtime.js"), "");
fs.writeFileSync(path.join(hooks.dir, "hyperframe.runtime.iife.js"), "second artifact");
expect(await loadRuntimeSource()).toBe("");
expectClosed();
});
it("returns null when artifacts are absent", async () => {
expect(await loadRuntimeSource()).toBeNull();
expect(hooks.active.size).toBe(0);
});
it.each(["stat", "read"])("closes an artifact after %s failure", async (step) => {
fs.writeFileSync(path.join(hooks.dir, "hyperframe-runtime.js"), "bytes");
const fail = () => {
throw new Error("Injected artifact failure");
};
if (step === "stat") hooks.checked = fail;
else hooks.beforeRead = fail;
await expect(loadRuntimeSource()).rejects.toThrow("Injected artifact failure");
expectClosed();
});
});
+4 -2
View File
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { existsSync } from "node:fs";
import { readBundleFile } from "./readBundleFile.js";
import { resolve, dirname } from "node:path";
const ARTIFACT_NAMES = ["hyperframe-runtime.js", "hyperframe.runtime.iife.js"];
@@ -73,7 +74,8 @@ function readPrebuiltArtifact(): string | null {
function readFromDir(dir: string): string | null {
for (const name of ARTIFACT_NAMES) {
const path = resolve(dir, name);
if (existsSync(path)) return readFileSync(path, "utf-8");
const content = readBundleFile(path);
if (content !== null) return content.toString("utf-8");
}
return null;
}
@@ -0,0 +1,267 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import { tmpdir } from "node:os";
import { execFileSync } from "node:child_process";
import {
createStudioServer,
loadPreviewServerBuildSignature,
type StudioServer,
} from "./studioServer.js";
const hooks = vi.hoisted(() => ({
studioDir: "",
runtimeDir: "",
ignoreNextExists: "",
useRuntimeFallback: false,
checked: (_path: fs.PathLike) => {},
beforeRead: () => {},
opened: new Map<number, fs.PathLike>(),
active: new Set<number>(),
}));
vi.mock("./runtimeSource.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./runtimeSource.js")>();
return {
...actual,
loadRuntimeSource: () =>
hooks.useRuntimeFallback ? Promise.resolve(null) : actual.loadRuntimeSource(),
loadRuntimeSourceSignature: () => Promise.resolve("stable-runtime-signature"),
};
});
vi.mock("node:path", async (importOriginal) => {
const actual = await importOriginal<typeof path>();
return {
...actual,
resolve: (...parts: string[]) => {
if (
hooks.studioDir &&
parts.length === 2 &&
parts[0]?.endsWith("server") &&
parts[1] === "studio"
)
return hooks.studioDir;
if (
hooks.runtimeDir &&
["hyperframe-runtime.js", "hyperframe.runtime.iife.js"].includes(parts.at(-1) ?? "")
) {
return actual.resolve(hooks.runtimeDir, parts.at(-1) ?? "");
}
return actual.resolve(...parts);
},
};
});
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof fs>();
return {
...actual,
existsSync: (file: fs.PathLike) => {
const exists = actual.existsSync(file);
if (file === hooks.ignoreNextExists) hooks.ignoreNextExists = "";
else if (exists) hooks.checked(file);
return exists;
},
openSync: (file: fs.PathLike, flags: number | string) => {
const fd = actual.openSync(file, flags);
hooks.opened.set(fd, file);
hooks.active.add(fd);
return fd;
},
closeSync: (fd: number) => {
actual.closeSync(fd);
hooks.active.delete(fd);
},
statSync: (file: fs.PathLike) => {
const stat = actual.statSync(file);
hooks.checked(file);
return stat;
},
fstatSync: (fd: number) => {
const stat = actual.fstatSync(fd);
const file = hooks.opened.get(fd);
if (file) hooks.checked(file);
return stat;
},
readFileSync: (file: fs.PathOrFileDescriptor, encoding?: BufferEncoding) => {
hooks.beforeRead();
return encoding ? actual.readFileSync(file, encoding) : actual.readFileSync(file);
},
};
});
describe("Studio bundle file reads", () => {
let root: string;
let server: StudioServer;
beforeEach(() => {
root = fs.mkdtempSync(path.join(tmpdir(), "hf-studio-static-"));
hooks.studioDir = path.join(root, "studio");
hooks.runtimeDir = path.join(root, "runtime");
fs.mkdirSync(hooks.runtimeDir);
fs.writeFileSync(path.join(hooks.runtimeDir, "hyperframe-runtime.js"), "checked runtime");
const projectDir = path.join(root, "project");
fs.mkdirSync(projectDir);
fs.mkdirSync(hooks.studioDir);
fs.mkdirSync(path.join(hooks.studioDir, "assets"));
fs.mkdirSync(path.join(hooks.studioDir, "icons"));
fs.writeFileSync(
path.join(hooks.studioDir, "index.html"),
"<html><head></head><body>Studio</body></html>",
);
server = createStudioServer({ projectDir });
hooks.opened.clear();
hooks.active.clear();
});
afterEach(() => {
hooks.checked = () => {};
hooks.beforeRead = () => {};
server.watcher.close();
vi.unstubAllEnvs();
vi.restoreAllMocks();
fs.rmSync(root, { recursive: true, force: true });
hooks.studioDir = "";
hooks.runtimeDir = "";
hooks.ignoreNextExists = "";
hooks.useRuntimeFallback = false;
});
function expectClosed() {
expect(hooks.opened.size).toBeGreaterThan(0);
expect(hooks.active.size).toBe(0);
}
it.each(["assets/main.js", "icons/logo.svg", "favicon.svg", "index.html"])(
"reads the checked %s despite replacement",
async (name) => {
const file = path.join(hooks.studioDir, name);
fs.writeFileSync(file, "checked bytes");
hooks.checked = (checked) => {
if (checked !== file) return;
hooks.checked = () => {};
fs.renameSync(file, path.join(root, "original"));
fs.writeFileSync(file, "replacement bytes");
};
const response = await server.app.request(name === "index.html" ? "/" : `/${name}`);
expect(response.status).toBe(200);
expect(await response.text()).toBe("checked bytes");
expectClosed();
},
);
it.each([
["assets/main.js", "text/javascript"],
["icons/logo.svg", "image/svg+xml"],
["favicon.svg", "image/svg+xml"],
])("preserves %s MIME and cache headers", async (name, mime) => {
fs.writeFileSync(path.join(hooks.studioDir, name), "");
const response = await server.app.request(`/${name}`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain(mime);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.text()).toBe("");
expectClosed();
});
it("hashes the checked Studio index despite replacement", async () => {
const file = path.join(hooks.studioDir, "index.html");
const expected = await loadPreviewServerBuildSignature();
hooks.ignoreNextExists = file; // bundle selection precedes the content read
hooks.checked = (checked) => {
if (checked !== file) return;
hooks.checked = () => {};
fs.renameSync(file, path.join(root, "original-index"));
fs.writeFileSync(file, "replacement index");
};
expect(await loadPreviewServerBuildSignature()).toBe(expected);
expectClosed();
});
it("serves the checked runtime fallback despite replacement", async () => {
hooks.useRuntimeFallback = true;
const file = path.join(hooks.runtimeDir, "hyperframe-runtime.js");
hooks.checked = (checked) => {
if (checked !== file) return;
hooks.checked = () => {};
fs.renameSync(file, path.join(root, "original-runtime"));
fs.writeFileSync(file, "replacement runtime");
};
const response = await server.app.request("/api/runtime.js");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/javascript");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.text()).toBe("checked runtime");
expectClosed();
});
it.each(["signature", "runtime"])("closes a failed %s read", async (kind) => {
hooks.useRuntimeFallback = true;
hooks.beforeRead = () => {
throw new Error("Injected read failure");
};
vi.spyOn(console, "error").mockImplementation(() => {});
if (kind === "signature")
await expect(loadPreviewServerBuildSignature()).rejects.toThrow("Injected read failure");
else expect((await server.app.request("/api/runtime.js")).status).toBe(500);
expectClosed();
});
it("returns 404 for absent assets and directories", async () => {
fs.mkdirSync(path.join(hooks.studioDir, "assets", "folder"));
for (const route of ["/assets/missing", "/assets/folder", "/icons/missing", "/favicon.svg"]) {
const response = await server.app.request(route);
expect(response.status).toBe(404);
expect(await response.text()).toBe("not found");
}
expectClosed();
});
it.skipIf(process.platform === "win32")("rejects an asset FIFO without blocking", async () => {
execFileSync("mkfifo", [path.join(hooks.studioDir, "assets", "pipe")]);
expect((await server.app.request("/assets/pipe")).status).toBe(404);
expectClosed();
});
it("continues serving symlinked bundle assets", async () => {
const shared = path.join(root, "shared");
fs.mkdirSync(shared);
fs.writeFileSync(path.join(shared, "file.js"), "shared asset");
fs.symlinkSync(
shared,
path.join(hooks.studioDir, "assets", "shared"),
process.platform === "win32" ? "junction" : "dir",
);
const response = await server.app.request("/assets/shared/file.js");
expect(response.status).toBe(200);
expect(await response.text()).toBe("shared asset");
expectClosed();
});
it("preserves the unavailable-bundle diagnostic", async () => {
fs.unlinkSync(path.join(hooks.studioDir, "index.html"));
const response = await server.app.request("/");
expect(response.status).toBe(500);
expect(await response.text()).toContain("HyperFrames Studio unavailable");
});
it("still injects runtime environment into the SPA fallback", async () => {
vi.stubEnv("VITE_STUDIO_STATIC_TEST", "runtime-value");
const response = await server.app.request("/editor");
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toContain("Studio");
expect(text).toContain('"VITE_STUDIO_STATIC_TEST":"runtime-value"');
expectClosed();
});
it.each(["stat", "read"])("closes a file when %s fails", async (step) => {
fs.writeFileSync(path.join(hooks.studioDir, "assets", "file.js"), "bytes");
const fail = () => {
throw new Error("Injected file failure");
};
if (step === "stat") hooks.checked = fail;
else hooks.beforeRead = fail;
vi.spyOn(console, "error").mockImplementation(() => {});
expect((await server.app.request("/assets/file.js")).status).toBe(500);
expectClosed();
});
});
+11 -11
View File
@@ -7,8 +7,9 @@
import { Hono, type Context } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, writeFileSync, statSync, unlinkSync } from "node:fs";
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import { resolve, join, basename } from "node:path";
import { readBundleFile } from "./readBundleFile.js";
import {
createProjectWatcher,
shouldWatchProjectFile,
@@ -314,10 +315,9 @@ export interface StudioServer {
export async function loadPreviewServerBuildSignature(): Promise<string> {
const runtimeSignature = await loadRuntimeSourceSignature();
const studioBundle = resolveStudioBundle();
const studioIndex =
studioBundle.available && existsSync(studioBundle.indexPath)
? readFileSync(studioBundle.indexPath, "utf-8")
: "";
const studioIndex = studioBundle.available
? (readBundleFile(studioBundle.indexPath)?.toString("utf-8") ?? "")
: "";
return hashSignatureParts([
version,
runtimeSignature,
@@ -734,8 +734,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
app.get("/api/runtime.js", (c) => {
const serve = async () => {
const runtimeSource =
(await loadRuntimeSource()) ??
(existsSync(runtimePath) ? readFileSync(runtimePath, "utf-8") : null);
(await loadRuntimeSource()) ?? readBundleFile(runtimePath)?.toString("utf-8") ?? null;
if (!runtimeSource) return c.text("runtime not available", 404);
return c.body(runtimeSource, 200, {
"Content-Type": "text/javascript",
@@ -877,8 +876,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// Studio SPA static files
const serveStudioStaticFile = (c: Context) => {
const filePath = resolve(studioDir, c.req.path.slice(1));
if (!existsSync(filePath) || !statSync(filePath).isFile()) return c.text("not found", 404);
const content = readFileSync(filePath);
const content = readBundleFile(filePath);
if (content === null) return c.text("not found", 404);
return new Response(content, {
headers: { "Content-Type": getMimeType(filePath), "Cache-Control": "no-store" },
});
@@ -906,7 +905,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// SPA fallback
app.get("*", (c) => {
const indexPath = resolve(studioDir, "index.html");
if (!existsSync(indexPath)) {
const indexContent = readBundleFile(indexPath);
if (indexContent === null) {
return c.html(
`<!doctype html>
<html>
@@ -962,7 +962,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
500,
);
}
let html = readFileSync(indexPath, "utf-8");
let html = indexContent.toString("utf-8");
// Inject before the studio bundle runs. Identity script first (see
// buildStudioHeadScripts) so the CLI distinct id is on `window` by the time
// telemetry init reads it.