@webjsdev/server 0.8.62 → 0.8.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/index.d.ts +32 -3
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +1 -1
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/rate-limit.js +50 -5
  30. package/src/router.js +11 -1
  31. package/src/ssr/client-router-flag.js +42 -0
  32. package/src/ssr/document.js +361 -0
  33. package/src/ssr/env-shim.js +51 -0
  34. package/src/ssr/escape.js +29 -0
  35. package/src/ssr/head.js +857 -0
  36. package/src/ssr/preloads.js +195 -0
  37. package/src/ssr/render.js +1481 -0
  38. package/src/ssr/responses.js +201 -0
  39. package/src/ssr.js +28 -2445
  40. package/src/vendor/audit.js +400 -0
  41. package/src/vendor/integrity.js +245 -0
  42. package/src/vendor/jspm.js +348 -0
  43. package/src/vendor/manifest.js +96 -0
  44. package/src/vendor/pins.js +892 -0
  45. package/src/vendor/providers.js +35 -0
  46. package/src/vendor/resolver.js +225 -0
  47. package/src/vendor/scanner.js +132 -0
  48. package/src/vendor.js +29 -2371
@@ -0,0 +1,348 @@
1
+ import { BUILTIN, FRAMEWORK_SERVER_ONLY, extractPackageName } from './scanner.js';
2
+ import { getPackageVersion } from './manifest.js';
3
+ import { normalizeProvider } from './providers.js';
4
+
5
+ /**
6
+ * In-memory cache of resolved importmap fragments from api.jspm.io.
7
+ * Two kinds of key share this map:
8
+ * - The UNIFIED key (`<provider>::unified::<sorted installs joined>`)
9
+ * caches the whole-set resolve produced by one `generate` call, the
10
+ * default path (issue #446).
11
+ * - The PER-INSTALL key (`<provider>::<install>`) caches a single
12
+ * install's isolated resolve, used only on the fallback path when the
13
+ * unified call fails because some install is unresolvable.
14
+ * Per-process; cleared by `clearVendorCache` on file-watcher rebuild
15
+ * so new versions get re-resolved.
16
+ *
17
+ * @type {Map<string, Record<string, string>>}
18
+ */
19
+ const jspmCache = new Map();
20
+ // Set by jspmResolveOne whenever a LIVE resolution attempt fails (network
21
+ // error, timeout, or a non-ok jspm response). resolveVendorImports resets it
22
+ // before a scan and reads it after, so a caller can tell "resolved cleanly"
23
+ // from "served a partial map because the CDN was unreachable" and avoid
24
+ // memoizing the failure as done. Safe under the single-flighted ensureReady
25
+ // (one live resolve at a time); the vendor CLI does not run alongside a server.
26
+ let lastLiveResolveFailed = false;
27
+
28
+ export function isLastLiveResolveFailed() {
29
+ return lastLiveResolveFailed;
30
+ }
31
+
32
+ export function setLastLiveResolveFailed(val) {
33
+ lastLiveResolveFailed = val;
34
+ }
35
+
36
+ const JSPM_GENERATE_ENDPOINT = 'https://api.jspm.io/generate';
37
+ const JSPM_GENERATE_TIMEOUT_MS = 10_000;
38
+
39
+ /**
40
+ * Make ONE api.jspm.io/generate POST for a list of installs and return a
41
+ * structured result. The single point that talks to the network; both the
42
+ * unified path and the per-install fallback funnel through it.
43
+ *
44
+ * jspm fails the WHOLE batch (401) when ANY one install is unresolvable, so
45
+ * a multi-install POST is all-or-nothing: either the entire coherent graph
46
+ * comes back, or nothing does. `jspmGenerate` uses that property to decide
47
+ * when to fall back to per-install isolation.
48
+ *
49
+ * @param {Array<string>} installs e.g. ['dayjs@1.11.13', '@codemirror/lint@6.9.6']
50
+ * @param {string} provider one of SUPPORTED_PROVIDERS
51
+ * @param {number} [timeoutMs] defaults to the SERVER budget; a CLI caller
52
+ * passes the longer one, since it is a command someone is waiting on rather
53
+ * than a request being held open.
54
+ * @returns {Promise<JspmCallResult>}
55
+ */
56
+ async function jspmCall(installs, provider, timeoutMs = JSPM_GENERATE_TIMEOUT_MS) {
57
+ const label = installs.length === 1 ? `'${installs[0]}'` : `${installs.length} packages`;
58
+ const controller = new AbortController();
59
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
60
+ try {
61
+ const response = await fetch(JSPM_GENERATE_ENDPOINT, {
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/json' },
64
+ body: JSON.stringify({
65
+ install: installs,
66
+ // flattenScope:true merges transitive ESM deps into the flat
67
+ // `imports` map instead of a separate `scopes` field. Webjs only
68
+ // consumes `imports`, so without this any package with an
69
+ // unbundled ESM transitive (e.g. react-dom imports `scheduler`,
70
+ // @codemirror/lint imports @codemirror/state) would break in the
71
+ // browser with an unresolved-bare-specifier error. With the
72
+ // WHOLE-set call (issue #446) the flattened transitives are now
73
+ // ALSO mutually consistent: one `@codemirror/view` URL shared by
74
+ // the direct import and lint's transitive need, instead of two
75
+ // skewed versions from independent per-package calls.
76
+ flattenScope: true,
77
+ env: ['browser', 'production', 'module'],
78
+ provider: normalizeProvider(provider),
79
+ }),
80
+ signal: controller.signal,
81
+ });
82
+ if (!response.ok) {
83
+ // jspm.io returns the error reason in the body with a 401 (its
84
+ // quirk: 401 is what it sends for unresolvable installs, not auth
85
+ // failures). Surface it so the user sees WHAT failed and why.
86
+ let detail = '';
87
+ try {
88
+ const body = await response.json();
89
+ if (body && typeof body.error === 'string') detail = `: ${body.error}`;
90
+ } catch { /* non-JSON body */ }
91
+ console.error(
92
+ `[webjs] could not vendor ${label} via ${provider} (status ${response.status})${detail}`,
93
+ );
94
+ // A 5xx/429 is a transient jspm problem worth retrying. A 401/4xx
95
+ // means at least one install is genuinely unresolvable (jspm uses
96
+ // 401 for that): a private / workspace / server-only package (e.g.
97
+ // @webjsdev/server, pg) the browser never fetches
98
+ // anyway. Permanent failures must NOT block readiness.
99
+ const transient = response.status >= 500 || response.status === 429;
100
+ return { ok: false, imports: {}, transient };
101
+ }
102
+ const result = await response.json();
103
+ const imports = (result && result.map && result.map.imports) || {};
104
+ return { ok: true, imports, transient: false };
105
+ } catch (e) {
106
+ const msg = e && e.name === 'AbortError'
107
+ ? `timed out after ${timeoutMs}ms`
108
+ : `${e && e.message}`;
109
+ console.error(`[webjs] could not vendor ${label} via ${provider}: ${msg}`);
110
+ return { ok: false, imports: {}, transient: true };
111
+ } finally {
112
+ clearTimeout(timer);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Resolve a SINGLE install in isolation, cached per install + provider.
118
+ * This is the FALLBACK path: it only runs when the unified whole-set call
119
+ * fails because some install is unresolvable. Isolating each install means
120
+ * one bad dep (a 401) drops out on its own instead of collapsing the map
121
+ * for its legitimate neighbours. The cross-package coherence the unified
122
+ * call provides is lost for this degraded set, which is acceptable: it is
123
+ * exactly the pre-#446 behaviour, reached only when the app already has an
124
+ * unresolvable dep.
125
+ *
126
+ * Sets `lastLiveResolveFailed` on a TRANSIENT failure (so the caller
127
+ * retries), never on a permanent 401 (tolerated).
128
+ *
129
+ * @param {string} install e.g. 'dayjs@1.11.13' or 'dayjs@1.11.13/plugin/utc'
130
+ * @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
131
+ * @returns {Promise<Record<string, string>>}
132
+ */
133
+ async function jspmResolveOne(install, provider = 'jspm', timeoutMs) {
134
+ const { ok, imports, transient } = await jspmProbeOne(install, provider, timeoutMs);
135
+ // Preserve the public contract: an empty map on any failure, and the
136
+ // module-global retry flag set ONLY on a transient one (a permanent 401
137
+ // for an unresolvable private/server-only dep is tolerated).
138
+ if (!ok && transient) lastLiveResolveFailed = true;
139
+ return imports;
140
+ }
141
+
142
+ /**
143
+ * Probe a SINGLE install and return the FULL classification, not just the
144
+ * imports. Unlike `jspmResolveOne` this does NOT collapse a transient
145
+ * failure into the same empty map a permanent one yields, and does NOT
146
+ * touch `lastLiveResolveFailed`: the caller (the 401 fallback in
147
+ * `jspmGenerate`) needs to tell "genuinely unresolvable, safe to drop"
148
+ * (`ok:false, transient:false`) from "a network blip mid-probe, do NOT
149
+ * drop" (`ok:false, transient:true`), and owns the retry flag itself.
150
+ *
151
+ * Cached per install + provider; a successful probe's `{imports}` is the
152
+ * same value `jspmResolveOne` returns, so the two share the cache and the
153
+ * later unified re-run reuses it.
154
+ *
155
+ * @param {string} install
156
+ * @param {string} provider
157
+ * @returns {Promise<JspmCallResult>}
158
+ */
159
+ function jspmProbeOne(install, provider, timeoutMs) {
160
+ const cacheKey = `${provider}::probe::${install}`;
161
+ const existing = jspmCache.get(cacheKey);
162
+ if (existing) return existing;
163
+
164
+ const promise = (async () => {
165
+ const result = await jspmCall([install], provider, timeoutMs);
166
+ // Do not cache a failure: a transient one must be re-attempted on the
167
+ // next resolve, and a permanent one is cheap to re-confirm and must not
168
+ // pin a stale "unresolvable" verdict across a dependency change.
169
+ if (!result.ok) jspmCache.delete(cacheKey);
170
+ return result;
171
+ })();
172
+
173
+ jspmCache.set(cacheKey, promise);
174
+ return promise;
175
+ }
176
+
177
+ /**
178
+ * Last-write-wins merge of per-install import fragments. Subpath installs
179
+ * never collide (their keys include the subpath); a shared base package
180
+ * resolves to the same root URL across fragments, so the merge is stable.
181
+ *
182
+ * @param {Array<Record<string, string>>} fragments
183
+ * @returns {Record<string, string>}
184
+ */
185
+ function mergePerInstall(fragments) {
186
+ const merged = {};
187
+ for (const fragment of fragments) Object.assign(merged, fragment);
188
+ return merged;
189
+ }
190
+
191
+ /**
192
+ * Resolve a list of `pkg@version` installs to importmap entries.
193
+ *
194
+ * Issue #446: the WHOLE set is resolved in ONE api.jspm.io/generate call
195
+ * (a single `install[]` array) so jspm computes one mutually-consistent
196
+ * dependency graph. A directly-imported package and a transitive that
197
+ * needs a newer version of the same package now agree on one URL, instead
198
+ * of the old per-package-in-isolation merge that pinned the direct dep to
199
+ * its local version while the transitive floated to jspm-latest, producing
200
+ * a missing-export crash in the browser.
201
+ *
202
+ * The per-package-isolation property is PRESERVED as a fallback only: if
203
+ * the unified call fails because some install is unresolvable (a 401 for a
204
+ * private / server-only dep), one bad install must not collapse the map
205
+ * for the rest. So:
206
+ * 1. Try the unified call. On success, return its coherent graph.
207
+ * 2. On a PERMANENT failure (401/4xx), probe each install in isolation
208
+ * to learn which ones resolve, then RE-RUN the unified call over only
209
+ * the resolvable subset so the survivors stay mutually consistent.
210
+ * Only installs whose probe fails PERMANENTLY drop out (genuinely
211
+ * unresolvable, the browser never fetched them anyway); if any probe
212
+ * fails TRANSIENTLY, no one is dropped and the resolve is flagged for
213
+ * retry, so a network blip mid-probe cannot evict a good package. If
214
+ * the re-run itself fails, fall back to the merged per-install
215
+ * fragments so the app is no worse off than pre-#446.
216
+ * 3. On a TRANSIENT failure (network / timeout / 5xx / 429), set the
217
+ * retry flag and serve whatever the per-install probe produced.
218
+ *
219
+ * The unified result is cached per sorted-install-set + provider; the
220
+ * per-install fallback reuses the per-install cache entries.
221
+ *
222
+ * @param {Array<string>} installs e.g. ['dayjs@1.11.13', 'clsx@2.1.1']
223
+ * @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
224
+ * @param {number} [timeoutMs] per-call budget. Defaults to the SERVER one,
225
+ * because this runs on a cold first request as well as from the CLI; the two
226
+ * pin commands pass PIN_BUNDLE_TIMEOUT_MS instead. importmap-rails only ever
227
+ * resolves from the CLI, so its flat 60s has no request path to slow down.
228
+ * @returns {Promise<Record<string, string>>}
229
+ */
230
+ export async function jspmGenerate(installs, provider = 'jspm', timeoutMs) {
231
+ if (installs.length === 0) return {};
232
+
233
+ // A single install has no cross-package graph to reconcile, so the
234
+ // isolated path IS the coherent path; reuse the per-install cache.
235
+ if (installs.length === 1) return jspmResolveOne(installs[0], provider, timeoutMs);
236
+
237
+ // Stable key regardless of scan order so the same dep set hits cache.
238
+ const unifiedKey = `${provider}::unified::${[...installs].sort().join('\n')}`;
239
+ const cached = jspmCache.get(unifiedKey);
240
+ if (cached) return cached;
241
+
242
+ const promise = (async () => {
243
+ const unified = await jspmCall(installs, provider, timeoutMs);
244
+ if (unified.ok) return unified.imports;
245
+
246
+ // The unified call failed. Drop the cached failure so a later retry
247
+ // re-attempts; the per-install fallback owns the retry flag.
248
+ jspmCache.delete(unifiedKey);
249
+
250
+ if (unified.transient) {
251
+ // Network / 5xx: nothing resolved coherently. Fall back to merged
252
+ // per-install fragments (each may still be cached / reachable) so we
253
+ // serve whatever we can, and flag the transient failure for retry.
254
+ lastLiveResolveFailed = true;
255
+ return mergePerInstall(await Promise.all(installs.map(i => jspmResolveOne(i, provider, timeoutMs))));
256
+ }
257
+
258
+ // Permanent failure: at least one install is unresolvable. Probe each
259
+ // in isolation to learn which ones jspm can resolve, then re-run the
260
+ // unified call over only those so the survivors form one consistent
261
+ // graph (restores #446 coherence for the resolvable subset).
262
+ const probes = await Promise.all(installs.map(i => jspmProbeOne(i, provider, timeoutMs)));
263
+
264
+ // A GOOD package whose isolated probe failed TRANSIENTLY (a network blip
265
+ // mid-probe) must NOT be classified as unresolvable and dropped. Only a
266
+ // PERMANENT probe failure (401/404) means the install is genuinely
267
+ // unresolvable. If any probe failed transiently, we cannot safely decide
268
+ // the resolvable set this pass, so flag the whole resolve transient-
269
+ // failed and serve the merged fragments WITHOUT dropping anyone; the next
270
+ // ensureReady retry re-resolves once the blip clears. Conflating the two
271
+ // here is exactly the bug this guard prevents.
272
+ const transientProbe = probes.some(p => !p.ok && p.transient);
273
+ if (transientProbe) {
274
+ lastLiveResolveFailed = true;
275
+ return mergePerInstall(probes.map(p => p.imports));
276
+ }
277
+
278
+ // From here every failed probe is PERMANENT, so dropping it is safe.
279
+ const resolvable = installs.filter((_, idx) => probes[idx].ok);
280
+
281
+ if (resolvable.length === installs.length) {
282
+ // Every install resolved alone but the batch 401'd: a genuine
283
+ // cross-package CONFLICT jspm could not satisfy as one graph (rare).
284
+ // The coherent graph is unavailable, so serve the merged fragments
285
+ // (pre-#446 behaviour) rather than nothing. NOTE: this degraded path
286
+ // can REINTRODUCE the #446 skew, because last-write-wins on a shared
287
+ // transitive across independent fragments is exactly the merge the
288
+ // unified call exists to avoid. It is a deliberate degrade-not-crash
289
+ // fallback for an unsatisfiable graph: no coherent graph exists, so a
290
+ // possibly-skewed map beats no map. The common conflicting-deps case
291
+ // (one shared transitive needing a newer version, issue #446's repro)
292
+ // IS satisfiable and resolves coherently on the unified path above;
293
+ // only a genuinely unsatisfiable set reaches here.
294
+ return mergePerInstall(probes.map(p => p.imports));
295
+ }
296
+ if (resolvable.length === 0) return {};
297
+ if (resolvable.length === 1) return jspmResolveOne(resolvable[0], provider, timeoutMs);
298
+
299
+ // Re-run unified over the resolvable subset. If even that fails (a
300
+ // conflict among the survivors), fall back to their merged fragments.
301
+ const retry = await jspmCall(resolvable, provider, timeoutMs);
302
+ if (retry.ok) return retry.imports;
303
+ return mergePerInstall(resolvable.map(i => probes[installs.indexOf(i)].imports));
304
+ })();
305
+
306
+ jspmCache.set(unifiedKey, promise);
307
+ return promise;
308
+ }
309
+
310
+ /**
311
+ * Build importmap entries for discovered bare imports. For each scanned
312
+ * package, resolve its installed version from node_modules, then ask
313
+ * api.jspm.io/generate for the full importmap fragment.
314
+ *
315
+ * Async because the Generator API call is networked. Called from
316
+ * `resolveVendorImports` on the first request (and after a rebuild),
317
+ * inside `ensureReady`; never at boot, and not on every request.
318
+ *
319
+ * @param {Set<string>} bareImports from scanBareImports()
320
+ * @param {string} appDir
321
+ * @returns {Promise<Record<string, string>>}
322
+ */
323
+ export async function vendorImportMapEntries(bareImports, appDir) {
324
+ const installs = [];
325
+ for (const spec of bareImports) {
326
+ if (BUILTIN.has(spec)) continue;
327
+ const pkg = extractPackageName(spec);
328
+ if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg)) continue;
329
+ const version = getPackageVersion(pkg, appDir);
330
+ if (!version) continue;
331
+ // Splice the version into the specifier: 'dayjs/plugin/utc' with
332
+ // version 1.11.13 becomes 'dayjs@1.11.13/plugin/utc'. jspm.io's
333
+ // Generator API resolves subpaths individually via the package's
334
+ // `exports` field. Root imports stay as `<pkg>@<version>` with no
335
+ // trailing subpath.
336
+ const subpath = spec.slice(pkg.length);
337
+ installs.push(`${pkg}@${version}${subpath}`);
338
+ }
339
+ return jspmGenerate(installs);
340
+ }
341
+
342
+ /**
343
+ * Clear in-memory JSPM generator cache.
344
+ */
345
+ export function clearJspmCache() {
346
+ jspmCache.clear();
347
+ }
348
+
@@ -0,0 +1,96 @@
1
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
2
+ import { join, dirname, sep } from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+
5
+ /**
6
+ * Resolve a package's installed directory on disk, handling both direct
7
+ * installation and npm workspace hoisting.
8
+ *
9
+ * @param {string} pkgName
10
+ * @param {string} appDir
11
+ * @returns {string | null}
12
+ */
13
+ function resolvePackageDir(pkgName, appDir) {
14
+ let entry;
15
+ try {
16
+ const req = createRequire(join(appDir, 'package.json'));
17
+ entry = req.resolve(pkgName);
18
+ } catch {
19
+ // Resolve ONLY from the app. Falling back to the framework's own
20
+ // resolution would vendor a package the app never installed, which in a
21
+ // monorepo (where everything hoists to the root) is most of them.
22
+ return null;
23
+ }
24
+ try {
25
+ const parts = entry.split(sep);
26
+ const nmIdx = parts.lastIndexOf('node_modules');
27
+ if (nmIdx < 0) {
28
+ let dir = dirname(entry);
29
+ for (let i = 0; i < 8; i++) {
30
+ if (existsSync(join(dir, 'package.json'))) return realpathSync(dir);
31
+ const parent = dirname(dir);
32
+ if (parent === dir) break;
33
+ dir = parent;
34
+ }
35
+ return null;
36
+ }
37
+ const segmentsAfterNm = pkgName.startsWith('@') ? 2 : 1;
38
+ const root = parts.slice(0, nmIdx + 1 + segmentsAfterNm).join(sep);
39
+ return realpathSync(root);
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Read the installed version of a package from `node_modules/<pkg>/
47
+ * package.json`. Handles workspace hoisting and packages that lock
48
+ * down `./package.json` in their exports field.
49
+ *
50
+ * @param {string} pkgName
51
+ * @param {string} appDir
52
+ * @returns {string | null}
53
+ */
54
+ export function getPackageVersion(pkgName, appDir) {
55
+ const real = resolvePackageDir(pkgName, appDir);
56
+ if (!real) return null;
57
+ try {
58
+ const pkg = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8'));
59
+ return pkg.version || null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Read the installed package's declared `dependencies` + `peerDependencies`
67
+ * from its `package.json`, hoist-aware (same resolution as `getPackageVersion`,
68
+ * so a monorepo-hoisted dep resolves from the workspace root). Returns null
69
+ * when the package is not installed / unreadable, which the importmap-coherence
70
+ * check (#450) treats as "could not verify" rather than a conflict.
71
+ *
72
+ * This is the "already-resolved metadata, no network" source the coherence
73
+ * check prefers: the package is on disk because the importmap pinned it, so its
74
+ * manifest is a local read.
75
+ *
76
+ * @param {string} pkgName
77
+ * @param {string} appDir
78
+ * @returns {{ dependencies: Record<string,string>, peerDependencies: Record<string,string> } | null}
79
+ */
80
+ export function getPackageManifest(pkgName, appDir) {
81
+ const real = resolvePackageDir(pkgName, appDir);
82
+ if (!real) return null;
83
+ try {
84
+ const pkg = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8'));
85
+ return {
86
+ dependencies: pkg.dependencies || {},
87
+ peerDependencies: pkg.peerDependencies || {},
88
+ };
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // JSPM Generator API client
96
+ // ---------------------------------------------------------------------------