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

Ahead-of-time compilation is experimental.

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 messageSee
Cannot assign to ... because built-in objects are read-onlyBuilt-in objects are read-only
Cannot delete ... because built-in objects are read-onlyBuilt-in objects are read-only
Cannot change the prototype of ... because built-in objects are read-onlyBuilt-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 executableCode that isn't in the executable
Cannot load '...': this executable can't load a module from a dynamic pathCode 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 itCode from strings
Cannot replace a loader in Module._extensions: imports in this executable were resolved ...Loader hooks
... can't be used with this executableOptions that load code
... was compiled ahead of time and can't be imported inside a ShadowRealmWhat isn't available
super() cannot be called from direct evalWhat isn't available
Anything elseTroubleshooting

What works the same#

  • npm packages, with or without types, except for a few
  • ES modules, CommonJS, require(), import() of a string literal, top-level await
  • every node: and bun: module
  • Workers and native addons
  • eval and new Function, in the interpreter
  • macros
  • plugins of Bun.build, files, define, loader and 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#

Areabun runCompiled
Type errorsbun build ignores themThey stop the build
The version of TypeScriptTypes are never checkedTypeScript 7's errors, whichever version you have installed
A .ts file without import or exportIts variables aren't global. Another file that reads one throwsThe other file is a type error, also where tsc takes the file for a script
Built-in objectsCan be changedCan be extended. Their existing members can't be replaced
Seven Bun classesTheir prototypes can be changedTheir prototypes are read-only. Subclasses work
Code from stringsThe JIT compiles itBun interprets it
WebAssemblyThe JIT compiles itA .wasm file that you import is compiled. Bun interprets any other module
Dynamic importsLoaded from diskEntry points are found in the executable. Other code throws. Data files load
Exports of compiled modulesspyOn(namespace, "f") from bun:test replaces the exportThrows a TypeError. An export of a module loaded from disk at run time can still be spied on
The module cacheLists every module that has run. delete makes one run againDoesn't list the modules of the executable. Each runs at most once
A CommonJS module that threwThe next require() runs it againThe next require() throws the same error
Loader hooksModule.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.parentLead from any module object to every otherThe same if some file of your program names one of them. Otherwise undefined for everybody
export * of a CommonJS moduleThe namespace object follows the specificationIt differs in one rare case
The namespace object of an ES module that a CommonJS module has requiredObject.isExtensible() says true, Object.isSealed() falsefalse and true, as for every other namespace object. In both, nothing can be added to it
Some error messagesQuote 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 errorPrinted above the message, with a caretNot printed. The message and the stack trace are the same
The line, column and stack of a DOMException. Where a module disposes a usingPlaces in the text that runsPlaces in the source file
The stack of an error that the parameters of an async function with an await throwDoesn't list the functionLists the function
The stack of an error from the setup of an enum, a namespace or a decorated classHas an <anonymous> frame for the declarationHas 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 constructorLists new and the name of the classDoesn't, as for a class without decorators
The stack of a RangeError from new Array(-1), where Array is a variable of your ownOften doesn't list Array, if the variable holds the built-inLists Array, like any other function that a variable holds
The frames printed for an error that leaves a generator and that nobody catchesDon't list the generatorList the generator
A closure that runs while another closure of the same function gets a displayNameStack traces list it under its own nameThey 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 oneStack traces list each closure under its own nameWhere 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 seeThe stack lists it under the new nameThe 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 keySet after the static fields ran, and skipped if they read nameSet before the static fields run
The name of a function in a local variableEmpty, if the variable is a let or const that is used onceThe name of the variable
The name of a function or a class behind export default that has no name of its ownThe file's name and _default. A class: starDefault in tracesdefault, in name and in stack traces
Raw strings of tagged templatesCharacters outside ASCII come back as escapesThe text as you wrote it
Function.prototype.toString()What the transpiler printedThe text of your file, without the types
Code nested more than 2,000 levels deep: calls in calls, an else if behind 2,000 othersRuns, as deep as the stack allowsThe 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 deepThrows a RangeErrorWrites 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 moreWrites the text, or throws a RangeError for some objectsWrites 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.defaultundefinedThe object itself
The own keys of Object, copied and then listed, behind a for (key in Bun) loopsetPrototypeOf, defineProperties stand ahead of the othersIn 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 codef.caller, f.argumentsBoth are null
Sloppy-mode codegetFunction() of a call site answers the functionundefined, as for a strict function, and as in an executable built without --aot
A .cjs file loaded with importStrict, if nothing in its text shows that it's CommonJSSloppy, as in Node.js
A .mjs file that calls require() and has no import or export, behind an importRuns ahead of the files that are imported before itRuns 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 othersThe 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() throwsThe first of them begins, then require() throwsIn 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 loadingSettles in a turn that changes from run to runSettles 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 executableLooks in the folder of the file that asksLooks 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 itAn import goes by what it was when the file had runAn 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 enumerableThe import fails: the file has no export named xFinds 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 itSome importers keep its exports of that moment, later ones don'tAll 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 runWhen the tick runs hangs on when the other files were readEach 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" namesLoads 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 nullConverts the key, which can run your code, then throwsThrows first, as the specification says. The key's toString() never runs
arguments in a string that eval runs in a generator or an async functionA ReferenceError, or those of a function further outThose 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 codeRuns the text as sloppy-mode codeRuns 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 outThe text stores to the outer xThe 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 codeThe body keeps reading the parameter aThe 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 functionf reads and stores to the a of the bodyf 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 + throwsx holds the symbol in the catch and after itx 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 importsStays pending, and the process waitsLoads a module without exports, and the file continues
import("./a.js", options) with { with: { type: "text" } } held in a variableThe text of the fileThe 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 typeAnother namespace object, with the same membersThe 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 assignedLoads the file of the assigned nameThrows 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 awaitnullundefined, as the specification says
this at the top of a CommonJS file, in eval("this") or behind // @bun @bun-cjsThe module objectmodule.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 documentRemoved. Nothing is thrown, though the global doesn't existThrows a ReferenceError, as the specification says
An object literal that nothing uses, with a symbol as a computed keyThrows a TypeErrorNothing is thrown, as the specification says
Object.keys({ get a() {}, b: 1, get a() {} }): a getter that a literal has twiceb, then aa, 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 answersThe index is empty afterwardsWhat 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 literaltruefalse, and a TypeError in strict code, as the specification says
The name of a class with a class decorator, read before the decorators have runundefinedThrows a ReferenceError, as the specification says
The computed key of a member with a decorator, with experimentalDecoratorsRuns the key expression twiceRuns it once, as TypeScript's compiler emits it
design:paramtypes of a decorated class that has fields and no constructorAn empty list, with useDefineForClassFields: falseNot set, as in what TypeScript's compiler emits
An imported interface that only the type of a decorated member namesThrows a SyntaxError, with emitDecoratorMetadataRuns. design:type is Object, as in what TypeScript's compiler emits
export { T } from "./file" where T is only a typeThrows a SyntaxError: the export is not foundRuns the file, as an executable built without --aot does
namespace A.B {} or namespace A { /*! note */ } with nothing but types insideA is an object, and an export of the file if it is exportedThere 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 aboveSome run behind the defaults of the constructor's parametersAll 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 statementThrows a ReferenceErrorWorks, 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 nameThe key. Node.js lists the function under it too
for (const { a = a } of list), from the second turn onReads what a held on the turn beforeThrows 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 letQuotes the pattern, '[a]', or nothing, ''Names the variable, as Node.js does: Cannot access 'a' before initialization.
{ [key]: f() }, where key is an objectCalls 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, aa, b, as in Node.js: a key keeps its first place
0 / 0 in the scope of a variable of yours named NaNWhat the variable holdsNaN, 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 clauseThrows a ReferenceErrorIs there, as in Node.js: the clauses are one block
label: function f() {} in sloppy-mode codeNothing around the label sees fThe function around it sees f, as in Node.js
super.x in the constructor of a class that extends a ProxyThrows a TypeErrorWorks, as in a method and as in Node.js
[f, g][1](): a function called straight out of an array literal, by a constant indexthis is undefined, or the global object in sloppy-mode codethis 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 commathis is objectthis 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 parameterThe number of parametersThe number of arguments, as in Node.js
Object.isExtensible(ns), where ns is what require() returns for an ES moduletrue, and Object.isSealed(ns) is falsefalse, 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 runRemoved. Nothing is thrownThrows a ReferenceError
export const B = class extends Proxy {}, where no file imports BThrows a TypeError where the class is madeThe 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 aCalls fThe 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 resultThrows a RangeError, until the JIT has optimized the functionThrows a RangeError every time
A regular expression whose groups begin a hundred million turns before it finds its matchGives the match up and answers that there is noneFinds 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 DataViewDropped at the read, until the JIT has optimized the functionDropped 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 stringCalls f(), then throws the TypeErrorThrows the TypeError first. f() never runs
{ [key]: value }, where converting key to a string runs your codeEvaluates value, then converts keyConverts key, then evaluates value
An object literal or a class that defines the same getter or setter twiceIts key is listed where it was defined lastIts key is listed where it was defined first
static { a: { break a; } }: a break out of a labeled block in a static blockA SyntaxError when the file loadsLeaves the block
(5).#a: a private name read off a whole number in parenthesesA SyntaxError when the file loadsThe file loads. The read throws a TypeError when it runs
Level.Low ** n, where the enum member is negative and n isn't a constantA SyntaxError when the file loadsComputes the power
In sloppy-mode code: function f(a, a) {}, arguments = 1, eval++ on a local evalAn error when the file loadsRuns, as in Node.js
"use\x20strict" or ("use strict") at the top of a function that has a var publicAn error when the file loadsRuns, 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 loopAn error when the file loadsContinues the loop, as in Node.js
enum E { static } below "use strict": a member named by a reserved wordAn error when the file loadsRuns, as in what TypeScript's compiler emits
if (x) const enum A {}: an enum as the one statement of an if or a loopAn error when the file loadsRuns, as in what TypeScript's compiler emits
declare class C { static prototype: number }An error when the file loadsRuns. The declaration is erased
accessor a = 1; in a class, with experimentalDecoratorsAn error when the file loadsRuns, as in what TypeScript's compiler emits
@d(x => x.#a) m() {}: a private name in a decorator, with experimentalDecoratorsA SyntaxError when the file loadsRuns, 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 loadsRuns
import require = require("./file") in an ES moduleThrows a ReferenceErrorImports the file under that name
import { x } from "./m"; const x = 1; below // @ts-ignore, where that x is a valueRuns. x is the constantThe 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-nocheckRuns. x is the later importThe 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 variableAn error when the file loadsRuns, as in Node.js
(a?.b)`x` , new (a?.b)(): an optional chain in parentheses as a tag or behind newA SyntaxError when the file loadsRuns, as in Node.js
x <!--y in a TypeScript fileAn error when the file loadsCompares x with !--y, as in what TypeScript's compiler emits
function f(a, this) {} in a file that is not type-checkedRuns without the parameter: f.length is 1The 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 moduleRunsThe 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 parseThe import or require() that loads it throwsThe 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".lengthEvery Size.Length is 4, whatever the program stores thereSize.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 + 30Every Totals.Sum is 31, whatever the program stores thereTotals.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 tagCalls the value of the member, so it throws a TypeErrorReads 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.FixedReads the object, so it returns what the program stores thereEvery 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 codeOnly the block sees itThe code around the block sees it too, once the block has run
/(?:\b){5000000}/: a group that takes no characters, repeated millions of timesnullMatches, as the specification says
await f(text) in an async function whose parameter f is RegExp.prototype.testPrints another outputThrows 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 functionReads list first, and throws if it isn't a listThrows 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 _eventsEach is read and stored as often as in Node.jsWhile 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 _eventsEach is read and stored wherever the server uses itDuring 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 modulesSees the calls that the modules make under bun runSees 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 prototypeLists an index that both have twiceLists it once
o[9] = x, where the prototype of o is a typed array without the index 9Adds the element to o. Throws a TypeError if o is sealedThe 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 environmentDates use that time zone from the startThe same. An executable built without --aot starts in the time zone of the system
Five flags on processA value that process inherits countsOnly own properties of process count
A FinalizationRegistry that only a local variable holdsLives while the function waits at an awaitCan 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 threadLimited by memory32 GB, or no limit with --aot-huge-heap

What isn't available#

FeatureWhat you seeInstead
--target for another platformerror: bun compile can't cross-compile. It only builds for the platform it runs onRun the build on the target platform, or pass --no-aot
More than 256 JSCallbacks from bun:ffi open at a timeRangeError: bun:ffi: a compiled executable can have 256 JSCallbacks open at a timeclose() the ones you no longer use. The rest of bun:ffi works
cc() from bun:ffi, on Linux 6.7 or laterError: 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 laterWhatever the addon does when mprotect or mmap failsRun that code in a child process. Child processes aren't restricted
Breakpoints, stepping, debugger statementsA breakpoint never bindsbun --inspect-brk ./app.ts, from your source files
import(), require() or import * as of an HTML fileAn error from the buildimport 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 pathImport the path without it
importValue() of one of your modules in a ShadowRealmSyntaxError: The module ... was compiled ahead of time and can't be imported inside a ShadowRealmevaluate(), with the code as a string
super() in the string of a direct eval, in a few classesTypeError: super() cannot be called from direct eval in a compiled executableCall it outside of the string. Only with private methods under useDefineForClassFields: false
process.stdout and others, used only in a string you evalError: ... was not compiled into this executable because your code never uses it, when the string runsUse it in your own code too
bun --aot with -e or -pAn errorRun under the compiler's rules has the list
--inspect in BUN_OPTIONS, BUN_INSPECTAn error when the executable startsBuild with --compile-inspector
--preload in BUN_OPTIONSAn error when the executable startsImport the file from your entry point
banner or footer with codeAn error from the buildPlugins 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)
PackageWhat it doesWhat you seeWhyInstead
sass 1.85.0, 1.106.1The 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 changedCompile your stylesheets before you build the executable
zone.js 0.16.3, and Angular through itKeeps 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 changedAngular 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.3Copies 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 ownrecast 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.0Opal, 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 changedasciidoctor 4.1.1, which has no Ruby runtime
ses 2.3.0Redefines 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 redefinedTo 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 itReplaces require to patch modules as they load: this._require = Module.prototype.require = function (id) {No error. The hook never runs, so nothing is instrumentedA require() of a constant path calls no hookBuild 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.0Replaces 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 replacedRemove it. The build compiles TypeScript and JSX
module-alias 2.2.3, tsconfig-paths 4.2.0Replaces 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 timepaths in tsconfig.json, or imports in package.json
async-listener 0.6.10, and continuation-local-storage 3.2.1 through itWraps 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 toAsyncLocalStorage from node:async_hooks, or cls-hooked 4.2.2
core-js 2.6.12require("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 changedcore-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 itReplaces 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 replacedRemove the import. Bun implements what it fills in
es6-shim 0.35.8Replaces toString of regular expressions: defineProperty(RegExp.prototype, 'toString', function toString() {On import: TypeError: Cannot assign to RegExp.prototype.toString ...RegExp.prototype can't be changedRemove the import. Bun implements what it fills in
array.prototype.map 1.0.8, and array.from, array.prototype.every, filter, foreach, reduce and somerequire("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 replacedCall 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 addedCall 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 addedCall what the package exports: require("string.prototype.matchall")(text, pattern)
url-polyfill 1.1.13Adds a helper to the built-in class: Object.defineProperty(proto, '_fromString', {On import: TypeError: Cannot assign to URLSearchParams.prototype._fromString ...URLSearchParams.prototype is read-onlyRemove the import. URL and URLSearchParams are built in
promise 8.3.0require("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 changedRemove the import, and use then
harmony-reflect 1.6.2Replaces functions of Object: Object.preventExtensions = function(subject) {On import: TypeError: Cannot assign to Object.preventExtensions ...Existing members can't be replacedRemove the import. Reflect and Proxy are built in
sugar 2.0.6, 1.5.0Sugar.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.atExisting members can't be replacedThe functions on Sugar itself: Sugar.Array.sum(list). Sugar.Date.extend() works
collections 5.1.13Replaces functions of Object: Object.values = function (object) {On import: TypeError: Cannot assign to Object.values ...Existing members can't be replacedNone
prototype 0.0.5Copies 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 replacedNone
chai 4.5.0, 6.3.0chai.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.prototypeexpect and assert
should 13.2.3Defines 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.prototyperequire("should/as-function")
must 0.13.4Defines 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.prototyperequire("must/must")
timezone-mock 1.3.6register() 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 changedSet the time zone itself: process.env.TZ = "US/Pacific"
clear-module 4.1.2Takes 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 exportsA module of the executable runs at most onceNone
@js-temporal/polyfill 0.5.1Its 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 executableExisting members can't be replacedLeave the line out. The rest of the polyfill works, and Temporal is built in
next 16.4.2Puts 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 DateExisting members can't be replacedNone. 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 fileThe error in the TypeScript fileWrite instead
function f() { return arguments.length }, called with argumentsTS2554: 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 signatureA 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 constraintA 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 typeTS2749: '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 filetsc and bun checkThe build
An interface, a type, anything with declareGlobal. 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 selfTS2300: 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 itGlobal. Another file can use it without an importAnother 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 fileThe two mergeThey 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.

builtins.ts
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));
terminal
bun compile ./builtins.ts && ./builtins
1
TypeError
TypeError
false
TypeError
1
ObjectThrows a TypeErrorWorks
Object.prototype, RegExp.prototype, Promise.prototype, the prototypes of generatorsEvery changeThe same on your own object or subclass
Function.prototypeEvery change but the ones on the rightReplacing 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 BufferReplacing, 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 prototypeAdding 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 BufferReplacing, 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 prototypeAdding 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, ErrorGiving the function a call, apply, bind or Symbol.hasInstance of its own: Math.max.call = f. Changing its prototypeAdding a property under any other name. All of it on your own functions, classes and bound functions
createRequire and Module of node:moduleReplacing, redefining or deleting itCalling 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, fetchAn assignment from code in a string or a module loaded at run time to a member that your compiled code never assignsEvery 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.

database.ts
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 timeThe answer
typeof Bun, typeof process, globalThis.Bun, globalThis.process, "Bun" in globalThisTwo objects
process.isBuntrue
process.versions.bun, process.versions.node, "bun" in process.versions, typeof process.versionsThe versions of the Bun that built the program
process.release.name"node"
process.platform, process.archThose of the --target
typeof require, typeof moduleDepends 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:

NameLooked up byCan't be added to
thenawait, and a promise that is resolved with the objectThe prototypes whose instances are objects: the third group without String, Number, Boolean, BigInt and Symbol
toJSONJSON.stringifyThe same prototypes, except Map.prototype and Set.prototype
next, return, throwfor...of, spread, destructuring and yield*, on the object that [Symbol.iterator]() returnedEvery 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.

response.ts
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());
terminal
bun compile ./response.ts && ./response
TypeError: 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)
HELLO

A 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.

strings.ts
const add = new Function("a", "b", "return a + b");
const local = 5;
console.log(add(2, 3), eval("local * 2"), (0, eval)("typeof Bun"));
terminal
bun compile ./strings.ts && ./strings
5 10 object

That'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.plugin
  • require and process.getBuiltinModule used 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:

stream.ts
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.

terminal
bun compile --disallow-code-generation-from-strings ./strings.ts && ./strings
EvalError: Code generation from strings disallowed for this context
      at /$bunfs/root/strings.ts:1:17

With 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:

ServerWithout the flagWith the flag
Bun.serve47,08150,364+7.0%
Hono42,90645,375+5.8%
node:http37,50138,649+3.1%
Express23,63925,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.params and schema.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.

server.ts
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.

server.ts
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 });
terminal
bun add @fastify/ajv-compiler ajv
bun ./server.ts write
bun compile ./server.ts

route 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.

app.ts
const wanted = process.argv[2] ?? "csv";
const plugin = await import("./plugins/" + wanted + ".ts");
console.log(plugin.name);
plugins/csv.ts
export const name: string = "csv";
plugins/json.ts
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.

terminal
bun compile ./app.ts --outfile elsewhere/app && cd elsewhere && ./app json
json

The 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.

lib.js
(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.

terminal
bun compile ./app.ts './extra/*.ts' --outfile app

A 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.

terminal
bun compile ./app.ts ./node_modules/pg/lib/index.js --outfile app

A 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, __filename or import.meta. That includes handing import.meta to a function. Files that only read them aren't affected.
  • The program replaces path.join or path.resolve. The executable compares the path that the call returned with the path that Bun's own function returns. If they differ, require throws and import() rejects. bun run would 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:.

CodeIn 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.

load.ts
import "node:path";

const name = process.argv[2] ?? "node:path";
console.log(Object.keys(require(name)).length > 0);
terminal
bun compile ./load.ts && ./load node:path && ./load node:dgram
true
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.

load.ts
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 wantDo this
The file compiledPass 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
DataNothing. JSON, JSON5, TOML, YAML, XML, text and CSS files run no code, so they load from disk as usual
A database or an HTML pageImport 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 it

Load 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.

host.ts
const plugin = await import(Bun.pathToFileURL(process.argv[2]).href);
console.log(plugin.default(20));
plugin.ts
import { cpus } from "node:os";

export default (n: number) => n * 2 + Math.sign(cpus().length);
terminal
bun compile --aot-allow-code-from-disk ./host.ts && ./host ./plugin.ts
41
  • 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, a data: or blob: URL. The path can be absolute, relative to the working directory, or a file: URL.
  • The file imports what it likes: files beside it, packages in a node_modules on disk, and every node: and bun: module. It gets the same node:fs and the same EventEmitter as your program. A built-in module that isn't in the executable is interpreted too. A package.json on 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.plugin applies to it. onResolve, onLoad and module() 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 --trim can'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:http can 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.

CallCompiled 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.

CallCompiled 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.

CallCompiled 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.

terminal
# Next.js with `output: "standalone"`, in .next/standalone
bun compile ./server.js ".next/server/**/*.js" --asset .next --asset public --asset node_modules/@img --outfile server

The 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.

terminal
bun compile --compile-in-place ./server.js $(find .next/server -name '*.js') --outfile server

For 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:

terminal
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 own

Without 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.

greeting.ts
console.log("hello");
before.ts
console.log("before");
terminal
bun compile ./greeting.ts && BUN_OPTIONS="--preload ./before.ts" ./greeting
error: --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 code

The 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 buildOptions
Rejects--preload, -r, --require, --import, --eval, -e, --print, -p, --hot and --watch in BUN_OPTIONS, and BUN_INSPECT_PRELOAD
RejectsBUN_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
Ignorespreload 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 code

In --compile-exec-argv, the options of the first and the third row fail the build.

Three build flags change that.

FlagIn Bun.buildThe executable
--compile-inspectorcompile: { inspector: true }Accepts all of the above, so that a debugger can attach. Bun interprets the code it loads
--aot-allow-code-from-diskcompile: { allowCodeFromDisk: true }Accepts --preload, -r and --import if its program has a dynamic import() or require()
--no-compile-function-sourcecompile: { functionSource: false }Rejects the options in the first row, with --compile-inspector too
  • --compile-inspector has 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 --trim removes no features.
  • An option in --compile-exec-argv is part of the build. --inspect there needs no --compile-inspector.
  • Editors set BUN_INSPECT_CONNECT_TO for every process that you start from their terminals. An executable built without --compile-inspector ignores 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.

counter.cjs
globalThis.runs = (globalThis.runs ?? 0) + 1;
exports.runs = globalThis.runs;
again.cjs
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);
terminal
bun run ./again.cjs && bun compile ./again.cjs && ./again
1 true
2
1 false
1

Circular 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.

HookThe assignmentBun calls it for
Module._resolveFilename, Module.prototype.requireWorksA require() or a require.resolve() of a dynamic path, for a name that the build didn't find
Module._load, Module.wrapWorksNothing. bun run doesn't call Module._load either
Module._extensions[".js"], and every other extension that a file in the executable hasThrows 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 eval and in an --external package. It doesn't matter whether the code that names it ever runs.
  • If no file does, all four are undefined for 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 a module that 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(), and require(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.

worker.ts
declare var self: Worker;
self.onmessage = event => self.postMessage(event.data * 2);
spawn.ts
const worker = new Worker("./worker.ts");
worker.onmessage = event => {
  console.log(event.data);
  worker.terminate();
};
worker.postMessage(21);
terminal
bun compile ./spawn.ts --outfile spawn && rm worker.ts && ./spawn
42

These all name a file that the compiler finds:

You writeThe 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 .hrefFrom 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, __filenameThe 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:

spawn.ts
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:

spawn.ts
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:

spawn.ts
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 load

Any 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.

index.js
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 that forEach, map, filter or find hands 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 what parts.push() adds outside of a loop
  • what require.resolve() or import.meta.resolve() returns for any such name or for a constant, handed on as it is, as in require(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.

terminal
bun compile ./app.ts ./node_modules/my-app-linux-x64-gnu/my-app.linux-x64-gnu.node --outfile app

An 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.

build.ts
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(), and onBeforeParse with 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, contents as 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, minify and sourcemap
  • which file of a package ends up in the executable: "exports" and conditions first, then "module" for an import and "main" for a require(). If anything requires the package, its imports use "main" too, so there's one copy. bun run always takes "main"
  • which loader a file gets: with { type: "file" } and the loader option come before the extension, and a file with an extension that Bun doesn't know is embedded as it is, for import and for require()

This differs:

bun build --compilebun compile
Files that a computed path or import.meta.resolve namesNot in the executable, so no hook runsIn the executable, so the hooks run for them
One import that a file has twiceonResolve runs twiceonResolve runs once
The "development" condition of "exports"Holds, except in a production buildHolds only with --conditions=development, as under bun run
One path in two namespacesOne moduleTwo modules
minifyRenames functions and classesShortens the same names in name, stack traces and messages
sourcemapA map is one of the outputsOnly external writes a map
metafileOne output for each entry pointOne output, the executable
format, bytecode, --bytecode-depth, --bytecode-orderChange what's in the executableNo effect. Nothing is bundled, and the executable has no bytecode
banner or footer with codeWorkThe 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.

settings.d.ts
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:

  • outputs has one entry, the executable, whatever the number of entry points. Its entryPoint is the first one.
  • bytes of the output is the size of your compiled program inside of the executable. That includes the parts of Bun's own modules, like node:fs, that are compiled with it. It doesn't include the Bun runtime.
  • bytesInOutput is 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.resolve names 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 }.

text.ts
function add(a: number, b: number): number {
  return a + b;
}
console.log(String(add), add.name, add.length);
terminal
bun compile --no-compile-function-source ./text.ts && ./text
function add() { [native code] } add 2

Code 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.

messages.ts
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));
}
terminal
bun messages.ts
TypeError: 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')
terminal
bun compile ./messages.ts && ./messages
TypeError: 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 messageWhat an executable quotes
… is not a functionWhat you called, as you wrote it: o.b, this.client.send, handlers[name]. Not the arguments
… is not a constructorThe same, behind new
… is not a function, of instanceofThe whole expression, where both sides consist of names as above: (evaluating 'value instanceof o'). Otherwise nothing: Object is not a function
… is not an objectThe 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 inNothing: 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.

names.ts
function create(key: string) {
  return {
    [key]: class {
      static label = this.name;
    },
  }[key]!;
}

const Shape = create("Shape");
console.log([Shape.label, Shape.name]);
terminal
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.

locals.ts
function create() {
  const double = (x: number) => x * 2;
  return double;
}

console.log([create().name]);
terminal
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.

raw.ts
console.log(String.raw`é`, String.raw`é`.length);
terminal
bun compile ./raw.ts && ./raw
é 1

That'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 writeCompiled
f.caller, f.argumentsnull. The build warns where it can see that f is a function
getFunction() of a call siteundefined, 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 blockThe 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 functionThe 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 experimentalDecoratorsThe 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 importSloppy 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. Each Worker has 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 a RangeError whose 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, in JSON.parse and structuredClone, or at the next function call that checks the stack. One case is left: a loop that allocates inside a try of 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 a Worker it ends that Worker alone: its parent gets an error event with the RangeError, and the exit code is 1. Move the loop into a function of its own. bun compile --aot-huge-heap compiles 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 a TypeError when there is no room for another thread. If another Worker that the same thread started is ending at that moment, the constructor returns, and the new Worker starts once that one has ended. One new Worker can wait for each one that is ending. If there is still no room then, it gets an error event with the same TypeError and 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, and this, stay alive until the function ends, as under bun run. In any other function, a parameter is like a local variable. Under bun run, a local variable that a closure captures lives as long as the function's other variables. So if a FinalizationRegistry is 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 WeakRef to it keeps answering, or so that its FinalizationRegistry callback doesn't run yet, read that variable somewhere.

Platforms#

bun compile compiles for arm64 and x64, on the platform that runs the build.

PlatformState
Linux x64, glibcTested
Linux arm64, Linux musl, macOS arm64, macOS x64Tested. Some tests still fail
Windows x64, Windows arm64Tested. Many tests still fail
FreeBSD, AndroidBun builds there. Not tested yet