@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.
- package/LICENSE +21 -0
- package/README.md +356 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +809 -0
- package/dist/resolution/node-builtins.d.ts +24 -0
- package/dist/resolution/node-builtins.d.ts.map +1 -0
- package/dist/resolution/resolve-entry.d.ts +9 -0
- package/dist/resolution/resolve-entry.d.ts.map +1 -0
- package/dist/server/new-module-server.d.ts +4 -0
- package/dist/server/new-module-server.d.ts.map +1 -0
- package/dist/server/specifiers.d.ts +11 -0
- package/dist/server/specifiers.d.ts.map +1 -0
- package/dist/sources/npm-registry-source.d.ts +29 -0
- package/dist/sources/npm-registry-source.d.ts.map +1 -0
- package/dist/sources/untar.d.ts +9 -0
- package/dist/sources/untar.d.ts.map +1 -0
- package/dist/transform/index.d.ts +15 -0
- package/dist/transform/index.d.ts.map +1 -0
- package/dist/transform/scan.d.ts +11 -0
- package/dist/transform/scan.d.ts.map +1 -0
- package/dist/transform/to-js.d.ts +10 -0
- package/dist/transform/to-js.d.ts.map +1 -0
- package/dist/transform/transform-cjs.d.ts +13 -0
- package/dist/transform/transform-cjs.d.ts.map +1 -0
- package/dist/transform/transform-esm.d.ts +9 -0
- package/dist/transform/transform-esm.d.ts.map +1 -0
- package/dist/types.d.ts +127 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +66 -0
- package/src/index.ts +26 -0
- package/src/resolution/node-builtins.ts +79 -0
- package/src/resolution/resolve-entry.ts +65 -0
- package/src/server/new-module-server.ts +416 -0
- package/src/server/specifiers.ts +26 -0
- package/src/sources/npm-registry-source.ts +86 -0
- package/src/sources/untar.ts +37 -0
- package/src/transform/index.ts +52 -0
- package/src/transform/scan.ts +32 -0
- package/src/transform/to-js.ts +18 -0
- package/src/transform/transform-cjs.ts +85 -0
- package/src/transform/transform-esm.ts +35 -0
- package/src/types.ts +143 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import type { FilesApi } from "@statewalker/webrun-files";
|
|
2
|
+
import { readText, writeText } from "@statewalker/webrun-files";
|
|
3
|
+
import semver from "semver";
|
|
4
|
+
import { resolveNodeBuiltin } from "../resolution/node-builtins.js";
|
|
5
|
+
import { resolveEntry } from "../resolution/resolve-entry.js";
|
|
6
|
+
import { npmRegistrySource } from "../sources/npm-registry-source.js";
|
|
7
|
+
import { detectFormat, newDefaultTransform } from "../transform/index.js";
|
|
8
|
+
import { scanSpecifiers } from "../transform/scan.js";
|
|
9
|
+
import type {
|
|
10
|
+
Lockfile,
|
|
11
|
+
ModuleRef,
|
|
12
|
+
ModuleServer,
|
|
13
|
+
ModuleServerOptions,
|
|
14
|
+
PackageManifest,
|
|
15
|
+
ResolvedModule,
|
|
16
|
+
} from "../types.js";
|
|
17
|
+
import { ModuleResolveError } from "../types.js";
|
|
18
|
+
import { parseSpecifier, relativeUrl } from "./specifiers.js";
|
|
19
|
+
|
|
20
|
+
const RAW_EXT = ["", ".js", ".mjs", ".cjs", ".json", "/index.js", "/index.mjs"];
|
|
21
|
+
|
|
22
|
+
/** JS/TS module files — the only ones the transform touches. */
|
|
23
|
+
const MODULE_EXT = /\.(?:m|c)?[jt]sx?$/;
|
|
24
|
+
const isModuleFile = (path: string) => MODULE_EXT.test(path);
|
|
25
|
+
|
|
26
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
27
|
+
json: "application/json",
|
|
28
|
+
css: "text/css",
|
|
29
|
+
md: "text/markdown",
|
|
30
|
+
html: "text/html",
|
|
31
|
+
svg: "image/svg+xml",
|
|
32
|
+
wasm: "application/wasm",
|
|
33
|
+
map: "application/json",
|
|
34
|
+
txt: "text/plain",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Guess a content-type from a file path (non-module resources). */
|
|
38
|
+
function contentType(path: string): string {
|
|
39
|
+
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
40
|
+
return CONTENT_TYPES[ext] ?? "application/octet-stream";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Create a module/dependency server over an injected `FilesApi` cache. */
|
|
44
|
+
export function newModuleServer(options: ModuleServerOptions): ModuleServer {
|
|
45
|
+
const cache = options.cache;
|
|
46
|
+
const project = options.project;
|
|
47
|
+
const sources = options.sources ?? [npmRegistrySource()];
|
|
48
|
+
const transformer = options.transform ?? newDefaultTransform();
|
|
49
|
+
const target = options.target ?? "browser";
|
|
50
|
+
const basePath = normalizeBase(options.basePath ?? "/");
|
|
51
|
+
// Optional prefix (relative to `basePath`) for external package URLs, so npm
|
|
52
|
+
// deps can be isolated under e.g. `/deps/` while authored project files stay at
|
|
53
|
+
// `~/`. Default "" ⇒ packages served alongside project files (unchanged).
|
|
54
|
+
const depsPrefix = normalizeDeps(options.depsPath ?? "");
|
|
55
|
+
const lock: Lockfile = { ...(options.lock ?? {}) };
|
|
56
|
+
const tRoot = `/t/${target}`;
|
|
57
|
+
|
|
58
|
+
let ready: Promise<void> | undefined;
|
|
59
|
+
const init = () => {
|
|
60
|
+
ready ??= (async () => {
|
|
61
|
+
if (await cache.exists("/lock.json")) {
|
|
62
|
+
Object.assign(lock, JSON.parse(await readText(cache, "/lock.json")), options.lock ?? {});
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
return ready;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Project ids (`~/…`) are served verbatim; package ids get the `depsPrefix`.
|
|
69
|
+
// `urlPath` is the site-relative form (after `basePath`) and the space that
|
|
70
|
+
// import URLs are made relative in, so cross-prefix imports resolve correctly.
|
|
71
|
+
const urlPath = (id: string) => (id.startsWith("~/") ? id : depsPrefix + id);
|
|
72
|
+
const urlFor = (id: string) => basePath + urlPath(id);
|
|
73
|
+
const idFromPath = (pathname: string): string => {
|
|
74
|
+
let p = pathname.startsWith(basePath)
|
|
75
|
+
? pathname.slice(basePath.length)
|
|
76
|
+
: pathname.replace(/^\//, "");
|
|
77
|
+
if (depsPrefix && p.startsWith(depsPrefix)) p = p.slice(depsPrefix.length);
|
|
78
|
+
return p;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function matchSource(ref: ModuleRef) {
|
|
82
|
+
const s = sources.find((x) => x.matches(ref));
|
|
83
|
+
if (!s) throw new ModuleResolveError(ref, "no source matches");
|
|
84
|
+
return s;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function reusable(locked: string, spec: string | undefined): boolean {
|
|
88
|
+
if (!spec) return true;
|
|
89
|
+
if (semver.valid(spec)) return locked === spec;
|
|
90
|
+
if (semver.validRange(spec)) return semver.satisfies(locked, spec);
|
|
91
|
+
return true; // dist-tag → reuse the locked version
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function cachedManifest(pkgKey: string): Promise<PackageManifest> {
|
|
95
|
+
return JSON.parse(await readText(cache, `/raw/${pkgKey}/package.json`));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Persist a loaded package's files under `/raw/{name}@{version}/…` (idempotent).
|
|
99
|
+
* The manifest is written as `package.json` (the authority — a Source's files
|
|
100
|
+
* are not required to include one). */
|
|
101
|
+
async function cacheRaw(
|
|
102
|
+
name: string,
|
|
103
|
+
version: string,
|
|
104
|
+
files: FilesApi,
|
|
105
|
+
manifest: PackageManifest,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const key = `${name}@${version}`;
|
|
108
|
+
if (await cache.exists(`/raw/${key}/package.json`)) return;
|
|
109
|
+
for await (const info of files.list("/", { recursive: true })) {
|
|
110
|
+
if (info.kind === "file") {
|
|
111
|
+
await cache.write(`/raw/${key}${info.path}`, [await collect(files.read(info.path))]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!(await cache.exists(`/raw/${key}/package.json`))) {
|
|
115
|
+
await writeText(cache, `/raw/${key}/package.json`, JSON.stringify(manifest));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Lazily load a package's raw files by its `name@version` cache key (idempotent). */
|
|
120
|
+
async function ensureRawByKey(pkgKey: string): Promise<void> {
|
|
121
|
+
if (await cache.exists(`/raw/${pkgKey}/package.json`)) return;
|
|
122
|
+
const at = pkgKey.lastIndexOf("@");
|
|
123
|
+
const ref = { pkg: pkgKey.slice(0, at), version: pkgKey.slice(at + 1) };
|
|
124
|
+
const loaded = await matchSource(ref).load(ref);
|
|
125
|
+
await cacheRaw(loaded.name, loaded.version, loaded.files, loaded.manifest);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Ensure a package's raw files + manifest are cached; return its pinned id. */
|
|
129
|
+
async function ensurePackage(ref: { pkg: string; version?: string; subpath?: string }) {
|
|
130
|
+
const name = ref.pkg;
|
|
131
|
+
const locked = lock[name];
|
|
132
|
+
let version: string;
|
|
133
|
+
let manifest: PackageManifest;
|
|
134
|
+
if (locked && reusable(locked, ref.version)) {
|
|
135
|
+
// Honor the lock even on a cold cache — load the *locked* version, not latest.
|
|
136
|
+
version = locked;
|
|
137
|
+
await ensureRawByKey(`${name}@${version}`);
|
|
138
|
+
manifest = await cachedManifest(`${name}@${version}`);
|
|
139
|
+
} else {
|
|
140
|
+
const loaded = await matchSource(ref).load({ pkg: name, version: ref.version });
|
|
141
|
+
version = loaded.version;
|
|
142
|
+
await cacheRaw(name, version, loaded.files, loaded.manifest);
|
|
143
|
+
manifest = loaded.manifest;
|
|
144
|
+
if (!locked) lock[name] = version;
|
|
145
|
+
}
|
|
146
|
+
const entry = resolveEntry(manifest, ref.subpath, target);
|
|
147
|
+
const file = await resolveRawFile(`${name}@${version}`, entry); // resolve real extension/index
|
|
148
|
+
return { name, version, file, id: `${name}@${version}/${file}`, manifest };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Resolve an import specifier (from `fromId`) to what to emit + its cache id. */
|
|
152
|
+
async function resolveSpec(spec: string, fromId: string): Promise<{ url: string; id?: string }> {
|
|
153
|
+
if (/^(https?:|data:)/.test(spec)) return { url: spec }; // absolute — pass through
|
|
154
|
+
const builtin = resolveNodeBuiltin(spec, target); // node: builtins → polyfill/external
|
|
155
|
+
if (builtin) {
|
|
156
|
+
if ("external" in builtin) return { url: builtin.external };
|
|
157
|
+
const t = await ensurePackage(builtin.ref);
|
|
158
|
+
return { url: relativeUrl(urlPath(fromId), urlPath(t.id)), id: t.id };
|
|
159
|
+
}
|
|
160
|
+
if (spec.startsWith(".")) {
|
|
161
|
+
const id = await resolveRelativeId(fromId, spec);
|
|
162
|
+
// Emit the *extension-resolved* relative URL (`./x` → `./x.js`), not the raw
|
|
163
|
+
// specifier: the browser fetches this URL verbatim, and only a recognized
|
|
164
|
+
// module extension makes the server transform it + serve `text/javascript`.
|
|
165
|
+
return { url: jsonModuleUrl(relativeUrl(urlPath(fromId), urlPath(id)), id), id };
|
|
166
|
+
}
|
|
167
|
+
const { pkg, subpath } = parseSpecifier(spec);
|
|
168
|
+
// Resolve the version from the *importing package's* context (Node semantics):
|
|
169
|
+
// a package requiring its own name → itself; a dependency → the importer's
|
|
170
|
+
// declared range. Falls back to the global lock/latest for project files or
|
|
171
|
+
// undeclared (transitive) deps.
|
|
172
|
+
const version = await importerVersion(pkg, fromId);
|
|
173
|
+
const t = await ensurePackage({ pkg, version, subpath });
|
|
174
|
+
return { url: jsonModuleUrl(relativeUrl(urlPath(fromId), urlPath(t.id)), t.id), id: t.id };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The version constraint a bare specifier should resolve to, from the importer's
|
|
178
|
+
* package: self-reference → the importer's own version; a dependency → the
|
|
179
|
+
* importer's `package.json` range; otherwise undefined (global lock/latest). */
|
|
180
|
+
async function importerVersion(pkg: string, fromId: string): Promise<string | undefined> {
|
|
181
|
+
const m = fromId.match(/^((?:@[^/]+\/)?[^/]+)@([^/]+)\//);
|
|
182
|
+
if (!m) return undefined; // project file — no package context
|
|
183
|
+
const [, impName, impVersion] = m;
|
|
184
|
+
if (pkg === impName) return impVersion; // self-reference → same version
|
|
185
|
+
const manifest = await cachedManifest(`${impName}@${impVersion}`).catch(() => undefined);
|
|
186
|
+
return (
|
|
187
|
+
manifest?.dependencies?.[pkg] ??
|
|
188
|
+
manifest?.peerDependencies?.[pkg] ??
|
|
189
|
+
(manifest?.optionalDependencies as Record<string, string> | undefined)?.[pkg]
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A `.json` imported by a JS module is served as an ESM (`export default …`);
|
|
194
|
+
* mark its URL with `?module` so `fetch` wraps it instead of serving raw JSON. */
|
|
195
|
+
function jsonModuleUrl(url: string, id: string): string {
|
|
196
|
+
return id.endsWith(".json") ? `${url}?module` : url;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Resolve a relative specifier against an importer id to a concrete cache id. */
|
|
200
|
+
async function resolveRelativeId(fromId: string, spec: string): Promise<string> {
|
|
201
|
+
const dir = fromId.split("/").slice(0, -1);
|
|
202
|
+
for (const part of spec.split("/")) {
|
|
203
|
+
if (part === "." || part === "") continue;
|
|
204
|
+
if (part === "..") dir.pop();
|
|
205
|
+
else dir.push(part);
|
|
206
|
+
}
|
|
207
|
+
const base = dir.join("/");
|
|
208
|
+
const pkgKey = base.match(/^(?:@[^/]+\/)?[^/]+@[^/]+/)?.[0];
|
|
209
|
+
if (pkgKey) {
|
|
210
|
+
const rel = base.slice(pkgKey.length + 1);
|
|
211
|
+
const file = await resolveRawFile(pkgKey, rel);
|
|
212
|
+
return `${pkgKey}/${file}`;
|
|
213
|
+
}
|
|
214
|
+
return base; // project-relative
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Try extension/index variants for a package-relative file; return what exists. */
|
|
218
|
+
async function resolveRawFile(pkgKey: string, file: string): Promise<string> {
|
|
219
|
+
for (const ext of RAW_EXT) {
|
|
220
|
+
if (await cache.exists(`/raw/${pkgKey}/${file}${ext}`)) return file + ext;
|
|
221
|
+
}
|
|
222
|
+
return file;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Load a canonical id's raw source + format context. */
|
|
226
|
+
async function loadRaw(
|
|
227
|
+
id: string,
|
|
228
|
+
): Promise<{ path: string; source: string; format: ReturnType<typeof detectFormat> }> {
|
|
229
|
+
if (id.startsWith("~/")) {
|
|
230
|
+
if (!project) throw new ModuleResolveError({ url: id }, "no project FilesApi");
|
|
231
|
+
const path = `/${id.slice(2)}`;
|
|
232
|
+
const source = await readText(project, path);
|
|
233
|
+
return { path, source, format: detectFormat(path, source) };
|
|
234
|
+
}
|
|
235
|
+
const m = id.match(/^((?:@[^/]+\/)?[^/]+@[^/]+)\/(.+)$/);
|
|
236
|
+
if (!m) throw new ModuleResolveError({ url: id }, "not a module id");
|
|
237
|
+
const [, pkgKey, rawFile] = m;
|
|
238
|
+
await ensureRawByKey(pkgKey);
|
|
239
|
+
const file = await resolveRawFile(pkgKey, rawFile);
|
|
240
|
+
const source = await readText(cache, `/raw/${pkgKey}/${file}`);
|
|
241
|
+
const manifest = await cachedManifest(pkgKey).catch(() => undefined);
|
|
242
|
+
return { path: `/${pkgKey}/${file}`, source, format: detectFormat(file, source, manifest) };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Transform a module (pre-resolving its specifiers) and cache the ESM output. */
|
|
246
|
+
async function transformAndCache(id: string): Promise<string> {
|
|
247
|
+
const { path, source, format } = await loadRaw(id);
|
|
248
|
+
const specs = await scanSpecifiers(source, format);
|
|
249
|
+
const map = new Map<string, string>();
|
|
250
|
+
for (const spec of specs) map.set(spec, (await resolveSpec(spec, id)).url);
|
|
251
|
+
const out = await transformer.transform({ path, source, format }, (s) => map.get(s) ?? s);
|
|
252
|
+
await writeText(cache, `${tRoot}/${id}`, out);
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Resolve a file id (project `~/…` or `{pkg}@{ver}/{file}`) to its raw bytes. */
|
|
257
|
+
async function rawBytes(id: string): Promise<Uint8Array | undefined> {
|
|
258
|
+
if (id.startsWith("~/")) {
|
|
259
|
+
const path = `/${id.slice(2)}`;
|
|
260
|
+
if (!project || !(await project.exists(path))) return undefined;
|
|
261
|
+
return collect(project.read(path));
|
|
262
|
+
}
|
|
263
|
+
const m = id.match(/^((?:@[^/]+\/)?[^/]+@[^/]+)\/(.+)$/);
|
|
264
|
+
if (!m) return undefined;
|
|
265
|
+
await ensureRawByKey(m[1]);
|
|
266
|
+
const file = await resolveRawFile(m[1], m[2]);
|
|
267
|
+
if (!(await cache.exists(`/raw/${m[1]}/${file}`))) return undefined;
|
|
268
|
+
return collect(cache.read(`/raw/${m[1]}/${file}`));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Serve a file's raw bytes (non-module resources, or `?raw`). */
|
|
272
|
+
async function serveRaw(id: string, asOctet: boolean): Promise<Response> {
|
|
273
|
+
const bytes = await rawBytes(id);
|
|
274
|
+
if (!bytes) return new Response(null, { status: 404 });
|
|
275
|
+
return new Response(bytes as BodyInit, {
|
|
276
|
+
status: 200,
|
|
277
|
+
headers: { "content-type": asOctet ? "application/octet-stream" : contentType(id) },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Serve a JSON file as an ESM module (`export default …`) — how a JS `import`
|
|
282
|
+
* of a `.json` is satisfied without relying on browser JSON-import attributes. */
|
|
283
|
+
async function serveJsonModule(id: string): Promise<Response> {
|
|
284
|
+
const bytes = await rawBytes(id);
|
|
285
|
+
if (!bytes) return new Response(null, { status: 404 });
|
|
286
|
+
const json = new TextDecoder().decode(bytes);
|
|
287
|
+
return new Response(`export default ${json};`, {
|
|
288
|
+
status: 200,
|
|
289
|
+
headers: { "content-type": "text/javascript" },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function resolveEntryId(ref: ModuleRef): Promise<string> {
|
|
294
|
+
if ("url" in ref) {
|
|
295
|
+
if (/^https?:/.test(ref.url)) throw new ModuleResolveError(ref, "absolute URL is not local");
|
|
296
|
+
const p = idFromPath(ref.url);
|
|
297
|
+
return p.startsWith("~/") ? p : `~/${p.replace(/^\//, "")}`;
|
|
298
|
+
}
|
|
299
|
+
return (await ensurePackage(ref)).id;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Walk + transform + cache the whole graph from an entry; persist the lockfile.
|
|
303
|
+
* Returns the entry id and every reachable module id. */
|
|
304
|
+
async function walkGraph(entry: ModuleRef): Promise<{ rootId: string; ids: string[] }> {
|
|
305
|
+
const rootId = await resolveEntryId(entry);
|
|
306
|
+
const seen = new Set<string>();
|
|
307
|
+
const queue = [rootId];
|
|
308
|
+
while (queue.length) {
|
|
309
|
+
const id = queue.shift();
|
|
310
|
+
if (id === undefined || seen.has(id)) continue;
|
|
311
|
+
seen.add(id);
|
|
312
|
+
if (!isModuleFile(id)) {
|
|
313
|
+
// non-JS resource (json/css/…): ensure it's cached, don't scan/transform.
|
|
314
|
+
const m = id.match(/^((?:@[^/]+\/)?[^/]+@[^/]+)\//);
|
|
315
|
+
if (m) await ensureRawByKey(m[1]);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const { source, format } = await loadRaw(id);
|
|
319
|
+
for (const spec of await scanSpecifiers(source, format)) {
|
|
320
|
+
const { id: childId } = await resolveSpec(spec, id);
|
|
321
|
+
if (childId && !seen.has(childId)) queue.push(childId);
|
|
322
|
+
}
|
|
323
|
+
await transformAndCache(id);
|
|
324
|
+
}
|
|
325
|
+
await writeText(cache, "/lock.json", JSON.stringify(lock));
|
|
326
|
+
return { rootId, ids: [...seen] };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
get lock() {
|
|
331
|
+
return lock;
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
async resolve(ref: ModuleRef): Promise<ResolvedModule> {
|
|
335
|
+
await init();
|
|
336
|
+
const id = await resolveEntryId(ref);
|
|
337
|
+
return { url: urlFor(id), target };
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
async prime(entry: ModuleRef): Promise<ResolvedModule> {
|
|
341
|
+
await init();
|
|
342
|
+
const { rootId } = await walkGraph(entry);
|
|
343
|
+
return { url: urlFor(rootId), target };
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
async listResources(entry: ModuleRef): Promise<string[]> {
|
|
347
|
+
await init();
|
|
348
|
+
const { ids } = await walkGraph(entry);
|
|
349
|
+
return ids.map(urlFor).sort();
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
async listPackageFiles(ref: ModuleRef): Promise<string[]> {
|
|
353
|
+
await init();
|
|
354
|
+
if ("url" in ref) throw new ModuleResolveError(ref, "not a package reference");
|
|
355
|
+
const { name, version } = await ensurePackage({ pkg: ref.pkg, version: ref.version });
|
|
356
|
+
const key = `${name}@${version}`;
|
|
357
|
+
const files: string[] = [];
|
|
358
|
+
for await (const info of cache.list(`/raw/${key}`, { recursive: true })) {
|
|
359
|
+
if (info.kind === "file") files.push(info.path.replace(`/raw/${key}/`, ""));
|
|
360
|
+
}
|
|
361
|
+
return files.sort();
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
async fetch(request: Request): Promise<Response> {
|
|
365
|
+
await init();
|
|
366
|
+
const url = new URL(request.url);
|
|
367
|
+
const id = idFromPath(url.pathname);
|
|
368
|
+
try {
|
|
369
|
+
// `?raw` → octet-stream; `?module` on a `.json` → ESM wrapper; non-module
|
|
370
|
+
// files (json/md/css/…) → raw + guessed type.
|
|
371
|
+
if (url.searchParams.has("raw")) return await serveRaw(id, true);
|
|
372
|
+
if (url.searchParams.has("module") && id.endsWith(".json")) {
|
|
373
|
+
return await serveJsonModule(id);
|
|
374
|
+
}
|
|
375
|
+
if (!isModuleFile(id)) return await serveRaw(id, false);
|
|
376
|
+
// module files → transform to ESM.
|
|
377
|
+
const body = (await cache.exists(`${tRoot}/${id}`))
|
|
378
|
+
? await readText(cache, `${tRoot}/${id}`)
|
|
379
|
+
: await transformAndCache(id);
|
|
380
|
+
return new Response(body, { status: 200, headers: { "content-type": "text/javascript" } });
|
|
381
|
+
} catch {
|
|
382
|
+
return new Response(null, { status: 404 });
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function normalizeBase(base: string): string {
|
|
389
|
+
let b = base.startsWith("/") ? base : `/${base}`;
|
|
390
|
+
if (!b.endsWith("/")) b += "/";
|
|
391
|
+
return b;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// The deps prefix is relative to `basePath` — no leading slash, trailing slash
|
|
395
|
+
// when non-empty; "" means "no prefix" (packages served alongside project files).
|
|
396
|
+
function normalizeDeps(prefix: string): string {
|
|
397
|
+
if (!prefix) return "";
|
|
398
|
+
const s = prefix.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
399
|
+
return s ? `${s}/` : "";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function collect(chunks: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
|
|
403
|
+
const parts: Uint8Array[] = [];
|
|
404
|
+
let n = 0;
|
|
405
|
+
for await (const c of chunks) {
|
|
406
|
+
parts.push(c);
|
|
407
|
+
n += c.length;
|
|
408
|
+
}
|
|
409
|
+
const out = new Uint8Array(n);
|
|
410
|
+
let off = 0;
|
|
411
|
+
for (const p of parts) {
|
|
412
|
+
out.set(p, off);
|
|
413
|
+
off += p.length;
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Split a bare specifier into package name + optional subpath (scope-aware). */
|
|
2
|
+
export function parseSpecifier(spec: string): { pkg: string; subpath?: string } {
|
|
3
|
+
const parts = spec.split("/");
|
|
4
|
+
if (spec.startsWith("@")) {
|
|
5
|
+
const pkg = parts.slice(0, 2).join("/");
|
|
6
|
+
const subpath = parts.slice(2).join("/");
|
|
7
|
+
return subpath ? { pkg, subpath } : { pkg };
|
|
8
|
+
}
|
|
9
|
+
const [pkg, ...rest] = parts;
|
|
10
|
+
const subpath = rest.join("/");
|
|
11
|
+
return subpath ? { pkg, subpath } : { pkg };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Relative URL from one canonical module id to another (both are slash paths with
|
|
16
|
+
* no leading slash, filename last). Keeps served imports mount-prefix-portable.
|
|
17
|
+
*/
|
|
18
|
+
export function relativeUrl(fromId: string, toId: string): string {
|
|
19
|
+
const from = fromId.split("/").slice(0, -1); // importer directory parts
|
|
20
|
+
const to = toId.split("/");
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < from.length && i < to.length && from[i] === to[i]) i++;
|
|
23
|
+
const ups = from.length - i;
|
|
24
|
+
const rel = [...Array(ups).fill(".."), ...to.slice(i)].join("/");
|
|
25
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { FilesApi } from "@statewalker/webrun-files";
|
|
2
|
+
import { MemFilesApi } from "@statewalker/webrun-files-mem";
|
|
3
|
+
import semver from "semver";
|
|
4
|
+
import type { LoadedPackage, ModuleRef, PackageManifest, Source } from "../types.js";
|
|
5
|
+
import { ModuleResolveError } from "../types.js";
|
|
6
|
+
import { untarTgz } from "./untar.js";
|
|
7
|
+
|
|
8
|
+
export interface NpmRegistrySourceOptions {
|
|
9
|
+
/** Registry base URL. Defaults to the public npm registry. */
|
|
10
|
+
registryUrl?: string;
|
|
11
|
+
/** Injectable fetch (tests supply a fake; defaults to global `fetch`). */
|
|
12
|
+
fetch?: typeof fetch;
|
|
13
|
+
/** Factory for the in-memory package tree. Defaults to `new MemFilesApi()`. */
|
|
14
|
+
createFiles?: () => FilesApi;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Registry metadata shape (only the fields we read). */
|
|
18
|
+
interface RegistryMeta {
|
|
19
|
+
"dist-tags"?: Record<string, string>;
|
|
20
|
+
versions?: Record<string, PackageManifest & { dist?: { tarball?: string } }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The default acquisition `Source`: resolves a version from npm registry
|
|
25
|
+
* metadata, downloads the tarball, untars it into a fresh `FilesApi`, and returns
|
|
26
|
+
* the package files + manifest. Fully isomorphic (`fetch` + pure-JS untar).
|
|
27
|
+
*/
|
|
28
|
+
export function npmRegistrySource(opts: NpmRegistrySourceOptions = {}): Source {
|
|
29
|
+
const registry = (opts.registryUrl ?? "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
30
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
31
|
+
const createFiles = opts.createFiles ?? (() => new MemFilesApi());
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
matches(ref: ModuleRef): boolean {
|
|
35
|
+
return "pkg" in ref;
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async load(ref: ModuleRef): Promise<LoadedPackage> {
|
|
39
|
+
if (!("pkg" in ref)) throw new ModuleResolveError(ref, "not an npm reference");
|
|
40
|
+
const name = ref.pkg;
|
|
41
|
+
|
|
42
|
+
const metaRes = await doFetch(`${registry}/${encodeName(name)}`);
|
|
43
|
+
if (!metaRes.ok) throw new ModuleResolveError(ref, `registry ${metaRes.status} for ${name}`);
|
|
44
|
+
const meta = (await metaRes.json()) as RegistryMeta;
|
|
45
|
+
|
|
46
|
+
const version = resolveVersion(meta, ref.version, ref);
|
|
47
|
+
const vmeta = meta.versions?.[version];
|
|
48
|
+
const tarball = vmeta?.dist?.tarball;
|
|
49
|
+
if (!tarball) throw new ModuleResolveError(ref, `no tarball for ${name}@${version}`);
|
|
50
|
+
|
|
51
|
+
const tgzRes = await doFetch(tarball);
|
|
52
|
+
if (!tgzRes.ok)
|
|
53
|
+
throw new ModuleResolveError(ref, `tarball ${tgzRes.status} for ${name}@${version}`);
|
|
54
|
+
const entries = untarTgz(new Uint8Array(await tgzRes.arrayBuffer()));
|
|
55
|
+
|
|
56
|
+
const files = createFiles();
|
|
57
|
+
for (const [path, bytes] of entries) await files.write(`/${path}`, [bytes]);
|
|
58
|
+
|
|
59
|
+
return { name, version, files, manifest: { ...vmeta, name, version } };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve a dist-tag, exact version, or semver range to a concrete version. */
|
|
65
|
+
export function resolveVersion(
|
|
66
|
+
meta: RegistryMeta,
|
|
67
|
+
spec: string | undefined,
|
|
68
|
+
ref: ModuleRef,
|
|
69
|
+
): string {
|
|
70
|
+
const versions = Object.keys(meta.versions ?? {});
|
|
71
|
+
const tags = meta["dist-tags"] ?? {};
|
|
72
|
+
const s = spec ?? "latest";
|
|
73
|
+
if (tags[s]) return tags[s]; // dist-tag (latest, next, …)
|
|
74
|
+
if (versions.includes(s)) return s; // exact
|
|
75
|
+
const range = semver.validRange(s);
|
|
76
|
+
if (range) {
|
|
77
|
+
const best = semver.maxSatisfying(versions, range);
|
|
78
|
+
if (best) return best;
|
|
79
|
+
}
|
|
80
|
+
throw new ModuleResolveError(ref, `no version satisfies "${s}"`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Encode a package name for a registry URL (scoped names need the `/` escaped). */
|
|
84
|
+
function encodeName(name: string): string {
|
|
85
|
+
return name.startsWith("@") ? name.replace("/", "%2F") : name;
|
|
86
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { gunzipSync } from "fflate";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Untar an npm `.tgz` (gzipped ustar) into a map of path → bytes, isomorphically
|
|
5
|
+
* (no `node:zlib`, no `Buffer`). npm tarballs prefix every entry with `package/`;
|
|
6
|
+
* that prefix is stripped so keys are relative to the package root.
|
|
7
|
+
*/
|
|
8
|
+
export function untarTgz(tgz: Uint8Array): Map<string, Uint8Array> {
|
|
9
|
+
return untar(gunzipSync(tgz));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const td = new TextDecoder();
|
|
13
|
+
|
|
14
|
+
function str(buf: Uint8Array, start: number, end: number): string {
|
|
15
|
+
return td.decode(buf.subarray(start, end)).replace(/\0.*/s, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Parse a raw ustar archive (512-byte blocks). */
|
|
19
|
+
export function untar(buf: Uint8Array): Map<string, Uint8Array> {
|
|
20
|
+
const out = new Map<string, Uint8Array>();
|
|
21
|
+
let off = 0;
|
|
22
|
+
while (off + 512 <= buf.length) {
|
|
23
|
+
const name = str(buf, off, off + 100);
|
|
24
|
+
if (!name) break; // two zero blocks terminate the archive
|
|
25
|
+
const size = Number.parseInt(str(buf, off + 124, off + 136).trim() || "0", 8);
|
|
26
|
+
const type = str(buf, off + 156, off + 157);
|
|
27
|
+
const prefix = str(buf, off + 345, off + 500);
|
|
28
|
+
const full = prefix ? `${prefix}/${name}` : name;
|
|
29
|
+
const start = off + 512;
|
|
30
|
+
// type "0" or "" = regular file; skip directories/links/pax headers.
|
|
31
|
+
if (type === "0" || type === "") {
|
|
32
|
+
out.set(full.replace(/^package\//, ""), buf.subarray(start, start + size));
|
|
33
|
+
}
|
|
34
|
+
off = start + Math.ceil(size / 512) * 512;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PackageManifest, SourceFile, SourceFormat, Transform } from "../types.js";
|
|
2
|
+
import { newCjsTransform } from "./transform-cjs.js";
|
|
3
|
+
import { newEsmTransform } from "./transform-esm.js";
|
|
4
|
+
|
|
5
|
+
/** The default per-file transform: dispatch ESM/TS/JSX vs CJS by `file.format`. */
|
|
6
|
+
export function newDefaultTransform(): Transform {
|
|
7
|
+
const esm = newEsmTransform();
|
|
8
|
+
const cjs = newCjsTransform();
|
|
9
|
+
return {
|
|
10
|
+
transform(file: SourceFile, rewrite: (specifier: string) => string): Promise<string> {
|
|
11
|
+
return file.format === "cjs" ? cjs.transform(file, rewrite) : esm.transform(file, rewrite);
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// `module.exports` / `exports.x` / `exports[…]` are impossible in real ESM — a
|
|
17
|
+
// *definitive* CJS signal (unlike the word "export", which appears in CJS files'
|
|
18
|
+
// strings/comments, e.g. React's dev warnings).
|
|
19
|
+
const CJS_EXPORTS = /\bmodule\.exports\b|\bexports\s*[.[]/;
|
|
20
|
+
// A real ESM statement: `import`/`export` as syntax, not a word in prose.
|
|
21
|
+
const ESM_STMT = /(^|[\s;])(import|export)[\s{*'"]/;
|
|
22
|
+
const REQUIRE_CALL = /(^|[^.\w])require\s*\(/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decide a file's `SourceFormat` from its extension, the package `type`, and
|
|
26
|
+
* (for ambiguous `.js`) a content sniff. Mirrors Node: `.mjs`=ESM, `.cjs`=CJS,
|
|
27
|
+
* `.js` follows `package.json#type`; with no `type`, a package `.js` is CJS
|
|
28
|
+
* (Node's default) unless real ESM syntax is present. Definitive CJS markers
|
|
29
|
+
* (`module.exports`/`exports.x`) win over a loose `import`/`export` word-match,
|
|
30
|
+
* so a CJS file with "export" in a string is not mis-read as ESM.
|
|
31
|
+
*/
|
|
32
|
+
export function detectFormat(
|
|
33
|
+
path: string,
|
|
34
|
+
source: string,
|
|
35
|
+
manifest?: PackageManifest,
|
|
36
|
+
): SourceFormat {
|
|
37
|
+
if (path.endsWith(".ts")) return "ts";
|
|
38
|
+
if (path.endsWith(".tsx") || path.endsWith(".jsx")) return "tsx";
|
|
39
|
+
if (path.endsWith(".mjs")) return "esm";
|
|
40
|
+
if (path.endsWith(".cjs")) return "cjs";
|
|
41
|
+
if (manifest?.type === "module") return "esm";
|
|
42
|
+
if (manifest?.type === "commonjs") return "cjs";
|
|
43
|
+
if (CJS_EXPORTS.test(source)) return "cjs"; // definitive CJS
|
|
44
|
+
if (ESM_STMT.test(source)) return "esm"; // real import/export syntax
|
|
45
|
+
if (REQUIRE_CALL.test(source)) return "cjs"; // weaker CJS hint
|
|
46
|
+
// Node default: a package `.js` with no `type:module` is CJS; authored source
|
|
47
|
+
// (no manifest) defaults to ESM.
|
|
48
|
+
return manifest ? "cjs" : "esm";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { newCjsTransform } from "./transform-cjs.js";
|
|
52
|
+
export { newEsmTransform } from "./transform-esm.js";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { init as initCjs, parse as parseCjs } from "cjs-module-lexer";
|
|
2
|
+
import { init as initEsm, parse as parseEsm } from "es-module-lexer";
|
|
3
|
+
import type { SourceFormat } from "../types.js";
|
|
4
|
+
import { toJs } from "./to-js.js";
|
|
5
|
+
|
|
6
|
+
const REQUIRE_RE = /require\(\s*(['"])((?:(?!\1)[^\\]|\\.)*)\1\s*\)/g;
|
|
7
|
+
|
|
8
|
+
let esmReady: Promise<unknown> | undefined;
|
|
9
|
+
let cjsReady: Promise<unknown> | undefined;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* List the import specifiers a file's transform will rewrite — the exact same set
|
|
13
|
+
* the ESM/CJS transforms discover — so the server can pre-resolve them (async)
|
|
14
|
+
* before running the synchronous rewrite. For TS/JSX the raw source is scanned;
|
|
15
|
+
* `es-module-lexer` tolerates types well enough to find every specifier.
|
|
16
|
+
*/
|
|
17
|
+
export async function scanSpecifiers(source: string, format: SourceFormat): Promise<string[]> {
|
|
18
|
+
if (format === "cjs") {
|
|
19
|
+
cjsReady ??= initCjs();
|
|
20
|
+
await cjsReady;
|
|
21
|
+
return [...new Set([...source.matchAll(REQUIRE_RE)].map((m) => m[2]))];
|
|
22
|
+
}
|
|
23
|
+
esmReady ??= initEsm;
|
|
24
|
+
await esmReady;
|
|
25
|
+
const [imports] = parseEsm(toJs(source, format));
|
|
26
|
+
const out = new Set<string>();
|
|
27
|
+
for (const imp of imports) if (imp.n != null) out.add(imp.n);
|
|
28
|
+
return [...out];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// re-export so callers can warm the CJS lexer for export detection if needed
|
|
32
|
+
export { parseCjs };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { transform as sucraseTransform } from "sucrase";
|
|
2
|
+
import type { SourceFormat } from "../types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Strip TS/JSX to plain JS (leave already-ESM/CJS JS untouched). Shared by the
|
|
6
|
+
* ESM transform and the specifier scanner so both see the *same* JS — raw TS/JSX
|
|
7
|
+
* cannot be lexed directly (`es-module-lexer` throws on JSX). JSX uses the
|
|
8
|
+
* automatic runtime, so scanning the output also surfaces the `react/jsx-runtime`
|
|
9
|
+
* import the runtime injects.
|
|
10
|
+
*/
|
|
11
|
+
export function toJs(source: string, format: SourceFormat, path?: string): string {
|
|
12
|
+
if (format === "ts" || format === "tsx") {
|
|
13
|
+
const transforms: ("typescript" | "jsx")[] =
|
|
14
|
+
format === "tsx" ? ["typescript", "jsx"] : ["typescript"];
|
|
15
|
+
return sucraseTransform(source, { transforms, jsxRuntime: "automatic", filePath: path }).code;
|
|
16
|
+
}
|
|
17
|
+
return source;
|
|
18
|
+
}
|