24 KiB
Originmain SDK Architecture
From Proxy to SDK — System Design & Implementation Progress
Living document. Updated every time a piece of this architecture is implemented. Supersedes
RENDERING-ARCHITECTURE.md(proxy-era, now deprecated).Last updated: 2026-05-13
Table of Contents
- Why We Moved Away From the Proxy
- Target Architecture
- System Components
- Wire Protocol
- Implementation Progress
- Package Map
- Data Flow — Step by Step
- Local Dev Problem & Tunnel Strategy
- Edge Cases
- What Remains To Be Built
1. Why We Moved Away From the Proxy
The original approach (@originmain/cli) ran a local HTTP reverse proxy that injected a
<script> tag into every HTML response. This failed in practice for four compounding reasons:
| Failure | Root Cause | Fatal? |
|---|---|---|
| Script never executed | React 19 hydrateRoot(document, ...) reconciles the entire <head> and removes any <script> tag not present in React's VDOM |
✅ Yes |
window.name always empty |
Chrome 88+ strips window.name on cross-origin iframe loads (Spectre mitigation) — the SDK used this as its artboard ID source |
✅ Yes |
| Inline script blocked | CSPs without 'unsafe-inline' blocked the injected <script>…</script> content |
✅ Yes |
| Hook installed too late | __REACT_DEVTOOLS_GLOBAL_HOOK__ must exist before React's module body evaluates. Injected scripts run after HTML parses — after React is already loaded |
✅ Yes |
The only reliable fix: the fiber hook must be installed by the app itself, not by an external injector. That means it ships as an npm package the developer imports before React in their app entry point.
2. Target Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ ORIGINMAIN CLOUD (originmain.com) │
│ │
│ ┌─────────────────────────────┐ ┌──────────────────────────────────┐ │
│ │ Canvas (Next.js app) │◄───►│ WebSocket Bridge │ │
│ │ • Artboards / iframes │ │ /api/sdk/[projectId] │ │
│ │ • Inspector / Editor │ │ • auth via SDK token │ │
│ │ • File diff viewer │ │ • routes messages to user │ │
│ └─────────────────────────────┘ └──────────────────────────────────┘ │
│ ▲ │
└────────────────────────────────────────────────────────│───────────────────┘
│ WSS
│ (wss://originmain.com/api/sdk/[projectId])
┌────────────────────────────────────────────────────────│───────────────────┐
│ USER MACHINE (localhost / staging) │ │
│ ▼ │
│ ┌──────────────────────────┐ ┌────────────────────────────────────┐ │
│ │ Next.js dev server │◄───►│ @originmain/dev (SDK) │ │
│ │ (next dev / Vercel) │ │ • client runtime in browser │ │
│ │ │ │ • server runtime in Node │ │
│ │ │ │ • direct React fiber access │ │
│ │ Components.tsx ◄────────┼─────┤ • file-write capability │ │
│ │ │ │ • opens WSS to cloud canvas │ │
│ └──────────────────────────┘ └────────────────────────────────────┘ │
│ │
│ next.config.js — wrapped with @originmain/next plugin │
└─────────────────────────────────────────────────────────────────────────────┘
Key invariant: the fiber hook lives inside the user's app bundle.
No proxy, no injection, no external script — the user's import '@originmain/live'
is what instruments React.
3. System Components
3.1 @originmain/live (client runtime) — packages/live-sdk
The browser-side SDK. A side-effect import that:
- Installs
__REACT_DEVTOOLS_GLOBAL_HOOK__before React evaluates (module-load time) - Resolves the artboard ID from three sources in priority order:
- URL fragment:
location.hashcontains#__om_artboard=<id>← primary, cross-origin safe window.name: starts withom:(same-origin iframes only)- postMessage handshake: sends
{ __om_init_request: true }to parent, awaits reply
- URL fragment:
- Runs the full postMessage protocol (see §4) once the artboard ID is known
- Is a complete no-op outside an Originmain artboard iframe (zero runtime cost in production)
3.2 @originmain/next (build plugin) — packages/next
A withOriginmain(nextConfig) wrapper for next.config.ts that:
- Prepends
@originmain/liveto every client-side webpack entry point - Ensures the fiber hook import runs before any other module in the bundle
- Skips the server-side bundle (fiber hook is browser-only)
- Is idempotent (safe to wrap twice)
- Delegates to any existing
webpackcustomisation in the user's config
3.3 @originmain/dev (full SDK) — packages/dev ❌ NOT BUILT YET
The full SDK intended for local development. Will combine:
- Everything in
@originmain/live(client runtime, fiber hook) - A server runtime that runs inside the Next.js dev server process
- A WebSocket client that connects outbound to the cloud canvas bridge
- File-write capability: receives design panel edits from the canvas and applies them to source files (
.tsx,.ts, CSS modules)
3.4 WebSocket Bridge — packages/app/src/app/api/sdk/[projectId]/ ❌ NOT BUILT YET
A Next.js API route on the cloud canvas that:
- Accepts an inbound WSS connection from
@originmain/dev - Authenticates via a project-scoped SDK token (issued in project settings)
- Routes messages bidirectionally:
- SDK → Canvas:
FIBER_TREE_UPDATE,ELEMENT_STYLES,ROUTES_DISCOVERED, etc. - Canvas → SDK:
PATCH_ELEMENT_STYLE,REQUEST_ELEMENT_STYLES,CAPTURE_SNAPSHOT, etc.
- SDK → Canvas:
- Maintains one WebSocket connection per active project session
3.5 Canvas (Next.js app) — packages/app
Originmain's cloud editor. Current transport: postMessage via iframe.
Future transport: WebSocket via bridge (for @originmain/dev local dev).
Key components:
LiveArtboard.tsx— manages the<iframe>, postMessage listener, message dispatchDesignTab.tsx— design property sections (Frame, Layout, Fill, Typography, etc.)Inspector.tsx— tab container (Design / Props / Code / Diff / Graph)Artboard.tsx— artboard frame, selection overlay, static-page detectionCanvas.tsx— infinite canvas, zoom/pan, artboard layout, onboarding overlay
4. Wire Protocol
Defined in packages/renderer/src/protocol.ts. Both the postMessage transport
(current) and the WebSocket transport (planned) use the same message shapes.
4.1 Canvas → App (Host → Renderer)
| Message | Payload | Purpose |
|---|---|---|
SET_DESIGN_TOKENS |
{ tokens: Record<string, string> } |
Push CSS custom property values |
NAVIGATE |
{ path: string } |
SPA navigation inside the iframe |
SELECT_COMPONENT |
{ nodeId: string } |
Show highlight ring on component |
DESELECT |
— | Remove highlight ring |
REQUEST_ELEMENT_STYLES |
{ nodeId: string } |
Ask for computed CSS properties |
PATCH_ELEMENT_STYLE |
{ nodeId, property, value } |
Apply one inline CSS override |
PATCH_CHILDREN_STYLE |
{ parentNodeId, selector, property, value } |
Patch CSS on all matching children |
REMOVE_ELEMENT |
{ nodeId: string } |
Set display:none on element |
CAPTURE_THUMBNAIL |
— | Capture full-page JPEG via html2canvas |
CAPTURE_SNAPSHOT |
{ nodeId: string } |
Capture selected element PNG |
CANCEL_SNAPSHOT |
— | Abort in-flight snapshot |
4.2 App → Canvas (Renderer → Host)
| Message | Payload | Purpose |
|---|---|---|
READY |
{ rootFontSizePx?: number } |
Hook active, React detected |
FIBER_TREE_UPDATE |
{ root: FiberNode } |
Full serialized component tree |
COMPONENT_SELECTED |
{ nodeId, rect } |
User clicked a component |
COMPONENT_DESELECTED |
— | User clicked empty space |
ELEMENT_STYLES |
{ nodeId, styles, hasDirectText, hasParagraphChildren } |
Computed CSS response |
ROUTES_DISCOVERED |
{ routes: Array<{ path, label }> } |
App's navigation routes |
THUMBNAIL_READY |
{ dataUrl: string | null } |
JPEG data URL or null |
SNAPSHOT_READY |
{ dataUrl: string | null, nodeId: string } |
PNG data URL or null |
ERROR |
{ message: string } |
Hook or serialization error |
4.3 Envelope Format (postMessage)
// Canvas → App
{ source: 'originmain-host', artboardId: string, message: HostMessage }
// App → Canvas
{ source: 'originmain-renderer', artboardId: string, message: RendererMessage }
4.4 Artboard ID Resolution (how the SDK finds its artboard)
The canvas appends the artboard ID to the iframe's src as a URL fragment:
// LiveArtboard.tsx
src={url + '#__om_artboard=' + encodeURIComponent(id)}
The SDK reads it in priority order:
1. location.hash → /__om_artboard=abc123/ (primary, cross-origin safe)
2. window.name → "om:abc123" (same-origin iframes)
3. postMessage handshake (async fallback, 10s timeout)
5. Implementation Progress
✅ Done
| Component | File(s) | Notes |
|---|---|---|
@originmain/live client runtime |
packages/live-sdk/src/hook.ts |
Full rewrite. URL fragment guard. Full protocol parity. html2canvas support. |
@originmain/live build step |
packages/live-sdk/build.mjs |
esbuild → dist/index.js (9.4kb browser ESM bundle, minified). |
@originmain/next build plugin |
packages/next/src/index.ts |
withOriginmain() — webpack entry prepend. Idempotent. |
@originmain/next build step |
packages/next/build.mjs |
esbuild → ESM + CJS; tsc → dist/index.d.ts type declarations. |
Root sdk:build script |
package.json |
pnpm sdk:build builds both SDK packages in order. |
| LiveArtboard postMessage handshake | packages/app/src/components/canvas/LiveArtboard.tsx |
Responds to __om_init_request from SDK |
| LiveArtboard URL fragment injection | packages/app/src/components/canvas/LiveArtboard.tsx |
Appends #__om_artboard=<id> to all iframe src URLs |
| Style refresh after design panel edit | packages/app/src/components/canvas/LiveArtboard.tsx |
120ms debounced REQUEST_ELEMENT_STYLES after queue drains |
| Design panel → history tracking | packages/app/src/components/inspector/DesignTab.tsx |
pushEdit() on every patch() call — feeds Diff tab |
| Design panel → optimistic styles | packages/app/src/components/inspector/DesignTab.tsx |
setComponentStyles() optimistic update — inputs reflect change immediately |
| Canvas onboarding UI | packages/app/src/components/canvas/Canvas.tsx |
Updated from proxy instructions to SDK install instructions |
| Static page banner | packages/app/src/components/canvas/Artboard.tsx |
Now says "add import '@originmain/live'" instead of confusing message |
tsconfig.base.json path alias |
tsconfig.base.json |
@originmain/next added to paths |
🚧 In Progress / Partially Done
| Component | File(s) | Status | Blocker |
|---|---|---|---|
@originmain/live publishing |
packages/live-sdk/package.json |
Build complete. "private": false. Not yet npm publish-ed |
Run pnpm sdk:build && cd packages/live-sdk && npm publish |
@originmain/next publishing |
packages/next/package.json |
Build complete. "private": false. Not yet npm publish-ed |
Same (depends on live being published first) |
| CLI proxy deprecation | packages/cli/ |
Still exists and still works | Can delete once SDK is published and users migrate |
❌ Not Built
| Component | Target File(s) | Priority | Depends On |
|---|---|---|---|
| WebSocket bridge API route | packages/app/src/app/api/sdk/[projectId]/route.ts |
HIGH | — |
@originmain/dev package |
packages/dev/ |
HIGH | WebSocket bridge |
| SDK server runtime (Node.js) | packages/dev/src/server.ts |
HIGH | WebSocket bridge |
| File-write capability | packages/dev/src/file-writer.ts |
HIGH | Server runtime |
| SDK token issuance in canvas | packages/app/src/app/api/sdk/token/route.ts |
HIGH | WebSocket bridge |
| Local dev tunnel (localhost → WSS) | TBD | MEDIUM | @originmain/dev |
IsolationFrame.tsx SDK migration |
packages/app/src/components/canvas/IsolationFrame.tsx |
LOW | @originmain/dev |
6. Package Map
packages/
├── app/ @originmain/app — Cloud canvas (Next.js, Vercel)
├── live-sdk/ @originmain/live — Client SDK: browser fiber hook ✅ DONE
├── next/ @originmain/next — Next.js build plugin ✅ DONE
├── dev/ @originmain/dev — Full SDK (client + server) ❌ NOT BUILT
├── renderer/ @originmain/renderer — Protocol types, message shapes
├── cli/ @originmain/cli — Legacy proxy (deprecated, not deleted yet)
├── diff-engine/ @originmain/diff-engine
├── design-language/ @originmain/design-language
├── agent-bridge/ @originmain/agent-bridge
├── ai-layer/ @originmain/ai-layer
├── platform/ @originmain/platform
├── multiplayer/ @originmain/multiplayer
├── origin-graph/ @originmain/origin-graph
├── ui/ @originmain/ui
└── e2e/ (test suite)
7. Data Flow — Step by Step
7.1 Current Flow (postMessage via iframe)
1. User adds @originmain/live to their Next.js app
2. User wraps next.config.ts with withOriginmain()
3. User deploys to Vercel — @originmain/live is in the bundle
4. Canvas: user pastes Vercel URL → artboard created in DB
5. Canvas: LiveArtboard renders <iframe src="https://app.vercel.app/route#__om_artboard=abc123">
6. Browser: iframe loads app from Vercel
7. App bundle: @originmain/live runs (module-level side effect, before React)
→ installs __REACT_DEVTOOLS_GLOBAL_HOOK__
→ reads location.hash → finds __om_artboard=abc123
→ calls startMainLoop("abc123")
8. React evaluates → sees hook → registers onCommitFiberRoot
9. App renders → React commits
→ onCommitFiberRoot fires
→ SDK serializes fiber tree
→ postMessage({ source: 'originmain-renderer', artboardId: 'abc123',
message: { type: 'FIBER_TREE_UPDATE', root: ... } })
10. Canvas: LiveArtboard.handleMessage receives FIBER_TREE_UPDATE
→ setFiberRoot(artboardId, root) [Zustand]
→ SelectionOverlay renders component hit-test overlay
→ Inspector shows component tree (Graph tab)
11. User clicks a component in the iframe
→ SDK: click handler → getFiberKey(el) → walk fiber.return chain
→ postMessage COMPONENT_SELECTED { nodeId, rect }
12. Canvas: receives COMPONENT_SELECTED
→ selectComponent(nodeId) [Zustand]
→ LiveArtboard sends REQUEST_ELEMENT_STYLES { nodeId }
13. SDK: respondWithStyles() → getComputedStyle(el) → all CSS properties
→ postMessage ELEMENT_STYLES { nodeId, styles, hasDirectText, hasParagraphChildren }
14. Canvas: setComponentStyles(styles) [Zustand]
→ DesignTab re-renders with actual computed values
→ Frame/Layout/Fill/Typography sections show live data
15. User edits width in Frame section: 200px → 250px
[DesignTab.patch('width', '250px')]
→ patchStyleEdit(artboardId, nodeId, 'width', '250px') → styleEditQueue
→ setComponentStyles({ ...styles, width: '250px' }) → optimistic panel update
→ pushEdit({ key: 'width', before: '200px', after: '250px', ... }) → history
16. LiveArtboard: styleEditQueue effect drains
→ postMessage PATCH_ELEMENT_STYLE { nodeId, property: 'width', value: '250px' }
→ starts 120ms debounce timer
17. SDK: patchElementStyle(nodeId, 'width', '250px')
→ el.style.setProperty('width', '250px')
→ iframe visually updates ✅
18. 120ms later: LiveArtboard sends REQUEST_ELEMENT_STYLES
→ SDK: getComputedStyle now returns '250px' (inline style overrides)
→ ELEMENT_STYLES response → panel shows accurate computed value
19. Diff tab: pendingChanges = [{ key: 'width', before: '200px', after: '250px' }]
→ "Export diff →" button active
→ Code tab shows generated file patch
→ "Send to Agent" sends diff to AI for code application
7.2 Target Flow (WebSocket — not built yet)
Same as above, except steps 5–9 use WebSocket instead of postMessage:
5. Canvas: LiveArtboard connects to wss://originmain.com/api/sdk/abc123
6. @originmain/dev (server runtime): connects to same WSS URL with SDK token
7. Bridge authenticates token, pairs the two connections
8. All messages flow through the bridge instead of postMessage
→ This unlocks local dev (no Vercel deploy required)
→ This enables file-write (SDK has Node.js fs access)
8. Local Dev Problem & Tunnel Strategy
The Problem
The cloud canvas (https://originmain.com) is served over HTTPS. Browsers
enforce mixed content policy: an HTTPS page cannot iframe http://localhost:3000.
This means the current SDK approach (iframe + postMessage) only works for deployed apps
(Vercel, Netlify, etc.) — not for local development.
Current Workaround (v1)
Users must deploy to Vercel (or any HTTPS host) to use the canvas.
Vercel's preview deployments are free and instant (vercel deploy --prod is optional).
Planned Solution (@originmain/dev + WebSocket bridge)
When the WebSocket bridge exists:
@originmain/devruns a Node.js server alongsidenext dev- It connects outbound from the user's machine via WSS to the canvas bridge
- Outbound WSS from localhost → cloud is always allowed (no mixed content issue)
- The canvas receives fiber data via the bridge, not via the iframe
- The iframe can be replaced with a screenshot stream or kept pointing to a tunnel URL
Tunnel Options (if iframe is still needed for visual rendering)
| Option | Effort | Notes |
|---|---|---|
| Vercel deploy (v1 recommendation) | Lowest | Works today. No extra tooling. |
cloudflared tunnel (user-run) |
Low | npx cloudflared tunnel --url http://localhost:3000 |
| Originmain-managed tunnel | High | Requires Originmain to run tunnel infrastructure |
9. Edge Cases
9.1 React DevTools Coexistence
React DevTools extension installs its own __REACT_DEVTOOLS_GLOBAL_HOOK__ first.
The SDK detects the existing hook and wraps onCommitFiberRoot — both DevTools and
Originmain receive fiber commits. Neither overwrites the other.
9.2 HMR (Hot Module Replacement)
When webpack/turbopack pushes a hot update, React re-renders affected components.
onCommitFiberRoot fires again → SDK sends a new FIBER_TREE_UPDATE → canvas updates.
The artboard ID is stable across hot updates (URL fragment persists).
9.3 SPA Navigation
SPA routers change the URL without a full page reload. React re-renders.
onCommitFiberRoot fires → new FIBER_TREE_UPDATE. Route discovery re-runs via
a popstate listener. The SDK sends updated ROUTES_DISCOVERED.
9.4 React 19 Server Components
Server Components don't produce fiber nodes in the client tree — they're rendered
to RSC payload and hydrated as static DOM. The SDK correctly skips these
(they have no type function). Only client components appear in the tree.
9.5 Non-React Pages (Static HTML)
If the app serves a page with no React (e.g., a static landing page),
onCommitFiberRoot never fires. The READY message is still sent.
After 8 seconds with no FIBER_TREE_UPDATE, LiveArtboard calls
onStaticPageDetected() → the "No React detected" banner appears.
9.6 Multiple Artboards, Same App
Multiple artboards can iframe the same app at different routes. Each gets a
unique #__om_artboard=<id> in its URL. The SDK is a module singleton — it runs
once per page load — and binds to the single artboard ID from the URL fragment.
Each iframe is a separate browsing context with its own SDK instance.
9.7 CSP (script-src 'self')
Since the SDK ships in the app's own bundle (not injected externally), CSP
script-src 'self' does not block it. The SDK is part of the same origin as the app.
10. What Remains To Be Built
Priority order for next implementation sprint:
Priority 1 — Publish packages (unblocks users) ✅ BUILD COMPLETE
- Add esbuild build step for
@originmain/live→packages/live-sdk/build.mjs - Add esbuild + tsc build step for
@originmain/next→packages/next/build.mjs - Update exports in both
package.jsonfiles to point todist/ - Add
@originmain/liveas workspace dependency of@originmain/next - Root
pnpm sdk:buildscript for one-command build npm publish@originmain/live— ready to publish, command:cd packages/live-sdk && npm publishnpm publish@originmain/next— depends on live being published first
Priority 2 — WebSocket Bridge (unblocks local dev)
packages/app/src/app/api/sdk/[projectId]/route.ts- Accept WSS upgrade from
@originmain/dev - Authenticate via
Authorization: Bearer <sdk-token>header - Pair with the canvas session for the same projectId
- Bidirectional message routing
- Handle reconnect / heartbeat
- Accept WSS upgrade from
Priority 3 — @originmain/dev package (file-write, local dev)
packages/dev/src/client.ts— re-export@originmain/livepackages/dev/src/server.ts— Node.js WebSocket client, connects to bridgepackages/dev/src/file-writer.ts— applies design panel patches to source files- Parse
PATCH_ELEMENT_STYLEmessages → locate source file viacallSite - Rewrite Tailwind classes / CSS modules / inline styles
- Parse
packages/next/src/index.ts— extendwithOriginmain()to also start the server runtime
Priority 4 — SDK token issuance
- Project settings UI: "Generate SDK token" button
packages/app/src/app/api/sdk/token/route.ts— create/rotate tokens- Store tokens in Supabase (scoped to project, revocable)
Priority 5 — IsolationFrame migration
- Migrate
IsolationFrame.tsxfrom proxy URL to SDK-based approach- Currently still requires the CLI proxy to serve
/__om_isolation__/*
- Currently still requires the CLI proxy to serve
Document owner: engineering Last updated: 2026-05-13 Next review: when WebSocket bridge is implemented