Skip to content

Commit 0cbe760

Browse files
committed
fix(studio): enforce optimistic file concurrency
1 parent 3981498 commit 0cbe760

32 files changed

Lines changed: 702 additions & 67 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,
@@ -637,7 +638,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
637638
app.get("/api/events", (c) => {
638639
return streamSSE(c, async (stream) => {
639640
const listener = (path: string) => {
640-
stream.writeSSE({ event: "file-change", data: JSON.stringify({ path }) }).catch(() => {});
641+
const receipt = consumeFileWriteReceipt(resolve(projectDir, path));
642+
stream
643+
.writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) })
644+
.catch(() => {});
641645
};
642646
watcher.addListener(listener);
643647
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
@@ -13,6 +13,11 @@ import { tmpdir } from "node:os";
1313
import { join } from "node:path";
1414
import { commitElementPatchBatches, registerFileRoutes } from "./files";
1515
import type { StudioApiAdapter } from "../types";
16+
import {
17+
consumeFileWriteReceipt,
18+
fileContentVersion,
19+
resetFileWriteReceipts,
20+
} from "../helpers/fileVersion";
1621

1722
const recastImportGate = vi.hoisted<{
1823
wait: Promise<void> | null;
@@ -30,6 +35,7 @@ const tempDirs: string[] = [];
3035
afterEach(() => {
3136
recastImportGate.wait = null;
3237
recastImportGate.onEnter = null;
38+
resetFileWriteReceipts();
3339
for (const dir of tempDirs.splice(0)) {
3440
rmSync(dir, { recursive: true, force: true });
3541
}
@@ -104,6 +110,98 @@ describe("registerFileRoutes", () => {
104110
expect(response.status).toBe(404);
105111
});
106112

113+
it("returns the same strong content version in JSON and ETag", async () => {
114+
const projectDir = createProjectDir();
115+
const app = new Hono();
116+
registerFileRoutes(app, createAdapter(projectDir));
117+
118+
const response = await app.request("http://localhost/projects/demo/files/index.html");
119+
const payload = (await response.json()) as { content?: string; version?: string };
120+
121+
expect(payload.version).toBe(fileContentVersion(payload.content!));
122+
expect(response.headers.get("etag")).toBe(payload.version);
123+
});
124+
125+
it("requires If-Match for updates and preserves the current bytes", async () => {
126+
const projectDir = createProjectDir();
127+
const app = new Hono();
128+
registerFileRoutes(app, createAdapter(projectDir));
129+
130+
const response = await app.request("http://localhost/projects/demo/files/index.html", {
131+
method: "PUT",
132+
body: "stale overwrite",
133+
});
134+
135+
expect(response.status).toBe(428);
136+
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(
137+
"<html><body>Preview</body></html>",
138+
);
139+
});
140+
141+
it("requires an explicit create precondition for missing files", async () => {
142+
const projectDir = createProjectDir();
143+
const app = new Hono();
144+
registerFileRoutes(app, createAdapter(projectDir));
145+
146+
const response = await app.request("http://localhost/projects/demo/files/new.html", {
147+
method: "PUT",
148+
body: "new bytes",
149+
});
150+
151+
expect(response.status).toBe(428);
152+
expect(() => readFileSync(join(projectDir, "new.html"), "utf-8")).toThrow();
153+
});
154+
155+
it("creates a missing file only when it is still missing", async () => {
156+
const projectDir = createProjectDir();
157+
const app = new Hono();
158+
registerFileRoutes(app, createAdapter(projectDir));
159+
160+
const created = await app.request("http://localhost/projects/demo/files/new.html", {
161+
method: "PUT",
162+
headers: { "If-None-Match": "*" },
163+
body: "new bytes",
164+
});
165+
166+
expect(created.status).toBe(200);
167+
expect(readFileSync(join(projectDir, "new.html"), "utf-8")).toBe("new bytes");
168+
169+
const raced = await app.request("http://localhost/projects/demo/files/new.html", {
170+
method: "PUT",
171+
headers: { "If-None-Match": "*" },
172+
body: "overwrite",
173+
});
174+
const payload = (await raced.json()) as { currentContent?: string; currentVersion?: string };
175+
176+
expect(raced.status).toBe(409);
177+
expect(payload.currentContent).toBe("new bytes");
178+
expect(payload.currentVersion).toBe(fileContentVersion("new bytes"));
179+
expect(readFileSync(join(projectDir, "new.html"), "utf-8")).toBe("new bytes");
180+
});
181+
182+
it("returns 409 with the current version/content for a stale writer", async () => {
183+
const projectDir = createProjectDir();
184+
const app = new Hono();
185+
registerFileRoutes(app, createAdapter(projectDir));
186+
const current = "newer external bytes";
187+
writeFileSync(join(projectDir, "index.html"), current);
188+
189+
const response = await app.request("http://localhost/projects/demo/files/index.html", {
190+
method: "PUT",
191+
headers: { "If-Match": fileContentVersion("older bytes") },
192+
body: "stale overwrite",
193+
});
194+
const payload = (await response.json()) as {
195+
currentVersion?: string;
196+
currentContent?: string;
197+
};
198+
199+
expect(response.status).toBe(409);
200+
expect(payload.currentVersion).toBe(fileContentVersion(current));
201+
expect(payload.currentContent).toBe(current);
202+
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(current);
203+
});
204+
107205
it("backs up the previous file content before PUT overwrite", async () => {
108206
const projectDir = createProjectDir();
109207
writeFileSync(join(projectDir, "index.html"), "before");
@@ -112,12 +210,29 @@ describe("registerFileRoutes", () => {
112210

113211
const response = await app.request("http://localhost/projects/demo/files/index.html", {
114212
method: "PUT",
213+
headers: {
214+
"If-Match": fileContentVersion("before"),
215+
"X-Hyperframes-Write-Token": "studio-write-1",
216+
},
115217
body: "after",
116218
});
117-
const payload = (await response.json()) as { path?: string; backupPath?: string };
219+
const payload = (await response.json()) as {
220+
path?: string;
221+
version?: string;
222+
writeToken?: string;
223+
backupPath?: string;
224+
};
118225

119226
expect(response.status).toBe(200);
120227
expect(payload.path).toBe("index.html");
228+
expect(payload.version).toBe(fileContentVersion("after"));
229+
expect(payload.writeToken).toBe("studio-write-1");
230+
expect(response.headers.get("etag")).toBe(payload.version);
231+
expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({
232+
path: "index.html",
233+
version: payload.version,
234+
writeToken: "studio-write-1",
235+
});
121236
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
122237
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before");
123238
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe("after");
@@ -404,6 +519,41 @@ describe("registerFileRoutes", () => {
404519
expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false);
405520
});
406521

522+
it("returns the new strong version after a split-element mutation", async () => {
523+
const projectDir = createProjectDir();
524+
writeFileSync(
525+
join(projectDir, "index.html"),
526+
'<div id="clip" data-start="0" data-duration="4">Clip</div>',
527+
);
528+
const app = new Hono();
529+
registerFileRoutes(app, createAdapter(projectDir));
530+
531+
const response = await app.request(
532+
"http://localhost/projects/demo/file-mutations/split-element/index.html",
533+
{
534+
method: "POST",
535+
headers: { "Content-Type": "application/json" },
536+
body: JSON.stringify({
537+
target: { id: "clip" },
538+
splitTime: 2,
539+
newId: "clip-split",
540+
elementStart: 0,
541+
elementDuration: 4,
542+
}),
543+
},
544+
);
545+
const payload = (await response.json()) as {
546+
changed?: boolean;
547+
content?: string;
548+
version?: string;
549+
};
550+
551+
expect(response.status).toBe(200);
552+
expect(payload.changed).toBe(true);
553+
expect(payload.version).toBe(fileContentVersion(payload.content!));
554+
expect(response.headers.get("etag")).toBe(payload.version);
555+
});
556+
407557
// A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
408558
// targeting element variables resolved from querySelector, with interleaved
409559
// gsap.set() calls. This is the shape every scaffolded composition uses.
@@ -710,13 +860,16 @@ const tl = gsap.timeline({ paused: true });
710860
ok: boolean;
711861
mutated?: boolean;
712862
after: string;
863+
version?: string;
713864
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
714865
};
715866

716867
expect(res.status).toBe(200);
717868
expect(result.ok).toBe(true);
718869
expect(result.mutated).toBe(true);
719870
expect(result.after).toContain("opacity: 0.2");
871+
expect(result.version).toBe(fileContentVersion(result.after));
872+
expect(res.headers.get("etag")).toBe(result.version);
720873
expect(result.parsed.animations[0].fromProperties?.opacity).toBe(0.2);
721874
// x unchanged
722875
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);

0 commit comments

Comments
 (0)