mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user