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