fix(studio): journal source writebacks (#1388)

This commit is contained in:
Miguel Ángel
2026-06-12 20:40:12 -04:00
committed by GitHub
parent 2ec006297f
commit aec3c3b58c
10 changed files with 361 additions and 16 deletions
+1
View File
@@ -75,6 +75,7 @@ docs/plans/
# Local proof / test artifacts
qa-artifacts/
my-video/
.hyperframes/backup/
examples/*
# Tracked OSS examples — negations override the blanket `examples/*` ignore.
!examples/aws-lambda
@@ -0,0 +1,88 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { backupPathForResponse, snapshotBeforeWrite } from "./backupJournal";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function createProjectDir(): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-backup-journal-"));
tempDirs.push(projectDir);
return projectDir;
}
describe("snapshotBeforeWrite", () => {
it("copies the current file bytes before overwrite", () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, "compositions"), { recursive: true });
const file = join(projectDir, "compositions", "scene.html");
writeFileSync(file, "before");
const result = snapshotBeforeWrite(projectDir, file);
writeFileSync(file, "after");
expect(result.backupPath && existsSync(result.backupPath)).toBe(true);
expect(readFileSync(result.backupPath!, "utf-8")).toBe("before");
expect(backupPathForResponse(projectDir, result.backupPath)).toMatch(
/^\.hyperframes\/backup\//,
);
});
it("creates backups for zero-byte files", () => {
const projectDir = createProjectDir();
const file = join(projectDir, "empty.html");
writeFileSync(file, "");
const result = snapshotBeforeWrite(projectDir, file);
expect(result.backupPath && existsSync(result.backupPath)).toBe(true);
expect(readFileSync(result.backupPath!, "utf-8")).toBe("");
});
it("prunes older backups for the same file", () => {
const projectDir = createProjectDir();
const file = join(projectDir, "index.html");
writeFileSync(file, "0");
for (let i = 1; i <= 5; i += 1) {
writeFileSync(file, String(i));
snapshotBeforeWrite(projectDir, file, { keepPerFile: 3 });
}
expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(3);
});
it("does not prune backups for paths with colliding sanitized names", () => {
const projectDir = createProjectDir();
const first = join(projectDir, "My File.html");
const second = join(projectDir, "My_File.html");
writeFileSync(first, "space");
writeFileSync(second, "underscore");
snapshotBeforeWrite(projectDir, first, { keepPerFile: 1 });
snapshotBeforeWrite(projectDir, second, { keepPerFile: 1 });
const backups = readdirSync(join(projectDir, ".hyperframes", "backup"));
expect(backups).toHaveLength(2);
expect(
backups
.map((name) => readFileSync(join(projectDir, ".hyperframes", "backup", name), "utf-8"))
.sort(),
).toEqual(["space", "underscore"]);
});
});
@@ -0,0 +1,99 @@
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { Buffer } from "node:buffer";
import { join, relative } from "node:path";
import { isSafePath } from "./safePath.js";
const DEFAULT_KEEP_PER_FILE = 10;
export interface BackupJournalResult {
backupPath: string | null;
error?: string;
}
function backupKeyForPath(path: string): string {
return Buffer.from(path, "utf-8").toString("base64url");
}
function timestampPrefix(): string {
return new Date().toISOString().replace(/[:.]/g, "-");
}
export function backupPathForResponse(
projectDir: string,
backupPath: string | null,
): string | null {
if (!backupPath) return null;
const rel = relative(projectDir, backupPath);
if (!rel || rel.startsWith("..")) return null;
return rel.split("\\").join("/");
}
export function snapshotBeforeWrite(
projectDir: string,
absPath: string,
options: { keepPerFile?: number } = {},
): BackupJournalResult {
if (!isSafePath(projectDir, absPath)) return { backupPath: null };
try {
const content = readFileSync(absPath);
const relativePath = relative(projectDir, absPath);
const backupDir = join(projectDir, ".hyperframes", "backup");
mkdirSync(backupDir, { recursive: true });
const backupKey = backupKeyForPath(relativePath);
const backupPath = nextBackupPath(backupDir, backupKey);
writeFileSync(backupPath, content);
pruneBackups(backupDir, backupKey, options.keepPerFile ?? DEFAULT_KEEP_PER_FILE);
return { backupPath };
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
(error.code === "ENOENT" || error.code === "EISDIR")
) {
return { backupPath: null };
}
return { backupPath: null, error: error instanceof Error ? error.message : String(error) };
}
}
function nextBackupPath(backupDir: string, backupKey: string): string {
const base = `${timestampPrefix()}-${backupKey}`;
let candidate = join(backupDir, base);
let counter = 2;
while (true) {
try {
readFileSync(candidate);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return candidate;
}
throw error;
}
candidate = join(backupDir, `${base}-${counter}`);
counter += 1;
}
}
function pruneBackups(backupDir: string, backupKey: string, keepPerFile: number): void {
const keep = Math.max(1, Math.floor(keepPerFile));
const suffix = `-${backupKey}`;
const numberedSuffix = new RegExp(`-${backupKey}-\\d+$`);
const matches = readdirSync(backupDir)
.filter((name) => name.endsWith(suffix) || numberedSuffix.test(name))
.map((name) => join(backupDir, name))
.sort((a, b) => {
return b.localeCompare(a);
});
for (const file of matches.slice(keep)) {
try {
unlinkSync(file);
} catch {
// Backup pruning is best-effort and must not block the user's write.
}
}
}
@@ -0,0 +1,31 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { walkDir } from "./safePath";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function createProjectDir(): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-safe-path-"));
tempDirs.push(projectDir);
return projectDir;
}
describe("walkDir", () => {
it("hides internal HyperFrames backup files from project listings", () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true });
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeFileSync(join(projectDir, ".hyperframes", "backup", "snapshot.html"), "backup");
writeFileSync(join(projectDir, "compositions", "scene.html"), "scene");
expect(walkDir(projectDir)).toEqual(["compositions/scene.html"]);
});
});
@@ -7,7 +7,7 @@ export function isSafePath(base: string, resolved: string): boolean {
return resolved.startsWith(norm) || resolved === resolve(base);
}
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
const IGNORE_DIRS = new Set([".hyperframes", ".thumbnails", "node_modules", ".git"]);
/** Recursively walk a directory and return relative file paths. */
export function walkDir(dir: string, prefix = ""): string[] {
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerFileRoutes } from "./files";
@@ -64,6 +64,74 @@ describe("registerFileRoutes", () => {
expect(response.status).toBe(404);
});
it("backs up the previous file content before PUT overwrite", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "index.html"), "before");
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/files/index.html", {
method: "PUT",
body: "after",
});
const payload = (await response.json()) as { path?: string; backupPath?: string };
expect(response.status).toBe(200);
expect(payload.path).toBe("index.html");
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");
});
it("backs up the previous file content before delete", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "index.html"), "before delete");
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/files/index.html", {
method: "DELETE",
});
const payload = (await response.json()) as { backupPath?: string };
expect(response.status).toBe(200);
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before delete");
});
it("backs up the previous file content before structured DOM mutations", 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" },
body: JSON.stringify({
target: { id: "title" },
operations: [{ type: "text-content", property: "textContent", value: "After" }],
}),
},
);
const payload = (await response.json()) as {
changed?: boolean;
path?: string;
backupPath?: string;
};
expect(response.status).toBe(200);
expect(payload.changed).toBe(true);
expect(payload.path).toBe("index.html");
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe(
'<div id="title">Before</div>',
);
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After");
});
// 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.
+63 -8
View File
@@ -17,6 +17,7 @@ import { isAudioFile } from "../helpers/mime.js";
import { generateWaveformCache } from "../helpers/waveform.js";
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
import { isSafePath } from "../helpers/safePath.js";
import { backupPathForResponse, snapshotBeforeWrite } from "../helpers/backupJournal.js";
import type { GsapAnimation } from "../../parsers/gsapSerialize.js";
import {
removeElementFromHtml,
@@ -94,15 +95,25 @@ type MutationTarget = {
/** Write `next` to `absPath` only if it differs from `original`, returning a standardized change response. */
function writeIfChanged(
c: RouteContext,
projectDir: string,
filePath: string,
absPath: string,
original: string,
next: string,
): Response {
if (next === original) {
return c.json({ ok: true, changed: false, content: 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");
return c.json({ ok: true, changed: true, content: next });
return c.json({
ok: true,
changed: true,
content: next,
path: filePath,
backupPath: backupPathForResponse(projectDir, backup.backupPath),
});
}
/**
@@ -815,9 +826,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
ensureDir(res.absPath);
const body = await c.req.text();
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
writeFileSync(res.absPath, body, "utf-8");
return c.json({ ok: true });
return c.json({
ok: true,
path: res.filePath,
backupPath: backupPathForResponse(res.project.dir, backup.backupPath),
});
});
// ── Create (fail if exists) ──
@@ -844,13 +861,18 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
if ("error" in res) return res.error;
const stat = statSync(res.absPath);
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
if (stat.isDirectory()) {
rmSync(res.absPath, { recursive: true });
} else {
unlinkSync(res.absPath);
}
return c.json({ ok: true });
return c.json({
ok: true,
backupPath: backupPathForResponse(res.project.dir, backup.backupPath),
});
});
api.post("/projects/:id/file-mutations/remove-element/*", async (c) => {
@@ -867,6 +889,8 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const originalContent = readFileSync(ctx.absPath, "utf-8");
return writeIfChanged(
c,
ctx.project.dir,
ctx.filePath,
ctx.absPath,
originalContent,
removeElementFromHtml(originalContent, parsed.target),
@@ -900,10 +924,19 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
parsed.body.newId,
);
if (!result.matched) {
return c.json({ ok: false, changed: false, content: originalContent });
return c.json({ ok: false, changed: false, content: originalContent, 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, result.html, "utf-8");
return c.json({ ok: true, changed: true, content: result.html, newId: result.newId });
return c.json({
ok: true,
changed: true,
content: result.html,
newId: result.newId,
path: ctx.filePath,
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
});
});
api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
@@ -931,10 +964,25 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
parsed.body.operations,
);
if (patched === originalContent) {
return c.json({ ok: true, changed: false, matched, content: originalContent });
return c.json({
ok: true,
changed: false,
matched,
content: originalContent,
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");
return c.json({ ok: true, changed: true, matched, content: patched });
return c.json({
ok: true,
changed: true,
matched,
content: patched,
path: ctx.filePath,
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
});
});
api.post("/projects/:id/file-mutations/probe-element/*", async (c) => {
@@ -1113,7 +1161,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const newScript = typeof result === "string" ? result : result.script;
const changed = newScript !== block.scriptText;
const newHtml = changed ? block.replaceScript(newScript) : html;
let backupPath: string | null = null;
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");
}
@@ -1126,6 +1179,8 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
before: html,
after: newHtml,
scriptText: newScript,
path: res.filePath,
backupPath,
};
if (typeof result !== "string" && result.skippedSelectors.length > 0) {
responsePayload.skippedSelectors = result.skippedSelectors;
@@ -33,7 +33,6 @@ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditO
import type { EditHistoryKind } from "../utils/editHistory";
import { useDomEditTextCommits } from "./useDomEditTextCommits";
// ── Helpers ──
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
@@ -70,8 +69,6 @@ function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLEl
return false;
}
// ── Types ──
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
@@ -105,7 +102,6 @@ export interface UseDomEditCommitsParams {
projectIdRef: React.MutableRefObject<string | null>;
reloadPreview: () => void;
// From useDomSelection
domEditSelection: DomEditSelection | null;
applyDomSelection: (
selection: DomEditSelection | null,
@@ -119,8 +115,6 @@ export interface UseDomEditCommitsParams {
) => Promise<DomEditSelection | null>;
}
// ── Hook ──
export function useDomEditCommits({
activeCompPath,
previewIframeRef,
@@ -209,6 +203,7 @@ export function useDomEditCommits({
changed?: boolean;
matched?: boolean;
content?: string;
path?: string;
};
if (!patchData.changed) {
@@ -248,6 +243,7 @@ export function useDomEditCommits({
coalesceKey: options?.coalesceKey,
files: { [targetPath]: { before: originalContent, after: finalContent } },
});
showToast(`Updated ${patchData.path ?? targetPath}`, "info");
if (!options?.skipRefresh) {
reloadPreview();
@@ -260,6 +256,7 @@ export function useDomEditCommits({
projectIdRef,
domEditSaveTimestampRef,
reloadPreview,
showToast,
],
);
@@ -285,6 +285,7 @@ export function useDomEditSession({
reloadPreview,
onCacheInvalidate: bumpGsapCache,
onFileContentChanged: updateEditingFileContent,
showToast,
});
// ── Commit handlers (delegated to useDomEditCommits) ──
@@ -56,6 +56,7 @@ interface MutationResult {
before?: string;
after?: string;
scriptText?: string;
path?: string;
}
async function mutateGsapScript(
@@ -94,6 +95,7 @@ interface GsapScriptCommitsParams {
reloadPreview: () => void;
onCacheInvalidate: () => void;
onFileContentChanged?: (path: string, content: string) => void;
showToast?: (message: string, tone?: "error" | "info") => void;
}
const DEBOUNCE_MS = 150;
@@ -107,6 +109,7 @@ export function useGsapScriptCommits({
reloadPreview,
onCacheInvalidate,
onFileContentChanged,
showToast,
}: GsapScriptCommitsParams) {
const pendingPropertyEditRef = useRef<{
selection: DomEditSelection;
@@ -157,6 +160,7 @@ export function useGsapScriptCommits({
if (result.after != null) {
onFileContentChanged?.(targetPath, result.after);
}
showToast?.(`Updated ${result.path ?? targetPath}`, "info");
if (options.skipReload) return;
@@ -195,6 +199,7 @@ export function useGsapScriptCommits({
reloadPreview,
onCacheInvalidate,
onFileContentChanged,
showToast,
],
);
const flushPendingPropertyEdit = useCallback(() => {