From 45be1b935d0ce4957b8beec1b88f489b585994ad Mon Sep 17 00:00:00 2001 From: SinachPat Date: Wed, 29 Apr 2026 12:09:18 +0100 Subject: [PATCH] improved a lot of things --- HANDOFF.md | 93 +++- RENDERING-ARCHITECTURE.md | 512 ++++++++++++++++++ .../app/src/components/canvas/Artboard.tsx | 29 +- .../src/components/canvas/LiveArtboard.tsx | 28 +- packages/app/tsconfig.tsbuildinfo | 2 +- packages/cli/package.json | 28 + packages/cli/src/cli.ts | 100 ++++ packages/cli/src/index.ts | 4 + packages/cli/src/inject.ts | 45 ++ packages/cli/src/proxy.ts | 187 +++++++ packages/cli/tsconfig.build.json | 11 + packages/cli/tsconfig.json | 9 + .../integrations/src/connectors/github.ts | 15 +- packages/integrations/src/types.ts | 5 +- packages/live-sdk/package.json | 19 + packages/live-sdk/src/hook.ts | 178 ++++++ packages/live-sdk/src/index.ts | 14 + packages/live-sdk/tsconfig.json | 8 + packages/renderer/src/fiber-hook.ts | 116 ++++ packages/renderer/src/index.ts | 2 +- pnpm-lock.yaml | 19 + tsconfig.base.json | 4 +- 22 files changed, 1381 insertions(+), 47 deletions(-) create mode 100644 RENDERING-ARCHITECTURE.md create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/cli.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/inject.ts create mode 100644 packages/cli/src/proxy.ts create mode 100644 packages/cli/tsconfig.build.json create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/live-sdk/package.json create mode 100644 packages/live-sdk/src/hook.ts create mode 100644 packages/live-sdk/src/index.ts create mode 100644 packages/live-sdk/tsconfig.json 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 +//