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