Bun vs Node

What behaves differently in JSLab's Bun runtime and in the Browser & Node APIs runtime compared with a plain Node process.

JSLab’s Bun runtime runs your code with the Bun that ships inside JSLab: version 1.4.0 at the time of writing. Help → About JSLab shows the version your copy includes. Bun aims to be Node-compatible, and most code written for Node runs unchanged. These are the differences you’re most likely to notice.

The engine

Bun runs on JavaScriptCore, the engine in Safari, not V8. Anything tied to V8 behaves differently:

  • The v8 module is only partly available, and heap snapshots don’t use V8’s format.
  • Node’s --inspect flags don’t apply.
  • Stack trace text is formatted differently. JSLab maps positions back to your code and renders stacks itself, so the output panel looks the same either way.

Built-in modules

  • node:inspector, node:trace_events and node:repl are partial or missing.
  • node:vm works, with edge-case differences in how contexts behave.
  • worker_threads supports most APIs; some options are missing.
  • Native addons built on N-API generally work. Addons that use V8’s C++ API directly don’t.

Module resolution

  • Resolution is looser than Node’s, closer to a bundler’s: extensionless imports such as import "./util" work, and require is allowed inside ES modules.
  • Packages you install from Tools → NPM Packages… are available in every tab without a restart, through NODE_PATH.
  • With a working directory set, <working directory>/node_modules is searched before JSLab’s own packages.

Environment and files

  • JSLab turns off Bun’s own .env loading and reads a working folder’s .env itself, so the result is the same in every run. See Environment variables for the order the layers apply in.
  • A working folder’s .env is read only up to 1 MiB. Anything past that is ignored.
  • With a working directory, process.cwd(), __dirname and import.meta.dir are that folder, and __filename and import.meta.path are <working directory>/<tab title>.<extension>. Relative imports written as string literals resolve there too. An import whose path is computed at run time, such as import(name), resolves against the run’s own folder instead.
  • BUN_OPTIONS is removed from every layer of a run’s environment and never reaches your code. NODE_OPTIONS is passed through, but the bundled Bun ignores it, so it has no effect on a run.
  • A working folder’s bunfig.toml is never applied: JSLab launches every run with --config=/dev/null.

Ending a run

process.exit() ends the run once its output is delivered. Code that catches the exit, for example in try/catch, doesn’t keep running: later output is dropped, and timers or servers it starts are stopped.

Installing packages

  • Install scripts, such as postinstall, are blocked unless Allow install scripts is on in the package sheet or Settings → NPM.
  • npm operations use JSLab’s own .npmrc, set in Settings → NPM, and never your ~/.npmrc.
  • A git dependency over SSH authenticates through your SSH agent (SSH_AUTH_SOCK) or GIT_SSH_COMMAND, because npm operations don’t see the ~/.ssh folder in your home.

See npm packages.

In the Browser & Node APIs runtime

This runtime is a browser page with Node added, not a Node process, so parts of Node behave differently:

  • Only the async APIs work for files and processes. fs/promises, the callback-style fs functions and child_process.exec, execFile and spawn work, bridged out to JSLab. The synchronous versions, such as fs.readFileSync, refuse with a message naming the async alternative and offering to switch the tab to Bun. http, net, tls, dgram, worker_threads and vm refuse the same way.
  • process is a snapshot. env, cwd(), platform, argv and versions are captured when the page loads, and nextTick runs on the microtask queue. process.memoryUsage() isn’t available; use the Bun runtime for it.
  • Shared built-ins aren’t always the same object. stream, events and buffer each carry their own copy of what they depend on. So new Readable() instanceof EventEmitter can be false, and a Buffer from one module may not be recognized by another. Code that branches on instanceof across these modules can take the wrong branch.
  • A command’s environment can differ. A child_process call made without an explicit env inherits JSLab’s own environment, not the layered one a Bun run sees. Pass env yourself when it matters.
  • fetch has no CORS. It’s routed through JSLab, so cross-origin requests succeed.

In both browser runtimes

  • alert, confirm and prompt can’t block. An embedded web view gives them no way to wait for you. alert shows a JSLab notice and returns at once, confirm always returns false, and prompt always returns null. A console warning says so once per run.
  • import * as ns can show extra keys. Packages are bundled in two halves that join inside the page, and a namespace object can carry the default export’s own keys as well. The values are the same as under Bun; only listing the namespace’s keys, or spreading it into an object, differs.
  • In the Browser runtime, fetch is the page’s own, and CORS applies exactly as it would in Safari.
  • The speaker icon tracks live audio only. See The Web View.

Last updated