@webjsdev/server 0.7.3 → 0.8.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/src/vendor.js CHANGED
@@ -1,56 +1,91 @@
1
1
  /**
2
- * Auto-bundle npm dependencies for the browser.
2
+ * Resolve bare npm imports to browser-loadable URLs via jspm.io.
3
3
  *
4
- * When user code imports a bare specifier (e.g. `import dayjs from 'dayjs'`)
5
- * from a client-side file, the browser can't resolve it natively. This module
6
- * provides Vite-style `optimizeDeps` behaviour:
4
+ * webjs follows the Rails 7 + importmap-rails posture exactly. When user
5
+ * code imports a bare specifier (e.g. `import dayjs from 'dayjs'`), the
6
+ * browser can't resolve it natively. The framework's job is to emit an
7
+ * importmap that translates each bare specifier to a real URL.
7
8
  *
8
- * 1. On startup (and rebuild), scan client-reachable source for bare import
9
- * specifiers that aren't already in the import map.
9
+ * The URL points at **jspm.io**, the same CDN Rails uses by default:
10
10
  *
11
- * 2. For each discovered package, bundle it into a single ESM file via
12
- * esbuild (inlining transitive deps) and cache the result.
11
+ * importmap: { "dayjs": "https://ga.jspm.io/npm:dayjs@1.11.13/index.js" }
13
12
  *
14
- * 3. Serve the bundle at `/__webjs/vendor/<pkg>.js` and add it to the
15
- * import map automatically.
13
+ * The browser fetches the bundle directly from jspm.io. The webjs server
14
+ * does not proxy, cache, or bundle anything. jspm.io has done the work
15
+ * server-side (CJS-to-ESM conversion, transitive bundling, browser
16
+ * polyfills).
16
17
  *
17
- * This is intentionally lazy + cached: the first request for a vendor bundle
18
- * triggers the esbuild build; subsequent requests are served from the in-memory
19
- * cache. A file watcher rebuild clears the cache so new deps are picked up.
18
+ * Why jspm.io: institutional backing (37signals, CacheFly for CDN
19
+ * infrastructure, Rails ecosystem dependency creates downstream pressure
20
+ * for continued operation), status page at status.jspm.io, standards-
21
+ * first maintenance by Guy Bedford (TC39 contributor on ESM and import
22
+ * maps). Years of uptime track record.
23
+ *
24
+ * URL resolution: jspm.io's bare-package URL (without entry path)
25
+ * returns metadata, not JavaScript. The correct entry file (e.g.,
26
+ * `/dayjs.min.js`, `/index.js`) varies per package and must be
27
+ * resolved from the JSPM Generator API. The Generator is called once
28
+ * per server boot for the full set of bare imports; results are
29
+ * cached in-memory for the process lifetime.
30
+ *
31
+ * Server boot connectivity: the Generator API call happens during
32
+ * `setVendorEntries` at boot. If api.jspm.io is unreachable, the
33
+ * importmap will be missing vendor entries and the browser will
34
+ * report "unresolved bare specifier" errors. The server itself still
35
+ * boots and serves user routes; only vendor-importing pages break
36
+ * until api.jspm.io is reachable again. Failure is loud and clear.
37
+ *
38
+ * No local bundler. No disk cache. No memory cache of bundle bytes.
39
+ * Matches Rails' "no build" posture literally.
20
40
  */
21
41
 
22
- import { readFile, readdir, stat } from 'node:fs/promises';
23
- import { join, extname, sep } from 'node:path';
42
+ import { readFile, readdir, writeFile, mkdir, unlink, stat, rename } from 'node:fs/promises';
43
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
44
+ import { join, dirname, basename, sep } from 'node:path';
24
45
  import { createRequire } from 'node:module';
46
+ import { digestBase64, digestHex } from './crypto-utils.js';
25
47
 
26
48
  /**
27
- * Cache of bundled vendor modules.
28
- * @type {Map<string, string>}
29
- */
30
- const vendorCache = new Map();
31
- const VENDOR_CACHE_MAX = 100;
32
-
33
- /**
34
- * Set of package names known to be built-in / already mapped.
35
- * These are never auto-bundled.
49
+ * Set of package names whose importmap entries are populated by the
50
+ * framework, not by the vendor scanner. The scanner skips these to
51
+ * keep `@webjsdev/core` (and any future framework-internal package)
52
+ * off the jspm.io path: their bytes are served by the dev server's
53
+ * dedicated `/__webjs/core/*` route, and `buildCoreEntries()` in
54
+ * `importmap.js` derives one importmap line per exported subpath
55
+ * directly from the package's own `exports` field.
56
+ *
57
+ * The `'@webjsdev/core/'` prefix entry is here so that `extractPackageName`
58
+ * returning the bare name is enough to recognise core-subpath imports
59
+ * (`@webjsdev/core/directives`, `@webjsdev/core/task`, …) and skip
60
+ * them; the prefix form catches anything whose extractPackageName
61
+ * returns null but whose specifier starts with the prefix. Same
62
+ * mechanism, no special casing per subpath.
36
63
  */
37
- const BUILTIN = new Set(['@webjsdev/core', '@webjsdev/core/', '@webjsdev/core/client-router']);
64
+ const BUILTIN = new Set(['@webjsdev/core', '@webjsdev/core/']);
38
65
 
39
66
  /**
40
- * Scan source files under `dir` for bare import specifiers. Returns a Set of
41
- * package names (e.g. `'dayjs'`, `'@tanstack/query-core'`).
67
+ * Scan source files under `dir` for bare import specifiers reachable
68
+ * from the browser. Returns a Set of package names.
42
69
  *
43
- * Only scans `.js`, `.ts`, `.mjs`, `.mts` files. Skips `node_modules`,
44
- * `.webjs`, `public`, and `_private` directories.
70
+ * Excludes:
71
+ * - `node_modules`, `.webjs`, `public` directories
72
+ * - Any directory starting with `_` (webjs `_private/` convention)
73
+ * - `test/` and `tests/` (server-only by webjs convention)
74
+ * - Files whose name marks them as server-only:
75
+ * * `*.server.{js,ts,mjs,mts}` (path-level boundary)
76
+ * * `route.{js,ts,mjs,mts}` (file-router HTTP handler)
77
+ * * `middleware.{js,ts,mjs,mts}` (file-router middleware)
78
+ * - Any file whose first non-whitespace content is `'use server'`
79
+ * - `import type` statements (TypeScript erases them at compile time)
80
+ * - `import` strings inside `/* … *​/` block comments or `//` line comments
45
81
  *
46
82
  * @param {string} dir
47
83
  * @returns {Promise<Set<string>>}
48
84
  */
49
- export async function scanBareImports(dir) {
85
+ export async function scanBareImports(dir, skipFiles) {
50
86
  /** @type {Set<string>} */
51
87
  const found = new Set();
52
- await walk(dir, found);
53
- // Remove built-ins
88
+ await walk(dir, found, skipFiles);
54
89
  for (const b of BUILTIN) found.delete(b);
55
90
  return found;
56
91
  }
@@ -68,7 +103,6 @@ export async function scanBareImports(dir) {
68
103
  */
69
104
  export function extractPackageName(spec) {
70
105
  if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('__')) return null;
71
- // Protocol URLs (http:, data:, blob:, etc.)
72
106
  if (/^[a-z]+:/.test(spec)) return null;
73
107
  if (spec.startsWith('@')) {
74
108
  const parts = spec.split('/');
@@ -77,35 +111,92 @@ export function extractPackageName(spec) {
77
111
  return spec.split('/')[0];
78
112
  }
79
113
 
80
- /** @type {RegExp} */
81
- const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
114
+ // Matches `import { x } from 'pkg'`, `import 'pkg'`, `import * as x from 'pkg'`.
115
+ // The `(?!type\s)` negative lookahead skips `import type … from 'pkg'`
116
+ // because TypeScript type-only imports are fully erased at compile time
117
+ // and never reach the browser.
118
+ const IMPORT_RE = /\bimport\s+(?!type\s)(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
82
119
  const DYNAMIC_IMPORT_RE = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
83
120
 
121
+ const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
122
+ const LINE_COMMENT_RE = /(^|[^:])\/\/.*$/gm;
123
+ function stripComments(src) {
124
+ return src.replace(BLOCK_COMMENT_RE, '').replace(LINE_COMMENT_RE, '$1');
125
+ }
126
+
127
+ /**
128
+ * Filename matches webjs's server-only file-router conventions.
129
+ *
130
+ * @param {string} name basename of the file
131
+ */
132
+ function isServerOnlyFile(name) {
133
+ if (/\.server\.(js|ts|mjs|mts)$/.test(name)) return true;
134
+ if (/^route\.(js|ts|mjs|mts)$/.test(name)) return true;
135
+ if (/^middleware\.(js|ts|mjs|mts)$/.test(name)) return true;
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * Tooling config files at any depth. They import test runners, build
141
+ * helpers, AI plugins etc. that legitimately cannot resolve through
142
+ * jspm.io (e.g. `@web/test-runner-playwright` pulls in `playwright-core`
143
+ * with subpaths jspm.io can't bundle). Their bare imports must never
144
+ * reach the importmap.
145
+ */
146
+ const CONFIG_FILE_RE = /\.config\.(js|ts|mjs|mts|cjs|cts)$/;
147
+
84
148
  /**
85
149
  * @param {string} dir
86
150
  * @param {Set<string>} found
87
151
  */
88
- async function walk(dir, found) {
152
+ async function walk(dir, found, skipFiles) {
89
153
  let entries;
90
154
  try { entries = await readdir(dir, { withFileTypes: true }); }
91
155
  catch { return; }
92
156
  for (const e of entries) {
93
- if (e.name === 'node_modules' || e.name === '.webjs' || e.name === 'public' || e.name.startsWith('_')) continue;
157
+ if (
158
+ e.name === 'node_modules' ||
159
+ e.name === '.webjs' ||
160
+ e.name === 'public' ||
161
+ e.name === 'test' ||
162
+ e.name === 'tests' ||
163
+ e.name.startsWith('_') ||
164
+ // Skip ALL dot-prefixed dirs (.opencode, .claude, .github, .husky,
165
+ // .git, .vscode, .idea, .cursor, …). They hold tooling / IDE /
166
+ // agent state that imports packages the browser will never load
167
+ // (e.g. `@opencode-ai/plugin`). The walker visits dirs and files
168
+ // separately; this guard only fires for directory entries because
169
+ // dot-prefixed *files* (e.g. `.env.d.ts` someday) still need the
170
+ // extension check below.
171
+ (e.isDirectory() && e.name.startsWith('.'))
172
+ ) continue;
94
173
  const full = join(dir, e.name);
95
174
  if (e.isDirectory()) {
96
- await walk(full, found);
97
- } else if (/\.(js|ts|mjs|mts)$/.test(e.name) && !e.name.endsWith('.server.ts') && !e.name.endsWith('.server.js')) {
175
+ await walk(full, found, skipFiles);
176
+ } else if (skipFiles && skipFiles.has(full)) {
177
+ // Display-only component file: its imports are stripped from the
178
+ // served source, so a vendor specifier reachable ONLY through it
179
+ // never loads in the browser and must not enter the importmap. A
180
+ // specifier also imported by a shipping file still appears via that
181
+ // file's scan, so shared deps are retained.
182
+ continue;
183
+ } else if (/\.(js|ts|mjs|mts)$/.test(e.name) && !isServerOnlyFile(e.name) && !CONFIG_FILE_RE.test(e.name)) {
98
184
  try {
99
- const src = await readFile(full, 'utf8');
100
- // Skip files with 'use server' pragma
101
- if (src.trimStart().startsWith("'use server'") || src.trimStart().startsWith('"use server"')) continue;
185
+ const raw = await readFile(full, 'utf8');
186
+ if (raw.trimStart().startsWith("'use server'") || raw.trimStart().startsWith('"use server"')) continue;
187
+ const src = stripComments(raw);
188
+ // We keep the FULL specifier (with subpath), not just the package
189
+ // name. `import 'dayjs/plugin/utc'` adds `'dayjs/plugin/utc'` to the
190
+ // set, not just `'dayjs'`. vendorImportMapEntries needs the
191
+ // subpath to emit a per-specifier importmap entry; jspm.io
192
+ // resolves each subpath independently via the package's `exports`
193
+ // field. extractPackageName is still applied to filter out
194
+ // relative / absolute / protocol-URL specifiers.
102
195
  for (const m of src.matchAll(IMPORT_RE)) {
103
- const pkg = extractPackageName(m[1]);
104
- if (pkg) found.add(pkg);
196
+ if (extractPackageName(m[1])) found.add(m[1]);
105
197
  }
106
198
  for (const m of src.matchAll(DYNAMIC_IMPORT_RE)) {
107
- const pkg = extractPackageName(m[1]);
108
- if (pkg) found.add(pkg);
199
+ if (extractPackageName(m[1])) found.add(m[1]);
109
200
  }
110
201
  } catch { /* unreadable file */ }
111
202
  }
@@ -113,99 +204,1136 @@ async function walk(dir, found) {
113
204
  }
114
205
 
115
206
  /**
116
- * Bundle an npm package into a single ESM file for the browser.
207
+ * Resolve a package's installed directory on disk, handling both direct
208
+ * installation and npm workspace hoisting.
117
209
  *
118
- * @param {string} pkgName e.g. `'dayjs'`
119
- * @param {string} appDir app root for resolving node_modules
120
- * @param {boolean} dev
121
- * @returns {Promise<string | null>} bundled JS source, or null if not found
210
+ * @param {string} pkgName
211
+ * @param {string} appDir
212
+ * @returns {string | null}
213
+ */
214
+ function resolvePackageDir(pkgName, appDir) {
215
+ try {
216
+ const require = createRequire(join(appDir, 'package.json'));
217
+ const entry = require.resolve(pkgName);
218
+ const parts = entry.split(sep);
219
+ const nmIdx = parts.lastIndexOf('node_modules');
220
+ if (nmIdx < 0) {
221
+ let dir = dirname(entry);
222
+ for (let i = 0; i < 8; i++) {
223
+ if (existsSync(join(dir, 'package.json'))) return realpathSync(dir);
224
+ const parent = dirname(dir);
225
+ if (parent === dir) break;
226
+ dir = parent;
227
+ }
228
+ return null;
229
+ }
230
+ const segmentsAfterNm = pkgName.startsWith('@') ? 2 : 1;
231
+ const root = parts.slice(0, nmIdx + 1 + segmentsAfterNm).join(sep);
232
+ return realpathSync(root);
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Read the installed version of a package from `node_modules/<pkg>/
240
+ * package.json`. Handles workspace hoisting and packages that lock
241
+ * down `./package.json` in their exports field.
242
+ *
243
+ * @param {string} pkgName
244
+ * @param {string} appDir
245
+ * @returns {string | null}
246
+ */
247
+ export function getPackageVersion(pkgName, appDir) {
248
+ const real = resolvePackageDir(pkgName, appDir);
249
+ if (!real) return null;
250
+ try {
251
+ const pkg = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8'));
252
+ return pkg.version || null;
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // JSPM Generator API client
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /**
263
+ * In-memory cache of resolved importmap fragments from api.jspm.io.
264
+ * Keyed by the sorted+joined list of `pkg@version` install specs.
265
+ * Per-process; cleared by `clearVendorCache` on file-watcher rebuild
266
+ * so new versions get re-resolved.
267
+ *
268
+ * @type {Map<string, Record<string, string>>}
269
+ */
270
+ const jspmCache = new Map();
271
+
272
+ const JSPM_GENERATE_ENDPOINT = 'https://api.jspm.io/generate';
273
+ const JSPM_GENERATE_TIMEOUT_MS = 10_000;
274
+
275
+ /**
276
+ * Provider names accepted by `webjs vendor pin --from <provider>`.
277
+ * Default `jspm` resolves to jspm.io. Same set Rails's importmap-rails
278
+ * accepts (`packager.rb:normalize_provider`).
279
+ *
280
+ * jspm.io's Generator API itself supports multiple providers via the
281
+ * `provider` field in the request body. We surface the same choice as
282
+ * a CLI flag.
283
+ *
284
+ * @type {Set<string>}
285
+ */
286
+ export const SUPPORTED_PROVIDERS = new Set(['jspm', 'jsdelivr', 'unpkg', 'skypack']);
287
+
288
+ /**
289
+ * Normalize the user-facing provider name to what the jspm.io API
290
+ * expects in its `provider` field. Mirrors importmap-rails's
291
+ * `normalize_provider`: `jspm` is shorthand for `jspm.io`; the rest
292
+ * pass through verbatim.
293
+ *
294
+ * @param {string} name
295
+ * @returns {string}
296
+ */
297
+ export function normalizeProvider(name) {
298
+ return name === 'jspm' ? 'jspm.io' : name;
299
+ }
300
+
301
+ /**
302
+ * Resolve a SINGLE `pkg@version` (or `pkg@version/subpath`) install via
303
+ * api.jspm.io/generate. Returns the imports fragment (typically one or
304
+ * two entries; subpath installs sometimes include the root package).
305
+ *
306
+ * Per-package isolation is the whole point: api.jspm.io/generate fails
307
+ * the ENTIRE batch with a 401 when any single package can't be
308
+ * resolved (e.g. a transitive that has no jspm.io-compatible exports).
309
+ * Calling per-package means one bad dep can no longer poison the
310
+ * importmap for legitimate deps.
311
+ *
312
+ * Cached in-process by the install spec + provider. Failures are
313
+ * logged loudly with the package name and the reason jspm.io
314
+ * returned.
315
+ *
316
+ * @param {string} install e.g. 'dayjs@1.11.13' or 'dayjs@1.11.13/plugin/utc'
317
+ * @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
318
+ * @returns {Promise<Record<string, string>>}
319
+ */
320
+ async function jspmResolveOne(install, provider = 'jspm') {
321
+ // Cache key includes provider since the same install can resolve
322
+ // to different URLs across CDNs (e.g. `dayjs@1.11.13` returns
323
+ // ga.jspm.io vs cdn.jsdelivr.net depending on `provider`).
324
+ const cacheKey = `${provider}::${install}`;
325
+ const existing = jspmCache.get(cacheKey);
326
+ if (existing) return existing;
327
+
328
+ const promise = (async () => {
329
+ const controller = new AbortController();
330
+ const timer = setTimeout(() => controller.abort(), JSPM_GENERATE_TIMEOUT_MS);
331
+ try {
332
+ const response = await fetch(JSPM_GENERATE_ENDPOINT, {
333
+ method: 'POST',
334
+ headers: { 'content-type': 'application/json' },
335
+ body: JSON.stringify({
336
+ install: [install],
337
+ // flattenScope:true merges transitive ESM deps into the
338
+ // flat `imports` map instead of returning them in a
339
+ // separate `scopes` field. Webjs only consumes `imports`
340
+ // (the importmap output doesn't carry `scopes`), so
341
+ // without this any package with an unbundled ESM
342
+ // transitive (e.g. react-dom imports `scheduler`)
343
+ // would break in the browser with an unresolved-bare-
344
+ // specifier error. Matches importmap-rails's posture.
345
+ flattenScope: true,
346
+ env: ['browser', 'production', 'module'],
347
+ provider: normalizeProvider(provider),
348
+ }),
349
+ signal: controller.signal,
350
+ });
351
+ if (!response.ok) {
352
+ // jspm.io returns the error reason in the body with a 401 (its
353
+ // quirk: 401 is what it sends for unresolvable installs, not
354
+ // auth failures). Surface it so the user sees WHICH dep failed
355
+ // and why, not just a generic "vendor pipeline broken".
356
+ let detail = '';
357
+ try {
358
+ const body = await response.json();
359
+ if (body && typeof body.error === 'string') detail = `: ${body.error}`;
360
+ } catch { /* non-JSON body */ }
361
+ console.error(
362
+ `[webjs] could not vendor '${install}' via ${provider} (status ${response.status})${detail}`,
363
+ );
364
+ jspmCache.delete(cacheKey);
365
+ return {};
366
+ }
367
+ const result = await response.json();
368
+ return (result && result.map && result.map.imports) || {};
369
+ } catch (e) {
370
+ const msg = e && e.name === 'AbortError'
371
+ ? `timed out after ${JSPM_GENERATE_TIMEOUT_MS}ms`
372
+ : `${e && e.message}`;
373
+ console.error(`[webjs] could not vendor '${install}' via ${provider}: ${msg}`);
374
+ jspmCache.delete(cacheKey);
375
+ return {};
376
+ } finally {
377
+ clearTimeout(timer);
378
+ }
379
+ })();
380
+
381
+ jspmCache.set(cacheKey, promise);
382
+ return promise;
383
+ }
384
+
385
+ /**
386
+ * Resolve a list of `pkg@version` installs to importmap entries by
387
+ * calling api.jspm.io/generate ONCE PER INSTALL in parallel. Per-package
388
+ * isolation prevents one bad dep from collapsing the whole importmap
389
+ * (see jspmResolveOne for the rationale).
390
+ *
391
+ * The merge is last-write-wins per key. In practice subpath installs
392
+ * never collide with each other (their keys include the subpath), and
393
+ * the bare-package install for `dayjs` always produces the same root
394
+ * URL as `dayjs@x.y.z/plugin/foo`'s incidental `dayjs` entry.
395
+ *
396
+ * @param {Array<string>} installs e.g. ['dayjs@1.11.13', 'clsx@2.1.1']
397
+ * @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
398
+ * @returns {Promise<Record<string, string>>}
399
+ */
400
+ export async function jspmGenerate(installs, provider = 'jspm') {
401
+ if (installs.length === 0) return {};
402
+ const perPackage = await Promise.all(installs.map(i => jspmResolveOne(i, provider)));
403
+ const merged = {};
404
+ for (const fragment of perPackage) Object.assign(merged, fragment);
405
+ return merged;
406
+ }
407
+
408
+ /**
409
+ * Build importmap entries for discovered bare imports. For each scanned
410
+ * package, resolve its installed version from node_modules, then ask
411
+ * api.jspm.io/generate for the full importmap fragment.
412
+ *
413
+ * Async because the Generator API call is networked. Called from
414
+ * `setVendorEntries` during server boot and rebuild; not per request.
415
+ *
416
+ * @param {Set<string>} bareImports from scanBareImports()
417
+ * @param {string} appDir
418
+ * @returns {Promise<Record<string, string>>}
419
+ */
420
+ export async function vendorImportMapEntries(bareImports, appDir) {
421
+ const installs = [];
422
+ for (const spec of bareImports) {
423
+ if (BUILTIN.has(spec)) continue;
424
+ const pkg = extractPackageName(spec);
425
+ if (!pkg || BUILTIN.has(pkg)) continue;
426
+ const version = getPackageVersion(pkg, appDir);
427
+ if (!version) continue;
428
+ // Splice the version into the specifier: 'dayjs/plugin/utc' with
429
+ // version 1.11.13 becomes 'dayjs@1.11.13/plugin/utc'. jspm.io's
430
+ // Generator API resolves subpaths individually via the package's
431
+ // `exports` field. Root imports stay as `<pkg>@<version>` with no
432
+ // trailing subpath.
433
+ const subpath = spec.slice(pkg.length);
434
+ installs.push(`${pkg}@${version}${subpath}`);
435
+ }
436
+ return jspmGenerate(installs);
437
+ }
438
+
439
+ /**
440
+ * Clear the resolved-importmap cache. Called on file-watcher rebuild
441
+ * so newly-added bare imports trigger a fresh api.jspm.io/generate
442
+ * call on the next request to populate the in-memory cache.
443
+ */
444
+ export function clearVendorCache() {
445
+ jspmCache.clear();
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // File-based pin (.webjs/vendor/importmap.json, optional --download bundles)
450
+ // ---------------------------------------------------------------------------
451
+
452
+ const PIN_DIR_REL = ['.webjs', 'vendor'];
453
+ const PIN_FILE = 'importmap.json';
454
+
455
+ /** Compute the absolute path of the pin directory for an app. */
456
+ function pinDir(appDir) {
457
+ return join(appDir, ...PIN_DIR_REL);
458
+ }
459
+
460
+ /** Compute the absolute path of the importmap config file for an app. */
461
+ function pinFilePath(appDir) {
462
+ return join(pinDir(appDir), PIN_FILE);
463
+ }
464
+
465
+ /**
466
+ * Filesystem-safe filename for a downloaded bundle. Encodes the full
467
+ * specifier (which may include a subpath) into a flat filename:
468
+ *
469
+ * bundleFilenameWithSubpath('dayjs', '1.11.13', '') returns 'dayjs@1.11.13.js'
470
+ * bundleFilenameWithSubpath('dayjs', '1.11.13', '/plugin/utc') returns 'dayjs@1.11.13__plugin__utc.js'
471
+ * bundleFilenameWithSubpath('@hotwired/turbo', '8.0.0', '') returns '@hotwired--turbo@8.0.0.js'
472
+ *
473
+ * Scoped names use `--` to encode `/`; subpath separators use `__`.
474
+ * Both are reversible round-trip so unpin / list can parse the
475
+ * package + version + subpath back from the filename.
122
476
  */
123
- export async function bundlePackage(pkgName, appDir, dev) {
124
- const cached = vendorCache.get(pkgName);
125
- if (cached) return cached;
477
+ function bundleFilenameWithSubpath(pkgName, version, subpath) {
478
+ const safeName = pkgName.replace(/\//g, '--');
479
+ const safeSubpath = subpath.replace(/\//g, '__');
480
+ return `${safeName}@${version}${safeSubpath}.js`;
481
+ }
126
482
 
127
- let build;
128
- try { ({ build } = await import('esbuild')); }
129
- catch { return null; }
483
+ /**
484
+ * Compute the SHA-384 SRI hash for a bundle body. Matches the format
485
+ * the browser's importmap `integrity` field and the `integrity`
486
+ * attribute on `<link rel="modulepreload">` expect. Accepts a string
487
+ * or any ArrayBufferView / ArrayBuffer.
488
+ *
489
+ * @param {string | ArrayBufferView | ArrayBuffer} body
490
+ * @returns {Promise<string>} e.g. `sha384-<base64>`
491
+ */
492
+ export async function sha384Integrity(body) {
493
+ return `sha384-${await digestBase64('SHA-384', body)}`;
494
+ }
130
495
 
131
- // Locate the package entry via Node resolution
132
- const require = createRequire(join(appDir, 'package.json'));
133
- let entryPoint;
496
+ /**
497
+ * Read the committed pin importmap if one exists. Returns the parsed
498
+ * `{ imports, integrity?, provider? }` shape or null if no pin file.
499
+ * The `integrity` and `provider` fields are optional: pin files
500
+ * written before SRI / multi-CDN support lack them; pin files written
501
+ * by current `webjs vendor pin` include them (provider only when
502
+ * non-default).
503
+ *
504
+ * @param {string} appDir
505
+ * @returns {Promise<{ imports: Record<string, string>, integrity?: Record<string, string>, provider?: string } | null>}
506
+ */
507
+ export async function readPinFile(appDir) {
134
508
  try {
135
- entryPoint = require.resolve(pkgName);
509
+ const raw = await readFile(pinFilePath(appDir), 'utf8');
510
+ const parsed = JSON.parse(raw);
511
+ if (!parsed || typeof parsed.imports !== 'object' || Array.isArray(parsed.imports)) {
512
+ return null;
513
+ }
514
+ // Validate every imports entry. Drop:
515
+ // - non-string keys/values (numbers, nulls, objects from malformed
516
+ // hand-edits would otherwise land structurally-invalid entries in
517
+ // the served importmap and break the browser parser);
518
+ // - keys containing newlines or other control chars (they would
519
+ // serialize to escape sequences in JSON and confuse downstream
520
+ // diffing logic);
521
+ // - values whose URL scheme isn't `http(s)://` or a path starting
522
+ // with `/` (relative to the app's origin). `javascript:` and
523
+ // `data:` URLs in a malicious pin file would otherwise be
524
+ // accepted by the browser's importmap parser and let an attacker
525
+ // ship code via a single-line pin diff. Tightest acceptable
526
+ // set: matches what `webjs vendor pin` itself produces
527
+ // (`https://ga.jspm.io/...` or `/__webjs/vendor/...`).
528
+ /** @type {Record<string, string>} */
529
+ const cleanImports = {};
530
+ for (const [k, v] of Object.entries(parsed.imports)) {
531
+ if (typeof k !== 'string' || typeof v !== 'string') continue;
532
+ if (/[\x00-\x1f\x7f]/.test(k)) continue;
533
+ // Require a non-slash byte after the scheme prefix so a
534
+ // hand-edited or tampered pin file cannot smuggle a
535
+ // protocol-relative URL like `//attacker.tld/x.js` past the
536
+ // filter. Browsers resolve `//host/path` against the document
537
+ // origin and would happily fetch attacker-controlled code if
538
+ // the importmap accepted it. The framework itself only writes
539
+ // `https://ga.jspm.io/...` or `/__webjs/vendor/...`, which both
540
+ // satisfy the tighter form.
541
+ if (!/^(?:https?:\/\/[^/]|\/[^/])/.test(v)) continue;
542
+ cleanImports[k] = v;
543
+ }
544
+ if (Object.keys(cleanImports).length === 0) return null;
545
+
546
+ /** @type {Record<string, string>} */
547
+ const cleanIntegrity = {};
548
+ if (parsed.integrity && typeof parsed.integrity === 'object' && !Array.isArray(parsed.integrity)) {
549
+ for (const [k, v] of Object.entries(parsed.integrity)) {
550
+ // Integrity values must look like SRI hashes end-to-end
551
+ // (`sha(256|384|512)-<base64>`). Anchor the regex on both
552
+ // ends and constrain the body to the base64 alphabet so a
553
+ // hand-edited or tampered pin file can't slip an attribute
554
+ // injection (e.g. `sha384-x"><script>`) past the prefix
555
+ // check and through to `integrity="..."` emission in ssr.js
556
+ // unescaped.
557
+ if (typeof k === 'string' && typeof v === 'string' && /^sha(256|384|512)-[A-Za-z0-9+/=]+$/.test(v)) {
558
+ cleanIntegrity[k] = v;
559
+ }
560
+ }
561
+ }
562
+ /** @type {{ imports: Record<string,string>, integrity?: Record<string,string>, provider?: string }} */
563
+ const out = { imports: cleanImports };
564
+ if (Object.keys(cleanIntegrity).length) out.integrity = cleanIntegrity;
565
+ // Provider is optional in the pin file. Validate against the
566
+ // supported set so a tampered file can't smuggle an arbitrary
567
+ // string into downstream code paths.
568
+ if (typeof parsed.provider === 'string' && SUPPORTED_PROVIDERS.has(parsed.provider)) {
569
+ out.provider = parsed.provider;
570
+ }
571
+ return out;
136
572
  } catch {
137
573
  return null;
138
574
  }
575
+ }
576
+
577
+ /**
578
+ * Write the pin importmap to `.webjs/vendor/importmap.json`. Ensures
579
+ * the directory exists. Pretty-printed for human-reviewable diffs.
580
+ *
581
+ * When `integrity` is provided and non-empty, it's included alongside
582
+ * `imports` as a sibling key (matching the browser importmap-integrity
583
+ * spec: a flat `{url: 'sha384-...'}` map). Omitted entirely when empty
584
+ * so older webjs versions read the file as before.
585
+ *
586
+ * `provider` is persisted alongside imports when non-default. It lets
587
+ * `webjs vendor update` know which CDN to re-resolve against, and
588
+ * makes the pin file self-describing for incident response: if jspm.io
589
+ * has an outage you can read the file and know which alternate CDN
590
+ * the deploy targets. Omitted for the default jspm provider so the
591
+ * pin file shape stays stable for the 99% case.
592
+ *
593
+ * @param {string} appDir
594
+ * @param {Record<string, string>} imports
595
+ * @param {Record<string, string>} [integrity]
596
+ * @param {string} [provider]
597
+ */
598
+ async function writePinFile(appDir, imports, integrity, provider) {
599
+ await mkdir(pinDir(appDir), { recursive: true });
600
+ /** @type {Record<string, any>} */
601
+ const payload = { imports };
602
+ if (integrity && Object.keys(integrity).length) payload.integrity = integrity;
603
+ if (provider && provider !== 'jspm') payload.provider = provider;
604
+ const body = JSON.stringify(payload, null, 2) + '\n';
605
+ // Atomic write: stage into a sibling tmp file, then rename onto the
606
+ // final path. Rename within the same directory is atomic on POSIX
607
+ // and on Windows since Node 14+, so a crash mid-write can leave the
608
+ // tmp file as garbage but cannot corrupt the live pin file. Without
609
+ // this, a partially-written importmap.json round-trips through
610
+ // readPinFile as null (fail-closed) but still requires the user to
611
+ // notice and rerun pin; the rename keeps the live file intact across
612
+ // every failure mode.
613
+ const finalPath = pinFilePath(appDir);
614
+ const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
615
+ await writeFile(tmpPath, body, 'utf8');
616
+ await rename(tmpPath, finalPath);
617
+ }
618
+
619
+ /**
620
+ * Download a single jspm.io URL and write the body to
621
+ * `.webjs/vendor/<filename>`. Returns `{ bytes, integrity }` on
622
+ * success or null on failure. The integrity hash is computed from the
623
+ * downloaded bytes so it's always consistent with what's on disk.
624
+ *
625
+ * @param {string} url
626
+ * @param {string} appDir
627
+ * @param {string} filename
628
+ * @returns {Promise<{ bytes: number, integrity: string } | null>}
629
+ */
630
+ async function downloadBundle(url, appDir, filename) {
631
+ try {
632
+ const response = await fetch(url);
633
+ if (!response.ok) {
634
+ console.error(`[webjs] download ${url} returned ${response.status}`);
635
+ return null;
636
+ }
637
+ // Hash raw response bytes, not the UTF-8 decoded string. The
638
+ // browser's SRI implementation hashes the raw body bytes; if we
639
+ // hashed `.text()` here we'd risk encoding round-trip drift on
640
+ // any byte sequence the decode-then-re-encode pipeline doesn't
641
+ // round-trip exactly. arrayBuffer + Uint8Array gives us the
642
+ // same primitive the browser uses.
643
+ const buf = new Uint8Array(await response.arrayBuffer());
644
+ await mkdir(pinDir(appDir), { recursive: true });
645
+ await writeFile(join(pinDir(appDir), filename), buf);
646
+ return { bytes: buf.byteLength, integrity: await sha384Integrity(buf) };
647
+ } catch (e) {
648
+ console.error(`[webjs] download ${url} failed: ${e && e.message}`);
649
+ return null;
650
+ }
651
+ }
139
652
 
653
+ /**
654
+ * Fetch a jspm.io URL just to compute its SHA-384 hash, without
655
+ * writing anything to disk. Used by `webjs vendor pin` (default mode)
656
+ * so the importmap can carry SRI hashes even when bundles aren't
657
+ * locally vendored.
658
+ *
659
+ * @param {string} url
660
+ * @returns {Promise<string | null>} the integrity string, or null on failure
661
+ */
662
+ async function fetchIntegrity(url) {
140
663
  try {
141
- const result = await build({
142
- entryPoints: [entryPoint],
143
- bundle: true,
144
- format: 'esm',
145
- target: 'es2022',
146
- platform: 'browser',
147
- write: false,
148
- minify: !dev,
149
- // External: don't bundle packages already in the import map
150
- external: [...BUILTIN],
151
- });
152
- const code = result.outputFiles[0].text;
153
- if (vendorCache.size >= VENDOR_CACHE_MAX) {
154
- const oldest = vendorCache.keys().next().value;
155
- vendorCache.delete(oldest);
664
+ const response = await fetch(url);
665
+ if (!response.ok) {
666
+ console.error(`[webjs] hash ${url} returned ${response.status}`);
667
+ return null;
156
668
  }
157
- vendorCache.set(pkgName, code);
158
- return code;
669
+ // Hash raw response bytes so the integrity matches what the
670
+ // browser computes when fetching the same URL. See the
671
+ // matching comment in downloadBundle.
672
+ const buf = new Uint8Array(await response.arrayBuffer());
673
+ return await sha384Integrity(buf);
159
674
  } catch (e) {
160
- // Build failed (native module, server-only dep, etc.): skip silently
675
+ console.error(`[webjs] hash ${url} failed: ${e && e.message}`);
161
676
  return null;
162
677
  }
163
678
  }
164
679
 
165
680
  /**
166
- * Build extra import map entries for discovered bare imports.
681
+ * After writing the new pin output, delete any file in the pin
682
+ * directory that doesn't belong. Handles three orphan scenarios
683
+ * uniformly: version-bump leftovers, removed packages, and mode
684
+ * switches (default <-> download).
167
685
  *
168
- * @param {Set<string>} bareImports from scanBareImports()
169
- * @returns {Record<string, string>}
686
+ * @param {string} appDir
687
+ * @param {Set<string>} expected filenames that should remain
688
+ * @returns {Promise<string[]>} list of pruned filenames
170
689
  */
171
- export function vendorImportMapEntries(bareImports) {
690
+ async function pruneOrphans(appDir, expected) {
691
+ const dir = pinDir(appDir);
692
+ let files;
693
+ try { files = await readdir(dir); } catch { return []; }
694
+ const pruned = [];
695
+ for (const f of files) {
696
+ if (expected.has(f)) continue;
697
+ try {
698
+ await unlink(join(dir, f));
699
+ pruned.push(f);
700
+ } catch { /* race or permission; ignore */ }
701
+ }
702
+ return pruned;
703
+ }
704
+
705
+ /**
706
+ * Pin all currently-imported npm packages to `.webjs/vendor/
707
+ * importmap.json`. Two modes:
708
+ *
709
+ * - Default: importmap URLs point at jspm.io (browser fetches from
710
+ * CDN directly at runtime). Only `importmap.json` is committed.
711
+ * - `download: true`: also fetches each bundle from jspm.io and
712
+ * writes it to `.webjs/vendor/<pkg>@<version>.js`. importmap URLs
713
+ * become local paths (`/__webjs/vendor/<filename>`), and the
714
+ * server handler serves them from disk. Both `importmap.json` and
715
+ * the bundle files are committed to source control.
716
+ *
717
+ * After pinning, prunes any orphan file in `.webjs/vendor/` not
718
+ * produced by the current run. Pin is idempotent with respect to the
719
+ * current source + node_modules: removed packages, bumped versions,
720
+ * and mode switches all leave a clean directory.
721
+ *
722
+ * On success (at least one install resolved), returns
723
+ * `{ pins, pruned, downloaded, provider }`. On total failure (one or
724
+ * more installs were attempted but every jspm.io resolution failed),
725
+ * the pin file is NOT written and the function returns
726
+ * `{ pins: [], pruned: [], downloaded: 0, failed: true, attemptedInstalls }`
727
+ * instead. When the app has zero bare-specifier imports at all
728
+ * (scanned source produced nothing), returns
729
+ * `{ pins: [], pruned: [], downloaded: 0, noBareImports: true }`
730
+ * WITHOUT writing the pin file. Callers that need to surface a
731
+ * non-zero exit code key off `failed` or `noBareImports`; both
732
+ * are absent on the success path.
733
+ *
734
+ * The `from` option mirrors importmap-rails's `bin/importmap pin foo
735
+ * --from jsdelivr`. Default `jspm` resolves to jspm.io; other values
736
+ * (jsdelivr, unpkg, skypack) are passed through to jspm.io's
737
+ * Generator API which returns URLs from the chosen CDN. The provider
738
+ * is persisted in the pin file so `vendor update` and incident
739
+ * response know which CDN to re-resolve against.
740
+ *
741
+ * @param {string} appDir
742
+ * @param {{ download?: boolean, from?: string }} [opts]
743
+ * @returns {Promise<{
744
+ * pins: Array<{ pkg: string, version: string, url: string, bytes?: number, integrity?: string }>,
745
+ * pruned: string[],
746
+ * downloaded: number,
747
+ * provider?: string,
748
+ * failed?: boolean,
749
+ * noBareImports?: boolean,
750
+ * attemptedInstalls?: string[],
751
+ * }>}
752
+ */
753
+ export async function pinAll(appDir, opts = {}) {
754
+ const download = !!opts.download;
755
+ // Provider precedence (same as updatePinned for consistency):
756
+ // 1. explicit opts.from (CLI --from flag wins)
757
+ // 2. existing pin file's persisted provider (stickiness: user
758
+ // who pinned via jsdelivr stays on jsdelivr until they
759
+ // explicitly switch back)
760
+ // 3. default 'jspm'
761
+ // Pre-read the file once to access its provider.
762
+ const existing = await readPinFile(appDir);
763
+ const from = opts.from || existing?.provider || 'jspm';
764
+ if (!SUPPORTED_PROVIDERS.has(from)) {
765
+ throw new Error(
766
+ `[webjs] unknown provider '${from}'. Supported: ${[...SUPPORTED_PROVIDERS].join(', ')}.`,
767
+ );
768
+ }
769
+ const bare = await scanBareImports(appDir);
770
+ const installs = [];
771
+ /**
772
+ * Map from install spec (`pkg@version<subpath>`) to its components,
773
+ * so we can recover the pkg + version + subpath when iterating jspm.io's
774
+ * resolved imports.
775
+ * @type {Map<string, { pkg: string, version: string, subpath: string }>}
776
+ */
777
+ const partsByInstall = new Map();
778
+ for (const spec of bare) {
779
+ if (BUILTIN.has(spec)) continue;
780
+ const pkg = extractPackageName(spec);
781
+ if (!pkg || BUILTIN.has(pkg)) continue;
782
+ const version = getPackageVersion(pkg, appDir);
783
+ if (!version) continue;
784
+ const subpath = spec.slice(pkg.length);
785
+ const install = `${pkg}@${version}${subpath}`;
786
+ installs.push(install);
787
+ partsByInstall.set(spec, { pkg, version, subpath });
788
+ }
789
+ const resolved = await jspmGenerate(installs, from);
790
+
172
791
  /** @type {Record<string, string>} */
173
- const entries = {};
174
- for (const pkg of bareImports) {
175
- if (BUILTIN.has(pkg)) continue;
176
- entries[pkg] = `/__webjs/vendor/${encodeURIComponent(pkg)}.js`;
792
+ const importmap = {};
793
+ /**
794
+ * SRI integrity by FINAL URL (post-rewrite). The browser's
795
+ * importmap-integrity spec keys on the URL that appears in the
796
+ * importmap, not the source jspm.io URL. For default mode the two
797
+ * are identical; for --download mode the URL is the local
798
+ * /__webjs/vendor/<filename> path.
799
+ * @type {Record<string, string>}
800
+ */
801
+ const integrity = {};
802
+ /** @type {Array<{ pkg: string, version: string, url: string, bytes?: number, integrity?: string }>} */
803
+ const pins = [];
804
+ const expected = new Set([PIN_FILE]);
805
+ let downloaded = 0;
806
+
807
+ for (const [spec, jspmUrl] of Object.entries(resolved)) {
808
+ const parts = partsByInstall.get(spec);
809
+ if (!parts) continue;
810
+ const { pkg, version, subpath } = parts;
811
+ if (download) {
812
+ const filename = bundleFilenameWithSubpath(pkg, version, subpath);
813
+ const result = await downloadBundle(jspmUrl, appDir, filename);
814
+ if (result == null) continue;
815
+ const localUrl = `/__webjs/vendor/${filename}`;
816
+ importmap[spec] = localUrl;
817
+ integrity[localUrl] = result.integrity;
818
+ expected.add(filename);
819
+ pins.push({ pkg: spec, version, url: localUrl, bytes: result.bytes, integrity: result.integrity });
820
+ downloaded++;
821
+ } else {
822
+ importmap[spec] = jspmUrl;
823
+ // Fetch the bundle just to hash it. Bytes aren't written to
824
+ // disk; only the SHA-384 reaches the pin file. CDN compromise
825
+ // defense for default mode: if jspm.io serves different bytes
826
+ // later, the browser refuses to execute (integrity mismatch).
827
+ const sri = await fetchIntegrity(jspmUrl);
828
+ if (sri) integrity[jspmUrl] = sri;
829
+ else console.warn(
830
+ `[webjs] could not compute SRI for ${jspmUrl}; pinning without ` +
831
+ `integrity (browser will accept any bytes from this URL on ` +
832
+ `next load). Rerun \`webjs vendor pin\` when jspm.io is healthy ` +
833
+ `to lock in the integrity hash.`,
834
+ );
835
+ pins.push({ pkg: spec, version, url: jspmUrl, integrity: sri || undefined });
836
+ }
837
+ }
838
+
839
+ // If pin was attempted (installs non-empty) but resolved zero, do
840
+ // NOT write the pin file. Writing `{ imports: {} }` would shadow
841
+ // the live-API fallback (which reads when no pin file exists) and
842
+ // leave the browser with an empty importmap, silently breaking
843
+ // every bare-specifier import. Better: surface the failure so the
844
+ // user knows pin didn't take, and let the next boot fall back to
845
+ // live API resolution (which may have recovered by then).
846
+ if (installs.length > 0 && pins.length === 0) {
847
+ return { pins, pruned: [], downloaded, failed: true, attemptedInstalls: installs, provider: from };
848
+ }
849
+
850
+ // Partial-failure surface. Some installs were attempted but not
851
+ // every one made it into pins (jspm.io returned the package OK,
852
+ // but downloadBundle failed mid-stream in --download mode, or the
853
+ // resolver response was missing the package entirely). Write the
854
+ // pin file anyway so the working packages get committed, but warn
855
+ // so the user knows the next runtime fetch for the missing
856
+ // packages will fall through to a live jspm.io call (or 404 in
857
+ // --download mode).
858
+ //
859
+ // Derive the missing set from partsByInstall (the bare-spec keys)
860
+ // rather than from `installs` (the versioned strings). pins[].pkg
861
+ // is the bare spec, so a direct filter over `installs` wouldn't
862
+ // match anything.
863
+ if (installs.length > pins.length) {
864
+ const pinnedSpecs = new Set(pins.map(p => p.pkg));
865
+ /** @type {string[]} */
866
+ const missing = [];
867
+ for (const [spec, parts] of partsByInstall.entries()) {
868
+ if (!pinnedSpecs.has(spec)) {
869
+ missing.push(`${parts.pkg}@${parts.version}${parts.subpath}`);
870
+ }
871
+ }
872
+ console.warn(
873
+ `[webjs] pin: partial success. The following installs did NOT ` +
874
+ `make it into the pin file and will fall back to live ` +
875
+ `resolution on next boot:`,
876
+ );
877
+ for (const m of missing) console.warn(` ${m}`);
878
+ }
879
+
880
+ // The app legitimately has zero bare-specifier imports (or the
881
+ // scanner is running outside a webjs project). Don't create an
882
+ // empty `.webjs/vendor/importmap.json`. Without this guard the file
883
+ // gets written as `{ imports: {} }` in whatever cwd the CLI was
884
+ // invoked from, then immediately rejected by readPinFile's empty
885
+ // -imports filter, so the file exists but does nothing. The CLI
886
+ // surfaces this as a clearer "no bare imports found" message.
887
+ if (installs.length === 0) {
888
+ return { pins, pruned: [], downloaded, noBareImports: true, provider: from };
889
+ }
890
+
891
+ await writePinFile(appDir, importmap, integrity, from);
892
+ const pruned = await pruneOrphans(appDir, expected);
893
+ return { pins, pruned, downloaded, provider: from };
894
+ }
895
+
896
+ /**
897
+ * Remove a single package from the committed pin output. Deletes the
898
+ * package's entry from `importmap.json`, and (if a bundle file
899
+ * exists for it) deletes that file too.
900
+ *
901
+ * @param {string} appDir
902
+ * @param {string} pkg
903
+ * @returns {Promise<{ removed: boolean, deletedFile?: string }>}
904
+ */
905
+ export async function unpinPackage(appDir, pkg) {
906
+ const file = await readPinFile(appDir);
907
+ if (!file || !(pkg in file.imports)) return { removed: false };
908
+ const url = file.imports[pkg];
909
+ delete file.imports[pkg];
910
+ // Also strip the integrity entry for this URL, if present.
911
+ const newIntegrity = { ...(file.integrity || {}) };
912
+ delete newIntegrity[url];
913
+ if (Object.keys(file.imports).length === 0) {
914
+ // The pin file would now be empty. Delete it so the next boot
915
+ // falls back to live API resolution rather than seeing an empty
916
+ // importmap. Same reasoning as pinAll's "don't write empty pin"
917
+ // guard.
918
+ try { await unlink(pinFilePath(appDir)); } catch { /* race or never existed */ }
919
+ } else {
920
+ // Preserve the pin file's persisted provider (jsdelivr, unpkg,
921
+ // etc.). Without this, `webjs vendor unpin <pkg>` would silently
922
+ // revert the file to the default jspm provider, defeating
923
+ // pinAll's stickiness for the remaining packages.
924
+ await writePinFile(appDir, file.imports, newIntegrity, file.provider);
925
+ }
926
+
927
+ let deletedFile;
928
+ if (url.startsWith('/__webjs/vendor/')) {
929
+ const filename = url.slice('/__webjs/vendor/'.length);
930
+ try {
931
+ await unlink(join(pinDir(appDir), filename));
932
+ deletedFile = filename;
933
+ } catch { /* file already gone; ignore */ }
934
+ }
935
+ return { removed: true, deletedFile };
936
+ }
937
+
938
+ /**
939
+ * List entries from the committed pin file. Parses the package
940
+ * version from the URL (jspm.io URL or the local file's @version).
941
+ *
942
+ * @param {string} appDir
943
+ * @returns {Promise<Array<{ pkg: string, version: string, url: string, bytes?: number }>>}
944
+ */
945
+ export async function listPinned(appDir) {
946
+ const file = await readPinFile(appDir);
947
+ if (!file) return [];
948
+ const entries = [];
949
+ for (const [pkg, url] of Object.entries(file.imports)) {
950
+ let version = '(unknown)';
951
+ let bytes;
952
+ // Order matters: try the local `/__webjs/vendor/` filename
953
+ // parser first, then the CDN bare-name search. The local
954
+ // filename embeds the subpath as `__plugin__utc.js`, which the
955
+ // bare-name regex would match as part of the version (greedy
956
+ // `[^/]+` swallows the encoded subpath). Handling the local
957
+ // case explicitly preserves the cleaner version output for
958
+ // `--download` mode pins.
959
+ if (url.startsWith('/__webjs/vendor/')) {
960
+ const filename = url.slice('/__webjs/vendor/'.length);
961
+ const atIdx = filename.lastIndexOf('@');
962
+ if (atIdx > 0) {
963
+ // Strip trailing `.js`, split off any `__subpath` segment, keep
964
+ // only the version. `dayjs@1.11.13__plugin__utc.js` parses as
965
+ // version `1.11.13` (not `1.11.13__plugin__utc`).
966
+ const afterAt = filename.slice(atIdx + 1, -3);
967
+ const subIdx = afterAt.indexOf('__');
968
+ version = subIdx < 0 ? afterAt : afterAt.slice(0, subIdx);
969
+ }
970
+ try {
971
+ const st = await stat(join(pinDir(appDir), filename));
972
+ bytes = st.size;
973
+ } catch { /* file missing; bytes stays undefined */ }
974
+ } else {
975
+ // Derive the version from the URL by searching for the spec's
976
+ // bare package name followed by `@<version>`. Works across
977
+ // every CDN we support (jspm.io's `npm:dayjs@1.11.13`,
978
+ // jsdelivr's `npm/dayjs@1.11.13`, unpkg's bare
979
+ // `dayjs@1.11.13/`, skypack's `dayjs@1.11.13`). The bare name
980
+ // lives in entries[].pkg (the import-map key), so we know it
981
+ // exactly and just need to find the `<bare>@<version>`
982
+ // substring. Stop at the first `/` after the version so we
983
+ // don't include the entry-point path.
984
+ //
985
+ // Anchor the match against a non-pkg-name char (or string
986
+ // start) so a short package name like `ms` doesn't false-
987
+ // match inside another package's URL like `npm/terms@1.0.0/`.
988
+ // npm package names use `[a-zA-Z0-9._-]` (plus `@` and `/`
989
+ // for scoped names), so anything else is a safe boundary.
990
+ const bare = extractPackageName(pkg) || pkg;
991
+ const escapedBare = bare.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
992
+ const bareMatch = new RegExp(`(?:^|[^a-zA-Z0-9_.-])${escapedBare}@([^/]+)`).exec(url);
993
+ if (bareMatch) version = bareMatch[1];
994
+ }
995
+ entries.push({ pkg, version, url, bytes });
177
996
  }
178
997
  return entries;
179
998
  }
180
999
 
1000
+ // ---------------------------------------------------------------------------
1001
+ // npm registry queries: audit + outdated + update
1002
+ // ---------------------------------------------------------------------------
1003
+
1004
+ const NPM_REGISTRY = 'https://registry.npmjs.org';
1005
+ const NPM_TIMEOUT_MS = 10_000;
1006
+
181
1007
  /**
182
- * Clear the vendor cache (called on file-watcher rebuild so newly added
183
- * deps are picked up on next request).
1008
+ * Fetch one URL from registry.npmjs.org with a small timeout. Returns
1009
+ * the parsed JSON body on 2xx, or null on any non-2xx / network /
1010
+ * timeout. Used by audit + outdated.
1011
+ *
1012
+ * @param {string} url
1013
+ * @param {RequestInit} [init]
1014
+ * @returns {Promise<any | null>}
184
1015
  */
185
- export function clearVendorCache() {
186
- vendorCache.clear();
1016
+ async function fetchNpmJson(url, init) {
1017
+ const controller = new AbortController();
1018
+ const timer = setTimeout(() => controller.abort(), NPM_TIMEOUT_MS);
1019
+ try {
1020
+ const resp = await fetch(url, { ...init, signal: controller.signal });
1021
+ if (!resp.ok) return null;
1022
+ return await resp.json();
1023
+ } catch { return null; }
1024
+ finally { clearTimeout(timer); }
187
1025
  }
188
1026
 
189
1027
  /**
190
- * Serve a vendor bundle for the given package name.
1028
+ * Group the pin file's entries by package name + the set of versions
1029
+ * actually pinned (a single package can be pinned at multiple versions
1030
+ * via subpath imports). Used by audit (npm advisories want
1031
+ * `{ pkgName: [versions] }`) and outdated (one query per package).
191
1032
  *
192
- * @param {string} pkgName
1033
+ * @param {Array<{ pkg: string, version: string }>} entries
1034
+ * @returns {Map<string, Set<string>>}
1035
+ */
1036
+ function groupPinnedByPackage(entries) {
1037
+ const out = new Map();
1038
+ for (const e of entries) {
1039
+ if (!e.version || e.version === '(unknown)') continue;
1040
+ // entries[].pkg can include a subpath (e.g. `dayjs/plugin/utc`).
1041
+ // Extract the bare package name (`dayjs` or `@scope/name`).
1042
+ const bare = extractPackageName(e.pkg) || e.pkg;
1043
+ if (!out.has(bare)) out.set(bare, new Set());
1044
+ out.get(bare).add(e.version);
1045
+ }
1046
+ return out;
1047
+ }
1048
+
1049
+ /**
1050
+ * Run a security audit against the pinned versions in the committed
1051
+ * pin file. POSTs to npm's bulk-advisory endpoint, the same one
1052
+ * `npm audit` uses internally.
1053
+ *
1054
+ * Returns `{ errored: true }` when the registry call failed (network
1055
+ * down, timeout, 5xx) so the CLI can surface the failure clearly
1056
+ * instead of misleading the user with "no vulnerabilities found".
1057
+ *
1058
+ * Mirrors importmap-rails's `bin/importmap audit`.
1059
+ *
1060
+ * @param {string} appDir
1061
+ * @returns {Promise<{
1062
+ * vulnerable: Array<{ name: string, severity: string, vulnerableVersions: string, title: string }>,
1063
+ * totalChecked: number,
1064
+ * errored?: boolean,
1065
+ * }>}
1066
+ */
1067
+ export async function auditPinned(appDir) {
1068
+ const entries = await listPinned(appDir);
1069
+ if (!entries.length) return { vulnerable: [], totalChecked: 0 };
1070
+ const grouped = groupPinnedByPackage(entries);
1071
+ const body = {};
1072
+ for (const [pkg, versions] of grouped) body[pkg] = [...versions];
1073
+ const result = await fetchNpmJson(`${NPM_REGISTRY}/-/npm/v1/security/advisories/bulk`, {
1074
+ method: 'POST',
1075
+ headers: { 'content-type': 'application/json' },
1076
+ body: JSON.stringify(body),
1077
+ });
1078
+ const totalChecked = grouped.size;
1079
+ if (result === null) {
1080
+ // Distinguish "registry returned no advisories" (success, empty
1081
+ // object) from "couldn't reach registry" (null). The latter is
1082
+ // user-visible because a silent "no vulnerabilities" on a failed
1083
+ // call would falsely reassure the user.
1084
+ return { vulnerable: [], totalChecked, errored: true };
1085
+ }
1086
+ if (typeof result !== 'object') return { vulnerable: [], totalChecked };
1087
+ /** @type {Array<{ name: string, severity: string, vulnerableVersions: string, title: string }>} */
1088
+ const vulnerable = [];
1089
+ for (const [name, advisories] of Object.entries(result)) {
1090
+ if (!Array.isArray(advisories)) continue;
1091
+ for (const a of advisories) {
1092
+ vulnerable.push({
1093
+ name,
1094
+ severity: String(a?.severity || 'unknown'),
1095
+ vulnerableVersions: String(a?.vulnerable_versions || a?.range || ''),
1096
+ title: String(a?.title || a?.overview || ''),
1097
+ });
1098
+ }
1099
+ }
1100
+ return { vulnerable, totalChecked };
1101
+ }
1102
+
1103
+ /**
1104
+ * Find pinned packages that have a newer version available on npm.
1105
+ * Queries `registry.npmjs.org/<pkg>` per pinned package, compares the
1106
+ * pinned version against `dist-tags.latest` with semver-shaped string
1107
+ * ordering (regex parse, then numeric compare per segment).
1108
+ *
1109
+ * Mirrors importmap-rails's `bin/importmap outdated`.
1110
+ *
1111
+ * @param {string} appDir
1112
+ * @returns {Promise<Array<{ pkg: string, current: string, latest: string }>>}
1113
+ */
1114
+ export async function findOutdated(appDir) {
1115
+ const entries = await listPinned(appDir);
1116
+ if (!entries.length) return [];
1117
+ const grouped = groupPinnedByPackage(entries);
1118
+ // Fetch in parallel. With sequential awaits a 50-package project
1119
+ // could take 50 × 10s = 500s in the worst case (one npm registry
1120
+ // timeout each). Parallel `Promise.all` collapses this to one
1121
+ // round-trip's wall-clock, while staying well below npm registry's
1122
+ // unauthenticated-client soft rate limit (registry-side concern,
1123
+ // not ours to throttle).
1124
+ //
1125
+ // Scoped packages: the `/` between `@scope` and `name` is part of
1126
+ // the URL path, NOT a path separator that should be encoded.
1127
+ // `encodeURIComponent` would emit `%2F`, which the npm registry
1128
+ // accepts but other npm-compatible registries (Verdaccio, JFrog,
1129
+ // GitHub Packages) sometimes reject. The npm-cli uses the literal
1130
+ // form. npm package-name rules disallow URL-unsafe chars so this
1131
+ // is safe.
1132
+ const queries = [...grouped].map(async ([pkg, versions]) => {
1133
+ const meta = await fetchNpmJson(`${NPM_REGISTRY}/${pkg}`);
1134
+ const latest = meta?.['dist-tags']?.latest;
1135
+ if (typeof latest !== 'string') return null;
1136
+ // A package can be pinned at multiple versions (subpath imports).
1137
+ // Take the max pinned version as the "current" for the comparison
1138
+ // so we only report it as outdated when EVERY pinned version
1139
+ // trails latest.
1140
+ const current = maxSemverVersion([...versions]);
1141
+ if (compareSemver(current, latest) >= 0) return null;
1142
+ return { pkg, current, latest };
1143
+ });
1144
+ const results = await Promise.all(queries);
1145
+ // `return` followed by a newline triggers ASI: `return; (expr);`
1146
+ // returns undefined and drops the value. Keep the filter on the
1147
+ // same line as `return` (or pull the result into a variable
1148
+ // first) to avoid the trap.
1149
+ /** @type {Array<{ pkg: string, current: string, latest: string }>} */
1150
+ const out = results.filter((x) => x !== null);
1151
+ return out;
1152
+ }
1153
+
1154
+ /**
1155
+ * Re-pin every package returned by findOutdated to its latest version.
1156
+ * Calls jspm.io's Generator API with `<pkg>@<latest>` for each
1157
+ * outdated entry, then writes the new pin file.
1158
+ *
1159
+ * Mirrors importmap-rails's `bin/importmap update`, with the same
1160
+ * caveat: this updates the pin file but does NOT update the user's
1161
+ * `package.json` / `node_modules`. The user should run `npm install
1162
+ * <pkg>@<latest>` afterward to keep package.json in sync.
1163
+ *
1164
+ * When `opts.from` is not passed, the existing pin file's `provider`
1165
+ * field is used (so a user who pinned `--from jsdelivr` originally
1166
+ * stays on jsdelivr after update). When the file has no provider
1167
+ * field, defaults to `jspm`.
1168
+ *
1169
+ * @param {string} appDir
1170
+ * @param {{ from?: string }} [opts]
1171
+ * @returns {Promise<{ updated: Array<{ pkg: string, from: string, to: string }>, noOutdated?: boolean, provider?: string }>}
1172
+ */
1173
+ export async function updatePinned(appDir, opts = {}) {
1174
+ const file = await readPinFile(appDir);
1175
+ // Provider precedence:
1176
+ // 1. explicit opts.from (CLI flag wins)
1177
+ // 2. pin file's persisted provider
1178
+ // 3. default 'jspm'
1179
+ // Validate AFTER resolving so a stale pin file with a previously-
1180
+ // valid-but-now-removed provider still errors clearly.
1181
+ const from = opts.from || file?.provider || 'jspm';
1182
+ if (!SUPPORTED_PROVIDERS.has(from)) {
1183
+ throw new Error(
1184
+ `[webjs] unknown provider '${from}'. Supported: ${[...SUPPORTED_PROVIDERS].join(', ')}.`,
1185
+ );
1186
+ }
1187
+ const outdated = await findOutdated(appDir);
1188
+ if (!outdated.length) return { updated: [], noOutdated: true, provider: from };
1189
+ if (!file) return { updated: [], provider: from };
1190
+ const newImports = { ...file.imports };
1191
+ const newIntegrity = { ...(file.integrity || {}) };
1192
+ /** @type {Array<{ pkg: string, from: string, to: string }>} */
1193
+ const updated = [];
1194
+ for (const { pkg, current, latest } of outdated) {
1195
+ // Resolve the new version via jspm.io. The Generator API
1196
+ // returns URLs for `<pkg>@<latest>` (and any subpath we ask
1197
+ // for, but for update we just refresh the bare root pin and
1198
+ // any subpaths that were already pinned).
1199
+ let anySpecUpdated = false;
1200
+ for (const [spec, oldUrl] of Object.entries(file.imports)) {
1201
+ const specPkg = extractPackageName(spec) || spec;
1202
+ if (specPkg !== pkg) continue;
1203
+ const subpath = spec.slice(specPkg.length);
1204
+ const install = `${pkg}@${latest}${subpath}`;
1205
+ const resolved = await jspmGenerate([install], from);
1206
+ const newUrl = resolved[spec];
1207
+ if (!newUrl) continue;
1208
+ newImports[spec] = newUrl;
1209
+ // Recompute integrity for the new URL. Drop the stale entry
1210
+ // even on fetch failure so the new pin doesn't carry the
1211
+ // wrong hash silently.
1212
+ delete newIntegrity[oldUrl];
1213
+ const sri = await fetchIntegrity(newUrl);
1214
+ if (sri) newIntegrity[newUrl] = sri;
1215
+ anySpecUpdated = true;
1216
+ }
1217
+ // Only report `pkg` as updated when at least one spec actually
1218
+ // got a new URL. If every subpath failed to resolve via
1219
+ // jspm.io (transient outage, the new version not yet indexed),
1220
+ // the CLI must not lie about having updated it.
1221
+ if (anySpecUpdated) updated.push({ pkg, from: current, to: latest });
1222
+ }
1223
+ await writePinFile(appDir, newImports, newIntegrity, from);
1224
+ return { updated, provider: from };
1225
+ }
1226
+
1227
+ /**
1228
+ * Lightweight semver-aware comparison (no prerelease tags). Returns
1229
+ * negative if a < b, zero if equal, positive if a > b. Used by
1230
+ * findOutdated to decide if `current` lags `latest`. Non-numeric
1231
+ * segments fall back to string compare so prerelease-ish strings
1232
+ * still sort somewhere.
1233
+ *
1234
+ * @param {string} a
1235
+ * @param {string} b
1236
+ * @returns {number}
1237
+ */
1238
+ function compareSemver(a, b) {
1239
+ const aParts = a.split(/[.+-]/).map((p) => /^\d+$/.test(p) ? Number(p) : p);
1240
+ const bParts = b.split(/[.+-]/).map((p) => /^\d+$/.test(p) ? Number(p) : p);
1241
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
1242
+ const ai = aParts[i] ?? 0;
1243
+ const bi = bParts[i] ?? 0;
1244
+ if (typeof ai === 'number' && typeof bi === 'number') {
1245
+ if (ai !== bi) return ai - bi;
1246
+ } else if (ai !== bi) {
1247
+ return String(ai) < String(bi) ? -1 : 1;
1248
+ }
1249
+ }
1250
+ return 0;
1251
+ }
1252
+
1253
+ /** @param {string[]} versions */
1254
+ function maxSemverVersion(versions) {
1255
+ return versions.reduce((max, v) => compareSemver(v, max) > 0 ? v : max, versions[0]);
1256
+ }
1257
+
1258
+ /**
1259
+ * Resolve the vendor importmap fragment for runtime use. Prefers the
1260
+ * committed pin file over a live api.jspm.io call. Called by dev.js
1261
+ * at server boot.
1262
+ *
1263
+ * Order of preference:
1264
+ * 1. `.webjs/vendor/importmap.json` (committed; no network needed)
1265
+ * 2. Live api.jspm.io/generate (fallback when no pin file exists)
1266
+ *
1267
+ * Returns both `imports` (the URL map) and `integrity` (SRI hashes
1268
+ * keyed by URL). Integrity is populated only from the pin file;
1269
+ * live-API mode skips it (would require per-package fetches just to
1270
+ * hash, defeating the live-mode speed advantage. Users who want SRI
1271
+ * run `webjs vendor pin`).
1272
+ *
1273
+ * @param {Set<string>} bareImports
1274
+ * @param {string} appDir
1275
+ * @returns {Promise<{ imports: Record<string, string>, integrity: Record<string, string> }>}
1276
+ */
1277
+ export async function resolveVendorImports(bareImports, appDir) {
1278
+ const file = await readPinFile(appDir);
1279
+ if (file) {
1280
+ return { imports: file.imports, integrity: file.integrity || {} };
1281
+ }
1282
+ const imports = await vendorImportMapEntries(bareImports, appDir);
1283
+ return { imports, integrity: {} };
1284
+ }
1285
+
1286
+ /**
1287
+ * Serve a downloaded vendor bundle from `.webjs/vendor/<filename>`.
1288
+ * Called by dev.js when the importmap contains `/__webjs/vendor/`
1289
+ * paths (i.e. user ran `webjs vendor pin --download`).
1290
+ *
1291
+ * @param {string} filename e.g. `'dayjs@1.11.13.js'`
193
1292
  * @param {string} appDir
194
1293
  * @param {boolean} dev
195
1294
  * @returns {Promise<Response>}
196
1295
  */
197
- export async function serveVendorBundle(pkgName, appDir, dev) {
198
- const code = await bundlePackage(pkgName, appDir, dev);
199
- if (code == null) {
200
- return new Response(`/* vendor bundle failed for ${pkgName} */`, {
1296
+ export async function serveDownloadedBundle(filename, appDir, dev) {
1297
+ // Strict allowlist. Vendor filenames are framework-generated:
1298
+ // `<pkg>@<version>.js` or `<pkg>@<version>__<subpath>.js` plus the
1299
+ // `@scope__name` form for scoped packages. The legal charset is
1300
+ // alphanumeric plus `@`, `.`, `_`, `-`, `+` (`+` covers semver
1301
+ // build metadata like `1.0.0+build.42`). Reject anything else
1302
+ // (slashes / backslashes / dots-dots / null bytes / Unicode
1303
+ // separators / glob chars) without echoing the input.
1304
+ if (!/^[A-Za-z0-9@._+-]+\.js$/.test(filename) || filename.includes('..')) {
1305
+ return new Response(`/* invalid vendor filename */`, {
1306
+ status: 400,
1307
+ headers: { 'content-type': 'application/javascript; charset=utf-8' },
1308
+ });
1309
+ }
1310
+ try {
1311
+ // Read as raw bytes (no encoding arg). downloadBundle writes the
1312
+ // file from the response arrayBuffer (the same primitive the
1313
+ // browser's SRI implementation hashes), so the bytes on disk are
1314
+ // byte-identical to what jspm.io originally served. Reading with
1315
+ // utf8 here would decode-then-re-encode and risk dropping the SRI
1316
+ // match if any byte didn't round-trip exactly (e.g. invalid
1317
+ // surrogate replacement). Keep the I/O binary end-to-end.
1318
+ const body = await readFile(join(pinDir(appDir), filename));
1319
+ // ETag for downstream caches that strip the `immutable` directive.
1320
+ // Bundle filenames already carry the version, so content + ETag
1321
+ // round-trip is deterministic per filename.
1322
+ const etag = `"${(await digestHex('SHA-1', body)).slice(0, 16)}"`;
1323
+ return new Response(body, {
1324
+ headers: {
1325
+ 'content-type': 'application/javascript; charset=utf-8',
1326
+ 'cache-control': dev ? 'no-cache' : 'public, max-age=31536000, immutable',
1327
+ 'etag': etag,
1328
+ },
1329
+ });
1330
+ } catch {
1331
+ // Don't echo `filename` (already validated by the regex above so
1332
+ // safe to echo, but keep the body fixed for grep-ability and to
1333
+ // discourage anyone copying this pattern with untrusted input).
1334
+ return new Response(`/* vendor bundle not found. Run webjs vendor pin --download to (re-)download. */`, {
201
1335
  status: 404,
202
1336
  headers: { 'content-type': 'application/javascript; charset=utf-8' },
203
1337
  });
204
1338
  }
205
- return new Response(code, {
206
- headers: {
207
- 'content-type': 'application/javascript; charset=utf-8',
208
- 'cache-control': dev ? 'no-cache' : 'public, max-age=31536000, immutable',
209
- },
210
- });
211
1339
  }