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