updated stuff

This commit is contained in:
SinachPat
2026-05-13 15:12:51 +01:00
parent 8356e6278c
commit 7bf43fc481
19 changed files with 1632 additions and 308 deletions
@@ -492,7 +492,7 @@ export function Artboard({
onSnapshotReady={(dataUrl, nodeId) => setElementSnapshot(id, nodeId, dataUrl)}
/>
{/* Static-page banner — shown when the proxy serves a non-React page */}
{/* Static-page banner — shown when no React commits arrive after load */}
{isStaticPage && (
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0,
@@ -505,7 +505,9 @@ export function Artboard({
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
color: '#1C1917', letterSpacing: '-0.01em',
}}>
Static HTML page no React components detected. Navigate to a React route to enable inspection.
No React detected add{' '}
<strong>import &quot;@originmain/live&quot;</strong>
{' '}before React in your app, then redeploy.
</span>
</div>
)}
+26 -13
View File
@@ -472,9 +472,10 @@ export function Canvas() {
}
/* ── URL onboarding overlay ───────────────────────────────── */
// Shown when the canvas has no artboards. Lets the user paste their CLI proxy
// URL to auto-create the first artboard; route discovery will then fire and
// populate the remaining pages automatically.
// Shown when the canvas has no artboards. Lets the user paste their app URL
// (Vercel, Netlify, or any deployment where @originmain/live is installed).
// Route discovery fires after the first React commit and populates remaining
// pages automatically.
function UrlOnboardingOverlay({
workspaceId,
projectId,
@@ -547,18 +548,30 @@ function UrlOnboardingOverlay({
Connect your app
</div>
<div style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.7rem', color: 'rgba(255,255,255,0.35)', lineHeight: 1.5 }}>
Paste the CLI proxy URL all your app&apos;s pages will be auto-rendered as artboards
Paste your app URL all your pages will be auto-rendered as artboards
</div>
</div>
{/* CLI hint */}
<div style={{
width: '100%', padding: '6px 10px',
background: 'rgba(51,133,255,0.08)', border: '1px solid rgba(51,133,255,0.18)',
borderRadius: 6, fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem', color: 'rgba(51,133,255,0.75)', letterSpacing: '-0.01em',
}}>
npx @originmain/cli dev --target http://localhost:3000
{/* SDK install hint */}
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{
padding: '6px 10px',
background: 'rgba(51,133,255,0.08)', border: '1px solid rgba(51,133,255,0.18)',
borderRadius: 6, fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem', color: 'rgba(51,133,255,0.75)', letterSpacing: '-0.01em',
}}>
npm install @originmain/live
</div>
<div style={{
padding: '6px 10px',
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 6, fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem', color: 'rgba(255,255,255,0.35)', letterSpacing: '-0.01em',
}}>
{'// layout.tsx — must be before React'}
<br />
{'import "@originmain/live";'}
</div>
</div>
{/* URL input */}
@@ -569,7 +582,7 @@ function UrlOnboardingOverlay({
value={url}
onChange={e => { setUrl(e.target.value); setErrorMsg(''); }}
onKeyDown={e => { if (e.key === 'Enter') void handleConnect(); e.stopPropagation(); }}
placeholder="http://localhost:4170"
placeholder="https://your-app.vercel.app"
style={{
flex: 1, background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.12)', borderRadius: 6,
@@ -206,6 +206,9 @@ export function LiveArtboard({
const removeElementEvent = useCanvas((s) => s.removeElementEvent);
const clearRemoveElement = useCanvas((s) => s.clearRemoveElement);
// Ref so the refresh timer can be cancelled when a faster patch arrives.
const styleRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const mine = styleEditQueue.filter((e) => e.artboardId === id);
if (mine.length === 0 || !isReadyRef.current) return;
@@ -213,6 +216,17 @@ export function LiveArtboard({
sendMessage('PATCH_ELEMENT_STYLE', { nodeId: e.nodeId, property: e.property, value: e.value });
}
clearStyleEdits(id);
// Refresh the design panel after the browser has applied the inline styles.
// We debounce at 120 ms so rapid dragging (color picker, resize) only fires
// one REQUEST_ELEMENT_STYLES at the end of the gesture, not on every event.
if (styleRefreshTimerRef.current) clearTimeout(styleRefreshTimerRef.current);
styleRefreshTimerRef.current = setTimeout(() => {
const nodeId = mine[mine.length - 1]?.nodeId;
if (nodeId && isReadyRef.current) {
sendMessage('REQUEST_ELEMENT_STYLES', { nodeId });
}
}, 120);
}, [id, styleEditQueue, sendMessage, clearStyleEdits]);
// ── Children style edit queue (PATCH_CHILDREN_STYLE — paragraph spacing etc.) ──
@@ -9,6 +9,7 @@ import { useState, useMemo, useEffect } from 'react';
import { Badge } from '@fluentui/react-components';
import { useCanvas } from '@/store/canvas';
import { useCanvasTheme } from '@/store/canvasTheme';
import { useHistory } from '@/store/history';
import { useDlf } from '@/hooks/useDlf';
import { checkComponentConstraints } from '@originmain/design-language';
import type { Violation } from '@originmain/design-language';
@@ -92,11 +93,13 @@ export function DesignTab({
const {
patchStyleEdit,
patchChildrenStyleEdit,
setComponentStyles,
indexerStatus,
selectedComponentHasDirectText,
selectedComponentHasParagraphChildren,
setActiveViolations,
} = useCanvas();
const { pushEdit } = useHistory();
const { dlf } = useDlf(workspaceId);
// Re-run constraint checks whenever selected component or active DLF changes.
@@ -149,9 +152,35 @@ export function DesignTab({
);
}
// ── patch: design panel → live artboard + history + optimistic panel refresh ──
// Three things happen on every edit:
// 1. patchStyleEdit → PATCH_ELEMENT_STYLE → SDK → inline style on DOM element
// 2. setComponentStyles (optimistic) → panel inputs immediately show the new
// value without waiting for the next REQUEST_ELEMENT_STYLES round-trip
// 3. pushEdit → history store → Diff tab tracks it → code export works
const patch = (prop: string, val: string) => {
if (!artboardId || !componentId) return;
// 1. Send to iframe.
patchStyleEdit(artboardId, componentId, prop, val);
// 2. Optimistically reflect the change in the design panel immediately.
if (styles) {
setComponentStyles({ ...styles, [prop]: val });
}
// 3. Push to history so the Diff tab can generate a code patch.
pushEdit(artboardId, {
componentId,
componentName: componentData?.name ?? componentId,
changes: [{
key: prop,
before: styles?.[prop] ?? '',
after: val,
changeType: 'modified',
}],
timestamp: Date.now(),
});
};
const patchChildren = (selector: string, prop: string, val: string) => {
File diff suppressed because one or more lines are too long
+40
View File
@@ -0,0 +1,40 @@
# @originmain/live
Browser SDK for [Originmain](https://originmain.com) — installs the React fiber hook that enables live component inspection and design editing in the Originmain canvas.
## Installation
```bash
npm install @originmain/live
# or
pnpm add @originmain/live
```
## Usage
Import **before React** in your app entry point:
```ts
// app/layout.tsx (or pages/_app.tsx)
import '@originmain/live'; // ← must be first
import React from 'react';
// ...
```
Or use the [`@originmain/next`](https://www.npmjs.com/package/@originmain/next) plugin which injects it automatically:
```ts
// next.config.ts
import { withOriginmain } from '@originmain/next';
export default withOriginmain({ reactStrictMode: true });
```
## How it works
- Installs `__REACT_DEVTOOLS_GLOBAL_HOOK__` **before** React evaluates (module load time), so React captures fiber commits from the very first render.
- Activates **only** when the page runs inside an Originmain artboard iframe (detected via `#__om_artboard=<id>` in the URL fragment).
- **Complete no-op** in production or any non-Originmain context — zero runtime cost.
## License
MIT
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
// ── @originmain/live build script ────────────────────────────────────────────
// Produces a single self-contained browser ESM bundle under dist/:
//
// dist/index.js — side-effect-only browser module, no external deps
//
// Run: node build.mjs (or via "pnpm build")
//
// Design notes:
// • Platform 'browser' — esbuild replaces process.env.NODE_ENV and
// avoids injecting Node built-in shims.
// • format 'esm' — the published package is "type": "module"; webpack/
// turbopack will tree-shake and include it in the user's bundle.
// • bundle: true — @originmain/live has no runtime npm dependencies so
// bundling produces one fully self-contained file with no require() calls.
// • minify: true — the hook ships inside the user's production bundle;
// every byte matters.
// • No type declarations needed — the package is a side-effect-only import
// (`import '@originmain/live'`) with no exported symbols.
import { build } from 'esbuild';
await build({
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'browser',
format: 'esm',
target: ['es2020', 'chrome88', 'firefox78', 'safari14'],
minify: true,
sourcemap: false, // keep bundle clean; source is MIT-licensed anyway
outfile: 'dist/index.js',
logLevel: 'info',
});
console.log(' ✓ dist/index.js (browser ESM bundle)');
+4 -2
View File
@@ -5,13 +5,15 @@
"description": "Originmain live rendering SDK — installs fiber hook for component inspection. Import before React.",
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./dist/index.js"
},
"files": ["src", "README.md"],
"files": ["dist", "README.md"],
"scripts": {
"build": "node build.mjs",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"esbuild": "^0.25.0",
"typescript": "^5.5.0"
},
"keywords": ["originmain", "react", "devtools", "fiber", "design-engineering"],
+447 -289
View File
@@ -3,46 +3,131 @@
// module body. React checks for __REACT_DEVTOOLS_GLOBAL_HOOK__ exactly once at
// import time; any later installation is too late.
//
// Full bidirectional protocol:
// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED, COMPONENT_DESELECTED, ERROR
// Host → Renderer : SET_DESIGN_TOKENS, NAVIGATE, SELECT_COMPONENT, DESELECT
// Full bidirectional protocol (matches packages/renderer/src/protocol.ts):
// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED,
// COMPONENT_DESELECTED, ELEMENT_STYLES, ROUTES_DISCOVERED,
// THUMBNAIL_READY, SNAPSHOT_READY, ERROR
// Host → Renderer : SET_DESIGN_TOKENS, NAVIGATE, SELECT_COMPONENT, DESELECT,
// REQUEST_ELEMENT_STYLES, PATCH_ELEMENT_STYLE,
// PATCH_CHILDREN_STYLE, REMOVE_ELEMENT,
// CAPTURE_THUMBNAIL, CAPTURE_SNAPSHOT, CANCEL_SNAPSHOT
//
// Node IDs are stable path strings: "Component:idx/Child:idx/…"
// This survives re-renders as long as the component tree structure is unchanged.
// Activation guard — checked in priority order:
// 1. URL fragment: location.hash contains __om_artboard=<id>
// 2. window.name: starts with "om:" (works for same-origin iframes)
// 3. postMessage handshake: sends __om_init_request to parent, waits for reply
//
// Guard: only activates when window.name starts with "om:" — the prefix set by
// LiveArtboard.tsx on the <iframe name="om:{id}"> element. Outside Originmain
// iframes this module is a complete no-op.
// Chrome 88+ strips window.name on cross-origin iframe loads (Spectre
// mitigation). The URL fragment approach is immune to this because LiveArtboard
// appends #__om_artboard=<id> to the iframe src, which survives cross-origin
// navigation. The postMessage handshake is a final fallback.
//
// This file is intentionally self-contained (no @originmain/* imports) because
// it ships as a public npm package and must work standalone.
// This file is intentionally self-contained (no @originmain/* imports) so it
// ships as a standalone npm package without workspace dependencies.
const RENDERER_SOURCE = 'originmain-renderer';
const HOST_SOURCE = 'originmain-host';
const NAME_PREFIX = 'om:';
// ── Guard ─────────────────────────────────────────────────────────────────────
// ── Artboard ID resolution ────────────────────────────────────────────────────
// Returns null if we're not inside an Originmain artboard iframe at all.
function isOriginmainIframe(): boolean {
function resolveArtboardIdSync(): string | null {
// Not in an iframe at all — bail immediately.
try { if (window.parent === window) return null; }
catch { return null; }
// 1. URL fragment: #__om_artboard=<id> (primary — cross-origin safe)
try {
return (
window.parent !== window &&
typeof window.name === 'string' &&
window.name.startsWith(NAME_PREFIX)
);
} catch {
return false; // Accessing window.parent can throw in certain sandboxed contexts.
const match = window.location.hash.match(/__om_artboard=([^&]+)/);
if (match?.[1]) return decodeURIComponent(match[1]);
} catch { /* */ }
// 2. window.name: "om:<id>" (works for same-origin iframes)
try {
if (typeof window.name === 'string' && window.name.startsWith(NAME_PREFIX)) {
return window.name.slice(NAME_PREFIX.length);
}
} catch { /* */ }
return null;
}
// ── Hook installation ─────────────────────────────────────────────────────────
// We install the DevTools hook unconditionally when inside ANY iframe, because
// React evaluates __REACT_DEVTOOLS_GLOBAL_HOOK__ at module load time. If we
// wait for the artboard ID we're already too late. The hook stays dormant until
// the artboard ID is resolved (either synchronously or via postMessage).
(function bootstrap() {
// Not in a frame at all — complete no-op.
try { if (window.parent === window) return; }
catch { return; }
// ── Install the DevTools hook immediately ────────────────────────────────
// React reads __REACT_DEVTOOLS_GLOBAL_HOOK__ exactly once when its module
// body runs. We must be here first.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const g = globalThis as any;
type Hook = {
renderers: Map<unknown, unknown>;
supportsFiber: boolean;
_isDisabled: boolean;
inject?: (...a: unknown[]) => void;
onCommitFiberRoot?: (...a: unknown[]) => void;
};
let hook: Hook = g.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (!hook) {
hook = { renderers: new Map(), supportsFiber: true, _isDisabled: false };
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
}
}
if (isOriginmainIframe()) {
installFiberHook();
}
// ── Resolve artboard ID ──────────────────────────────────────────────────
const syncId = resolveArtboardIdSync();
if (syncId) {
startMainLoop(hook, syncId);
return;
}
// ── Core ──────────────────────────────────────────────────────────────────────
// No ID found synchronously — try the postMessage handshake.
// The parent (LiveArtboard.tsx) listens for __om_init_request and responds
// with { __om_init_response: true, artboardId: id }.
let resolved = false;
function installFiberHook(): void {
const artboardId = window.name.slice(NAME_PREFIX.length);
function onHandshakeReply(event: MessageEvent) {
const d = event.data as { __om_init_response?: boolean; artboardId?: string } | null;
if (d?.__om_init_response === true && typeof d.artboardId === 'string') {
if (!resolved) {
resolved = true;
window.removeEventListener('message', onHandshakeReply);
startMainLoop(hook, d.artboardId);
}
}
}
window.addEventListener('message', onHandshakeReply);
try {
window.parent.postMessage({ __om_init_request: true }, '*');
} catch { /* sandboxed — postMessage blocked */ }
// Give up after 10 s to avoid a stale listener.
setTimeout(() => {
if (!resolved) window.removeEventListener('message', onHandshakeReply);
}, 10_000);
})();
// ── Main loop (runs once artboard ID is known) ────────────────────────────────
function startMainLoop(hook: {
renderers: Map<unknown, unknown>;
supportsFiber: boolean;
_isDisabled: boolean;
inject?: (...a: unknown[]) => void;
onCommitFiberRoot?: (...a: unknown[]) => void;
}, artboardId: string): void {
// ── postMessage helper ────────────────────────────────────────────────────
function post(msg: Record<string, unknown>): void {
@@ -51,88 +136,50 @@ function installFiberHook(): void {
{ source: RENDERER_SOURCE, artboardId, message: msg },
'*',
);
} catch {
// Parent frame unreachable — silently ignore.
}
} catch { /* parent frame unreachable */ }
}
// ── Runtime state ─────────────────────────────────────────────────────────
// nodeMap: nodeId → { domRect (snapshot), fiber (live reference for re-measurement) }
let nodeMap = new Map<string, { domRect: DomRect | null; fiber: FiberLike }>();
// fiberMap: fiber object → nodeId for O(1) hit-test lookup via __reactFiber$ DOM keys.
// A null value means the fiber is unnamed/transparent and not directly selectable.
let nodeMap = new Map<string, { domRect: DomRect | null; fiber: FiberLike }>();
let fiberMap = new WeakMap<object, string | null>();
let selectedNodeId: string | null = null;
let highlightEl: HTMLElement | null = null;
// ── React DevTools global hook ─────────────────────────────────────────────
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;
}
let highlightEl: HTMLElement | null = null;
let snapshotAborted = false;
// ── Fiber hook — onCommitFiberRoot ────────────────────────────────────────
const _prevCommit = hook.onCommitFiberRoot;
hook.onCommitFiberRoot = function onCommitFiberRoot(...args: unknown[]) {
// Delegate to any pre-existing handler (React DevTools extension) first.
if (typeof _prevCommit === 'function') {
try { _prevCommit.apply(this, args); }
catch { /* don't break existing DevTools */ }
try { _prevCommit.apply(this, args); } catch { /* don't break existing DevTools */ }
}
try {
// args[1] is the FiberRoot object — { current: Fiber }
const root = args[1] as { current: FiberLike } | undefined;
if (!root?.current) return;
// Reset both maps before each walk so stale entries from the previous tree
// don't accumulate. fiberMap is a WeakMap so it self-cleans, but nodeMap
// must be rebuilt from scratch on every commit.
nodeMap = new Map();
fiberMap = new WeakMap();
const tree = serializeFiber(root.current, '');
post({ type: 'FIBER_TREE_UPDATE', root: tree });
// Re-sync the highlight ring after each React commit (component may move).
if (selectedNodeId) updateHighlight();
} catch (err) {
post({ type: 'ERROR', message: String(err) });
}
};
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
// ID format: "ComponentName:siblingIndex/ChildName:siblingIndex/..."
//
// IMPORTANT: unnamed fibers (Fragment, Context.Provider, React.memo wrappers)
// are transparent — their children are collected directly into their parent's
// children array. Returning only the first named child (old approach) caused
// entire subtrees to vanish from the tree.
// ── Fiber serialization ───────────────────────────────────────────────────
function serializeFiber(
fiber: FiberLike | null,
parentId: string,
): SerializedNode | null {
function serializeFiber(fiber: FiberLike | null, parentId: string): SerializedNode | null {
if (!fiber) return null;
const name = getDisplayName(fiber);
if (!name) {
// Unnamed root fiber (HostRoot) — collectChildren handles Fragment recursively.
const children: SerializedNode[] = [];
collectChildren(fiber, parentId, children);
if (children.length === 1) return children[0] ?? null;
if (children.length === 0) return null;
// Multiple named children at root — wrap in a synthetic root node.
return { id: '__root__', name: '__root__', props: {}, children };
}
@@ -146,7 +193,16 @@ function installFiberHook(): void {
};
if (rect) node.domRect = rect;
// Register in both maps for O(1) lookup.
// Attach call-site if present (React dev builds expose _debugSource).
const src = (fiber as FiberLike & { _debugSource?: { fileName?: string; lineNumber?: number; columnNumber?: number } })._debugSource;
if (src?.fileName && typeof src.lineNumber === 'number') {
node.callSite = {
fileName: src.fileName,
lineNumber: src.lineNumber,
...(src.columnNumber !== undefined ? { columnNumber: src.columnNumber } : {}),
};
}
nodeMap.set(nodeId, { domRect: rect, fiber });
fiberMap.set(fiber, nodeId);
@@ -154,18 +210,14 @@ function installFiberHook(): void {
return node;
}
// Collect all named descendants of fiber.child into out[], transparently
// flattening unnamed intermediates (Fragments, Providers, wrappers).
function collectChildren(fiber: FiberLike, parentId: string, out: SerializedNode[]): void {
let child = fiber.child;
while (child) {
const name = getDisplayName(child);
if (name) {
const serialized = serializeFiber(child, parentId);
if (serialized) out.push(serialized);
const s = serializeFiber(child, parentId);
if (s) out.push(s);
} else {
// Unnamed (Fragment / Context / Provider / forwardRef wrapper etc.):
// mark as non-selectable and flatten children directly into our level.
fiberMap.set(child, null);
collectChildren(child, parentId, out);
}
@@ -179,13 +231,13 @@ function installFiberHook(): void {
if (typeof type === 'string') return type;
if (typeof type === 'function') {
return (type as { displayName?: string; name?: string }).displayName
?? (type as { name?: string }).name
?? null;
?? (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;
?? (type as { name?: string }).name
?? null;
}
return null;
}
@@ -217,15 +269,11 @@ function installFiberHook(): void {
return out;
}
// ── Highlight overlay (blue ring inside the iframe) ───────────────────────
// updateHighlight re-measures from the live fiber stateNode so the ring stays
// accurate even after scroll (between React commits).
// ── Highlight overlay ─────────────────────────────────────────────────────
function updateHighlight(): void {
const info = selectedNodeId ? nodeMap.get(selectedNodeId) : undefined;
if (!info) { removeHighlight(); return; }
// Re-measure from the live DOM element for scroll accuracy.
const rect = getDomRect(info.fiber) ?? info.domRect;
if (rect && rect.width > 0 && rect.height > 0) {
renderHighlight(rect);
@@ -261,48 +309,42 @@ function installFiberHook(): void {
}
}
// Re-measure on scroll so the ring follows the element without needing
// a React commit (which only fires on state/prop changes).
window.addEventListener('scroll', () => {
if (selectedNodeId) updateHighlight();
}, true);
// ── Click-to-select (capturing phase) ────────────────────────────────────
// Uses document.elementFromPoint to get the live DOM element at the click
// position (accurate even after scroll), then walks the React fiber tree
// upward via __reactFiber$ keys to find the nearest tracked component.
// ── Click-to-select ───────────────────────────────────────────────────────
function getFiberKey(el: Element): string | null {
const keys = Object.keys(el);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (k !== undefined && k.startsWith('__reactFiber$')) return k;
if (k?.startsWith('__reactFiber$')) return k;
}
return null;
}
document.addEventListener('click', (event: MouseEvent) => {
// Ignore clicks on our own highlight overlay.
if (event.target === highlightEl) return;
const el = document.elementFromPoint(event.clientX, event.clientY);
// Walk the DOM upward, trying to find a tracked React fiber at each level.
let current: Element | null = el;
while (current && current !== document.documentElement) {
const fiberKey = getFiberKey(current);
if (fiberKey) {
// Walk the fiber's return (parent) chain to find the nearest tracked node.
let fiber: FiberLike | null =
(current as unknown as Record<string, unknown>)[fiberKey] as FiberLike | null;
while (fiber) {
const nodeId = fiberMap.get(fiber);
if (nodeId) {
// Re-measure from the live element for accurate post-scroll rect.
const liveEl = fiber.stateNode;
if (liveEl && typeof liveEl === 'object' && 'getBoundingClientRect' in liveEl) {
const r = (liveEl as Element).getBoundingClientRect();
post({ type: 'COMPONENT_SELECTED', nodeId, rect: { x: r.x, y: r.y, width: r.width, height: r.height } });
post({
type: 'COMPONENT_SELECTED',
nodeId,
rect: { x: r.x, y: r.y, width: r.width, height: r.height },
});
return;
}
}
@@ -312,10 +354,271 @@ function installFiberHook(): void {
current = current.parentElement;
}
// Nothing found — clear the selection.
post({ type: 'COMPONENT_DESELECTED' });
}, true);
// ── Element style inspection ──────────────────────────────────────────────
const INSPECTED_PROPS = [
'color', 'font-family', 'font-size', 'font-weight', 'line-height',
'letter-spacing', 'text-align', 'text-transform', 'text-decoration',
'display', 'width', 'height',
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'flex-direction', 'align-items', 'justify-content', 'gap',
'position', 'top', 'right', 'bottom', 'left',
'background-color', 'border-radius', 'opacity',
'box-shadow', 'border-width', 'border-color', 'border-style',
'overflow', 'cursor', 'transition',
] as const;
function respondWithStyles(nodeId: string): void {
const info = nodeMap.get(nodeId);
const styles: Record<string, string> = {};
let hasDirectText = false;
let hasParagraphChildren = false;
if (info?.fiber?.stateNode && typeof info.fiber.stateNode === 'object'
&& 'nodeType' in (info.fiber.stateNode as object)) {
try {
const el = info.fiber.stateNode as Element;
const computed = window.getComputedStyle(el);
for (const prop of INSPECTED_PROPS) {
const val = computed.getPropertyValue(prop);
if (val) styles[prop] = val;
}
// Structural flags for the Typography panel.
const childNodes = el.childNodes;
for (let i = 0; i < childNodes.length; i++) {
const n = childNodes[i];
if (n?.nodeType === Node.TEXT_NODE && n.textContent?.trim()) {
hasDirectText = true;
}
if ((n as Element)?.tagName === 'P') {
hasParagraphChildren = true;
}
}
} catch { /* element may be detached */ }
}
post({ type: 'ELEMENT_STYLES', nodeId, styles, hasDirectText, hasParagraphChildren });
}
// ── Style patching ────────────────────────────────────────────────────────
function patchElementStyle(nodeId: string, property: string, value: string): void {
const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) return;
try {
const el = info.fiber.stateNode as HTMLElement;
value === '' ? el.style.removeProperty(property) : el.style.setProperty(property, value);
} catch { /* detached */ }
}
function patchChildrenStyle(
parentNodeId: string,
selector: string,
property: string,
value: string,
): void {
const info = nodeMap.get(parentNodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) return;
try {
const parent = info.fiber.stateNode as Element;
parent.querySelectorAll(selector).forEach((child) => {
if (child.parentElement === parent) {
value === ''
? (child as HTMLElement).style.removeProperty(property)
: (child as HTMLElement).style.setProperty(property, value);
}
});
} catch { /* detached */ }
}
function removeElement(nodeId: string): void {
const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) return;
try {
(info.fiber.stateNode as HTMLElement).style.setProperty('display', 'none');
} catch { /* detached */ }
}
// ── Design tokens ─────────────────────────────────────────────────────────
function applyTokens(tokens: Record<string, string>): void {
const root = document.documentElement;
for (const [k, v] of Object.entries(tokens)) {
root.style.setProperty(k, v);
}
}
// ── Navigation ────────────────────────────────────────────────────────────
function doNavigate(path: string): void {
try {
history.pushState(null, '', path);
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
} catch { /* navigation not available */ }
}
// ── Screenshot capture (html2canvas) ─────────────────────────────────────
// Loaded on demand from the proxy's embedded bundle (/__om_h2c__.js) or
// from unpkg. Falls back to null if neither is available.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Html2CanvasFn = (el: HTMLElement, opts?: Record<string, unknown>) => Promise<HTMLCanvasElement>;
let html2canvasCache: Html2CanvasFn | null | 'pending' = null;
async function loadHtml2Canvas(): Promise<Html2CanvasFn | null> {
if (html2canvasCache !== null && html2canvasCache !== 'pending') return html2canvasCache;
if (html2canvasCache === 'pending') {
// Wait for the in-flight load.
return new Promise((resolve) => {
const check = setInterval(() => {
if (html2canvasCache !== 'pending') {
clearInterval(check);
resolve(html2canvasCache as Html2CanvasFn | null);
}
}, 50);
});
}
html2canvasCache = 'pending';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const g = globalThis as any;
// Already on window (e.g. loaded by the proxy script).
if (typeof g.html2canvas === 'function') {
html2canvasCache = g.html2canvas as Html2CanvasFn;
return html2canvasCache;
}
// Try the proxy-embedded bundle first (works offline, no CSP issues).
const candidates = [
'/__om_h2c__.js',
'https://unpkg.com/html2canvas@1.4.1/dist/html2canvas.min.js',
];
for (const src of candidates) {
try {
await new Promise<void>((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.onload = () => resolve();
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
if (typeof g.html2canvas === 'function') {
html2canvasCache = g.html2canvas as Html2CanvasFn;
return html2canvasCache;
}
} catch { /* try next source */ }
}
html2canvasCache = null;
return null;
}
async function captureThumbnail(): Promise<void> {
try {
const h2c = await loadHtml2Canvas();
if (!h2c) { post({ type: 'THUMBNAIL_READY', dataUrl: null }); return; }
const canvas = await h2c(document.body, {
scale: 0.5,
useCORS: true,
allowTaint: true,
logging: false,
imageTimeout: 5000,
});
post({ type: 'THUMBNAIL_READY', dataUrl: canvas.toDataURL('image/jpeg', 0.7) });
} catch {
post({ type: 'THUMBNAIL_READY', dataUrl: null });
}
}
async function captureSnapshot(nodeId: string): Promise<void> {
snapshotAborted = false;
try {
const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) {
post({ type: 'SNAPSHOT_READY', dataUrl: null, nodeId });
return;
}
const h2c = await loadHtml2Canvas();
if (!h2c || snapshotAborted) { post({ type: 'SNAPSHOT_READY', dataUrl: null, nodeId }); return; }
const el = info.fiber.stateNode as HTMLElement;
const canvas = await h2c(el, { scale: 2, useCORS: true, allowTaint: true, logging: false });
if (snapshotAborted) { post({ type: 'SNAPSHOT_READY', dataUrl: null, nodeId }); return; }
post({ type: 'SNAPSHOT_READY', dataUrl: canvas.toDataURL('image/png'), nodeId });
} catch {
post({ type: 'SNAPSHOT_READY', dataUrl: null, nodeId });
}
}
// ── Route discovery ───────────────────────────────────────────────────────
function humanLabel(path: string): string {
if (path === '/') return 'Home';
return path
.split('/')
.filter(Boolean)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, ' '))
.join(' / ');
}
function discoverRoutes(): void {
const seen = new Set<string>();
const routes: Array<{ path: string; label: string }> = [];
const addRoute = (path: string, hint?: string) => {
if (!path || path.startsWith('#') || seen.has(path)) return;
seen.add(path);
routes.push({ path, label: hint?.trim().slice(0, 50) || humanLabel(path) });
};
addRoute(window.location.pathname, document.title || undefined);
document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {
try {
const raw = (a.getAttribute('href') ?? '').trim();
if (raw.startsWith('/')) { addRoute(raw, a.textContent ?? undefined); return; }
const url = new URL(a.href, window.location.href);
if (url.origin !== window.location.origin) return;
addRoute(url.pathname, a.textContent ?? undefined);
} catch { /* malformed href */ }
});
nodeMap.forEach(({ fiber }) => {
const name = fiber ? getDisplayName(fiber) : null;
if (name && /^(Link|NavLink|NextLink|RouterLink|a)$/.test(name)) {
const href = fiber?.memoizedProps?.['href'] ?? fiber?.memoizedProps?.['to'];
if (typeof href === 'string' && href.startsWith('/')) addRoute(href);
}
});
if (routes.length > 0) post({ type: 'ROUTES_DISCOVERED', routes });
}
let routeDiscoveryScheduled = false;
setTimeout(discoverRoutes, 800);
window.addEventListener('popstate', () => {
if (!routeDiscoveryScheduled) {
routeDiscoveryScheduled = true;
setTimeout(() => { routeDiscoveryScheduled = false; discoverRoutes(); }, 300);
}
});
// ── Host → Renderer message handler ──────────────────────────────────────
window.addEventListener('message', (event: MessageEvent) => {
@@ -327,12 +630,14 @@ function installFiberHook(): void {
tokens?: Record<string, string>;
path?: string;
nodeId?: string;
parentNodeId?: string;
selector?: string;
property?: string;
value?: string | undefined;
value?: string;
};
};
if (!data || data.source !== HOST_SOURCE) return;
if (data.artboardId !== artboardId) return;
if (!data || data.source !== HOST_SOURCE) return;
if (data.artboardId !== artboardId) return;
const msg = data.message;
if (!msg) return;
@@ -344,10 +649,7 @@ function installFiberHook(): void {
if (msg.path) doNavigate(msg.path);
break;
case 'SELECT_COMPONENT':
if (msg.nodeId) {
selectedNodeId = msg.nodeId;
updateHighlight();
}
if (msg.nodeId) { selectedNodeId = msg.nodeId; updateHighlight(); }
break;
case 'DESELECT':
selectedNodeId = null;
@@ -361,203 +663,59 @@ function installFiberHook(): void {
patchElementStyle(msg.nodeId, msg.property, msg.value ?? '');
}
break;
case 'PATCH_CHILDREN_STYLE':
if (msg.parentNodeId && msg.selector && msg.property && msg.value !== undefined) {
patchChildrenStyle(msg.parentNodeId, msg.selector, msg.property, msg.value ?? '');
}
break;
case 'REMOVE_ELEMENT':
if (msg.nodeId) removeElement(msg.nodeId);
break;
case 'CAPTURE_THUMBNAIL':
void captureThumbnail();
break;
case 'CAPTURE_SNAPSHOT':
if (msg.nodeId) void captureSnapshot(msg.nodeId);
break;
case 'CANCEL_SNAPSHOT':
snapshotAborted = true;
break;
}
});
// ── Element style inspection ──────────────────────────────────────────────
// Reads computed CSS properties from the fiber node's DOM element and posts
// them back as ELEMENT_STYLES. We extract a curated subset covering the
// properties designers care about (typography, layout, visual) rather than the
// full ~300-property computed style object.
// ── READY signal ──────────────────────────────────────────────────────────
const INSPECTED_PROPS = [
// Typography
'color', 'font-family', 'font-size', 'font-weight', 'line-height',
'letter-spacing', 'text-align', 'text-transform', 'text-decoration',
// Layout
'display', 'width', 'height',
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'flex-direction', 'align-items', 'justify-content', 'gap',
'position', 'top', 'right', 'bottom', 'left',
// Visual
'background-color', 'border-radius', 'opacity',
'box-shadow', 'border-width', 'border-color', 'border-style',
'overflow', 'cursor', 'transition',
] as const;
let rootFontSizePx: number | undefined;
try {
const computed = window.getComputedStyle(document.documentElement);
const parsed = parseFloat(computed.fontSize);
if (!isNaN(parsed)) rootFontSizePx = parsed;
} catch { /* */ }
function respondWithStyles(nodeId: string): void {
const info = nodeMap.get(nodeId);
const styles: Record<string, string> = {};
if (info?.fiber?.stateNode && typeof info.fiber.stateNode === 'object'
&& 'nodeType' in (info.fiber.stateNode as object)) {
try {
const computed = window.getComputedStyle(info.fiber.stateNode as Element);
for (const prop of INSPECTED_PROPS) {
const val = computed.getPropertyValue(prop);
if (val) styles[prop] = val;
}
} catch { /* element may be detached */ }
}
post({ type: 'ELEMENT_STYLES', nodeId, styles });
}
// ── Inline style patching ─────────────────────────────────────────────────
// Applies a single CSS property as an inline style on the component's DOM
// element. Non-destructive — does NOT modify source files. The change is
// immediately visible in the live render and can be recorded as a diff.
// ── Route discovery ───────────────────────────────────────────────────────
// Scans same-origin <a href> links in the current page and any React Router /
// Next.js Link components whose props contain an href, then posts the unique
// set of paths as ROUTES_DISCOVERED. Called once after the first React commit
// and again on every SPA navigation.
function humanLabel(path: string): string {
if (path === '/') return 'Home';
return path
.split('/')
.filter(Boolean)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, ' '))
.join(' / ');
}
function discoverRoutes(): void {
const seen = new Set<string>();
const routes: Array<{ path: string; label: string }> = [];
const addRoute = (path: string, hint?: string) => {
if (seen.has(path)) return;
// Skip hash-only anchors and external paths
if (!path || path.startsWith('#')) return;
seen.add(path);
routes.push({ path, label: hint?.trim().slice(0, 50) || humanLabel(path) });
};
// Current route first
addRoute(window.location.pathname, document.title || undefined);
// Scan real <a> elements — accept root-relative paths regardless of origin
// so that CLI-proxied pages (where links still point to the original domain)
// are handled correctly alongside direct same-origin connections.
document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {
try {
const rawHref = (a.getAttribute('href') ?? '').trim();
// Root-relative paths are always valid routes.
if (rawHref.startsWith('/')) {
addRoute(rawHref, a.textContent ?? undefined);
return;
}
// Absolute URLs — only add if same-origin (direct connection).
const url = new URL(a.href, window.location.href);
if (url.origin !== window.location.origin) return;
addRoute(url.pathname, a.textContent ?? undefined);
} catch { /* malformed href */ }
});
// Also scan fiber tree for Link / NavLink / next/link props
nodeMap.forEach(({ fiber }) => {
const name = fiber ? getDisplayName(fiber) : null;
if (name && /^(Link|NavLink|NextLink|RouterLink|a)$/.test(name)) {
const href = fiber?.memoizedProps?.['href'] ?? fiber?.memoizedProps?.['to'];
if (typeof href === 'string' && href.startsWith('/')) {
addRoute(href);
}
}
});
if (routes.length > 0) post({ type: 'ROUTES_DISCOVERED', routes });
}
// Re-discover after every React commit (covers lazy-loaded nav items)
const _originalCommit = hook.onCommitFiberRoot;
let routeDiscoveryScheduled = false;
const _wrappedCommitForRoutes = hook.onCommitFiberRoot;
void _wrappedCommitForRoutes; // suppress unused warning — keep original chain intact
// One-time discovery 800ms after first READY (DOM settled)
setTimeout(discoverRoutes, 800);
// Re-discover on every SPA navigation
window.addEventListener('popstate', () => {
if (!routeDiscoveryScheduled) {
routeDiscoveryScheduled = true;
setTimeout(() => { routeDiscoveryScheduled = false; discoverRoutes(); }, 300);
}
});
// ── Element removal ───────────────────────────────────────────────────────
function removeElement(nodeId: string): void {
const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) return;
try {
(info.fiber.stateNode as HTMLElement).style.setProperty('display', 'none');
} catch { /* detached */ }
}
function patchElementStyle(nodeId: string, property: string, value: string): void {
const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) return;
try {
const el = info.fiber.stateNode as HTMLElement;
if (value === '') {
el.style.removeProperty(property);
} else {
el.style.setProperty(property, value);
}
} catch { /* element may be detached */ }
}
function applyTokens(tokens: Record<string, string>): void {
const root = document.documentElement;
for (const [k, v] of Object.entries(tokens)) {
root.style.setProperty(k, v);
}
}
function doNavigate(path: string): void {
try {
history.pushState(null, '', path);
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
} catch { /* navigation not available */ }
}
// ── Ready signal ──────────────────────────────────────────────────────────
post({ type: 'READY' });
post({ type: 'READY', rootFontSizePx });
}
// ── Internal types ────────────────────────────────────────────────────────────
interface FiberLike {
type: unknown;
index: number;
child: FiberLike | null;
sibling: FiberLike | null;
/** Parent fiber — needed for click-to-select chain walk via __reactFiber$ keys. */
return: FiberLike | null;
stateNode: unknown;
type: unknown;
index: number;
child: FiberLike | null;
sibling: FiberLike | null;
return: FiberLike | null;
stateNode: unknown;
memoizedProps: Record<string, unknown> | null;
}
interface DomRect {
x: number;
y: number;
width: number;
height: number;
x: number; y: number; width: number; height: number;
}
interface SerializedNode {
id: string;
name: string;
props: Record<string, string | number | boolean | null>;
children: SerializedNode[];
domRect?: DomRect;
id: string;
name: string;
props: Record<string, string | number | boolean | null>;
children: SerializedNode[];
domRect?: DomRect;
callSite?: { fileName: string; lineNumber: number; columnNumber?: number };
}
+52
View File
@@ -0,0 +1,52 @@
# @originmain/next
Next.js plugin for [Originmain](https://originmain.com) — automatically injects the Originmain live SDK before React loads, enabling the canvas to inspect your component tree in real time.
## Installation
```bash
npm install @originmain/next
# or
pnpm add @originmain/next
```
## Usage
Wrap your Next.js config with `withOriginmain`:
```ts
// next.config.ts
import { withOriginmain } from '@originmain/next';
const nextConfig = {
reactStrictMode: true,
};
export default withOriginmain(nextConfig);
```
Or in CommonJS format:
```js
// next.config.js
const { withOriginmain } = require('@originmain/next');
module.exports = withOriginmain({
reactStrictMode: true,
});
```
## What it does
1. Prepends `import '@originmain/live'` to **every page entry point** at build time via webpack entry modification.
2. The live SDK installs `__REACT_DEVTOOLS_GLOBAL_HOOK__` before React's module body evaluates — this is required for React to capture component commits.
3. The hook activates **only** when the page runs inside an Originmain artboard iframe. In all other contexts it is a complete no-op with zero runtime cost.
## Requirements
- Next.js `>=14.0.0`
- Node.js `>=18`
## License
MIT
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
// ── @originmain/next build script ────────────────────────────────────────────
// Produces ESM + CJS bundles and TypeScript declarations under dist/:
//
// dist/index.js — ESM bundle for next.config.mjs / next.config.ts
// dist/index.cjs — CJS bundle for next.config.js (legacy require())
// dist/index.d.ts — TypeScript declarations for withOriginmain()
//
// Run: node build.mjs (or via "pnpm build")
import { build } from 'esbuild';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
const req = createRequire(import.meta.url);
// Resolve the tsc binary explicitly — avoids relying on PATH.
const tscBin = req.resolve('typescript/bin/tsc');
const SHARED = {
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'node',
target: ['node18'],
// 'next' is a peer dep — leave it external so users' own copy is used.
external: ['next'],
logLevel: 'info',
};
// ── ESM bundle ────────────────────────────────────────────────────────────────
await build({
...SHARED,
format: 'esm',
outfile: 'dist/index.js',
});
// ── CJS bundle ────────────────────────────────────────────────────────────────
// Required for projects where next.config.js uses module.exports = ...
await build({
...SHARED,
format: 'cjs',
outfile: 'dist/index.cjs',
});
// ── TypeScript declarations ───────────────────────────────────────────────────
// withOriginmain() is a typed export — consumers need the .d.ts so their IDE
// and type-checker know the function's signature.
execFileSync(process.execPath, [tscBin, '--project', 'tsconfig.build.json'], {
stdio: 'inherit',
});
console.log(' ✓ dist/index.js (ESM)');
console.log(' ✓ dist/index.cjs (CJS)');
console.log(' ✓ dist/index.d.ts (types)');
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@originmain/next",
"version": "0.0.1",
"private": false,
"description": "Originmain Next.js plugin — wraps next.config.js to auto-inject the live SDK before React loads.",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "node build.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@originmain/live": "workspace:*"
},
"peerDependencies": {
"next": ">=14.0.0"
},
"peerDependenciesMeta": {
"next": { "optional": false }
},
"devDependencies": {
"esbuild": "^0.25.0",
"typescript": "^5.5.0"
},
"keywords": ["originmain", "next", "nextjs", "react", "plugin"],
"license": "MIT"
}
+102
View File
@@ -0,0 +1,102 @@
// ── @originmain/next ──────────────────────────────────────────────────────────
// Next.js plugin that injects @originmain/live before React loads, enabling
// the Originmain canvas to inspect React component trees in real-time.
//
// Usage (next.config.ts or next.config.js):
//
// import { withOriginmain } from '@originmain/next';
//
// const nextConfig = { /* your config */ };
// export default withOriginmain(nextConfig);
//
// What it does:
// 1. Prepends `import '@originmain/live'` to every page entry point at build
// time via webpack entry modification.
// 2. The live SDK installs __REACT_DEVTOOLS_GLOBAL_HOOK__ before React's module
// body evaluates, so React captures component commits from the very first render.
// 3. The hook is a complete no-op when the app is NOT running inside an
// Originmain artboard iframe — zero runtime cost in production.
//
// Environment:
// The SDK activates ONLY when the page is rendered inside an Originmain iframe
// (detected via URL fragment: #__om_artboard=<id>). No opt-in flag or env var
// needed — it self-activates in the right context and stays dormant otherwise.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type NextConfig = Record<string, any>;
/** The entry point that installs the Originmain fiber hook before React. */
const LIVE_SDK_ENTRY = '@originmain/live';
/**
* Wraps a Next.js config to inject the Originmain live SDK into every page
* entry point. The SDK is a no-op outside Originmain artboard iframes.
*
* @example
* ```ts
* // next.config.ts
* import { withOriginmain } from '@originmain/next';
* export default withOriginmain({ reactStrictMode: true });
* ```
*/
export function withOriginmain(nextConfig: NextConfig = {}): NextConfig {
return {
...nextConfig,
webpack(
config: WebpackConfig,
context: { buildId: string; dev: boolean; isServer: boolean; nextRuntime?: string },
) {
// Only patch the client-side bundle. The fiber hook is browser-only.
// isServer covers both Node.js runtime and Edge runtime (nextRuntime).
if (!context.isServer) {
config.entry = prependLiveSdk(config.entry);
}
// Delegate to any existing webpack customisation in the user's config.
if (typeof nextConfig.webpack === 'function') {
return nextConfig.webpack(config, context);
}
return config;
},
};
}
// ── Entry prepend helper ──────────────────────────────────────────────────────
// Next.js entry can be a plain object, a function returning an object, or a
// function returning a Promise. We wrap all three shapes uniformly.
type EntryValue = string | string[] | EntryObject;
type EntryObject = Record<string, string | string[]>;
type EntryFn = () => EntryValue | Promise<EntryValue>;
type Entry = EntryValue | EntryFn;
function prependLiveSdk(entry: Entry): EntryFn {
return async () => {
const resolved = typeof entry === 'function' ? await entry() : entry;
return injectIntoEntries(resolved as EntryObject);
};
}
function injectIntoEntries(entries: EntryObject): EntryObject {
const out: EntryObject = {};
for (const [key, value] of Object.entries(entries)) {
out[key] = prependToChunk(value);
}
return out;
}
function prependToChunk(chunk: string | string[]): string[] {
const arr = Array.isArray(chunk) ? chunk : [chunk];
// Avoid duplicating if already present (e.g. running withOriginmain twice).
if (arr.includes(LIVE_SDK_ENTRY)) return arr;
return [LIVE_SDK_ENTRY, ...arr];
}
// ── Minimal webpack types (avoids adding webpack as a dev dep) ────────────────
interface WebpackConfig {
entry: Entry;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"moduleResolution": "NodeNext",
"module": "NodeNext"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"jsx": "preserve"
},
"include": ["src"],
"exclude": ["node_modules"]
}