require --compile for ESM bytecode (#26624)

## Summary
- Add validation to require `--compile` when using ESM bytecode
- Update documentation to clarify ESM bytecode requirements

## Why
ESM module resolution is two-phase: (1) analyze imports/exports, (2)
evaluate. Without `--compile`, there's no `module_info` embedded, so JSC
must still parse the file for module analysis even with bytecode -
causing a double-parse deopt.

## Changes
- **CLI**: Error when `--bytecode --format=esm` is used without
`--compile`
- **JS API**: Error when `bytecode: true, format: 'esm'` is used without
`compile: true`
- **Docs**: Update bytecode.mdx, executables.mdx, index.mdx to clarify
requirements
- **Types**: Update JSDoc for bytecode option in bun.d.ts

## Test plan
```bash
# Should error
bun build ./test.js --bytecode --format=esm --outdir=./out
# error: ESM bytecode requires --compile. Use --format=cjs for bytecode without --compile.

# Should work
bun build ./test.js --bytecode --format=esm --compile --outfile=./mytest
bun build ./test.js --bytecode --format=cjs --outdir=./out
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
robobun
2026-01-31 17:35:03 -08:00
committed by GitHub
parent 1337f5dba4
commit 9d68ec882a
7 changed files with 67 additions and 45 deletions

View File

@@ -7,9 +7,9 @@ Bytecode caching is a build-time optimization that dramatically improves applica
## Usage ## Usage
### Basic usage ### Basic usage (CommonJS)
Enable bytecode caching with the `--bytecode` flag: Enable bytecode caching with the `--bytecode` flag. Without `--format`, this defaults to CommonJS:
```bash terminal icon="terminal" ```bash terminal icon="terminal"
bun build ./index.ts --target=bun --bytecode --outdir=./dist bun build ./index.ts --target=bun --bytecode --outdir=./dist
@@ -17,7 +17,7 @@ bun build ./index.ts --target=bun --bytecode --outdir=./dist
This generates two files: This generates two files:
- `dist/index.js` - Your bundled JavaScript - `dist/index.js` - Your bundled JavaScript (CommonJS)
- `dist/index.jsc` - The bytecode cache file - `dist/index.jsc` - The bytecode cache file
At runtime, Bun automatically detects and uses the `.jsc` file: At runtime, Bun automatically detects and uses the `.jsc` file:
@@ -28,14 +28,24 @@ bun ./dist/index.js # Automatically uses index.jsc
### With standalone executables ### With standalone executables
When creating executables with `--compile`, bytecode is embedded into the binary: When creating executables with `--compile`, bytecode is embedded into the binary. Both ESM and CommonJS formats are supported:
```bash terminal icon="terminal" ```bash terminal icon="terminal"
# ESM (requires --compile)
bun build ./cli.ts --compile --bytecode --format=esm --outfile=mycli
# CommonJS (works with or without --compile)
bun build ./cli.ts --compile --bytecode --outfile=mycli bun build ./cli.ts --compile --bytecode --outfile=mycli
``` ```
The resulting executable contains both the code and bytecode, giving you maximum performance in a single file. The resulting executable contains both the code and bytecode, giving you maximum performance in a single file.
### ESM bytecode
ESM bytecode requires `--compile` because Bun embeds module metadata (import/export information) in the compiled binary. This metadata allows the JavaScript engine to skip parsing entirely at runtime.
Without `--compile`, ESM bytecode would still require parsing the source to analyze module dependencies—defeating the purpose of bytecode caching.
### Combining with other optimizations ### Combining with other optimizations
Bytecode works great with minification and source maps: Bytecode works great with minification and source maps:
@@ -90,35 +100,9 @@ Larger applications benefit more because they have more code to parse.
- ❌ **Code that runs once** - ❌ **Code that runs once**
- ❌ **Development builds** - ❌ **Development builds**
- ❌ **Size-constrained environments** - ❌ **Size-constrained environments**
- ❌ **Code with top-level await** (not supported)
## Limitations ## Limitations
### CommonJS only
Bytecode caching currently works with CommonJS output format. Bun's bundler automatically converts most ESM code to CommonJS, but **top-level await** is the exception:
```js
// This prevents bytecode caching
const data = await fetch("https://api.example.com");
export default data;
```
**Why**: Top-level await requires async module evaluation, which can't be represented in CommonJS. The module graph becomes asynchronous, and the CommonJS wrapper function model breaks down.
**Workaround**: Move async initialization into a function:
```js
async function init() {
const data = await fetch("https://api.example.com");
return data;
}
export default init;
```
Now the module exports a function that the consumer can await when needed.
### Version compatibility ### Version compatibility
Bytecode is **not portable across Bun versions**. The bytecode format is tied to JavaScriptCore's internal representation, which changes between versions. Bytecode is **not portable across Bun versions**. The bytecode format is tied to JavaScriptCore's internal representation, which changes between versions.
@@ -236,8 +220,6 @@ It's normal for it it to log a cache miss multiple times since Bun doesn't curre
- Compressing `.jsc` files for network transfer (gzip/brotli) - Compressing `.jsc` files for network transfer (gzip/brotli)
- Evaluating if the startup performance gain is worth the size increase - Evaluating if the startup performance gain is worth the size increase
**Top-level await**: Not supported. Refactor to use async initialization functions.
## What is bytecode? ## What is bytecode?
When you run JavaScript, the JavaScript engine doesn't execute your source code directly. Instead, it goes through several steps: When you run JavaScript, the JavaScript engine doesn't execute your source code directly. Instead, it goes through several steps:

View File

@@ -322,10 +322,7 @@ Using bytecode compilation, `tsc` starts 2x faster:
Bytecode compilation moves parsing overhead for large input files from runtime to bundle time. Your app starts faster, in exchange for making the `bun build` command a little slower. It doesn't obscure source code. Bytecode compilation moves parsing overhead for large input files from runtime to bundle time. Your app starts faster, in exchange for making the `bun build` command a little slower. It doesn't obscure source code.
<Warning> <Note>Bytecode compilation supports both `cjs` and `esm` formats when used with `--compile`.</Note>
**Experimental:** Bytecode compilation is an experimental feature. Only `cjs` format is supported (which means no
top-level-await). Let us know if you run into any issues!
</Warning>
### What do these flags do? ### What do these flags do?

View File

@@ -1508,22 +1508,43 @@ BuildArtifact (entry-point) {
## Bytecode ## Bytecode
The `bytecode: boolean` option can be used to generate bytecode for any JavaScript/TypeScript entrypoints. This can greatly improve startup times for large applications. Only supported for `"cjs"` format, only supports `"target": "bun"` and dependent on a matching version of Bun. This adds a corresponding `.jsc` file for each entrypoint. The `bytecode: boolean` option can be used to generate bytecode for any JavaScript/TypeScript entrypoints. This can greatly improve startup times for large applications. Requires `"target": "bun"` and is dependent on a matching version of Bun.
- **CommonJS**: Works with or without `compile: true`. Generates a `.jsc` file alongside each entrypoint.
- **ESM**: Requires `compile: true`. Bytecode and module metadata are embedded in the standalone executable.
Without an explicit `format`, bytecode defaults to CommonJS.
<Tabs> <Tabs>
<Tab title="JavaScript"> <Tab title="JavaScript">
```ts title="build.ts" icon="/icons/typescript.svg" ```ts title="build.ts" icon="/icons/typescript.svg"
// CommonJS bytecode (generates .jsc files)
await Bun.build({ await Bun.build({
entrypoints: ["./index.tsx"], entrypoints: ["./index.tsx"],
outdir: "./out", outdir: "./out",
bytecode: true, bytecode: true,
}) })
// ESM bytecode (requires compile)
await Bun.build({
entrypoints: ["./index.tsx"],
outfile: "./mycli",
bytecode: true,
format: "esm",
compile: true,
})
``` ```
</Tab> </Tab>
<Tab title="CLI"> <Tab title="CLI">
```bash terminal icon="terminal" ```bash terminal icon="terminal"
# CommonJS bytecode
bun build ./index.tsx --outdir ./out --bytecode bun build ./index.tsx --outdir ./out --bytecode
# ESM bytecode (requires --compile)
bun build ./index.tsx --outfile ./mycli --bytecode --format=esm --compile
``` ```
</Tab> </Tab>
</Tabs> </Tabs>
@@ -1690,7 +1711,10 @@ interface BuildConfig {
* start times, but will make the final output larger and slightly increase * start times, but will make the final output larger and slightly increase
* memory usage. * memory usage.
* *
* Bytecode is currently only supported for CommonJS (`format: "cjs"`). * - CommonJS: works with or without `compile: true`
* - ESM: requires `compile: true`
*
* Without an explicit `format`, defaults to CommonJS.
* *
* Must be `target: "bun"` * Must be `target: "bun"`
* @default false * @default false

View File

@@ -50,7 +50,8 @@ bun build <entry points>
</ParamField> </ParamField>
<ParamField path="--format" type="string" default="esm"> <ParamField path="--format" type="string" default="esm">
Module format of the output bundle. One of <code>esm</code>, <code>cjs</code>, or <code>iife</code> Module format of the output bundle. One of <code>esm</code>, <code>cjs</code>, or <code>iife</code>. Defaults to{" "}
<code>cjs</code> when <code>--bytecode</code> is used.
</ParamField> </ParamField>
### File Naming ### File Naming

View File

@@ -2594,7 +2594,10 @@ declare module "bun" {
* start times, but will make the final output larger and slightly increase * start times, but will make the final output larger and slightly increase
* memory usage. * memory usage.
* *
* Bytecode is currently only supported for CommonJS (`format: "cjs"`). * - CommonJS: works with or without `compile: true`
* - ESM: requires `compile: true`
*
* Without an explicit `format`, defaults to CommonJS.
* *
* Must be `target: "bun"` * Must be `target: "bun"`
* @default false * @default false

View File

@@ -1019,6 +1019,13 @@ pub const JSBundler = struct {
} }
} }
// ESM bytecode requires compile because module_info (import/export metadata)
// is only available in compiled binaries. Without it, JSC must parse the file
// twice (once for module analysis, once for bytecode), which is a deopt.
if (this.bytecode and this.format == .esm and this.compile == null) {
return globalThis.throwInvalidArguments("ESM bytecode requires compile: true. Use format: 'cjs' for bytecode without compile.", .{});
}
return this; return this;
} }

View File

@@ -179,7 +179,7 @@ pub const build_only_params = [_]ParamType{
clap.parseParam("--sourcemap <STR>? Build with sourcemaps - 'linked', 'inline', 'external', or 'none'") catch unreachable, clap.parseParam("--sourcemap <STR>? Build with sourcemaps - 'linked', 'inline', 'external', or 'none'") catch unreachable,
clap.parseParam("--banner <STR> Add a banner to the bundled output such as \"use client\"; for a bundle being used with RSCs") catch unreachable, clap.parseParam("--banner <STR> Add a banner to the bundled output such as \"use client\"; for a bundle being used with RSCs") catch unreachable,
clap.parseParam("--footer <STR> Add a footer to the bundled output such as // built with bun!") catch unreachable, clap.parseParam("--footer <STR> Add a footer to the bundled output such as // built with bun!") catch unreachable,
clap.parseParam("--format <STR> Specifies the module format to build to. \"esm\", \"cjs\" and \"iife\" are supported. Defaults to \"esm\".") catch unreachable, clap.parseParam("--format <STR> Specifies the module format to build to. \"esm\", \"cjs\" and \"iife\" are supported. Defaults to \"esm\", or \"cjs\" with --bytecode.") catch unreachable,
clap.parseParam("--root <STR> Root directory used for multiple entry points") catch unreachable, clap.parseParam("--root <STR> Root directory used for multiple entry points") catch unreachable,
clap.parseParam("--splitting Enable code splitting") catch unreachable, clap.parseParam("--splitting Enable code splitting") catch unreachable,
clap.parseParam("--public-path <STR> A prefix to be appended to any import paths in bundled code") catch unreachable, clap.parseParam("--public-path <STR> A prefix to be appended to any import paths in bundled code") catch unreachable,
@@ -1346,11 +1346,19 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
} }
ctx.bundler_options.output_format = format; ctx.bundler_options.output_format = format;
// ESM bytecode is supported for --compile builds (module_info is embedded in binary) if (ctx.bundler_options.bytecode) {
if (format != .cjs and format != .esm and ctx.bundler_options.bytecode) { if (format != .cjs and format != .esm) {
Output.errGeneric("format must be 'cjs' or 'esm' when bytecode is true.", .{}); Output.errGeneric("format must be 'cjs' or 'esm' when bytecode is true.", .{});
Global.exit(1); Global.exit(1);
} }
// ESM bytecode requires --compile because module_info (import/export metadata)
// is only available in compiled binaries. Without it, JSC must parse the file
// twice (once for module analysis, once for bytecode), which is a deopt.
if (format == .esm and !ctx.bundler_options.compile) {
Output.errGeneric("ESM bytecode requires --compile. Use --format=cjs for bytecode without --compile.", .{});
Global.exit(1);
}
}
} }
if (args.flag("--splitting")) { if (args.flag("--splitting")) {