Skip to content
Merged
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
38 changes: 34 additions & 4 deletions packages/cli/src/server/fileWatcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";

type WatchCallback = (eventType: string, filename: string | Buffer | null) => void;

const mockWatcher = new EventEmitter() as EventEmitter & { close: () => void };
mockWatcher.close = vi.fn();

vi.mock("node:fs", () => ({
watch: vi.fn(() => mockWatcher),
}));
vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs")>();
return {
...original,
watch: vi.fn((_path: string, _options: unknown, onChange: WatchCallback) => {
mockWatcher.on("change", onChange);
return mockWatcher;
}),
};
});

const { shouldWatchProjectFile, createProjectWatcher } = await import("./fileWatcher.js");

Expand All @@ -30,6 +39,27 @@ describe("shouldWatchProjectFile", () => {
});

describe("createProjectWatcher", () => {
beforeEach(() => {
mockWatcher.removeAllListeners();
vi.clearAllMocks();
vi.useRealTimers();
});

it("notifies once for every file changed in one debounce burst", () => {
vi.useFakeTimers();
const projectWatcher = createProjectWatcher("/fake/project/dir");
const listener = vi.fn();
projectWatcher.addListener(listener);

mockWatcher.emit("change", "change", "scene-a.html");
mockWatcher.emit("change", "change", "scene-b.html");
mockWatcher.emit("change", "change", "scene-a.html");
vi.advanceTimersByTime(300);

expect(listener.mock.calls).toEqual([["scene-a.html"], ["scene-b.html"]]);
projectWatcher.close();
});

// Regression: fs.watch can fail asynchronously (e.g. EMFILE from exhausted
// OS watch handles) via an 'error' event, not a thrown exception. An
// EventEmitter 'error' with no listener crashes the whole process — this
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/server/fileWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function shouldWatchProjectFile(filename: string): boolean {

export function createProjectWatcher(projectDir: string): ProjectWatcher {
const listeners = new Set<FileChangeListener>();
const pendingPaths = new Set<string>();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let watcher: FSWatcher | null = null;

Expand All @@ -43,10 +44,16 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
const relativePath = filename.toString();
if (!shouldWatchProjectFile(relativePath)) return;

pendingPaths.add(relativePath);
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
for (const fn of listeners) {
fn(relativePath);
const changedPaths = [...pendingPaths];
pendingPaths.clear();
debounceTimer = null;
for (const changedPath of changedPaths) {
for (const fn of listeners) {
fn(changedPath);
}
}
}, DEBOUNCE_MS);
});
Expand All @@ -72,6 +79,7 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
},
close() {
if (debounceTimer) clearTimeout(debounceTimer);
pendingPaths.clear();
watcher?.close();
listeners.clear();
},
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
createProjectSignature,
createBackgroundRemovalJob,
consumeFileWriteReceipt,
fileContentVersion,
getMimeType,
type PreviewApiAdapter,
thumbnailDeviceScaleFactor,
Expand Down Expand Up @@ -752,7 +753,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
app.get("/api/events", (c) => {
return streamSSE(c, async (stream) => {
const listener = (path: string) => {
const receipt = consumeFileWriteReceipt(resolve(projectDir, path));
const absPath = resolve(projectDir, path);
let version: string | null = null;
try {
version = fileContentVersion(readFileSync(absPath, "utf-8"));
} catch {
// A deletion has no current bytes to match against an API write receipt.
}
const receipt = version ? consumeFileWriteReceipt(absPath, version) : null;
stream
.writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) })
.catch(() => {});
Expand Down
22 changes: 20 additions & 2 deletions packages/studio-server/src/helpers/fileVersion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,25 @@ describe("file versions and write receipts", () => {
};
recordFileWriteReceipt("/project/index.html", receipt);

expect(consumeFileWriteReceipt("/project/index.html")).toEqual(receipt);
expect(consumeFileWriteReceipt("/project/index.html")).toBeNull();
expect(consumeFileWriteReceipt("/project/index.html", receipt.version)).toEqual(receipt);
expect(consumeFileWriteReceipt("/project/index.html", receipt.version)).toBeNull();
});

it("matches the final debounced watcher version instead of receipt insertion order", () => {
const first = {
path: "index.html",
version: fileContentVersion("first"),
writeToken: "write-1",
};
const last = {
path: "index.html",
version: fileContentVersion("last"),
writeToken: "write-2",
};
recordFileWriteReceipt("/project/index.html", first);
recordFileWriteReceipt("/project/index.html", last);

expect(consumeFileWriteReceipt("/project/index.html", last.version)).toEqual(last);
expect(consumeFileWriteReceipt("/project/index.html", first.version)).toEqual(first);
});
});
10 changes: 7 additions & 3 deletions packages/studio-server/src/helpers/fileVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,17 @@ export function recordFileWriteReceipt(absPath: string, receipt: FileWriteReceip
receipts.set(absPath, current);
}

/** Attach one API write's identity to the corresponding filesystem-watch echo. */
export function consumeFileWriteReceipt(absPath: string): FileWriteReceipt | null {
/** Attach one API write's identity to the watcher echo for its exact bytes. */
export function consumeFileWriteReceipt(
absPath: string,
expectedVersion: 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;
const receiptIndex = current.findIndex((entry) => entry.version === expectedVersion);
const receipt = receiptIndex === -1 ? null : (current.splice(receiptIndex, 1)[0] ?? null);
if (current.length > 0) receipts.set(absPath, current);
else receipts.delete(absPath);
if (!receipt) return null;
Expand Down
144 changes: 128 additions & 16 deletions packages/studio-server/src/routes/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,33 @@ function createAdapter(projectDir: string): StudioApiAdapter {
};
}

function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Promise<Response> {
function postElementPatchBatch(
app: Hono,
file: string,
patches: unknown[],
writeToken?: string,
): Promise<Response> {
return app.request(`http://localhost/projects/demo/file-mutations/patch-elements-batch/${file}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
...(writeToken ? { "X-Hyperframes-Write-Token": writeToken } : {}),
},
body: JSON.stringify({ patches }),
});
}

function postElementPatchBatches(
app: Hono,
batches: Array<{ sourceFile: string; patches: unknown[] }>,
writeToken?: string,
): Promise<Response> {
return app.request("http://localhost/projects/demo/file-mutations/patch-element-batches", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
...(writeToken ? { "X-Hyperframes-Write-Token": writeToken } : {}),
},
body: JSON.stringify({ batches }),
});
}
Expand Down Expand Up @@ -119,7 +131,10 @@ describe("registerFileRoutes", () => {
const insert = (expectedVersion: string) =>
app.request("http://localhost/projects/demo/file-mutations/insert-composition/index.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"X-Hyperframes-Write-Token": "studio-insert-1",
},
body: JSON.stringify({ sourcePath: "child.html", start: 4, track: 0, expectedVersion }),
});

Expand All @@ -131,6 +146,11 @@ describe("registerFileRoutes", () => {
expect(result.after).toContain('data-duration="7"');
expect(result.after).toContain(`id="${result.hostId}"`);
expect(result.version).toBe(fileContentVersion(result.after));
expect(consumeFileWriteReceipt(join(projectDir, "index.html"), result.version)).toEqual({
path: "index.html",
version: result.version,
writeToken: "studio-insert-1",
});

const committed = result.after;
const stale = await insert(fileContentVersion(before));
Expand Down Expand Up @@ -366,7 +386,7 @@ describe("registerFileRoutes", () => {
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({
expect(consumeFileWriteReceipt(join(projectDir, "index.html"), payload.version!)).toEqual({
path: "index.html",
version: payload.version,
writeToken: "studio-write-1",
Expand Down Expand Up @@ -425,6 +445,39 @@ describe("registerFileRoutes", () => {
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After");
});

// Without the receipt the client cannot recognise its own edit in the watcher
// broadcast, so it treats it as someone else's write and does a full preview
// reload — a visible blank on the stage right after the user typed.
it("leaves a write receipt so the patch's own file-change echo is identifiable", async () => {
const projectDir = createProjectDir();
writeFileSync(projectDir + "/index.html", '<div id="title">Before</div>');
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const response = await app.request(
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hyperframes-Write-Token": "studio-patch-1",
},
body: JSON.stringify({
target: { id: "title" },
operations: [{ type: "text-content", property: "textContent", value: "After" }],
}),
},
);

expect(response.status).toBe(200);
const version = fileContentVersion(readFileSync(join(projectDir, "index.html"), "utf-8"));
expect(consumeFileWriteReceipt(join(projectDir, "index.html"), version)).toEqual({
path: "index.html",
version,
writeToken: "studio-patch-1",
});
});

it("applies an ordered element patch batch with one file write", async () => {
const projectDir = createProjectDir();
const original =
Expand All @@ -433,16 +486,21 @@ describe("registerFileRoutes", () => {
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const response = await postElementPatchBatch(app, "index.html", [
{
target: { id: "back" },
operations: [{ type: "inline-style", property: "z-index", value: "2" }],
},
{
target: { id: "front" },
operations: [{ type: "inline-style", property: "z-index", value: "1" }],
},
]);
const response = await postElementPatchBatch(
app,
"index.html",
[
{
target: { id: "back" },
operations: [{ type: "inline-style", property: "z-index", value: "2" }],
},
{
target: { id: "front" },
operations: [{ type: "inline-style", property: "z-index", value: "1" }],
},
],
"studio-layer-order-1",
);
expect(response.status).toBe(200);
const payload = (await response.json()) as {
changed?: boolean;
Expand All @@ -458,6 +516,12 @@ describe("registerFileRoutes", () => {
expect(payload.content).toContain('id="back" style="z-index: 2"');
expect(payload.content).toContain('id="front" style="z-index: 1"');
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe(original);
const version = fileContentVersion(payload.content!);
expect(consumeFileWriteReceipt(join(projectDir, "index.html"), version)).toEqual({
path: "index.html",
version,
writeToken: "studio-layer-order-1",
});
expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(1);
});

Expand Down Expand Up @@ -519,6 +583,52 @@ describe("registerFileRoutes", () => {
expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false);
});

it("leaves one exact write receipt for every file in a durable element patch batch", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "index.html"), '<div id="index">Before</div>');
writeFileSync(join(projectDir, "scene.html"), '<div id="scene">Before</div>');
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));

const response = await postElementPatchBatches(
app,
[
{
sourceFile: "index.html",
patches: [
{
target: { id: "index" },
operations: [{ type: "text-content", property: "textContent", value: "After" }],
},
],
},
{
sourceFile: "scene.html",
patches: [
{
target: { id: "scene" },
operations: [{ type: "text-content", property: "textContent", value: "After" }],
},
],
},
],
"studio-group-drag-1",
);
const payload = (await response.json()) as {
files: Array<{ sourceFile: string; after: string }>;
};

expect(response.status).toBe(200);
for (const file of payload.files) {
const version = fileContentVersion(file.after);
expect(consumeFileWriteReceipt(join(projectDir, file.sourceFile), version)).toEqual({
path: file.sourceFile,
version,
writeToken: "studio-group-drag-1",
});
}
});

it("refuses every file when one batch contains an unmatched target", async () => {
const projectDir = createProjectDir();
const indexOriginal = '<div id="present" style="z-index: 1">Present</div>';
Expand Down Expand Up @@ -732,7 +842,9 @@ describe("registerFileRoutes", () => {
expect(payload.files[0].after).toContain('id="a-split"');
expect(payload.files[0].after).toContain('id="b-split"');
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(payload.files[0].after);
expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({
expect(
consumeFileWriteReceipt(join(projectDir, "index.html"), payload.files[0].version),
).toEqual({
path: "index.html",
version: payload.files[0].version,
writeToken: "cut-test",
Expand Down
Loading
Loading