diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index c134fb1034..344f2acef0 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -24,6 +24,7 @@ import { createStudioApi, createProjectSignature, createBackgroundRemovalJob, + consumeFileWriteReceipt, getMimeType, type StudioApiAdapter, type ResolvedProject, @@ -636,7 +637,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { app.get("/api/events", (c) => { return streamSSE(c, async (stream) => { const listener = (path: string) => { - stream.writeSSE({ event: "file-change", data: JSON.stringify({ path }) }).catch(() => {}); + const receipt = consumeFileWriteReceipt(resolve(projectDir, path)); + stream + .writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) }) + .catch(() => {}); }; watcher.addListener(listener); while (true) { diff --git a/packages/studio-server/src/helpers/fileVersion.test.ts b/packages/studio-server/src/helpers/fileVersion.test.ts new file mode 100644 index 0000000000..3ecd0fbdb0 --- /dev/null +++ b/packages/studio-server/src/helpers/fileVersion.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + consumeFileWriteReceipt, + fileContentVersion, + recordFileWriteReceipt, + resetFileWriteReceipts, +} from "./fileVersion"; + +afterEach(resetFileWriteReceipts); + +describe("file versions and write receipts", () => { + it("produces a strong quoted SHA-256 ETag", () => { + expect(fileContentVersion("abc")).toBe( + '"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"', + ); + }); + + it("attaches each API write identity to exactly one watcher echo", () => { + const receipt = { + path: "index.html", + version: fileContentVersion("after"), + writeToken: "write-1", + }; + recordFileWriteReceipt("/project/index.html", receipt); + + expect(consumeFileWriteReceipt("/project/index.html")).toEqual(receipt); + expect(consumeFileWriteReceipt("/project/index.html")).toBeNull(); + }); +}); diff --git a/packages/studio-server/src/helpers/fileVersion.ts b/packages/studio-server/src/helpers/fileVersion.ts new file mode 100644 index 0000000000..ab95e866b5 --- /dev/null +++ b/packages/studio-server/src/helpers/fileVersion.ts @@ -0,0 +1,51 @@ +import { createHash, randomUUID } from "node:crypto"; + +export interface FileWriteReceipt { + path: string; + version: string; + writeToken: string; +} + +interface StoredReceipt extends FileWriteReceipt { + recordedAt: number; +} + +const RECEIPT_TTL_MS = 10_000; +const receipts = new Map(); + +/** 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 createWriteToken(requestToken?: string): string { + const token = requestToken?.trim(); + return token && token.length <= 200 ? token : randomUUID(); +} + +export function recordFileWriteReceipt(absPath: string, receipt: FileWriteReceipt): void { + const now = Date.now(); + const current = (receipts.get(absPath) ?? []).filter( + (entry) => now - entry.recordedAt < RECEIPT_TTL_MS, + ); + current.push({ ...receipt, recordedAt: now }); + receipts.set(absPath, current); +} + +/** Attach one API write's identity to the corresponding filesystem-watch echo. */ +export function consumeFileWriteReceipt(absPath: string): FileWriteReceipt | null { + const now = Date.now(); + const current = (receipts.get(absPath) ?? []).filter( + (entry) => now - entry.recordedAt < RECEIPT_TTL_MS, + ); + const receipt = current.shift() ?? null; + if (current.length > 0) receipts.set(absPath, current); + else receipts.delete(absPath); + if (!receipt) return null; + const { path, version, writeToken } = receipt; + return { path, version, writeToken }; +} + +export function resetFileWriteReceipts(): void { + receipts.clear(); +} diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index 4211df1db6..d663a21035 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -12,6 +12,11 @@ export type { } from "./types.js"; export { isSafePath, walkDir } from "./helpers/safePath.js"; export { getMimeType, MIME_TYPES } from "./helpers/mime.js"; +export { + consumeFileWriteReceipt, + fileContentVersion, + type FileWriteReceipt, +} from "./helpers/fileVersion.js"; export { buildSubCompositionHtml } from "./helpers/subComposition.js"; export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screenshotClip.js"; export { diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 6df7cf3672..dd937e769c 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -13,10 +13,16 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { registerFileRoutes } from "./files"; import type { StudioApiAdapter } from "../types"; +import { + consumeFileWriteReceipt, + fileContentVersion, + resetFileWriteReceipts, +} from "../helpers/fileVersion"; const tempDirs: string[] = []; afterEach(() => { + resetFileWriteReceipts(); for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -80,6 +86,98 @@ describe("registerFileRoutes", () => { expect(response.status).toBe(404); }); + it("returns the same strong content version in JSON and ETag", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/files/index.html"); + const payload = (await response.json()) as { content?: string; version?: string }; + + expect(payload.version).toBe(fileContentVersion(payload.content!)); + expect(response.headers.get("etag")).toBe(payload.version); + }); + + it("requires If-Match for updates and preserves the current bytes", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/files/index.html", { + method: "PUT", + body: "stale overwrite", + }); + + expect(response.status).toBe(428); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe( + "Preview", + ); + }); + + it("requires an explicit create precondition for missing files", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/files/new.html", { + method: "PUT", + body: "new bytes", + }); + + expect(response.status).toBe(428); + expect(() => readFileSync(join(projectDir, "new.html"), "utf-8")).toThrow(); + }); + + it("creates a missing file only when it is still missing", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const created = await app.request("http://localhost/projects/demo/files/new.html", { + method: "PUT", + headers: { "If-None-Match": "*" }, + body: "new bytes", + }); + + expect(created.status).toBe(200); + expect(readFileSync(join(projectDir, "new.html"), "utf-8")).toBe("new bytes"); + + const raced = await app.request("http://localhost/projects/demo/files/new.html", { + method: "PUT", + headers: { "If-None-Match": "*" }, + body: "overwrite", + }); + const payload = (await raced.json()) as { currentContent?: string; currentVersion?: string }; + + expect(raced.status).toBe(409); + expect(payload.currentContent).toBe("new bytes"); + expect(payload.currentVersion).toBe(fileContentVersion("new bytes")); + expect(readFileSync(join(projectDir, "new.html"), "utf-8")).toBe("new bytes"); + }); + + it("returns 409 with the current version/content for a stale writer", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + const current = "newer external bytes"; + writeFileSync(join(projectDir, "index.html"), current); + + const response = await app.request("http://localhost/projects/demo/files/index.html", { + method: "PUT", + headers: { "If-Match": fileContentVersion("older bytes") }, + body: "stale overwrite", + }); + const payload = (await response.json()) as { + currentVersion?: string; + currentContent?: string; + }; + + expect(response.status).toBe(409); + expect(payload.currentVersion).toBe(fileContentVersion(current)); + expect(payload.currentContent).toBe(current); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(current); + }); + it("backs up the previous file content before PUT overwrite", async () => { const projectDir = createProjectDir(); writeFileSync(join(projectDir, "index.html"), "before"); @@ -88,12 +186,29 @@ describe("registerFileRoutes", () => { const response = await app.request("http://localhost/projects/demo/files/index.html", { method: "PUT", + headers: { + "If-Match": fileContentVersion("before"), + "X-Hyperframes-Write-Token": "studio-write-1", + }, body: "after", }); - const payload = (await response.json()) as { path?: string; backupPath?: string }; + const payload = (await response.json()) as { + path?: string; + version?: string; + writeToken?: string; + backupPath?: string; + }; expect(response.status).toBe(200); expect(payload.path).toBe("index.html"); + expect(payload.version).toBe(fileContentVersion("after")); + expect(payload.writeToken).toBe("studio-write-1"); + expect(response.headers.get("etag")).toBe(payload.version); + expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({ + path: "index.html", + version: payload.version, + writeToken: "studio-write-1", + }); expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//); expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before"); expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe("after"); @@ -238,6 +353,41 @@ describe("registerFileRoutes", () => { expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false); }); + it("returns the new strong version after a split-element mutation", async () => { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "index.html"), + '
Clip
', + ); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/file-mutations/split-element/index.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + target: { id: "clip" }, + splitTime: 2, + newId: "clip-split", + elementStart: 0, + elementDuration: 4, + }), + }, + ); + const payload = (await response.json()) as { + changed?: boolean; + content?: string; + version?: string; + }; + + expect(response.status).toBe(200); + expect(payload.changed).toBe(true); + expect(payload.version).toBe(fileContentVersion(payload.content!)); + expect(response.headers.get("etag")).toBe(payload.version); + }); + // A realistic sub-composition: markup + GSAP wrapped in a