mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(studio): stop a Studio edit from reloading the preview (#3137)
* fix(studio): stop a Studio edit from reloading the preview as if it were external Every mutation route wrote the file without leaving a write receipt, so the watcher's broadcast of Studio's own edit arrived with no identity on it. The external-change coordinator could not tell that echo from an agent or an editor writing the file behind Studio's back, so it took the safe branch and did a full iframe reload. That reload hides the stage for the length of the reload, which is what the flash after a text edit was. Every mutation write now goes through one helper that records the receipt, and the client claims the write before the request goes out rather than after it: the server writes and the watcher fires while the request is still in flight, so a token marked from the response can arrive after the echo it was meant to match. Reproduced in the browser before and after, with the reload path traced end to end. Before, a patch-element write logged `token: null` then a reload from the coordinator; after, the same write logs the token and `suppressed: own write token`, with no reload. Adds `hf-reload-debug` (localStorage, off by default) alongside the existing `hf-resize-debug`: it records each file-change decision and its reason, plus the stack of whoever asked for a full reload. * fix(studio): claim the timeline and caption writes too, not just the DOM ones The receipt only helps when the client marked the token it sent, and the GSAP mutation writers never sent one. A drag commits through gsap-mutations, so the server minted a token the client had never seen, the change came back looking like someone else's, and the preview did the full reload the receipt was meant to prevent. Same one-line claim on both GSAP mutation writers, the timing sync's mutation call, and the caption auto-save PUT. The rollback call stays deliberately unclaimed and says why: it runs because a mutation did not converge, so the preview is on bytes nobody can vouch for and the reload is the point. Verified live: a drag-shaped update-properties on the timeline now logs `suppressed: own write token` with no reload, where it logged a coordinator reload before. * refactor(studio): keep timelineTimingSync under the size cap Claiming the timeline writes pushed this file one line past the 600-line gate. Same change as the branch made later, landed with the commit that caused it. * fix(studio): cover remaining write receipt paths * fix(studio): preserve batch write receipts * fix(cli): emit every file in a watcher burst
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -72,6 +79,7 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
|
||||
},
|
||||
close() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
pendingPaths.clear();
|
||||
watcher?.close();
|
||||
listeners.clear();
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
createProjectSignature,
|
||||
createBackgroundRemovalJob,
|
||||
consumeFileWriteReceipt,
|
||||
fileContentVersion,
|
||||
getMimeType,
|
||||
type PreviewApiAdapter,
|
||||
thumbnailDeviceScaleFactor,
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -65,10 +65,18 @@ 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 }),
|
||||
});
|
||||
}
|
||||
@@ -76,10 +84,14 @@ function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Pro
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
@@ -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",
|
||||
@@ -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 =
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>';
|
||||
@@ -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",
|
||||
|
||||
@@ -112,6 +112,7 @@ interface RouteContext {
|
||||
param: (name: string) => string;
|
||||
path: string;
|
||||
query: (name: string) => string | undefined;
|
||||
header: (name: string) => string | undefined;
|
||||
};
|
||||
header: (name: string, value: string) => void;
|
||||
json: (data: unknown, status?: number) => Response;
|
||||
@@ -399,6 +400,69 @@ export function commitElementPatchBatches(
|
||||
return { durable: true, files };
|
||||
}
|
||||
|
||||
function commitElementPatchBatchesWithReceipts(
|
||||
c: RouteContext,
|
||||
projectDir: string,
|
||||
batches: ElementPatchBatchRequest[],
|
||||
): ReturnType<typeof commitElementPatchBatches> {
|
||||
const result = commitElementPatchBatches(projectDir, batches);
|
||||
if ("error" in result || !result.durable) return result;
|
||||
|
||||
for (const file of result.files) {
|
||||
if (!file.changed) continue;
|
||||
const absPath = resolveWithinProject(projectDir, file.sourceFile);
|
||||
if (!absPath) throw new Error(`Committed element patch escaped project: ${file.sourceFile}`);
|
||||
recordMutationReceipt(c, file.sourceFile, absPath, file.after);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the receipt that claims a mutation result.
|
||||
*
|
||||
* The file watcher broadcasts every write, including the ones Studio itself just
|
||||
* asked for. The receipt is what lets the client tell its own echo from an agent
|
||||
* or an editor writing the file behind its back: without one, the client treats
|
||||
* its own edit as an external change and does a full preview reload, which blanks
|
||||
* the stage for a few hundred milliseconds right after the user typed. Every
|
||||
* mutation route records through here so no route can forget.
|
||||
*/
|
||||
function recordMutationReceipt(
|
||||
c: RouteContext,
|
||||
filePath: string,
|
||||
absPath: string,
|
||||
html: string,
|
||||
): { version: string; writeToken: string } {
|
||||
const version = fileContentVersion(html);
|
||||
const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
|
||||
recordFileWriteReceipt(absPath, { path: filePath, version, writeToken });
|
||||
return { version, writeToken };
|
||||
}
|
||||
|
||||
function writeFileWithReceipt(
|
||||
c: RouteContext,
|
||||
filePath: string,
|
||||
absPath: string,
|
||||
html: string,
|
||||
): { version: string; writeToken: string } {
|
||||
writeFileSync(absPath, html, "utf-8");
|
||||
// The synchronous write cannot yield before its receipt is recorded; keep this block await-free.
|
||||
return recordMutationReceipt(c, filePath, absPath, html);
|
||||
}
|
||||
|
||||
function writeMutationResult(
|
||||
c: RouteContext,
|
||||
projectDir: string,
|
||||
filePath: string,
|
||||
absPath: string,
|
||||
html: string,
|
||||
): { backupPath: string | null; version: string } {
|
||||
const backup = snapshotBeforeWrite(projectDir, absPath);
|
||||
if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`);
|
||||
const { version } = writeFileWithReceipt(c, filePath, absPath, html);
|
||||
return { backupPath: backupPathForResponse(projectDir, backup.backupPath), version };
|
||||
}
|
||||
|
||||
/** Write `next` to `absPath` only if it differs from `original`, returning a standardized change response. */
|
||||
function writeIfChanged(
|
||||
c: RouteContext,
|
||||
@@ -411,15 +475,13 @@ function writeIfChanged(
|
||||
if (next === original) {
|
||||
return c.json({ ok: true, changed: false, content: original, path: filePath });
|
||||
}
|
||||
const backup = snapshotBeforeWrite(projectDir, absPath);
|
||||
if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`);
|
||||
writeFileSync(absPath, next, "utf-8");
|
||||
const { backupPath } = writeMutationResult(c, projectDir, filePath, absPath, next);
|
||||
return c.json({
|
||||
ok: true,
|
||||
changed: true,
|
||||
content: next,
|
||||
path: filePath,
|
||||
backupPath: backupPathForResponse(projectDir, backup.backupPath),
|
||||
backupPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1238,10 +1300,13 @@ async function applyGsapMutations(
|
||||
return c.json({ error: "file changed during GSAP mutation", conflict: true }, 409);
|
||||
}
|
||||
if (changed) {
|
||||
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
|
||||
if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
|
||||
backupPath = backupPathForResponse(res.project.dir, backup.backupPath);
|
||||
writeFileSync(res.absPath, newHtml, "utf-8");
|
||||
backupPath = writeMutationResult(
|
||||
c,
|
||||
res.project.dir,
|
||||
res.filePath,
|
||||
res.absPath,
|
||||
newHtml,
|
||||
).backupPath;
|
||||
}
|
||||
|
||||
const responsePayload: Record<string, unknown> = {
|
||||
@@ -2388,10 +2453,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
|
||||
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
|
||||
if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
|
||||
writeFileSync(ctx.absPath, insertion.html, "utf-8");
|
||||
const version = fileContentVersion(insertion.html);
|
||||
const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
|
||||
recordFileWriteReceipt(ctx.absPath, { path: ctx.filePath, version, writeToken });
|
||||
const { version, writeToken } = writeFileWithReceipt(
|
||||
c,
|
||||
ctx.filePath,
|
||||
ctx.absPath,
|
||||
insertion.html,
|
||||
);
|
||||
c.header("ETag", version);
|
||||
return c.json({
|
||||
ok: true,
|
||||
@@ -2623,10 +2690,13 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
version,
|
||||
});
|
||||
}
|
||||
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
|
||||
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
|
||||
writeFileSync(ctx.absPath, result.html, "utf-8");
|
||||
const version = fileContentVersion(result.html);
|
||||
const { version, backupPath } = writeMutationResult(
|
||||
c,
|
||||
ctx.project.dir,
|
||||
ctx.filePath,
|
||||
ctx.absPath,
|
||||
result.html,
|
||||
);
|
||||
c.header("ETag", version);
|
||||
return c.json({
|
||||
ok: true,
|
||||
@@ -2635,7 +2705,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
newId: result.newId,
|
||||
path: ctx.filePath,
|
||||
version,
|
||||
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
|
||||
backupPath,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2676,16 +2746,20 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
path: ctx.filePath,
|
||||
});
|
||||
}
|
||||
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
|
||||
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
|
||||
writeFileSync(ctx.absPath, patched, "utf-8");
|
||||
const { backupPath } = writeMutationResult(
|
||||
c,
|
||||
ctx.project.dir,
|
||||
ctx.filePath,
|
||||
ctx.absPath,
|
||||
patched,
|
||||
);
|
||||
return c.json({
|
||||
ok: true,
|
||||
changed: true,
|
||||
matched,
|
||||
content: patched,
|
||||
path: ctx.filePath,
|
||||
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
|
||||
backupPath,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2707,7 +2781,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
const unsafeFields = findUnsafeElementPatchBatchValues(body.batches);
|
||||
if (unsafeFields.length > 0) return rejectUnsafeMutationValues(c, unsafeFields);
|
||||
|
||||
const result = commitElementPatchBatches(project.dir, body.batches);
|
||||
const result = commitElementPatchBatchesWithReceipts(c, project.dir, body.batches);
|
||||
if ("error" in result) {
|
||||
return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile);
|
||||
}
|
||||
@@ -2735,7 +2809,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
return rejectUnsafeMutationValues(c, unsafeFields);
|
||||
}
|
||||
|
||||
const result = commitElementPatchBatches(ctx.project.dir, [batch]);
|
||||
const result = commitElementPatchBatchesWithReceipts(c, ctx.project.dir, [batch]);
|
||||
if ("error" in result) {
|
||||
return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile);
|
||||
}
|
||||
@@ -2807,16 +2881,20 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
result.error === "grouped elements must share a single parent" ? 422 : 400,
|
||||
);
|
||||
}
|
||||
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
|
||||
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
|
||||
writeFileSync(ctx.absPath, result.html, "utf-8");
|
||||
const { backupPath } = writeMutationResult(
|
||||
c,
|
||||
ctx.project.dir,
|
||||
ctx.filePath,
|
||||
ctx.absPath,
|
||||
result.html,
|
||||
);
|
||||
return c.json({
|
||||
ok: true,
|
||||
changed: true,
|
||||
groupId: result.groupId,
|
||||
content: result.html,
|
||||
path: ctx.filePath,
|
||||
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
|
||||
backupPath,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCaptionStore } from "../store";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { trackEvent } from "../../telemetry/client";
|
||||
import type { CaptionStyle } from "../types";
|
||||
import { studioWriteHeaders } from "../../utils/studioFileVersion";
|
||||
|
||||
interface CaptionOverrideEntry {
|
||||
wordId?: string;
|
||||
@@ -77,7 +78,7 @@ export function useCaptionSync(projectId: string | null) {
|
||||
|
||||
fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
|
||||
body: JSON.stringify(overrides, null, 2),
|
||||
}).catch((error: unknown) => {
|
||||
// Caption auto-save is a data-loss path; surface failures via telemetry
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
|
||||
export { PROPERTY_DEFAULTS } from "./gsapShared";
|
||||
import { idSelector, matchesExactlyOne } from "./gsapShared";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
|
||||
/**
|
||||
* The selector to author a NEW tween against, minting an id on the element when
|
||||
@@ -119,7 +120,7 @@ export async function assignGsapTargetAutoIdIfNeeded({
|
||||
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify(patchBody),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftR
|
||||
import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
|
||||
import type { RecordEditInput } from "../utils/studioFileHistory";
|
||||
import { patchDocumentRootDuration } from "./timelineEditingGsap";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
|
||||
class GsapPreviewConvergenceError extends Error {}
|
||||
class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
|
||||
@@ -58,6 +59,8 @@ async function rollbackOwnedMutation(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
// Deliberately unclaimed: a rollback runs because a mutation did not
|
||||
// converge, so let the restored file reload the preview.
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expected, restore }),
|
||||
},
|
||||
@@ -156,7 +159,7 @@ async function postGsapMutation(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify(mutation),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
readErrorResponseBody,
|
||||
} from "./useDomEditCommitsHelpers";
|
||||
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
@@ -201,7 +202,7 @@ export function useDomEditCommits({
|
||||
`/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify(patchBody),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
||||
import type { DomEditPatchBatch } from "./domEditCommitTypes";
|
||||
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
|
||||
export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
||||
return fields.map((field) => field.path).join(", ");
|
||||
@@ -99,7 +100,7 @@ export async function patchElementBatches(projectId: string, batches: DomEditPat
|
||||
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../components/editor/useLayerRevealOverride";
|
||||
import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
|
||||
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
|
||||
interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
|
||||
/** Route delete through SDK when session resolves the hf-id. */
|
||||
@@ -115,7 +116,7 @@ export function useElementLifecycleOps({
|
||||
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify({ target: patchTarget }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
||||
import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage";
|
||||
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
||||
import { consumeStudioWriteToken } from "../utils/studioFileVersion";
|
||||
import { logReload } from "../utils/reloadDebug";
|
||||
|
||||
type ExternalChangeDrainResult =
|
||||
| { status: "clean" }
|
||||
@@ -194,6 +195,7 @@ export function useExternalFileChangeCoordinator({
|
||||
|
||||
const reloadAcceptedGeneration = useCallback(
|
||||
(path: string) => {
|
||||
logReload("reload", { path, by: "external-change coordinator" });
|
||||
reloadPreview();
|
||||
reloadSdkSession(path);
|
||||
},
|
||||
@@ -221,11 +223,22 @@ export function useExternalFileChangeCoordinator({
|
||||
pendingTimelinePaths.delete(path);
|
||||
|
||||
const content = readFileChangeContent(payload);
|
||||
if (consumeStudioWriteToken(readFileChangeWriteToken(payload))) return;
|
||||
if (content != null && isSelfWriteEcho(path, content)) return;
|
||||
const token = readFileChangeWriteToken(payload);
|
||||
logReload("file-change", { path, token: token ?? null, hasContent: content != null });
|
||||
if (consumeStudioWriteToken(token)) {
|
||||
logReload("suppressed", { path, why: "own write token" });
|
||||
return;
|
||||
}
|
||||
if (content != null && isSelfWriteEcho(path, content)) {
|
||||
logReload("suppressed", { path, why: "own content echo" });
|
||||
return;
|
||||
}
|
||||
|
||||
const identity = eventIdentity(path, payload);
|
||||
if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) return;
|
||||
if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) {
|
||||
logReload("suppressed", { path, why: "duplicate event" });
|
||||
return;
|
||||
}
|
||||
lastEventIdentityRef.current = identity;
|
||||
const generation = ++generationRef.current;
|
||||
const result = await drainPendingChanges();
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
StudioFileConflictError,
|
||||
StudioSaveNetworkError,
|
||||
} from "../utils/studioSaveDiagnostics";
|
||||
import {
|
||||
createStudioWriteToken,
|
||||
markStudioWriteToken,
|
||||
studioExpectedFileVersion,
|
||||
} from "../utils/studioFileVersion";
|
||||
import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
import { useFileTree } from "./useFileTree";
|
||||
import { useEditorSave } from "./useEditorSave";
|
||||
|
||||
@@ -124,8 +120,6 @@ export function useFileManager({
|
||||
await retryStudioSave(async () => {
|
||||
// Each request gets its own receipt identity. If a committed request loses its response,
|
||||
// the retry can produce a second filesystem receipt that must be suppressed independently.
|
||||
const writeToken = createStudioWriteToken();
|
||||
markStudioWriteToken(writeToken);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(
|
||||
@@ -134,7 +128,7 @@ export function useFileManager({
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"X-Hyperframes-Write-Token": writeToken,
|
||||
...studioWriteHeaders(),
|
||||
...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
|
||||
},
|
||||
body: content,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type DomEditCommitBaseParams,
|
||||
} from "../utils/studioFileHistory";
|
||||
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
|
||||
interface UseGroupCommitsParams extends DomEditCommitBaseParams {
|
||||
/** Resync the SDK session after a server-side write (the wrapper/unwrap changes
|
||||
@@ -75,7 +76,7 @@ async function commitStructuralMutation(
|
||||
`/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
useGsapSaveFailureTelemetry,
|
||||
useSafeGsapCommitMutation,
|
||||
} from "./useSafeGsapCommitMutation";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
|
||||
async function mutateGsapScript(
|
||||
projectId: string,
|
||||
@@ -46,7 +47,7 @@ async function mutateGsapScript(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify(mutation),
|
||||
},
|
||||
);
|
||||
@@ -65,7 +66,7 @@ async function mutateGsapScriptBatch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify({ mutations }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
||||
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
||||
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
||||
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
|
||||
type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
||||
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||
@@ -412,7 +413,7 @@ export function useTimelineEditing({
|
||||
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify({ target: patchTarget }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
|
||||
import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
|
||||
import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop";
|
||||
import { logReload } from "../../utils/reloadDebug";
|
||||
|
||||
export type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
|
||||
@@ -445,6 +446,7 @@ export function useTimelinePlayer() {
|
||||
const refreshPlayer = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
|
||||
saveSeekPosition();
|
||||
// Hide the iframe across the full reload so the user never sees the reloading
|
||||
// document's RAW DOM (every clip stacked and visible) in the window between the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { buildAtomicCutIntents, runAtomicCutTransaction } from "./razorSplitTransaction";
|
||||
import { consumeStudioWriteToken, resetStudioWriteTokens } from "./studioFileVersion";
|
||||
|
||||
const element = (over: Partial<TimelineElement> = {}): TimelineElement => ({
|
||||
id: "clip",
|
||||
@@ -14,7 +15,10 @@ const element = (over: Partial<TimelineElement> = {}): TimelineElement => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
afterEach(() => {
|
||||
resetStudioWriteTokens();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("buildAtomicCutIntents", () => {
|
||||
it("deduplicates runtime aliases but keeps repeated authored hosts distinct", () => {
|
||||
@@ -45,12 +49,16 @@ describe("buildAtomicCutIntents", () => {
|
||||
});
|
||||
|
||||
function installCutServer(options: { status?: number } = {}) {
|
||||
const requests: Array<{ url: string; body?: unknown }> = [];
|
||||
const requests: Array<{ url: string; body?: unknown; headers?: HeadersInit }> = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
requests.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
||||
requests.push({
|
||||
url,
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
headers: init?.headers,
|
||||
});
|
||||
if (url.includes("/files/")) {
|
||||
return new Response(JSON.stringify({ content: "before", version: '"v0"' }), {
|
||||
status: 200,
|
||||
@@ -109,6 +117,10 @@ describe("runAtomicCutTransaction", () => {
|
||||
"/api/projects/launch%2Fdemo/files/index.html",
|
||||
"/api/projects/launch%2Fdemo/file-mutations/split-batch",
|
||||
]);
|
||||
const splitRequest = requests.find((request) => request.url.includes("split-batch"));
|
||||
const writeToken = new Headers(splitRequest?.headers).get("X-Hyperframes-Write-Token");
|
||||
expect(writeToken).toMatch(/^cut:/);
|
||||
expect(consumeStudioWriteToken(writeToken)).toBe(true);
|
||||
expect(writeProjectFile).not.toHaveBeenCalled();
|
||||
expect(recordEdit).toHaveBeenCalledWith({
|
||||
label: "Split timeline clip",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RecordEditInput } from "../hooks/timelineEditingHelpers";
|
||||
import { buildPatchTarget } from "./timelineElementSplit";
|
||||
import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
|
||||
import { buildProjectApiPath } from "./projectRouting";
|
||||
import { markStudioWriteToken } from "./studioFileVersion";
|
||||
|
||||
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
|
||||
@@ -117,6 +118,7 @@ async function requestAtomicCut(
|
||||
});
|
||||
}
|
||||
const transactionToken = `cut:${crypto.randomUUID()}`;
|
||||
markStudioWriteToken(transactionToken);
|
||||
const response = await fetch(buildProjectApiPath(projectId, "/file-mutations/split-batch"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Preview full-reload diagnostics — grep [hf-reload]. Off by default; opt in per
|
||||
// session with `localStorage.setItem("hf-reload-debug", "1")` (then reload).
|
||||
//
|
||||
// A full reload blanks the stage for ~100-300ms, so any reload the user did not
|
||||
// ask for reads as a flash. These lines answer the only question that matters
|
||||
// when one appears: who asked for it, and why the write that triggered it was
|
||||
// not recognised as Studio's own.
|
||||
let enabled: boolean | null = null;
|
||||
|
||||
function isEnabled(): boolean {
|
||||
if (enabled === null) {
|
||||
try {
|
||||
enabled = localStorage.getItem("hf-reload-debug") === "1";
|
||||
} catch {
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function logReload(
|
||||
stage: string,
|
||||
data: Record<string, unknown> | (() => Record<string, unknown>) = {},
|
||||
): void {
|
||||
if (!isEnabled()) return;
|
||||
const details = typeof data === "function" ? data() : data;
|
||||
console.log(
|
||||
`[hf-reload] ${JSON.stringify({ stage, t: Math.round(performance.now()), ...details })}`,
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,21 @@ export async function studioExpectedFileVersion(
|
||||
return versions.get(path);
|
||||
}
|
||||
|
||||
export function createStudioWriteToken(): string {
|
||||
function createStudioWriteToken(): string {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers that claim the write a mutation request is about to make as our own.
|
||||
*
|
||||
* The token is marked BEFORE the request goes out on purpose: the server writes
|
||||
* the file and the watcher broadcasts it while the request is still in flight, so
|
||||
* a token marked from the response can arrive after the echo it was meant to
|
||||
* match. An unmatched echo reads as an external change and costs a full preview
|
||||
* reload, which the user sees as a flash right after their own edit.
|
||||
*/
|
||||
export function studioWriteHeaders(): Record<string, string> {
|
||||
const token = createStudioWriteToken();
|
||||
markStudioWriteToken(token);
|
||||
return { "X-Hyperframes-Write-Token": token };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
|
||||
import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
|
||||
import type { RecordEditInput } from "./studioFileHistory";
|
||||
import { buildProjectApiPath } from "./projectRouting";
|
||||
import { studioWriteHeaders } from "./studioFileVersion";
|
||||
|
||||
interface TimelineCompositionInsertionResult {
|
||||
path: string;
|
||||
@@ -34,7 +35,7 @@ async function insertTimelineComposition(input: {
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify({
|
||||
sourcePath: input.sourcePath,
|
||||
start: input.start,
|
||||
|
||||
Reference in New Issue
Block a user