improved a lot of things

This commit is contained in:
SinachPat
2026-04-29 12:09:18 +01:00
parent dca0aa5768
commit 45be1b935d
22 changed files with 1381 additions and 47 deletions
@@ -352,12 +352,18 @@ function EmptyArtboardContent({
setEditing(false);
};
const hintStyle: React.CSSProperties = {
margin: 0, fontSize: 10, color: '#A1A1AA', textAlign: 'center',
lineHeight: 1.55, fontFamily: "'JetBrains Mono', ui-monospace, monospace",
letterSpacing: '-0.01em',
};
return (
<div
style={{
width, height, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
background: '#F8F8FA', gap: 12, padding: 20,
background: '#F8F8FA', gap: 10, padding: 20,
}}
>
{/* Artboard name */}
@@ -366,20 +372,23 @@ function EmptyArtboardContent({
</div>
{editing ? (
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ width: '100%', maxWidth: 280, display: 'flex', flexDirection: 'column', gap: 8 }}>
<input
autoFocus
type="url"
value={urlValue}
onChange={(e) => setUrlValue(e.target.value)}
placeholder="http://localhost:3000"
placeholder="http://localhost:4170"
onKeyDown={(e) => { if (e.key === 'Enter') void save(); if (e.key === 'Escape') setEditing(false); e.stopPropagation(); }}
style={{
padding: '7px 10px', borderRadius: 6, border: '1.5px solid #0066FF',
fontSize: 11, fontFamily: 'inherit', outline: 'none', width: '100%',
boxSizing: 'border-box' as const,
fontSize: 11, fontFamily: 'inherit', width: '100%',
boxSizing: 'border-box' as const, color: '#0A0A0A', background: '#fff',
}}
/>
<p style={hintStyle}>
Use the CLI proxy URL or a preview deployment URL with @originmain/live installed
</p>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => void save()} disabled={saving}
@@ -396,6 +405,7 @@ function EmptyArtboardContent({
style={{
padding: '6px 10px', borderRadius: 5, border: '1px solid #E4E4E7',
background: '#fff', fontSize: 11, cursor: 'pointer', fontFamily: 'inherit',
color: '#3F3F46',
}}
>
Cancel
@@ -415,8 +425,11 @@ function EmptyArtboardContent({
<path d="M6 8l2.5 2.5L12 6" stroke="#0066FF" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<p style={{ margin: 0, fontSize: 11, color: '#71717A', textAlign: 'center', lineHeight: 1.5, maxWidth: 180 }}>
Connect a running app URL to enable live rendering
<p style={{ margin: 0, fontSize: 11, color: '#52525B', textAlign: 'center', lineHeight: 1.5, maxWidth: 220 }}>
Connect your running app to enable live component inspection
</p>
<p style={hintStyle}>
npx @originmain/cli dev --target :3000
</p>
<button
onClick={() => setEditing(true)}
@@ -426,7 +439,7 @@ function EmptyArtboardContent({
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Connect app
Enter proxy URL
</button>
</>
)}
@@ -2,7 +2,6 @@
import { useEffect, useRef, useCallback } from 'react';
import {
buildFiberHookScript,
createHostEnvelope,
isRendererEnvelope,
} from '@originmain/renderer';
@@ -12,7 +11,9 @@ import type { FiberNode, RendererMessage } from '@originmain/renderer';
export interface LiveArtboardProps {
id: string;
/** URL of the connected application route to render */
/** URL of the connected application route to render.
* For live dev: the CLI proxy URL (e.g., http://localhost:4170)
* For previews: the Vercel/Netlify preview URL (with @originmain/live SDK) */
src: string;
width?: number;
height?: number;
@@ -58,8 +59,9 @@ export function LiveArtboard({
const msg: RendererMessage = event.data.message;
switch (msg.type) {
case 'READY':
// Inject fiber hook after the renderer signals it's ready
injectFiberHook(iframeRef.current, id);
// The fiber hook is already installed — either by the CLI proxy
// (injected into the HTML response) or by @originmain/live SDK
// (imported before React in the user's app). No injection needed.
if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
onReady?.();
break;
@@ -85,6 +87,10 @@ export function LiveArtboard({
return (
<iframe
ref={iframeRef}
// The name attribute carries the artboard ID to the fiber hook.
// The hook reads window.name to tag postMessage envelopes.
// Format: "om:<artboardId>"
name={`om:${id}`}
src={src}
title={`artboard-${id}`}
// Security: allow-scripts required to run React; allow-same-origin required
@@ -101,17 +107,3 @@ export function LiveArtboard({
/>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function injectFiberHook(iframe: HTMLIFrameElement | null, artboardId: string) {
if (!iframe?.contentDocument) return;
try {
const script = iframe.contentDocument.createElement('script');
script.textContent = buildFiberHookScript(artboardId);
iframe.contentDocument.head.appendChild(script);
} catch {
// Cross-origin or sandboxing prevents injection — renderer must include the
// hook script itself in that case.
}
}
File diff suppressed because one or more lines are too long
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@originmain/cli",
"version": "0.0.1",
"private": false,
"description": "Originmain CLI — reverse proxy for live React component inspection.",
"type": "module",
"bin": {
"originmain": "./dist/cli.js"
},
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json"
},
"dependencies": {
"@originmain/renderer": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.5.0"
},
"engines": {
"node": ">=22"
},
"license": "MIT"
}
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env node
// ── Originmain CLI ───────────────────────────────────────────────────────────
// Usage:
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
//
// Starts a reverse proxy that enables live React component inspection
// in Originmain artboards. See RENDERING-ARCHITECTURE.md for details.
import { parseArgs } from 'node:util';
import { startProxy } from './proxy.js';
const DEFAULT_PORT = 4170;
function printUsage(): void {
console.log(`
\x1b[36m\x1b[1mOriginmain CLI\x1b[0m
Usage:
originmain dev --target <url> [--port <number>]
Options:
--target, -t Target dev server URL (required)
Example: http://localhost:3000
--port, -p Proxy listen port (default: ${DEFAULT_PORT})
--help, -h Show this help
Example:
npx @originmain/cli dev --target http://localhost:3000
`);
}
function main(): void {
// Parse arguments
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
target: { type: 'string', short: 't' },
port: { type: 'string', short: 'p' },
help: { type: 'boolean', short: 'h' },
},
allowPositionals: true,
strict: false,
});
const command = positionals[0];
if (values.help || !command) {
printUsage();
process.exit(command ? 0 : 1);
}
if (command !== 'dev') {
console.error(` Unknown command: ${command}\n Run "originmain --help" for usage.`);
process.exit(1);
}
// Validate --target
const target = values.target;
if (!target || typeof target !== 'string') {
console.error(' Error: --target is required.\n Example: originmain dev --target http://localhost:3000');
process.exit(1);
}
// Validate target URL
let targetUrl: URL;
try {
targetUrl = new URL(target);
} catch {
console.error(` Error: Invalid target URL: ${target}`);
process.exit(1);
}
if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') {
console.error(` Error: Target must be http:// or https:// (got ${targetUrl.protocol})`);
process.exit(1);
}
// Parse port
const port = values.port ? parseInt(values.port as string, 10) : DEFAULT_PORT;
if (Number.isNaN(port) || port < 1 || port > 65535) {
console.error(` Error: Invalid port: ${values.port}`);
process.exit(1);
}
// Start proxy
const proxy = startProxy({ target, port });
// Graceful shutdown
function shutdown(): void {
console.log('\n Shutting down proxy...');
proxy.close();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main();
+4
View File
@@ -0,0 +1,4 @@
// Programmatic API — allows using the proxy from other Node.js code
export { startProxy } from './proxy.js';
export type { ProxyOptions } from './proxy.js';
export { injectFiberHook } from './inject.js';
+45
View File
@@ -0,0 +1,45 @@
// ── HTML Injection ───────────────────────────────────────────────────────────
// Injects the Originmain fiber hook <script> into an HTML response body.
// The script must appear BEFORE any other scripts so that
// __REACT_DEVTOOLS_GLOBAL_HOOK__ is installed before React evaluates.
import { buildProxyFiberHookScript } from '@originmain/renderer';
/** The fiber hook script wrapped in a <script> tag, generated once at startup. */
let cachedScriptTag: string | undefined;
function getScriptTag(): string {
if (cachedScriptTag === undefined) {
cachedScriptTag = `<script data-originmain-fiber-hook>${buildProxyFiberHookScript()}</script>`;
}
return cachedScriptTag;
}
/**
* Inject the fiber hook script into an HTML string.
*
* Injection strategy (in order of preference):
* 1. After `<head...>` — standard position for early scripts
* 2. After `<html...>` — fallback if <head> is missing
* 3. Prepend to document — final fallback
*/
export function injectFiberHook(html: string): string {
const tag = getScriptTag();
// Try after <head>
const headMatch = /<head[^>]*>/i.exec(html);
if (headMatch) {
const insertAt = headMatch.index + headMatch[0].length;
return html.slice(0, insertAt) + tag + html.slice(insertAt);
}
// Try after <html>
const htmlMatch = /<html[^>]*>/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;
}
+187
View File
@@ -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<string, string> = {
'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<string, string | string[]> = {};
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<string, string | string[]> = {};
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();
},
};
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"declaration": true,
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+10 -5
View File
@@ -52,12 +52,17 @@ export const githubIngester: OriginIngester<GitHubPullRequestPayload> = {
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://<pr-number>.<preview-domain> — 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<GitHubPullRequestPayload> = {
},
},
artboardTitle: `PR #${pr.number}: ${pr.title}`,
renderUrl,
...(renderUrl !== undefined ? { renderUrl } : {}),
};
},
};
+3 -2
View File
@@ -14,8 +14,9 @@ export interface IngestionResult {
export interface OriginIngester<TPayload> {
/** 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<string, unknown>): IngestionResult;
}
// ── Shared helpers ────────────────────────────────────────────────────────────
+19
View File
@@ -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"
}
+178
View File
@@ -0,0 +1,178 @@
// ── Originmain Fiber Hook ────────────────────────────────────────────────────
// This module installs a React DevToolscompatible 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
// <iframe> element). Outside an Originmain iframe the module is a no-op.
//
// This file is intentionally self-contained — no imports from other
// @originmain/* packages — because it ships as a public npm package.
const RENDERER_SOURCE = 'originmain-renderer';
const NAME_PREFIX = 'om:';
// ── Guard: only run inside an Originmain iframe ──────────────────────────────
function isOriginmainIframe(): boolean {
try {
return window.parent !== window
&& typeof window.name === 'string'
&& window.name.startsWith(NAME_PREFIX);
} catch {
// Accessing window.parent can throw in certain sandboxed contexts.
return false;
}
}
if (isOriginmainIframe()) {
installFiberHook();
}
// ── Core ─────────────────────────────────────────────────────────────────────
function installFiberHook(): void {
const artboardId = window.name.slice(NAME_PREFIX.length);
function post(msg: Record<string, unknown>): void {
try {
window.parent.postMessage(
{ source: RENDERER_SOURCE, artboardId, message: msg },
'*',
);
} catch {
// Parent frame unreachable — silently ignore.
}
}
// Install or wrap the global hook. If React DevTools is already present,
// we wrap its onCommitFiberRoot so both receive commits.
type Hook = {
renderers: Map<unknown, unknown>;
supportsFiber: boolean;
_isDisabled: boolean;
onCommitFiberRoot?: (...args: unknown[]) => void;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const g = globalThis as any;
let hook: Hook = g.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (!hook) {
hook = { renderers: new Map(), supportsFiber: true, _isDisabled: false };
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
}
const originalOnCommit = hook.onCommitFiberRoot;
hook.onCommitFiberRoot = function onCommitFiberRoot(...args: unknown[]) {
// Delegate to the previous handler (React DevTools) first.
if (typeof originalOnCommit === 'function') {
try { originalOnCommit.apply(this, args); }
catch { /* don't break DevTools */ }
}
try {
// args[1] is the FiberRoot — { current: Fiber }
const root = args[1] as { current: FiberLike } | undefined;
if (!root?.current) return;
const tree = serializeFiber(root.current);
post({ type: 'FIBER_TREE_UPDATE', root: tree });
} catch (err) {
post({ type: 'ERROR', message: String(err) });
}
};
// Signal readiness.
post({ type: 'READY' });
}
// ── Fiber Serialization ──────────────────────────────────────────────────────
interface FiberLike {
type: unknown;
index: number;
child: FiberLike | null;
sibling: FiberLike | null;
stateNode: unknown;
memoizedProps: Record<string, unknown> | null;
}
interface SerializedNode {
id: string;
name: string;
props: Record<string, string | number | boolean | null>;
children: SerializedNode[];
domRect?: { x: number; y: number; width: number; height: number };
}
function serializeFiber(fiber: FiberLike | null): SerializedNode | null {
if (!fiber) return null;
const name = getDisplayName(fiber);
if (!name) return serializeFiber(fiber.child);
const rect = getDomRect(fiber);
const node: SerializedNode = {
id: String(fiber.index || Math.random()),
name,
props: serializeProps(fiber.memoizedProps),
children: [],
};
if (rect) {
node.domRect = rect;
}
let child = fiber.child;
while (child) {
const serialized = serializeFiber(child);
if (serialized) node.children.push(serialized);
child = child.sibling;
}
return node;
}
function getDisplayName(fiber: FiberLike): string | null {
const type = fiber.type;
if (!type) return null;
if (typeof type === 'string') return type;
if (typeof type === 'function') {
return (type as { displayName?: string; name?: string }).displayName
?? (type as { name?: string }).name
?? null;
}
if (typeof type === 'object' && type !== null && '$$typeof' in type) {
return (type as { displayName?: string; name?: string }).displayName
?? (type as { name?: string }).name
?? null;
}
return null;
}
function getDomRect(fiber: FiberLike): { x: number; y: number; width: number; height: number } | null {
try {
const dom = fiber.stateNode;
if (dom && typeof dom === 'object' && 'getBoundingClientRect' in dom) {
const r = (dom as Element).getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}
} catch { /* no DOM node */ }
return null;
}
function serializeProps(
props: Record<string, unknown> | null,
): Record<string, string | number | boolean | null> {
if (!props || typeof props !== 'object') return {};
const out: Record<string, string | number | boolean | null> = {};
for (const key in props) {
if (key === 'children') continue;
const val = props[key];
const t = typeof val;
if (t === 'string' || t === 'number' || t === 'boolean' || val === null) {
out[key] = val as string | number | boolean | null;
}
}
return out;
}
+14
View File
@@ -0,0 +1,14 @@
// @originmain/live — Originmain fiber hook for live component inspection.
//
// Usage:
// import '@originmain/live'; // MUST be before any React import
// import React from 'react';
// ...
//
// This is a side-effect-only import. It installs __REACT_DEVTOOLS_GLOBAL_HOOK__
// before React evaluates, enabling Originmain to inspect the component tree,
// read props, and generate diffs. The hook only activates when the app is
// rendered inside an Originmain artboard iframe — otherwise it is a complete
// no-op with zero runtime cost.
import './hook.js';
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src"],
"exclude": ["node_modules"]
}
+116
View File
@@ -110,5 +110,121 @@ export function buildFiberHookScript(artboardId: string): string {
})(${JSON.stringify(artboardId)});`;
}
// ── Proxy-compatible fiber hook script ────────────────────────────────────────
// Unlike buildFiberHookScript (which bakes in an artboard ID), this version
// reads the artboard ID from `window.name` at runtime. The iframe element sets
// `name="om:<artboardId>"` and this script extracts the ID.
//
// This script is injected by the CLI proxy (`@originmain/cli`) into every HTML
// response, and is also used by the `@originmain/live` SDK. It is fully
// self-contained — no imports, no dependencies.
export function buildProxyFiberHookScript(): string {
return `(function() {
// Only activate inside an Originmain iframe
if (window.parent === window) return;
var NAME_PREFIX = 'om:';
var artboardId = '';
try {
if (typeof window.name === 'string' && window.name.indexOf(NAME_PREFIX) === 0) {
artboardId = window.name.slice(NAME_PREFIX.length);
}
} catch (e) { /* window.name access denied — not our iframe */ }
if (!artboardId) return;
var SOURCE = ${JSON.stringify(RENDERER_SOURCE)};
function post(msg) {
try { window.parent.postMessage({ source: SOURCE, artboardId: artboardId, message: msg }, '*'); }
catch (e) { /* parent unreachable */ }
}
// Install the React DevTools global hook BEFORE React loads.
// If React DevTools extension is already present, wrap its handler.
var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (!hook) {
hook = { renderers: new Map(), supportsFiber: true, _isDisabled: false };
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
}
var originalOnCommit = hook.onCommitFiberRoot;
hook.onCommitFiberRoot = function(rendererId, root, priorityLevel, didError) {
if (typeof originalOnCommit === 'function') {
try { originalOnCommit.call(this, rendererId, root, priorityLevel, didError); }
catch (e) { /* don't break DevTools */ }
}
try {
var fiberRoot = root.current;
var tree = serializeFiber(fiberRoot);
post({ type: 'FIBER_TREE_UPDATE', root: tree });
} catch (err) {
post({ type: 'ERROR', message: String(err) });
}
};
function serializeFiber(fiber) {
if (!fiber) return null;
var name = getDisplayName(fiber);
if (!name) return serializeFiber(fiber.child) || null;
var rect = getDomRect(fiber);
var node = {
id: String(fiber.index || Math.random()),
name: name,
props: serializeProps(fiber.memoizedProps),
children: [],
};
if (rect) node.domRect = rect;
var child = fiber.child;
while (child) {
var serialized = serializeFiber(child);
if (serialized) node.children.push(serialized);
child = child.sibling;
}
return node;
}
function getDisplayName(fiber) {
var type = fiber.type;
if (!type) return null;
if (typeof type === 'string') return type;
if (typeof type === 'function') return type.displayName || type.name || null;
if (type.$$typeof) return type.displayName || type.name || null;
return null;
}
function getDomRect(fiber) {
try {
var dom = fiber.stateNode;
if (dom && typeof dom.getBoundingClientRect === 'function') {
var r = dom.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}
} catch (e) { /* no DOM node */ }
return null;
}
function serializeProps(props) {
if (!props || typeof props !== 'object') return {};
var out = {};
for (var key in props) {
if (key === 'children') continue;
var val = props[key];
var t = typeof val;
if (t === 'string' || t === 'number' || t === 'boolean' || val === null) {
out[key] = val;
}
}
return out;
}
// Signal that the fiber hook is installed and the iframe is ready.
post({ type: 'READY' });
})();`;
}
// Re-export types for convenience
export type { FiberNode, DOMRectLike };
+1 -1
View File
@@ -16,7 +16,7 @@ export type {
RendererEnvelope,
} from './protocol.js';
export { buildFiberHookScript } from './fiber-hook.js';
export { buildFiberHookScript, buildProxyFiberHookScript } from './fiber-hook.js';
export {
createRendererHostConfig,