Skip to content

Commit 0b9eb5d

Browse files
committed
fix(studio): enforce optimistic file concurrency
1 parent 4dc6e83 commit 0b9eb5d

29 files changed

Lines changed: 676 additions & 62 deletions

packages/cli/src/server/studioServer.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
createStudioApi,
2525
createProjectSignature,
2626
createBackgroundRemovalJob,
27+
consumeFileWriteReceipt,
2728
getMimeType,
2829
type StudioApiAdapter,
2930
type ResolvedProject,
@@ -636,7 +637,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
636637
app.get("/api/events", (c) => {
637638
return streamSSE(c, async (stream) => {
638639
const listener = (path: string) => {
639-
stream.writeSSE({ event: "file-change", data: JSON.stringify({ path }) }).catch(() => {});
640+
const receipt = consumeFileWriteReceipt(resolve(projectDir, path));
641+
stream
642+
.writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) })
643+
.catch(() => {});
640644
};
641645
watcher.addListener(listener);
642646
while (true) {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import {
3+
consumeFileWriteReceipt,
4+
fileContentVersion,
5+
recordFileWriteReceipt,
6+
resetFileWriteReceipts,
7+
} from "./fileVersion";
8+
9+
afterEach(resetFileWriteReceipts);
10+
11+
describe("file versions and write receipts", () => {
12+
it("produces a strong quoted SHA-256 ETag", () => {
13+
expect(fileContentVersion("abc")).toBe(
14+
'"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"',
15+
);
16+
});
17+
18+
it("attaches each API write identity to exactly one watcher echo", () => {
19+
const receipt = {
20+
path: "index.html",
21+
version: fileContentVersion("after"),
22+
writeToken: "write-1",
23+
};
24+
recordFileWriteReceipt("/project/index.html", receipt);
25+
26+
expect(consumeFileWriteReceipt("/project/index.html")).toEqual(receipt);
27+
expect(consumeFileWriteReceipt("/project/index.html")).toBeNull();
28+
});
29+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { createHash, randomUUID } from "node:crypto";
2+
3+
export interface FileWriteReceipt {
4+
path: string;
5+
version: string;
6+
writeToken: string;
7+
}
8+
9+
interface StoredReceipt extends FileWriteReceipt {
10+
recordedAt: number;
11+
}
12+
13+
const RECEIPT_TTL_MS = 10_000;
14+
const receipts = new Map<string, StoredReceipt[]>();
15+
16+
/** Strong content version used as both the JSON version and HTTP ETag. */
17+
export function fileContentVersion(content: string): string {
18+
return `"sha256:${createHash("sha256").update(content, "utf8").digest("hex")}"`;
19+
}
20+
21+
export function createWriteToken(requestToken?: string): string {
22+
const token = requestToken?.trim();
23+
return token && token.length <= 200 ? token : randomUUID();
24+
}
25+
26+
export function recordFileWriteReceipt(absPath: string, receipt: FileWriteReceipt): void {
27+
const now = Date.now();
28+
const current = (receipts.get(absPath) ?? []).filter(
29+
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS,
30+
);
31+
current.push({ ...receipt, recordedAt: now });
32+
receipts.set(absPath, current);
33+
}
34+
35+
/** Attach one API write's identity to the corresponding filesystem-watch echo. */
36+
export function consumeFileWriteReceipt(absPath: string): FileWriteReceipt | null {
37+
const now = Date.now();
38+
const current = (receipts.get(absPath) ?? []).filter(
39+
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS,
40+
);
41+
const receipt = current.shift() ?? null;
42+
if (current.length > 0) receipts.set(absPath, current);
43+
else receipts.delete(absPath);
44+
if (!receipt) return null;
45+
const { path, version, writeToken } = receipt;
46+
return { path, version, writeToken };
47+
}
48+
49+
export function resetFileWriteReceipts(): void {
50+
receipts.clear();
51+
}

packages/studio-server/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ export type {
1212
} from "./types.js";
1313
export { isSafePath, walkDir } from "./helpers/safePath.js";
1414
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
15+
export {
16+
consumeFileWriteReceipt,
17+
fileContentVersion,
18+
type FileWriteReceipt,
19+
} from "./helpers/fileVersion.js";
1520
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
1621
export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screenshotClip.js";
1722
export {

packages/studio-server/src/routes/files.test.ts

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@ import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { registerFileRoutes } from "./files";
77
import type { StudioApiAdapter } from "../types";
8+
import {
9+
consumeFileWriteReceipt,
10+
fileContentVersion,
11+
resetFileWriteReceipts,
12+
} from "../helpers/fileVersion";
813

914
const tempDirs: string[] = [];
1015

1116
afterEach(() => {
17+
resetFileWriteReceipts();
1218
for (const dir of tempDirs.splice(0)) {
1319
rmSync(dir, { recursive: true, force: true });
1420
}
@@ -64,6 +70,98 @@ describe("registerFileRoutes", () => {
6470
expect(response.status).toBe(404);
6571
});
6672

73+
it("returns the same strong content version in JSON and ETag", async () => {
74+
const projectDir = createProjectDir();
75+
const app = new Hono();
76+
registerFileRoutes(app, createAdapter(projectDir));
77+
78+
const response = await app.request("http://localhost/projects/demo/files/index.html");
79+
const payload = (await response.json()) as { content?: string; version?: string };
80+
81+
expect(payload.version).toBe(fileContentVersion(payload.content!));
82+
expect(response.headers.get("etag")).toBe(payload.version);
83+
});
84+
85+
it("requires If-Match for updates and preserves the current bytes", async () => {
86+
const projectDir = createProjectDir();
87+
const app = new Hono();
88+
registerFileRoutes(app, createAdapter(projectDir));
89+
90+
const response = await app.request("http://localhost/projects/demo/files/index.html", {
91+
method: "PUT",
92+
body: "stale overwrite",
93+
});
94+
95+
expect(response.status).toBe(428);
96+
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(
97+
"<html><body>Preview</body></html>",
98+
);
99+
});
100+
101+
it("requires an explicit create precondition for missing files", async () => {
102+
const projectDir = createProjectDir();
103+
const app = new Hono();
104+
registerFileRoutes(app, createAdapter(projectDir));
105+
106+
const response = await app.request("http://localhost/projects/demo/files/new.html", {
107+
method: "PUT",
108+
body: "new bytes",
109+
});
110+
111+
expect(response.status).toBe(428);
112+
expect(() => readFileSync(join(projectDir, "new.html"), "utf-8")).toThrow();
113+
});
114+
115+
it("creates a missing file only when it is still missing", async () => {
116+
const projectDir = createProjectDir();
117+
const app = new Hono();
118+
registerFileRoutes(app, createAdapter(projectDir));
119+
120+
const created = await app.request("http://localhost/projects/demo/files/new.html", {
121+
method: "PUT",
122+
headers: { "If-None-Match": "*" },
123+
body: "new bytes",
124+
});
125+
126+
expect(created.status).toBe(200);
127+
expect(readFileSync(join(projectDir, "new.html"), "utf-8")).toBe("new bytes");
128+
129+
const raced = await app.request("http://localhost/projects/demo/files/new.html", {
130+
method: "PUT",
131+
headers: { "If-None-Match": "*" },
132+
body: "overwrite",
133+
});
134+
const payload = (await raced.json()) as { currentContent?: string; currentVersion?: string };
135+
136+
expect(raced.status).toBe(409);
137+
expect(payload.currentContent).toBe("new bytes");
138+
expect(payload.currentVersion).toBe(fileContentVersion("new bytes"));
139+
expect(readFileSync(join(projectDir, "new.html"), "utf-8")).toBe("new bytes");
140+
});
141+
142+
it("returns 409 with the current version/content for a stale writer", async () => {
143+
const projectDir = createProjectDir();
144+
const app = new Hono();
145+
registerFileRoutes(app, createAdapter(projectDir));
146+
const current = "newer external bytes";
147+
writeFileSync(join(projectDir, "index.html"), current);
148+
149+
const response = await app.request("http://localhost/projects/demo/files/index.html", {
150+
method: "PUT",
151+
headers: { "If-Match": fileContentVersion("older bytes") },
152+
body: "stale overwrite",
153+
});
154+
const payload = (await response.json()) as {
155+
currentVersion?: string;
156+
currentContent?: string;
157+
};
158+
159+
expect(response.status).toBe(409);
160+
expect(payload.currentVersion).toBe(fileContentVersion(current));
161+
expect(payload.currentContent).toBe(current);
162+
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(current);
163+
});
164+
67165
it("backs up the previous file content before PUT overwrite", async () => {
68166
const projectDir = createProjectDir();
69167
writeFileSync(join(projectDir, "index.html"), "before");
@@ -72,12 +170,29 @@ describe("registerFileRoutes", () => {
72170

73171
const response = await app.request("http://localhost/projects/demo/files/index.html", {
74172
method: "PUT",
173+
headers: {
174+
"If-Match": fileContentVersion("before"),
175+
"X-Hyperframes-Write-Token": "studio-write-1",
176+
},
75177
body: "after",
76178
});
77-
const payload = (await response.json()) as { path?: string; backupPath?: string };
179+
const payload = (await response.json()) as {
180+
path?: string;
181+
version?: string;
182+
writeToken?: string;
183+
backupPath?: string;
184+
};
78185

79186
expect(response.status).toBe(200);
80187
expect(payload.path).toBe("index.html");
188+
expect(payload.version).toBe(fileContentVersion("after"));
189+
expect(payload.writeToken).toBe("studio-write-1");
190+
expect(response.headers.get("etag")).toBe(payload.version);
191+
expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({
192+
path: "index.html",
193+
version: payload.version,
194+
writeToken: "studio-write-1",
195+
});
81196
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
82197
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before");
83198
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe("after");
@@ -132,6 +247,41 @@ describe("registerFileRoutes", () => {
132247
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After");
133248
});
134249

250+
it("returns the new strong version after a split-element mutation", async () => {
251+
const projectDir = createProjectDir();
252+
writeFileSync(
253+
join(projectDir, "index.html"),
254+
'<div id="clip" data-start="0" data-duration="4">Clip</div>',
255+
);
256+
const app = new Hono();
257+
registerFileRoutes(app, createAdapter(projectDir));
258+
259+
const response = await app.request(
260+
"http://localhost/projects/demo/file-mutations/split-element/index.html",
261+
{
262+
method: "POST",
263+
headers: { "Content-Type": "application/json" },
264+
body: JSON.stringify({
265+
target: { id: "clip" },
266+
splitTime: 2,
267+
newId: "clip-split",
268+
elementStart: 0,
269+
elementDuration: 4,
270+
}),
271+
},
272+
);
273+
const payload = (await response.json()) as {
274+
changed?: boolean;
275+
content?: string;
276+
version?: string;
277+
};
278+
279+
expect(response.status).toBe(200);
280+
expect(payload.changed).toBe(true);
281+
expect(payload.version).toBe(fileContentVersion(payload.content!));
282+
expect(response.headers.get("etag")).toBe(payload.version);
283+
});
284+
135285
// A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
136286
// targeting element variables resolved from querySelector, with interleaved
137287
// gsap.set() calls. This is the shape every scaffolded composition uses.
@@ -229,13 +379,16 @@ tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, eas
229379
ok: boolean;
230380
mutated?: boolean;
231381
after: string;
382+
version?: string;
232383
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
233384
};
234385

235386
expect(res.status).toBe(200);
236387
expect(result.ok).toBe(true);
237388
expect(result.mutated).toBe(true);
238389
expect(result.after).toContain("opacity: 0.2");
390+
expect(result.version).toBe(fileContentVersion(result.after));
391+
expect(res.headers.get("etag")).toBe(result.version);
239392
expect(result.parsed.animations[0].fromProperties?.opacity).toBe(0.2);
240393
// x unchanged
241394
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);

0 commit comments

Comments
 (0)