@webjsdev/server 0.8.62 → 0.8.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/index.d.ts +32 -3
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +1 -1
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/rate-limit.js +50 -5
  30. package/src/router.js +11 -1
  31. package/src/ssr/client-router-flag.js +42 -0
  32. package/src/ssr/document.js +361 -0
  33. package/src/ssr/env-shim.js +51 -0
  34. package/src/ssr/escape.js +29 -0
  35. package/src/ssr/head.js +857 -0
  36. package/src/ssr/preloads.js +195 -0
  37. package/src/ssr/render.js +1481 -0
  38. package/src/ssr/responses.js +201 -0
  39. package/src/ssr.js +28 -2445
  40. package/src/vendor/audit.js +400 -0
  41. package/src/vendor/integrity.js +245 -0
  42. package/src/vendor/jspm.js +348 -0
  43. package/src/vendor/manifest.js +96 -0
  44. package/src/vendor/pins.js +892 -0
  45. package/src/vendor/providers.js +35 -0
  46. package/src/vendor/resolver.js +225 -0
  47. package/src/vendor/scanner.js +132 -0
  48. package/src/vendor.js +29 -2371
@@ -0,0 +1,892 @@
1
+ import { readFile, writeFile, mkdir, unlink, stat, rename, readdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { BUILTIN, FRAMEWORK_SERVER_ONLY, extractPackageName, scanBareImports } from './scanner.js';
6
+ import { getPackageVersion } from './manifest.js';
7
+ import { SUPPORTED_PROVIDERS } from './providers.js';
8
+ import { PIN_BUNDLE_TIMEOUT_MS, fetchIntegrity, sha384Integrity } from './integrity.js';
9
+ import { jspmGenerate } from './jspm.js';
10
+
11
+ const PIN_DIR_REL = ['.webjs', 'vendor'];
12
+ const PIN_FILE = 'importmap.json';
13
+
14
+ /**
15
+ * Compute the absolute path of the pin directory for an app.
16
+ *
17
+ * Exported because `vendor/resolver.js` READS the pinned bundles this module
18
+ * WRITES. A second copy of this path there would let a change to
19
+ * `PIN_DIR_REL` move the write without moving the read, and the resolver would
20
+ * then miss every pinned bundle and silently fall back to a live resolve. One
21
+ * owner, so the two sides cannot drift.
22
+ */
23
+ export function pinDir(appDir) {
24
+ return join(appDir, ...PIN_DIR_REL);
25
+ }
26
+
27
+ /** Compute the absolute path of the importmap config file for an app. */
28
+ function pinFilePath(appDir) {
29
+ return join(pinDir(appDir), PIN_FILE);
30
+ }
31
+
32
+ /**
33
+ * The three-line `.gitignore` pattern that ignores the transient
34
+ * `.webjs` caches at any depth while re-including the committed
35
+ * `.webjs/vendor/` pin output. This mirrors the scaffold template
36
+ * (`packages/cli/templates/gitignore`, shipped dotless so npm cannot
37
+ * strip it, renamed to `.gitignore` on copy) and the `vendor-gitignore`
38
+ * check in `doctor.js` verbatim, so a self-healed `.gitignore`
39
+ * ends up byte-identical to a freshly scaffolded one.
40
+ */
41
+ const VENDOR_GITIGNORE_LINES = [
42
+ '**/.webjs/*',
43
+ '!**/.webjs/vendor/',
44
+ '!**/.webjs/vendor/**',
45
+ ];
46
+
47
+ /**
48
+ * Probe whether `appDir`'s `.gitignore` would swallow the vendor pin
49
+ * output, via `git check-ignore`. Best-effort: returns false when the
50
+ * directory is not a git repo, git is absent, or the spawn fails.
51
+ *
52
+ * The inherited GIT_* env vars are stripped so `cwd` is the sole
53
+ * authority on which repo + `.gitignore` stack is consulted. Git
54
+ * exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE / GIT_PREFIX into
55
+ * hook processes (a pre-commit hook from a linked worktree exports
56
+ * GIT_WORK_TREE), and those OVERRIDE cwd-based discovery; without the
57
+ * strip the probe would consult the outer repo instead of `appDir`.
58
+ * Same reasoning as the `vendor-gitignore` doctor check.
59
+ *
60
+ * @param {string} appDir
61
+ * @returns {boolean} true when `.webjs/vendor/importmap.json` is ignored
62
+ */
63
+ function vendorPinIsIgnored(appDir) {
64
+ try {
65
+ const {
66
+ GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
67
+ ...gitEnv
68
+ } = process.env;
69
+ const probe = `.webjs/vendor/${PIN_FILE}`;
70
+ // `git check-ignore -q` exits 0 when ignored, 1 when not ignored,
71
+ // 128 on error (not a git repo, etc.). Treat anything but 0 as
72
+ // "not ignored" so a non-git project never gets its .gitignore
73
+ // touched.
74
+ const result = spawnSync('git', ['check-ignore', '-q', probe], {
75
+ cwd: appDir,
76
+ stdio: 'pipe',
77
+ env: gitEnv,
78
+ });
79
+ return result.status === 0;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Make the `webjs vendor pin` output committable, idempotently.
87
+ *
88
+ * Vendoring is an OPTIONAL opt-in: the no-build default resolves bare
89
+ * specifiers at runtime via jspm.io and needs nothing committed. But a
90
+ * user who runs `webjs vendor pin` deliberately creates pins they want
91
+ * in source control, and a `.gitignore` that excludes `.webjs/` (the
92
+ * older scaffold pattern, or one an editor/agent "simplified") silently
93
+ * swallows that output. Fresh scaffolds already carry the vendor
94
+ * exception (a glob exclusion plus a re-include negation), so for them
95
+ * this is a no-op.
96
+ *
97
+ * Behaviour (only ever called from the opt-in `vendor pin` path):
98
+ * - The pin output is NOT ignored (the common, already-correct case):
99
+ * return `{ ignored: false, patched: false }`. Nothing is written.
100
+ * - The pin output IS ignored AND a `.gitignore` exists: heal it (see
101
+ * below), then re-probe. If the pin is committable afterwards return
102
+ * `{ ignored: true, patched: true, gitignorePath }`; if a broader
103
+ * unrelated rule still swallows it (e.g. a root `*.json`), the heal
104
+ * cannot help, so revert the edit and return `patched: false` so the
105
+ * caller prints a notice rather than claiming a fix that did not take.
106
+ * - The pin output IS ignored but there is NO `.gitignore` to patch
107
+ * (e.g. the ignore comes from a parent repo's `.gitignore`, or from
108
+ * `.git/info/exclude`): leave the tree untouched and return
109
+ * `{ ignored: true, patched: false, gitignorePath: null }` so the
110
+ * caller can print a notice instead of writing a file the user did
111
+ * not create.
112
+ *
113
+ * Healing has two parts, because a plain append is NOT enough. A bare
114
+ * directory exclusion (`.webjs/`, `/.webjs/`, `.webjs`, with or without a
115
+ * leading glob) excludes the directory itself, and git CANNOT re-include
116
+ * a child of an excluded directory, so any later negation is silently
117
+ * dead. So: (1) rewrite each such line IN PLACE to the glob form
118
+ * (`**` + `/.webjs/*`), which ignores the directory's CONTENTS at any
119
+ * depth while leaving the directory re-includable; (2) append whichever
120
+ * of the three exception lines are still missing. The heal is idempotent:
121
+ * a re-run finds the pin already committable and short-circuits.
122
+ *
123
+ * @param {string} appDir
124
+ * @returns {Promise<{ ignored: boolean, patched: boolean, gitignorePath: string | null }>}
125
+ */
126
+ export async function ensureVendorCommittable(appDir) {
127
+ if (!vendorPinIsIgnored(appDir)) {
128
+ return { ignored: false, patched: false, gitignorePath: null };
129
+ }
130
+ const gitignorePath = join(appDir, '.gitignore');
131
+ let original;
132
+ try {
133
+ original = await readFile(gitignorePath, 'utf8');
134
+ } catch {
135
+ // No app-level .gitignore to patch. The ignore is coming from a
136
+ // parent repo or from .git/info/exclude; do not fabricate a
137
+ // .gitignore the user never had. Let the caller print a notice.
138
+ return { ignored: true, patched: false, gitignorePath: null };
139
+ }
140
+
141
+ // The exclusion glob, assembled so the literal `*` + `/` sequence never
142
+ // appears in this file's source comments above.
143
+ const exclude = VENDOR_GITIGNORE_LINES[0]; // **/.webjs/*
144
+
145
+ // Preserve the file's line ending so a CRLF .gitignore stays all-CRLF
146
+ // (and an LF one all-LF). Splitting on bare `\n` keeps each existing
147
+ // line's trailing `\r`; the lines we WRITE (the rewritten exclusion and
148
+ // the appended block) must use the same ending or the file goes mixed.
149
+ // A file with any CRLF is treated as CRLF; otherwise LF.
150
+ const eol = /\r\n/.test(original) ? '\r\n' : '\n';
151
+
152
+ // 1. Rewrite any bare `.webjs` DIRECTORY exclusion to the glob form. A
153
+ // directory exclusion blocks all child negations, so it must become
154
+ // `**/.webjs/*` (ignore contents, keep the dir re-includable).
155
+ const lines = original.split('\n');
156
+ let rewroteDir = false;
157
+ const rewritten = lines.map((line) => {
158
+ // Trim CR too, so a CRLF file's `.webjs/\r` still matches.
159
+ const t = line.replace(/\r$/, '').trim();
160
+ // Match the bare-directory shapes only (no `/*` suffix, not already a
161
+ // negation): `.webjs`, `.webjs/`, `/.webjs`, `/.webjs/`, `**/.webjs`,
162
+ // `**/.webjs/`. These all exclude the directory itself. Emit the
163
+ // replacement with the file's own ending if the original line carried
164
+ // one (every line but a no-trailing-newline last line does).
165
+ if (/^(\*\*\/|\/)?\.webjs\/?$/.test(t)) {
166
+ rewroteDir = true;
167
+ return line.endsWith('\r') ? exclude + '\r' : exclude;
168
+ }
169
+ return line;
170
+ });
171
+
172
+ // 2. Append whichever exception lines are still missing.
173
+ const present = new Set(rewritten.map((l) => l.replace(/\r$/, '').trim()));
174
+ const missing = VENDOR_GITIGNORE_LINES.filter((l) => !present.has(l));
175
+
176
+ let next = rewritten.join('\n');
177
+ if (missing.length > 0) {
178
+ const block =
179
+ [
180
+ '# webjs: keep the committed vendor pin (`webjs vendor pin`) out of',
181
+ '# the `.webjs` cache exclusion so the pinned importmap is committable.',
182
+ ...missing,
183
+ ].join(eol) + eol;
184
+ const sep = next.endsWith('\n') || next === '' ? '' : eol;
185
+ next = next + sep + block;
186
+ }
187
+
188
+ if (!rewroteDir && missing.length === 0) {
189
+ // Nothing to change, yet git still ignores the pin: a broader,
190
+ // unrelated rule is the cause and the vendor exception cannot fix it.
191
+ return { ignored: true, patched: false, gitignorePath };
192
+ }
193
+
194
+ await writeFile(gitignorePath, next);
195
+
196
+ // 3. Re-probe. If a broader unrelated rule still swallows the pin, the
197
+ // edit did not achieve the goal, so revert it and report not-patched
198
+ // so the caller prints a notice instead of an inaccurate success.
199
+ if (vendorPinIsIgnored(appDir)) {
200
+ await writeFile(gitignorePath, original);
201
+ return { ignored: true, patched: false, gitignorePath };
202
+ }
203
+ return { ignored: true, patched: true, gitignorePath };
204
+ }
205
+
206
+ /**
207
+ * True when the app commits a vendor pin file (`.webjs/vendor/importmap.json`).
208
+ * A pinned app's importmap is deterministic and cheap to read, so `dev.js`
209
+ * resolves it AT BOOT (no analysis, no network) and publishes the build id
210
+ * immediately, giving the recommended posture a stable id from the first
211
+ * response with zero warmup exposure. An unpinned app returns false and keeps
212
+ * its vendor resolution deferred to the first request.
213
+ *
214
+ * @param {string} appDir
215
+ * @returns {boolean}
216
+ */
217
+ export function hasVendorPin(appDir) {
218
+ return existsSync(pinFilePath(appDir));
219
+ }
220
+
221
+ /**
222
+ * Filesystem-safe filename for a downloaded bundle. Encodes the full
223
+ * specifier (which may include a subpath) into a flat filename:
224
+ *
225
+ * bundleFilenameWithSubpath('dayjs', '1.11.13', '') returns 'dayjs@1.11.13.js'
226
+ * bundleFilenameWithSubpath('dayjs', '1.11.13', '/plugin/utc') returns 'dayjs@1.11.13__plugin__utc.js'
227
+ * bundleFilenameWithSubpath('@hotwired/turbo', '8.0.0', '') returns '@hotwired--turbo@8.0.0.js'
228
+ *
229
+ * Scoped names use `--` to encode `/`; subpath separators use `__`.
230
+ * Both are reversible round-trip so unpin / list can parse the
231
+ * package + version + subpath back from the filename.
232
+ */
233
+ function bundleFilenameWithSubpath(pkgName, version, subpath) {
234
+ const safeName = pkgName.replace(/\//g, '--');
235
+ const safeSubpath = subpath.replace(/\//g, '__');
236
+ return `${safeName}@${version}${safeSubpath}.js`;
237
+ }
238
+
239
+ /**
240
+ * Read the committed pin importmap if one exists. Returns the parsed
241
+ * `{ imports, integrity?, provider? }` shape or null if no pin file.
242
+ * The `integrity` and `provider` fields are optional: pin files
243
+ * written before SRI / multi-CDN support lack them; pin files written
244
+ * by current `webjs vendor pin` include them (provider only when
245
+ * non-default).
246
+ *
247
+ * @param {string} appDir
248
+ * @returns {Promise<{ imports: Record<string, string>, integrity?: Record<string, string>, provider?: string } | null>}
249
+ */
250
+ export async function readPinFile(appDir) {
251
+ try {
252
+ const raw = await readFile(pinFilePath(appDir), 'utf8');
253
+ const parsed = JSON.parse(raw);
254
+ if (!parsed || typeof parsed.imports !== 'object' || Array.isArray(parsed.imports)) {
255
+ return null;
256
+ }
257
+ // Validate every imports entry. Drop:
258
+ // - non-string keys/values (numbers, nulls, objects from malformed
259
+ // hand-edits would otherwise land structurally-invalid entries in
260
+ // the served importmap and break the browser parser);
261
+ // - keys containing newlines or other control chars (they would
262
+ // serialize to escape sequences in JSON and confuse downstream
263
+ // diffing logic);
264
+ // - values whose URL scheme isn't `http(s)://` or a path starting
265
+ // with `/` (relative to the app's origin). `javascript:` and
266
+ // `data:` URLs in a malicious pin file would otherwise be
267
+ // accepted by the browser's importmap parser and let an attacker
268
+ // ship code via a single-line pin diff. Tightest acceptable
269
+ // set: matches what `webjs vendor pin` itself produces
270
+ // (`https://ga.jspm.io/...` or `/__webjs/vendor/...`).
271
+ /** @type {Record<string, string>} */
272
+ const cleanImports = {};
273
+ for (const [k, v] of Object.entries(parsed.imports)) {
274
+ if (typeof k !== 'string' || typeof v !== 'string') continue;
275
+ if (/[\x00-\x1f\x7f]/.test(k)) continue;
276
+ // Require a non-slash byte after the scheme prefix so a
277
+ // hand-edited or tampered pin file cannot smuggle a
278
+ // protocol-relative URL like `//attacker.tld/x.js` past the
279
+ // filter. Browsers resolve `//host/path` against the document
280
+ // origin and would happily fetch attacker-controlled code if
281
+ // the importmap accepted it. The framework itself only writes
282
+ // `https://ga.jspm.io/...` or `/__webjs/vendor/...`, which both
283
+ // satisfy the tighter form.
284
+ if (!/^(?:https?:\/\/[^/]|\/[^/])/.test(v)) continue;
285
+ cleanImports[k] = v;
286
+ }
287
+ if (Object.keys(cleanImports).length === 0) return null;
288
+
289
+ /** @type {Record<string, string>} */
290
+ const cleanIntegrity = {};
291
+ if (parsed.integrity && typeof parsed.integrity === 'object' && !Array.isArray(parsed.integrity)) {
292
+ for (const [k, v] of Object.entries(parsed.integrity)) {
293
+ // Integrity values must look like SRI hashes end-to-end
294
+ // (`sha(256|384|512)-<base64>`). Anchor the regex on both
295
+ // ends and constrain the body to the base64 alphabet so a
296
+ // hand-edited or tampered pin file can't slip an attribute
297
+ // injection (e.g. `sha384-x"><script>`) past the prefix
298
+ // check and through to `integrity="..."` emission in ssr.js
299
+ // unescaped.
300
+ if (typeof k === 'string' && typeof v === 'string' && /^sha(256|384|512)-[A-Za-z0-9+/=]+$/.test(v)) {
301
+ cleanIntegrity[k] = v;
302
+ }
303
+ }
304
+ }
305
+ /** @type {{ imports: Record<string,string>, integrity?: Record<string,string>, provider?: string }} */
306
+ const out = { imports: cleanImports };
307
+ if (Object.keys(cleanIntegrity).length) out.integrity = cleanIntegrity;
308
+ // Provider is optional in the pin file. Validate against the
309
+ // supported set so a tampered file can't smuggle an arbitrary
310
+ // string into downstream code paths.
311
+ if (typeof parsed.provider === 'string' && SUPPORTED_PROVIDERS.has(parsed.provider)) {
312
+ out.provider = parsed.provider;
313
+ }
314
+ return out;
315
+ } catch {
316
+ return null;
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Write the pin importmap to `.webjs/vendor/importmap.json`. Ensures
322
+ * the directory exists. Pretty-printed for human-reviewable diffs.
323
+ *
324
+ * When `integrity` is provided and non-empty, it's included alongside
325
+ * `imports` as a sibling key (matching the browser importmap-integrity
326
+ * spec: a flat `{url: 'sha384-...'}` map). Omitted entirely when empty
327
+ * so older WebJs versions read the file as before.
328
+ *
329
+ * `provider` is persisted alongside imports when non-default. It lets
330
+ * `webjs vendor update` know which CDN to re-resolve against, and
331
+ * makes the pin file self-describing for incident response: if jspm.io
332
+ * has an outage you can read the file and know which alternate CDN
333
+ * the deploy targets. Omitted for the default jspm provider so the
334
+ * pin file shape stays stable for the 99% case.
335
+ *
336
+ * @param {string} appDir
337
+ * @param {Record<string, string>} imports
338
+ * @param {Record<string, string>} [integrity]
339
+ * @param {string} [provider]
340
+ */
341
+ export async function writePinFile(appDir, imports, integrity, provider) {
342
+ await mkdir(pinDir(appDir), { recursive: true });
343
+ /** @type {Record<string, any>} */
344
+ const payload = { imports };
345
+ if (integrity && Object.keys(integrity).length) payload.integrity = integrity;
346
+ if (provider && provider !== 'jspm') payload.provider = provider;
347
+ const body = JSON.stringify(payload, null, 2) + '\n';
348
+ // Atomic write: stage into a sibling tmp file, then rename onto the
349
+ // final path. Rename within the same directory is atomic on POSIX
350
+ // and on Windows since Node 14+, so a crash mid-write can leave the
351
+ // tmp file as garbage but cannot corrupt the live pin file. Without
352
+ // this, a partially-written importmap.json round-trips through
353
+ // readPinFile as null (fail-closed) but still requires the user to
354
+ // notice and rerun pin; the rename keeps the live file intact across
355
+ // every failure mode.
356
+ const finalPath = pinFilePath(appDir);
357
+ const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
358
+ await writeFile(tmpPath, body, 'utf8');
359
+ await rename(tmpPath, finalPath);
360
+ }
361
+
362
+ /**
363
+ * Download a single jspm.io URL and write the body to
364
+ * `.webjs/vendor/<filename>`. Returns `{ bytes, integrity }` on
365
+ * success or null on failure. The integrity hash is computed from the
366
+ * downloaded bytes so it's always consistent with what's on disk.
367
+ *
368
+ * Bounded by PIN_BUNDLE_TIMEOUT_MS. `pinAll(dir, { download: true })`
369
+ * runs this once per resolved URL on a CLI run with no ambient
370
+ * deadline, so a CDN that accepts the connection and then stalls would
371
+ * otherwise hang the pin with nothing to interrupt it (#1150).
372
+ *
373
+ * @param {string} url
374
+ * @param {string} appDir
375
+ * @param {string} filename
376
+ * @returns {Promise<{ bytes: number, integrity: string } | null>}
377
+ */
378
+ async function downloadBundle(url, appDir, filename) {
379
+ const controller = new AbortController();
380
+ const timer = setTimeout(() => controller.abort(), PIN_BUNDLE_TIMEOUT_MS);
381
+ try {
382
+ const response = await fetch(url, { signal: controller.signal });
383
+ if (!response.ok) {
384
+ console.error(`[webjs] download ${url} returned ${response.status}`);
385
+ return null;
386
+ }
387
+ // Hash raw response bytes, not the UTF-8 decoded string. The
388
+ // browser's SRI implementation hashes the raw body bytes; if we
389
+ // hashed `.text()` here we'd risk encoding round-trip drift on
390
+ // any byte sequence the decode-then-re-encode pipeline doesn't
391
+ // round-trip exactly. arrayBuffer + Uint8Array gives us the
392
+ // same primitive the browser uses.
393
+ const buf = new Uint8Array(await response.arrayBuffer());
394
+ await mkdir(pinDir(appDir), { recursive: true });
395
+ await writeFile(join(pinDir(appDir), filename), buf);
396
+ return { bytes: buf.byteLength, integrity: await sha384Integrity(buf) };
397
+ } catch (e) {
398
+ const why = e && e.name === 'AbortError'
399
+ ? `timed out after ${PIN_BUNDLE_TIMEOUT_MS}ms`
400
+ : e && e.message;
401
+ console.error(`[webjs] download ${url} failed: ${why}`);
402
+ return null;
403
+ } finally {
404
+ clearTimeout(timer);
405
+ }
406
+ }
407
+
408
+ /**
409
+ * After writing the new pin output, delete any file in the pin
410
+ * directory that doesn't belong. Handles three orphan scenarios
411
+ * uniformly: version-bump leftovers, removed packages, and mode
412
+ * switches (default <-> download).
413
+ *
414
+ * @param {string} appDir
415
+ * @param {Set<string>} expected filenames that should remain
416
+ * @returns {Promise<string[]>} list of pruned filenames
417
+ */
418
+ async function pruneOrphans(appDir, expected) {
419
+ const dir = pinDir(appDir);
420
+ let files;
421
+ try { files = await readdir(dir); } catch { return []; }
422
+ const pruned = [];
423
+ for (const f of files) {
424
+ if (expected.has(f)) continue;
425
+ try {
426
+ await unlink(join(dir, f));
427
+ pruned.push(f);
428
+ } catch { /* race or permission; ignore */ }
429
+ }
430
+ return pruned;
431
+ }
432
+
433
+ /**
434
+ * Recover `{ pkg, version, subpath }` for a resolved import spec that was
435
+ * NOT in the directly-scanned set, i.e. a flattened transitive the unified
436
+ * resolve added (issue #446). The bare package name and subpath come from
437
+ * the spec; the version is read out of the resolved CDN URL by locating
438
+ * `<bare>@<version>` in it (same logic `listPinned` uses, which handles
439
+ * every supported provider's URL shape). Returns null when the version
440
+ * can't be parsed, in which case the caller pins the entry by URL anyway
441
+ * but cannot derive a `--download` filename for it.
442
+ *
443
+ * @param {string} spec e.g. '@codemirror/state' or 'dayjs/plugin/utc'
444
+ * @param {string} url the resolved CDN URL for that spec
445
+ * @returns {{ pkg: string, version: string, subpath: string } | null}
446
+ */
447
+ export function derivePinParts(spec, url) {
448
+ const pkg = extractPackageName(spec);
449
+ if (!pkg) return null;
450
+ const subpath = spec.slice(pkg.length);
451
+ const escapedBare = pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
452
+ const match = new RegExp(`(?:^|[^a-zA-Z0-9_.-])${escapedBare}@([^/]+)`).exec(url);
453
+ if (!match) return null;
454
+ return { pkg, version: match[1], subpath };
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // File-based pin (.webjs/vendor/importmap.json, optional --download bundles)
459
+ // ---------------------------------------------------------------------------
460
+
461
+ /**
462
+ * Resolve the vendor importmap fragment for runtime use. Prefers the
463
+ * committed pin file over a live api.jspm.io call. Called from
464
+ * `ensureReady()` in dev.js on the first request, never at boot.
465
+ *
466
+ * Order of preference:
467
+ * 1. `.webjs/vendor/importmap.json` (committed; no network needed)
468
+ * 2. Live api.jspm.io/generate (fallback when no pin file exists)
469
+ *
470
+ * Returns both `imports` (the URL map) and `integrity` (SRI hashes
471
+ * keyed by the FINAL URL). Integrity is populated on BOTH paths (#235):
472
+ * the pin file supplies it directly, and the live-API path now hashes
473
+ * each cross-origin bundle after resolving (bounded + fail-open, see
474
+ * `computeLiveIntegrity`), so an unpinned app also serves SRI. A fetch
475
+ * failure for one URL degrades to a missing hash for that URL plus a
476
+ * one-time warning, never a broken resolve.
477
+ *
478
+ * @param {string} appDir
479
+ * @param {() => Promise<Set<string>>} getBareImports lazy scan, invoked ONLY
480
+ * on the unpinned path (so a pinned app never pays the whole-app walk).
481
+ * @returns {Promise<{ imports: Record<string, string>, integrity: Record<string, string> }>}
482
+ */
483
+ /**
484
+ * Base package of a bare specifier: `dayjs` -> `dayjs`,
485
+ * `dayjs/plugin/utc` -> `dayjs`, `@scope/pkg/sub` -> `@scope/pkg`.
486
+ *
487
+ * @param {string} spec
488
+ * @returns {string}
489
+ */
490
+ export function basePackage(spec) {
491
+ const parts = spec.split('/');
492
+ return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
493
+ }
494
+
495
+ /**
496
+ * Prune a pinned import map to the vendor specifiers still reachable from
497
+ * NON-elided modules. A committed pin is the whole map, but elision can make
498
+ * a pinned package unreachable (its only importer is a display-only component
499
+ * that ships no JS, e.g. dayjs via the blog's vendor-badge). The live-resolve
500
+ * path prunes such a package by excluding elided components from the bare-
501
+ * import scan; this brings the pinned path to the same result, so a pinned app
502
+ * and an unpinned app serve the same import map (issue #197).
503
+ *
504
+ * Keeps an entry when its specifier is reachable, OR when its base package is
505
+ * the base of any reachable specifier (so a pinned base entry `dayjs` survives
506
+ * when code imports `dayjs/plugin/utc`, and vice versa). Integrity hashes for
507
+ * dropped URLs are pruned too.
508
+ *
509
+ * @param {Record<string, string>} imports pin entries (specifier -> URL)
510
+ * @param {Record<string, string>} integrity SRI hashes keyed by URL
511
+ * @param {Set<string>} reachable bare specifiers used by non-elided modules
512
+ * @returns {{ imports: Record<string, string>, integrity: Record<string, string> }}
513
+ */
514
+ export function prunePinToReachable(imports, integrity, reachable) {
515
+ const reachableBases = new Set([...reachable].map(basePackage));
516
+ /** @type {Record<string, string>} */
517
+ const keptImports = {};
518
+ for (const [spec, url] of Object.entries(imports || {})) {
519
+ if (reachable.has(spec) || reachableBases.has(basePackage(spec))) {
520
+ keptImports[spec] = url;
521
+ }
522
+ }
523
+ const keptUrls = new Set(Object.values(keptImports));
524
+ /** @type {Record<string, string>} */
525
+ const keptIntegrity = {};
526
+ for (const [url, hash] of Object.entries(integrity || {})) {
527
+ if (keptUrls.has(url)) keptIntegrity[url] = hash;
528
+ }
529
+ return { imports: keptImports, integrity: keptIntegrity };
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // Importmap coherence check (issue #450)
534
+ // ---------------------------------------------------------------------------
535
+ //
536
+ // A produced importmap pins one URL per resolved package, each URL carrying an
537
+ // `@<version>`. That pinned graph is INCOHERENT when one resolved package
538
+ // declares a dependency or peer range on ANOTHER resolved package and the
539
+ // version actually pinned for that other package falls OUTSIDE the range. The
540
+ // motivating crash (#446): `@codemirror/view@6.39.16` pinned while
541
+ // `@codemirror/lint@6.9.6` (also pinned) needs `view@^6.42.0`, so a symbol
542
+ // `lint` expects is missing from the older `view` bundle at runtime.
543
+ //
544
+ // This is a VALIDATION over a produced importmap, NOT a re-resolution (that is
545
+ // #446's job) and NOT bundling. It emits warnings; it never mutates the map.
546
+ //
547
+ // PARITY: `checkImportmapCoherence` is a pure function of the EXTRACTED
548
+ // `{ package -> pinned version }` set plus the dependency metadata. It does not
549
+ // know or care whether the importmap came from a live jspm.io resolve or from a
550
+ // committed `.webjs/vendor/importmap.json`. Two importmaps that pin the same
551
+ // versions for the same packages therefore always produce the same verdict,
552
+ // which is exactly the runtime-vs-vendored parity the maintainer requires.
553
+
554
+ /**
555
+ * Pin all currently-imported npm packages to `.webjs/vendor/
556
+ * importmap.json`. Two modes:
557
+ *
558
+ * - Default: importmap URLs point at jspm.io (browser fetches from
559
+ * CDN directly at runtime). Only `importmap.json` is committed.
560
+ * - `download: true`: also fetches each bundle from jspm.io and
561
+ * writes it to `.webjs/vendor/<pkg>@<version>.js`. importmap URLs
562
+ * become local paths (`/__webjs/vendor/<filename>`), and the
563
+ * server handler serves them from disk. Both `importmap.json` and
564
+ * the bundle files are committed to source control.
565
+ *
566
+ * After pinning, prunes any orphan file in `.webjs/vendor/` not
567
+ * produced by the current run. Pin is idempotent with respect to the
568
+ * current source + node_modules: removed packages, bumped versions,
569
+ * and mode switches all leave a clean directory.
570
+ *
571
+ * On success (at least one install resolved), returns
572
+ * `{ pins, pruned, downloaded, provider }`. On total failure (one or
573
+ * more installs were attempted but every jspm.io resolution failed),
574
+ * the pin file is NOT written and the function returns
575
+ * `{ pins: [], pruned: [], downloaded: 0, failed: true, attemptedInstalls }`
576
+ * instead. When the app has zero bare-specifier imports at all
577
+ * (scanned source produced nothing), returns
578
+ * `{ pins: [], pruned: [], downloaded: 0, noBareImports: true }`
579
+ * WITHOUT writing the pin file. When the scan DID find specifiers but
580
+ * every one was dropped because no local version resolved (the package
581
+ * is not installed under `node_modules`), returns
582
+ * `{ pins: [], pruned: [], downloaded: 0, droppedUnresolvable: [...] }`
583
+ * (also WITHOUT a pin file), so the caller can name the found-but-
584
+ * uninstalled specifiers instead of claiming there were none. A
585
+ * partial pin (some resolved, some dropped) still writes the file and
586
+ * carries `droppedUnresolvable` alongside `pins`. Callers that need to
587
+ * surface a non-zero exit code key off `failed`, `noBareImports`, or an
588
+ * all-dropped `droppedUnresolvable`; all are absent on a clean success.
589
+ *
590
+ * The `from` option mirrors importmap-rails's `bin/importmap pin foo
591
+ * --from jsdelivr`. Default `jspm` resolves to jspm.io; other values
592
+ * (jsdelivr, unpkg, skypack) are passed through to jspm.io's
593
+ * Generator API which returns URLs from the chosen CDN. The provider
594
+ * is persisted in the pin file so `vendor update` and incident
595
+ * response know which CDN to re-resolve against.
596
+ *
597
+ * @param {string} appDir
598
+ * @param {{ download?: boolean, from?: string }} [opts]
599
+ * @returns {Promise<{
600
+ * pins: Array<{ pkg: string, version: string, url: string, bytes?: number, integrity?: string }>,
601
+ * pruned: string[],
602
+ * downloaded: number,
603
+ * provider?: string,
604
+ * failed?: boolean,
605
+ * noBareImports?: boolean,
606
+ * droppedUnresolvable?: string[],
607
+ * attemptedInstalls?: string[],
608
+ * }>}
609
+ */
610
+ export async function pinAll(appDir, opts = {}) {
611
+ const download = !!opts.download;
612
+ // Provider precedence (same as updatePinned for consistency):
613
+ // 1. explicit opts.from (CLI --from flag wins)
614
+ // 2. existing pin file's persisted provider (stickiness: user
615
+ // who pinned via jsdelivr stays on jsdelivr until they
616
+ // explicitly switch back)
617
+ // 3. default 'jspm'
618
+ // Pre-read the file once to access its provider.
619
+ const existing = await readPinFile(appDir);
620
+ const from = opts.from || existing?.provider || 'jspm';
621
+ if (!SUPPORTED_PROVIDERS.has(from)) {
622
+ throw new Error(
623
+ `[webjs] unknown provider '${from}'. Supported: ${[...SUPPORTED_PROVIDERS].join(', ')}.`,
624
+ );
625
+ }
626
+ const bare = await scanBareImports(appDir);
627
+ const installs = [];
628
+ /**
629
+ * Map from install spec (`pkg@version<subpath>`) to its components,
630
+ * so we can recover the pkg + version + subpath when iterating jspm.io's
631
+ * resolved imports.
632
+ * @type {Map<string, { pkg: string, version: string, subpath: string }>}
633
+ */
634
+ const partsByInstall = new Map();
635
+ /**
636
+ * Bare specifiers the scan FOUND but that were dropped because no local
637
+ * version could be resolved (the package is not installed under the app's
638
+ * `node_modules`). Kept distinct from "scan found nothing" so the CLI can
639
+ * name them and point at the remedy instead of claiming there were none.
640
+ * @type {string[]}
641
+ */
642
+ const droppedUnresolvable = [];
643
+ for (const spec of bare) {
644
+ if (BUILTIN.has(spec)) continue;
645
+ const pkg = extractPackageName(spec);
646
+ if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg)) continue;
647
+ const version = getPackageVersion(pkg, appDir);
648
+ if (!version) {
649
+ droppedUnresolvable.push(spec);
650
+ continue;
651
+ }
652
+ const subpath = spec.slice(pkg.length);
653
+ const install = `${pkg}@${version}${subpath}`;
654
+ installs.push(install);
655
+ partsByInstall.set(spec, { pkg, version, subpath });
656
+ }
657
+ const resolved = await jspmGenerate(installs, from, PIN_BUNDLE_TIMEOUT_MS);
658
+
659
+ /** @type {Record<string, string>} */
660
+ const importmap = {};
661
+ /**
662
+ * SRI integrity by FINAL URL (post-rewrite). The browser's
663
+ * importmap-integrity spec keys on the URL that appears in the
664
+ * importmap, not the source jspm.io URL. For default mode the two
665
+ * are identical; for --download mode the URL is the local
666
+ * /__webjs/vendor/<filename> path.
667
+ * @type {Record<string, string>}
668
+ */
669
+ const integrity = {};
670
+ /** @type {Array<{ pkg: string, version: string, url: string, bytes?: number, integrity?: string }>} */
671
+ const pins = [];
672
+ const expected = new Set([PIN_FILE]);
673
+ let downloaded = 0;
674
+
675
+ // Specs that were directly scanned (`partsByInstall`) AND the flattened
676
+ // transitive specs the unified resolve returns (issue #446) must BOTH be
677
+ // pinned, or a pinned app's importmap would be missing the transitive
678
+ // entries the runtime live-resolve serves (e.g. `@codemirror/state` pulled
679
+ // in by `@codemirror/lint`), breaking parity: the browser would hit an
680
+ // unresolved-bare-specifier error for the transitive. For a transitive we
681
+ // recover pkg + version + subpath by parsing the spec against the resolved
682
+ // jspm URL (`derivePinParts`), since it has no `partsByInstall` entry.
683
+ /** @type {Set<string>} */
684
+ const pinnedDirectSpecs = new Set();
685
+ for (const [spec, jspmUrl] of Object.entries(resolved)) {
686
+ const parts = partsByInstall.get(spec) || derivePinParts(spec, jspmUrl);
687
+ if (!parts) continue;
688
+ const direct = partsByInstall.has(spec);
689
+ const { pkg, version, subpath } = parts;
690
+ if (download) {
691
+ const filename = bundleFilenameWithSubpath(pkg, version, subpath);
692
+ const result = await downloadBundle(jspmUrl, appDir, filename);
693
+ if (result == null) continue;
694
+ const localUrl = `/__webjs/vendor/${filename}`;
695
+ importmap[spec] = localUrl;
696
+ integrity[localUrl] = result.integrity;
697
+ expected.add(filename);
698
+ pins.push({ pkg: spec, version, url: localUrl, bytes: result.bytes, integrity: result.integrity });
699
+ downloaded++;
700
+ } else {
701
+ importmap[spec] = jspmUrl;
702
+ // Fetch the bundle just to hash it. Bytes aren't written to
703
+ // disk; only the SHA-384 reaches the pin file. CDN compromise
704
+ // defense for default mode: if jspm.io serves different bytes
705
+ // later, the browser refuses to execute (integrity mismatch).
706
+ const sri = await fetchIntegrity(jspmUrl);
707
+ if (sri) integrity[jspmUrl] = sri;
708
+ else console.warn(
709
+ `[webjs] could not compute SRI for ${jspmUrl}; pinning without ` +
710
+ `integrity (browser will accept any bytes from this URL on ` +
711
+ `next load). Rerun \`webjs vendor pin\` when jspm.io is healthy ` +
712
+ `to lock in the integrity hash.`,
713
+ );
714
+ pins.push({ pkg: spec, version, url: jspmUrl, integrity: sri || undefined });
715
+ }
716
+ if (direct) pinnedDirectSpecs.add(spec);
717
+ }
718
+
719
+ // If pin was attempted (installs non-empty) but resolved zero, do
720
+ // NOT write the pin file. Writing `{ imports: {} }` would shadow
721
+ // the live-API fallback (which reads when no pin file exists) and
722
+ // leave the browser with an empty importmap, silently breaking
723
+ // every bare-specifier import. Better: surface the failure so the
724
+ // user knows pin didn't take, and let the next boot fall back to
725
+ // live API resolution (which may have recovered by then).
726
+ //
727
+ // Account on DIRECT specs only: pins also carries flattened transitive
728
+ // entries (#446), so `pins.length === 0` would no longer mean "every
729
+ // direct install failed". A resolve that returned only transitives but
730
+ // no direct spec is still a total failure for the user's deps.
731
+ if (installs.length > 0 && pinnedDirectSpecs.size === 0) {
732
+ return { pins, pruned: [], downloaded, failed: true, attemptedInstalls: installs, provider: from };
733
+ }
734
+
735
+ // Partial-failure surface. Some DIRECT installs were attempted but not
736
+ // every one made it into pins (jspm.io returned the package OK,
737
+ // but downloadBundle failed mid-stream in --download mode, or the
738
+ // resolver response was missing the package entirely). Write the
739
+ // pin file anyway so the working packages get committed, but warn
740
+ // so the user knows the next runtime fetch for the missing
741
+ // packages will fall through to a live jspm.io call (or 404 in
742
+ // --download mode).
743
+ //
744
+ // Derive the missing set from partsByInstall (the bare-spec keys)
745
+ // rather than from `installs` (the versioned strings). Compare against
746
+ // the DIRECT specs that pinned, NOT pins[].pkg (which now includes
747
+ // transitives), so a transitive can't mask a missing direct dep.
748
+ if (pinnedDirectSpecs.size < partsByInstall.size) {
749
+ /** @type {string[]} */
750
+ const missing = [];
751
+ for (const [spec, parts] of partsByInstall.entries()) {
752
+ if (!pinnedDirectSpecs.has(spec)) {
753
+ missing.push(`${parts.pkg}@${parts.version}${parts.subpath}`);
754
+ }
755
+ }
756
+ console.warn(
757
+ `[webjs] pin: partial success. The following installs did NOT ` +
758
+ `make it into the pin file and will fall back to live ` +
759
+ `resolution on next boot:`,
760
+ );
761
+ for (const m of missing) console.warn(` ${m}`);
762
+ }
763
+
764
+ // The app legitimately has zero bare-specifier imports (or the
765
+ // scanner is running outside a WebJs project). Don't create an
766
+ // empty `.webjs/vendor/importmap.json`. Without this guard the file
767
+ // gets written as `{ imports: {} }` in whatever cwd the CLI was
768
+ // invoked from, then immediately rejected by readPinFile's empty
769
+ // -imports filter, so the file exists but does nothing. The CLI
770
+ // surfaces this as a clearer "no bare imports found" message.
771
+ if (installs.length === 0) {
772
+ // Distinguish the two empty-set causes the CLI must report differently:
773
+ // the scan genuinely found nothing (noBareImports) vs it found specifiers
774
+ // that were all dropped for a missing local version (droppedUnresolvable).
775
+ if (droppedUnresolvable.length > 0) {
776
+ return { pins, pruned: [], downloaded, droppedUnresolvable, provider: from };
777
+ }
778
+ return { pins, pruned: [], downloaded, noBareImports: true, provider: from };
779
+ }
780
+
781
+ await writePinFile(appDir, importmap, integrity, from);
782
+ const pruned = await pruneOrphans(appDir, expected);
783
+ // Some specifiers may have pinned while others were dropped for a missing
784
+ // version; surface the dropped ones so a partial pin is not silent.
785
+ return droppedUnresolvable.length > 0
786
+ ? { pins, pruned, downloaded, provider: from, droppedUnresolvable }
787
+ : { pins, pruned, downloaded, provider: from };
788
+ }
789
+
790
+ /**
791
+ * Remove a single package from the committed pin output. Deletes the
792
+ * package's entry from `importmap.json`, and (if a bundle file
793
+ * exists for it) deletes that file too.
794
+ *
795
+ * @param {string} appDir
796
+ * @param {string} pkg
797
+ * @returns {Promise<{ removed: boolean, deletedFile?: string }>}
798
+ */
799
+ export async function unpinPackage(appDir, pkg) {
800
+ const file = await readPinFile(appDir);
801
+ if (!file || !(pkg in file.imports)) return { removed: false };
802
+ const url = file.imports[pkg];
803
+ delete file.imports[pkg];
804
+ // Also strip the integrity entry for this URL, if present.
805
+ const newIntegrity = { ...(file.integrity || {}) };
806
+ delete newIntegrity[url];
807
+ if (Object.keys(file.imports).length === 0) {
808
+ // The pin file would now be empty. Delete it so the next boot
809
+ // falls back to live API resolution rather than seeing an empty
810
+ // importmap. Same reasoning as pinAll's "don't write empty pin"
811
+ // guard.
812
+ try { await unlink(pinFilePath(appDir)); } catch { /* race or never existed */ }
813
+ } else {
814
+ // Preserve the pin file's persisted provider (jsdelivr, unpkg,
815
+ // etc.). Without this, `webjs vendor unpin <pkg>` would silently
816
+ // revert the file to the default jspm provider, defeating
817
+ // pinAll's stickiness for the remaining packages.
818
+ await writePinFile(appDir, file.imports, newIntegrity, file.provider);
819
+ }
820
+
821
+ let deletedFile;
822
+ if (url.startsWith('/__webjs/vendor/')) {
823
+ const filename = url.slice('/__webjs/vendor/'.length);
824
+ try {
825
+ await unlink(join(pinDir(appDir), filename));
826
+ deletedFile = filename;
827
+ } catch { /* file already gone; ignore */ }
828
+ }
829
+ return { removed: true, deletedFile };
830
+ }
831
+
832
+ /**
833
+ * List entries from the committed pin file. Parses the package
834
+ * version from the URL (jspm.io URL or the local file's @version).
835
+ *
836
+ * @param {string} appDir
837
+ * @returns {Promise<Array<{ pkg: string, version: string, url: string, bytes?: number }>>}
838
+ */
839
+ export async function listPinned(appDir) {
840
+ const file = await readPinFile(appDir);
841
+ if (!file) return [];
842
+ const entries = [];
843
+ for (const [pkg, url] of Object.entries(file.imports)) {
844
+ let version = '(unknown)';
845
+ let bytes;
846
+ // Order matters: try the local `/__webjs/vendor/` filename
847
+ // parser first, then the CDN bare-name search. The local
848
+ // filename embeds the subpath as `__plugin__utc.js`, which the
849
+ // bare-name regex would match as part of the version (greedy
850
+ // `[^/]+` swallows the encoded subpath). Handling the local
851
+ // case explicitly preserves the cleaner version output for
852
+ // `--download` mode pins.
853
+ if (url.startsWith('/__webjs/vendor/')) {
854
+ const filename = url.slice('/__webjs/vendor/'.length);
855
+ const atIdx = filename.lastIndexOf('@');
856
+ if (atIdx > 0) {
857
+ // Strip trailing `.js`, split off any `__subpath` segment, keep
858
+ // only the version. `dayjs@1.11.13__plugin__utc.js` parses as
859
+ // version `1.11.13` (not `1.11.13__plugin__utc`).
860
+ const afterAt = filename.slice(atIdx + 1, -3);
861
+ const subIdx = afterAt.indexOf('__');
862
+ version = subIdx < 0 ? afterAt : afterAt.slice(0, subIdx);
863
+ }
864
+ try {
865
+ const st = await stat(join(pinDir(appDir), filename));
866
+ bytes = st.size;
867
+ } catch { /* file missing; bytes stays undefined */ }
868
+ } else {
869
+ // Derive the version from the URL by searching for the spec's
870
+ // bare package name followed by `@<version>`. Works across
871
+ // every CDN we support (jspm.io's `npm:dayjs@1.11.13`,
872
+ // jsdelivr's `npm/dayjs@1.11.13`, unpkg's bare
873
+ // `dayjs@1.11.13/`, skypack's `dayjs@1.11.13`). The bare name
874
+ // lives in entries[].pkg (the import-map key), so we know it
875
+ // exactly and just need to find the `<bare>@<version>`
876
+ // substring. Stop at the first `/` after the version so we
877
+ // don't include the entry-point path.
878
+ //
879
+ // Anchor the match against a non-pkg-name char (or string
880
+ // start) so a short package name like `ms` doesn't false-
881
+ // match inside another package's URL like `npm/terms@1.0.0/`.
882
+ // npm package names use `[a-zA-Z0-9._-]` (plus `@` and `/`
883
+ // for scoped names), so anything else is a safe boundary.
884
+ const bare = extractPackageName(pkg) || pkg;
885
+ const escapedBare = bare.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
886
+ const bareMatch = new RegExp(`(?:^|[^a-zA-Z0-9_.-])${escapedBare}@([^/]+)`).exec(url);
887
+ if (bareMatch) version = bareMatch[1];
888
+ }
889
+ entries.push({ pkg, version, url, bytes });
890
+ }
891
+ return entries;
892
+ }