diff --git a/HANDOFF.md b/HANDOFF.md
index 3cca855..41a81b7 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -11,7 +11,7 @@
|-------|-----------------------------|--------------|------------------------------------------------------------------|
| 0 | Infrastructure & DevOps | ⏳ Deferred | Skipped for now per plan |
| 1 | Canvas UI Shell | ✅ Complete | ArtboardTree, CodebaseFileTree, CodeDiffPanel |
-| 2 | Live Rendering Engine | ✅ Complete | postMessage protocol, fiber hook, MF config, LiveArtboard iframe |
+| 2 | Live Rendering Engine | ✅ Revised | CLI proxy + Live SDK; see RENDERING-ARCHITECTURE.md |
| 3 | Visual Editing & Diff Engine| ✅ Complete | Diff engine (49 tests), SelectionOverlay, history store |
| 4 | Origin Graph & Data Store | ✅ Complete | 3 migrations, RLS, Zod types, query helpers |
| 5 | Design Language Runtime | ✅ Complete | New package, JSON Schema, Zod validator, token pipeline |
@@ -49,16 +49,52 @@
---
-## Layer 2 — Live Rendering Engine ✅
+## Layer 2 — Live Rendering Engine ✅ (Revised 2026-04-29)
-**Completed:** 2026-04-25
+**Original:** 2026-04-25 — postMessage protocol, fiber hook, MF config, LiveArtboard iframe
+**Revised:** 2026-04-29 — Replaced non-functional cross-origin injection with two working modes
-### Files created
-- **`packages/renderer/src/protocol.ts`** — `HostMessage`/`RendererMessage` unions, `isHostEnvelope()`, `isRendererEnvelope()`, `createHostEnvelope()`, `createRendererEnvelope()`; source discriminants `'originmain-host'` / `'originmain-renderer'`
-- **`packages/renderer/src/fiber-hook.ts`** — `buildFiberHookScript(artboardId)` generates injectable script that patches `__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot`, walks Fiber tree, posts `FIBER_TREE_UPDATE` envelopes to `window.parent`
-- **`packages/renderer/src/module-federation.ts`** — `createRendererHostConfig()` and `createRemoteConfig()` MF host config helpers with shared React singleton
-- **`packages/renderer/src/index.ts`** — exports all of the above
-- **`packages/app/src/components/canvas/LiveArtboard.tsx`** — React iframe component; `sandbox="allow-scripts allow-same-origin allow-forms"`; injects fiber hook on `READY`; sends `SET_DESIGN_TOKENS` on token change
+> See **`RENDERING-ARCHITECTURE.md`** for the full design document.
+
+### Why the original approach was non-functional
+The original `LiveArtboard.tsx` tried `iframe.contentDocument.createElement('script')` to inject the fiber hook. This fails for cross-origin iframes (the browser blocks DOM access), and the READY handshake had a circular dependency. See `RENDERING-ARCHITECTURE.md` §2 for details.
+
+### Two rendering modes (both use the same postMessage protocol)
+
+#### Mode A — CLI Proxy (`@originmain/cli`)
+For active local development. User runs `npx @originmain/cli dev --target http://localhost:3000` and enters the proxy URL (`http://localhost:4170`) into the artboard.
+
+The proxy:
+1. Strips `X-Frame-Options` / CSP headers from responses
+2. Injects the fiber hook ``;
+ }
+ return cachedScriptTag;
+}
+
+/**
+ * Inject the fiber hook script into an HTML string.
+ *
+ * Injection strategy (in order of preference):
+ * 1. After `
` — standard position for early scripts
+ * 2. After `` — fallback if is missing
+ * 3. Prepend to document — final fallback
+ */
+export function injectFiberHook(html: string): string {
+ const tag = getScriptTag();
+
+ // Try after
+ const headMatch = /]*>/i.exec(html);
+ if (headMatch) {
+ const insertAt = headMatch.index + headMatch[0].length;
+ return html.slice(0, insertAt) + tag + html.slice(insertAt);
+ }
+
+ // Try after
+ const htmlMatch = /]*>/i.exec(html);
+ if (htmlMatch) {
+ const insertAt = htmlMatch.index + htmlMatch[0].length;
+ return html.slice(0, insertAt) + tag + html.slice(insertAt);
+ }
+
+ // Final fallback: prepend
+ return tag + html;
+}
diff --git a/packages/cli/src/proxy.ts b/packages/cli/src/proxy.ts
new file mode 100644
index 0000000..d3ad2be
--- /dev/null
+++ b/packages/cli/src/proxy.ts
@@ -0,0 +1,187 @@
+// ── Reverse Proxy ────────────────────────────────────────────────────────────
+// HTTP reverse proxy that:
+// 1. Forwards all requests to the target dev server
+// 2. Strips X-Frame-Options and CSP frame-ancestors from responses
+// 3. Injects the Originmain fiber hook into HTML responses
+// 4. Passes WebSocket upgrades through for HMR
+//
+// Uses only Node.js built-ins — no external dependencies.
+
+import { createServer, request as httpRequest } from 'node:http';
+import { connect as netConnect } from 'node:net';
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import type { Socket } from 'node:net';
+import { injectFiberHook } from './inject.js';
+
+export interface ProxyOptions {
+ /** Target dev server URL, e.g. "http://localhost:3000" */
+ target: string;
+ /** Port for the proxy to listen on (default: 4170) */
+ port: number;
+}
+
+/** Headers to strip from proxied responses (case-insensitive). */
+const STRIP_RESPONSE_HEADERS = new Set([
+ 'x-frame-options',
+ 'content-security-policy',
+ 'content-security-policy-report-only',
+]);
+
+/** CORS headers added to every response for cross-origin API compatibility. */
+const CORS_HEADERS: Record = {
+ 'access-control-allow-origin': '*',
+ 'access-control-allow-methods': '*',
+ 'access-control-allow-headers': '*',
+ 'access-control-allow-credentials': 'true',
+};
+
+/**
+ * Start the reverse proxy server.
+ * Returns a cleanup function that shuts down the server.
+ */
+export function startProxy(opts: ProxyOptions): { close: () => void } {
+ const targetUrl = new URL(opts.target);
+ const targetHost = targetUrl.hostname;
+ const targetPort = parseInt(targetUrl.port || '80', 10);
+
+ const server = createServer((clientReq: IncomingMessage, clientRes: ServerResponse) => {
+ // ── Build the outgoing request to the target ─────────────────────────
+
+ // Clone headers, stripping Accept-Encoding so the target sends
+ // uncompressed HTML (avoids needing to decompress before injection).
+ const outHeaders: Record = {};
+ for (const [key, val] of Object.entries(clientReq.headers)) {
+ if (key.toLowerCase() === 'accept-encoding') continue;
+ if (key.toLowerCase() === 'host') {
+ outHeaders[key] = `${targetHost}:${targetPort}`;
+ continue;
+ }
+ if (val !== undefined) {
+ outHeaders[key] = val;
+ }
+ }
+
+ const proxyReq = httpRequest(
+ {
+ hostname: targetHost,
+ port: targetPort,
+ path: clientReq.url ?? '/',
+ method: clientReq.method,
+ headers: outHeaders,
+ },
+ (proxyRes) => {
+ // ── Process response headers ───────────────────────────────────
+
+ const resHeaders: Record = {};
+
+ for (const [key, val] of Object.entries(proxyRes.headers)) {
+ if (STRIP_RESPONSE_HEADERS.has(key.toLowerCase())) continue;
+ if (val !== undefined) {
+ resHeaders[key] = val;
+ }
+ }
+
+ // Add CORS headers
+ for (const [key, val] of Object.entries(CORS_HEADERS)) {
+ resHeaders[key] = val;
+ }
+
+ // ── Determine if this is an HTML response ──────────────────────
+
+ const contentType = (proxyRes.headers['content-type'] ?? '').toLowerCase();
+ const isHtml = contentType.includes('text/html');
+
+ if (!isHtml) {
+ // Non-HTML: stream through unchanged (headers already stripped)
+ clientRes.writeHead(proxyRes.statusCode ?? 200, resHeaders);
+ proxyRes.pipe(clientRes);
+ return;
+ }
+
+ // HTML: buffer the full body, inject the script, then send.
+ const chunks: Buffer[] = [];
+ proxyRes.on('data', (chunk: Buffer) => chunks.push(chunk));
+ proxyRes.on('end', () => {
+ const rawHtml = Buffer.concat(chunks).toString('utf-8');
+ const injectedHtml = injectFiberHook(rawHtml);
+ const body = Buffer.from(injectedHtml, 'utf-8');
+
+ // Update Content-Length to match the injected body
+ resHeaders['content-length'] = String(body.byteLength);
+ // Remove Transfer-Encoding: chunked since we send the full body
+ delete resHeaders['transfer-encoding'];
+
+ clientRes.writeHead(proxyRes.statusCode ?? 200, resHeaders);
+ clientRes.end(body);
+ });
+ },
+ );
+
+ proxyReq.on('error', (err) => {
+ console.error(`[originmain proxy] Target request failed: ${err.message}`);
+ if (!clientRes.headersSent) {
+ clientRes.writeHead(502, { 'content-type': 'text/plain' });
+ }
+ clientRes.end(`Originmain proxy: could not reach target at ${opts.target}\n${err.message}`);
+ });
+
+ // Pipe the client request body to the target
+ clientReq.pipe(proxyReq);
+ });
+
+ // ── WebSocket upgrade passthrough (for HMR) ─────────────────────────────
+
+ server.on('upgrade', (req: IncomingMessage, clientSocket: Socket, head: Buffer) => {
+ // Open a TCP connection to the target and forward the HTTP upgrade
+ const targetSocket = netConnect(targetPort, targetHost, () => {
+ // Reconstruct the raw HTTP upgrade request
+ const reqLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`;
+ const headers = Object.entries(req.headers)
+ .filter(([key]) => key.toLowerCase() !== 'host')
+ .map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(', ') : val ?? ''}`)
+ .join('\r\n');
+ const hostHeader = `host: ${targetHost}:${targetPort}`;
+
+ targetSocket.write(`${reqLine}${hostHeader}\r\n${headers}\r\n\r\n`);
+ if (head.length > 0) {
+ targetSocket.write(head);
+ }
+
+ // Pipe bidirectionally
+ targetSocket.pipe(clientSocket);
+ clientSocket.pipe(targetSocket);
+ });
+
+ targetSocket.on('error', (err) => {
+ console.error(`[originmain proxy] WebSocket proxy error: ${err.message}`);
+ clientSocket.destroy();
+ });
+
+ clientSocket.on('error', () => {
+ targetSocket.destroy();
+ });
+ });
+
+ server.listen(opts.port, () => {
+ const proxyUrl = `http://localhost:${opts.port}`;
+ console.log('');
+ console.log(' \x1b[36m\x1b[1mOriginmain\x1b[0m proxy running');
+ console.log('');
+ console.log(` Target: ${opts.target}`);
+ console.log(` Proxy: \x1b[1m${proxyUrl}\x1b[0m`);
+ console.log('');
+ console.log(' Paste the proxy URL into your Originmain artboard\'s');
+ console.log(' "Connect app" field to enable live rendering.');
+ console.log('');
+ console.log(' \x1b[2mFiber hook injection ........ active\x1b[0m');
+ console.log(' \x1b[2mX-Frame-Options stripping ... active\x1b[0m');
+ console.log(' \x1b[2mWebSocket passthrough ....... active\x1b[0m');
+ console.log('');
+ });
+
+ return {
+ close() {
+ server.close();
+ },
+ };
+}
diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json
new file mode 100644
index 0000000..3a46da3
--- /dev/null
+++ b/packages/cli/tsconfig.build.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "outDir": "dist",
+ "declaration": true,
+ "sourceMap": true
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json
new file mode 100644
index 0000000..e97766c
--- /dev/null
+++ b/packages/cli/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["node"]
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/integrations/src/connectors/github.ts b/packages/integrations/src/connectors/github.ts
index a5a2056..22b1be7 100644
--- a/packages/integrations/src/connectors/github.ts
+++ b/packages/integrations/src/connectors/github.ts
@@ -52,12 +52,17 @@ export const githubIngester: OriginIngester = {
return GitHubPullRequestPayloadSchema.parse(raw);
},
- ingest(payload): IngestionResult {
+ ingest(payload, opts?: { deploymentUrl?: string }): IngestionResult {
const { pull_request: pr, repository } = payload;
- // The render URL points to the head commit's deployed preview if available.
- // Conventionally: https://. — caller overrides as needed.
- const renderUrl = pr.html_url;
+ // The render URL should point to a preview deployment (e.g., Vercel or
+ // Netlify auto-deploy), NOT to the GitHub PR page. GitHub.com sets
+ // X-Frame-Options: deny, so pr.html_url cannot be iframed.
+ //
+ // The caller supplies deploymentUrl from a deployment_status webhook or
+ // from the GitHub Deployments API. If unavailable, renderUrl is omitted
+ // and the user can enter a URL manually in the artboard.
+ const renderUrl = opts?.deploymentUrl;
return {
origin: {
@@ -78,7 +83,7 @@ export const githubIngester: OriginIngester = {
},
},
artboardTitle: `PR #${pr.number}: ${pr.title}`,
- renderUrl,
+ ...(renderUrl !== undefined ? { renderUrl } : {}),
};
},
};
diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts
index 7591793..217eb5e 100644
--- a/packages/integrations/src/types.ts
+++ b/packages/integrations/src/types.ts
@@ -14,8 +14,9 @@ export interface IngestionResult {
export interface OriginIngester {
/** Validates and parses the raw webhook payload. Throws ZodError on invalid input. */
parsePayload(raw: unknown): TPayload;
- /** Converts a validated payload into an IngestionResult. */
- ingest(payload: TPayload): IngestionResult;
+ /** Converts a validated payload into an IngestionResult.
+ * @param opts — Connector-specific options (e.g., deploymentUrl for GitHub). */
+ ingest(payload: TPayload, opts?: Record): IngestionResult;
}
// ── Shared helpers ────────────────────────────────────────────────────────────
diff --git a/packages/live-sdk/package.json b/packages/live-sdk/package.json
new file mode 100644
index 0000000..6423ecc
--- /dev/null
+++ b/packages/live-sdk/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@originmain/live",
+ "version": "0.0.1",
+ "private": false,
+ "description": "Originmain live rendering SDK — installs fiber hook for component inspection. Import before React.",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "files": ["src", "README.md"],
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "typescript": "^5.5.0"
+ },
+ "keywords": ["originmain", "react", "devtools", "fiber", "design-engineering"],
+ "license": "MIT"
+}
diff --git a/packages/live-sdk/src/hook.ts b/packages/live-sdk/src/hook.ts
new file mode 100644
index 0000000..e623046
--- /dev/null
+++ b/packages/live-sdk/src/hook.ts
@@ -0,0 +1,178 @@
+// ── Originmain Fiber Hook ────────────────────────────────────────────────────
+// This module installs a React DevTools–compatible global hook BEFORE React
+// evaluates its module body. React checks for __REACT_DEVTOOLS_GLOBAL_HOOK__
+// exactly once at import time; any later installation is too late.
+//
+// The hook only activates when the app is iframed by Originmain (detected by
+// the `om:` prefix in `window.name`, which LiveArtboard.tsx sets on the
+//