@forgecart/designer-runtime
v1.202607161748.0
Published
ForgeCart visual-editor guest runtime for tenant storefronts: dormant iframe protocol endpoint, spray-region element resolution, and the Turbopack data-fc-source stamping loader
Maintainers
Readme
@forgecart/designer-runtime
The guest half of ForgeCart's visual editor. It ships inside tenant Next.js
storefronts, boots dormant, and is activated exclusively over the iframe
postMessage API by the dashboard host. Once active it resolves spray-stroke
regions into DOM element descriptors (with file:line:col source attribution
stamped at build time), renders highlight overlays, and reports scroll /
resize / navigation so the host can keep its canvas in sync.
Three entrypoints:
| Subpath | Environment | Contents |
|---------|-------------|----------|
| . | Browser (ESM) | installDesignerRuntime(): () => void — the storefront mounts this once |
| ./protocol | Anywhere (ESM, DOM-free) | Wire types, MessageCodec, ElementReferenceFormatter, protocol constants — the dashboard host and the agent import only this |
| ./turbo-loader | Node (CJS) | The Turbopack/webpack loader that stamps data-fc-source onto host JSX elements in development |
The browser entrypoints have zero runtime dependencies; the loader
subpath depends on @babel/parser + magic-string only.
Wire protocol (v1, namespace fc-designer)
Every message carries the envelope { ns: 'fc-designer', v: 1 }; anything
else on the message bus (payment SDKs, analytics, HMR) is foreign traffic and
ignored silently. All coordinates are iframe-viewport CSS pixels — the
guest is zoom-agnostic by construction.
GUEST boots dormant ── READY (targetOrigin '*', empty payload) ──▶ HOST
HOST ── HELLO { nonce, protocolVersion } ──▶ GUEST nonce-gated, pins event.origin
GUEST ── HELLO_ACK { capabilities, url, viewport } ──▶ HOST state: handshaken
HOST ── ACTIVATE ──▶ GUEST ── ACTIVATED ──▶ HOST state: active
HOST ── REGION_SELECT { requestId, stroke, radius, maxElements } ──▶ GUEST
GUEST ── REGION_RESOLVED { requestId, elements, regionBounds, truncated } ──▶ HOST
HOST ── HIGHLIGHT_SET { refIds } / HIGHLIGHT_CLEAR ──▶ GUEST
HOST ── ELEMENT_BOUNDS_QUERY { requestId, refIds } ──▶ GUEST ── ELEMENT_BOUNDS ──▶ HOST
HOST ── PING ──▶ GUEST ── PONG { url, scroll } ──▶ HOST liveness
GUEST ── SCROLL / RESIZE (rAF-throttled), NAVIGATED ──▶ HOST while active
HOST ── DEACTIVATE ──▶ GUEST ── DEACTIVATED ──▶ HOST state: handshaken
GUEST ── ERROR { code, requestId, detail } ──▶ HOST post-handshake onlyError codes: INVALID_MESSAGE, NOT_ACTIVE, REGION_TOO_LARGE,
UNKNOWN_REF, INTERNAL — a wire-level string union; the host maps them to
its own UX.
Activation security
- Inert outside an iframe —
window.parent === windowmakesinstallDesignerRuntimea no-op. The dormant footprint is exactly onemessagelistener and zero DOM nodes. - Capability nonce — the host appends
#fcd=<uuid>to the preview URL. The fragment never reaches the server; the guest parks the nonce insessionStorage(survives iframe reloads) and strips only thefcdkey from the hash. Without the nonce, every HELLO is ignored silently. - Origin pinning — a HELLO is accepted only from
window.parentwith a real (non-'null') origin and the exact nonce; the first success pinsevent.originand every later post targets that origin. A re-HELLO must come from the pinned origin. - Reload recovery — an iframe reload boots a fresh runtime that posts a
fresh READY beacon; the host re-handshakes and the nonce is still in
sessionStorage.
Region resolution
REGION_SELECT rasterizes the stroke into grid cells (cell size
max(8, radius/2), budget 600 cells with coarsening back-off; padded bbox
beyond 4× the viewport area → REGION_TOO_LARGE), stack hit-tests every
cell center, drops non-meaningful elements (document chrome, invisible,
zero-area, > 85%-viewport wrappers, Next dev chrome), collapses
ancestor/descendant stacks by coverage, ranks by coverage then area, caps at
min(maxElements ?? 12, 25), and stamps survivors with data-fc-ref for
later HIGHLIGHT_SET / ELEMENT_BOUNDS_QUERY. Stamps accumulate per
activation and are stripped on DEACTIVATE.
Template integration (tenant storefront)
next.config.js — register the loader under turbopack.rules. With no as
field Turbopack chains the loader into its built-in SWC, so the stamped TSX is
still compiled normally. next build (webpack) ignores turbopack and the
loader self-gates on NODE_ENV === 'development', so production output is
untouched.
Glob gotcha (verified on Next 15.5): a rule key is matched by FILENAME
unless it contains / (then by full project-relative path), and Turbopack does
NOT expand braces. src/**/*.{tsx,jsx} therefore matches nothing and the loader
silently never runs (data-fc-source count stays 0 with no error). Use
per-extension filename globs:
module.exports = {
turbopack: {
rules: {
'*.tsx': { loaders: ['@forgecart/designer-runtime/turbo-loader'] },
'*.jsx': { loaders: ['@forgecart/designer-runtime/turbo-loader'] },
},
},
};Mount the runtime last in the root layout's <body> via a 'use client'
component so it survives App Router client navigations:
'use client';
import { useEffect } from 'react';
export function ForgecartDesigner(): null {
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return; // DCE'd from prod bundles
if (window.parent === window) return;
let dispose: (() => void) | undefined;
let cancelled = false;
void import('@forgecart/designer-runtime').then((mod) => {
if (cancelled) return;
dispose = mod.installDesignerRuntime();
});
return () => {
cancelled = true;
dispose?.();
};
}, []);
return null;
}installDesignerRuntime is idempotent across React StrictMode's double
effect: a second install while a runtime is live returns the live dispose,
and disposing clears the singleton.
Host integration (dashboard)
Import only @forgecart/designer-runtime/protocol. The host:
- appends
#fcd=<crypto.randomUUID()>to the preview URL before setting the iframesrc; - listens for
READY, then postsHELLO { nonce, protocolVersion }to the iframe's content window — and re-handshakes whenever a freshREADYarrives (iframe reload); - validates every inbound message with
MessageCodec.parseGuestMessageand checksevent.sourceis the iframe's content window; - correlates request/response pairs by
requestIdwith a timeout; - serializes selections for the first message of an anchored AI thread via
ElementReferenceFormatter(<fc-selection>{json}</fc-selection>).
Contingency: jsx-dev-runtime fallback
The loader runs under Turbopack (see the glob gotcha above — the config has to
be right, but Turbopack does invoke it). If a future Next/Turbopack release
breaks turbopack.rules for JS loaders entirely, the documented fallback is
intercepting jsx-dev-runtime's jsxDEV source argument to inject the same
attribute at render time. Note this fallback is dead on React 19.2, which
drops the jsxDEV source argument — so the loader (which owns its own parse and
is independent of React's dev runtime) is the only mechanism that survives the
current stack, not merely the preferred one.
Develop, build, publish
nx run designer-runtime:test # jsdom unit + loader integration suite
nx run designer-runtime:typecheck # lib + loader + spec tsconfigs
nx run designer-runtime:build # tsc ESM lib → dist/, tsc CJS loader → dist/loader-cjs/
nx run designer-runtime:publish # npm publish --access public (runs build first)The build stamps dist/loader-cjs/package.json with { "type": "commonjs" }
so the loader subpath require()s correctly from this otherwise
"type": "module" package.
Publish order for real workspace pods: publish this package first (the
pod-image template warmup npm installs it through the Nexus npm proxy),
then bump + commit the storefront template / CLI, then rebuild the
workspace-pod image.
