Skip to main content

Packaging and Build

An extension ships as one JavaScript file plus its manifest and any UI assets. This page explains the two rules that decide whether your build output will load at all, and gives you a working configuration.

The one-bundle rule

manifest.main points at a single file, and the host evaluates exactly that file. There is no module resolution: nothing the file references at runtime can be loaded, because the realm has no module system and no filesystem.

This is a rule about your output, not your source. Split your source across as many modules as you like -- a bundler inlines them:

src/main.ts ──┐
src/verse.ts ──┼── esbuild ──▶ dist/main.js ← manifest.main points here
src/format.ts ─┘

The failure mode to recognise: running bare tsc over a multi-file project emits one output file per source file, with require() calls between them. Every one of those calls throws inside the realm. The extension loads and then dies on its first import.

{
"main": "dist/main.js"
}

main must be a package-relative path. Absolute paths, file: URLs and http(s): URLs are rejected, and a path that resolves outside the extension directory (../../../evil.js) is rejected on the resolved path, not just the literal one.

CommonJS output, ESM source

The host wraps your bundle like this before evaluating it:

(function (exports, module, require) {
/* your bundle */
})

So the shipped file must be CommonJS. export syntax in dist/main.js is a parse error -- the extension fails to load with no useful stack, because nothing ever ran.

Your source should still be ESM. Write:

export async function activate(api: BibleExtensionAPI): Promise<void> {}
export async function deactivate(): Promise<void> {}

and let the bundler convert it. Do not change format: 'cjs' in the build config to "match" your source style.

The build configuration

This is what npx @bible/create-extension writes as esbuild.config.mjs. If you are adding a build to an existing project, start here:

import { build, context } from 'esbuild';

const options = {
entryPoints: ['src/main.ts'],
outfile: 'dist/main.js',
bundle: true,

// CommonJS, NOT ESM -- see "CommonJS output, ESM source" above.
format: 'cjs',

// A build-time tripwire: makes `import fs from 'fs'` fail with
// "Could not resolve" instead of failing at runtime in a realm that
// has no filesystem.
platform: 'neutral',
mainFields: ['module', 'main'],
conditions: ['import', 'default'],

target: 'es2020',
sourcemap: true,
logLevel: 'info',
};

if (process.argv.includes('--watch')) {
const ctx = await context(options);
await ctx.watch();
} else {
await build(options);
}

Why platform: 'neutral' is the load-bearing line

esbuild's default (platform: 'node') treats Node built-ins as external and leaves the require('fs') in your bundle. It builds cleanly and then fails at runtime, inside the sandbox, with an error whose cause is three dependencies away from anything you wrote.

platform: 'neutral' refuses to resolve them at build time instead:

✘ [ERROR] Could not resolve "fs"

node_modules/some-dep/index.js:1:23:
1 │ import { readFile } from "fs";

That message is the honest answer to "can this dependency run as an extension?" -- and the answer is no. Do not work around it with external: ['fs']; that only moves the failure to a place where you cannot diagnose it.

target: 'es2020'

The realm is QuickJS. es2020 is a safe floor. Do not target esnext -- you will get syntax the engine may not accept, and the failure looks like a parse error in a minified bundle.

TypeScript's role

In the scaffold, tsc runs with noEmit: true. Typechecking and bundling are separate jobs: tsc checks, esbuild emits.

{
"scripts": {
"typecheck": "tsc --noEmit",
"build": "npm run typecheck && node esbuild.config.mjs",
"watch": "node esbuild.config.mjs --watch"
}
}

The scaffold's tsconfig sets "types": [] and "lib": ["ES2020"], which drops @types/node from your extension source. That matters on its own: with @types/node in scope, process.env and require() typecheck cleanly in code that cannot possibly run. Keep @types/node in devDependencies for the build scripts, but out of src/.

What ships in a package

my-extension/
extension.json # manifest -- required
dist/
main.js # the single bundle -- manifest.main points here
main.js.map # optional, useful for reading stack traces
ui/
index.html # panel UI, served over ext-ui://
styles.css
README.md
LICENSE

Not shipped: src/, node_modules/, tsconfig.json, esbuild.config.mjs, tests. They are build inputs, and everything they contribute is already inlined in dist/main.js.

Panel UI assets are the exception to the one-bundle rule. They are served to a sandboxed iframe over the ext-ui:// protocol as ordinary files, so ui/ can contain as many HTML, CSS and JS files as you like -- but they must all be local. External URLs are blocked by the panel's Content Security Policy. See Building Extension UI.

Verifying the output

Three checks worth running before you publish:

# 1. It typechecks and builds.
npm run build

# 2. There is exactly one output file, and it is CommonJS.
head -1 dist/main.js # should not start with `import` or `export`

# 3. Every hook returns something valid within its declared permissions.
npm run smoke

Note what the third check does not cover: the smoke CLI runs your code in Node, where require('fs') and process work. It will not catch a sandbox violation. The build tripwire above is your first line of defence, and sideloading into the app is the only complete one. See Testing Extensions.

Next steps