@webjsdev/server 0.7.3 → 0.8.1

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