feat(studio-server): preview variable injection + render variables forwarding#2049
Conversation
a4cd1b5 to
a2085a5
Compare
5870b13 to
c843527
Compare
c843527 to
dc24770
Compare
6acf033 to
39e9b70
Compare
2c0283f to
e0f52e3
Compare
39e9b70 to
cd67511
Compare
e0f52e3 to
aa20eba
Compare
cd67511 to
5e26843
Compare
aa20eba to
59929fb
Compare
5e26843 to
47e6506
Compare
miga-heygen
left a comment
There was a problem hiding this comment.
LGTM — clean server-side plumbing.
The shared variablesPayload.ts helper keeps the preview and render route contracts in sync, the <-escaping prevents script breakout, and the ETag salting ensures correct cache invalidation per variable set. Test coverage is thorough across both routes.
One minor observation (not blocking): parsePreviewVariablesParam and previewVariablesFromRequest are thin wrappers over each other — but the layering is justified since previewVariablesFromRequest packages the raw string for ETag salting alongside the parsed values, which the caller needs as a bundle. Clean separation.
— Miga
miguel-heygen
left a comment
There was a problem hiding this comment.
Stamped after Miga's stack review. GitHub state checked: mergeable, no red checks visible; #2046 base conflict still needs resolution separately.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 59929fb. Stack context: render-path lane (#2049 / #2054 / #2084) — 3-of-12 in template-variables stack.
🟢 What's good
<-escape defends against</script>breakout —JSON.stringify(values).replace(/</g, "\\u003c")is the right one-liner, and the test atpreview.test.ts:57-70exercises exactly the breakout payload ("</script><script>alert(1)</script>") and asserts the escaped form. Nice.- ETag salting (
variablesEtagSalt) — SHA-1 of the raw query string, 12-hex slice — is the right shape and the three-distinct-ETag test atpreview.test.ts:85-99proves cache coherency acrossnone / A / B. Cheap and correct. - Quirks-mode-safe insertion chain — the
<head…>→<html…>→ after-doctype → prepend fallback is defensible; the PR body flags this as a review fix over a bare<head>regex, and that catch was worth it. - Malformed payload → 400, not silent-defaults on both
?variables=and the render POST body. The rubric in the render.ts comment ("Unlike fps/quality (lenient with safe fallbacks)…") justifies the asymmetry well. - Shared payload helper (
variablesPayload.ts) enforces one contract, one error string across both routes — preview and render can't drift on what counts as a validvariablespayload. - CLI + Vite adapter parity — both
StudioApiAdapter.startRendercall sites forwardvariablessymmetrically; no drift between the CLI-embedded server and the Vite dev adapter.
🟡 Nits / questions
<head[^>]*>regex matches inside HTML comments. An authored HTML with<!-- ... <head> ... -->in the doctype-preamble (rare but legal) would land the injection inside a comment →window.__hfVariablesnever set → preview silently uses defaults. Not a security issue; a robustness one. A one-line "reject match inside a<!--…-->span" would harden this, but low-priority; almost no real composition has this.- Preview vs render mechanism divergence. Preview injects a literal object via JS syntax (
window.__hfVariables={"a":1}); render passes a JSON string throughevaluateOnNewDocumentand doesJSON.parseinside the page (frameCapture.ts:901-909). For pure JSON values these are semantically identical, but the PR body's "cannot diverge from render output" claim glosses over one edge: values containingU+2028/U+2029are valid JSON strings but historically illegal in JS string/statement contexts (ES2019 fixed this in modern engines). Any customer still shipping a pre-2019 engine on the render side (unlikely) would see a divergence. Not worth blocking on, but worth a note somewhere. ?variables=raw string feeds the ETag salt. Two clients sending the same JSON with different URL-encodings (e.g.%20vs+) get different ETags — cache duplication, not correctness bugs. Minor.- No payload size limit on
?variables=orbody.variables. A megabyte-scale payload is inefficient but doesn't cause an obvious DoS on this route. Consider a cap (e.g. 64KB) — future-proofing. - Sub-composition preview at
/preview/comp/*injects__hfVariablesbut not__hfVariablesByComp— bindings inside a standalone sub-comp preview will resolve against the top-level global. Probably intentional (previewing a sub-comp standalone means the sub-comp IS the composition), but if any existing consumer previews a sub-comp expecting per-instance scoping, they'll see the top-level values instead. Docs / route contract could clarify.
🟠 Hold
- No blockers on this PR, but a cross-cutting observation:
#2054(the declarative bindings PR sibling) will consume this payload in the DOM without any URL-protocol filtering. That's the layer where sanitization needs to live, not here. The server payload plumbing is correctly permissive (a variable value is just JSON); the DOM sink is where the safety story lives. Flagging on#2054in detail.
🔴 Blockers
None.
CI: green minus the pre-existing Perf: matrix.shard template-eval failure (not tied to this diff). Graphite mergeability pending — normal.
vanceingalls
left a comment
There was a problem hiding this comment.
R1 — hyperframes #2049 at 59929fbe
🟢 LGTM concurring with Miga and with Rames-D's 🟢/🟡 assessment.
Verified independently:
</script>escape hygiene.JSON.stringify(vars).replace(/</g, "\\u003c")— right one-liner, andpreview.test.ts:57-70exercises the exact breakout payload.- ETag salting via
variablesEtagSalt(SHA-1 of raw query, 12-hex slice); three-distinct-tag test at:85-99proves cache coherency. - Quirks-mode-safe insertion chain:
<head…>→<html…>→ after-doctype → prepend. Defensible. - Malformed payload → 400 on both
?variables=(preview) and body-json (render). No silent-defaults. The rubric contrast inrender.ts("Unlike fps/quality (lenient with safe fallbacks)…") justifies the asymmetry. - Shared
variablesPayload.tshelper across both routes — preview and render can't drift on the payload contract. - CLI + Vite adapter parity — both
StudioApiAdapter.startRendersites forwardvariablessymmetrically. - Global name matches downstream.
window.__hfVariablesmatches #2054'svariableScope.tslookup exactly. - 56 tests + CI green.
Concurring with Miga's LGTM.
Concurring with Rames-D's 🟡 nits — they're all fair calls, none blocking:
<head[^>]*>regex could match inside HTML comments. Rare authored input, but a one-line "reject match inside<!--…-->" is cheap hardening.__hfVariablesByCompnot populated on standalone sub-comp preview at/preview/comp/*. Bindings inside the standalone sub-comp will resolve against the top-level global. Probably intentional (previewing sub-comp standalone = the sub-comp IS the composition), but the route contract could clarify.- U+2028 / U+2029 legality in pre-ES2019 engines. Preview injects literal JS; render uses
JSON.parse. Modern engines converged in 2019, so this is theoretical for prod but worth a note. - No payload size cap. A future 64KB
?variables=guard would be future-proofing. ?variables=raw string feeds ETag — url-encoding variance produces different ETags (cache duplication, not correctness). Minor.
Rames-D's 🟠 cross-cutting observation is exactly right: the DOM sink safety story lives on #2054, not here. Payload plumbing is correctly permissive. I've concurred on his four defense-in-depth holds over on #2054.
No unique blockers.
R1 by Via
47e6506 to
4f96eef
Compare
476b923 to
fbc61e4
Compare
4f96eef to
f6b03f7
Compare
fbc61e4 to
e909dcc
Compare
dc29e00 to
6d6b162
Compare
e909dcc to
265f388
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
R2 at 265f388.
R1 was 🟢 with 🟡 nits (regex-inside-comments edge, U+2028/2029 in JSON.parse-vs-literal-obj corner, no payload size limit). Nits stand as fine-tuning items rather than merge holds — none surface as regressions in the diff.
The CSP compat concern that was cross-stack (this PR emits the inline script the CSP note references) is now documented at docs/concepts/variables.mdx:236-244 — explicit callout of the fallback behavior + nonce/hash workaround.
No residuals from my side. CI green.
vanceingalls
left a comment
There was a problem hiding this comment.
🟢 LGTM concurring with Rames-D — R2 at 265f388.
R1 had no 🟠 holds — only 🟡 nits (regex-in-HTML-comment, U+2028/2029 in JSON.parse vs literal-obj, no payload size limit, ETag salt URL-encoding variance, sub-comp __hfVariablesByComp gap). All stand as optional fine-tuning items rather than merge holds.
The cross-stack CSP-compat concern I flagged (this PR emits the inline <script>window.__hfVariables=…</script> that the CSP fallback rule references) is now documented at docs/concepts/variables.mdx:236-244 — explicit callout of the fallback-to-defaults behavior + nonce/hash workaround. Closes the cross-stack loop with #2054.
No residuals from my side.
R2 by Via
265f388 to
afd70d5
Compare
6d6b162 to
2f5d730
Compare
afd70d5 to
7631965
Compare
2f5d730 to
11d547a
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
🟢 LGTM at R3 — afd70d5.
No R2 holds. R2 verdict stands.
Cross-stack CSP compat docs at docs/concepts/variables.mdx still landed on #2054 through the rebase.
R3 by Via
7631965 to
901a425
Compare
11d547a to
193eb5c
Compare
…rwarding
Fourth PR of the template-variables Studio stack — the HTTP plumbing.
- preview routes (/preview and /preview/comp/*) accept
?variables=<url-encoded json> and inject
`window.__hfVariables = {...}` into <head>, before the runtime and any
composition script — the exact global the engine sets via
evaluateOnNewDocument at render time, so preview-with-values cannot
diverge from render output. Values are escaped against </script>
breakout, malformed payloads 400 instead of silently previewing
defaults, and the ETag is salted with a hash of the payload so cached
previews revalidate when values change.
- POST /projects/:id/render accepts variables ({variableId: value}) and
forwards them through StudioApiAdapter.startRender into the producer's
RenderConfig.variables — the same channel `hyperframes render
--variables` uses. Wired in both adapters (CLI embedded server + vite
dev adapter).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
193eb5c to
91831f3
Compare
901a425 to
2e0b884
Compare

What
Fourth PR of the template-variables stack: the HTTP plumbing.
/previewand/preview/comp/*) accept?variables=<url-encoded json>and injectwindow.__hfVariables = {...}before the runtime and any composition script — the exact global the engine sets viaevaluateOnNewDocumentat render time, so preview-with-values cannot diverge from render outputPOST /projects/:id/renderacceptsvariablesand forwards them throughStudioApiAdapter.startRenderinto the producer'sRenderConfig.variables— the same channelhyperframes render --variablesuses. Wired in both adapters (CLI embedded server + vite dev adapter)Why
Render-time injection existed; preview had no equivalent, so "preview with values" was impossible. Mirroring the engine's exact injection mechanism (rather than a bespoke postMessage protocol) is what makes the Studio preview render-truthful.
How
Safety/correctness details worth reviewing:
<-escaped against</script>breakout<head…>→<html…>→ after doctype → prepend (review fix — the original bare-<head>regex could land the tag before the doctype)helpers/variablesPayload.ts) used by both routesTest plan
variablesto this route🤖 Generated with Claude Code