Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Bot
bcf12f361f fix(transpiler): convert import to require for runtime helpers in CJS modules
When transpiling `using` declarations in CommonJS modules, the parser
generates `import { __using, __callDispose } from "bun:wrap"` statements.
These `import` statements are invalid inside the CJS function wrapper,
causing a "Expected CommonJS module to have a function wrapper" error.

Three changes:
- In js_printer.zig: convert `import` statements to `var { ... } = require(...)`
  when the output module type is CJS
- In transpiler.zig: pass `resolve_result.module_type` to ParseOptions so the
  parser correctly identifies .cjs files
- In transpiler.zig: prioritize `ast.exports_kind == .cjs` over the CLI
  output_format when determining printer module_type

Closes #11100

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 10:05:18 +00:00
Jarred Sumner
9fbe6a5826 Update standalone-html.mdx 2026-02-17 23:30:04 -08:00
Jarred Sumner
c0d97ebd88 Add docs for standalone HTML 2026-02-17 23:22:31 -08:00
7 changed files with 492 additions and 5 deletions

View File

@@ -1184,7 +1184,8 @@ Currently, the `--compile` flag can only accept a single entrypoint at a time an
- `--outdir` — use `outfile` instead (except when using with `--splitting`).
- `--public-path`
- `--target=node` or `--target=browser`
- `--target=node`
- `--target=browser` (without HTML entrypoints — see [Standalone HTML](/bundler/standalone-html) for `--compile --target=browser` with `.html` files)
- `--no-bundle` - we always bundle everything into the executable.
---

View File

@@ -481,6 +481,16 @@ All paths are resolved relative to your HTML file, making it easy to organize yo
This is a small wrapper around Bun's support for HTML imports in JavaScript.
## Standalone HTML
You can bundle your entire frontend into a **single self-contained `.html` file** with no external dependencies using `--compile --target=browser`. All JavaScript, CSS, and images are inlined directly into the HTML.
```bash terminal icon="terminal"
bun build --compile --target=browser ./index.html --outdir=dist
```
Learn more in the [Standalone HTML docs](/bundler/standalone-html).
## Adding a backend to your frontend
To add a backend to your frontend, you can use the "routes" option in `Bun.serve`.

View File

@@ -0,0 +1,314 @@
---
title: Standalone HTML
description: Bundle a single-page app into a single self-contained .html file with no external dependencies
---
Bun can bundle your entire frontend into a **single `.html` file** with zero external dependencies. JavaScript, TypeScript, JSX, CSS, images, fonts, videos, WASM — everything gets inlined into one file.
```bash terminal icon="terminal"
bun build --compile --target=browser ./index.html --outdir=dist
```
The output is a completely self-contained HTML document. No relative paths. No external files. No server required. Just one `.html` file that works anywhere a browser can open it.
## One file. Upload anywhere. It just works.
The output is a single `.html` file you can put anywhere:
- **Upload it to S3** or any static file host — no directory structure to maintain, just one file
- **Double-click it from your desktop** — it opens in the browser and works offline, no localhost server needed
- **Embed it in your webview** — No need to deal with relative files
- **Insert it in an `<iframe>`** — embed interactive content in another page with a single file URL
- **Serve it from anywhere** — any HTTP server, CDN, or file share. One file, zero configuration.
There's nothing to install, no `node_modules` to deploy, no build artifacts to coordinate, no relative paths to think about. The entire app — framework code, stylesheets, images, everything — lives in that one file.
## Truly one file
Normally, distributing a web page means managing a folder of assets — the HTML, the JavaScript bundles, the CSS files, the images. Move the HTML without the rest and everything breaks. Browsers have tried to solve this before: Safari's `.webarchive` and `.mhtml` are supposed to save a page as a single file, but in practice they unpack into a folder of loose files on your computer — defeating the purpose.
Standalone HTML is different. The output is a plain `.html` file. Not an archive. Not a folder. One file, with everything inside it. Every image, every font, every line of CSS and JavaScript is embedded directly in the HTML using standard `<style>` tags, `<script>` tags, and `data:` URIs. Any browser can open it. Any server can host it. Any file host can store it.
This makes it practical to distribute web pages the same way you'd distribute a PDF — as a single file you can move, copy, upload, or share without worrying about broken paths or missing assets.
## Quick start
<CodeGroup>
```html index.html icon="file-code"
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="root"></div>
<script src="./app.tsx"></script>
</body>
</html>
```
```tsx app.tsx icon="/icons/typescript.svg"
import React from "react";
import { createRoot } from "react-dom/client";
function App() {
return <h1>Hello from a single HTML file!</h1>;
}
createRoot(document.getElementById("root")!).render(<App />);
```
```css styles.css icon="file-code"
body {
margin: 0;
font-family: system-ui, sans-serif;
background: #f5f5f5;
}
```
</CodeGroup>
```bash terminal icon="terminal"
bun build --compile --target=browser ./index.html --outdir=dist
```
Open `dist/index.html` — the React app works with no server.
## Everything gets inlined
Bun inlines every local asset it finds in your HTML. If it has a relative path, it gets embedded into the output file. This isn't limited to images and stylesheets — it works with any file type.
### What gets inlined
| In your source | In the output |
| ------------------------------------------------ | ------------------------------------------------------------------------ |
| `<script src="./app.tsx">` | `<script type="module">...bundled code...</script>` |
| `<link rel="stylesheet" href="./styles.css">` | `<style>...bundled CSS...</style>` |
| `<img src="./logo.png">` | `<img src="data:image/png;base64,...">` |
| `<img src="./icon.svg">` | `<img src="data:image/svg+xml;base64,...">` |
| `<video src="./demo.mp4">` | `<video src="data:video/mp4;base64,...">` |
| `<audio src="./click.wav">` | `<audio src="data:audio/wav;base64,...">` |
| `<source src="./clip.webm">` | `<source src="data:video/webm;base64,...">` |
| `<video poster="./thumb.jpg">` | `<video poster="data:image/jpeg;base64,...">` |
| `<link rel="icon" href="./favicon.ico">` | `<link rel="icon" href="data:image/x-icon;base64,...">` |
| `<link rel="manifest" href="./app.webmanifest">` | `<link rel="manifest" href="data:application/manifest+json;base64,...">` |
| CSS `url("./bg.png")` | CSS `url(data:image/png;base64,...)` |
| CSS `@import "./reset.css"` | Flattened into the `<style>` tag |
| CSS `url("./font.woff2")` | CSS `url(data:font/woff2;base64,...)` |
| JS `import "./styles.css"` | Merged into the `<style>` tag |
Images, fonts, WASM binaries, videos, audio files, SVGs — any file referenced by a relative path gets base64-encoded into a `data:` URI and embedded directly in the HTML. The MIME type is automatically detected from the file extension.
External URLs (like CDN links or absolute URLs) are left untouched.
## Using with React
React apps work out of the box. Bun handles JSX transpilation and npm package resolution automatically.
```bash terminal icon="terminal"
bun install react react-dom
```
<CodeGroup>
```html index.html icon="file-code"
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My App</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="root"></div>
<script src="./app.tsx"></script>
</body>
</html>
```
```tsx app.tsx icon="/icons/typescript.svg"
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
import { Counter } from "./components/Counter.tsx";
function App() {
return (
<main>
<h1>Single-file React App</h1>
<Counter />
</main>
);
}
createRoot(document.getElementById("root")!).render(<App />);
```
```tsx components/Counter.tsx icon="/icons/typescript.svg"
import React, { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
```
</CodeGroup>
```bash terminal icon="terminal"
bun build --compile --target=browser ./index.html --outdir=dist
```
All of React, your components, and your CSS are bundled into `dist/index.html`. Upload that one file anywhere and it works.
## Using with Tailwind CSS
Install the plugin and reference Tailwind in your HTML or CSS:
```bash terminal icon="terminal"
bun install --dev bun-plugin-tailwind
```
<CodeGroup>
```html index.html icon="file-code"
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="tailwindcss" />
</head>
<body class="bg-gray-100 flex items-center justify-center min-h-screen">
<div id="root"></div>
<script src="./app.tsx"></script>
</body>
</html>
```
```tsx app.tsx icon="/icons/typescript.svg"
import React from "react";
import { createRoot } from "react-dom/client";
function App() {
return (
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md">
<h1 className="text-2xl font-bold text-gray-800">Hello Tailwind</h1>
<p className="text-gray-600 mt-2">This is a single HTML file.</p>
</div>
);
}
createRoot(document.getElementById("root")!).render(<App />);
```
</CodeGroup>
Build with the plugin using the JavaScript API:
```ts build.ts icon="/icons/typescript.svg"
await Bun.build({
entrypoints: ["./index.html"],
compile: true,
target: "browser",
outdir: "./dist",
plugins: [require("bun-plugin-tailwind")],
});
```
```bash terminal icon="terminal"
bun run build.ts
```
The generated Tailwind CSS is inlined directly into the HTML file as a `<style>` tag.
## How it works
When you pass `--compile --target=browser` with an HTML entrypoint, Bun:
1. Parses the HTML and discovers all `<script>`, `<link>`, `<img>`, `<video>`, `<audio>`, `<source>`, and other asset references
2. Bundles all JavaScript/TypeScript/JSX into a single module
3. Bundles all CSS (including `@import` chains and CSS imported from JS) into a single stylesheet
4. Converts every relative asset reference into a base64 `data:` URI
5. Inlines the bundled JS as `<script type="module">` before `</body>`
6. Inlines the bundled CSS as `<style>` in `<head>`
7. Outputs a single `.html` file with no external dependencies
## Minification
Add `--minify` to minify the JavaScript and CSS:
```bash terminal icon="terminal"
bun build --compile --target=browser --minify ./index.html --outdir=dist
```
Or via the API:
```ts build.ts icon="/icons/typescript.svg"
await Bun.build({
entrypoints: ["./index.html"],
compile: true,
target: "browser",
outdir: "./dist",
minify: true,
});
```
## JavaScript API
You can use `Bun.build()` to produce standalone HTML programmatically:
```ts build.ts icon="/icons/typescript.svg"
const result = await Bun.build({
entrypoints: ["./index.html"],
compile: true,
target: "browser",
outdir: "./dist", // optional — omit to get output as BuildArtifact
minify: true,
});
if (!result.success) {
console.error("Build failed:");
for (const log of result.logs) {
console.error(log);
}
} else {
console.log("Built:", result.outputs[0].path);
}
```
When `outdir` is omitted, the output is available as a `BuildArtifact` in `result.outputs`:
```ts icon="/icons/typescript.svg"
const result = await Bun.build({
entrypoints: ["./index.html"],
compile: true,
target: "browser",
});
const html = await result.outputs[0].text();
await Bun.write("output.html", html);
```
## Multiple HTML files
You can pass multiple HTML files as entrypoints. Each produces its own standalone HTML file:
```bash terminal icon="terminal"
bun build --compile --target=browser ./index.html ./about.html --outdir=dist
```
## Environment variables
Use `--env` to inline environment variables into the bundled JavaScript:
```bash terminal icon="terminal"
API_URL=https://api.example.com bun build --compile --target=browser --env=inline ./index.html --outdir=dist
```
References to `process.env.API_URL` in your JavaScript are replaced with the literal value at build time.
## Limitations
- **Code splitting** is not supported — `--splitting` cannot be used with `--compile --target=browser`
- **Large assets** increase file size since they're base64-encoded (33% overhead vs the raw binary)
- **External URLs** (CDN links, absolute URLs) are left as-is — only relative paths are inlined

View File

@@ -234,7 +234,7 @@
{
"group": "Asset Processing",
"icon": "image",
"pages": ["/bundler/html-static", "/bundler/css", "/bundler/loaders"]
"pages": ["/bundler/html-static", "/bundler/standalone-html", "/bundler/css", "/bundler/loaders"]
},
{
"group": "Single File Executable",

View File

@@ -4560,6 +4560,63 @@ fn NewPrinter(
return;
}
// When rewriting ESM to CJS (e.g. for .cjs files or files detected as CommonJS),
// convert `import { x, y } from "path"` to `var { x, y } = require("path")`.
// This is needed because import statements are invalid inside a CJS function wrapper.
if (rewrite_esm_to_cjs or p.options.module_type == .cjs) {
const has_items = s.items.len > 0 or s.default_name != null or record.flags.contains_import_star;
if (has_items) {
p.print("var ");
if (record.flags.contains_import_star and s.items.len == 0 and s.default_name == null) {
// import * as ns from "path" → var ns = require("path")
p.printSymbol(s.namespace_ref);
} else {
// import { a, b } from "path" → var { a, b } = require("path")
// import def, { a } from "path" → var { default: def, a } = require("path")
p.print("{");
p.printSpace();
var cjs_item_count: usize = 0;
if (s.default_name) |default_name| {
p.print("default:");
p.printSpace();
p.printSymbol(default_name.ref.?);
cjs_item_count += 1;
}
for (s.items) |item| {
if (cjs_item_count > 0) {
p.print(",");
p.printSpace();
}
p.printClauseItemAs(item, .@"var");
cjs_item_count += 1;
}
if (record.flags.contains_import_star) {
// This is a rare edge case; just add it as an extra item
if (cjs_item_count > 0) {
p.print(",");
p.printSpace();
}
}
p.printSpace();
p.print("}");
}
p.@"print = "();
}
p.print("require(");
p.printImportRecordPath(record);
p.print(")");
p.printSemicolonAfterStatement();
return;
}
p.print("import");
var item_count: usize = 0;

View File

@@ -640,6 +640,7 @@ pub const Transpiler = struct {
.jsx = resolve_result.jsx,
.emit_decorator_metadata = resolve_result.flags.emit_decorator_metadata,
.experimental_decorators = resolve_result.flags.experimental_decorators,
.module_type = resolve_result.module_type,
},
client_entry_point_,
) orelse {
@@ -838,6 +839,7 @@ pub const Transpiler = struct {
.minify_syntax = transpiler.options.minify_syntax,
.minify_identifiers = transpiler.options.minify_identifiers,
.transform_only = transpiler.options.transform_only,
.module_type = if (ast.exports_kind == .cjs) .cjs else .esm,
.import_meta_ref = ast.import_meta_ref,
.runtime_transpiler_cache = runtime_transpiler_cache,
.print_dce_annotations = transpiler.options.emit_dce_annotations,
@@ -864,12 +866,12 @@ pub const Transpiler = struct {
.minify_syntax = transpiler.options.minify_syntax,
.minify_identifiers = transpiler.options.minify_identifiers,
.transform_only = transpiler.options.transform_only,
.module_type = if (is_bun and transpiler.options.transform_only)
.module_type = if (ast.exports_kind == .cjs)
.cjs
else if (is_bun and transpiler.options.transform_only)
// this is for when using `bun build --no-bundle`
// it should copy what was passed for the cli
transpiler.options.output_format
else if (ast.exports_kind == .cjs)
.cjs
else
.esm,
.inline_require_and_import_errors = false,

View File

@@ -0,0 +1,103 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// https://github.com/oven-sh/bun/issues/11100
// `using` syntax should work in CommonJS modules
test("using works in .cjs file", async () => {
using dir = tempDir("issue-11100", {
"test.cjs": `
using server = { [Symbol.dispose]() { console.log("disposed"); } };
console.log("hello");
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.cjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("hello\ndisposed\n");
expect(exitCode).toBe(0);
});
test("using works in .js file with require (CJS detection)", async () => {
using dir = tempDir("issue-11100", {
"test.js": `
const path = require("path");
using server = { [Symbol.dispose]() { console.log("disposed"); } };
console.log("hello", path.join("a", "b"));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.js"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toContain("hello");
expect(stdout).toContain("disposed");
expect(exitCode).toBe(0);
});
test("await using works in .cjs file", async () => {
using dir = tempDir("issue-11100", {
"test.cjs": `
async function main() {
await using server = { [Symbol.asyncDispose]() { console.log("async disposed"); return Promise.resolve(); } };
console.log("hello");
}
main();
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.cjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("hello\nasync disposed\n");
expect(exitCode).toBe(0);
});
test("bun build --no-bundle emits require for bun:wrap in CJS", async () => {
using dir = tempDir("issue-11100", {
"test.cjs": `
using server = { [Symbol.dispose]() { console.log("disposed"); } };
console.log("hello");
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "--no-bundle", "test.cjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should use require() not import for CJS files
expect(stdout).toContain("require(");
expect(stdout).not.toContain("import ");
expect(exitCode).toBe(0);
});