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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions packages/core/src/studio-api/helpers/backupJournal.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
99 changes: 99 additions & 0 deletions packages/core/src/studio-api/helpers/backupJournal.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}
}
31 changes: 31 additions & 0 deletions packages/core/src/studio-api/helpers/safePath.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
2 changes: 1 addition & 1 deletion packages/core/src/studio-api/helpers/safePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
70 changes: 69 additions & 1 deletion packages/core/src/studio-api/routes/files.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading