Macros

Run JavaScript functions at bundle-time with Bun macros

Macros are JavaScript functions that run at bundle-time. Bun inlines their return values directly into your bundle.

As a toy example, consider this function that returns a random number.

random.ts
export function random() {
  return Math.random();
}

This is a regular function in a regular file, but you can use it as a macro:

cli.tsx
import { random } from "./random.ts" with { type: "macro" };

console.log(`Your random number is ${random()}`);

Macros are marked with import attribute syntax, a Stage 4 TC39 proposal for attaching additional metadata to import statements.

Bundle the file with bun build. Bun prints the bundled file to stdout.

terminal
bun build ./cli.tsx
console.log(`Your random number is ${0.6805550949689833}`);

The source code of the random function occurs nowhere in the bundle. Instead, the function runs during bundling and Bun replaces the call (random()) with its result. Since the source code is never included in the bundle, macros can safely perform privileged operations like reading from a database.

When to use macros#

For small things you would otherwise write a one-off build script for, bundle-time code execution can be easier to maintain. It lives with the rest of your code and runs with the rest of the build. Bun parallelizes it automatically, and if it fails, the build fails too.

If you find yourself running a lot of code at bundle-time though, consider running a server instead.

Import attributes#

Macros are import statements annotated with either:

  • with { type: 'macro' } — an import attribute, a Stage 4 ECMAScript proposal
  • assert { type: 'macro' } — an import assertion, an earlier incarnation of import attributes that has now been abandoned (but several browsers and runtimes already support it)

Security considerations#

You must explicitly import a macro with { type: "macro" } for it to run at bundle-time. These imports have no effect unless you call them, unlike regular JavaScript imports which may have side effects.

You can disable macros entirely with the --no-macros flag. It produces a build error like this:

error: Macros are disabled

foo();
^
./hello.js:3:1 53

To reduce the potential attack surface for malicious packages, Bun does not let code inside node_modules/**/* invoke macros. If a package attempts to invoke a macro, you'll see an error like this:

error: For security reasons, macros cannot be run from node_modules.

beEvil();
^
node_modules/evil/index.js:3:1 50

Your application code can still import macros from node_modules and invoke them.

cli.tsx
import { macro } from "some-package" with { type: "macro" };

macro();

Export condition "macro"#

When shipping a library containing a macro to npm or another package registry, use the "macro" export condition to provide a version of your package exclusively for the macro environment.

package.json
{
  "name": "my-package",
  "exports": {
    "import": "./index.js",
    "require": "./index.js",
    "default": "./index.js",
    "macro": "./index.macro.js"
  }
}

With this configuration, users can consume your package at runtime or at bundle-time using the same import specifier:

index.ts
import pkg from "my-package"; // runtime import
import { macro } from "my-package" with { type: "macro" }; // macro import

The first import resolves to ./node_modules/my-package/index.js; Bun's bundler resolves the second to ./node_modules/my-package/index.macro.js.

Execution#

When Bun's transpiler sees a macro import, it calls the function using Bun's JavaScript runtime and converts the return value into an AST node.

Macros run synchronously in the transpiler during the visiting phase, after the transpiler parses the file into an AST. They run in the order their calls appear in the file; the transpiler does not load or run a macro module until it reaches a call to one of its exports. The transpiler waits for each macro to finish before continuing, and awaits any Promise a macro returns.

Bun's bundler is multi-threaded, so macros execute in parallel in multiple spawned JavaScript "workers".

Dead code elimination#

The bundler performs dead code elimination after running and inlining macros. Given the following macro:

returnFalse.ts
export function returnFalse() {
  return false;
}

...bundling the following file produces an empty bundle, provided that the minify syntax option is enabled.

index.ts
import { returnFalse } from "./returnFalse.ts" with { type: "macro" };

if (returnFalse()) {
  console.log("This code is eliminated");
}

Serializability#

Bun's transpiler must be able to serialize the result of the macro to inline it into the AST. All JSON-compatible data structures are supported:

macro.ts
export function getObject() {
  return {
    foo: "bar",
    baz: 123,
    array: [1, 2, { nested: "value" }],
  };
}

Macros can be async, or return Promise instances. Bun's transpiler awaits the Promise and inlines the result.

macro.ts
export async function getText() {
  return "async value";
}

The transpiler implements special logic for serializing common data formats like Response and Blob.

  • Response: Bun reads the Content-Type and serializes accordingly. For example, it parses a Response with type application/json into an object and inlines text/plain as a string. Bun base64-encodes Responses with an unrecognized or undefined type.
  • Blob: As with Response, the serialization depends on the type property.

The result of fetch is Promise<Response>, so a macro can return it directly.

macro.ts
export function getObject() {
  return fetch("https://bun.com");
}

Functions and instances of most classes (except those listed earlier) are not serializable.

macro.ts
export function getText(url: string) {
  // this doesn't work!
  return () => {};
}

Arguments#

Macros can accept inputs, but only in limited cases. The value must be statically known. For example, the following is not allowed:

index.ts
import { getText } from "./getText.ts" with { type: "macro" };

export function howLong() {
  // the value of `foo` cannot be statically known
  const foo = Math.random() ? "foo" : "bar";

  const text = getText(`https://example.com/${foo}`);
  console.log("The page is ", text.length, " characters long");
}

However, if the value of foo is known at bundle-time (say, if it's a constant or the result of another macro), then the call is allowed:

index.ts
import { getText } from "./getText.ts" with { type: "macro" };
import { getFoo } from "./getFoo.ts" with { type: "macro" };

export function howLong() {
  // this works because getFoo() is statically known
  const foo = getFoo();
  const text = getText(`https://example.com/${foo}`);
  console.log("The page is", text.length, "characters long");
}

This outputs:

function howLong() {
  console.log("The page is", 1322, "characters long");
}
export { howLong };

Examples#

Embed latest git commit hash#

getGitCommitHash.ts
export function getGitCommitHash() {
  const { stdout } = Bun.spawnSync({
    cmd: ["git", "rev-parse", "HEAD"],
    stdout: "pipe",
  });

  return stdout.toString();
}

When you build it, Bun replaces the getGitCommitHash call with the result of calling the function:

import { getGitCommitHash } from "./getGitCommitHash.ts" with { type: "macro" };

console.log(`The current Git commit hash is ${getGitCommitHash()}`);

You're probably thinking "Why not use process.env.GIT_COMMIT_HASH?" Well, you can do that too. But can you do this with an environment variable?

Make fetch() requests at bundle-time#

This example makes an outgoing HTTP request with fetch(), parses the HTML response with HTMLRewriter, and returns an object containing the title and meta tags, all at bundle-time.

meta.ts
export async function extractMetaTags(url: string) {
  const response = await fetch(url);
  const meta = {
    title: "",
  };
  new HTMLRewriter()
    .on("title", {
      text(element) {
        meta.title += element.text;
      },
    })
    .on("meta", {
      element(element) {
        const name =
          element.getAttribute("name") || element.getAttribute("property") || element.getAttribute("itemprop");

        if (name) meta[name] = element.getAttribute("content");
      },
    })
    .transform(response);

  return meta;
}

Bun erases the extractMetaTags function at bundle-time and replaces it with the result of the function call. The fetch request happens at bundle-time, and Bun embeds the result in the bundle. Bun also eliminates the branch throwing the error since it's unreachable, provided that the minify syntax option is enabled.

import { extractMetaTags } from "./meta.ts" with { type: "macro" };

export const Head = () => {
  const headTags = extractMetaTags("https://example.com");

  if (headTags.title !== "Example Domain") {
    throw new Error("Expected title to be 'Example Domain'");
  }

  return (
    <head>
      <title>{headTags.title}</title>
      <meta name="viewport" content={headTags.viewport} />
    </head>
  );
};