als-pack
v0.1.2
Published
In-memory, modifier-first JS bundler that outputs an eval-ready string. Build once, bundle many.
Maintainers
Readme
als-pack
In‑memory, modifier‑first JS bundler (eval‑ready).
als-pack is a tiny, dependency‑light bundler focused on on‑the‑fly packaging of ES modules into a single string you can eval or run in a sandbox.
It parses your ESM graph, rewrites imports to a lazy runtime, and lets you apply modifiers to transform code (JSX/TS/minify/instrumentation) at bundle time.
- ⚡ In‑memory: build once, generate many bundles from the same graph.
- 🧩 Modifier‑first: plug any transforms without complex config.
- 🧪 Eval‑ready: output is a single template literal string.
- 🌐 Node + browser: file resolvers included; bare specifiers can be handled externally.
- 🧭 Deterministic: no dev server, no HMR, no hidden async; what you see is what runs.
Use it for editors, REPLs, SDKs, demos, and anywhere a heavyweight toolchain is overkill.
Installation
npm i als-pack
# or
pnpm add als-pack
# or
yarn add als-packUsage
Use in Nodejs:
import { pack } from 'als-pack';Or use in browser:
<script src="/node_modules/als-pack/pack.js"><script>
or
<script src="/node_modules/als-pack/pack.min.js"><script>Quick start
Minimal two‑file example
project/
entry.js
util.jsutil.js
export const greet = (name) => `Hello, ${name}!`;entry.js
import { greet } from "./util.js";
export default () => greet("world");bundle & run (Node):
import { pack } from "als-pack";
const p = await pack(resolve("./mini-project/entry.js"));
p.build();
// Get a *pure expression* that evaluates to the exports object:
const expr = p.bundle();
// Execute in a sandbox (recommended) and grab exports:
const { default: greet } = eval(expr);By default
bundle(varName)returns a statementconst ${varName} = (bundleExpr)which is handy for REPLs, but most apps preferbundle()to get a pure expression and explicitly capture the value.
Result (bundle expression):
new (class {
constructor() {
void this.entry
}
get util() {
if(this._util) return this._util
const greet = (name) => `Hello, ${name}!`;
this._util = {greet:greet}
return this._util
}
get entry() {
if(this._entry) return this._entry
const { greet } = this.util;;
this._entry = {default:() => greet('world')}
return this._entry
}
})().entryAnd pack after build should look like this:
{
commonPath: "/examples/demo",
defaultAlias: "main",
entry: "entry",
modifiers: [],
modules: {util: {…}, entry: {…}},
order: (2) ['/examples/demo/util.js', '/examples/demo/entry.js'],
orderByName: (2) ['util', 'entry'],
sep: "/",
}API
pack(entry: string): Promise<Pack>
Builds a module graph starting at entry. Detects environment (Node/browser) and uses the proper file/resolve helpers.
Throws on cyclic dependencies during preparation (message includes importer, imported and original import string).
class Pack
Properties
modules: Record<string, Module>— internal module table (afterbuild()it containsmodifiedsources andexportKeys).order: string[]— absolute file paths in topological order (leaf → entry).orderByName: string[]— same as above but normalized to export names.commonPath: string— resolved common root of all module paths.entry: string— normalized name of the entry module.modifiers: Array<Modifier>— transformation pipeline applied per‑module during bundling (see below).
Methods
build(): void
Prepares modules for bundling (rewrites imports/exports, computes exportKeys, throws on cycles). Must be called before bundle().
bundle(varName): string
Returns bundle code string.
- When
varNamepassed, returns a statement like:
Useful in REPLs;const ${varName} = (/* bundle expression */)eval(code)will declare those bindings in the current scope. - When no
varName, returns a pure expression that evaluates to an exports object:const expr = pack.bundle(); const exportsObj = (new Function(`return (${expr})`))();
Module shape (internal)
A Module object (internal, for advanced users and modifiers):
type Module = {
modulePath: string; // absolute path
expName: string; // normalized export name ('dir_file_js' etc.)
imported: Array<{
importString: string;
relativePath: string;
fullPath: string; // '' for external/builtin
command: 'import' | 'export';
isBuiltin: boolean; // node:* modules
isExternal: boolean; // bare specifiers (kept external)
}>;
exported: Array<{ asname: string; name: string; exportString: string; declaration: boolean; }>;
modified: string; // source with imports/exports rewritten
exportKeys: Array<[asname: string, name: string]>; // collected during prepare
};Modifiers
A modifier transforms each module right before it is placed into the bundle.
Signature:
type Modifier = ([modname, code, toReturn]: [string, string, string]) => [string, string, string];modname— the normalized module name (same as getter name).code— final, prepared source of the module (imports/exports already rewritten).toReturn— string representation of{ ... }returned from the module getter.
! The modName has to remain the same! It passed only for understanding which module is it.
Example: inject a feature flag
pack.modifiers.push(([m, c, t]) => [m, c.replace('ENABLE_X=false', 'ENABLE_X=true'), t]);Example: JSX (using @babel/standalone in the browser)
pack.modifiers.push(([m, c, t]) => {
const out = Babel.transform(c, { presets: ['react'] });
return [m, out.code, t];
});Modifiers run per bundle call. You can generate multiple different bundles from the same
Packby changingmodifiersbetween calls.
Resolving modules
- Relative/absolute imports are read from the file system (Node) or your fetch adapter (browser).
- Built‑in Node modules (
node:*or bare names likefs,path) are treated as externals and removed from the code. You can provide bindings yourself (e.g. viaglobalThisor a small prelude). - Bare specifiers (e.g.
react) are treated as externals by default. To include a package in the graph, pass a full file path to its entry instead of a bare name, or use the browser resolver to turn a specifier into a URL.
Browser helper: getNodeModule(spec: string): Promise<string>
Resolves spec to /node_modules/<pkg>/<target> by reading /node_modules/<pkg>/package.json over fetch and using exports / module / main (simplified).
import { getNodeModule } from 'als-pack/lib/browser/node-module.js';
const url = await getNodeModule('react/jsx-runtime');
// → '/node_modules/react/dist/jsx-runtime.js'Node helper: getNodeModule(spec: string): Promise<string | "node:*">
Resolves using import.meta.resolve (Node ≥ 20) or Module.createRequire().resolve and converts file:// URLs to filesystem paths.
import { getNodeModule } from 'als-pack/lib/node-module.js';
console.log(await getNodeModule('node:path')); // 'node:path'
console.log(await getNodeModule('react')); // '/abs/path/to/.../react/...'Runtime semantics (how it differs from ESM)
- Lazy module getters. Each module is generated as a getter on an internal runtime object. The module body executes on first access to that module from another module.
- Bare imports
import 'x'becomevoid this.x;inside the importer. This means side‑effects ofxrun when the importer module is first evaluated, not eagerly at graph load like in native ESM. - Cyclic dependencies are not allowed. als‑pack throws during preparation if a module depends on another that isn’t ready (message shows importer, imported, and the import string). There’s no simulation of live‑bindings.
- Top‑level await in regular modules is not executed/awaited by the runtime. If you need async, return a
Promisefrom the entry module and await the bundle’s result at the call site.
Limitations
- Do not rename exported identifiers in modifiers. If a modifier changes names that other modules import, the prepared graph may break.
- Combined declaration export like
export const A = 1, B = 2;is not supported. Use either separate declarations orexport { A, B };. - No live‑binding simulation across modules (ESM semantics are approximated with object snapshots).
- Bare imports are lazy (see semantics). If you rely on eager side‑effects, place the bare import at the top of the importer module.
Limitations for node_modules
Browser resolver (getNodeModule for browsers)
- Assumes packages are hosted under
/node_modules/<package>/package.jsonand accessible viafetch; this requires proper static hosting and CORS configuration. - Only a subset of the
exportsfield is honored (e.g.,browser,import,module,default). Conditional exports (e.g.,{ "production": ..., "development": ... }), pattern exports, and advanced condition matching are not fully implemented. - Scoped packages (
@scope/name) and subpath exports (pkg/subpath) are supported, but resolution semantics are simplified compared to Node’s resolver. - Fallbacks rely on
moduleormain; if neither is present, the resolver guessesindex.js. Extension and directory resolution (index.*, missing extensions) are not performed beyond what the package declares. - Does not automatically resolve transitive dependencies of the resolved file; it only returns a URL for the requested specifier.
- Built‑in Node modules are mapped to
node:*URLs; there are no polyfills shipped for the browser. - No support for the package
"imports"map, self‑references ("name": "pkg"+import "pkg/..."), or Node’s conditional flags (e.g.,require,node,deno,workerd). - Network errors or hosts that do not support
HEADrequests must be handled by usingGET; servers that block/package.jsoncannot be resolved.
Node resolver (getNodeModule for Node.js)
- Relies on
import.meta.resolve(Node ≥ 20) when available; otherwise falls back toModule.createRequire(...).resolve. Environments that restrict either API may not be supported. - Returns
node:*for built‑ins; no polyfills are provided. - The function delegates real package resolution to Node; custom resolution conditions or non‑standard loaders are not considered.
- The returned value is a filesystem path when Node returns a
file://URL. Onlynode:andfile:schemes are supported. - Does not attempt to implement Node’s entire resolution algorithm; behavior is whatever your Node version provides (differences may exist across Node versions).
