@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
@@ -0,0 +1,85 @@
1
+ import { init, parse } from "cjs-module-lexer";
2
+ import type { SourceFile, Transform } from "../types.js";
3
+
4
+ let lexerReady: Promise<unknown> | undefined;
5
+
6
+ // Matches `require("x")` / `require('x')` with a static string literal argument.
7
+ const REQUIRE_RE = /require\(\s*(['"])((?:(?!\1)[^\\]|\\.)*)\1\s*\)/g;
8
+
9
+ const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
10
+
11
+ /** Collect the unique static-string `require(...)` specifiers in a CJS source. */
12
+ function findRequires(source: string): string[] {
13
+ const set = new Set<string>();
14
+ for (const m of source.matchAll(REQUIRE_RE)) set.add(m[2]);
15
+ return [...set];
16
+ }
17
+
18
+ /**
19
+ * Transform a CommonJS file into served, browser-runnable ESM — no global runtime
20
+ * registry: each static `require` target is a static namespace import, so the ESM
21
+ * module graph itself provides load ordering and circular-dep handling. The CJS
22
+ * body runs synchronously against a synthetic `require` that maps specifiers to
23
+ * those namespaces; named exports (via `cjs-module-lexer`) are re-exported as
24
+ * eval-time snapshots (valid because the body executed synchronously above).
25
+ * Computed `require(expr)` hits the synthetic require's miss path (throws) — the
26
+ * declared `esbuild-wasm` fallback's job.
27
+ */
28
+ export function newCjsTransform(): Transform {
29
+ return {
30
+ async transform(file: SourceFile, rewrite: (specifier: string) => string): Promise<string> {
31
+ lexerReady ??= init();
32
+ await lexerReady;
33
+
34
+ const specs = findRequires(file.source);
35
+ const importLines: string[] = [];
36
+ const mapEntries: string[] = [];
37
+ specs.forEach((spec, i) => {
38
+ importLines.push(`import * as __d${i} from ${JSON.stringify(rewrite(spec))};`);
39
+ mapEntries.push(` ${JSON.stringify(spec)}: __d${i},`);
40
+ });
41
+
42
+ let names: string[] = [];
43
+ let reexports: string[] = [];
44
+ try {
45
+ const parsed = parse(file.source);
46
+ names = parsed.exports.filter((n) => IDENT_RE.test(n) && n !== "default");
47
+ reexports = parsed.reexports;
48
+ } catch {
49
+ names = []; // lexer can't parse → default-only interop
50
+ }
51
+ const namedExports = [...new Set(names)]
52
+ .map((n) => `export const ${n} = module.exports.${n};`)
53
+ .join("\n");
54
+ // A `module.exports = require("x")` entry (e.g. React's `index.js`) has no
55
+ // own statically-lexable names — only a reexport. Surface x's named exports
56
+ // by re-exporting its already-served namespace, so `import { StrictMode }
57
+ // from "react"` (and the automatic-JSX `jsxDEV` import) resolve. `export *`
58
+ // never re-exports `default`, so the `export default module.exports` above
59
+ // stays authoritative.
60
+ const reexportLines = [...new Set(reexports)]
61
+ .map((spec) => `export * from ${JSON.stringify(rewrite(spec))};`)
62
+ .join("\n");
63
+
64
+ const dir = file.path.replace(/\/[^/]*$/, "");
65
+ return [
66
+ ...importLines,
67
+ `const __ns = {\n${mapEntries.join("\n")}\n};`,
68
+ `const module = { exports: {} };`,
69
+ `const require = (s) => {`,
70
+ ` const m = __ns[s];`,
71
+ ` if (!m) throw new Error("Cannot require (computed/unresolved): " + s);`,
72
+ ` return m.default !== undefined ? m.default : m;`,
73
+ `};`,
74
+ `(function (module, exports, require, __filename, __dirname) {`,
75
+ file.source,
76
+ `}).call(module.exports, module, module.exports, require, ${JSON.stringify(file.path)}, ${JSON.stringify(dir)});`,
77
+ `export default module.exports;`,
78
+ namedExports,
79
+ reexportLines,
80
+ ]
81
+ .filter(Boolean)
82
+ .join("\n");
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,35 @@
1
+ import { init, parse } from "es-module-lexer";
2
+ import type { SourceFile, Transform } from "../types.js";
3
+ import { toJs } from "./to-js.js";
4
+
5
+ let lexerReady: Promise<unknown> | undefined;
6
+
7
+ /**
8
+ * The default per-file transform for ESM (and TS/JSX) sources: transpile to plain
9
+ * JS, then rewrite every static/dynamic-string import & re-export specifier in
10
+ * place via `rewrite`, leaving quotes and everything else byte-for-byte intact.
11
+ * Computed dynamic specifiers (no static string) are left untouched.
12
+ */
13
+ export function newEsmTransform(): Transform {
14
+ return {
15
+ async transform(file: SourceFile, rewrite: (specifier: string) => string): Promise<string> {
16
+ lexerReady ??= init;
17
+ await lexerReady;
18
+ const js = toJs(file.source, file.format, file.path);
19
+ const [imports] = parse(js, file.path);
20
+ let out = js;
21
+ // Splice from the end so earlier offsets stay valid. For static imports the
22
+ // [s,e) span sits *inside* the quotes; for dynamic `import("x")` it *includes*
23
+ // them — detect a leading quote and preserve it either way.
24
+ for (let i = imports.length - 1; i >= 0; i--) {
25
+ const imp = imports[i];
26
+ if (imp.n == null) continue; // computed/dynamic specifier — cannot rewrite statically
27
+ const q = out[imp.s];
28
+ const quoted = q === '"' || q === "'" || q === "`";
29
+ const replacement = quoted ? q + rewrite(imp.n) + q : rewrite(imp.n);
30
+ out = out.slice(0, imp.s) + replacement + out.slice(imp.e);
31
+ }
32
+ return out;
33
+ },
34
+ };
35
+ }
package/src/types.ts ADDED
@@ -0,0 +1,143 @@
1
+ import type { FilesApi } from "@statewalker/webrun-files";
2
+
3
+ /**
4
+ * Public contract for `@statewalker/webrun-modules`. Types only — the resolver
5
+ * core, the default npm `Source`, and the transforms are implemented separately.
6
+ * Contract source: notes/2026/2026-07/2026-07-23/grill-module-webrun-modules.md
7
+ */
8
+
9
+ /** The build target — selects `exports` conditions and is part of the cache key. */
10
+ export type ModuleTarget = "browser" | "node";
11
+
12
+ /**
13
+ * A reference to resolve: either an npm package (with optional pinned version and
14
+ * subpath) or a URL (a local project script served by this server, or an already
15
+ * resolved local URL).
16
+ */
17
+ export type ModuleRef = { pkg: string; version?: string; subpath?: string } | { url: string };
18
+
19
+ /** The result of resolving a `ModuleRef`: a directly `import`-able local URL. */
20
+ export interface ResolvedModule {
21
+ /** Importable URL — `/{name}@{version}/{subpath}` for a package, carries `basePath`. */
22
+ url: string;
23
+ target: ModuleTarget;
24
+ }
25
+
26
+ /**
27
+ * The version-dedupe map produced by `prime` and reusable as a pin input. One
28
+ * artifact, two roles: a partial lockfile pins only the names it lists; `prime`
29
+ * writes the complete one back to the cache.
30
+ */
31
+ export type Lockfile = Record<string, string /* resolved version */>;
32
+
33
+ /** A parsed `package.json` (only the fields resolution reads). */
34
+ export interface PackageManifest {
35
+ name: string;
36
+ version: string;
37
+ main?: string;
38
+ module?: string;
39
+ browser?: string | Record<string, string | false>;
40
+ exports?: unknown;
41
+ imports?: unknown;
42
+ dependencies?: Record<string, string>;
43
+ peerDependencies?: Record<string, string>;
44
+ [key: string]: unknown;
45
+ }
46
+
47
+ /** A package acquired by a `Source`: its untarred files plus parsed manifest. */
48
+ export interface LoadedPackage {
49
+ name: string;
50
+ version: string;
51
+ /** The untarred package tree — every file individually addressable. */
52
+ files: FilesApi;
53
+ manifest: PackageManifest;
54
+ }
55
+
56
+ /**
57
+ * Pluggable acquisition seam. The default impl fetches an npm registry tarball and
58
+ * untars it in memory; JSR and direct-URL are alternative impls.
59
+ */
60
+ export interface Source {
61
+ /** Whether this source handles the given reference (npm | jsr | url). */
62
+ matches(ref: ModuleRef): boolean;
63
+ /** Resolve a concrete version, fetch bytes, untar, parse the manifest. */
64
+ load(ref: ModuleRef): Promise<LoadedPackage>;
65
+ }
66
+
67
+ /** The source-file kinds the transform distinguishes. */
68
+ export type SourceFormat = "esm" | "cjs" | "ts" | "tsx";
69
+
70
+ export interface SourceFile {
71
+ path: string;
72
+ source: string;
73
+ format: SourceFormat;
74
+ }
75
+
76
+ /**
77
+ * Pluggable per-file transform seam. Turns one raw file into browser-runnable ESM,
78
+ * passing every discovered specifier through `rewrite` (which returns the
79
+ * same-origin, prefix-portable relative URL to emit).
80
+ */
81
+ export interface Transform {
82
+ transform(file: SourceFile, rewrite: (specifier: string) => string): Promise<string>;
83
+ }
84
+
85
+ export interface ModuleServerOptions {
86
+ /** Injected cache — NodeFilesApi / browser-OPFS / MemFilesApi. Never node:fs. */
87
+ cache: FilesApi;
88
+ /** Optional: local project files served under their own paths (authored scripts). */
89
+ project?: FilesApi;
90
+ /** Acquisition sources; defaults to `[npmRegistrySource()]`. */
91
+ sources?: Source[];
92
+ /** Per-file transform; defaults to the sucrase-based ESM + CJS-interop transform. */
93
+ transform?: Transform;
94
+ /** Selects `exports` conditions and cache key. Defaults to `"browser"`. */
95
+ target?: ModuleTarget;
96
+ /** Optional lockfile input for reproducible resolution; also written by `prime`. */
97
+ lock?: Lockfile;
98
+ /** Mount prefix, e.g. `"/deps/v1/"`. Defaults to `"/"`. */
99
+ basePath?: string;
100
+ /** Prefix (relative to `basePath`) for external package URLs, isolating npm
101
+ * deps under e.g. `"deps/"` while authored project files stay at `~/`.
102
+ * Defaults to `""` — packages served alongside project files. */
103
+ depsPath?: string;
104
+ }
105
+
106
+ export interface ModuleServer {
107
+ /** Resolve a single reference to an importable local URL (resolve-on-miss). */
108
+ resolve(ref: ModuleRef, importer?: string): Promise<ResolvedModule>;
109
+ /** Walk the entry's whole graph, warm the cache, write `lock`. */
110
+ prime(entry: ModuleRef): Promise<ResolvedModule>;
111
+ /** Prime an entry and return every reachable module URL (the exact set of
112
+ * scripts required to run it, `basePath`-prefixed, sorted). */
113
+ listResources(entry: ModuleRef): Promise<string[]>;
114
+ /** List every file of a package (raw, package-relative paths), loading it if
115
+ * needed — the full package contents, not just the reachable subset. */
116
+ listPackageFiles(ref: ModuleRef): Promise<string[]>;
117
+ /** Standard Web handler; serves the cache and matches under `basePath`. */
118
+ fetch(request: Request): Promise<Response>;
119
+ /** The resolved graph — also the persisted lockfile. */
120
+ readonly lock: Lockfile;
121
+ }
122
+
123
+ /** Thrown when a package / version / subpath cannot be resolved. */
124
+ export class ModuleResolveError extends Error {
125
+ constructor(
126
+ readonly ref: ModuleRef,
127
+ readonly reason: string,
128
+ ) {
129
+ super(`Cannot resolve ${JSON.stringify(ref)}: ${reason}`);
130
+ this.name = "ModuleResolveError";
131
+ }
132
+ }
133
+
134
+ /** Thrown when a file cannot be transformed to runnable ESM. */
135
+ export class ModuleTransformError extends Error {
136
+ constructor(
137
+ readonly path: string,
138
+ readonly reason: string,
139
+ ) {
140
+ super(`Cannot transform ${path}: ${reason}`);
141
+ this.name = "ModuleTransformError";
142
+ }
143
+ }