513 lines
21 KiB
Markdown
513 lines
21 KiB
Markdown
# 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](#1-overview)
|
|
2. [Why the Previous Approach Failed](#2-why-the-previous-approach-failed)
|
|
3. [Architecture Overview](#3-architecture-overview)
|
|
4. [Mode A — CLI Proxy (Live Development)](#4-mode-a--cli-proxy-live-development)
|
|
5. [Mode B — Live SDK (Preview Deployments / GitHub)](#5-mode-b--live-sdk-preview-deployments--github)
|
|
6. [PostMessage Protocol (Unchanged)](#6-postmessage-protocol-unchanged)
|
|
7. [Artboard ID Routing via `window.name`](#7-artboard-id-routing-via-windowname)
|
|
8. [Sequence Diagrams](#8-sequence-diagrams)
|
|
9. [Edge Cases](#9-edge-cases)
|
|
10. [Package Reference](#10-package-reference)
|
|
11. [Migration Notes](#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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
npm install @originmain/live
|
|
```
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
// 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
|
|
|
|
```ts
|
|
// 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`:
|
|
|
|
```tsx
|
|
<iframe name={`om:${artboardId}`} src={proxyOrPreviewUrl} ... />
|
|
```
|
|
|
|
The fiber hook (whether injected by the proxy or by the SDK) reads
|
|
`window.name` at initialization:
|
|
|
|
```js
|
|
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 ID** — `window.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*
|