Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
createStudioApi,
createProjectSignature,
createBackgroundRemovalJob,
consumeFileWriteReceipt,
getMimeType,
type StudioApiAdapter,
type ResolvedProject,
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions packages/studio-server/src/helpers/fileVersion.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
51 changes: 51 additions & 0 deletions packages/studio-server/src/helpers/fileVersion.ts
Original file line number Diff line number Diff line change
@@ -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<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 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();
}
5 changes: 5 additions & 0 deletions packages/studio-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
155 changes: 154 additions & 1 deletion packages/studio-server/src/routes/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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(
"<html><body>Preview</body></html>",
);
});

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");
Expand All @@ -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");
Expand Down Expand Up @@ -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"),
'<div id="clip" data-start="0" data-duration="4">Clip</div>',
);
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 <template>, tweens
// targeting element variables resolved from querySelector, with interleaved
// gsap.set() calls. This is the shape every scaffolded composition uses.
Expand Down Expand Up @@ -465,13 +615,16 @@ const tl = gsap.timeline({ paused: true });
ok: boolean;
mutated?: boolean;
after: string;
version?: string;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};

expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.mutated).toBe(true);
expect(result.after).toContain("opacity: 0.2");
expect(result.version).toBe(fileContentVersion(result.after));
expect(res.headers.get("etag")).toBe(result.version);
expect(result.parsed.animations[0].fromProperties?.opacity).toBe(0.2);
// x unchanged
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);
Expand Down
Loading
Loading