Files
wursor/RENDERING-ARCHITECTURE.md
T
2026-04-29 12:09:18 +01:00

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

  1. Overview
  2. Why the Previous Approach Failed
  3. Architecture Overview
  4. Mode A — CLI Proxy (Live Development)
  5. Mode B — Live SDK (Preview Deployments / GitHub)
  6. PostMessage Protocol (Unchanged)
  7. Artboard ID Routing via window.name
  8. Sequence Diagrams
  9. Edge Cases
  10. Package Reference
  11. 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:

  1. Forward the request to the target dev server (localhost:3000)
  2. Strip response headers that block iframing:
    • X-Frame-Options (any value)
    • Content-Security-Policy frame-ancestors directive
  3. For HTML responses only (Content-Type contains text/html):
    • Strip Accept-Encoding from 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-Length to match the modified body
  4. 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:

  1. Checks window.parent !== window (only runs when iframed)
  2. Reads window.name for the artboard ID (set by LiveArtboard)
  3. Installs __REACT_DEVTOOLS_GLOBAL_HOOK__ if not present
  4. Wraps onCommitFiberRoot to serialize the fiber tree on every commit
  5. Sends READY via postMessage to the parent
  6. On each React commit, sends FIBER_TREE_UPDATE via postMessage

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.name starts with om: (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:

  1. Multiple artboards can share one proxy — each iframe has a unique name, but all load through the same localhost:4170 proxy.
  2. SPA navigation preserves the IDwindow.name persists across navigations within the same browsing context.
  3. 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

  1. Add name={om:${id}} to the <iframe> element
  2. Remove the injectFiberHook() call from the READY handler
  3. Keep the postMessage listener — the protocol is identical

Artboard.tsx Changes

  1. Update EmptyArtboardContent to show proxy instructions
  2. 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