fix(studio-server): previewing a project never rewrites its files (#4480)

* fix(studio-server): previewing a project never rewrites its files

* fix(studio-server): a GSAP save on a served id writes that id to the file too
This commit is contained in:
Miguel Ángel
2026-09-25 10:04:38 +00:00
committed by GitHub
parent 2455a3d0ee
commit 11ac9bce69
17 changed files with 270 additions and 250 deletions
+9
View File
@@ -45,6 +45,15 @@ A `StudioApiAdapter` supplies:
Use the interface exported by your installed version as the exact contract. It
changes as Studio gains capabilities.
`bundle(projectDir, options)` must pass `options` through to `bundleToSingleHtml`,
spread over your own options. The preview sets `stampHfIds` there so each element
is served with the id its source file mints. If a host drops it, a composition
the bundler inlines can be served with ids that a later save does not mint.
```ts
bundle: (dir, options) => bundleToSingleHtml(dir, { runtime: "inline", ...options }),
```
## Other entry points
| Import | Purpose |
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import { tmpdir } from "node:os";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { createStudioServer, type StudioServer } from "./studioServer.js";
const SCENES = Array.from({ length: 24 }, (_, i) => `s${String(i).padStart(2, "0")}`);
@@ -53,6 +54,24 @@ describe("opening a film of external scenes", () => {
expect(stamped).toContain("data-hf-id=");
});
it("serves a scene added after open with the ids its source mints, so a save can find them", async () => {
server = createStudioServer({ projectDir: dir, projectName: "film" });
const added = `<template id="added-template"><div data-composition-id="added" data-width="320" data-height="180"><img class="logo" src="logo.png"></div></template>`;
fs.writeFileSync(path.join(dir, "compositions", "added.html"), added);
fs.writeFileSync(path.join(dir, "compositions", "logo.png"), "png");
fs.writeFileSync(path.join(dir, "index.html"), root(["added"]));
const html = await (await server.app.request("/api/projects/film/preview")).text();
const img = /<img[^>]*class="logo"[^>]*>/.exec(html)?.[0] ?? "";
expect(img).toContain("compositions/logo.png");
expect(/data-hf-id="([^"]+)"/.exec(img)?.[1]).toBe(
/<img[^>]*data-hf-id="([^"]+)"/.exec(ensureHfIds(added))?.[1],
);
expect(fs.readFileSync(path.join(dir, "compositions", "added.html"), "utf-8")).toBe(added);
expect(fs.readFileSync(path.join(dir, "index.html"), "utf-8")).toBe(root(["added"]));
});
it("stamps a composition rewritten since the last open even when its size and mtime match", () => {
const file = path.join(dir, "compositions", "s00.html");
const pinned = new Date("2026-01-01T00:00:00Z");
+2 -2
View File
@@ -467,14 +467,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
resolveProject: (id: string) => (id === projectId ? project : null),
async bundle(dir: string): Promise<string | null> {
async bundle(dir, options): Promise<string | null> {
try {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
// Studio dev server: ask the bundler for an empty `src=""` placeholder so
// we can point it at our hot-reloadable local runtime endpoint. Inlining
// ~150 KB of runtime body on every preview render would defeat browser
// caching across composition edits.
let html = await bundleToSingleHtml(dir, PREVIEW_BUNDLE_OPTIONS);
let html = await bundleToSingleHtml(dir, { ...PREVIEW_BUNDLE_OPTIONS, ...options });
html = html.replace(
'data-hyperframes-preview-runtime="1" src=""',
'data-hyperframes-preview-runtime="1" src="/api/runtime.js"',
@@ -9,6 +9,7 @@ import { ensureExternalScriptTag } from "./externalScripts";
import { resetUnknownEnumWarnings } from "../runtime/getVariables";
import { sanitizeCssValue } from "../runtime/applyVariableBindings";
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
import { ensureHfIds } from "../parsers/hfIds";
function makeTempProject(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "hf-bundler-test-"));
@@ -2005,3 +2006,31 @@ describe("bundleToSingleHtml script order", () => {
}
});
});
describe("stampHfIds", () => {
const project = () =>
makeTempProject({
"index.html": `<!doctype html><html><head></head><body><div id="root" data-composition-id="main" data-width="1920" data-height="1080"><div id="s" data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="3"></div></div></body></html>`,
"compositions/scene.html": `<template id="scene-template"><div data-composition-id="scene" data-width="1920" data-height="1080"><img class="logo" src="logo.png"></div></template>`,
"compositions/logo.png": "png",
});
const logo = (html: string) => /<img[^>]*class="logo"[^>]*>/.exec(html)?.[0] ?? "";
it("gives an inlined element the id its source file mints, though the bundle re-points its src", async () => {
const dir = project();
const html = await bundleToSingleHtml(dir, { inlineAssets: false, stampHfIds: true });
const source = ensureHfIds(
`<template id="scene-template"><div data-composition-id="scene" data-width="1920" data-height="1080"><img class="logo" src="logo.png"></div></template>`,
);
expect(logo(html)).toContain("compositions/logo.png");
expect(/data-hf-id="([^"]+)"/.exec(logo(html))?.[1]).toBe(
/<img[^>]*data-hf-id="([^"]+)"/.exec(source)?.[1],
);
});
it("leaves a render's bundle without ids", async () => {
const html = await bundleToSingleHtml(project(), { inlineAssets: false });
expect(logo(html)).toContain("compositions/logo.png");
expect(logo(html)).not.toContain("data-hf-id");
});
});
+6 -2
View File
@@ -33,6 +33,7 @@ import { readDeclaredDefaults } from "../runtime/getVariables";
import { inlineSubCompositions } from "./inlineSubCompositions";
import { queryByAttr } from "../utils/cssSelector";
import { isSafePath, resolveWithinProject } from "../safePath.js";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { HF_COLOR_GRADING_ATTR } from "../colorGrading";
const DEFAULT_RUNTIME_SCRIPT_URL = "";
@@ -763,6 +764,7 @@ function stripJsCommentsParserSafe(source: string): string {
export interface BundleOptions {
/** Project-relative HTML entry to bundle. Defaults to `index.html`. */
entryFile?: string;
stampHfIds?: boolean;
/** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */
probeMediaDuration?: MediaDurationProber;
/**
@@ -922,7 +924,8 @@ export async function bundleToSingleHtml(
return isSafePath(projectDir, resolved) ? resolved : null;
};
const rawHtml = readFileSync(indexPath, "utf-8");
const readSource = options?.stampHfIds ? ensureHfIds : (html: string) => html;
const rawHtml = readSource(readFileSync(indexPath, "utf-8"));
const compiled = await compileHtml(rawHtml, sourceDir, options?.probeMediaDuration);
if (options?.staticGuard !== false) {
@@ -983,7 +986,8 @@ export async function bundleToSingleHtml(
resolveHtml: (srcPath: string) => {
if (!isRelativeUrl(srcPath)) return null;
const compPath = resolveEntryPath(srcPath);
return compPath ? safeReadFile(compPath) : null;
const html = compPath ? safeReadFile(compPath) : null;
return html === null ? null : readSource(html);
},
parseHtml: parseHTMLContent,
hostIdentityMap: hostIdentityByElement,
+9 -9
View File
@@ -62,17 +62,17 @@ function contentKey(el: Element): string {
* get different ids based on which comes first in the DOM. This is unavoidable:
* unique ids for byte-identical elements require a positional signal.
*
* Why this is safe in practice: once `ensureHfIds` write-back persists
* `data-hf-id` to source the attribute is physically bound to its element.
* Reordering identical siblings carries the attribute along → zero
* order-dependence post-persist. `ensureHfIds` skips pinned elements
* (`if (getContractAttribute(el, "data-hf-id")) continue`), so normal operation
* never re-exposes the ordering after first persist.
* Why this is safe in practice: once `data-hf-id` is on disk (stamped at project open, or by the
* first save) it is bound to its element, so reordering identical siblings carries it along, and
* `ensureHfIds` skips pinned elements (`if (getContractAttribute(el, "data-hf-id")) continue`).
* Before that, an outside edit changes what is minted (an element's attributes or own text, or a
* twin added or removed), so a save carrying an id served before the edit misses or hits the other
* twin until the watcher reloads the preview.
*/
// WIRE CONTRACT: id minting is content-keyed (FNV1a of innerHTML + tag). R7's
// WIRE CONTRACT: id minting is content-keyed (FNV1a of tag, sorted attrs, own text). R7's
// preview route relies on mintHfId producing identical ids across mint contexts
// (disk-persist pass vs. in-memory bundle pass) — see preview.test.ts
// "bundle returning untagged HTML gets same ids as disk". Any change that adds
// (served copy vs. the source a save parses) — see preview.test.ts
// "bundle returning untagged HTML gets the ids minted from the source file". Any change that adds
// positional, session, or random input to the hash breaks that invariant and
// makes hf- ids diverge between disk and served HTML, silently corrupting
// drag-to-edit targeting.
@@ -2,73 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { persistHfIdsIfNeeded, stampFileHfIds } from "./hfIdPersist.js";
describe("persistHfIdsIfNeeded", () => {
const tmpDirs: string[] = [];
afterEach(() => {
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
tmpDirs.length = 0;
});
function tmpFile(content: string): string {
const dir = mkdtempSync(join(tmpdir(), "hfid-test-"));
tmpDirs.push(dir);
const file = join(dir, "index.html");
writeFileSync(file, content, "utf-8");
return file;
}
it("writes data-hf-id to disk when source is untagged", () => {
const raw = `<!doctype html><html><body><div>hello</div></body></html>`;
const file = tmpFile(raw);
const returned = persistHfIdsIfNeeded(file, raw);
expect(returned).toContain('data-hf-id="hf-');
const onDisk = readFileSync(file, "utf-8");
expect(onDisk).toContain('data-hf-id="hf-');
expect(onDisk).toBe(returned);
});
it("does not rewrite disk when source is already tagged", () => {
const raw = `<!doctype html><html><body><div>hello</div></body></html>`;
const file = tmpFile(raw);
const tagged = persistHfIdsIfNeeded(file, raw);
const diskAfterFirst = readFileSync(file, "utf-8");
const returned2 = persistHfIdsIfNeeded(file, tagged);
expect(returned2).toBe(tagged);
expect(readFileSync(file, "utf-8")).toBe(diskAfterFirst);
});
it("does not rewrite when source is already tagged with non-standard HTML formatting", () => {
// Single-quoted attrs would cause a false-positive write under string-equality
// change detection; count-based detection handles this correctly.
const alreadyTagged = `<!doctype html><html><body><div data-hf-id='hf-ab12'>hello</div></body></html>`;
const file = tmpFile(alreadyTagged);
persistHfIdsIfNeeded(file, alreadyTagged);
expect(readFileSync(file, "utf-8")).toBe(alreadyTagged);
});
it("returned id matches id written to disk (serve-time == persist-time invariant)", () => {
const raw = `<!doctype html><html><body><span>text</span></body></html>`;
const file = tmpFile(raw);
const result = persistHfIdsIfNeeded(file, raw);
const onDisk = readFileSync(file, "utf-8");
expect(result).toBe(onDisk);
});
it("skips write if file was modified concurrently (TOCTOU guard)", () => {
const old = `<!doctype html><html><body><div>original</div></body></html>`;
const newer = `<!doctype html><html><body><div>modified by user</div></body></html>`;
// Disk has newer content — simulates a concurrent save after the server read old.
const file = tmpFile(newer);
const returned = persistHfIdsIfNeeded(file, old);
// Serve-time HTML gets ids based on what we read.
expect(returned).toContain('data-hf-id="hf-');
// Disk must not be overwritten — user's concurrent save is preserved.
expect(readFileSync(file, "utf-8")).toBe(newer);
});
});
import { stampFileHfIds } from "./hfIdPersist.js";
describe("stampFileHfIds", () => {
const tmpDirs: string[] = [];
@@ -7,7 +7,6 @@ import {
mkdirSync,
openSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
@@ -57,42 +56,6 @@ export function stampProjectHfIds(projectDir: string): void {
}
}
/**
* Ensure `html` has `data-hf-id` attributes minted, and write the result back
* to `filePath` if new ids were added.
*
* **Invariant:** `html` must be the raw file content read from `filePath` just
* before this call. If `html` is constructed or transformed HTML the TOCTOU
* guard (`current === html`) will never match and writes will silently be
* skipped — no ids will reach disk.
*/
export function persistHfIdsIfNeeded(filePath: string, html: string): string {
const normalized = ensureHfIds(html);
// Use attribute count instead of string equality: linkedom serialization may
// normalize quote style and whitespace even when no ids were actually minted,
// which would cause spurious writes on every request.
const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
if (idsAfter > idsBefore) {
try {
// Re-read before writing to guard against concurrent user saves. If the
// file changed since we read it, skip the write — serving with ids is
// still correct; the next request will re-persist. Best-effort only: a
// user save landing between readFileSync and writeFileSync below can
// still be overwritten (microsecond window).
const current = readFileSync(filePath, "utf-8");
if (current === html) {
replaceFileAtomically(filePath, normalized, statSync(filePath).mode);
}
} catch (err) {
// Non-fatal — serve with ids even if the disk write fails (e.g. read-only
// filesystem, sandboxed environment). Log so the failure is diagnosable.
console.warn("[hyperframes] persistHfIdsIfNeeded: failed to write ids to disk:", err);
}
}
return normalized;
}
function openNoFollow(filePath: string, flags: number): number | null {
// O_NOFOLLOW is undefined on Windows; opening without it is the platform norm there.
const noFollow = constants.O_NOFOLLOW ?? 0;
@@ -118,8 +81,7 @@ function openNoFollow(filePath: string, flags: number): number | null {
* Returns null when the file is missing, unreadable, or not a regular file.
*
* Best-effort on concurrent saves: a user save landing between the read and
* the write below can still be overwritten (same microsecond window
* persistHfIdsIfNeeded documents) — the next save simply re-persists.
* the write below can still be overwritten — the next save simply re-persists.
*/
export function stampFileHfIds(filePath: string): string | null {
let fd: number | null = openNoFollow(filePath, constants.O_RDWR);
@@ -134,7 +96,7 @@ export function stampFileHfIds(filePath: string): string | null {
const html = readFileSync(fd, "utf-8");
const normalized = ensureHfIds(html);
// Attribute count, not string equality — linkedom serialization normalizes
// quote style/whitespace even when no ids were minted (see persistHfIdsIfNeeded).
// quote style/whitespace even when no ids were minted.
const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
if (writable && idsAfter > idsBefore) {
@@ -1,5 +1,6 @@
// fallow-ignore-file code-duplication
import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { describe, expect, it } from "vitest";
import {
removeElementFromHtml,
@@ -80,7 +81,7 @@ describe("removeElementFromHtml", () => {
it("supports fragment html by returning updated body markup", () => {
const html = `<div id="photo"></div><div id="rest"></div>`;
expect(removeElementFromHtml(html, { id: "photo" })).toBe(`<div id="rest"></div>`);
expect(removeElementFromHtml(html, { id: "photo" })).toBe(ensureHfIds(`<div id="rest"></div>`));
});
});
@@ -20,10 +20,11 @@ export interface SourceMutationTarget {
selectorIndex?: number;
}
export function parseSourceDocument(source: string): {
export function parseSourceDocument(raw: string): {
document: Document;
wrappedFragment: boolean;
} {
const source = ensureHfIds(raw);
const hasDocumentShell = /<!doctype|<html[\s>]/i.test(source);
if (hasDocumentShell) {
return { document: parseHTML(source).document, wrappedFragment: false };
@@ -274,8 +274,8 @@ export function buildSubCompositionHtml(
if (!existsSync(compFile)) return null;
// rawOverride lets the preview route thread the hf-id-stamped content in
// directly, so the build uses pinned ids even when the persist-to-disk write
// was skipped (read-only fs, concurrent-save TOCTOU guard).
// directly, so the build uses ids minted from the raw file, which is never
// written by serving.
const rawComp = rawOverride ?? readFileSync(compFile, "utf-8");
let compHeadContent = "";
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import {
existsSync,
mkdirSync,
@@ -1517,6 +1518,48 @@ const tl = gsap.timeline({ paused: true });
expect(res.status).toBe(400);
});
it.each([
["gsap-mutations", "<script>const tl = gsap.timeline({ paused: true });</script>"],
["gsap-mutations-batch", "<script>const tl = gsap.timeline({ paused: true });</script>"],
["gsap-mutations", ""],
])(
"%s saves a tween on a served id together with that id (script: %j)",
async (route, script) => {
const projectDir = createProjectDir();
const scene = `<div data-composition-id="scene"><div class="box">Hi</div>${script}</div>`;
writeHtml(projectDir, "scene.html", scene);
const servedBox = parseHTML(ensureHfIds(scene)).document.querySelector(".box");
const servedId = servedBox?.getAttribute("data-hf-id");
expect(servedId).toMatch(/^hf-/);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const add = {
type: "add",
targetSelector: `[data-hf-id="${servedId}"]`,
method: "set",
position: 0,
properties: { opacity: 0.5 },
};
const res = await app.request(`http://localhost/projects/demo/${route}/scene.html`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(route === "gsap-mutations" ? add : { mutations: [add] }),
});
const result = (await res.json()) as {
after: string;
parsed: { animations: Array<{ targetSelector: string }> };
};
expect(res.status).toBe(200);
expect(result.parsed.animations.map((a) => a.targetSelector)).toEqual([add.targetSelector]);
const saved = readFileSync(join(projectDir, "scene.html"), "utf8");
expect(saved).toBe(result.after);
const savedBox = parseHTML(saved).document.querySelector(".box");
expect(savedBox?.getAttribute("data-hf-id")).toBe(servedId);
},
);
it("rejects raw JavaScript expressions at the GSAP mutation boundary", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
+5 -4
View File
@@ -84,6 +84,7 @@ import {
type ElementRebase,
} from "../helpers/sourceMutation.js";
import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import {
CompositionInsertionError,
insertCompositionIntoSource,
@@ -644,16 +645,16 @@ function updateReferences(projectDir: string, oldPath: string, newPath: string):
// ── GSAP script extraction ──────────────────────────────────────────────────
/**
* Parse an HTML string with linkedom, locate the inline `<script>` that
* contains GSAP timeline code, and return both its text content and a
* function that replaces that script block and serialises back to HTML.
* Mint the HTML's ids (so a tween saved on a served id writes that id too), parse it with
* linkedom, locate the inline `<script>` holding GSAP timeline code, and return its text and
* a function that replaces that script block and serialises back to HTML.
*/
function extractGsapScriptBlock(html: string): {
scriptText: string;
document: Document;
replaceScript: (newText: string) => string;
} | null {
const { document } = parseHTML(html);
const { document } = parseHTML(ensureHfIds(html));
const scripts = [
...document.querySelectorAll("script:not([src])"),
...Array.from(document.querySelectorAll("template")).flatMap((tmpl) =>
+122 -83
View File
@@ -7,14 +7,18 @@ import {
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
writeSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerPreviewRoutes } from "./preview";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { PREVIEW_BUNDLE_OPTIONS, registerPreviewRoutes } from "./preview";
import { registerFileRoutes } from "./files";
import { createPreviewDocumentStore } from "../helpers/previewDocumentStore";
import type { StudioApiAdapter } from "../types";
@@ -495,6 +499,93 @@ describe("built preview reuse", () => {
});
describe("hf-id surfacing in preview route", () => {
const idsOf = (html: string) =>
[...html.matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map((m) => m[1]).sort();
const postPatch = (app: Hono, file: string, hfId: string | undefined) =>
app.request(`http://localhost/projects/demo/file-mutations/patch-element/${file}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target: { hfId },
operations: [{ type: "inline-style", property: "opacity", value: "0.5" }],
}),
});
it("serving the preview and a sub-comp twice leaves both files' bytes and mtime unchanged", async () => {
const projectDir = createProjectDir();
const files = [join(projectDir, "index.html"), join(projectDir, "scene.html")];
writeFileSync(
files[0]!,
`<!doctype html><html><head></head><body><div>hello</div></body></html>`,
);
writeFileSync(
files[1]!,
`<div class="clip" data-start="0" data-end="3"><img src="logo.png"></div>`,
);
const snapshot = () => files.map((f) => [readFileSync(f, "utf-8"), statSync(f).mtimeMs]);
const before = snapshot();
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
for (let i = 0; i < 2; i++) {
expect((await app.request("http://localhost/projects/demo/preview")).status).toBe(200);
const comp = await app.request("http://localhost/projects/demo/preview/comp/scene.html");
expect(comp.status).toBe(200);
}
expect(snapshot()).toEqual(before);
});
it("a save finds an element by the id the sub-comp route served, though the file has no ids", async () => {
const projectDir = createProjectDir();
const compPath = join(projectDir, "scene.html");
writeFileSync(
compPath,
`<div class="clip" data-start="0" data-end="3"><img src="assets/logo.png"></div>`,
);
const app = new Hono();
const adapter = createAdapter(projectDir);
registerPreviewRoutes(app, adapter);
registerFileRoutes(app, adapter);
const served = await (
await app.request("http://localhost/projects/demo/preview/comp/scene.html")
).text();
const imgId = /<img[^>]*data-hf-id="(hf-[a-z0-9]+)"/.exec(served)?.[1];
expect(imgId).toBeDefined();
const res = await postPatch(app, "scene.html", imgId);
expect(await res.json()).toMatchObject({ matched: true, changed: true });
expect(readFileSync(compPath, "utf-8")).toMatch(/<img[^>]*opacity: ?0\.5/);
});
it("a save finds an element whose src the bundler rewrote, though its file has no ids", async () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, "compositions"));
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head></head><body><div id="root" data-composition-id="main" data-width="1920" data-height="1080"><div id="s" data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="3"></div></div></body></html>`,
);
const compPath = join(projectDir, "compositions", "scene.html");
writeFileSync(
compPath,
`<template id="scene-template"><div data-composition-id="scene" data-width="1920" data-height="1080"><img class="logo" src="logo.png"></div></template>`,
);
writeFileSync(join(projectDir, "compositions", "logo.png"), "png");
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const app = new Hono();
const adapter = createAdapter(projectDir, {
bundle: (dir, options) => bundleToSingleHtml(dir, { ...PREVIEW_BUNDLE_OPTIONS, ...options }),
});
registerPreviewRoutes(app, adapter);
registerFileRoutes(app, adapter);
const served = await (await app.request("http://localhost/projects/demo/preview")).text();
const img = /<img[^>]*class="logo"[^>]*>/.exec(served)?.[0] ?? "";
expect(img).not.toContain('src="logo.png"');
const res = await postPatch(
app,
"compositions/scene.html",
/data-hf-id="(hf-[a-z0-9]+)"/.exec(img)?.[1],
);
expect(await res.json()).toMatchObject({ matched: true, changed: true });
});
it("serves HTML with data-hf-id on body elements (R7 write-back)", async () => {
const projectDir = createProjectDir();
writeFileSync(
@@ -511,28 +602,7 @@ describe("hf-id surfacing in preview route", () => {
expect(ids?.length).toBeGreaterThanOrEqual(2);
});
it("writes data-hf-id back to disk on first serve", async () => {
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
const indexPath = join(projectDir, "index.html");
writeFileSync(
indexPath,
`<!doctype html><html><head></head><body><div>hello</div></body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
await app.request("http://localhost/projects/demo/preview");
const onDisk = readFileSync(indexPath, "utf-8");
expect(onDisk).toContain('data-hf-id="hf-');
});
it("bundle returning untagged HTML gets same ids as disk — content-hash is stable across mint contexts", async () => {
// Regression guard for bundle-vs-disk id divergence: if the bundler reads from
// a pre-write cache snapshot (no ids), ensureHfIds mints ids on the bundle output.
// Because ids are content-keyed (FNV1a of element content), the minted ids must
// equal the ids persisted to disk for the same source HTML — otherwise a
// drag-to-edit patch keyed by a wire-time id would fail to apply on disk.
const { readFileSync } = await import("node:fs");
it("bundle returning untagged HTML gets the ids minted from the source file", async () => {
const projectDir = createProjectDir();
const indexPath = join(projectDir, "index.html");
const sourceHtml = `<!doctype html><html><head></head><body><div class="card"><p>hello</p></div></body></html>`;
@@ -544,27 +614,9 @@ describe("hf-id surfacing in preview route", () => {
const res = await app.request("http://localhost/projects/demo/preview");
expect(res.status).toBe(200);
const servedHtml = await res.text();
const diskHtml = readFileSync(indexPath, "utf-8");
// Extract ids from served HTML and disk HTML
const servedIds = [...servedHtml.matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map((m) => m[1]);
const diskIds = [...diskHtml.matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map((m) => m[1]);
const servedIds = idsOf(await res.text());
expect(servedIds.length).toBeGreaterThanOrEqual(2);
expect(servedIds).toEqual(diskIds);
});
it("sub-comp route writes data-hf-id back to disk on first serve", async () => {
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
const compPath = join(projectDir, "scene.html");
writeFileSync(compPath, `<div class="clip" data-start="0" data-end="3">Hi</div>`);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview/comp/scene.html");
expect(res.status).toBe(200);
expect(readFileSync(compPath, "utf-8")).toContain('data-hf-id="hf-');
expect(servedIds).toEqual(idsOf(ensureHfIds(sourceHtml)));
});
it("returns ByteString-safe stable and distinct ETags for percent-encoded CJK sub-comp paths", async () => {
@@ -596,57 +648,35 @@ describe("hf-id surfacing in preview route", () => {
expect(other.headers.get("ETag")).not.toBe(firstEtag);
});
it("sub-comp served ids equal disk ids even when relative asset paths are rewritten", async () => {
// Regression guard for the setTiming element_not_found divergence class:
// the sub-comp route rewrites relative src/href BEFORE minting, so an
// element with a relative asset path got a preview-only id that existed
// nowhere in the raw file. Persisting ids from the RAW file first pins
// them; the rewrite then carries the pinned ids through unchanged.
const { readFileSync } = await import("node:fs");
it("sub-comp served ids equal the source's ids even when relative asset paths are rewritten", async () => {
// Guards setTiming element_not_found: minting after the route rewrites src gives ids the source lacks.
const projectDir = createProjectDir();
const compPath = join(projectDir, "scene.html");
writeFileSync(
compPath,
`<div class="clip" data-start="0" data-end="3"><img src="assets/logo.png"></div>`,
);
const raw = `<div class="clip" data-start="0" data-end="3"><img src="assets/logo.png"></div>`;
writeFileSync(join(projectDir, "scene.html"), raw);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview/comp/scene.html");
expect(res.status).toBe(200);
const servedIds = [...(await res.text()).matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)]
.map((m) => m[1])
.sort();
const diskIds = [...readFileSync(compPath, "utf-8").matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)]
.map((m) => m[1])
.sort();
const servedIds = idsOf(await res.text());
expect(servedIds.length).toBeGreaterThanOrEqual(2); // div + img
expect(servedIds).toEqual(diskIds);
expect(servedIds).toEqual(idsOf(ensureHfIds(raw)));
});
it("template-based sub-comp: inner ids persist to disk and match the served (unwrapped) ids", async () => {
const { readFileSync } = await import("node:fs");
it("template-based sub-comp: the served (unwrapped) ids are the source's inner ids", async () => {
const projectDir = createProjectDir();
const compPath = join(projectDir, "test-minimal.html");
writeFileSync(
compPath,
`<template data-composition-id="test-minimal"><div class="clip" data-start="0" data-end="3">Hello</div><div class="clip" data-start="3" data-end="6">World</div></template>`,
);
const raw = `<template data-composition-id="test-minimal"><div class="clip" data-start="0" data-end="3">Hello</div><div class="clip" data-start="3" data-end="6">World</div></template>`;
writeFileSync(join(projectDir, "test-minimal.html"), raw);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview/comp/test-minimal.html");
expect(res.status).toBe(200);
const servedIds = [...(await res.text()).matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map(
(m) => m[1],
);
const diskIds = [
...readFileSync(compPath, "utf-8").matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g),
].map((m) => m[1]);
expect(diskIds.length).toBe(2);
for (const id of diskIds) expect(servedIds).toContain(id);
const servedIds = idsOf(await res.text());
const sourceIds = idsOf(ensureHfIds(raw));
expect(sourceIds.length).toBe(2);
for (const id of sourceIds) expect(servedIds).toContain(id);
});
it("sub-comp route does NOT rewrite a non-HTML file on disk (GET must not corrupt assets)", async () => {
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
const svgPath = join(projectDir, "logo.svg");
const svgBytes = `<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0"/></svg>`;
@@ -683,8 +713,7 @@ describe("hf-id surfacing in preview route", () => {
expect(traversal.status).toBe(404);
});
it("sub-comp route does NOT persist ids inside a plain <template> (runtime clone-source)", async () => {
const { readFileSync } = await import("node:fs");
it("a save does NOT stamp ids inside a plain <template> (runtime clone-source)", async () => {
const projectDir = createProjectDir();
const compPath = join(projectDir, "clones.html");
writeFileSync(
@@ -692,9 +721,19 @@ describe("hf-id surfacing in preview route", () => {
`<div class="clip" data-start="0" data-end="3">stage</div><template><li class="row">item</li></template>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview/comp/clones.html");
expect(res.status).toBe(200);
registerFileRoutes(app, createAdapter(projectDir));
const res = await app.request(
"http://localhost/projects/demo/file-mutations/patch-element/clones.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target: { selector: ".clip" },
operations: [{ type: "inline-style", property: "opacity", value: "0.5" }],
}),
},
);
expect(await res.json()).toMatchObject({ matched: true });
const disk = readFileSync(compPath, "utf-8");
expect(disk).toMatch(/<div[^>]*data-hf-id/); // stage div stamped
expect(disk).not.toMatch(/<li[^>]*data-hf-id/); // clone-source untouched
+12 -35
View File
@@ -22,7 +22,6 @@ import {
STUDIO_MOTION_PATH,
} from "../helpers/studioMotionRenderScript.js";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js";
import { settledFileTag } from "../helpers/fileVersion.js";
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
import { injectPreviewVariables } from "../helpers/previewVariables.js";
@@ -351,14 +350,11 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
previewVariables: Record<string, unknown> | null,
builtKey: string,
): Promise<string | null> {
// Normalize + persist data-hf-id to disk before bundle reads it. Idempotent.
const diskMain = resolveProjectMainHtml(project.dir, project.id);
const normalizedDisk = diskMain
? persistHfIdsIfNeeded(join(project.dir, diskMain.compositionPath), diskMain.html)
: null;
const normalizedDisk = diskMain ? ensureHfIds(diskMain.html) : null;
try {
let bundled = await adapter.bundle(project.dir);
let bundled = await adapter.bundle(project.dir, { stampHfIds: true });
let mainCompositionPath = "index.html";
if (!bundled) {
if (!diskMain) return null;
@@ -386,11 +382,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
}
// ensureHfIds runs after transformPreviewHtml in case the adapter injected
// new elements. On the no-bundle path bundled=normalizedDisk (already tagged)
// so this is idempotent. On the bundled path the bundler may return untagged
// HTML (stale cache); because ids are content-keyed the minted ids will match
// the ids already written to disk by persistHfIdsIfNeeded above.
// Also covers elements the adapter injected; ids already present are kept.
bundled = injectStudioPreviewAugmentations(
ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
adapter,
@@ -413,10 +405,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
// not the pre-request snapshot that may have been saved over.
const fallback = resolveProjectMainHtml(project.dir, project.id);
if (fallback) {
const fallbackHtml = persistHfIdsIfNeeded(
join(project.dir, fallback.compositionPath),
fallback.html,
);
const fallbackHtml = ensureHfIds(fallback.html);
let fallbackAugmented = injectStudioPreviewAugmentations(
await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
adapter,
@@ -477,27 +466,15 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
return c.html(html, 200, previewCacheHeaders(etag));
});
/**
* Pin hf-ids to the RAW sub-comp file before the build pipeline mutates
* attributes (rewriteRelativePaths etc.) — minting is content-keyed over
* attrs, so stamping only AFTER the rewrite mints preview-only ids that
* exist nowhere in the source. Pinned ids ride through the rewrite
* unchanged, keeping the served DOM, the disk file, and the studio SDK
* session in one id space. Mirrors the main-preview route's
* persistHfIdsIfNeeded call.
*
* Gated to composition files: the wildcard route serves any project path,
* and stamping a non-HTML file (SVG, etc.) would corrupt it on disk.
*
* Returns the stamped content to thread into the build (so served ids match
* the mint even when the disk write is skipped — read-only fs), undefined
* for non-HTML paths, or null when the file vanished after the caller's
* stat. stampFileHfIds does its validation, read, and write through one
* file descriptor, so there is no check/read/write path gap to race.
*/
/** Ids minted from the raw file before the build rewrites attributes, so they match the source's;
* in memory only, since a write here reaches the watcher as an outside edit. null: the file vanished. */
function pinSubCompHfIds(compFile: string, compPath: string): string | undefined | null {
if (!/\.html?$/i.test(compPath)) return undefined;
return stampFileHfIds(compFile);
try {
return ensureHfIds(readFileSync(compFile, "utf-8"));
} catch {
return null;
}
}
// Sub-composition preview
@@ -558,7 +535,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
// Assets are read-only and should mirror the renderer: permit a path that
// is lexically inside the project even if an explicit project symlink
// targets a shared directory outside it. Composition source files still
// use resolveWithinProject because preview mutates their data-hf-id values.
// use resolveWithinProject because saves write their data-hf-id values.
const candidate = resolve(project.dir, subPath);
const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null;
if (!file) {
+3 -2
View File
@@ -1,5 +1,6 @@
import type { CanvasResolution } from "@hyperframes/parsers";
import type { RegistryItem } from "@hyperframes/core";
import type { BundleOptions } from "@hyperframes/core/compiler";
import type { ProjectHistory } from "./history/projectHistory.js";
/** Resolved info about a single project. */
@@ -113,8 +114,8 @@ export interface StudioApiAdapter {
*/
history?: (project: ResolvedProject) => Promise<ProjectHistory | null> | ProjectHistory | null;
/** Bundle a project directory into a single HTML string. Returns null if unavailable. */
bundle(projectDir: string): Promise<string | null>;
/** Bundle a project directory into a single HTML string, forwarding `options` over the host's own. */
bundle(projectDir: string, options?: Pick<BundleOptions, "stampHfIds">): Promise<string | null>;
/** Optional: cached signature for project files that should invalidate preview frame caches. */
getProjectSignature?: (projectDir: string) => string;
+2 -2
View File
@@ -257,10 +257,10 @@ export function createViteAdapter(
return { id, dir: realpathSync(projectDir) };
},
async bundle(dir: string) {
async bundle(dir, options) {
const bundler = await getBundler();
if (!bundler) return null;
let html = await bundler(dir, PREVIEW_BUNDLE_OPTIONS);
let html = await bundler(dir, { ...PREVIEW_BUNDLE_OPTIONS, ...options });
html = html.replace(
'data-hyperframes-preview-runtime="1" src=""',
`data-hyperframes-preview-runtime="1" src="${this.runtimeUrl}"`,