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

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*