mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
* 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>
105 lines
4.2 KiB
TypeScript
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);
|
|
}
|
|
}
|