Files
hyperframes/packages/studio-server/src/helpers/hfIdPersist.ts
T
Vance IngallsandClaude Fable 5 3a717fa719 fix(parsers,sdk,studio-server,studio): unify hf-id space across preview, disk, and SDK session (#1981)
* fix(parsers,sdk,studio-server,studio): unify hf-id space across preview, disk, and SDK session

Root-causes the setTiming element_not_found resolver-shadow divergence class:
timeline edits carry hf-ids read from the live preview DOM, but the preview
minted ids AFTER rewriting attributes (and never persisted them for sub-comps),
while the SDK session mints from the raw file — content-keyed minting then
yields different ids for the same element. Template-based comps were worse:
the SDK excluded the whole <template> subtree, so the session had zero
elements and every edit diverged.

- parsers: ensureHfIds now descends into <template> subtrees (linkedom's
  querySelectorAll does not), minting and pinning inner ids
- sdk: buildRoots/buildElement treat <template> as a transparent container,
  and resolution (resolveScoped, animation-id map) searches template subtrees
  via querySelectorAllDeep — template comps now model, resolve, and edit
- studio-server: the sub-comp preview route persists hf-ids to the raw file
  BEFORE the rewrite pipeline (mirrors the main route), pinning one id space
  across served DOM, disk, and SDK session
- studio: resolver-shadow skips structurally-empty sessions (no event, no
  attempt) and tags fail-open emissions with sourceReadFailed so read errors
  are distinguishable from unwired readers in telemetry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(parsers,sdk,studio-server,studio): scope template descent, guard persist route

Addresses the 10 verified findings from the PR #1981 review:

- Restrict template transparency to COMPOSITION templates
  (<template data-composition-id>) everywhere — ensureHfIds, SDK
  buildChildren, querySelectorAllDeep. A plain <template> (runtime
  clone-source) keeps its old fully-excluded behavior: stamping its
  interior would duplicate one persisted id across every runtime clone,
  and modeling it would show phantom timeline clips.
- Guard the sub-comp persist: only .html files (the wildcard route can
  serve any project path — stamping an SVG corrupted it on disk),
  try/catch the read (file-removed race becomes 404, not 500), salt the
  etag (v2) so pre-fix cached clients don't 304 past the id pin, and
  thread the stamped content into buildSubCompositionHtml so served ids
  match the mint even when the disk write is skipped.
- Rewrite querySelectorAllDeep as a document-order DOM walk — appending
  template matches after top-level matches made duplicate-id tiebreaks
  disagree with the preview's unwrapped DOM (wrong-element edits).
- Recurse sourceMutation.querySelectorAllWithTemplates so server-side
  ops resolve ids at any template depth, matching SDK resolution.
- Replace the empty-session silent skip with ONE tagged session_empty
  event per session — silence would blind the tripwire to exactly the
  modeling-gap class that exposed the template bug. Attempts stay
  uncounted (an unmodelable comp can't cut over).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio-server): close TOCTOU in sub-comp hf-id persist (CodeQL js/file-system-race)

Replace the route-level stat/read/persist sequence with stampFileHfIds:
validation (fstat), read, mint, and write-back all go through ONE open
file descriptor (O_NOFOLLOW where supported), so the path cannot be
swapped between validation and write. Falls back to read-only stamping
when the file isn't writable — content-keyed minting means the SDK
derives the same ids from the same bytes even without the disk write.

Addresses miguel-heygen's blocking review on PR #1981.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio-server): linear-time template-attr match (CodeQL js/polynomial-redos)

promoteTemplateCompositionId's single-pattern regex backtracked
polynomially on crafted input. Two-step match: grab each <template>
open tag linearly, then find data-composition-id within that short
tag text. Same semantics (first template carrying the attr wins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:16:07 -07:00

105 lines
4.2 KiB
TypeScript

import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import {
closeSync,
constants,
fstatSync,
ftruncateSync,
openSync,
readFileSync,
writeFileSync,
writeSync,
} from "node:fs";
/**
* 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) {
writeFileSync(filePath, normalized, "utf-8");
}
} 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;
try {
return openSync(filePath, flags | noFollow);
} catch {
return null;
}
}
/**
* Read `filePath`, mint any missing `data-hf-id`s, write the stamped content
* back if new ids were added, and return the stamped content — all through ONE
* file descriptor. Unlike the check-path / read-path / write-path sequence a
* route handler would otherwise do, the validation (fstat), read, and write
* all target the same open inode, so the path cannot be swapped (e.g. for a
* symlink) between validation and write (CodeQL js/file-system-race).
*
* Falls back to read-only stamping when the file isn't writable (read-only
* fs, sandbox) — serving stamped content without persisting is still correct;
* ids are content-keyed so the SDK mints the same ones from the same bytes.
*
* 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.
*/
export function stampFileHfIds(filePath: string): string | null {
let fd = openNoFollow(filePath, constants.O_RDWR);
let writable = true;
if (fd === null) {
fd = openNoFollow(filePath, constants.O_RDONLY);
writable = false;
}
if (fd === null) return null;
try {
if (!fstatSync(fd).isFile()) return 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).
const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
if (writable && idsAfter > idsBefore) {
ftruncateSync(fd, 0);
writeSync(fd, normalized, 0, "utf-8");
}
return normalized;
} catch (err) {
console.warn("[hyperframes] stampFileHfIds: failed to stamp ids:", err);
return null;
} finally {
closeSync(fd);
}
}