End-to-end guide for bundling TypeScript + assets with Bun and running them in Goja.
This playbook shows how to manage npm dependencies with Bun, bundle TypeScript and assets into a CommonJS bundle, and execute the result inside Goja using the Go-provided require loader. It is written for a Go developer who needs a repeatable pipeline that turns a modern JS/TS project into a single embedded artifact.
The cmd/bun-demo runtime path now also supports HashiCorp plugin-backed modules through the same --plugin-dir and --allow-plugin-module flags exposed by the REPLs. That means a bundled CommonJS app can require("plugin:...") as long as the runtime entrypoint opts into plugin discovery.
The bundling pipeline is a simple, reproducible assembly line: Bun installs dependencies, esbuild bundles the TS entrypoint into a single CommonJS file, Go embeds that file, and Goja require() executes it at runtime. This keeps the runtime loader focused on CommonJS and avoids shipping a full Node runtime.
High-level flow:
cmd/bun-demo/js/src.make -C cmd/bun-demo js-install to install deps via the demo pipeline.cmd/bun-demo/js/dist/bundle.cjs.go generate ./cmd/bun-demo.cmd/bun-demo/assets/bundle.cjs for embedding.require inside Goja.Bundling for Goja usually fits into one of two models. The demo uses Model A because it keeps the runtime loader simple and uses the existing CommonJS require you already provide.
Model A: Single CommonJS bundle (recommended)
bundle.cjs) embeds all npm-managed code.fs, exec, database).Model B: Split bundles + runtime module graph (optional)
require at runtime.Pros/cons matrix:
| Model | Pros | Cons |
|---|---|---|
| A: Single bundle | Simplest runtime loader; single embedded artifact; easy to deploy | Larger bundle; rebuild required for any change; harder to tree-shake at runtime |
| B: Split bundles | Smaller updates per module; can lazily load modules; easier to share code across bundles | Requires more complex loader; higher runtime I/O; more moving parts to embed |
CommonJS is a good fit for Goja because require() and module.exports are already part of the Goja NodeJS compatibility layer. Your bundled output should be CommonJS so that Goja can execute it without an ESM loader.
CommonJS patterns you can rely on:
require() resolves modules through the loader you provide.module.exports and exports control what the bundle exposes.require() calls reuse the same instance.Example module export:
function run() {
return "hello from bundle";
}
module.exports = { run };
Example consumer inside Goja:
factory, err := engine.NewRuntimeFactoryBuilder().
WithRequireOptions(require.WithLoader(embeddedSourceLoader)).
Build().
Build()
if err != nil {
log.Fatalf("build factory: %v", err)
}
rt, err := factory.NewRuntime(engine.WithStartupContext(context.Background()), engine.WithLifetimeContext(context.Background()))
if err != nil {
log.Fatalf("new runtime: %v", err)
}
defer rt.Close(context.Background())
mod, err := rt.Require.Require("./assets/bundle.cjs")
if err != nil {
log.Fatalf("require bundle: %v", err)
}
exports := mod.ToObject(rt.VM)
run, ok := goja.AssertFunction(exports.Get("run"))
if !ok {
log.Fatalf("bundle export 'run' is not a function")
}
For repeated runtime creation (worker pools, high-throughput request handlers), use a reusable factory to reduce setup overhead:
factory, err := engine.NewRuntimeFactoryBuilder().
WithRequireOptions(require.WithLoader(embeddedSourceLoader)).
Build().
Build()
if err != nil {
log.Fatalf("build factory: %v", err)
}
rt, err := factory.NewRuntime(engine.WithStartupContext(context.Background()), engine.WithLifetimeContext(context.Background()))
if err != nil {
log.Fatalf("new runtime: %v", err)
}
defer rt.Close(context.Background())
mod, err := rt.Require.Require("./assets/bundle.cjs")
if err != nil {
log.Fatalf("require bundle: %v", err)
}
_ = mod
A self-contained demo keeps the JS workspace, bundle, and Go entrypoint in one directory so it is easy to copy or vendor. This is the layout used by cmd/bun-demo:
## Plugin-backed runtime extensions
If your bundled code wants to call plugin-backed modules in addition to built-in native modules, build the plugin into the default plugin tree and start the demo with the same flags used by the REPLs.
Example:
```bash
mkdir -p ~/.go-go-goja/plugins/examples
go build -o ~/.go-go-goja/plugins/examples/goja-plugin-examples-greeter ./plugins/examples/greeter
go run ./cmd/bun-demo --plugin-dir ~/.go-go-goja/plugins/examples
Inside the bundle, the JavaScript side can then call:
const greeter = require("plugin:examples:greeter")
If you want to constrain the runtime to a known plugin module name, add:
--allow-plugin-module plugin:examples:greeter
cmd/bun-demo/ Makefile main.go assets/ bundle.cjs js/ package.json bun.lock tsconfig.json src/ main.ts assets/ logo.svg types/ assets.d.ts goja-modules.d.ts
## Step-by-step setup
Each step focuses on a single moving part: the JS workspace, the TypeScript config, the bundler, and the Go loader. Keep each file minimal and focused so the workflow is easy to reason about.
### 1) Initialize the Bun workspace
The Bun workspace manages npm dependencies and scripts. You can create the workspace inside `cmd/bun-demo/js` and install common libraries.
```bash
cd cmd/bun-demo/js
bun init
bun add dayjs lodash
bun add -d typescript esbuild @types/lodash
TypeScript should target ES5 so Goja can execute the output. Native module declarations are generated by cmd/gen-dts, while asset declarations remain hand-authored.
cmd/bun-demo/js/tsconfig.json:
{
"compilerOptions": {
"target": "ES5",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react",
"jsxFactory": "jsx",
"jsxFragmentFactory": "Fragment",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
cmd/bun-demo/js/src/types/goja-modules.d.ts:
// Code generated by go-go-goja/cmd/gen-dts. DO NOT EDIT.
declare module "database" {
export function begin(): DatabaseTransaction;
export function close(): void;
export function configure(driverName: string, dataSourceName: string): void;
export function exec(query: string, ...args: unknown[]): unknown;
export function query(query: string, ...args: unknown[]): unknown;
interface DatabaseExecResult {
success: boolean;
rowsAffected?: number;
lastInsertId?: number;
error?: string;
}
interface DatabaseTransaction {
query(query: string, ...args: unknown[]): Array<Record<string, unknown>>;
exec(query: string, ...args: unknown[]): DatabaseExecResult;
commit(): { success: boolean; error?: string };
rollback(): { success: boolean; error?: string };
}
}
declare module "exec" {
export function run(cmd: string, args: string[]): string;
}
declare module "fs" {
export function readFileSync(path: string): string;
export function writeFileSync(path: string, data: string): void;
}
cmd/bun-demo/js/src/types/assets.d.ts:
declare module "*.svg" {
const content: string;
export default content;
}
Regenerate declarations after changing native module descriptors:
cd go-go-goja
go generate ./cmd/bun-demo
git diff --exit-code cmd/bun-demo/js/src/types/goja-modules.d.ts
The entrypoint can use modern TS syntax, import assets, and export a single run function for Goja to invoke.
cmd/bun-demo/js/src/main.ts:
import dayjs from "dayjs";
import _ from "lodash";
import logoSvg from "./assets/logo.svg";
function countTags(svg: string): number {
return (svg.match(/</g) || []).length;
}
export function run(): string {
var items = [1, 2, 3, 4];
var sum = _.sum(items);
var svgTags = countTags(logoSvg);
return [
"date=" + dayjs().format("YYYY-MM-DD"),
"sum=" + sum,
"svgLen=" + logoSvg.length,
"svgTags=" + svgTags,
].join(" ");
}
Bun manages dependencies, but esbuild performs the bundling so you can control the output format and asset loaders. The key flags are --format=cjs (CommonJS), --target=es5, and --loader:.svg=text.
cmd/bun-demo/js/package.json:
{
"name": "goja-bun-demo",
"private": true,
"type": "commonjs",
"scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --format=cjs --target=es5 --loader:.svg=text --outfile=dist/bundle.cjs --external:fs --external:exec --external:database",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dayjs": "^1.11.10",
"lodash": "^4.17.21"
},
"devDependencies": {
"@types/lodash": "^4.14.202",
"esbuild": "^0.25.0",
"typescript": "^5.4.5"
}
}
The demo Makefile ties the steps together and keeps the bundling commands co-located with the demo.
cmd/bun-demo/Makefile:
JS_DIR=js
BUN_ASSET_DIR=assets
BUN_ASSET=$(BUN_ASSET_DIR)/bundle.cjs
DAGGER?=dagger
DAGGER_RUN=$(DAGGER) run --
DAGGER_PIPELINE=$(DAGGER_RUN) go run ./dagger --project-dir .
js-install:
$(DAGGER_PIPELINE) deps
js-typecheck:
$(DAGGER_PIPELINE) typecheck
js-bundle:
$(DAGGER_PIPELINE) bundle
go-run-bun: js-bundle
go run .
Go embeds the bundle and provides a loader for Goja's CommonJS require. The loader maps bundle paths to embedded content and errors out if the file does not exist.
cmd/bun-demo/main.go (excerpt):
//go:embed assets/bundle.cjs
var bundleFS embed.FS
func embeddedSourceLoader(path string) ([]byte, error) {
cleaned := strings.TrimPrefix(path, "./")
cleaned = strings.TrimPrefix(cleaned, "/")
data, err := bundleFS.ReadFile(cleaned)
if err == nil {
return data, nil
}
if errors.Is(err, fs.ErrNotExist) {
return nil, require.ModuleFileDoesNotExistError
}
return nil, err
}
factory, err := engine.NewRuntimeFactoryBuilder().
WithRequireOptions(require.WithLoader(embeddedSourceLoader)).
Build().
Build()
if err != nil {
log.Fatalf("build engine factory: %v", err)
}
rt, err := factory.NewRuntime(engine.WithStartupContext(context.Background()), engine.WithLifetimeContext(context.Background()))
if err != nil {
log.Fatalf("create runtime: %v", err)
}
defer rt.Close(context.Background())
mod, err := rt.Require.Require("./assets/bundle.cjs")
The quickest validation is running declaration generation and then the demo targets. This confirms both static types and runtime bundle behavior.
go generate ./cmd/bun-demo
git diff --exit-code cmd/bun-demo/js/src/types/goja-modules.d.ts
Then run the demo:
make -C cmd/bun-demo go-run-bun
Expected output (example):
date=2026-01-10 sum=5 svgLen=191 svgTags=4
The split-bundle workflow demonstrates a runtime module graph: app.js is bundled, but it still require()s modules/metrics.js at runtime. This keeps modules decoupled and makes it easier to ship independent bundles while still relying on CommonJS execution in Goja.
The demo uses a separate entrypoint and output directory:
cmd/bun-demo/js/src/split/app.ts is the entrypoint.cmd/bun-demo/js/src/split/modules/metrics.ts is bundled separately and loaded via require.cmd/bun-demo/assets-split/.Build and run the split demo:
make -C cmd/bun-demo go-run-bun-split
Expected output (example):
mode=split date=2026-01-10 svgLen=191 svgTags=4 svgCsum=13804
Once the pipeline works for the demo, you can scale it to larger projects by treating the JS workspace like any other app repository. The key is to keep the output CommonJS and keep host-provided modules external.
Recommended practices:
--loader:.json=text, --loader:.txt=text, etc.).tsconfig.json only if your bundler supports them.--external: flags for any modules provided by Go or injected by the host.Most issues come down to module format, transpilation target, declaration drift, or missing loaders.
| Problem | Cause | Solution |
|---|---|---|
Unexpected token 'export' | Bundle not emitted as CommonJS | Ensure --format=cjs and "type": "commonjs" |
SyntaxError: Unexpected identifier | Output targets unsupported runtime syntax | Ensure --target=es5 and avoid unsupported runtime features |
Cannot find module 'fs' | Native modules were bundled or unresolved at runtime | Keep native modules external (--external:fs, --external:exec, --external:database) |
Declaration diff appears after go generate ./cmd/bun-demo | Generated declaration file drifted | Review and commit the generated goja-modules.d.ts diff |
| SVG import type errors | Asset ambient declaration missing | Keep declare module "*.svg" in assets.d.ts |
This section summarizes the integration points between Goja and the bundled output. Use these as the stable contract for your bundling pipeline.
Bundle entrypoint
run() function on module.exports or exports.--format=cjs).--target=es5).Goja loader
engine.NewRuntimeFactoryBuilder().WithRequireOptions(require.WithLoader(loader)).Build() configures runtime creation.factory.NewRuntime(engine.WithStartupContext(context.Background()), engine.WithLifetimeContext(context.Background())) creates an owned runtime that should be closed explicitly.require.ModuleFileDoesNotExistError signals to Goja that the module path is missing.Makefile targets
js-install: install npm dependencies via the demo Dagger pipeline.js-typecheck: run TypeScript checks in the demo pipeline.js-bundle: produce dist/bundle.cjs and copy to assets/bundle.cjs.js-bundle-split: produce dist-split outputs and copy to assets-split.go-run-bun: build the bundle and run the Go demo.go-run-bun-split: build the split outputs and run the split demo entrypoint.
Native module declarations are generated by go generate ./cmd/bun-demo; there are no Makefile wrappers for this workflow.Use this checklist before shipping a new bundle or updating dependencies.
make -C cmd/bun-demo js-typecheck to ensure TS types are clean.go generate ./cmd/bun-demo and review/commit any goja-modules.d.ts diff to ensure native module declarations are up to date.make -C cmd/bun-demo js-bundle and confirm assets/bundle.cjs updates.make -C cmd/bun-demo go-run-bun and confirm SVG metrics output.make -C cmd/bun-demo go-run-bun-split and confirm the split demo output.cmd/bun-demo/js/package.json to ensure native modules remain external.