@aurbi/hotplug
v1.1.0
Published
Runtime plugin loading for browser and Node.js — fetch, sandbox, and execute npm tarballs with React context bridging
Maintainers
Readme
hotplug
Runtime plugin loading for browser and Node.js.
Load plugins from npm registries, URLs, or raw tarball bytes at runtime — no host rebuild required. Plugins are stored locally (IndexedDB in the browser, filesystem in Node) and work offline after first install. Optionally sandbox plugins in an iframe or worker thread, with transparent React context bridging across the sandbox boundary.
AI Assistants: see AGENTS.md for a concise integration guide covering conventions, patterns, and common pitfalls.
Install
npm install hotplugQuick Start
import { loadPlugin } from 'hotplug'
const plugin = await loadPlugin({ package: '[email protected]' })
await plugin.install({ shared: ['react', 'react-dom'] })
for (const entry of plugin.exposed) {
const mod = await entry.import()
console.log(entry.type, entry.name, mod.default)
}Loading Plugins
loadPlugin accepts three source types:
import { loadPlugin } from 'hotplug'
// from the npm registry (or a private registry)
const a = await loadPlugin({ package: '[email protected]' })
const b = await loadPlugin({
registry: { url: 'https://npm.example.com', token: 'secret' },
package: 'my-plugin',
})
// from a URL pointing to a .tgz file
const c = await loadPlugin('https://example.com/my-plugin-1.0.0.tgz')
// from raw bytes (e.g. a file picker or fs.readFile)
const d = await loadPlugin(tgzUint8Array)To reload a previously installed plugin without re-downloading:
import { loadInstalledPlugin } from 'hotplug'
const plugin = await loadInstalledPlugin('[email protected]', {
shared: ['react', 'react-dom'],
sandbox: true,
})Plugin Lifecycle
const plugin = await loadPlugin({ package: '[email protected]' })
// 1. install — writes files to storage, releases in-memory bytes
await plugin.install({ shared: ['react'], sandbox: true })
// 2. import — returns the module (or a comlink proxy if sandboxed)
const mod = await plugin.exposed[0].import()
// 3. unload — destroys the sandbox but keeps files for offline re-activation
plugin.unload()
// re-importing after unload transparently re-creates the sandbox
const mod2 = await plugin.exposed[0].import()
// 4. uninstall — removes files from storage (implies unload)
await plugin.uninstall()Updating a Plugin
Update by uninstalling the old version and installing the new one. The name@version namespace means both versions can coexist briefly during the transition:
await oldPlugin.uninstall()
const newPlugin = await loadPlugin({ package: '[email protected]' })
await newPlugin.install({ shared: ['react'] })Shared Dependencies
Plugins ship self-contained ESM bundles, but some packages — React in particular — must be singletons shared between host and plugin. Pass them via the shared option at install time:
await plugin.install({ shared: ['react', 'react-dom', 'my-app-shared'] })hotplug is always added to the shared set automatically so host and plugin use the same hotplug/react wrappers.
In the browser, shared packages must be available via the host's import map. In Node, they resolve naturally through the host's module graph.
Sandboxing
Install with sandbox: true to isolate plugin code:
await plugin.install({ shared: ['react'], sandbox: true })Sandboxing is transparent to both host and plugin code — entry.import() returns a comlink proxy instead of a direct module reference, but the API surface is identical.
| | Browser | Node |
|-|---------|------|
| Mechanism | Hidden <iframe> | Worker thread (node:worker_threads) |
| Isolation | Separate origin, separate global | Separate V8 isolate, no shared memory |
| Communication | postMessage via comlink | MessagePort via comlink |
Node sandboxing caveat: worker threads do not prevent plugin code from importing Node built-ins (
fs,net, etc.). This provides trusted-but-isolated execution — it prevents accidental state corruption, not malicious code. For stronger isolation, run the host inside a container or use the Node.js permission model.
Browser Setup
In the browser, a service worker serves plugin files from IndexedDB-backed storage. Register it before loading any plugins:
import { registerPluginServiceWorker } from 'hotplug'
await registerPluginServiceWorker('/hotplug-sw.js')Copy dist/hotplug-sw.global.js from the package to your public directory (renamed as needed). The service worker intercepts requests to the /_hotplug/<name>@<version>/ namespace and serves files from storage.
Your host app also needs an import map so that shared bare specifiers resolve correctly inside sandboxed iframes:
<script type="importmap">
{
"imports": {
"react": "/vendor/react.js",
"react-dom": "/vendor/react-dom.js",
"hotplug/react": "/vendor/hotplug-react.js"
}
}
</script>React Integration
PluginComponent
PluginComponent renders a plugin's exposed React component. It handles module loading (with Suspense), iframe mounting when sandboxed, and context bridging:
import { PluginComponent } from 'hotplug/react'
import { Suspense } from 'react'
function HostView({ entry }) {
return (
<Suspense fallback={<p>Loading plugin...</p>}>
<PluginComponent entry={entry} contexts={[ThemeContext, AuthContext]} />
</Suspense>
)
}The contexts prop specifies which contexts to bridge into the sandbox. When unsandboxed, PluginComponent simply imports and renders the component directly — no bridging is needed.
createContext and useContext
Plugin code must use the wrappers from hotplug/react instead of React's built-in context API. These detect at call time whether the code is running inside a sandbox and swap between real React context and the bridged implementation:
import { createContext, useContext } from 'hotplug/react'
export const ThemeContext = createContext('ThemeContext', { mode: 'light' })
// in a component:
function PluginView() {
const theme = useContext(ThemeContext)
return <div className={theme.mode}>...</div>
}When unsandboxed, these behave identically to React.createContext and React.useContext. When sandboxed, useContext reads from bridged state maintained by comlink messages from the host.
Context Bridging Semantics
The bridge separates context values into two parts:
- Data — anything structured-cloneable (strings, numbers, objects, arrays) is sent as snapshots via
postMessage - Callables — functions at any depth are replaced with fire-and-forget stubs that forward calls to the host via comlink
This means sandboxed context updates have eventual-consistency semantics: when a plugin calls a context function (like a setter), the call is forwarded to the host, which triggers a state change and re-render, and a new snapshot is pushed back. There is a ~1–2 frame delay (~16–32ms) between calling a function and seeing its effect in the context value. For most contexts (theme, locale, auth, feature flags), this latency is imperceptible.
Context values must be structured-cloneable (minus functions, which are stubbed). Values containing Symbols, DOM nodes, or other non-transferable objects cannot be bridged.
Optimistic Updates
For latency-sensitive contexts like controlled text inputs, the round-trip delay can cause visible lag. Provide a reduce function to enable optimistic local updates:
import { createContext } from 'hotplug/react'
const TextContext = createContext<[string, (s: string) => void]>(
'TextContext',
['', () => {}],
{
reduce(current, callPath, args) {
// callPath "1" = the setter (index 1 in the tuple)
if (callPath === '1') return [args[0] as string, current[1]]
},
},
)When a plugin calls the setter, the bridge:
- Calls
reduce()synchronously to predict the next value - Updates the plugin's local state immediately (synchronous re-render)
- Forwards the call to the host via comlink
- When the next host snapshot arrives, it overwrites the prediction — the host is always authoritative
For the common [T, (t: T) => void] pattern, use the shorthand:
import { createStateContext } from 'hotplug/react'
// built-in reducer that treats the second tuple element as a setter
const TextContext = createStateContext<string>('TextContext', '')Authoring Plugins
package.json
Plugins declare their entry points via an expose key in package.json:
{
"name": "my-plugin",
"version": "1.0.0",
"expose": {
"widget:sidebar": "./dist/Sidebar.js",
"widget:toolbar": "./dist/Toolbar.js",
"datatype:markdown": "./dist/Markdown.js"
}
}Each key is type:name — both are arbitrary strings that the host uses to identify what the entry is and find it by name. Each value is a path to an ESM file within the package.
Bundling Requirements
Each exposed entry file must be a self-contained bundle: all dependencies are bundled in, except those listed in the host's shared set. Shared packages remain as bare specifiers in the output. This follows the same convention as Module Federation remotes.
For example, using tsup:
// tsup.config.ts
export default {
entry: {
'Sidebar': 'src/Sidebar.tsx',
'Toolbar': 'src/Toolbar.tsx',
'Markdown': 'src/Markdown.ts',
},
format: ['esm'],
external: ['react', 'react-dom', 'hotplug'],
noExternal: [/.*/], // bundle everything else
target: 'es2022',
}Plugin Components
Plugin React components use hotplug/react wrappers for context — they don't need to know whether they're sandboxed:
import { useContext } from 'hotplug/react'
import { ThemeContext } from 'my-app-shared'
export default function Sidebar() {
const theme = useContext(ThemeContext)
return <aside className={theme.mode}>...</aside>
}The default export of a component entry is what PluginComponent renders.
Sandbox Detection
If a plugin needs to know whether it's sandboxed (rare), it can check:
- Browser:
globalThis.__HOTPLUG_SANDBOXED__ - Node:
workerData.__sandboxedfromnode:worker_threads
These flags are set synchronously before any plugin module code executes.
API Reference
hotplug
| Export | Description |
|--------|-------------|
| loadPlugin(source) | Load a plugin from npm, a URL, or raw tarball bytes. Returns Promise<PluginHandle>. |
| loadInstalledPlugin(id, options?, storage?) | Load a previously installed plugin from storage. Returns Promise<PluginHandle>. |
| registerPluginServiceWorker(path) | Register the service worker (browser only). Returns Promise<void>. |
PluginHandle
| Member | Description |
|--------|-------------|
| meta | { name, version, id, exposed } — parsed plugin metadata |
| exposed | Array of expose entries (shorthand for meta.exposed) |
| installed | Whether the plugin has been installed |
| sandboxed | Whether the plugin is sandboxed |
| shared | List of shared package names |
| install(options?, storage?) | Write files to storage. options: { shared?: string[], sandbox?: boolean } |
| unload() | Destroy the sandbox/executor, keep files for offline re-activation |
| uninstall() | Remove files from storage (implies unload()) |
ExposeEntry
| Member | Description |
|--------|-------------|
| type | The type portion of the type:name key |
| name | The name portion of the type:name key |
| path | Path to the entry file within the plugin |
| import() | Import the entry's module. Returns Promise<Record<string, unknown>>. Available after install(). |
hotplug/react
| Export | Description |
|--------|-------------|
| createContext(name, defaultValue, options?) | Create a sandbox-aware context. options: { reduce? } for optimistic updates. |
| createStateContext(name, defaultValue) | Shorthand for [T, setter] contexts with built-in optimistic reducer. |
| useContext(ctx) | Read a DynamicContext — delegates to React or bridged state depending on sandbox. |
| PluginComponent | React component. Props: entry: ExposeEntry, contexts?: DynamicContext[]. Use inside <Suspense>. |
Types
type PluginLoadInfo = NpmInfo | string | Uint8Array | ArrayBuffer
type NpmInfo = {
registry?: { url?: string; token?: string }
package: string // "pkg" or "[email protected]"
}
interface InstallOptions {
shared?: string[]
sandbox?: boolean
}