21 KiB
Rendering Architecture
Definitive design document for Originmain's two live-rendering modes. Created 2026-04-29. Supersedes the original Layer 2 approach (direct cross-origin iframe script injection, which is non-functional — see "Why the Previous Approach Failed" below).
Table of Contents
- Overview
- Why the Previous Approach Failed
- Architecture Overview
- Mode A — CLI Proxy (Live Development)
- Mode B — Live SDK (Preview Deployments / GitHub)
- PostMessage Protocol (Unchanged)
- Artboard ID Routing via
window.name - Sequence Diagrams
- Edge Cases
- Package Reference
- Migration Notes
1. Overview
Originmain needs to render a user's running React application inside an artboard iframe on the canvas, while simultaneously extracting the React fiber tree (component names, props, DOM rects) so the Inspector, SelectionOverlay, and Diff Engine can operate on live data.
Two rendering modes serve different workflows:
| Mode | When to use | How the app loads | Who installs the fiber hook |
|---|---|---|---|
| A — CLI Proxy | Active local development | User's dev server proxied through @originmain/cli |
The proxy injects it into every HTML response |
| B — Live SDK | Preview deployments, CI, async review | Vercel/Netlify preview URL or any hosted app | @originmain/live npm package (imported before React) |
Both modes use the same postMessage protocol (packages/renderer/src/protocol.ts)
and the same LiveArtboard.tsx component. The only difference is
who is responsible for installing the fiber hook before React loads.
2. Why the Previous Approach Failed
The original Layer 2 implementation attempted direct cross-origin script
injection via iframe.contentDocument. This fails for three independent
reasons, any one of which is fatal:
2.1 Cross-Origin DOM Access Is Blocked
LiveArtboard.tsx called iframe.contentDocument.createElement('script').
When the iframe's origin (http://localhost:3000) differs from the
host (http://localhost:3001 or https://app.originmain.com), the
browser returns null for contentDocument. The try/catch in
injectFiberHook() silently swallowed this error.
2.2 Circular READY Handshake
The READY message was generated by the injected fiber hook script
itself (line 106 in fiber-hook.ts). But the host waited for READY
before injecting the script. Neither side could proceed first.
2.3 Hook Must Precede React Module Evaluation
__REACT_DEVTOOLS_GLOBAL_HOOK__ must exist before React's module body
runs. React checks for this object exactly once, at import time. Any
script injected after React has loaded is too late — React will never
register with a hook installed after the fact.
2.4 X-Frame-Options / CSP Blocking
Many apps (including GitHub pages) send X-Frame-Options: DENY or
Content-Security-Policy: frame-ancestors 'none'. The iframe renders
blank. No browser-side workaround exists.
3. Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Originmain Canvas (host) │
│ │
│ ┌──────────────┐ postMessage ┌──────────────────────┐ │
│ │ LiveArtboard │ ◄──────────────── │ iframe │ │
│ │ (listens) │ READY │ name="om:{id}" │ │
│ │ │ FIBER_TREE_UPDATE │ │ │
│ │ │ COMPONENT_SELECTED │ ┌────────────────┐ │ │
│ └──────────────┘ │ │ Fiber Hook │ │ │
│ │ │ │ (reads │ │ │
│ ▼ │ │ window.name) │ │ │
│ ┌──────────┐ │ └────────────────┘ │ │
│ │Inspector │ │ ▲ │ │
│ │Overlay │ │ │ installed │ │
│ │DiffEngine│ │ ┌──┴─────────┐ │ │
│ └──────────┘ │ │ Mode A: │ │ │
│ │ │ CLI Proxy │ │ │
│ │ │ Mode B: │ │ │
│ │ │ Live SDK │ │ │
│ │ └────────────┘ │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Key invariant: the fiber hook is always installed before React loads. The two modes differ only in who ensures this.
4. Mode A — CLI Proxy (Live Development)
4.1 User Experience
# Terminal 1 — user's dev server (as usual)
npm run dev # → http://localhost:3000
# Terminal 2 — Originmain proxy
npx @originmain/cli dev --target http://localhost:3000
# → Proxy listening on http://localhost:4170
# → Paste this URL into your Originmain artboard
The user enters http://localhost:4170 as the artboard's render URL
in Originmain. The iframe loads through the proxy.
4.2 What the Proxy Does
For every HTTP request:
- Forward the request to the target dev server (
localhost:3000) - Strip response headers that block iframing:
X-Frame-Options(any value)Content-Security-Policyframe-ancestorsdirective
- For HTML responses only (Content-Type contains
text/html):- Strip
Accept-Encodingfrom the outgoing request so the target sends uncompressed HTML (avoids decompressing gzip/brotli) - Buffer the full response body
- Inject the fiber hook
<script>tag immediately after<head>(or after<html>, or at document start as fallback) - Update
Content-Lengthto match the modified body
- Strip
- For non-HTML responses: stream through unchanged (only headers stripped)
4.3 WebSocket Passthrough (HMR)
Modern dev servers (Next.js, Vite, Webpack) use WebSocket for Hot
Module Replacement. The proxy listens for HTTP Upgrade requests and
opens a raw TCP tunnel to the target server, passing bytes
bidirectionally. HMR continues working normally.
4.4 CORS Headers
The proxy adds permissive CORS headers to all responses:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: *
Access-Control-Allow-Headers: *
This prevents issues when the user's app makes absolute-URL API calls
that resolve to localhost:3000 from an iframe at localhost:4170.
4.5 The Injected Script
The proxy injects a self-contained <script> block that:
- Checks
window.parent !== window(only runs when iframed) - Reads
window.namefor the artboard ID (set byLiveArtboard) - Installs
__REACT_DEVTOOLS_GLOBAL_HOOK__if not present - Wraps
onCommitFiberRootto serialize the fiber tree on every commit - Sends
READYviapostMessageto the parent - On each React commit, sends
FIBER_TREE_UPDATEviapostMessage
The script is ~90 lines, has zero dependencies, and is generated once
at proxy startup by buildProxyFiberHookScript() in
packages/renderer/src/fiber-hook.ts.
5. Mode B — Live SDK (Preview Deployments / GitHub)
5.1 User Experience
npm install @originmain/live
// MUST be the very first import in your app entry point
// (before React, before anything else)
import '@originmain/live';
import React from 'react';
import ReactDOM from 'react-dom/client';
// ... rest of app
The SDK is a zero-dependency side-effect import. At module evaluation time (before React's module body runs), it installs the fiber hook.
5.2 When It Activates
The SDK only activates when all of these are true:
window.parent !== window(the app is inside an iframe)window.namestarts withom:(the iframe was created by Originmain)
When running standalone (not iframed, or iframed by something other than Originmain), the SDK is a complete no-op: no global hook installed, no messages sent, zero runtime cost.
5.3 GitHub Integration Flow
1. User connects GitHub repo to Originmain workspace
2. User installs @originmain/live in their app
3. User pushes code → Vercel/Netlify deploys preview
4. GitHub sends deployment_status webhook → Originmain
5. Originmain creates artboard with preview deployment URL
6. Canvas iframes the preview URL
7. @originmain/live (bundled in preview) detects Originmain iframe
8. Fiber data flows via postMessage → full inspection works
5.4 Why Preview URLs Work Without a Proxy
Vercel and Netlify preview deployments do not set
X-Frame-Options or frame-ancestors by default. They are designed
to be embeddable (Vercel's own preview comments embed them). This
means the iframe renders correctly without header stripping.
If a user's deployment provider does block iframing, they should use Mode A (CLI proxy) instead, or configure their provider's headers.
5.5 Production Opt-Out
The SDK should not ship to production. Recommended patterns:
// Option 1: conditional import (Vite / Webpack)
if (process.env.NODE_ENV !== 'production') {
await import('@originmain/live');
}
// Option 2: separate entry point
// dev.tsx imports @originmain/live, then imports main.tsx
// prod.tsx imports main.tsx directly
6. PostMessage Protocol (Unchanged)
The protocol from packages/renderer/src/protocol.ts is unchanged.
Both modes use the same envelope format:
Host → Renderer (Originmain → iframe)
| Type | Payload | Purpose |
|---|---|---|
SET_DESIGN_TOKENS |
{ tokens: Record<string, string> } |
Push DLF tokens as CSS variables |
NAVIGATE |
{ path: string } |
Navigate to a route within the app |
SELECT_COMPONENT |
{ nodeId: string } |
Highlight a component |
DESELECT |
— | Clear selection |
INJECT_FIBER_HOOK |
— | Legacy; no longer used |
Renderer → Host (iframe → Originmain)
| Type | Payload | Purpose |
|---|---|---|
READY |
— | Fiber hook installed, app loaded |
FIBER_TREE_UPDATE |
{ root: FiberNode } |
Full serialized fiber tree |
COMPONENT_SELECTED |
{ nodeId, rect } |
User clicked a component in the iframe |
COMPONENT_DESELECTED |
— | Selection cleared |
ERROR |
{ message: string } |
Hook or serialization error |
Envelope Format
// Renderer → Host
{
source: 'originmain-renderer', // discriminant
artboardId: string, // from window.name
message: RendererMessage
}
// Host → Renderer
{
source: 'originmain-host', // discriminant
artboardId: string,
message: HostMessage
}
7. Artboard ID Routing via window.name
Each <iframe> element gets a name attribute set by LiveArtboard:
<iframe name={`om:${artboardId}`} src={proxyOrPreviewUrl} ... />
The fiber hook (whether injected by the proxy or by the SDK) reads
window.name at initialization:
var artboardId = '';
if (window.name && window.name.indexOf('om:') === 0) {
artboardId = window.name.slice(3);
}
if (!artboardId) return; // not an Originmain iframe — do nothing
This design has three advantages over the previous baked-in ID approach:
- Multiple artboards can share one proxy — each iframe has a
unique name, but all load through the same
localhost:4170proxy. - SPA navigation preserves the ID —
window.namepersists across navigations within the same browsing context. - The proxy is artboard-agnostic — it injects one generic script for all requests. No per-artboard configuration needed.
8. Sequence Diagrams
Mode A — CLI Proxy
User's Dev Server CLI Proxy (:4170) Originmain Canvas
│ │ │
│ │ iframe src=:4170/ │
│ │ ◄──────────────────────── │
│ GET / │ │
│ ◄──────────────────── │ │
│ │ │
│ 200 OK (HTML) │ │
│ ──────────────────── ▶ │ │
│ │ │
│ strip X-Frame-Options │
│ inject <script> after <head> │
│ update Content-Length │
│ │ │
│ │ 200 OK (modified HTML) │
│ │ ─────────────────────────▶ │
│ │ │
│ │ postMessage: READY │
│ │ ─────────────────────────▶ │
│ │ │
│ │ (React loads, commits) │
│ │ │
│ │ postMessage: │
│ │ FIBER_TREE_UPDATE │
│ │ ─────────────────────────▶ │
│ │ │
│ │ Inspector / Overlay / │
│ │ DiffEngine now have │
│ │ live fiber data │
Mode B — Live SDK
User's App (preview URL) Originmain Canvas
│ │
│ iframe src=preview-url.vercel.app │
│ ◄─────────────────────────────────────── │
│ │
│ @originmain/live evaluates (side effect) │
│ → installs __REACT_DEVTOOLS_GLOBAL_HOOK__ │
│ → reads window.name → artboard ID │
│ → sends postMessage: READY │
│ ──────────────────────────────────────────▶ │
│ │
│ React evaluates → registers with hook │
│ React renders → onCommitFiberRoot fires │
│ │
│ postMessage: FIBER_TREE_UPDATE │
│ ──────────────────────────────────────────▶ │
│ │
│ (identical from here on) │
9. Edge Cases
9.1 HMR / Hot Module Replacement
The CLI proxy passes WebSocket Upgrade requests through to the
target server via raw TCP socket tunneling. The user's HMR continues
working. When a hot update changes the component tree, React commits
again, the fiber hook fires, and the canvas receives an updated tree.
9.2 Server-Side Rendering (SSR)
The proxy receives the server-rendered HTML. The fiber hook script is
injected before any application scripts. When React hydrates the SSR
output, it finds the hook already installed and registers. Hydration
commits trigger onCommitFiberRoot just like client-side renders.
9.3 React DevTools Coexistence
If the user has the React DevTools browser extension installed, it
will have already set __REACT_DEVTOOLS_GLOBAL_HOOK__. The fiber
hook checks for an existing hook and wraps the existing
onCommitFiberRoot rather than replacing it. Both Originmain and
React DevTools receive fiber commits independently.
9.4 Non-React Applications
If the proxied app does not use React, the hook installs but
onCommitFiberRoot never fires. The READY message is still sent.
The artboard displays the app visually, but the Inspector shows no
component tree. This is by design — Originmain supports visual
review of any web app, with React-specific features only activating
when React is detected.
9.5 Multiple Artboards, One Proxy
Several artboards can point to the same proxy URL with different
routes (e.g., localhost:4170/dashboard and localhost:4170/settings).
Each iframe has a unique window.name, so fiber updates are correctly
routed to their respective artboards.
9.6 SPA Navigation Inside the Iframe
SPA routers change the URL without a full page reload. React does not
unmount — it re-renders. The fiber hook, already installed, continues
receiving commits. window.name persists across SPA navigations.
9.7 Full Page Navigation Inside the Iframe
If the user's app performs a full page navigation (e.g., <a href>
without client-side routing), the browser requests the new page from
the proxy. The proxy injects the fiber hook into the new HTML response.
window.name is preserved by the browser across same-frame
navigations. The new page's React instance finds the hook and
registers. A new READY message fires.
9.8 Compressed Responses
The proxy strips Accept-Encoding from outgoing requests to the
target, causing the target to respond with uncompressed HTML. This
avoids the complexity of decompressing gzip/brotli/deflate before
injection. The performance impact is negligible over localhost.
9.9 Chunked Transfer-Encoding
The proxy buffers the full HTML response before injecting the script. For streaming SSR responses, this adds latency equal to the full document size. This is acceptable for a development tool.
10. Package Reference
@originmain/cli (packages/cli)
| Export / Command | Description |
|---|---|
originmain dev --target <url> [--port <n>] |
Start the reverse proxy |
--target |
Required. The user's dev server URL |
--port |
Proxy listen port (default: 4170) |
Dependencies: @originmain/renderer (for buildProxyFiberHookScript).
Node.js built-ins only for proxy (node:http, node:net, node:url).
@originmain/live (packages/live-sdk)
| Export | Description |
|---|---|
import '@originmain/live' |
Side-effect import — installs fiber hook |
Zero dependencies. ~2 KB minified. No-op when not in an Originmain iframe.
@originmain/renderer (packages/renderer) — updated
| Export | Description |
|---|---|
buildProxyFiberHookScript() |
Returns the generic fiber hook script (reads window.name) |
buildFiberHookScript(id) |
Deprecated. Legacy baked-in ID version |
| (all other exports) | Unchanged |
11. Migration Notes
LiveArtboard.tsx Changes
- Add
name={om:${id}}to the<iframe>element - Remove the
injectFiberHook()call from theREADYhandler - Keep the
postMessagelistener — the protocol is identical
Artboard.tsx Changes
- Update
EmptyArtboardContentto show proxy instructions - No changes to fiber handling, SelectionOverlay, or diff logic
GitHub Connector Fix
packages/integrations/src/connectors/github.ts must set renderUrl
to the deployment preview URL (from a deployment_status webhook),
not pr.html_url (which is the GitHub PR page and blocks iframing).
Last updated: 2026-04-29