Digging Deeper

JavaScript Libraries

Bundle browser libraries with lazy js while keeping app-owned scripts easy to debug.

By Guillermo Alvarez - Published - Updated

Split app code from library code

lazy js builds JavaScript libraries, not application scripts.

The sample app keeps app-owned browser code as a normal module:

app/public/javascript/application.js

That file is loaded directly by the layout:

<script type="module" src="/javascript/application.js"></script>

Library code is generated separately:

app/public/assets/lazyshaft/
app/public/assets/importmap.json

This keeps third-party code production-ready while user scripts stay easy to inspect in the browser with their normal filenames.

Declare library entrypoints

The default manifest is intentionally short:

[entrypoint.turbo]
module = "@hotwired/turbo"

[entrypoint.stimulus]
module = "@hotwired/stimulus"

Each [entrypoint.<name>] block declares one library bundle. module is the package import that esbuild should bundle.

Entrypoints also have a group. The default is default, so the short manifest above keeps the current behavior.

The command supplies defaults for the common paths:

package.json
app/public/assets/lazyshaft
app/public/assets/importmap.json

Run the pipeline from the application module root:

lazy js

The command prepares dependencies, runs the package manager install, bundles the manifest entrypoints, and writes the importmap.

Group shared dependencies

By default, all entrypoints are in the same group:

[entrypoint.turbo]
module = "@hotwired/turbo"

[entrypoint.stimulus]
module = "@hotwired/stimulus"

When multiple entrypoints in a group import the same resolved dependency, esbuild can write a shared chunk for that group. If there is nothing to share, the group behaves like a no-op and only the entrypoint bundles are written.

Use explicit groups when bundles should not share runtime chunks:

[entrypoint.public]
group = "site"
module = "public-library"

[entrypoint.admin]
group = "admin"
module = "admin-library"

With shared bundling enabled, lazy js runs esbuild once per group. The default manifest still runs one esbuild build. If [bundle] shared = false, groups do not affect output.

Package versions are resolved by the package manager and lockfile, not by the group setting. If two libraries resolve the same dependency to the same installed package, esbuild can share it inside a group. If they resolve different physical versions, esbuild treats them as different modules and does not merge them. To use two top-level versions of one package, declare package manager aliases in package.json; lazy js preserves existing dependency versions and only adds missing manifest packages as latest.

Import libraries from app scripts

App scripts import libraries by package name:

import "@hotwired/turbo"
import { Application } from "@hotwired/stimulus"

window.Stimulus = Application.start()

The generated importmap maps those names to bundled public assets:

{
  "imports": {
    "@hotwired/stimulus": "/assets/lazyshaft/stimulus-FNW3RRR2.js",
    "@hotwired/turbo": "/assets/lazyshaft/turbo-QDEQVE6U.js"
  }
}

Layouts inline the importmap through the asset helper:

{{importmap "/assets/importmap.json"}}
<script type="module" src="/javascript/application.js"></script>

The importmap must be available before the module script that uses it.

Use explicit imports when needed

By default, an entrypoint maps its module value in the importmap. If the browser import name should be different from the bundled module path, set imports:

[entrypoint.monaco]
module = "monaco-editor/esm/vs/editor/editor.api.js"
imports = ["monaco-editor"]

Application code can then import the shorter name:

import * as monaco from "monaco-editor"

Include workers and assets

Some libraries load extra JavaScript workers or supporting files dynamically. Declare those files in the same entrypoint block:

[entrypoint.monaco]
module = "monaco-editor/esm/vs/editor/editor.api.js"
imports = ["monaco-editor"]
workers = [
  "monaco-editor/esm/vs/editor/editor.worker.js",
  "monaco-editor/esm/vs/language/json/json.worker.js",
  "monaco-editor/esm/vs/language/css/css.worker.js",
  "monaco-editor/esm/vs/language/html/html.worker.js",
  "monaco-editor/esm/vs/language/typescript/ts.worker.js",
]
assets = [
  "node_modules/monaco-editor/min/vs/**/*",
]

workers are bundled as extra JavaScript outputs. assets are copied into the lazyshaft output directory.

Use extra_files as a general alias when the extra JavaScript file is not a worker:

[entrypoint.library]
module = "library"
extra_files = ["library/extra-runtime.js"]

Modify the manifest from Go

Tools can edit js.toml through the same manifest and pipeline code used by lazy js:

import jscommand "github.com/golazy/lazy/commands/js"

editor, err := jscommand.OpenManifest(appDir)
if err != nil {
	return err
}
if err := editor.AddEntrypoint(jscommand.Entrypoint{
	Name:    "monaco",
	Group:   "editors",
	Module:  "monaco-editor/esm/vs/editor/editor.api.js",
	Imports: []string{"monaco-editor"},
}); err != nil {
	return err
}
if err := editor.Close(); err != nil {
	return err
}

Close writes the manifest, runs the JavaScript pipeline, and returns an error if dependency preparation, installation, or bundling fails. On failure it restores js.toml, package manager metadata, the importmap, and the lazyshaft output directory to their previous state. The returned *jscommand.ManifestCloseError includes the manifest diff and the complete pipeline output.

Commit generated outputs

lazy js writes files that production Go builds embed:

package-lock.json
app/public/assets/importmap.json
app/public/assets/lazyshaft/*.js

Commit those files with the manifest and package changes. Do not commit node_modules.

Before committing JavaScript-related changes, run:

lazy js
go test ./...

For release or deployment checks, also run the race detector, go vet, and a production build.