Skip to content
Closed
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
32 changes: 32 additions & 0 deletions packages/studio-server/src/routes/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,38 @@ 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);
expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({
path: "index.html",
version: fileContentVersion(readFileSync(join(projectDir, "index.html"), "utf-8")),
writeToken: "studio-patch-1",
});
});

it("applies an ordered element patch batch with one file write", async () => {
const projectDir = createProjectDir();
const original =
Expand Down
84 changes: 63 additions & 21 deletions packages/studio-server/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -399,6 +400,35 @@ export function commitElementPatchBatches(
return { durable: true, files };
}

/**
* Write a mutation result, and leave behind the receipt that claims it.
*
* 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 writes through here so no route can forget.
*/
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}`);
writeFileSync(absPath, html, "utf-8");
const version = fileContentVersion(html);
recordFileWriteReceipt(absPath, {
path: filePath,
version,
writeToken: createWriteToken(c.req.header("X-Hyperframes-Write-Token")),
});
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,
Expand All @@ -411,15 +441,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,
});
}

Expand Down Expand Up @@ -1238,10 +1266,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> = {
Expand Down Expand Up @@ -2623,10 +2654,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,
Expand All @@ -2635,7 +2669,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
newId: result.newId,
path: ctx.filePath,
version,
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
backupPath,
});
});

Expand Down Expand Up @@ -2676,16 +2710,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,
});
});

Expand Down Expand Up @@ -2807,16 +2845,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,
});
});

Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/captions/hooks/useCaptionSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/hooks/gsapScriptCommitHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
},
);
Expand Down
5 changes: 4 additions & 1 deletion packages/studio/src/hooks/timelineTimingSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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 }),
},
Expand Down Expand Up @@ -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),
},
);
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/hooks/useDomEditCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
},
);
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/hooks/useDomEditCommitsHelpers.ts
Original file line number Diff line number Diff line change
@@ -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(", ");
Expand Down Expand Up @@ -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,
},
);
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/hooks/useElementLifecycleOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 }),
},
);
Expand Down
19 changes: 16 additions & 3 deletions packages/studio/src/hooks/useExternalFileChangeCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -194,6 +195,7 @@ export function useExternalFileChangeCoordinator({

const reloadAcceptedGeneration = useCallback(
(path: string) => {
logReload("reload", { path, by: "external-change coordinator" });
reloadPreview();
reloadSdkSession(path);
},
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/hooks/useGroupCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
},
);
Expand Down
5 changes: 3 additions & 2 deletions packages/studio/src/hooks/useGsapScriptCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
useGsapSaveFailureTelemetry,
useSafeGsapCommitMutation,
} from "./useSafeGsapCommitMutation";
import { studioWriteHeaders } from "../utils/studioFileVersion";

async function mutateGsapScript(
projectId: string,
Expand All @@ -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),
},
);
Expand All @@ -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 }),
},
);
Expand Down
Loading
Loading