Injected into the page HTML by ByotThemeBoundary.tsx:
The browser's native import-map machinery resolves the bare specifiers in theme.js before fetching. No bundler-level shim, no runtime resolver — just W3C.
Each URL under /__numu-runtime/ serves a single module. They're plain ESM:
Same pattern for ReactDOM, the SDK, JSX runtimes.The storefront bundles these via Next.js + Vite's separate runtime-build step. We use named-export wrappers rather than re-exporting the whole module because some named exports (React internals like __CLIENT_INTERNALS_*) are needed by the SDK's useSyncExternalStore shim.
Webpack's official Module Federation plugin works but ties us to webpack. Vite has unofficial plugins but they break in subtle ways with Vite 5+. Native import maps are simpler, smaller, and the W3C standard.
The SDK exports registerReactSingleton() and getReactSingleton(). On mount, the SDK checks:
import { React } from "./internals";
import { getReactSingleton, registerReactSingleton } from "./federation";
const existing = getReactSingleton();
if (existing && existing !== React) {
throw new Error(
"Multiple React instances detected. The theme bundle is shipping its own React copy — externalize it."
);
}
registerReactSingleton(React);
This catches the most common build mistake: forgetting to externalize React in the theme's vite.config.ts. The plugin's contract-validate.ts runs the same check at build time so you don't ship a broken theme.
The numu-theme-plugin injects these externals automatically if you forget. Either way, the resulting theme.js has top-level import { useState } from "react" lines that the browser resolves via the import map.
CORS is configured on R2 to allow *.numueg.app for GET only. Themes load via <script type="module">, which respects CORS — without the CORS header the bundle silently fails to import.
The bundle URL is http://localhost:3001/theme.js (Vite dev server)
The import map points to http://localhost:3001/runtime/* (the plugin's middleware serves them)
HMR works because Vite's dev server handles the WebSocket protocol on the same port
When you load the customizer with the bundle in dev mode (point theme_settings.external_theme.bundle_url to http://localhost:3001/theme.js), edits in your editor hot-reload the iframe within ~100ms.
Local dev needs 127.0.0.1.nip.io
Browsers won't load http://localhost:3001/theme.js from a page on http://test.localhost:3000/ due to cross-origin restrictions on certain CORS edge cases. The plugin's mkcert-based HTTPS option (or using *.nip.io) sidesteps this.
That's it. Anything else a theme needs (lodash, framer-motion, etc.) must be bundled into theme.js itself.This keeps the runtime cacheable across every theme on the platform — one set of URLs, one CDN cache key, every theme benefits.
The customizer can hot-swap themes in preview mode. When that happens:
1.
The page calls the previous bundle's mount()-returned cleanup function: cleanup()
2.
That cleanup calls root.unmount()
3.
The page replaces the <script type="module"> tag with a new URL
4.
Browser fetches the new bundle, calls mount(newContext)
5.
New root mounts
Themes that allocate global resources (intervals, listeners on window, etc.) must clean them up in the returned cleanup function, otherwise they leak across swaps. The SDK's NuMuProvider handles its own internals automatically.