@statewalker/webrun-modules
v0.2.0
Published
Isomorphic module/dependency server: resolve + download + transform npm and authored TS/JS into browser-runnable ESM served from a local FilesApi cache, with no runtime CDN dependency
Readme
@statewalker/webrun-modules
Run authored TypeScript/JavaScript apps and the arbitrary npm modules they import in the browser (or Node) with no runtime CDN dependency — and with no install step: packages are downloaded, resolved, and transformed on request.
Given a TS/JS entry, webrun-modules resolves, downloads, and transforms the
dependency graph — from npm (or a CDN) at resolve time only — and serves the
transformed, browser-runnable ESM from a local
FilesApi cache. Every
internal import is rewritten to a same-origin local URL, so nothing is fetched
from a third party at run time. The result is always a URL any module-compatible
runtime can import directly — no bespoke client loader.
It is isomorphic: the same code runs in a browser ServiceWorker and in Node —
the only difference is which FilesApi backend you inject.
Install
npm add @statewalker/webrun-modulesQuick start
import { newModuleServer, npmRegistrySource } from "@statewalker/webrun-modules";
import { NodeFilesApi } from "@statewalker/webrun-files-node";
const server = newModuleServer({
cache: new NodeFilesApi({ rootDir: "./.modules-cache" }),
});
// Resolve an npm package (with optional subpath) to an importable URL:
const zod = await server.resolve({ pkg: "zod" });
// → { url: "/[email protected]/lib/index.mjs", target: "browser" }
// Serve it — `server.fetch` is a standard Web handler, mountable anywhere:
Deno.serve(server.fetch); // Deno
export default { fetch: server.fetch }; // Bun / Cloudflare
self.addEventListener("fetch", (e) => e.respondWith(server.fetch(e.request))); // ServiceWorkerThere is no separate "install" call. The first time a module URL is requested
— via resolve, prime, or a direct fetch — its package is downloaded and
transformed on demand, then cached. Requesting a URL for a package that isn't in
the cache yet just works:
// Nothing primed, nothing resolved — this single fetch downloads + transforms + serves:
const res = await server.fetch(new Request("http://host/[email protected]/index.js"));
// → 200, content-type: text/javascriptEager priming (optional)
To download and transform a whole dependency graph up front (e.g. before going
offline, or to warm a cache), use prime. It walks the entry's transitive graph,
transforms every module, and writes the lockfile:
await server.prime({ pkg: "react-dom", version: "^18" });
// entry + every transitive dep are now cached and importable with the network off.Listing what an entry needs, or what a package contains
Two questions, two methods:
// (1) Every module URL required to RUN an entry — the reachable graph (primes it).
const urls = await server.listResources({ pkg: "react" });
// → [ "/[email protected]/index.js",
// "/[email protected]/cjs/react.production.js",
// "/[email protected]/cjs/react.development.js" ] ← the exact set of scripts to serve
// (2) Every FILE in a package — the full tarball contents (loads it if needed).
const files = await server.listPackageFiles({ pkg: "react" });
// → [ "LICENSE", "index.js", "jsx-runtime.js", "cjs/react.development.js", … ] (27 files)listResources is the minimal set (what the entry actually imports);
listPackageFiles is everything the package ships (including alternative entry
points like react/jsx-runtime the main entry never imports). To capture an app's
full needs, call listResources for each entry point you import and take the union.
To download everything for later offline/static serving, point the cache at a
real directory and prime — the transformed files land under {rootDir}/t/{target}/:
const server = newModuleServer({ cache: new NodeFilesApi({ rootDir: "./react-bundle" }) });
await server.prime({ pkg: "react" });
await server.prime({ pkg: "react", subpath: "jsx-runtime" }); // if you use JSX
// ./react-bundle/t/browser/[email protected]/… now holds the importable scripts.Serving your own source too
Point the server at a project FilesApi and it resolves local scripts the same
way — bare imports rewritten to /{name}@{version}/…, relative imports kept
relative, TS/JSX transpiled:
const server = newModuleServer({ cache, project: myProjectFiles });
const app = await server.resolve({ url: "/src/app.ts" }); // → importable URLExamples
Three runnable examples (each has a package script; all hit the live npm registry, so they need network on first run):
pnpm --filter @statewalker/webrun-modules example # full-cycle (alias)
pnpm --filter @statewalker/webrun-modules example:full-cycle # examples/full-cycle.ts
pnpm --filter @statewalker/webrun-modules example:server # examples/http-server.ts (unpkg-like)
pnpm --filter @statewalker/webrun-modules example:site # examples/site-pipeline.ts(From inside the package directory you can drop the --filter … prefix:
pnpm example:server.)
examples/full-cycle.ts demonstrates the entire
cycle against the live npm registry — lazy download-on-request, resolve,
prime, executing a served module, ?raw, and the lockfile.
An unpkg-like HTTP service
Because server.fetch is a standard Web handler, exposing an unpkg-style endpoint
is a thin wrapper — mount it on any host and add the one convenience of
redirecting a bare/ranged spec to its pinned, versioned URL.
examples/http-server.ts is a complete, dependency-
free Node server that does exactly this:
pnpm --filter @statewalker/webrun-modules example:server
# then:
curl -L localhost:8787/lodash-es@4/merge # 302 → /[email protected]/merge.js → importable ESM
curl -L localhost:8787/debug # 302 → /[email protected]/src/browser.js
curl 'localhost:8787/react?meta' # JSON: react's full file list
curl 'localhost:8787/react?graph' # JSON: every module URL needed to run react
curl 'localhost:8787/[email protected]/package.json' # non-JS files served raw (application/json)For a given package it returns an importable JS module with every dependency
already resolved to a same-origin URL — e.g. requesting lodash-es@4/merge serves
merge.js whose import "./_baseMerge.js" / import "./_createAssigner.js" all
point back at the same server. Use it straight from a browser:
<script type="module">
import merge from "http://localhost:8787/lodash-es@4/merge";
console.log(merge({ a: 1 }, { b: 2 }));
</script>In-browser site pipeline (replacing a jspm-based resolver)
Because the server transpiles first-party TS/TSX and resolves the npm deps,
one newModuleServer replaces an entire @jspm/generator-based pipeline
(resolver + CDN providers + es-module-lexer rewrite + recursive prefetch +
/external mount). Put your source in a project FilesApi, mount server.fetch
under a site, and run server modules through the existing server-runner:
const server = newModuleServer({ cache, project: myAppFiles, target: "browser" });
new SiteBuilder()
.setEndpoint("/", server.fetch) // html + transpiled TSX + deps
.setEndpoint("/api", newServerRunner(serverEntryUrl, () => baseUrl)) // run server modules
.build();examples/site-pipeline.ts runs the whole thing
(JSX/TSX transpiled, import "react" rewritten to a same-origin URL, react +
react/jsx-runtime resolved, listResources = the exact scripts to serve).
Note: the server resolves a bare import "react" to latest unless a version
is pinned — seed lock (e.g. { react: "18.3.1" }) to honor a project's
package.json versions reproducibly.
Options
| Option | Default | Purpose |
|-------------|--------------------------|---------|
| cache | — (required) | Injected FilesApi for the module cache. |
| project | — | FilesApi of local project files to serve. |
| sources | [npmRegistrySource()] | Acquisition sources (npm tarball by default). |
| transform | newDefaultTransform() | Per-file transform (ESM + CJS-interop). |
| target | "browser" | Selects exports conditions + cache key; "node" supported. |
| lock | — | A Lockfile (pins versions); prime also writes one back. |
| basePath | "/" | Mount prefix, e.g. "/deps/v1/". |
ModuleServer
interface ModuleServer {
resolve(ref: ModuleRef, importer?: string): Promise<ResolvedModule>; // single ref → URL
prime(entry: ModuleRef): Promise<ResolvedModule>; // warm the whole graph
listResources(entry: ModuleRef): Promise<string[]>; // every URL the entry needs
listPackageFiles(ref: ModuleRef): Promise<string[]>; // a package's full file list
fetch(request: Request): Promise<Response>; // standard Web handler
readonly lock: Lockfile; // resolution map
}
type ModuleRef =
| { pkg: string; version?: string; subpath?: string } // e.g. { pkg: "lodash-es", subpath: "merge" }
| { url: string }; // a local project scriptTargets: browser vs node
target selects which package.json exports conditions win and how Node
builtins are handled, and is part of the cache key (a browser build and a node
build of the same package never collide):
// Browser (default): node:* builtins → self-hosted @jspm/core polyfill URLs.
newModuleServer({ cache, target: "browser" });
// Node: node:* builtins stay external (real Node builtins).
newModuleServer({ cache, target: "node" });Reproducible resolution (the lockfile)
The resolution map is a Lockfile ({ [name]: version }). prime writes it to
the cache and returns it via server.lock. Supply it back as lock to pin
versions reproducibly — a partial lockfile pins only the names it lists:
const first = newModuleServer({ cache });
await first.prime({ pkg: "app" });
const lock = first.lock; // e.g. { app: "1.0.0", react: "18.3.1", … }
// Elsewhere / later: identical resolution, no re-solve.
const pinned = newModuleServer({ cache, lock });Custom Source (npm / JSR / URL / your own registry)
A Source turns a reference into a package's files + manifest. The default is
npmRegistrySource(); provide your own (or several — the first whose matches
returns true wins):
import type { Source } from "@statewalker/webrun-modules";
import { MemFilesApi } from "@statewalker/webrun-files-mem";
const myRegistry: Source = {
matches: (ref) => "pkg" in ref,
async load(ref) {
// fetch + unpack however you like; return the package tree + manifest
const files = new MemFilesApi();
// … write files …
return { name: ref.pkg, version: "1.0.0", files, manifest: { name: ref.pkg, version: "1.0.0" } };
},
};
newModuleServer({ cache, sources: [myRegistry, npmRegistrySource()] });npmRegistrySource(options?) accepts { registryUrl, fetch, createFiles } — pass
a custom fetch (e.g. to add auth or point at a private registry) or a private
registry URL.
Custom Transform
The default transform (newDefaultTransform()) dispatches per file: ESM/TS/JSX go
through newEsmTransform(), CommonJS through newCjsTransform(). Swap in your own
Transform — it receives one file and a rewrite(specifier) => url callback and
returns browser-runnable ESM:
import { newDefaultTransform, detectFormat } from "@statewalker/webrun-modules";
import type { Transform } from "@statewalker/webrun-modules";
const myTransform: Transform = {
async transform(file, rewrite) {
// file = { path, source, format: "esm" | "cjs" | "ts" | "tsx" }
// call rewrite(spec) for each import specifier to get its local URL
return /* transformed ESM */ file.source;
},
};
newModuleServer({ cache, transform: myTransform });detectFormat(path, source, manifest?) returns the SourceFormat the default
transform would infer.
Serving surface
server.fetch(request) is a plain (Request) => Promise<Response>:
- JS/TS module files are transformed and served as
text/javascript; - non-module files (
package.json,README.md,.css, …) are served raw, untransformed, with a content-type guessed from the extension (application/json,text/markdown, …); - append
?rawto get the raw bytes of any file asapplication/octet-stream; - an unresolvable path returns a
404Response(never throws).
Mount it under any basePath (returned URLs carry the prefix; the cached bytes
stay portable, because internal imports are rewritten as relative URLs):
const server = newModuleServer({ cache, basePath: "/deps/v1/" });
const r = await server.resolve({ pkg: "zod" }); // → { url: "/deps/v1/[email protected]/lib/index.mjs" }Errors
ModuleResolveError { ref, reason }— a package / version / subpath can't be resolved (surfaced as a404fromfetch).ModuleTransformError { path, reason }— a file can't be transformed to runnable ESM.
Utilities
Also exported: untarTgz(bytes) (isomorphic npm-tarball unpacker),
parseSpecifier(spec) (bare specifier → { pkg, subpath? }, scope-aware), and
relativeUrl(fromId, toId).
How it works
- Acquire — the default
Sourcefetches the npm registry tarball, untars it in memory (pure-JS, isomorphic), and caches every file. - Resolve — versions resolve against the registry with whole-name dedupe (one
version per package where semver allows; incompatible ranges are kept side by
side).
package.jsonexports/importsconditions are honored for the target; Node builtins map to@jspm/corepolyfills (browser) or stay external (node). The resolution map is persisted as a lockfile. - Transform — each file becomes browser-runnable ESM one-to-one. ESM/TS/JSX is
transpiled and its specifiers rewritten in place; CommonJS is wrapped so the ESM
module graph itself provides
require(synchronously, backed by the eagerly primed graph). Internal imports are rewritten as relative URLs, so cached bytes are portable across mount prefixes.
Limitations
- Computed
require(expr)across package boundaries can't be pre-resolved and throws at execution time — the boundary where anesbuild-wasmbundle fallback would take over. - Dedupe is greedy (first-resolved version wins per name), not a full constraint hoist.
- Free Node globals under
target: "browser".require("process")is polyfilled, but packages that referenceprocess,Buffer, orglobalas bare free variables (e.g. React'sprocess.env.NODE_ENV) need the page to define them — setglobalThis.process = { env: { NODE_ENV: "development" } }(as esbuild/Vite do via a define). Undertarget: "node"they resolve natively. - Bundling/copying the resolved graph into a distributable tree,
.d.tstype serving, package lifecycle scripts, and HMR are out of scope.
License
MIT
