fix(studio-server): preserve binary file writes and versions (#3653)

* fix(studio-server): preserve binary file writes and versions

* fix(studio-server): rely on exclusive file creation for POST

* test(studio-server): create race fixture atomically
This commit is contained in:
James Russo
2026-09-04 17:44:19 -04:00
committed by GitHub
parent 924b98a0dc
commit d2741b3a28
4 changed files with 108 additions and 20 deletions
+1 -1
View File
@@ -779,7 +779,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
const absPath = resolve(projectDir, path);
let version: string | null = null;
try {
version = fileContentVersion(readFileSync(absPath, "utf-8"));
version = fileContentVersion(readFileSync(absPath));
} catch {
// A deletion has no current bytes to match against an API write receipt.
}
@@ -14,8 +14,8 @@ const RECEIPT_TTL_MS = 10_000;
const receipts = new Map<string, StoredReceipt[]>();
/** Strong content version used as both the JSON version and HTTP ETag. */
export function fileContentVersion(content: string): string {
return `"sha256:${createHash("sha256").update(content, "utf8").digest("hex")}"`;
export function fileContentVersion(content: string | Uint8Array): string {
return `"sha256:${createHash("sha256").update(content).digest("hex")}"`;
}
export function createWriteToken(requestToken?: string): string {
@@ -284,6 +284,91 @@ describe("registerFileRoutes", () => {
expect(response.headers.get("etag")).toBe(payload.version);
});
it.each(["POST", "PUT"])("preserves arbitrary bytes when creating through %s", async (method) => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0xff, 0xc0, 0x80]);
const url = "http://localhost/projects/demo/files/assets/image.png";
const response = await app.request(url, {
method,
headers: { "Content-Type": "application/octet-stream", "If-None-Match": "*" },
body: bytes,
});
expect(response.status).toBe(method === "POST" ? 201 : 200);
expect(readFileSync(join(projectDir, "assets/image.png"))).toEqual(bytes);
const read = await app.request(url);
const payload = await read.json();
expect(payload.content).toBe(bytes.toString("utf-8"));
expect(payload.version).toBe(fileContentVersion(bytes));
expect(read.headers.get("etag")).toBe(payload.version);
const duplicate = await app.request(url, {
method,
headers: { "If-None-Match": "*" },
body: "overwrite",
});
expect(duplicate.status).toBe(409);
expect(readFileSync(join(projectDir, "assets/image.png"))).toEqual(bytes);
});
it("does not overwrite a file created while a POST body is being read", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const path = join(projectDir, "raced.bin");
const existing = Buffer.from([0xff, 0, 0x80]);
const request = new Request("http://localhost/projects/demo/files/raced.bin", {
method: "POST",
body: "upload",
});
const readBody = request.arrayBuffer.bind(request);
vi.spyOn(request, "arrayBuffer").mockImplementation(async () => {
writeFileSync(path, existing, { flag: "wx" });
return readBody();
});
const response = await app.request(request);
expect(response.status).toBe(409);
expect(readFileSync(path)).toEqual(existing);
});
it("versions binary bytes exactly while preserving conflicts, backups, and write receipts", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const path = join(projectDir, "image.png");
const before = Buffer.from([0xff, 0, 0x80, 0x81]);
const after = Buffer.from([0xfe, 0, 0x80]);
writeFileSync(path, before);
const url = "http://localhost/projects/demo/files/image.png";
const read = await app.request(url);
const version = read.headers.get("etag")!;
// Invalid UTF-8 bytes may decode to the same string, but must not share a version.
const staleBytes = Buffer.from([0xfe, 0, 0x80, 0x81]);
expect(staleBytes.toString("utf-8")).toBe(before.toString("utf-8"));
for (const headers of [{}, { "If-Match": fileContentVersion(staleBytes) }]) {
const rejected = await app.request(url, { method: "PUT", headers, body: after });
expect([428, 409]).toContain(rejected.status);
expect((await rejected.json()).currentVersion).toBe(version);
expect(readFileSync(path)).toEqual(before);
}
const response = await app.request(url, {
method: "PUT",
headers: { "If-Match": version, "X-Hyperframes-Write-Token": "binary-write" },
body: after,
});
const payload = await response.json();
expect(response.status).toBe(200);
expect(readFileSync(path)).toEqual(after);
expect(readFileSync(join(projectDir, payload.backupPath))).toEqual(before);
expect(payload.version).toBe(fileContentVersion(after));
expect(response.headers.get("etag")).toBe(payload.version);
expect(consumeFileWriteReceipt(path, payload.version)).toEqual({
path: "image.png",
version: payload.version,
writeToken: "binary-write",
});
});
it("requires If-Match for updates and preserves the current bytes", async () => {
const projectDir = createProjectDir();
const app = new Hono();
+20 -17
View File
@@ -2253,10 +2253,10 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
return c.json({ error: "not found" }, 404);
}
const content = readFileSync(res.absPath, "utf-8");
const content = readFileSync(res.absPath);
const version = fileContentVersion(content);
c.header("ETag", version);
return c.json({ filename: res.filePath, content, version });
return c.json({ filename: res.filePath, content: content.toString("utf-8"), version });
});
// ── Write (overwrite) ──
@@ -2265,13 +2265,13 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const res = await resolveProjectFile(c, adapter);
if ("error" in res) return res.error;
const body = await c.req.text();
const body = Buffer.from(await c.req.arrayBuffer());
const expectedVersion = c.req.header("If-Match")?.trim() ?? null;
const createOnly = c.req.header("If-None-Match")?.trim() === "*";
if (expectedVersion === null && !createOnly) {
let currentContent: string | null = null;
let currentContent: Buffer | null = null;
try {
currentContent = readFileSync(res.absPath, "utf-8");
currentContent = readFileSync(res.absPath);
} catch (error) {
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
throw error;
@@ -2282,7 +2282,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
error: "precondition required",
path: res.filePath,
currentVersion: currentContent === null ? null : fileContentVersion(currentContent),
currentContent,
currentContent: currentContent?.toString("utf-8") ?? null,
},
428,
);
@@ -2298,19 +2298,19 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
throw error;
}
const currentContent = readFileSync(res.absPath, "utf-8");
const currentContent = readFileSync(res.absPath);
return c.json(
{
error: "file conflict",
path: res.filePath,
currentVersion: fileContentVersion(currentContent),
currentContent,
currentContent: currentContent.toString("utf-8"),
},
409,
);
}
try {
writeSync(fd, body, 0, "utf-8");
writeSync(fd, body, 0, body.length, 0);
} finally {
closeSync(fd);
}
@@ -2333,7 +2333,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
);
}
try {
const currentContent = readFileSync(fd, "utf-8");
const currentContent = readFileSync(fd);
const currentVersion = fileContentVersion(currentContent);
if (expectedVersion !== currentVersion) {
return c.json(
@@ -2341,7 +2341,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
error: "file conflict",
path: res.filePath,
currentVersion,
currentContent,
currentContent: currentContent.toString("utf-8"),
},
409,
);
@@ -2350,7 +2350,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
if (backup.error)
console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
ftruncateSync(fd, 0);
writeSync(fd, body, 0, "utf-8");
writeSync(fd, body, 0, body.length, 0);
} finally {
closeSync(fd);
}
@@ -2375,14 +2375,17 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const res = await resolveProjectFile(c, adapter);
if ("error" in res) return res.error;
if (existsSync(res.absPath)) {
ensureDir(res.absPath);
const body = Buffer.from(await c.req.arrayBuffer());
try {
writeFileSync(res.absPath, body, { flag: "wx" });
} catch (error) {
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
throw error;
}
return c.json({ error: "already exists" }, 409);
}
ensureDir(res.absPath);
const body = await c.req.text().catch(() => "");
writeFileSync(res.absPath, body, "utf-8");
return c.json({ ok: true, path: res.filePath }, 201);
});