bun-plugin-qiankun
v1.0.1
Published
Transform Bun-built HTML for qiankun micro-frontend compatibility. Inspired by vite-plugin-qiankun.
Maintainers
Readme
bun-plugin-qiankun
Transform Bun-built HTML for qiankun micro-frontend compatibility. Inspired by vite-plugin-qiankun.
Why?
When qiankun loads a sub-app, it fetches the sub-app's index.html and executes its scripts inside a JS sandbox (ProxySandbox). There's a critical difference between how scripts are executed:
- Inline scripts (eval'd via
new Function) — see the sandbox proxy aswindow - ES modules (loaded via
import()) — see the realwindow, not the proxy
This means:
- Static
<script type="module">paths resolve relative to the main app's origin, not the sub-app's — causing 404s window.__POWERED_BY_QIANKUN__is set on the proxy, so ESM modules cannot read it
This plugin solves both problems by transforming the HTML at build time.
What it does
The transformQiankunHtml() function performs these transformations on your built index.html:
Entry script → dynamic
import()— Converts<script type="module" src="./assets/index.js">intoimport('/childapps/sub-app/assets/index.js')with the correct URL prefix, so the module loads from the sub-app's pathLifecycle bridge injection — Injects a
<script>that createswindow['appName']with Promise-basedbootstrap/mount/unmount/updatemethods. These useSymbol.for()for deferred binding — qiankun can call them immediately, and they resolve once the ESM module finishes loadingLifecycle resolution — After the dynamic
import()resolves, connects the sub-app's actual lifecycle functions (registered viaexportQiankunLifeCycles) to the Promise bridgeModulepreload transformation — Converts static
<link rel="modulepreload">tags to dynamic creation with the correct URL prefix
Installation
bun add bun-plugin-qiankunExamples
The examples/ directory contains complete working examples:
| Directory | Description |
|-----------|-------------|
| app1 | Vite + React sub-app |
| app1-bun | Bun + React sub-app |
| app2 | Vite + Vue sub-app |
| app2-bun | Bun + Vue sub-app |
| mainvue | Vue 3 host app |
| mainreact | React host app |
| mainvanilla | Vanilla JS host app (no framework) |
Each sub-app can be debugged standalone (bun run start) or with a host app (bun run dev).
Usage
1. Build script (build.ts)
Use HTML as the entrypoint — Bun automatically transpiles TSX/TS, extracts CSS, and updates asset references in the HTML:
import { transformQiankunHtml } from 'bun-plugin-qiankun';
import { rm } from 'fs/promises';
import { join } from 'path';
const APP_NAME = 'my-sub-app';
const PUBLIC_PATH = '/childapps/my-sub-app/';
const distDir = join(import.meta.dir, 'dist');
await rm(distDir, { recursive: true, force: true });
const result = await Bun.build({
entrypoints: ['./index.html'],
outdir: distDir,
format: 'esm',
target: 'browser',
minify: true,
publicPath: PUBLIC_PATH,
naming: {
entry: 'assets/[name].[ext]',
chunk: 'assets/[name].[ext]',
asset: 'assets/[name].[ext]',
},
});
if (!result.success) {
console.error(result.logs);
process.exit(1);
}
// Move index.html from assets/ to dist root
await Bun.write(
join(distDir, 'index.html'),
await Bun.file(join(distDir, 'assets/index.html')).text()
);
await rm(join(distDir, 'assets/index.html'));
// Apply qiankun HTML transformation
const htmlPath = join(distDir, 'index.html');
const rawHtml = await Bun.file(htmlPath).text();
const transformedHtml = transformQiankunHtml(rawHtml, {
appName: APP_NAME,
changeScriptOrigin: false,
publicPath: PUBLIC_PATH,
});
await Bun.write(htmlPath, transformedHtml);2. HTML template (index.html)
Reference your source files directly — Bun handles transpilation:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>my-sub-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>After bun build, the output dist/index.html will reference the bundled JS/CSS automatically.
3. Dev server (dev.ts)
For development with hot rebuild, use Bun.build() in-memory bundling:
import { serve } from 'bun';
import { transformQiankunHtml } from 'bun-plugin-qiankun';
import { watch } from 'fs';
const APP_NAME = 'my-sub-app';
const BASE_PATH = '/childapps/my-sub-app';
const PORT = 3001;
const rootDir = import.meta.dir;
let jsCode = '';
let html = '';
async function bundle() {
const result = await Bun.build({
entrypoints: ['./src/main.tsx'],
format: 'esm',
target: 'browser',
minify: false,
naming: '[name].[ext]',
});
if (!result.success) {
for (const msg of result.logs) console.error(msg);
return;
}
let cssCode = '';
for (const o of result.outputs) {
const text = await o.text();
if (o.path.endsWith('.js')) jsCode = text;
else if (o.path.endsWith('.css')) cssCode += text;
}
let h = await Bun.file(`${rootDir}/index.html`).text();
h = h.replace(/src="\.\/src\/main\.tsx"/, 'src="./assets/main.js"');
if (cssCode) h = h.replace('</head>', `<style>${cssCode}</style></head>`);
html = transformQiankunHtml(h, {
appName: APP_NAME,
changeScriptOrigin: false,
publicPath: `http://localhost:${PORT}${BASE_PATH}/`,
});
}
await bundle();
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': '*',
};
serve({
port: PORT,
routes: {
[`${BASE_PATH}/assets/main.js`]: () =>
new Response(jsCode, { headers: { 'Content-Type': 'application/javascript', ...corsHeaders } }),
[`${BASE_PATH}/*`]: async (req) => {
const url = new URL(req.url);
const relative = url.pathname.slice(BASE_PATH.length + 1);
const file = Bun.file(`${rootDir}/${relative}`);
if (await file.exists()) return new Response(file, { headers: corsHeaders });
return new Response(html, { headers: { 'Content-Type': 'text/html', ...corsHeaders } });
},
[BASE_PATH]: () => new Response(null, { status: 302, headers: { Location: `${BASE_PATH}/` } }),
},
});
watch(`${rootDir}/src`, { recursive: true }, async () => {
await bundle();
});4. Entry file (src/main.tsx for React)
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { exportQiankunLifeCycles } from 'bun-plugin-qiankun/client';
import App from './App';
let root: any = null;
function isInQiankun() {
return window.location.pathname.startsWith('/my-sub-app');
}
function render(props: any = {}) {
const { container } = props;
const el = container ? container.querySelector('#root') : document.getElementById('root');
root = createRoot(el!);
root.render(
<BrowserRouter basename={isInQiankun() ? '/my-sub-app' : '/childapps/my-sub-app'}>
<App />
</BrowserRouter>
);
}
exportQiankunLifeCycles({
name: 'my-sub-app',
bootstrap() { console.log('bootstrap'); },
mount(props) { render(props); },
unmount() { root?.unmount(); root = null; },
update(props) { console.log('update', props); },
});
if (!isInQiankun()) {
render();
}5. Entry file (src/main.ts for Vue)
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import { exportQiankunLifeCycles } from 'bun-plugin-qiankun/client';
import App from './App';
let app: any = null;
function isInQiankun() {
return window.location.pathname.startsWith('/my-sub-app');
}
function render(props: any = {}) {
const { container } = props;
const el = container ? container.querySelector('#app') : document.getElementById('app');
const router = createRouter({
history: createWebHistory(isInQiankun() ? '/my-sub-app' : '/childapps/my-sub-app'),
routes: [
{ path: '/', component: App },
],
});
app = createApp(App);
app.use(router);
app.mount(el!);
}
exportQiankunLifeCycles({
name: 'my-sub-app',
bootstrap() { console.log('bootstrap'); },
mount(props) { render(props); },
unmount() { app?.unmount(); app = null; },
});
if (!isInQiankun()) {
render();
}6. Register in host app
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'my-sub-app',
entry: '/childapps/my-sub-app/',
container: '#subapp-container',
activeRule: '/my-sub-app',
},
]);
start({ sandbox: { experimentalStyleIsolation: true } });API
transformQiankunHtml(html, options)
Transforms an HTML string for qiankun compatibility.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| appName | string | required | Sub-app name, must match the name in exportQiankunLifeCycles and registerMicroApps |
| changeScriptOrigin | boolean | true | When true, uses window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ at runtime for cross-origin loading. When false, uses the static publicPath |
| publicPath | string | '' | Absolute public path (e.g. /childapps/my-app/). Used when changeScriptOrigin is false to convert relative asset paths to absolute |
exportQiankunLifeCycles(lifecycle)
Registers lifecycle hooks on window for qiankun to call.
interface QiankunLifeCycle {
name: string;
bootstrap?: (props?) => void | Promise<void>;
mount?: (props?) => void | Promise<void>;
unmount?: (props?) => void | Promise<void>;
update?: (props?) => void | Promise<void>;
}qiankunWindow
A reference to window.proxy || window. Useful for accessing qiankun's sandbox proxy in inline script contexts. Note: inside ESM modules loaded via import(), this still resolves to the real window (not the proxy).
FAQ
Why can't I use window.__POWERED_BY_QIANKUN__ in my ESM module?
In qiankun's ProxySandbox, __POWERED_BY_QIANKUN__ is set on the proxy window, not the real window. Inline scripts (executed via eval/new Function) see the proxy as their window. But ESM modules loaded via import() see the real window — so window.__POWERED_BY_QIANKUN__ is undefined inside your bundled code.
Solution: Use URL-based detection instead:
function isInQiankun() {
return window.location.pathname.startsWith('/your-active-rule');
}Why use publicPath instead of changeScriptOrigin?
changeScriptOrigin: true(default) — Asset URLs are prefixed at runtime withwindow.__INJECTED_PUBLIC_PATH_BY_QIANKUN__. Use this for cross-origin loading (e.g., dev mode where sub-app runs on a different port).changeScriptOrigin: false+publicPath— Asset URLs are converted to absolute paths at build time. Use this for same-origin deployment (e.g., nginx serving all apps on one domain). This is simpler and avoids runtime URL computation.
How does this compare to vite-plugin-qiankun?
| Feature | vite-plugin-qiankun | bun-plugin-qiankun |
|---------|--------------------|--------------------|
| Build tool | Vite | Bun |
| HTML transformation | Vite transformIndexHtml hook | Post-build transformQiankunHtml() |
| Dev server | Vite dev server | Bun serve() + Bun.build() in-memory bundling |
| Lifecycle bridge | Same mechanism | Same mechanism |
| Modulepreload transform | Yes | Yes |
Why a function instead of a Bun plugin?
Bun's plugin API (Bun.plugin()) provides onLoad/onResolve hooks for module resolution, but has no hook for post-build HTML transformation. Vite can implement vite-plugin-qiankun because Rollup/Vite exposes transformIndexHtml. Bun doesn't have an equivalent.
The function approach is simple, explicit, and works with any build setup:
const transformed = transformQiankunHtml(rawHtml, { appName, publicPath });Vue with Bun — what about .vue SFC files?
Bun's plugin system cannot intercept .vue file resolution (Bun treats unknown extensions as static assets). Workarounds:
- Use
.tsfiles withdefineComponent({ template: '...' })and the full Vue build (vue/dist/vue.esm-bundler.js) for runtime template compilation - Pre-compile
.vuefiles to.tsbefore runningbun build
Clean
To clean all node_modules and dist in the root and all example sub-directories:
npx rimraf node_modules dist examples/*/node_modules examples/*/distLicense
MIT
