@statewalker/webrun-modules 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +356 -0
  3. package/dist/index.d.ts +9 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +809 -0
  6. package/dist/resolution/node-builtins.d.ts +24 -0
  7. package/dist/resolution/node-builtins.d.ts.map +1 -0
  8. package/dist/resolution/resolve-entry.d.ts +9 -0
  9. package/dist/resolution/resolve-entry.d.ts.map +1 -0
  10. package/dist/server/new-module-server.d.ts +4 -0
  11. package/dist/server/new-module-server.d.ts.map +1 -0
  12. package/dist/server/specifiers.d.ts +11 -0
  13. package/dist/server/specifiers.d.ts.map +1 -0
  14. package/dist/sources/npm-registry-source.d.ts +29 -0
  15. package/dist/sources/npm-registry-source.d.ts.map +1 -0
  16. package/dist/sources/untar.d.ts +9 -0
  17. package/dist/sources/untar.d.ts.map +1 -0
  18. package/dist/transform/index.d.ts +15 -0
  19. package/dist/transform/index.d.ts.map +1 -0
  20. package/dist/transform/scan.d.ts +11 -0
  21. package/dist/transform/scan.d.ts.map +1 -0
  22. package/dist/transform/to-js.d.ts +10 -0
  23. package/dist/transform/to-js.d.ts.map +1 -0
  24. package/dist/transform/transform-cjs.d.ts +13 -0
  25. package/dist/transform/transform-cjs.d.ts.map +1 -0
  26. package/dist/transform/transform-esm.d.ts +9 -0
  27. package/dist/transform/transform-esm.d.ts.map +1 -0
  28. package/dist/types.d.ts +127 -0
  29. package/dist/types.d.ts.map +1 -0
  30. package/package.json +66 -0
  31. package/src/index.ts +26 -0
  32. package/src/resolution/node-builtins.ts +79 -0
  33. package/src/resolution/resolve-entry.ts +65 -0
  34. package/src/server/new-module-server.ts +416 -0
  35. package/src/server/specifiers.ts +26 -0
  36. package/src/sources/npm-registry-source.ts +86 -0
  37. package/src/sources/untar.ts +37 -0
  38. package/src/transform/index.ts +52 -0
  39. package/src/transform/scan.ts +32 -0
  40. package/src/transform/to-js.ts +18 -0
  41. package/src/transform/transform-cjs.ts +85 -0
  42. package/src/transform/transform-esm.ts +35 -0
  43. package/src/types.ts +143 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022-2026 statewalker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,356 @@
1
+ # @statewalker/webrun-modules
2
+
3
+ Run authored TypeScript/JavaScript apps **and the arbitrary npm modules they
4
+ import** in the browser (or Node) with **no runtime CDN dependency** — and with
5
+ **no install step**: packages are downloaded, resolved, and transformed **on
6
+ request**.
7
+
8
+ Given a TS/JS entry, `webrun-modules` resolves, downloads, and transforms the
9
+ dependency graph — from npm (or a CDN) **at resolve time only** — and serves the
10
+ transformed, browser-runnable ESM from a local
11
+ [`FilesApi`](https://www.npmjs.com/package/@statewalker/webrun-files) cache. Every
12
+ internal import is rewritten to a same-origin local URL, so nothing is fetched
13
+ from a third party at run time. The result is always a URL any module-compatible
14
+ runtime can `import` directly — no bespoke client loader.
15
+
16
+ It is **isomorphic**: the same code runs in a browser ServiceWorker and in Node —
17
+ the only difference is which `FilesApi` backend you inject.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ npm add @statewalker/webrun-modules
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ ```ts
28
+ import { newModuleServer, npmRegistrySource } from "@statewalker/webrun-modules";
29
+ import { NodeFilesApi } from "@statewalker/webrun-files-node";
30
+
31
+ const server = newModuleServer({
32
+ cache: new NodeFilesApi({ rootDir: "./.modules-cache" }),
33
+ });
34
+
35
+ // Resolve an npm package (with optional subpath) to an importable URL:
36
+ const zod = await server.resolve({ pkg: "zod" });
37
+ // → { url: "/zod@3.23.8/lib/index.mjs", target: "browser" }
38
+
39
+ // Serve it — `server.fetch` is a standard Web handler, mountable anywhere:
40
+ Deno.serve(server.fetch); // Deno
41
+ export default { fetch: server.fetch }; // Bun / Cloudflare
42
+ self.addEventListener("fetch", (e) => e.respondWith(server.fetch(e.request))); // ServiceWorker
43
+ ```
44
+
45
+ There is **no separate "install" call**. The first time a module URL is requested
46
+ — via `resolve`, `prime`, or a direct `fetch` — its package is downloaded and
47
+ transformed on demand, then cached. Requesting a URL for a package that isn't in
48
+ the cache yet just works:
49
+
50
+ ```ts
51
+ // Nothing primed, nothing resolved — this single fetch downloads + transforms + serves:
52
+ const res = await server.fetch(new Request("http://host/ms@2.1.3/index.js"));
53
+ // → 200, content-type: text/javascript
54
+ ```
55
+
56
+ ## Eager priming (optional)
57
+
58
+ To download and transform a whole dependency graph up front (e.g. before going
59
+ offline, or to warm a cache), use `prime`. It walks the entry's transitive graph,
60
+ transforms every module, and writes the lockfile:
61
+
62
+ ```ts
63
+ await server.prime({ pkg: "react-dom", version: "^18" });
64
+ // entry + every transitive dep are now cached and importable with the network off.
65
+ ```
66
+
67
+ ## Listing what an entry needs, or what a package contains
68
+
69
+ Two questions, two methods:
70
+
71
+ ```ts
72
+ // (1) Every module URL required to RUN an entry — the reachable graph (primes it).
73
+ const urls = await server.listResources({ pkg: "react" });
74
+ // → [ "/react@19.2.8/index.js",
75
+ // "/react@19.2.8/cjs/react.production.js",
76
+ // "/react@19.2.8/cjs/react.development.js" ] ← the exact set of scripts to serve
77
+
78
+ // (2) Every FILE in a package — the full tarball contents (loads it if needed).
79
+ const files = await server.listPackageFiles({ pkg: "react" });
80
+ // → [ "LICENSE", "index.js", "jsx-runtime.js", "cjs/react.development.js", … ] (27 files)
81
+ ```
82
+
83
+ `listResources` is the minimal set (what the entry actually imports);
84
+ `listPackageFiles` is everything the package ships (including alternative entry
85
+ points like `react/jsx-runtime` the main entry never imports). To capture an app's
86
+ full needs, call `listResources` for each entry point you import and take the union.
87
+
88
+ To **download** everything for later offline/static serving, point the cache at a
89
+ real directory and prime — the transformed files land under `{rootDir}/t/{target}/`:
90
+
91
+ ```ts
92
+ const server = newModuleServer({ cache: new NodeFilesApi({ rootDir: "./react-bundle" }) });
93
+ await server.prime({ pkg: "react" });
94
+ await server.prime({ pkg: "react", subpath: "jsx-runtime" }); // if you use JSX
95
+ // ./react-bundle/t/browser/react@19.2.8/… now holds the importable scripts.
96
+ ```
97
+
98
+ ## Serving your own source too
99
+
100
+ Point the server at a project `FilesApi` and it resolves local scripts the same
101
+ way — bare imports rewritten to `/{name}@{version}/…`, relative imports kept
102
+ relative, TS/JSX transpiled:
103
+
104
+ ```ts
105
+ const server = newModuleServer({ cache, project: myProjectFiles });
106
+ const app = await server.resolve({ url: "/src/app.ts" }); // → importable URL
107
+ ```
108
+
109
+ ## Examples
110
+
111
+ Three runnable examples (each has a package script; all hit the live npm
112
+ registry, so they need network on first run):
113
+
114
+ ```sh
115
+ pnpm --filter @statewalker/webrun-modules example # full-cycle (alias)
116
+ pnpm --filter @statewalker/webrun-modules example:full-cycle # examples/full-cycle.ts
117
+ pnpm --filter @statewalker/webrun-modules example:server # examples/http-server.ts (unpkg-like)
118
+ pnpm --filter @statewalker/webrun-modules example:site # examples/site-pipeline.ts
119
+ ```
120
+
121
+ (From inside the package directory you can drop the `--filter …` prefix:
122
+ `pnpm example:server`.)
123
+
124
+ [`examples/full-cycle.ts`](./examples/full-cycle.ts) demonstrates the entire
125
+ cycle against the live npm registry — lazy download-on-request, `resolve`,
126
+ `prime`, executing a served module, `?raw`, and the lockfile.
127
+
128
+ ### An unpkg-like HTTP service
129
+
130
+ Because `server.fetch` is a standard Web handler, exposing an unpkg-style endpoint
131
+ is a thin wrapper — mount it on any host and add the one convenience of
132
+ redirecting a bare/ranged spec to its pinned, versioned URL.
133
+ [`examples/http-server.ts`](./examples/http-server.ts) is a complete, dependency-
134
+ free Node server that does exactly this:
135
+
136
+ ```sh
137
+ pnpm --filter @statewalker/webrun-modules example:server
138
+ # then:
139
+ curl -L localhost:8787/lodash-es@4/merge # 302 → /lodash-es@4.18.1/merge.js → importable ESM
140
+ curl -L localhost:8787/debug # 302 → /debug@4.4.3/src/browser.js
141
+ curl 'localhost:8787/react?meta' # JSON: react's full file list
142
+ curl 'localhost:8787/react?graph' # JSON: every module URL needed to run react
143
+ curl 'localhost:8787/react@19.2.8/package.json' # non-JS files served raw (application/json)
144
+ ```
145
+
146
+ For a given package it returns an importable JS module with every dependency
147
+ already resolved to a same-origin URL — e.g. requesting `lodash-es@4/merge` serves
148
+ `merge.js` whose `import "./_baseMerge.js"` / `import "./_createAssigner.js"` all
149
+ point back at the same server. Use it straight from a browser:
150
+
151
+ ```html
152
+ <script type="module">
153
+ import merge from "http://localhost:8787/lodash-es@4/merge";
154
+ console.log(merge({ a: 1 }, { b: 2 }));
155
+ </script>
156
+ ```
157
+
158
+ ### In-browser site pipeline (replacing a jspm-based resolver)
159
+
160
+ Because the server transpiles first-party TS/TSX **and** resolves the npm deps,
161
+ one `newModuleServer` replaces an entire `@jspm/generator`-based pipeline
162
+ (resolver + CDN providers + `es-module-lexer` rewrite + recursive prefetch +
163
+ `/external` mount). Put your source in a `project` `FilesApi`, mount `server.fetch`
164
+ under a site, and run server modules through the existing server-runner:
165
+
166
+ ```ts
167
+ const server = newModuleServer({ cache, project: myAppFiles, target: "browser" });
168
+
169
+ new SiteBuilder()
170
+ .setEndpoint("/", server.fetch) // html + transpiled TSX + deps
171
+ .setEndpoint("/api", newServerRunner(serverEntryUrl, () => baseUrl)) // run server modules
172
+ .build();
173
+ ```
174
+
175
+ [`examples/site-pipeline.ts`](./examples/site-pipeline.ts) runs the whole thing
176
+ (JSX/TSX transpiled, `import "react"` rewritten to a same-origin URL, `react` +
177
+ `react/jsx-runtime` resolved, `listResources` = the exact scripts to serve).
178
+ **Note:** the server resolves a bare `import "react"` to *latest* unless a version
179
+ is pinned — seed `lock` (e.g. `{ react: "18.3.1" }`) to honor a project's
180
+ `package.json` versions reproducibly.
181
+
182
+ ## Options
183
+
184
+ | Option | Default | Purpose |
185
+ |-------------|--------------------------|---------|
186
+ | `cache` | — (required) | Injected `FilesApi` for the module cache. |
187
+ | `project` | — | `FilesApi` of local project files to serve. |
188
+ | `sources` | `[npmRegistrySource()]` | Acquisition sources (npm tarball by default). |
189
+ | `transform` | `newDefaultTransform()` | Per-file transform (ESM + CJS-interop). |
190
+ | `target` | `"browser"` | Selects `exports` conditions + cache key; `"node"` supported. |
191
+ | `lock` | — | A `Lockfile` (pins versions); `prime` also writes one back. |
192
+ | `basePath` | `"/"` | Mount prefix, e.g. `"/deps/v1/"`. |
193
+
194
+ ### `ModuleServer`
195
+
196
+ ```ts
197
+ interface ModuleServer {
198
+ resolve(ref: ModuleRef, importer?: string): Promise<ResolvedModule>; // single ref → URL
199
+ prime(entry: ModuleRef): Promise<ResolvedModule>; // warm the whole graph
200
+ listResources(entry: ModuleRef): Promise<string[]>; // every URL the entry needs
201
+ listPackageFiles(ref: ModuleRef): Promise<string[]>; // a package's full file list
202
+ fetch(request: Request): Promise<Response>; // standard Web handler
203
+ readonly lock: Lockfile; // resolution map
204
+ }
205
+
206
+ type ModuleRef =
207
+ | { pkg: string; version?: string; subpath?: string } // e.g. { pkg: "lodash-es", subpath: "merge" }
208
+ | { url: string }; // a local project script
209
+ ```
210
+
211
+ ## Targets: browser vs node
212
+
213
+ `target` selects which `package.json` `exports` conditions win and how Node
214
+ builtins are handled, and is part of the cache key (a browser build and a node
215
+ build of the same package never collide):
216
+
217
+ ```ts
218
+ // Browser (default): node:* builtins → self-hosted @jspm/core polyfill URLs.
219
+ newModuleServer({ cache, target: "browser" });
220
+
221
+ // Node: node:* builtins stay external (real Node builtins).
222
+ newModuleServer({ cache, target: "node" });
223
+ ```
224
+
225
+ ## Reproducible resolution (the lockfile)
226
+
227
+ The resolution map is a `Lockfile` (`{ [name]: version }`). `prime` writes it to
228
+ the cache and returns it via `server.lock`. Supply it back as `lock` to pin
229
+ versions reproducibly — a partial lockfile pins only the names it lists:
230
+
231
+ ```ts
232
+ const first = newModuleServer({ cache });
233
+ await first.prime({ pkg: "app" });
234
+ const lock = first.lock; // e.g. { app: "1.0.0", react: "18.3.1", … }
235
+
236
+ // Elsewhere / later: identical resolution, no re-solve.
237
+ const pinned = newModuleServer({ cache, lock });
238
+ ```
239
+
240
+ ## Custom `Source` (npm / JSR / URL / your own registry)
241
+
242
+ A `Source` turns a reference into a package's files + manifest. The default is
243
+ `npmRegistrySource()`; provide your own (or several — the first whose `matches`
244
+ returns true wins):
245
+
246
+ ```ts
247
+ import type { Source } from "@statewalker/webrun-modules";
248
+ import { MemFilesApi } from "@statewalker/webrun-files-mem";
249
+
250
+ const myRegistry: Source = {
251
+ matches: (ref) => "pkg" in ref,
252
+ async load(ref) {
253
+ // fetch + unpack however you like; return the package tree + manifest
254
+ const files = new MemFilesApi();
255
+ // … write files …
256
+ return { name: ref.pkg, version: "1.0.0", files, manifest: { name: ref.pkg, version: "1.0.0" } };
257
+ },
258
+ };
259
+
260
+ newModuleServer({ cache, sources: [myRegistry, npmRegistrySource()] });
261
+ ```
262
+
263
+ `npmRegistrySource(options?)` accepts `{ registryUrl, fetch, createFiles }` — pass
264
+ a custom `fetch` (e.g. to add auth or point at a private registry) or a private
265
+ registry URL.
266
+
267
+ ## Custom `Transform`
268
+
269
+ The default transform (`newDefaultTransform()`) dispatches per file: ESM/TS/JSX go
270
+ through `newEsmTransform()`, CommonJS through `newCjsTransform()`. Swap in your own
271
+ `Transform` — it receives one file and a `rewrite(specifier) => url` callback and
272
+ returns browser-runnable ESM:
273
+
274
+ ```ts
275
+ import { newDefaultTransform, detectFormat } from "@statewalker/webrun-modules";
276
+ import type { Transform } from "@statewalker/webrun-modules";
277
+
278
+ const myTransform: Transform = {
279
+ async transform(file, rewrite) {
280
+ // file = { path, source, format: "esm" | "cjs" | "ts" | "tsx" }
281
+ // call rewrite(spec) for each import specifier to get its local URL
282
+ return /* transformed ESM */ file.source;
283
+ },
284
+ };
285
+
286
+ newModuleServer({ cache, transform: myTransform });
287
+ ```
288
+
289
+ `detectFormat(path, source, manifest?)` returns the `SourceFormat` the default
290
+ transform would infer.
291
+
292
+ ## Serving surface
293
+
294
+ `server.fetch(request)` is a plain `(Request) => Promise<Response>`:
295
+
296
+ - JS/TS module files are transformed and served as `text/javascript`;
297
+ - non-module files (`package.json`, `README.md`, `.css`, …) are served **raw**,
298
+ untransformed, with a content-type guessed from the extension
299
+ (`application/json`, `text/markdown`, …);
300
+ - append `?raw` to get the raw bytes of *any* file as `application/octet-stream`;
301
+ - an unresolvable path returns a `404` `Response` (never throws).
302
+
303
+ Mount it under any `basePath` (returned URLs carry the prefix; the cached bytes
304
+ stay portable, because internal imports are rewritten as **relative** URLs):
305
+
306
+ ```ts
307
+ const server = newModuleServer({ cache, basePath: "/deps/v1/" });
308
+ const r = await server.resolve({ pkg: "zod" }); // → { url: "/deps/v1/zod@3.23.8/lib/index.mjs" }
309
+ ```
310
+
311
+ ## Errors
312
+
313
+ - `ModuleResolveError { ref, reason }` — a package / version / subpath can't be
314
+ resolved (surfaced as a `404` from `fetch`).
315
+ - `ModuleTransformError { path, reason }` — a file can't be transformed to runnable
316
+ ESM.
317
+
318
+ ## Utilities
319
+
320
+ Also exported: `untarTgz(bytes)` (isomorphic npm-tarball unpacker),
321
+ `parseSpecifier(spec)` (bare specifier → `{ pkg, subpath? }`, scope-aware), and
322
+ `relativeUrl(fromId, toId)`.
323
+
324
+ ## How it works
325
+
326
+ - **Acquire** — the default `Source` fetches the npm registry tarball, untars it
327
+ in memory (pure-JS, isomorphic), and caches every file.
328
+ - **Resolve** — versions resolve against the registry with whole-name dedupe (one
329
+ version per package where semver allows; incompatible ranges are kept side by
330
+ side). `package.json` `exports`/`imports` conditions are honored for the target;
331
+ Node builtins map to `@jspm/core` polyfills (browser) or stay external (node).
332
+ The resolution map is persisted as a lockfile.
333
+ - **Transform** — each file becomes browser-runnable ESM one-to-one. ESM/TS/JSX is
334
+ transpiled and its specifiers rewritten in place; CommonJS is wrapped so the ESM
335
+ module graph itself provides `require` (synchronously, backed by the eagerly
336
+ primed graph). Internal imports are rewritten as **relative** URLs, so cached
337
+ bytes are portable across mount prefixes.
338
+
339
+ ## Limitations
340
+
341
+ - **Computed `require(expr)`** across package boundaries can't be pre-resolved and
342
+ throws at execution time — the boundary where an `esbuild-wasm` bundle fallback
343
+ would take over.
344
+ - Dedupe is greedy (first-resolved version wins per name), not a full constraint
345
+ hoist.
346
+ - **Free Node globals under `target: "browser"`.** `require("process")` is
347
+ polyfilled, but packages that reference `process`, `Buffer`, or `global` as bare
348
+ *free variables* (e.g. React's `process.env.NODE_ENV`) need the page to define
349
+ them — set `globalThis.process = { env: { NODE_ENV: "development" } }` (as
350
+ esbuild/Vite do via a define). Under `target: "node"` they resolve natively.
351
+ - Bundling/copying the resolved graph into a distributable tree, `.d.ts` type
352
+ serving, package lifecycle scripts, and HMR are out of scope.
353
+
354
+ ## License
355
+
356
+ MIT
@@ -0,0 +1,9 @@
1
+ export { newModuleServer } from "./server/new-module-server.js";
2
+ export { parseSpecifier, relativeUrl } from "./server/specifiers.js";
3
+ export type { NpmRegistrySourceOptions } from "./sources/npm-registry-source.js";
4
+ export { npmRegistrySource } from "./sources/npm-registry-source.js";
5
+ export { untarTgz } from "./sources/untar.js";
6
+ export { detectFormat, newCjsTransform, newDefaultTransform, newEsmTransform, } from "./transform/index.js";
7
+ export type { LoadedPackage, Lockfile, ModuleRef, ModuleServer, ModuleServerOptions, ModuleTarget, PackageManifest, ResolvedModule, Source, SourceFile, SourceFormat, Transform, } from "./types.js";
8
+ export { ModuleResolveError, ModuleTransformError } from "./types.js";
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AACrE,YAAY,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AACjF,OAAO,EAAE,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AACrE,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EACL,YAAY,EACZ,eAAe,EACf,mBAAmB,EACnB,eAAe,GAChB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,aAAa,EACb,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,mBAAmB,EACnB,YAAY,EACZ,eAAe,EACf,cAAc,EACd,MAAM,EACN,UAAU,EACV,YAAY,EACZ,SAAS,GACV,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC"}