Compatibility
What works as it does under bun run, what differs on purpose, and what isn't available in an executable built with bun compile
An executable built with bun compile runs the same language and the same APIs as bun run. This page lists what differs.
A value that doesn't match its declared type isn't on the list. It falls back to the slow path, and the program behaves as it does under bun run.
If an error message sent you here:
| The message | See |
|---|---|
Cannot assign to ... because built-in objects are read-only | Built-in objects are read-only |
Cannot delete ... because built-in objects are read-only | Built-in objects are read-only |
Cannot change the prototype of ... because built-in objects are read-only | Built-in objects are read-only |
Cannot declare 'Math': compiled code treats the global 'Math' as a constant ... | Built-in objects are read-only |
Cannot load '...': it isn't in this executable | Code that isn't in the executable |
Cannot load '...': this executable can't load a module from a dynamic path | Code from strings |
Code generation from strings disallowed for this context: the compiler couldn't tell ... | Code from strings |
... was not compiled into this executable because your code never uses it | Code from strings |
Cannot replace a loader in Module._extensions: imports in this executable were resolved ... | Loader hooks |
... can't be used with this executable | Options that load code |
... was compiled ahead of time and can't be imported inside a ShadowRealm | What isn't available |
super() cannot be called from direct eval | What isn't available |
| Anything else | Troubleshooting |
What works the same#
- npm packages, with or without types, except for a few
- ES modules, CommonJS,
require(),import()of a string literal, top-levelawait - every
node:andbun:module - Workers and native addons
evalandnew Function, in the interpreter- macros
- plugins of
Bun.build,files,define,loaderand the other build options - proxies, getters and setters, generators, symbols,
Reflect, private members,delete, BigInt, regular expressions
Some of these take a slow path: a Proxy used as an array, and an array with holes, with getters for elements or with inherited elements. They behave as under bun run.
What differs on purpose#
| Area | bun run | Compiled |
|---|---|---|
| Type errors | bun build ignores them | They stop the build |
| The version of TypeScript | Types are never checked | TypeScript 7's errors, whichever version you have installed |
A .ts file without import or export | Its variables aren't global. Another file that reads one throws | The other file is a type error, also where tsc takes the file for a script |
| Built-in objects | Can be changed | Can be extended. Their existing members can't be replaced |
| Seven Bun classes | Their prototypes can be changed | Their prototypes are read-only. Subclasses work |
| Code from strings | The JIT compiles it | Bun interprets it |
| WebAssembly | The JIT compiles it | A .wasm file that you import is compiled. Bun interprets any other module |
| Dynamic imports | Loaded from disk | Entry points are found in the executable. Other code throws. Data files load |
| Exports of compiled modules | spyOn(namespace, "f") from bun:test replaces the export | Throws a TypeError. An export of a module loaded from disk at run time can still be spied on |
| The module cache | Lists every module that has run. delete makes one run again | Doesn't list the modules of the executable. Each runs at most once |
| A CommonJS module that threw | The next require() runs it again | The next require() throws the same error |
| Loader hooks | Module.prototype.require = f sees every require() | It sees a require() of a dynamic path. A constant path was resolved at build time |
require.main, module.children, module.parent | Lead from any module object to every other | The same if some file of your program names one of them. Otherwise undefined for everybody |
export * of a CommonJS module | The namespace object follows the specification | It differs in one rare case |
| The namespace object of an ES module that a CommonJS module has required | Object.isExtensible() says true, Object.isSealed() false | false and true, as for every other namespace object. In both, nothing can be added to it |
| Some error messages | Quote your source: (evaluating 'o.a.c') | Quote what you called, the name of the property, (evaluating 'null.c'), or nothing |
| The lines of code under an error | Printed above the message, with a caret | Not printed. The message and the stack trace are the same |
The line, column and stack of a DOMException. Where a module disposes a using | Places in the text that runs | Places in the source file |
The stack of an error that the parameters of an async function with an await throw | Doesn't list the function | Lists the function |
The stack of an error from the setup of an enum, a namespace or a decorated class | Has an <anonymous> frame for the declaration | Has no such frame. The function around the declaration is listed at the place that ran |
| The stack of an error from a field of a class with a decorated field and no constructor | Lists new and the name of the class | Doesn't, as for a class without decorators |
The stack of a RangeError from new Array(-1), where Array is a variable of your own | Often doesn't list Array, if the variable holds the built-in | Lists Array, like any other function that a variable holds |
| The frames printed for an error that leaves a generator and that nobody catches | Don't list the generator | List the generator |
A closure that runs while another closure of the same function gets a displayName | Stack traces list it under its own name | They list it under the displayName until it returns, and a callback of map() or forEach() until that call ends. Closures that are called later are listed under their own names |
Closures of one function that new Function() code or a Proxy trap names one by one | Stack traces list each closure under its own name | Where the build knows which function a call runs, they list each of the closures under the name that was set last |
A closure that is named after it threw, before stack is read, where the build can't see | The stack lists it under the new name | The first time in a program, the stack lists it under the name that it had when it threw. Later ones are listed under the new name |
| The name of a class under a computed key | Set after the static fields ran, and skipped if they read name | Set before the static fields run |
| The name of a function in a local variable | Empty, if the variable is a let or const that is used once | The name of the variable |
The name of a function or a class behind export default that has no name of its own | The file's name and _default. A class: starDefault in traces | default, in name and in stack traces |
| Raw strings of tagged templates | Characters outside ASCII come back as escapes | The text as you wrote it |
Function.prototype.toString() | What the transpiler printed | The text of your file, without the types |
Code nested more than 2,000 levels deep: calls in calls, an else if behind 2,000 others | Runs, as deep as the stack allows | The build stops with TS100301 at the code, or with TS100060 for a file that is too deep to read. A sum or an && of any number of operands isn't nested |
JSON.stringify() of objects nested more than 40,000 deep | Throws a RangeError | Writes the text where the types name the objects, as deep as the stack allows |
JSON.stringify() of an object that holds a string of 358 million characters or more | Writes the text, or throws a RangeError for some objects | Writes the text for more of them, as Node.js does: for an object that also holds a Date, and for an instance of a class declared in a function, where bun run throws once the function has run a few times |
process.default, Bun.default | undefined | The object itself |
The own keys of Object, copied and then listed, behind a for (key in Bun) loop | setPrototypeOf, defineProperties stand ahead of the others | In the place that they have without the loop, unless the program names Bun.$. The loop asks for $, bun run makes Bun.$ for that, and the code that makes it reads the two. A member of Bun that nothing names isn't made |
| Sloppy-mode code | f.caller, f.arguments | Both are null |
| Sloppy-mode code | getFunction() of a call site answers the function | undefined, as for a strict function, and as in an executable built without --aot |
A .cjs file loaded with import | Strict, if nothing in its text shows that it's CommonJS | Sloppy, as in Node.js |
A .mjs file that calls require() and has no import or export, behind an import | Runs ahead of the files that are imported before it | Runs in the order of the imports, as in an executable built without --aot |
require() of modules that an import() is loading, while the loader fetches others | The others run a few promise jobs behind the require() | In some turns they run inside the require(), as under bun run once nothing else is left to fetch. Each module runs once, behind what it imports. The language leaves open when a load ends |
require() of such modules where one of them awaits, so that require() throws | The first of them begins, then require() throws | In some turns require() throws first and they begin behind the error. Each module runs once, behind what it imports |
import() of a module that another import() is still loading | Settles in a turn that changes from run to run | Settles in the same turn in every run. Each module runs once, behind what it imports. The language leaves open when a load ends |
require.resolve("./x") of a file that isn't in the executable | Looks in the folder of the file that asks | Looks in that folder below the working directory if it's there, and else in the working directory, where an executable built without --aot looks |
__esModule of a CommonJS file, stored by a file that is imported behind it | An import goes by what it was when the file had run | An import goes by what it is when every CommonJS file that the module imports has run, as in bun build --compile and Node.js |
import { x }, where a CommonJS file has a getter for x that isn't enumerable | The import fails: the file has no export named x | Finds x if the getter only returns a member of another file, as in return other.x, and the text of the file shows all of its exports, as in an executable built without --aot. Otherwise the import fails |
A CommonJS file that is imported back while a require() of an ES module runs it | Some importers keep its exports of that moment, later ones don't | All importers read the same exports: those of that moment while the file runs, and what it has exported at its end from then on. import() gives the namespace object that import * as gave |
process.nextTick() in a file that an import loads, with more files still to run | When the tick runs hangs on when the other files were read | Each file runs once, in the order of the imports, and ticks keep their order. A tick may run behind a file that it runs ahead of under bun run, or ahead of the importing module where it runs behind it under bun run |
import of a package whose package.json has both "module" and "main" | Loads the file that "main" names | Loads the file that "module" names, as in an executable built without --aot. If the program also loads the package with require(), both load the file that "main" names |
undefined[key] = value, and the same on null | Converts the key, which can run your code, then throws | Throws first, as the specification says. The key's toString() never runs |
arguments in a string that eval runs in a generator or an async function | A ReferenceError, or those of a function further out | Those of the generator or async function. The same under bun run if its own code names arguments, or if an async function has no await |
eval(text) in a computed key or the extends clause of a class, in sloppy-mode code | Runs the text as sloppy-mode code | Runs it as strict code, like the rest of the class, as Node.js and an executable built without --aot do |
function (a, b = eval("var x = 1")) {} in sloppy-mode code where x exists further out | The text stores to the outer x | The text makes an x for this call, as in Node.js. The body reads it, delete x removes it, and the outer x keeps its value |
function (a, ...b) { eval("var a = 4"); }, or a pattern for ...b, in sloppy-mode code | The body keeps reading the parameter a | The body reads the a of the string from then on, as in Node.js: 4 here, undefined after eval("var a") |
function* (a, f = () => a) { var a = 2; }, and the same in an async function | f reads and stores to the a of the body | f reads and stores to the parameter, which is another variable than the var, as in Node.js and as in a function that is neither |
x = f() + "" in a try, where f() returns a symbol, so that the + throws | x holds the symbol in the catch and after it | x keeps the value that it had, as in Node.js |
await import("bun:main") at the top level of the entry point, or of a file it imports | Stays pending, and the process waits | Loads a module without exports, and the file continues |
import("./a.js", options) with { with: { type: "text" } } held in a variable | The text of the file | The module, as in an executable built without --aot. With the options written in the call, the text |
import("node:path", { with: { type: "json" } }): a built-in module with a type | Another namespace object, with the same members | The namespace object that import("node:path") gives. A built-in module is one module, whatever the attributes say |
require() by const { platform } = process where process.platform was assigned | Loads the file of the assigned name | Throws MODULE_NOT_FOUND. The executable contains the file for the platform that it's built for. Pass the other file as another entry point |
this at the top level of a file with an import, an export or a top-level await | null | undefined, as the specification says |
this at the top of a CommonJS file, in eval("this") or behind // @bun @bun-cjs | The module object | module.exports, which is what Node.js passes to a CommonJS file, and what a this that is written in the file is under bun run too |
An unused read of a browser global such as closed, name or document | Removed. Nothing is thrown, though the global doesn't exist | Throws a ReferenceError, as the specification says |
| An object literal that nothing uses, with a symbol as a computed key | Throws a TypeError | Nothing is thrown, as the specification says |
Object.keys({ get a() {}, b: 1, get a() {} }): a getter that a literal has twice | b, then a | a, then b: a key stays where it first stood, as the specification says and as in Node.js |
concat() of a list with a hole, into a filled list that Symbol.species answers | The index is empty afterwards | What the list held there stays, as the specification says and as in Node.js |
delete "text".length, delete "text"[1]: the length or a character of a string literal | true | false, and a TypeError in strict code, as the specification says |
| The name of a class with a class decorator, read before the decorators have run | undefined | Throws a ReferenceError, as the specification says |
The computed key of a member with a decorator, with experimentalDecorators | Runs the key expression twice | Runs it once, as TypeScript's compiler emits it |
design:paramtypes of a decorated class that has fields and no constructor | An empty list, with useDefineForClassFields: false | Not set, as in what TypeScript's compiler emits |
| An imported interface that only the type of a decorated member names | Throws a SyntaxError, with emitDecoratorMetadata | Runs. design:type is Object, as in what TypeScript's compiler emits |
export { T } from "./file" where T is only a type | Throws a SyntaxError: the export is not found | Runs the file, as an executable built without --aot does |
namespace A.B {} or namespace A { /*! note */ } with nothing but types inside | A is an object, and an export of the file if it is exported | There is no A, as in what TypeScript's compiler emits. Reading A as a value is a type error |
The values of accessor fields in a class with no class above | Some run behind the defaults of the constructor's parameters | All run ahead of them, in the order of the members, as the values of fields do |
accessor a = 1; in a subclass whose constructor has super() inside a statement | Throws a ReferenceError | Works, with or without a field beside it, as what TypeScript's compiler emits does |
The name of f behind const f = (0, () => {}) or const f = null ?? (() => {}) | "f" | Empty, as in Node.js |
The name of f behind const f = { held: () => {} }.held or const f = [() => {}][0] | "f" | "held", from the key in the literal, and empty for the function out of the list, as in Node.js |
f.bind().name, where f has its name from a computed key, as in { [key]() {} } | "bound ", without the key | "bound " and the key, as in Node.js |
An async f with such a name in a stack trace, behind its await. spyOn(o, key).name | <anonymous>. An empty name | The key. Node.js lists the function under it too |
for (const { a = a } of list), from the second turn on | Reads what a held on the turn before | Throws a ReferenceError, as on the first turn and as Node.js does |
The message for [a] = [1]; let a;: a pattern stores to a variable ahead of its let | Quotes the pattern, '[a]', or nothing, '' | Names the variable, as Node.js does: Cannot access 'a' before initialization. |
{ [key]: f() }, where key is an object | Calls f ahead of key.toString() | Calls key.toString() first, as Node.js does |
The keys of { set a(v) {}, b: 1, set a(v) {} } | b, a | a, b, as in Node.js: a key keeps its first place |
0 / 0 in the scope of a variable of yours named NaN | What the variable holds | NaN, as in Node.js and in an executable built without --aot |
A function that a later clause of a switch declares, read in an earlier clause | Throws a ReferenceError | Is there, as in Node.js: the clauses are one block |
label: function f() {} in sloppy-mode code | Nothing around the label sees f | The function around it sees f, as in Node.js |
super.x in the constructor of a class that extends a Proxy | Throws a TypeError | Works, as in a method and as in Node.js |
[f, g][1](): a function called straight out of an array literal, by a constant index | this is undefined, or the global object in sloppy-mode code | this is the array, as in Node.js and in an executable built without --aot |
(0, object.method)`x`: the tag of a template in parentheses, behind a comma | this is object | this is undefined, or the global object in sloppy-mode code, as in Node.js and as in the call (0, object.method)() |
arguments.length behind Object.freeze(arguments), where a closure reads a parameter | The number of parameters | The number of arguments, as in Node.js |
Object.isExtensible(ns), where ns is what require() returns for an ES module | true, and Object.isSealed(ns) is false | false, and Object.isSealed(ns) is true, as in Node.js and as for import * as ns |
An unused read of a let, const or local class before its declaration has run | Removed. Nothing is thrown | Throws a ReferenceError |
export const B = class extends Proxy {}, where no file imports B | Throws a TypeError where the class is made | The class is removed with the export that nothing uses. Nothing is thrown, as in an executable built without --aot |
const a = /* @__PURE__ */ f(), where nothing reads a | Calls f | The call is removed with the variable, as in an executable built without --aot |
| Joining strings into one that is too long, where nothing uses the result | Throws a RangeError, until the JIT has optimized the function | Throws a RangeError every time |
| A regular expression whose groups begin a hundred million turns before it finds its match | Gives the match up and answers that there is none | Finds the match where it needs fewer turns: it begins none of which it can tell beforehand that it fails. Otherwise it gives the match up too |
The sign and payload of a NaN that is read from a float view or a DataView | Dropped at the read, until the JIT has optimized the function | Dropped where the number leaves arithmetic and float views, as in a function that the JIT has optimized. The sign that -x gives it can be dropped there too, and to[i] = from[i] can copy the bytes |
"a" + symbol + f(): a symbol in a sum of three or more operands with a string | Calls f(), then throws the TypeError | Throws the TypeError first. f() never runs |
{ [key]: value }, where converting key to a string runs your code | Evaluates value, then converts key | Converts key, then evaluates value |
| An object literal or a class that defines the same getter or setter twice | Its key is listed where it was defined last | Its key is listed where it was defined first |
static { a: { break a; } }: a break out of a labeled block in a static block | A SyntaxError when the file loads | Leaves the block |
(5).#a: a private name read off a whole number in parentheses | A SyntaxError when the file loads | The file loads. The read throws a TypeError when it runs |
Level.Low ** n, where the enum member is negative and n isn't a constant | A SyntaxError when the file loads | Computes the power |
In sloppy-mode code: function f(a, a) {}, arguments = 1, eval++ on a local eval | An error when the file loads | Runs, as in Node.js |
"use\x20strict" or ("use strict") at the top of a function that has a var public | An error when the file loads | Runs, as in Node.js. Only the exact text is the directive |
a: b: while (x) { continue a; }: continue to the outer of two labels on one loop | An error when the file loads | Continues the loop, as in Node.js |
enum E { static } below "use strict": a member named by a reserved word | An error when the file loads | Runs, as in what TypeScript's compiler emits |
if (x) const enum A {}: an enum as the one statement of an if or a loop | An error when the file loads | Runs, as in what TypeScript's compiler emits |
declare class C { static prototype: number } | An error when the file loads | Runs. The declaration is erased |
accessor a = 1; in a class, with experimentalDecorators | An error when the file loads | Runs, as in what TypeScript's compiler emits |
@d(x => x.#a) m() {}: a private name in a decorator, with experimentalDecorators | A SyntaxError when the file loads | Runs, as in what TypeScript's compiler emits |
A class with a decorator in a file with this at the top or export = | A SyntaxError when the file loads | Runs |
import require = require("./file") in an ES module | Throws a ReferenceError | Imports the file under that name |
import { x } from "./m"; const x = 1; below // @ts-ignore, where that x is a value | Runs. x is the constant | The build reports TS2440, which no comment turns off: both are there when the program runs, so the name is declared twice in one scope |
import { a as x } and import { b as x } in a file with // @ts-nocheck | Runs. x is the later import | The build reports TS2300, which no comment turns off. In a JavaScript file bun run reports an error too |
for (using of list), for await (async of list): loops that store to such a variable | An error when the file loads | Runs, as in Node.js |
(a?.b)`x` , new (a?.b)(): an optional chain in parentheses as a tag or behind new | A SyntaxError when the file loads | Runs, as in Node.js |
x <!--y in a TypeScript file | An error when the file loads | Compares x with !--y, as in what TypeScript's compiler emits |
function f(a, this) {} in a file that is not type-checked | Runs without the parameter: f.length is 1 | The build reports TS2680, as TypeScript does in a file that it checks. Node.js reports a syntax error |
`\8`, ((a)) => 1, [(a = 1)] = [], (function await() {}) in a module | Runs | The build stops with an error that names the place. Node.js throws a SyntaxError when it loads the file |
A JSON, TOML or YAML file, or a data: URL imported as one, that doesn't parse | The import or require() that loads it throws | The build stops with an error that names the place, as in an executable built without --aot |
An enum member set to the length of a string literal: Length = "text".length | Every Size.Length is 4, whatever the program stores there | Size.Length reads the object, as with bun build --compile. It returns what the program stores there, and throws before the enum has run |
An enum member set from a path with brackets: Sum = Outer["Inner"].A + 30 | Every Totals.Sum is 31, whatever the program stores there | Totals.Sum reads the object, as in what TypeScript's compiler emits. It returns what the program stores there |
(Level.Low as any)(), (Level.Low as any)`x`: an enum member as callee or as tag | Calls the value of the member, so it throws a TypeError | Reads the member from the object, as in what TypeScript's compiler emits. It calls a function that the program has stored there, and throws a TypeError otherwise |
A const enum in a namespace of another file, or behind import Alias = Outer.Fixed | Reads the object, so it returns what the program stores there | Every Outer.Fixed.A is its value, whatever the program stores there, as when TypeScript's compiler compiles the files together |
| A function declared in a block of sloppy-mode code | Only the block sees it | The code around the block sees it too, once the block has run |
/(?:\b){5000000}/: a group that takes no characters, repeated millions of times | null | Matches, as the specification says |
await f(text) in an async function whose parameter f is RegExp.prototype.test | Prints another output | Throws a TypeError before it converts text to a string, as f.call(undefined, text) does. Array.fromAsync(list, RegExp.prototype.test) does the same |
f.apply(receiver, list), where f inherits apply and isn't a function | Reads list first, and throws if it isn't a list | Throws the TypeError about f first, as the specification says. list is never read, so its getters don't run |
new Readable(), stream.toArray(): internal properties of a stream, such as _events | Each is read and stored as often as in Node.js | While a stream is made or awaited, some are read once, or before the others are stored. Only a getter, a setter or a Proxy above the stream can tell |
req, res, req.socket of a node:http server: internal properties such as _events | Each is read and stored wherever the server uses it | During an exchange, some are read less often, or never. Only a getter or a setter that the program defines for one of them can tell |
A function that the program stores to process.nextTick, and built-in modules | Sees the calls that the modules make under bun run | Sees the calls that the compiled modules make. How many there are, their order and which internal functions they hand over may differ: a node:http server asks for fewer ticks for a request |
for...in over { 0: x, 1: y }, with a typed array as its prototype | Lists an index that both have twice | Lists it once |
o[9] = x, where the prototype of o is a typed array without the index 9 | Adds the element to o. Throws a TypeError if o is sealed | The same, but nothing happens if o is sealed and has elements, or once the prototype of a function that was never constructed has a Proxy above it, an accessor or a read-only element under an index, or is frozen with elements |
TZ in a .env file, or on Windows in the environment | Dates use that time zone from the start | The same. An executable built without --aot starts in the time zone of the system |
Five flags on process | A value that process inherits counts | Only own properties of process count |
A FinalizationRegistry that only a local variable holds | Lives while the function waits at an await | Can be collected after the variable's last use. Its callback then never runs. What the parameters and this of an async function or a generator hold lives until it ends, as under bun run |
| Objects per thread | Limited by memory | 32 GB, or no limit with --aot-huge-heap |
What isn't available#
| Feature | What you see | Instead |
|---|---|---|
--target for another platform | error: bun compile can't cross-compile. It only builds for the platform it runs on | Run the build on the target platform, or pass --no-aot |
More than 256 JSCallbacks from bun:ffi open at a time | RangeError: bun:ffi: a compiled executable can have 256 JSCallbacks open at a time | close() the ones you no longer use. The rest of bun:ffi works |
cc() from bun:ffi, on Linux 6.7 or later | Error: Cannot compile C code because this process can't create executable memory (PR_SET_MDWE) | Compile the C file when you build, and load the library with dlopen() |
| A native addon that generates machine code, on Linux 6.7 or later | Whatever the addon does when mprotect or mmap fails | Run that code in a child process. Child processes aren't restricted |
Breakpoints, stepping, debugger statements | A breakpoint never binds | bun --inspect-brk ./app.ts, from your source files |
import(), require() or import * as of an HTML file | An error from the build | import page from "./page.html", as in a full-stack executable |
A query string in an import path: import "./a.ts?raw" | bun compile does not support a query string in an import path | Import the path without it |
importValue() of one of your modules in a ShadowRealm | SyntaxError: The module ... was compiled ahead of time and can't be imported inside a ShadowRealm | evaluate(), with the code as a string |
super() in the string of a direct eval, in a few classes | TypeError: super() cannot be called from direct eval in a compiled executable | Call it outside of the string. Only with private methods under useDefineForClassFields: false |
process.stdout and others, used only in a string you eval | Error: ... was not compiled into this executable because your code never uses it, when the string runs | Use it in your own code too |
bun --aot with -e or -p | An error | Run under the compiler's rules has the list |
--inspect in BUN_OPTIONS, BUN_INSPECT | An error when the executable starts | Build with --compile-inspector |
--preload in BUN_OPTIONS | An error when the executable starts | Import the file from your entry point |
banner or footer with code | An error from the build | Plugins and build options |
Libraries that are known not to work#
These packages work under bun run and don't work in an executable. Each does something that bun compile rules out on purpose. The table says what that is, with the line of the package that does it, and links to the rule. The versions are the ones that were tried.
Most of them end in the same TypeError. It names the object and the property, and its stack trace points to the line in the package. Where the table cuts a TypeError short with ..., it ends like this one:
TypeError: Cannot assign to Function.prototype.call$0 because built-in objects are read-only in a compiled executable (bun compile differs from bun run here: https://bun.com/docs/compiler/compatibility)| Package | What it does | What you see | Why | Instead |
|---|---|---|---|---|
sass 1.85.0, 1.106.1 | The Dart runtime inside it adds call$0, call$1 and so on to every function: Function.prototype.call$0 = function() { return this(); }; | On import: TypeError: Cannot assign to Function.prototype.call$0 ... | Function.prototype can't be changed | Compile your stylesheets before you build the executable |
zone.js 0.16.3, and Angular through it | Keeps the then of promises under a second name, then replaces it: proto[symbolThen] = originalThen; | On import: TypeError: Cannot assign to Promise.prototype.__zone_symbol__then ... | Promise.prototype can't be changed | Angular without zones: provideZonelessChangeDetection() |
private 0.1.8. Through it recast before 0.20.4, regenerator-transform before 0.14.5 and babel-core 6.26.3 | Copies call and apply onto the built-in functions that it uses: defProp(fun, "call", fun.call); | On import: TypeError: Cannot assign to 'call' of a built-in function ... | A built-in function can't have a call of its own | recast 0.20.4, regenerator-transform 0.14.5 and Babel 7 do without it |
asciidoctor 2.2.8, 3.0.4, and opal-runtime 3.0.0 | Opal, the Ruby runtime inside it, puts Ruby's classes between the built-in prototypes and Object.prototype: $set_proto(native_klass.prototype, (klass.$$super || Opal.Object).$$prototype); | On import: TypeError: Cannot change the prototype of Boolean.prototype ... | The prototype of a built-in object can't be changed | asciidoctor 4.1.1, which has no Ruby runtime |
ses 2.3.0 | Redefines the members of ArrayBuffer.prototype when it loads: defineProperties(arrayBufferPrototype, getOwnPropertyDescriptors(immutableArrayBufferLibProperties)); | On import: TypeError: Cannot assign to ArrayBuffer.prototype.byteLength ... | Existing members can't be redefined | To guard against prototype pollution, freeze the objects yourself: Object.freeze(Object.prototype) works |
require-in-the-middle 8.0.1, and @opentelemetry/instrumentation 0.300.0 through it | Replaces require to patch modules as they load: this._require = Module.prototype.require = function (id) { | No error. The hook never runs, so nothing is instrumented | A require() of a constant path calls no hook | Build with --no-aot. The hook then runs for built-in modules |
pirates 4.0.7. Through it @babel/register 7.25.9 and sucrase/register 3.35.0 | Replaces the loader of .js files to transpile them as they load: loaders[ext] = Module._extensions[ext] = function newLoader(mod, filename) { | When the hook is added: error: Cannot replace a loader in Module._extensions: ... | The loader of a compiled file can't be replaced | Remove it. The build compiles TypeScript and JSX |
module-alias 2.2.3, tsconfig-paths 4.2.0 | Replaces the resolver, so that require("@lib/other.js") finds a file: Module._resolveFilename = function (request, parentModule, isMain, options) { | From the build: warn: TS100150: Could not resolve: "@lib/other.js". At the require(): error: Cannot find module '@lib/other.js' from ... | Constant paths are resolved at build time | paths in tsconfig.json, or imports in package.json |
async-listener 0.6.10, and continuation-local-storage 3.2.1 through it | Wraps setTimeout and other globals in a helper that takes the object and the name as arguments: massWrap(global, asynchronizers, activatorFirst); | On import: TypeError: Cannot assign to setTimeout ... | The compiler can't tell which object the assignment goes to | AsyncLocalStorage from node:async_hooks, or cls-hooked 4.2.2 |
core-js 2.6.12 | require("core-js") also loads extensions of its own, one of them for functions: $export($export.P + $export.F, 'Function', { part: require('./_partial') }); | On import: TypeError: Cannot assign to Function.prototype.part ... | Function.prototype can't be changed | core-js/shim, babel-polyfill 6.26.0, @babel/polyfill 7.12.1 or core-js 3 |
es5-shim 4.6.7, and airbnb-js-shims 2.2.1 through it | Replaces forEach and other methods of arrays: defineProperties(ArrayPrototype, { forEach: function forEach(callbackfn/*, thisArg*/) { | On import: TypeError: Cannot assign to Array.prototype.forEach ... | Existing members can't be replaced | Remove the import. Bun implements what it fills in |
es6-shim 0.35.8 | Replaces toString of regular expressions: defineProperty(RegExp.prototype, 'toString', function toString() { | On import: TypeError: Cannot assign to RegExp.prototype.toString ... | RegExp.prototype can't be changed | Remove the import. Bun implements what it fills in |
array.prototype.map 1.0.8, and array.from, array.prototype.every, filter, foreach, reduce and some | require("array.prototype.map/auto") and shim() replace the method: define(Array.prototype, { map: polyfill }, { map: function () { return Array.prototype.map !== polyfill; } }); | At that call: TypeError: Cannot assign to Array.prototype.map ... | Existing members can't be replaced | Call what the package exports: require("array.prototype.map")(list, f) |
set.prototype.union 1.1.3, and difference, intersection and issubsetof | /auto and shim() give the iterators of sets a Symbol.iterator of their own: addIterator(Object.getPrototypeOf(new OrigSet().values())); | At that call: TypeError: Cannot assign to %SetIterator%.prototype[Symbol(Symbol.iterator)] ... | A well-known symbol can't be added | Call what the package exports: require("set.prototype.union")(a, b) |
string.prototype.matchall 4.1.0 | /auto and shim() define a property of Symbol whose key is Symbol.matchAll: defineP(Symbol, symbol, { | At that call: TypeError: Cannot assign to Symbol[Symbol(Symbol.matchAll)] ... | A well-known symbol can't be added | Call what the package exports: require("string.prototype.matchall")(text, pattern) |
url-polyfill 1.1.13 | Adds a helper to the built-in class: Object.defineProperty(proto, '_fromString', { | On import: TypeError: Cannot assign to URLSearchParams.prototype._fromString ... | URLSearchParams.prototype is read-only | Remove the import. URL and URLSearchParams are built in |
promise 8.3.0 | require("promise/polyfill") adds done to the built-in promises: Promise.prototype.done = function (onFulfilled, onRejected) { | On import: TypeError: Cannot assign to Promise.prototype.done ... | Promise.prototype can't be changed | Remove the import, and use then |
harmony-reflect 1.6.2 | Replaces functions of Object: Object.preventExtensions = function(subject) { | On import: TypeError: Cannot assign to Object.preventExtensions ... | Existing members can't be replaced | Remove the import. Reflect and Proxy are built in |
sugar 2.0.6, 1.5.0 | Sugar.extend() copies its functions onto the built-in objects, over the ones that exist: setProperty(target, name, method); | When extend() is called: TypeError: Cannot assign to Object.values .... In 1.5.0 on import, for Array.prototype.at | Existing members can't be replaced | The functions on Sugar itself: Sugar.Array.sum(list). Sugar.Date.extend() works |
collections 5.1.13 | Replaces functions of Object: Object.values = function (object) { | On import: TypeError: Cannot assign to Object.values ... | Existing members can't be replaced | None |
prototype 0.0.5 | Copies its functions onto Object, over the ones that exist: destination[property] = source[property]; | On import: TypeError: Cannot assign to Object.values ... | Existing members can't be replaced | None |
chai 4.5.0, 6.3.0 | chai.should() and chai/register-should define a getter for every object: Object.defineProperty(Object.prototype, "should", { | At that call: TypeError: Cannot assign to Object.prototype.should ... | Nothing can be added to Object.prototype | expect and assert |
should 13.2.3 | Defines the same getter, and ignores the error: var prevShould = should$1.extend(defaultProperty, defaultProto); | Nothing on import. At the first .should: TypeError: undefined is not an object (evaluating 'undefined.be') | Nothing can be added to Object.prototype | require("should/as-function") |
must 0.13.4 | Defines a getter for every object: Object.defineProperty(Object.prototype, "must", { | On import: TypeError: Cannot assign to Object.prototype.must ... | Nothing can be added to Object.prototype | require("must/must") |
timezone-mock 1.3.6 | register() replaces a method of every object: Object.prototype.toString = mockDateObjectToString; | When register() is called: TypeError: Cannot assign to Object.prototype.toString ... | Object.prototype can't be changed | Set the time zone itself: process.env.TZ = "US/Pacific" |
clear-module 4.1.2 | Takes a module out of the cache, so that the next require() runs it again: delete require.cache[filePath]; | No error. The next require() returns the same exports | A module of the executable runs at most once | None |
@js-temporal/polyfill 0.5.1 | Its README has you replace a built-in method: Date.prototype.toTemporalInstant = toTemporalInstant; | From the build: error: TS100014: this assignment always throws: built-in objects are read-only in a compiled executable | Existing members can't be replaced | Leave the line out. The rest of the polyfill works, and Temporal is built in |
next 16.4.2 | Puts a Date of its own in the place of the built-in one, and catches the error: Object.defineProperty(originalConstructor.prototype, 'constructor', { | No error. Each time it starts, it logs a line that begins with Failed to install, and keeps the built-in Date | Existing members can't be replaced | None. Only cacheComponents uses it |
The rows about built-in objects apply to bun run --aot and bun test --aot too, with the same messages. The row of async-listener and the rows about the loader and the module cache don't: there your program runs from its source files.
The lines of sass and asciidoctor belong to a language runtime, not to the package's own code. Other packages that are compiled from Dart with dart2js, or from Ruby with Opal, can stop at the same place.
Two old benchmarks add a method to Object.prototype. deltablue of Octane defines Object.prototype.inheritsFrom at the top level of its file, so the build fails with TS100014. string-tagcloud of SunSpider assigns Object.prototype.toJSONString and throws the TypeError when it starts.
These change built-in objects too, in ways that are allowed. They load, and change the same members as under bun run: core-js 3.51.0 (all of it, core-js/stable and core-js/actual), @babel/polyfill, babel-polyfill, react-app-polyfill, regenerator-runtime, es7-shim, reflect-metadata, symbol-observable, zen-observable, colors, date-utils, temporal-polyfill, mockdate, timekeeper, jest-date-mock, and the other packages of es-shims. So does sinon, fake timers included. A sinon.stub() of a read-only member, such as Array.prototype.push, throws like an assignment.
Type errors are TypeScript 7's#
The build reports the errors that tsc from TypeScript 7 reports, like bun check, whichever version of typescript your project has installed. A project that passes TypeScript 5 or 6 can still have errors. Coming from TypeScript 5 lists the options and defaults that changed.
TypeScript 7 also stopped reading five older patterns in JavaScript files. They still run. But when a TypeScript file imports one of your own .js files that uses them, with import type too, it no longer sees what TypeScript 6 saw, and the build stops at the TypeScript file.
In the .js file | The error in the TypeScript file | Write instead |
|---|---|---|
function f() { return arguments.length }, called with arguments | TS2554: Expected 0 arguments, but got 3. | A rest parameter: function f(...values) {} |
A constructor function: function Old(v) { this.value = v } and new Old(3) | TS7009: 'new' expression, whose target lacks a construct signature | A class with a constructor |
The same function named as a type: (old: Old), InstanceType<typeof Old> | TS2749: 'Old' refers to a value, but is being used as a type here. and TS2344: Type '(v: any) => void' does not satisfy the constraint | A class with a constructor |
A method added later: Grown.prototype.extra = function () {} | TS2339: Property 'extra' does not exist on type 'Grown'. | A method in the body of the class |
/** @enum {number} */ const Level = { Low: 1 }, with Level used as a type | TS2749: 'Level' refers to a value, but is being used as a type here. | /** @typedef {number} Level */ above the object |
module.exports = Main followed by module.exports.helper = ... | TS2339: Property 'helper' does not exist on type 'typeof Main'. | Main.helper = ... first, then module.exports = Main |
The build doesn't report type errors inside a .js file, so only the TypeScript files that use it are affected.
Files without import or export#
For TypeScript, a .ts file that has no import and no export is a script, and everything at its top level is global. "moduleDetection": "force" turns that off. bun init writes it, and the build assumes it if there's no tsconfig.json.
Bun runs every file as a module, whatever moduleDetection says. So a variable, a function or a class of such a file exists only in that file. Its types follow tsc, and its values follow what runs:
| At the top level of the file | tsc and bun check | The build |
|---|---|---|
An interface, a type, anything with declare | Global. It adds to a global declaration of the same name: interface Array<T> { last(): T } | The same |
One of those that can't add to the global declaration: type Node beside the Node of the DOM types, declare var self: Worker beside their self | TS2300: Duplicate identifier 'Node'., TS2403: Subsequent variable declarations must have the same type. | No error. In that file the name is the file's own |
var, let, const, function, class, enum, a namespace with code in it | Global. Another file can use it without an import | Another file gets TS2304: Cannot find name. Under bun run it throws a ReferenceError |
A class, an enum or a namespace with code in it, named like a global declaration of another file | The two merge | They don't. It has the members that it has at run time, so the others are TS2339, TS2345 or TS2694 |
A name that is both, like interface Point beside const Point, counts as code. To use it in another file, export it and import it there.
An interface Request or interface Node of your own in such a file adds to the one of the DOM types or of @types/bun, so tsc, bun check and the build all report what is missing from your objects. An export {} at the end of the file, or "moduleDetection": "force", makes the name the file's own.
Every script that your tsconfig.json includes counts, as for tsc, also one that the entry point never imports. Two scripts of one project that each declare interface Options share one Options.
Built-in objects are read-only#
The compiler turns array.push(x) into a few instructions without first checking whether somebody has replaced push. So nobody can. Replacing an existing member of a built-in object isn't supported. Adding a new one is fine, with the exceptions in the table.
function attempt(change: () => unknown): void {
try {
console.log(change());
} catch (error) {
console.log((error as Error).name);
}
}
attempt(() => ((Array.prototype as any).last = 1));
attempt(() => ((Array.prototype as any).push = 1));
attempt(() => delete (String.prototype as any).slice);
attempt(() => Reflect.set(Array.prototype, "push", 1));
attempt(() => ((Object.prototype as any).extra = 1));
attempt(() => ((Date as any).tomorrow = 1));bun compile ./builtins.ts && ./builtins1
TypeError
TypeError
false
TypeError
1| Object | Throws a TypeError | Works |
|---|---|---|
Object.prototype, RegExp.prototype, Promise.prototype, the prototypes of generators | Every change | The same on your own object or subclass |
Function.prototype | Every change but the ones on the right | Replacing toString. Adding demethodize or unThis, as core-js does. Adding a property under a symbol that you made |
The prototypes of Array, String, Number, Boolean, BigInt, Symbol, Map, Set, WeakMap, WeakSet, Iterator, the built-in iterators, ArrayBuffer, SharedArrayBuffer, DataView, Date, the typed arrays and Buffer | Replacing, redefining or deleting a member. Adding a numeric key, a well-known symbol, a name that the object inherits, or a name that the language looks up by itself. Changing the object's prototype | Adding a property under a new name: Array.prototype.last, "text".red, BigInt.prototype.toJSON |
Math, JSON, Reflect, Atomics. The constructors Array, Object, String, Number, Boolean, BigInt, Symbol, Map, Set, WeakMap, WeakSet, Promise, Proxy, RegExp, Date, ArrayBuffer, SharedArrayBuffer, the typed arrays and Buffer | Replacing, redefining or deleting a member. Adding a well-known symbol or a name that the object inherits, such as call or toString. Changing the object's prototype | Adding a property under a new name, a number or a symbol that you made: Math.tau, Object.myHelper, Array[1], Symbol.observable. Changing it and deleting it again |
The built-in functions themselves, constructors too: Math.max, Array.prototype.map, Object.prototype.hasOwnProperty, parseInt, console.log, Error | Giving the function a call, apply, bind or Symbol.hasInstance of its own: Math.max.call = f. Changing its prototype | Adding a property under any other name. All of it on your own functions, classes and bound functions |
createRequire and Module of node:module | Replacing, redefining or deleting it | Calling it, and keeping it in a variable. The other members of node:module follow the rule of the last group |
Everything else: Error and the other error constructors, Function, Intl, Temporal, WebAssembly, console, process, fetch | An assignment from code in a string or a module loaded at run time to a member that your compiled code never assigns | Every assignment in the code that you compile |
A few members of the third and fourth groups can be replaced, because well-known packages do: String.prototype.at, trim, trimStart and trimEnd of strings, the HTML methods of strings such as bold and link, the methods of Set that take another set, such as union, Math.random, Math.sign, Math.sinh, Math.sumPrecise, JSON.parse, JSON.stringify, Date.now, Date.parse, Date.prototype.toString, toLocaleString, toLocaleDateString and toLocaleTimeString of dates, Number.parseFloat, Number.parseInt, Map.groupBy, Promise.try, RegExp.escape, Uint8Array.fromBase64, Uint8Array.fromHex and Buffer.poolSize. They follow the rule of the last group. toJSON can be added to Map.prototype and Set.prototype, and toLocaleString to a typed array constructor. The Reflect functions return false where an assignment would throw. Object.defineProperty() throws like an assignment. An assignment or a delete throws in sloppy-mode code too, which is what most CommonJS packages are. A package always finds out that it didn't replace the member.
In the last group, an assignment counts when the compiler can tell which object it goes to: console.log = f, const con = console; con[name] = f. If your code gets hold of console in a way that the compiler can't follow, such as out of a JSON.parse reviver, an assignment through that value to a member that nothing else assigns throws the same TypeError, with the member's name. Assign through the global name instead. delete and Object.defineProperty follow the same rule as an assignment.
The same goes for the global object as this. A constructor function that starts with if (!(this instanceof F)) return new F(...) can be called without new, and behind that test the compiler takes this for an instance of F. The global object passes the test only if you set F.prototype = Object.prototype. Then an assignment such as this[key] = value in that function goes to the global object in a way that the compiler didn't follow: it throws the same TypeError.
Object.freeze(), Object.seal() and Object.preventExtensions() work on all of these objects as they do under bun run, so a server can freeze Object.prototype when it starts, to guard against prototype pollution. So does an Object.defineProperty() that repeats what a member already is, or that only makes it read-only or non-configurable.
The build reports each such assignment that it can see as TS100014. It's an error if the assignment always runs, and a warning if it only might. The built-in objects of a node:vm context or a ShadowRealm that your program creates can be changed: each has its own.
A global that can't be replaced can't be shadowed either. A script that you run with vm.runInThisContext() or new vm.Script() shares its top-level let, const and class declarations with the whole program, so let Math = ... at the top of such a script throws a SyntaxError. Use another name, move the declaration into a function or a block, or run the script with vm.runInNewContext().
Bun and process#
An executable always runs on Bun, on the platform that you built it for. A test that asks which runtime this is has one answer, so the compiler answers it at build time. The code for the other runtimes isn't compiled, and what only that code imports isn't in the executable and doesn't have to be installed.
const isBun = typeof process !== "undefined" && "bun" in process.versions;
export function open(path: string) {
if (isBun) return new (require("bun:sqlite").Database)(path);
return new (require("better-sqlite3"))(path);
}| Answered at build time | The answer |
|---|---|
typeof Bun, typeof process, globalThis.Bun, globalThis.process, "Bun" in globalThis | Two objects |
process.isBun | true |
process.versions.bun, process.versions.node, "bun" in process.versions, typeof process.versions | The versions of the Bun that built the program |
process.release.name | "node" |
process.platform, process.arch | Those of the --target |
typeof require, typeof module | Depends on whether the file is a CommonJS module |
It makes no difference whether there is a ?. on the way, as in process?.versions?.bun. A const, or a let or var that is assigned once, keeps the answer for the functions that read it.
Only the globals count, and the process of import process from "node:process", which is the same object. A parameter, a variable or an import that is called Bun or process is read when the program runs, like any other.
So that the answers stay true, an executable can't replace what gives them. Assigning to, redefining or deleting the global process, or process.isBun, process.versions and process.release, throws the TypeError above. process.versions.bun, process.versions.node and process.release.name are read-only, so a program that overwrites process.versions.node to pass for another version of Node.js can't do that in an executable. The rest of the two objects works as it does under bun run: a loader can still add process.versions.pnp. The global Bun is read-only under bun run too. Every other property of process and Bun follows the rule of the last group.
New properties on built-in prototypes#
You can add a new property to the objects of the third group and to Error.prototype. It doesn't matter who adds it: your compiled code, code in a string or an --external package. Compiled code finds the property as it does under bun run. list.last finds an Array.prototype.last, and this.node = node in a class that extends Error runs a setter called node on Error.prototype.
The exceptions are the names that the language looks up by itself. A prototype of the third group that doesn't have such a name never gets it:
| Name | Looked up by | Can't be added to |
|---|---|---|
then | await, and a promise that is resolved with the object | The prototypes whose instances are objects: the third group without String, Number, Boolean, BigInt and Symbol |
toJSON | JSON.stringify | The same prototypes, except Map.prototype and Set.prototype |
next, return, throw | for...of, spread, destructuring and yield*, on the object that [Symbol.iterator]() returned | Every prototype of the third group, but next on Date.prototype |
TypeError: Cannot assign to Array.prototype.next because built-in objects are read-only in a compiled executable (bun compile differs from bun run here: https://bun.com/docs/compiler/compatibility)Put the method on your own object or class instead. A class that extends Array can have a then, a toJSON or a next.
Request, Response and five other classes#
The prototypes of Request, Response, Headers, Blob, URL and URLSearchParams are read-only too. So is the prototype of the request objects that Bun.serve passes to your routes handlers. File.prototype is the same object as Blob.prototype.
Use a subclass. It has a prototype of its own.
try {
(Response.prototype as any).text = async () => "patched";
} catch (error) {
console.log(String(error));
}
class Upper extends Response {
override async text(): Promise<string> {
return (await super.text()).toUpperCase();
}
}
console.log(await new Upper("hello").text());bun compile ./response.ts && ./responseTypeError: Cannot assign to Response.prototype.text because built-in objects are read-only in a compiled executable (bun compile differs from bun run here: https://bun.com/docs/compiler/compatibility)
HELLOA subclass can override methods and getters such as text() and ok, add fields and call super.
These also work as usual:
- a property on one instance:
Object.defineProperty(response, "url", { value }) - replacing a static function:
URL.createObjectURL = stub - replacing the global:
globalThis.Response = Upper - a polyfill that checks first:
Headers.prototype.getSetCookie ??= ...assigns nothing, so nothing throws
Packages that change built-in objects#
A few packages change built-ins, mostly to support old browsers or to offer a shorter syntax in tests. Libraries that are known not to work lists the ones that stop, what you see, and what to use instead. It also lists well-known ones that load.
Code from strings#
eval, new Function and node:vm work, and so does a Worker made from a string. The build can't see their code, and the executable has no JIT, so Bun interprets it.
const add = new Function("a", "b", "return a + b");
const local = 5;
console.log(add(2, 3), eval("local * 2"), (0, eval)("typeof Bun"));bun compile ./strings.ts && ./strings5 10 objectThat's fine for a template or a configuration file. It matters when a library generates its hot code that way: zod 4, ajv, fast-json-stringify and Elysia do. If the library has a mode without generated code, measure both: z.config({ jitless: true }), called before you create a schema, makes parse in zod 4 cheaper, while new Elysia({ aot: false }) makes a request slower. For Fastify, see below.
If your program never calls eval or new Function, the compiler assumes it has seen all the code that will ever run (a closed-world assumption, like GraalVM Native Image) and optimizes accordingly.
It finds these calls by tracking the functions as values, so const run = eval, Reflect.construct(Function, [code]), (() => {}).constructor(code) and new Proxy(Function, {})(code) all count.
A direct eval in strict code doesn't count if its string is known at build time and is only the name of a variable, as in eval("require"), or eval("quire".replace(/^/, "re")) in the @protobufjs/inquire package. It's compiled as a read of that variable.
Only a call counts. Naming eval or Function, storing one in an object or passing one to another function of your program costs nothing by itself. Neither does a call to a function that the compiler knows nothing about: it doesn't assume that such a call might be eval. The exception is a program that hands eval or Function to something that the compiler can't follow, such as the resolve function of Promise.withResolvers(), a proxy or the detail of an event. There it counts as called.
What it doesn't track is an eval or a Function that your program reads under a computed key, as in object[name](code) or globalThis[name](code). That holds for your own objects too. A table such as { "%eval%": eval, "%parse%": parse }, like the one in the get-intrinsic package, costs nothing however it's read, and table["%eval%"](code) counts as a call while table[name](code) doesn't. It doesn't track the third argument of Reflect.get(target, key, receiver) into the getter either, so this.constructor(code) in a getter that runs on a function that way doesn't count. In a program with no other such call, that one throws:
EvalError: Code generation from strings disallowed for this context: the compiler couldn't tell at build time that this call reaches eval() or the Function constructor. Call eval() or new Function() directly.A few other ways to load code follow the same rule. They work where the compiler finds the call, and throw where it doesn't.
Bun.pluginrequireandprocess.getBuiltinModuleused as values, in a program where every import path is a constant
Code in a string can use any API, with one exception. Some of process, console and Bun is written in JavaScript, and the executable only contains the APIs that your program uses. If a string is the only thing that uses one of the others, that use throws:
const text = process.argv[2] ?? "process.stdout.write('hi\\n')";
try {
new Function(text)();
} catch (error) {
console.log(String(error));
}Error: None of console._stderr, console._stdout, process.stderr or process.stdout was compiled into this executable because your code never uses them. To keep them, use one of them somewhere in your code. (bun compile differs from bun run here: https://bun.com/docs/compiler/compatibility)The error is catchable. Using process.stdout anywhere in your own code fixes it.
This affects process.stdout, stderr, stdin, openStdin(), finalization, allowedNodeEnvironmentFlags and loadEnvFile(), console.Console and console.write(), and Bun.$, Bun.sql, Bun.SQL and Bun.peek().
A function of a node: module that your program imports and never calls isn't compiled either. A string rarely finds one, but it can: on an object such as process that inherits from EventEmitter, or by loading the module itself, as in process.getBuiltinModule("node:http").request(url) in a program that only has import { createServer } from "node:http". The call then throws '...' was not compiled into this executable because your code never calls it, with the function's name.
process.mainModule is undefined in a string unless a file of your program names it, as it is in every executable built without --aot. Besides import() of an entry point, it's the only path from a string to your modules, so without it the compiler knows every caller of what the other files export. require.cache is no such path: it lists none of them.
A string can import() an entry point of the build by its path. A JSON file or another data file that's compiled into the executable is different: a string finds it only if your program has a dynamic import() or require() of its own. Otherwise the import rejects with ERR_MODULE_NOT_FOUND, as in an executable built without --aot.
Everything that belongs to the language always works in a string: Array, Map, Promise, JSON, regular expressions, generators. So does everything that Bun implements natively, such as fetch, Bun.file and Bun.serve.
It's always the call that throws. Importing node:vm, node:worker_threads, node:repl or bun:ffi costs nothing and never fails. inspector.open() from node:inspector starts no debugger and doesn't throw, unless you build with --compile-inspector.
If your program needs none of it, --disallow-code-generation-from-strings turns it off in the executable, like the Node.js flag of the same name.
bun compile --disallow-code-generation-from-strings ./strings.ts && ./stringsEvalError: Code generation from strings disallowed for this context
at /$bunfs/root/strings.ts:1:17With the flag, a call to eval or new Function can only throw, so the compiler treats your program as if the call ran nothing. It knows every caller of your functions again, which means less code to compile and more that it can optimize, and --trim can remove the interpreter. A library that tries new Function inside a try, such as zod 4, falls back to plain code. A library that can't work without generated code, such as Elysia, throws the EvalError when it starts.
That fallback is faster here. zod 4 generates a parser for each object schema, and an executable has to interpret it. The plain code it falls back to is compiled. A POST route that checks an order with ten items (600 bytes of JSON) with zod, in requests per second on one core:
| Server | Without the flag | With the flag | |
|---|---|---|---|
Bun.serve | 47,081 | 50,364 | +7.0% |
| Hono | 42,906 | 45,375 | +5.8% |
node:http | 37,501 | 38,649 | +3.1% |
| Express | 23,639 | 25,278 | +6.9% |
z.config({ jitless: true }) does the same for zod alone, without the flag.
With =strict, code from the disk fails with the flag's error too. require() and import() of a file that isn't in the executable throw EvalError: Code generation from strings disallowed for this context. --preload, -r, --require, --import and BUN_INSPECT_PRELOAD end the process with that error and exit code 1. An executable built without --aot does the same. Without the flag, you get the messages that say what to do.
Pass the flag when you build. BUN_OPTIONS can turn it on too, but that's too late for a string that's a constant. The compiler has compiled (0, eval)("1 + 1") and new Function("return 2") like the rest of your program, so they still run. A string that's put together at run time throws.
Fastify#
Fastify generates code from strings in three places:
- The router. find-my-way generates a few short functions for each route when the server starts, and a request calls two or three of them. That's 2 to 7% of a request. You can't turn it off.
- Response schemas. fast-json-stringify generates a serializer for each
schema.response. - Request schemas. ajv generates a validator for each
schema.body,schema.querystring,schema.paramsandschema.headers.
The serializers and validators run on every request, and interpreting them can cost more than the rest of the request. A route that returns 3.5 KB of JSON runs six times as many instructions with a response schema as without one. You can keep the schemas and avoid both.
Serialize with JSON.stringify.
const app = Fastify();
app.setSerializerCompiler(() => JSON.stringify);In Bun, JSON.stringify is faster than the generated serializer even with the JIT. The compiled server answers that route more than twice as often per second. There's one difference: fast-json-stringify leaves out properties that aren't in the schema, and JSON.stringify writes every property of the object you return.
Write the validators to files before you compile. @fastify/ajv-compiler can give you the source code of a validator instead of calling new Function. Save it to a file, and Bun compiles it with the rest of your program.
import { StandaloneValidator } from "@fastify/ajv-compiler";
import Fastify from "fastify";
import { writeFileSync } from "node:fs";
const writes = process.argv[2] === "write";
const buildValidator = writes
? StandaloneValidator({
readMode: false,
storeFunction: (route, code) => writeFileSync("./order-validator.js", code),
})
: StandaloneValidator({
readMode: true,
restoreFunction: route => require("./order-validator.js"),
});
const app = Fastify({ schemaController: { compilersFactory: { buildValidator } } });
app.post("/orders", { schema: { body: orderSchema } }, placeOrder);
await app.ready();
if (writes) process.exit(0);
await app.listen({ port: 3000 });bun add @fastify/ajv-compiler ajv
bun ./server.ts write
bun compile ./server.tsroute has the method, the url and the httpPart ("body", "querystring", ...) that the validator is for. If you have more than one schema, name each file after them. Give each require() a constant path, because a module behind a computed path isn't compiled. The validators import helpers from ajv, so it has to be installed.
With both changes, a route that validates an order of ten items answers about 1.6 times as many requests per second, the same as without schemas.
WebAssembly#
A .wasm file that your program imports is compiled at build time.
import path from "./add.wasm";
const bytes = await Bun.file(path).arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes);The executable recognizes the module by its bytes. So it makes no difference how your program reads the file, or which function of WebAssembly it passes the bytes to.
Bun interprets every other module: bytes that your program downloads, decodes from a string or assembles itself. The executable has no JIT, so a tight loop runs about 20 times slower there.
On some machines, and with some memories, the executable can't use the compiled code of a module. The run-time log says so.
Dynamic imports#
bun compile compiles every module that the entry point reaches through an import or require. When an import path isn't a constant, the compiler uses the parts of it that are.
const wanted = process.argv[2] ?? "csv";
const plugin = await import("./plugins/" + wanted + ".ts");
console.log(plugin.name);export const name: string = "csv";export const name: string = "json";Every path here starts with ./plugins/ and ends with .ts, so the compiler includes every such file below plugins/. The executable contains both plugins and runs without your source files.
bun compile ./app.ts --outfile elsewhere/app && cd elsewhere && ./app jsonjsonThe require doesn't have to be called under its own name. The compiler follows it into a variable that's never assigned again, and into a parameter of a function that's called where it's written. That covers the wrapper that TypeScript writes for "module": "umd", which an executable built without --aot doesn't follow.
(function (factory) {
if (typeof module === "object" && typeof module.exports === "object") factory(require, exports);
else if (typeof define === "function" && define.amd) define(["require", "exports", "./part"], factory);
})(function (require, exports) {
exports.part = require("./part");
});A path that's one of a few, like require(fast ? "./fast.ts" : "./small.ts"), adds those files and no others. Native addons lists the forms that the compiler follows.
A path with no constant start, like require(name), adds nothing to the executable. Of your own files it finds the entry points, by the path the file had at build time, as in an executable built without --aot. A file that's only in the executable because another file imports it isn't found, even after it has run.
error: Cannot load '/$bunfs/root/inside.ts': this executable can't load a module from a dynamic path. Pass the file to bun compile as another entry point.To load a file by a dynamic path, pass it as an additional entry point. An entry point can be a glob pattern.
bun compile ./app.ts './extra/*.ts' --outfile appA package that's loaded by a dynamic name, like the database driver of an ORM, works the same way. Pass the file that the package's main or exports points to. require(name) and import(name) then find it by the name of the package, from every file that sees the package in its node_modules, and the executable doesn't need node_modules on disk.
bun compile ./app.ts ./node_modules/pg/lib/index.js --outfile appA package with exports is found by the names it exports, patterns like "./features/*" included. A package without exports is also found by the paths of its files, like pg/lib/client.
Paths that start at the file's folder#
The compiler knows that __dirname, import.meta.dir and import.meta.dirname name the folder of the file, and it follows path.join and path.resolve when their first argument is one of them. require(path.join(__dirname, "..", "package.json")) is compiled like require("../package.json").
Two things turn such a path into a dynamic path:
- The file changes
__dirname,__filenameorimport.meta. That includes handingimport.metato a function. Files that only read them aren't affected. - The program replaces
path.joinorpath.resolve. The executable compares the path that the call returned with the path that Bun's own function returns. If they differ,requirethrows andimport()rejects.bun runwould load whatever file the replacement named.
error: Cannot load './plugin.js': path.join or path.resolve was replaced and returned a different path. This executable can't load a module from a dynamic path.Built-in modules#
The same rule applies to node: and bun: modules. The executable contains the ones that your program, a package or another built-in module imports by a constant name.
A name doesn't have to stand in the call. If your program or a package loads a module by a name that the compiler can't work out, or hands require, module, process or import.meta to code that the compiler can't follow, the executable also contains every built-in module whose name is written as a string anywhere in the program, with or without node:.
| Code | In the executable |
|---|---|
function tryRequire(name) { try { return require(name) } catch {} }; tryRequire("zlib") | node:zlib |
function load(mod, name) { return mod.require(name) }; load(module, "perf_hooks") | node:perf_hooks |
for (const name of ["node:vm", "node:v8"]) process.getBuiltinModule(name) | node:vm and node:v8 |
function ask(p) { return p.getBuiltinModule("node:dgram") }; ask(process) | node:dgram |
A string doesn't count if it's only compared (name === "fs", case "fs":), used as a property key (table["fs"]), or kept in an array or a Set that the program only searches (names.includes(name), names.has(name)). No string counts in a program where every import has a constant name. A dynamic path that can't be the name of a built-in module, like require("./locales/" + name), doesn't make strings count either.
If a function passes its parameter to require(), import() or process.getBuiltinModule() and the program only ever calls that function directly, as with tryRequire above, the compiler takes the names from the calls. Such a function doesn't make other strings count.
A require(name) doesn't add the rest, so one such call in a package doesn't put node:repl and node:dgram into your server. A name that comes from outside of the program, or that's put together from parts, only finds a module that's in the executable for another reason.
import "node:path";
const name = process.argv[2] ?? "node:path";
console.log(Object.keys(require(name)).length > 0);bun compile ./load.ts && ./load node:path && ./load node:dgramtrue
error: Cannot load 'node:dgram': it isn't in this executable. Import it by name somewhere in your program to keep it. (bun compile differs from bun run here: https://bun.com/docs/compiler/compatibility)A built-in module that a dynamic name finds works like one that you import, even if it's only in the executable because another built-in module imports it.
To keep a built-in module whose name isn't written anywhere in your program, import it once by its name. The import costs nothing until the module is used.
import "node:dgram";The same goes for node:cluster. server.listen() in a cluster worker asks the primary for its socket, and that code is in the executable only if your program imports node:cluster, itself or through a package. A program that forks itself does. If another program starts your executable as a worker, with cluster.setupPrimary({ exec }), and your program never imports node:cluster, listen() fails with Cannot load 'node:cluster': it isn't in this executable instead of listening on its own. import "node:cluster" fixes it.
Some properties of process, Bun and console are written in JavaScript too: process.stdout needs node:tty, and Bun.sql needs bun:sql. The executable contains the ones your program can reach. Reading process[name] with a dynamic name, or storing process where the compiler can't follow it, keeps all of them.
The compiler follows a built-in module through variables, parameters, return values and properties of objects that your own code creates, so fs[name]() with a dynamic name keeps every function of node:fs wherever fs got there that way. It can't follow a module through an object that came from outside your code: after const box = event.detail; box.url = require("node:url"), box.url[name]() throws '...' was not compiled into this executable because your code never calls it, unless your program also calls that function by name or reads url[name] somewhere the compiler can follow. The same goes for a method of a class of a built-in module: after box.handle = await open(path), box.handle.readLines() works, and box.handle[name]() throws that error unless your program also calls readLines() by name or reads handle[name] on a handle the compiler can follow.
The code behind the IPC channel is kept for process.on("message", ...) only when the compiler can see that the call is on process: called on process itself, or on a variable or parameter that holds it. If process got there through a value the compiler can't follow, like error.cause, a message that carries a Buffer or a handle throws an error that names what's missing. Using process.send anywhere in your code keeps it.
Code that isn't in the executable#
A dynamic import() or require() can only load code that's in the executable. Anything else throws: JavaScript, TypeScript, WebAssembly or a native addon. So does everything that Bun would read as JavaScript or turn into it: a file with no extension, a file with an extension that Bun doesn't know, a Markdown file, and a data: or blob: URL.
error: Cannot load '/home/me/plugins/late.ts': it isn't in this executable, which only runs code that was compiled into it. Pass the file to bun compile as another entry point, or build with --aot-allow-code-from-disk.The error has the code MODULE_NOT_FOUND from require() and ERR_MODULE_NOT_FOUND from import(), like a file that doesn't exist. The build warns at each import where this can happen.
warn: TS100064: this import path isn't a constant. If it resolves to code that isn't in the executable, the import throws at run time (data files still load). Add the file as an entry point, or build with --aot-allow-code-from-disk| You want | Do this |
|---|---|
| The file compiled | Pass it as an additional entry point |
| One package loaded from disk | --external <package>. Bun interprets it, and everything it loads |
| Any file loaded from disk | --aot-allow-code-from-disk |
| Data | Nothing. JSON, JSON5, TOML, YAML, XML, text and CSS files run no code, so they load from disk as usual |
| A database or an HTML page | Import it by a constant path. import(name, { with: { type: "sqlite" } }) and require(name) of an .html file throw like code |
An --external package has require, module, require.cache and every built-in module, whole: the compiler can't see what the package calls, so a build with --external compiles every function of the built-in modules in the executable, not only the ones that your program calls. That makes the executable a few megabytes larger. The package doesn't see the modules of your program, as in an executable built without --aot: its module.parent has the path of the file that required it and empty exports, and require.cache lists the files that were loaded from disk. If a file of your program names a way between module objects, the package finds your modules through process.mainModule too. Built-in objects are read-only for it too: it can add a global or a property, and it can't replace one that your compiled code uses. globalThis.fetch = polyfill in such a package throws a TypeError if your program calls fetch.
createRequire(), import.meta.require() and module.require() follow the same rules. The compiler follows createRequire from node:module to the files that its require names, so a program can't replace createRequire.
new Bun.Transpiler() works and costs nothing: it turns text into text. A macro in that text is code that the build never saw, so it follows the rule for code from strings. Making a transpiler doesn't count as a call to eval, and in a program with no such call, transform(), transformSync() and scan() throw for a text that calls a macro:
error: Macros cannot be used in this executable, which only runs code that was compiled into itLoad code from disk#
A plugin, a config file, a tool that your user wrote: the build never sees them, because their paths are only known when the program runs. With --aot-allow-code-from-disk, a dynamic import() or require() loads such a file from disk, as in an executable built without --aot. It isn't compiled. Bun transpiles it and interprets it, without a JIT, like code from a string.
const plugin = await import(Bun.pathToFileURL(process.argv[2]).href);
console.log(plugin.default(20));import { cpus } from "node:os";
export default (n: number) => n * 2 + Math.sign(cpus().length);bun compile --aot-allow-code-from-disk ./host.ts && ./host ./plugin.ts41- Every kind of file loads that loads in an executable built without
--aot: JavaScript, TypeScript, JSX, JSON and the other data files, WebAssembly, a native addon, adata:orblob:URL. The path can be absolute, relative to the working directory, or afile:URL. - The file imports what it likes: files beside it, packages in a
node_moduleson disk, and everynode:andbun:module. It gets the samenode:fsand the sameEventEmitteras your program. A built-in module that isn't in the executable is interpreted too. Apackage.jsonon disk is only read if you build with--compile-autoload-package-json, as in any executable. - The two sides are one program. The file calls your compiled functions and they call its functions. Objects, classes, promises and exceptions cross in both directions. A type of yours says nothing about what such a file returns or stores, and nothing throws because of that.
Bun.pluginapplies to it.onResolve,onLoadandmodule()run for what the file imports, so a host can hand its own modules to a plugin:build.module("my-sdk", () => ({ exports: sdk, loader: "object" })).- It doesn't get the files of your program by path. An entry point is found, and it's the compiled module, which runs once. Any other file of the executable isn't, as described above.
- Built-in objects are read-only for it too.
The option has a price, and only a program that can reach such an import pays it:
- The executable is larger. It keeps Bun's transpiler and the source code of Bun's built-in modules. It also holds every function of the built-in modules that are compiled into it, not only the ones that your program calls. That's a few megabytes, and
--trimcan't remove the transpiler or the interpreter. - The program can run slower. The compiler can't see what the file does with what it's handed, so it proves less about the objects and functions that the file can get hold of. Those include everything that the built-in modules export. A server on
node:httpcan need up to a third more CPU time for a request.
A program without a dynamic import() or require() is the same executable with and without the option.
With the option, a name that comes from outside of the program finds every built-in module. One that isn't in the executable isn't compiled: Bun interprets it, like a file from disk.
process.getBuiltinModule(name) is the exception. It never loads a file, so it doesn't make the executable ready for code that wasn't compiled. In a program that has no other dynamic import, it only finds a module that's in the executable, with the option too.
error: Cannot load 'node:dgram': it isn't in this executable. Import it by name somewhere in your program to keep it. (bun compile differs from bun run here: https://bun.com/docs/compiler/compatibility)In Bun.build, the option is compile: { allowCodeFromDisk: true }.
The compiler also follows require under another name, as long as it can see that the name holds require. Where it can't, every path is a dynamic path, a string literal too.
| Call | Compiled like |
|---|---|
const load = require; load("./inside.js") | require("./inside.js") |
require.call(this, "./inside.js"), require.apply(this, ["./inside.js"]) | require("./inside.js") |
const load = require; load("./plugins/" + wanted + ".js") | require("./plugins/" + wanted + ".js") |
(r => r("./inside.js"))(require) | require(name): it finds the entry points |
({ load: require }).load("./inside.js") | require(name): it finds the entry points |
require.bind(null)("./inside.js") | require(name): it finds the entry points |
let load = require; load = other; load("./inside.js") | require(name): it finds the entry points |
A function that's called where it's written and returns require counts as another name too. That's the __require helper that esbuild puts into ES module bundles.
| Call | Compiled like |
|---|---|
var __require = (x => typeof require !== "undefined" ? require : x)(f); __require("./inside.js") | require("./inside.js") |
const load = (() => require)(); load("./inside.js") | require("./inside.js") |
With code splitting, esbuild and bun build export the helper from a shared chunk. The compiler follows require, import.meta.require and createRequire() from an export to an import for built-in modules, whose names mean the same in every file. Any other path is still a dynamic path there.
| Call | Compiled like |
|---|---|
import { __require } from "./chunk.js"; __require("node:zlib") | require("node:zlib") |
import { __require } from "./chunk.js"; __require("./inside.js") | require(name): it finds the entry points |
process.getBuiltinModule("node:fs") with a string literal is compiled like require("node:fs"), also on a variable that holds process or globalThis and is never assigned again. If your program assigns a function of its own to process.getBuiltinModule, as require-in-the-middle does, every call runs that function. The compiler still follows process.getBuiltinModule("node:module").createRequire(import.meta.url) to the files that its require names, so the executable compares the createRequire that it's about to call with Bun's own. If the replacement returned an object with a different one, the call throws Cannot call createRequire: process.getBuiltinModule was replaced and returned a different createRequire. Under another name, or on a process that came through a parameter or a property, the function loads the built-in modules that your program names. It can't see the function behind a computed key (process[key](name)): such a call throws Cannot load '...': this executable can't load a module from a dynamic path.
require() can't wait. If it reaches an ES module outside of the executable that has a top-level await, it throws a TypeError that tells you to use await import().
Frameworks that load your pages by path#
Next.js and Angular load your application's pages and routes from paths that they build while the server runs, so the compiler doesn't find them. Pass the pages as additional entry points, and they are compiled. Pass the rest of the build output with --asset, and the server reads it out of the executable.
# Next.js with `output: "standalone"`, in .next/standalone
bun compile ./server.js ".next/server/**/*.js" --asset .next --asset public --asset node_modules/@img --outfile serverThe result is one file that needs nothing else on the machine that runs it. Bun expands the quoted pattern itself. node_modules/@img holds sharp, which resizes the images of next/image; without it Next.js serves them at their original size. Next.js can't write its page cache into the executable, so it keeps the cache in memory. It can't store the images it has resized either, and logs Failed to write image to cache for each of them.
--compile-in-place builds an executable that only runs on the machine that built it: modules keep their paths on disk, and whatever wasn't compiled or embedded is read from there.
bun compile --compile-in-place ./server.js $(find .next/server -name '*.js') --outfile serverFor Angular the files are $(find dist/site/server -name '*.mjs' -not -name server.mjs). Nuxt, SvelteKit, Astro and React Router need no additional entry points. Pass what the server reads from disk with --asset:
bun compile ./.output/server/index.mjs --asset .output/public --outfile server # Nuxt
bun compile ./dist/server/entry.mjs --asset dist/client --outfile server # Astro
bun compile ./build/index.js --asset build/client --asset build/prerendered --outfile server # SvelteKit
bun compile ./server.ts --asset build/client --outfile server # React Router, with a server of your ownWithout the entry points, the first request fails with the error above.
Options that load code#
--preload and a few other options load code that the compiler never saw. What the compiler proved about your program only holds while nothing else runs, so an executable rejects them. The exception is an executable that's built with --aot-allow-code-from-disk and whose program has a dynamic import() or require(): it's ready for such code, so it takes --preload, -r and --import.
console.log("hello");console.log("before");bun compile ./greeting.ts && BUN_OPTIONS="--preload ./before.ts" ./greetingerror: --preload can't be used with this executable because it only runs code that was compiled into it. Rebuild it with --compile-inspector to load other codeThe executable exits with code 1, before any of your code runs. To run before.ts first, import it at the top of greeting.ts.
| A default build | Options |
|---|---|
| Rejects | --preload, -r, --require, --import, --eval, -e, --print, -p, --hot and --watch in BUN_OPTIONS, and BUN_INSPECT_PRELOAD |
| Rejects | BUN_INSPECT, and --inspect, --inspect-wait and --inspect-brk in BUN_OPTIONS |
| Ignores | --trace-exit, --trace-env, --trace-env-js-stack and the three --trace-event options in BUN_OPTIONS and in the execArgv of a Worker, with a warning |
| Ignores | preload and [serve.static] plugins in a bunfig.toml |
An executable reads the bunfig.toml of the directory it's started in. That file may be there for bun run, so what it would load is ignored and isn't an error. The build has bundled your pages, so no server needs the plugins.
A module of Bun's own library carries out the --trace options of the third row, and an executable has only the modules that your program uses. They print or write something for you to look at, and your program does the same without them. So the executable prints one line for each and runs your program. --stack-trace-limit works like in bun run.
warn: --trace-exit is ignored by this executable because it only runs code that was compiled into it. Rebuild it with --compile-inspector to load other codeIn --compile-exec-argv, the options of the first and the third row fail the build.
Three build flags change that.
| Flag | In Bun.build | The executable |
|---|---|---|
--compile-inspector | compile: { inspector: true } | Accepts all of the above, so that a debugger can attach. Bun interprets the code it loads |
--aot-allow-code-from-disk | compile: { allowCodeFromDisk: true } | Accepts --preload, -r and --import if its program has a dynamic import() or require() |
--no-compile-function-source | compile: { functionSource: false } | Rejects the options in the first row, with --compile-inspector too |
--compile-inspectorhas a price. A debugger can run any code in any scope, so the compiler can prove less about the program. The executable is larger and slower, and--trimremoves no features.- An option in
--compile-exec-argvis part of the build.--inspectthere needs no--compile-inspector. - Editors set
BUN_INSPECT_CONNECT_TOfor every process that you start from their terminals. An executable built without--compile-inspectorignores it and runs.
The module cache#
A module of the executable runs at most once. require.cache and Module._cache don't list it, and delete require.cache[path] does nothing to it. That goes for CommonJS modules, ES modules and JSON files. An executable built without --aot does the same.
globalThis.runs = (globalThis.runs ?? 0) + 1;
exports.runs = globalThis.runs;const path = require.resolve("./counter.cjs");
console.log(require("./counter.cjs").runs, path in require.cache);
delete require.cache[path];
console.log(require("./counter.cjs").runs);bun run ./again.cjs && bun compile ./again.cjs && ./again1 true
2
1 false
1Circular requires work as under bun run. A module that is required again while it's still running doesn't start over. The second require() returns its module.exports as it is at that moment, with the names that have been assigned so far. The rest show up as the module keeps running.
One case differs. A callback that you handed to other code may call require() on an ES module while that module is still running, without any import or require() leading from the module back to the file of the callback. The call then returns the module's namespace object, and reading an export that isn't initialized yet throws a ReferenceError. Under bun run that's what happens if an import started the module; if a require() started it, the call returns an empty object.
A CommonJS module that throws isn't tried again. The require() that started it throws. Every later require() of that module throws the same error, as it does for an ES module. Under bun run the CommonJS module would run again, so a program that repeats a require() until something outside of it has changed keeps getting the first error.
In one case nobody gets the error, under bun run and in an executable alike. The CommonJS module is the entry point or was started by an import, it calls require() on an ES module that imports it back, and then it throws. The ES module has already taken the names that were exported by then, so the import counts as done. Every later require() of the CommonJS module returns the namespace object of that import: its default is the module's module.exports, next to the names it had at that moment.
A file that your program loads from disk at run time isn't part of the executable: an --external package, or a config file that a package reads with require(). It's in require.cache, delete takes it out, and it runs again at the next require(), as under bun run.
A module of the executable can't be loaded a second time in another form. new Bun.ModuleGraph().import(url) of one rejects with ERR_MODULE_NOT_FOUND: a second graph would have to run the module again from its file. require(path, { type: "text" }) with a path that is put together at run time throws as well, because the file isn't there to be read. Use import(path, { with: { type: "text" } }) with a constant path: the text is then included at build time.
error: Cannot load '/$bunfs/root/app.ts': it's compiled into this executable, and a compiled module can't be loaded a second time (for example into a Bun.ModuleGraph).Loader hooks#
The compiler resolves every import and every require() of a constant path at build time, and compiles it to a call of the module. Nothing in node:module is asked on the way, so a hook doesn't see those.
| Hook | The assignment | Bun calls it for |
|---|---|---|
Module._resolveFilename, Module.prototype.require | Works | A require() or a require.resolve() of a dynamic path, for a name that the build didn't find |
Module._load, Module.wrap | Works | Nothing. bun run doesn't call Module._load either |
Module._extensions[".js"], and every other extension that a file in the executable has | Throws an Error |
Frameworks such as Next.js install the first two when they are loaded, to point a few names at their own copies of a package. In an executable the names that are constants already point where the build found them.
A loader for an extension that no file in the executable has is stored as under bun run. Packages such as handlebars register one for their own kind of file (require.extensions[".hbs"]) when they are required, and it changes nothing that was compiled.
Cannot replace a loader in Module._extensions: imports in this executable were resolved at build time, so the module loader can't be patched.Packages that patch the loader to transpile, trace or mock what a program requires don't see the modules of an executable. Run such a program with bun run, or build it with --no-aot.
Module objects#
module, require.main and process.mainModule work as under bun run, and so do children and parent of the objects they give you.
console.log(require.main === module, module.parent?.filename);
for (const child of module.children) console.log(child.id, Object.keys(child.exports));require.main, process.mainModule, module.children and module.parent are the only ways from the object of one module to the object of another. The compiler looks for them in the text of your program: module.children, const m = module; m.parent, show(require.main). What it can answer at build time doesn't count: require.main === module, require.main.filename, and !module.parent in most files.
- If any file names one of the four, all four work everywhere, also in a string that you
evaland in an--externalpackage. It doesn't matter whether the code that names it ever runs. - If no file does, all four are
undefinedfor everybody, as in an executable built without--aot. That only shows where your code gets to them in a roundabout way:module[key]with a computed key, or amodulethat came out of an array or back from native code. The rest of such an object works:module.exports,module.id,module.filename,require.resolve(), andrequire(path), which finds the entry points.
On an object that the compiler didn't expect anybody to reach, children is undefined if it would have to list a module for which the compiler keeps no exports object, and parent likewise.
import.meta.require, require in an ES module and a function that createRequire() makes for the file that calls it count as the require of that file. A file that they load has the module object of the calling file as its parent for as long as the program runs, and a CommonJS file lists it in its own module.children. Under bun run each of these functions has a module object of its own: that object is the parent and lists the children, and once nothing else holds it, it is collected and parent reads undefined.
If the four always worked, one require that a package stores in a table would make the compiler keep an exports object for every CommonJS module of your program. Most need none.
Re-exports of CommonJS modules#
The names that a CommonJS module exports are only known once it has run. The same goes for a JSON file, a built-in module and a module outside of the executable. When an ES module re-exports one of these with export *, one rare case differs from bun run. It is about the namespace object that import * as ns gives you. Named imports work the same.
A file has export * for two ES modules that both export x. That makes x ambiguous, so the namespace object of the file has no x. If a third export * of that file re-exports a CommonJS module that exports x as well, the compiled namespace object has the CommonJS value.
Workers#
The compiler finds the file of a worker whose path your code writes out, and compiles it, with everything it imports, as one more entry point. Any other worker you pass as an additional entry point, like in any executable.
A Worker has its own global object, so the compiler treats each entry point as a separate program when your code starts Workers. What a worker does to a class, a prototype or a shared module doesn't slow down the main thread's code, and the other way around. A module that both import is compiled once for each, so when your entry points share most of their code, they are compiled as one program.
declare var self: Worker;
self.onmessage = event => self.postMessage(event.data * 2);const worker = new Worker("./worker.ts");
worker.onmessage = event => {
console.log(event.data);
worker.terminate();
};
worker.postMessage(21);bun compile ./spawn.ts --outfile spawn && rm worker.ts && ./spawn42These all name a file that the compiler finds:
| You write | The file is looked for |
|---|---|
new Worker("./worker.ts"), "../worker.ts" | From the folder of your entry point, as the executable does |
new Worker(new URL("./worker.ts", import.meta.url)), also with .href | From the file that says so |
import.meta.resolve("./worker.ts"), require.resolve("./worker.js") | As an import of that file is |
path.join(__dirname, "worker.js"), path.resolve(import.meta.dir, "worker.ts"), `${__dirname}/worker.js` | From the file that says so |
import.meta.url, import.meta.path, __filename | The file itself |
fileURLToPath() and pathToFileURL() of node:url around one of these |
The path can pass through const variables, parameters of functions in the same file and exports on its way to new Worker(), of the global Worker or of node:worker_threads.
bun run looks for a relative path like "./worker.ts" from the working directory. An executable looks in itself first, from the folder of its entry point, or from the folder that all of them share. So does the compiler.
A name without ./ in front, like "some-package/worker.js" or "worker.ts", leads to a package, else to a file, like under bun run, and from that same folder. A file above that folder is found too, like new URL("../workers/job.ts", import.meta.url) in src/main.ts. Only a path that new URL() or path.join() leads two or more folders above it isn't, because nothing is left of the executable's own folder in such a path: write "../../workers/job.ts", or pass the file as an additional entry point. The compiler only follows the paths in your own files: a worker that a package in node_modules starts from one of its files isn't compiled, because most programs that import such a package never start it. A package of your workspace that is only linked into node_modules counts as your own files. Pass such a file as an additional entry point. A path that names no file changes nothing about the build, and the Worker fails to load.
You don't have to list a worker that you embed with the file loader and start by the path of the copy. The compiler follows the import to new Worker() and compiles the file, with everything it imports, as one more entry point:
import path from "./worker.ts" with { type: "file" };
const worker = new Worker(path);The path doesn't have to go straight from the import to new Worker(). It can pass through variables, objects, arrays, functions and promises.
A file that you embed for another use, like a script that a server sends to browsers, stays a file. The compiler goes by what your program does with the path. A path that only ever reaches new Worker() is a worker. If the same path can also reach something like Bun.file() or fs.readFile(), it could be either. The compiler tries up to four files like that, and none of them if there are more. A Worker that gets the path of a file that wasn't compiled fails to load, so the build warns where that can happen:
warn: TS100071: This Worker may start from an embedded file that isn't compiled, and then it fails to load: "/home/me/app/site/index.js", "/home/me/app/site/login.js", "/home/me/app/site/settings.js" and 97 more. Add the file as an entry point, or pass its import straight to new Worker()You don't have to list a worker that starts from a data: URL either, if the whole URL is known at build time. The compiler decodes it and compiles what it holds as one more entry point:
const code = `postMessage("from the worker")`;
const worker = new Worker("data:text/javascript," + encodeURIComponent(code));The URL can be a string, a template or a + of constants, with encodeURIComponent() or btoa() around a part of it, percent-encoded or in base64, and wrapped in new URL(), which node:worker_threads asks for. It can pass through const variables, parameters of functions in the same file and exports on its way to new Worker(). The code is read as TypeScript with JSX whatever type the URL names, like under bun run, and its imports are resolved from the directory you build in. A dynamic import() in a data: URL only finds a built-in module or an absolute path, like under bun run and in Node.js.
The same goes for the code that you pass to new Worker(code, { eval: true }) of node:worker_threads, and for an object URL, if the code and the type of the Blob are known at build time:
const code = `postMessage("from the worker")`;
const worker = new Worker(URL.createObjectURL(new Blob([code], { type: "text/javascript" })));The options and the parts of the Blob have to be written as literals, { eval: true } and [code]. The Blob and the URL can be kept in const variables. The executable knows the code by what it says, so a Blob that your program makes elsewhere with the same text and the same type starts the compiled code too. In such a Worker import.meta.url, Bun.main and stack traces show a blob: URL that is the same in every run.
If the code in the URL, or in a file that the compiler found by its path, has a syntax error or an import that can't be resolved, the build leaves it out. It prints what's wrong as warnings, and the Worker fails as it does under bun run:
warn: TS100072: The code that this Worker starts from can't be compiled, so the Worker fails to loadAny other error in that code stops the build, as it does in an entry point that you list: a type error in worker.ts is the same error as in main.ts.
A Worker that you create from a data: URL, an object URL or eval code that your program puts together at run time works too. Its code is code from a string, so Bun interprets it, and the built-in modules it imports too. It shares nothing with your compiled code: it can use any API, built-in objects aren't read-only in it, and it makes the rest of your program neither larger nor slower.
With --trim such a Worker fails to load, like a Worker from a file on disk. So it does with --disallow-code-generation-from-strings=strict, where new Worker() throws the flag's EvalError.
A Worker that you create from a path that your program puts together at run time runs code that isn't in the executable, so it fails to load: the Worker gets an error event. Pass the file as an entry point. With --aot-allow-code-from-disk it loads, in a program that also has a dynamic import() or require().
A file can start itself as a Worker. To tell the two threads apart, use Bun.isMainThread, not import.meta.main. In an executable with a single entry point, import.meta.main is decided at build time, like in any executable: it's true in that file on every thread. Under bun run it's false in the Worker. With more than one entry point it's false in a Worker here too.
Everything is one program when entry points can meet in one global object: when your code calls import() or require() with a dynamic path, which finds any of them, or passes preload to new Worker(). The program of that global object has to include all of them, so separate programs would only repeat it.
Native addons#
require("./addon.node") embeds the addon in the executable, like in any executable. process.dlopen(module, path) loads one from disk, and its path can be computed. Calls into either work the same. A require() with a computed path doesn't load an addon, because it only finds code that's in the executable.
A package usually ships one addon per platform and makes the name of the file out of process.platform and process.arch. The compiler knows both, so such a name is a constant to it, and the executable contains the one addon that it loads.
module.exports = require(`./my-app.${process.platform}-${process.arch}.node`);The same goes for "./my-app." + process.platform + ".node", for path.join(__dirname, name), for a require from createRequire(), and for the loaders that NAPI-RS generates: a switch or a chain of ifs over the platform and the architecture, where each arm requires the file beside the loader or else the package for that platform, like @my/app-linux-x64-gnu. Such a loader first asks existsSync(join(__dirname, "my-app.linux-x64-gnu.node")). An embedded addon keeps its name, so the answer is true. In an executable built without --aot the name gets a hash, my-app.linux-x64-gnu-2ecze33x.node, which is also what Bun.embeddedFiles lists there.
Where only the start of a package's name is a constant, as in require(`my-app-${parts.join("-")}`), the executable contains every installed package whose name starts with my-app-.
A name can also be one of a few. The compiler follows each of them, and the executable contains every one that's installed:
- an entry of a table, as in
names[process.platform][process.arch] - an element of a list that a loop tries, as in
for (const path of paths) require(path), or thatforEach,map,filterorfindhands to a function written in the call - a value or a key of a table, as in
for (const name of Object.values(names)) - what a function in the same file returns, and what's passed for a parameter of a function that's only ever called
- a variable that's assigned in more than one place, or added to outside of a loop, as in
name += "-musl" - a list that's joined, as in
parts.join("-"), with whatparts.push()adds outside of a loop - what
require.resolve()orimport.meta.resolve()returns for any such name or for a constant, handed on as it is, as inrequire(require.resolve(name))
The table or the list has to be written in the file and stay as it's written: the file only reads it, and doesn't export it or pass it to a function. In such a name the compiler also takes const { platform, arch } = process, what require("node:process") returns, os.platform() and os.arch() for the platform and the architecture that the executable is built for.
The compiler still warns that such a path isn't a constant. If it can't follow a name, pass the addon as another entry point, and the loader finds it by the name that it makes.
bun compile ./app.ts ./node_modules/my-app-linux-x64-gnu/my-app.linux-x64-gnu.node --outfile appAn addon is native code. It can keep, call and change whatever your program hands it, right away or later, and it can read, call and change globals by name. Built-in objects are read-only for it too. napi_run_script runs code from a string, so it throws the same EvalError as eval unless the compiler found a call to eval or Function in your program.
On Linux, compiled code raises SIGSEGV and SIGBUS itself and handles them before anything else does. An addon or a library can still set a handler for either one with sigaction() or signal(). The handler is called for every other SIGSEGV and SIGBUS, the same as without --aot. Blocking them with pthread_sigmask() or sigprocmask() does nothing. All of this works on calls to the C library's functions by name. A library that makes the rt_sigaction system call itself, or that gets the function from dlsym() with RTLD_NEXT or a handle to the C library, takes the executable's place. It has to call the handler that was there before for any signal it didn't raise.
Plugins and build options#
Plugins of Bun.build work with compile: { aot: true }. The compiler is handed what your plugins resolve and load, never what's on disk. Which names a CommonJS file exports and in which order modules run come from the text that onLoad returns.
await Bun.build({
entrypoints: ["./app.ts"],
compile: { outfile: "./app", aot: true },
plugins: [
{
name: "settings",
setup(build) {
build.onResolve({ filter: /^settings:/ }, args => ({ path: args.path.slice(9), namespace: "settings" }));
build.onLoad({ filter: /.*/, namespace: "settings" }, async args => ({
contents: `export default ${JSON.stringify(await readSettings(args.path))};`,
loader: "js",
}));
},
},
],
});All of this behaves as in a build without aot, and each hook gets the same arguments:
onStart,onResolve,onLoad,onEnd,defer(), andonBeforeParsewith a native plugin- several plugins, which are asked in order until one answers
- filters, namespaces, modules that are in no file, and what such a module imports
external: true- every loader,
contentsas a string or as bytes, and answers that come late - a plugin that throws or rejects: the build fails with its message
- answers for the entry point, for a package in
node_modules, for a CommonJS file, for a file of a page files, macros,define,loader,env,conditions,external,packages,drop,features,jsx,tsconfig,root,minifyandsourcemap- which file of a package ends up in the executable:
"exports"andconditionsfirst, then"module"for animportand"main"for arequire(). If anything requires the package, its imports use"main"too, so there's one copy.bun runalways takes"main" - which loader a file gets:
with { type: "file" }and theloaderoption come before the extension, and a file with an extension that Bun doesn't know is embedded as it is, forimportand forrequire()
This differs:
bun build --compile | bun compile | |
|---|---|---|
Files that a computed path or import.meta.resolve names | Not in the executable, so no hook runs | In the executable, so the hooks run for them |
| One import that a file has twice | onResolve runs twice | onResolve runs once |
The "development" condition of "exports" | Holds, except in a production build | Holds only with --conditions=development, as under bun run |
| One path in two namespaces | One module | Two modules |
minify | Renames functions and classes | Shortens the same names in name, stack traces and messages |
sourcemap | A map is one of the outputs | Only external writes a map |
metafile | One output for each entry point | One output, the executable |
format, bytecode, --bytecode-depth, --bytecode-order | Change what's in the executable | No effect. Nothing is bundled, and the executable has no bytecode |
banner or footer with code | Work | The build fails. A banner or footer that's only comments is fine |
The text that onLoad returns is type checked like a file, and its types are what the files that import it see.
Plugins for Solid, Vue or Svelte return JavaScript (loader: "js" or "jsx") for a TypeScript file, so the types are gone from what they return. In that case the JavaScript is compiled, and the files that import it get their types from the file on disk, like in your editor. The build doesn't report type errors inside of that file. Your editor and bun check do. import type { Props } from "./view.tsx" keeps working.
onResolve doesn't change what the type checker reads: it resolves an import as TypeScript does. So a module that's in no file needs a declaration, like in your editor.
declare module "settings:*" {
const settings: { port: number };
export default settings;
}If a module doesn't match its declaration, or the JavaScript from a plugin doesn't match the file it was made from, the program behaves as under bun run.
Metafile#
--metafile, --metafile-md and metafile in Bun.build write the same format as without --aot. inputs lists every file of yours and of your packages that's in the executable, with its imports. Nothing is bundled, so a few things differ:
outputshas one entry, the executable, whatever the number of entry points. ItsentryPointis the first one.bytesof the output is the size of your compiled program inside of the executable. That includes the parts of Bun's own modules, likenode:fs, that are compiled with it. It doesn't include the Bun runtime.bytesInOutputis the machine code of the functions in that file. A function that's inlined counts for the file that calls it.- Files that a computed path or
import.meta.resolvenames are inputs, and imports of the file that names them. - An import that's only types, or that nothing uses, isn't listed. When a file that only re-exports is left out of the executable, its importer lists the files behind it instead, without
original.
The executable is the same with and without a metafile.
bun build on the command line has no plugins, with or without --aot. Bun.plugin in your program is about code that isn't in the executable. As in every executable from bun build --compile, a plugin is never asked for a module that's in the executable, whatever its filters match.
Function.prototype.toString()#
The executable includes the source code of your functions, with the types removed, and toString() returns it. It's the text of your file. bun run returns what its transpiler printed, so line breaks, indentation and quotes can differ.
TypeScript's parameter properties go with the types. The text of class Point { constructor(public id: number) {} } is class Point { constructor(id) {} }, where bun run shows the field and the assignment that it compiles the property to, id; and this.id = id;. new Point(1).id is 1 in both. A class that you get by evaluating the executable's text again doesn't set id.
--no-compile-function-source removes the text. The executable is smaller and doesn't contain your source code. It still runs code from strings. In Bun.build, the option is compile: { functionSource: false }.
function add(a: number, b: number): number {
return a + b;
}
console.log(String(add), add.name, add.length);bun compile --no-compile-function-source ./text.ts && ./textfunction add() { [native code] } add 2Code that reads parameter names out of that string, as some dependency injection libraries do, finds none.
A library that reads the text of your functions can also decide differently on the text of your file than on what the transpiler printed. Elysia reads a handler to find out whether it needs the request body, and it doesn't follow a variable that holds the context: after const c = context; return handle(c), context.body is undefined in an executable. bun run prints that handler as return handle(context), which Elysia does follow. Pass context itself.
Error messages#
The class of an error, its line and its stack are the same as under bun run. Where bun run quotes your source in a message, an executable quotes less of it.
const o: any = { a: null };
try {
o.b();
} catch (error) {
console.log(String(error));
}
try {
o.b(1, 2);
} catch (error) {
console.log(String(error));
}
try {
new o.b();
} catch (error) {
console.log(String(error));
}
try {
o.a.c;
} catch (error) {
console.log(String(error));
}
try {
o.a.c = 1;
} catch (error) {
console.log(String(error));
}bun messages.tsTypeError: o.b is not a function. (In 'o.b()', 'o.b' is undefined)
TypeError: o.b is not a function. (In 'o.b(1, 2)', 'o.b' is undefined)
TypeError: undefined is not a constructor (evaluating 'new o.b')
TypeError: null is not an object (evaluating 'o.a.c')
TypeError: null is not an object (evaluating 'o.a.c = 1')bun compile ./messages.ts && ./messagesTypeError: o.b is not a function. (In 'o.b()', 'o.b' is undefined)
TypeError: o.b is not a function. (In 'o.b()', 'o.b' is undefined)
TypeError: undefined is not a constructor (evaluating 'new o.b')
TypeError: null is not an object (evaluating 'null.c')
TypeError: null is not an object (evaluating 'null.c')| The message | What an executable quotes |
|---|---|
… is not a function | What you called, as you wrote it: o.b, this.client.send, handlers[name]. Not the arguments |
… is not a constructor | The same, behind new |
… is not a function, of instanceof | The whole expression, where both sides consist of names as above: (evaluating 'value instanceof o'). Otherwise nothing: Object is not a function |
… is not an object | The value that was null or undefined, then the name of the property: null.c, undefined["key"], null[0]. Not the expression that gave you the null |
… is not an Object., of in | Nothing: 5 is not an Object., where bun run adds (evaluating '"x" in five') |
An executable doesn't hold the text of each expression. It keeps what you called where that consists of names, this, super and keys in brackets, up to 80 characters. A callee that's more than that has no name in the message: f()() says 5 is not a function, and so does a call with spread arguments.
A method that you call on more than that has its own name alone: make().get(), new Late(1).get() and [1].get() all say get is not a function. (In 'get()', 'get' is undefined), where bun run says make().get is not a function. (In 'make().get()', 'make().get' is undefined). So does a method that many places call on different objects: stream.destroy() can say destroy is not a function.
A tagged template is worded as a call. tag`x` says tag is not a function. (In 'tag()', 'tag' is undefined), where bun run says undefined is not a function (near '...tag`x`...').
One more message differs in an executable built with --sourcemap=external, although bun run quotes nothing in it. There, an update through a computed key, o[k] += 1, o[k]++, --o[k] or o[k] &&= 1, throws undefined is not an object when o is undefined and null is not an object when it's null, unless k is an object. bun run and every other executable say Cannot access property of undefined or null for both.
The class of the error is the same, so test error instanceof TypeError or error.name, not error.message. A service that groups errors by message, such as Sentry, sees new groups.
When bun run prints an error, it prints the lines of your file around it first, with a caret under the place. An executable prints the message and the stack trace without those lines. It doesn't hold the text of your files, and it never opens a file to show you one: what lies on the disk under that name may not be what was compiled. The stack trace has the lines and columns of your sources, and the originalLine and originalColumn of an error are its line and column. bun run --aot runs your files where they are, so it prints the lines.
Class names from computed keys#
A class without a name takes the name of the property that it's assigned to. With a computed key, bun run sets that name after the static fields of the class have run, and not at all if one of them has read name. An executable sets it first, as the ECMAScript specification says.
function create(key: string) {
return {
[key]: class {
static label = this.name;
},
}[key]!;
}
const Shape = create("Shape");
console.log([Shape.label, Shape.name]);bun compile ./names.ts && ./names[ "Shape", "Shape" ]bun names.ts prints [ "", "" ].
Function names from local variables#
A function or a class without a name takes the name of the variable that it's assigned to. Inside a function or a block, Bun's transpiler replaces a let or const that is used once, in the next statement, with its value. Under bun run the function then never reaches a variable, so it has no name. An executable gives it the name, as the ECMAScript specification says. The same holds for a function that is declared in a block, such as the body of an if or of a loop, and used once in the first statement of that block.
function create() {
const double = (x: number) => x * 2;
return double;
}
console.log([create().name]);bun compile ./locals.ts && ./locals[ "double" ]bun locals.ts prints [ "" ]. A var, and a variable at the top level of a file, give the name in both.
Raw strings of tagged templates#
String.raw, and strings.raw in a tag function, give you the text of a template as you wrote it.
console.log(String.raw`é`, String.raw`é`.length);bun compile ./raw.ts && ./rawé 1That's what the ECMAScript specification says. bun raw.ts prints é 6, because Bun's transpiler rewrites every character above U+007F in a tagged template to an escape before the file runs. The cooked strings, strings[0] in a tag function, are the same in both.
Sloppy-mode code#
This section applies to CommonJS files without "use strict" at the top. ES modules and TypeScript files with import or export are always strict.
| You write | Compiled |
|---|---|
f.caller, f.arguments | null. The build warns where it can see that f is a function |
getFunction() of a call site | undefined, as for a strict function and in an executable built without --aot. Also in a text that eval or Function gets as a constant |
| A function declared in a block | The code around the block, branch, loop, case or try can read it too, once that has run. Under bun run only the block sees it |
"use strict" at the top of a function | The function runs in sloppy mode, as under bun run: Bun's transpiler removes the directive. A function declared in a block inside it stays in its block |
A decorator, with experimentalDecorators | The decorator expression runs in strict mode, like the class body. Under bun run it runs after the class, in sloppy mode |
A .cjs file loaded with import | Sloppy mode, as in Node.js. bun run has it strict if it never names require, module or exports and no package.json says "type": "commonjs" |
function (a, b = 2) { eval("var a"); } | var a makes a new variable, undefined at first, and the body reads it from then on, as in Node.js. Under bun run the body still reads the parameter |
with statements work, and so does a direct eval that declares variables.
Flags on process#
Bun itself looks at five properties of process: noDeprecation, throwDeprecation, traceDeprecation, traceProcessWarnings and domain. In a compiled executable, Bun only reads them as own properties of process. process.noDeprecation = true and BUN_OPTIONS="--no-deprecation" work as before. EventEmitter.prototype.noDeprecation = true doesn't: Bun ignores it, and still warns. util.inspect reads its styles the same way: only the object's own keys count.
Memory#
- Each thread can have 32 GB of objects. References between objects take 32 bits. Objects and small arrays count toward the limit. The characters of strings, large arrays,
ArrayBuffers and typed arrays don't. EachWorkerhas a limit of its own. Where the address space of the process is limited (ulimit -v), objects take address space as they need it and share the limit with everything else in the process. Address space that objects have taken stays reserved for objects until the process exits. A thread that goes over it gets aRangeErrorwhose message starts with "Out of memory" and names the limit and the option that removes it. A thread that stays just under the limit gets the same error once four garbage collections in a row have each freed less than 1/64 of the heap. Otherwise it would spend nearly all of its time collecting garbage. Your code can catch it. It is thrown where it allocates, inJSON.parseandstructuredClone, or at the next function call that checks the stack. One case is left: a loop that allocates inside atryof its own function, or that the compiler has inlined into one, and that calls no function gets no error there, and ends the process with an out-of-memory error once memory is used up, or after 16 such collections in a row. In aWorkerit ends that Worker alone: its parent gets anerrorevent with theRangeError, and the exit code is 1. Move the loop into a function of its own.bun compile --aot-huge-heapcompiles with 64-bit references, which removes the limit. The executable then uses more memory and runs slower. - Where the address space is limited,
new Worker()throws aTypeErrorwhen there is no room for another thread. If anotherWorkerthat the same thread started is ending at that moment, the constructor returns, and the newWorkerstarts once that one has ended. One newWorkercan wait for each one that is ending. If there is still no room then, it gets anerrorevent with the sameTypeErrorand closes with code 1. - A local variable stops keeping its value alive after its last use, even if the function is still waiting at an
await. In an async function or a generator, parameters are the exception: whatever the names in the parameter list hold, andthis, stay alive until the function ends, as underbun run. In any other function, a parameter is like a local variable. Underbun run, a local variable that a closure captures lives as long as the function's other variables. So if aFinalizationRegistryis in a local variable that you never touch again, it can be collected while the function waits, and its callback never runs. Keep the registry at module level. - A variable or property that nothing reads doesn't keep its value alive. The compiler removes it along with the stores into it. If you park an object in a module-level variable only so that a
WeakRefto it keeps answering, or so that itsFinalizationRegistrycallback doesn't run yet, read that variable somewhere.
Platforms#
bun compile compiles for arm64 and x64, on the platform that runs the build.
| Platform | State |
|---|---|
| Linux x64, glibc | Tested |
| Linux arm64, Linux musl, macOS arm64, macOS x64 | Tested. Some tests still fail |
| Windows x64, Windows arm64 | Tested. Many tests still fail |
| FreeBSD, Android | Bun builds there. Not tested yet |