@webjsdev/server 0.8.63 → 0.8.65

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 (47) hide show
  1. package/index.d.ts +7 -2
  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 +173 -26
  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/router.js +11 -1
  30. package/src/ssr/client-router-flag.js +42 -0
  31. package/src/ssr/document.js +361 -0
  32. package/src/ssr/env-shim.js +51 -0
  33. package/src/ssr/escape.js +29 -0
  34. package/src/ssr/head.js +857 -0
  35. package/src/ssr/preloads.js +195 -0
  36. package/src/ssr/render.js +1481 -0
  37. package/src/ssr/responses.js +201 -0
  38. package/src/ssr.js +28 -2445
  39. package/src/vendor/audit.js +400 -0
  40. package/src/vendor/integrity.js +245 -0
  41. package/src/vendor/jspm.js +348 -0
  42. package/src/vendor/manifest.js +96 -0
  43. package/src/vendor/pins.js +892 -0
  44. package/src/vendor/providers.js +35 -0
  45. package/src/vendor/resolver.js +225 -0
  46. package/src/vendor/scanner.js +132 -0
  47. package/src/vendor.js +29 -2371
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Provider names accepted by `webjs vendor pin --from <provider>`.
3
+ * Default `jspm` resolves to jspm.io. Same set Rails's importmap-rails
4
+ * accepts (`packager.rb:normalize_provider`).
5
+ *
6
+ * jspm.io's Generator API itself supports multiple providers via the
7
+ * `provider` field in the request body. We surface the same choice as
8
+ * a CLI flag.
9
+ *
10
+ * @type {Set<string>}
11
+ */
12
+ export const SUPPORTED_PROVIDERS = new Set(['jspm', 'jsdelivr', 'unpkg', 'skypack']);
13
+
14
+ /**
15
+ * Normalize the user-facing provider name to what the jspm.io API
16
+ * expects in its `provider` field. Mirrors importmap-rails's
17
+ * `normalize_provider`: `jspm` is shorthand for `jspm.io`; the rest
18
+ * pass through verbatim.
19
+ *
20
+ * @param {string} name
21
+ * @returns {string}
22
+ */
23
+ export function normalizeProvider(name) {
24
+ return name === 'jspm' ? 'jspm.io' : name;
25
+ }
26
+
27
+ /**
28
+ * Outcome of one api.jspm.io/generate POST.
29
+ * @typedef {Object} JspmCallResult
30
+ * @property {boolean} ok true when jspm returned a 2xx with a usable map
31
+ * @property {Record<string, string>} imports the resolved imports (empty on failure)
32
+ * @property {boolean} transient true when the failure is worth retrying
33
+ * (network / timeout / 5xx / 429), false for a permanent 4xx
34
+ * (jspm uses 401 for "this install is unresolvable")
35
+ */
@@ -0,0 +1,225 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { sha384Integrity } from './integrity.js';
4
+ import { vendorImportMapEntries, isLastLiveResolveFailed, setLastLiveResolveFailed } from './jspm.js';
5
+ import { readPinFile, pinDir } from './pins.js';
6
+ import { BUFFERED_MARKER } from '../conditional-get.js';
7
+
8
+ /**
9
+ * Per-process cache of SHA-384 integrity hashes for live-resolved vendor
10
+ * URLs, keyed by the FINAL cross-origin URL. A vendor bundle at a given
11
+ * versioned URL is immutable, so once hashed it never needs re-fetching
12
+ * within the process: a re-resolve (e.g. after a file-watcher rebuild
13
+ * that did not change the dep) reuses the hash instead of re-downloading.
14
+ * Cleared by `clearVendorCache` alongside the jspm fragment cache so a
15
+ * version bump re-hashes. This is NOT a persistent cache (that is the pin
16
+ * file's job); it only avoids redundant fetches in one running process.
17
+ *
18
+ * @type {Map<string, string>}
19
+ */
20
+ const liveIntegrityCache = new Map();
21
+
22
+ export function clearLiveIntegrityCache() {
23
+ liveIntegrityCache.clear();
24
+ }
25
+
26
+ // Bounds a single bundle GET during the SERVER's warmup live-integrity pass
27
+ // (`fetchLiveIntegrity`). Short on purpose: that pass gates readiness, so a
28
+ // stalled CDN must not hold the first request, and it is additionally capped
29
+ // by INTEGRITY_TOTAL_BUDGET_MS across all URLs.
30
+ const INTEGRITY_FETCH_TIMEOUT_MS = 10_000;
31
+ // Cap concurrent bundle fetches so a large dep set does not open dozens of
32
+ // sockets at once during warmup. Matches the bounded posture of the rest of
33
+ // vendor.js (the jspm resolve is per-package but the network is the shared
34
+ // constraint).
35
+ const INTEGRITY_FETCH_CONCURRENCY = 6;
36
+ // Total wall-clock budget for the whole live-integrity hashing phase. It runs
37
+ // inside the readiness-gating warmup, so even a CDN that serves the importmap
38
+ // then hangs on every bundle GET must not stall the first request for the sum
39
+ // of per-fetch timeouts. Once the budget passes, the remaining URLs are left
40
+ // without integrity (the same fail-open fallback as a fetch failure) instead of
41
+ // waiting out a 10s timeout each. A healthy CDN finishes in well under this.
42
+ const INTEGRITY_TOTAL_BUDGET_MS = 15_000;
43
+
44
+ /**
45
+ * Fetch a single cross-origin URL with a bounded timeout and return its
46
+ * SHA-384 SRI hash, or null on any failure (network, timeout, non-ok).
47
+ * Fail-OPEN by design: a CDN hiccup must never break warmup, so a failure
48
+ * is a skipped hash (the URL serves without `integrity`, the pre-#235
49
+ * behavior for that one URL), not a thrown error.
50
+ *
51
+ * @param {string} url
52
+ * @returns {Promise<string | null>}
53
+ */
54
+ async function fetchLiveIntegrity(url) {
55
+ const cached = liveIntegrityCache.get(url);
56
+ if (cached) return cached;
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), INTEGRITY_FETCH_TIMEOUT_MS);
59
+ try {
60
+ const response = await fetch(url, { signal: controller.signal });
61
+ if (!response.ok) return null;
62
+ // Hash the raw response bytes (arrayBuffer -> Uint8Array), the same
63
+ // primitive the browser's SRI implementation hashes. Decoding to a
64
+ // string first would risk encoding round-trip drift. See the matching
65
+ // comment in downloadBundle / fetchIntegrity.
66
+ const buf = new Uint8Array(await response.arrayBuffer());
67
+ const sri = await sha384Integrity(buf);
68
+ liveIntegrityCache.set(url, sri);
69
+ return sri;
70
+ } catch {
71
+ return null;
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Compute SRI integrity for the CROSS-ORIGIN targets of a live-resolved
79
+ * import map. Same-origin targets (the `@webjsdev/core/*` runtime under
80
+ * `/__webjs/core/...` and any local `/__webjs/vendor/...` bundle) are
81
+ * skipped: they are served by the framework and already trusted, and SRI
82
+ * is a cross-origin defense.
83
+ *
84
+ * The returned map is keyed by the FINAL URL (the import-map target
85
+ * value), matching `vendorIntegrityFor(url)`'s lookup key so ssr.js emits
86
+ * the `integrity` sibling for free.
87
+ *
88
+ * Bounded and fail-open: cross-origin bundles are fetched in parallel with
89
+ * a small concurrency cap and a per-fetch timeout, and a failed fetch is
90
+ * skipped (no integrity for that one URL) rather than breaking the resolve.
91
+ * A single one-time `console.warn` reports the count of URLs that could not
92
+ * be hashed (no per-URL spam).
93
+ *
94
+ * @param {Record<string, string>} imports specifier -> final URL
95
+ * @returns {Promise<Record<string, string>>} integrity keyed by final URL
96
+ */
97
+ async function computeLiveIntegrity(imports) {
98
+ // De-duplicate by URL: two specifiers can resolve to the same bundle URL
99
+ // (a bare import and one of its subpaths), so hash each URL once.
100
+ const urls = [...new Set(Object.values(imports))].filter((u) => /^https:\/\//.test(u));
101
+ /** @type {Record<string, string>} */
102
+ const integrity = {};
103
+ if (urls.length === 0) return integrity;
104
+
105
+ const failed = [];
106
+ let next = 0;
107
+ const deadline = Date.now() + INTEGRITY_TOTAL_BUDGET_MS;
108
+ async function worker() {
109
+ // Stop claiming new URLs once the total budget passes; a URL already
110
+ // in flight still settles under its own per-fetch timeout.
111
+ while (next < urls.length && Date.now() < deadline) {
112
+ const url = urls[next++];
113
+ const sri = await fetchLiveIntegrity(url);
114
+ if (sri) integrity[url] = sri;
115
+ else failed.push(url);
116
+ }
117
+ }
118
+ const workerCount = Math.min(INTEGRITY_FETCH_CONCURRENCY, urls.length);
119
+ await Promise.all(Array.from({ length: workerCount }, worker));
120
+ // Any URL never claimed because the budget passed also fails open (served
121
+ // without integrity), the same outcome as a fetch failure.
122
+ while (next < urls.length) failed.push(urls[next++]);
123
+
124
+ if (failed.length) {
125
+ // One-time, count-based warning. The app still boots and the imports
126
+ // still work; only these URLs lack SRI (served as before #235). Run
127
+ // `webjs vendor pin` to lock in integrity, or retry once the CDN is
128
+ // healthy. Naming one example URL aids diagnosis without per-URL spam.
129
+ console.warn(
130
+ `[webjs] could not compute SRI for ${failed.length} live-resolved ` +
131
+ `vendor URL(s) (e.g. ${failed[0]}); they will load WITHOUT integrity. ` +
132
+ `This is a fail-open fallback for a CDN fetch failure or the warmup ` +
133
+ `time budget; the app still works. Run \`webjs vendor pin\` to lock in ` +
134
+ `SRI hashes.`,
135
+ );
136
+ }
137
+ return integrity;
138
+ }
139
+
140
+ export async function resolveVendorImports(appDir, getBareImports) {
141
+ const file = await readPinFile(appDir);
142
+ // A committed pin file IS the import map. The whole-app bare-import scan is
143
+ // discarded in that case, so it must never run (runtime-first boot: no
144
+ // static analysis when pinned). The scan is supplied as a thunk and invoked
145
+ // solely here, only when there is no pin file.
146
+ if (file) {
147
+ // A pin file is a deterministic disk read: always "ok" (no live CDN call
148
+ // that could partially fail). This is the recommended prod posture. The
149
+ // pin's own integrity is used verbatim; the live-hash path below is NOT
150
+ // taken for a pinned app.
151
+ return { imports: file.imports, integrity: file.integrity || {}, ok: true };
152
+ }
153
+ setLastLiveResolveFailed(false);
154
+ const bareImports = await getBareImports();
155
+ const imports = await vendorImportMapEntries(bareImports, appDir);
156
+ // Fill the SRI gap for live-resolved (unpinned) apps (#235): hash each
157
+ // cross-origin bundle and key the integrity by its final URL, the same
158
+ // shape the pin path uses and `vendorIntegrityFor` looks up. Bounded +
159
+ // fail-open, so a CDN fetch failure degrades to a missing hash for that
160
+ // URL (a warning), never a broken resolve. This runs only AFTER a live
161
+ // resolve produced URLs; if the resolve itself failed there is nothing to
162
+ // hash.
163
+ const integrity = await computeLiveIntegrity(imports);
164
+ // ok=false means at least one install could not be resolved (CDN unreachable
165
+ // / timeout / non-ok), so `imports` is partial. The caller must not memoize
166
+ // this as done; it should retry once the CDN recovers.
167
+ return { imports, integrity, ok: !isLastLiveResolveFailed() };
168
+ }
169
+
170
+ /**
171
+ * Serve a downloaded vendor bundle from `.webjs/vendor/<filename>`.
172
+ * Called by dev.js when the importmap contains `/__webjs/vendor/`
173
+ * paths (i.e. user ran `webjs vendor pin --download`).
174
+ *
175
+ * @param {string} filename e.g. `'dayjs@1.11.13.js'`
176
+ * @param {string} appDir
177
+ * @param {boolean} dev
178
+ * @returns {Promise<Response>}
179
+ */
180
+ export async function serveDownloadedBundle(filename, appDir, dev) {
181
+ // Strict allowlist. Vendor filenames are framework-generated:
182
+ // `<pkg>@<version>.js` or `<pkg>@<version>__<subpath>.js` plus the
183
+ // `@scope__name` form for scoped packages. The legal charset is
184
+ // alphanumeric plus `@`, `.`, `_`, `-`, `+` (`+` covers semver
185
+ // build metadata like `1.0.0+build.42`). Reject anything else
186
+ // (slashes / backslashes / dots-dots / null bytes / Unicode
187
+ // separators / glob chars) without echoing the input.
188
+ if (!/^[A-Za-z0-9@._+-]+\.js$/.test(filename) || filename.includes('..')) {
189
+ return new Response(`/* invalid vendor filename */`, {
190
+ status: 400,
191
+ headers: { 'content-type': 'application/javascript; charset=utf-8' },
192
+ });
193
+ }
194
+ try {
195
+ // Read as raw bytes (no encoding arg). downloadBundle writes the
196
+ // file from the response arrayBuffer (the same primitive the
197
+ // browser's SRI implementation hashes), so the bytes on disk are
198
+ // byte-identical to what jspm.io originally served. Reading with
199
+ // utf8 here would decode-then-re-encode and risk dropping the SRI
200
+ // match if any byte didn't round-trip exactly (e.g. invalid
201
+ // surrogate replacement). Keep the I/O binary end-to-end.
202
+ const body = await readFile(join(pinDir(appDir), filename));
203
+ // Buffered (bytes) body, so opt into the conditional-GET funnel, which
204
+ // hashes the bytes into a weak ETag (for downstream caches that strip the
205
+ // `immutable` directive) and honors If-None-Match -> 304. A WEAK validator
206
+ // is correct here because compression may re-encode the bytes per request
207
+ // (RFC 7232 2.3.3); the funnel is the single source for that. See
208
+ // conditional-get.js.
209
+ return new Response(body, {
210
+ headers: {
211
+ 'content-type': 'application/javascript; charset=utf-8',
212
+ 'cache-control': dev ? 'no-cache' : 'public, max-age=31536000, immutable',
213
+ [BUFFERED_MARKER]: '1',
214
+ },
215
+ });
216
+ } catch {
217
+ // Don't echo `filename` (already validated by the regex above so
218
+ // safe to echo, but keep the body fixed for grep-ability and to
219
+ // discourage anyone copying this pattern with untrusted input).
220
+ return new Response(`/* vendor bundle not found. Run webjs vendor pin --download to (re-)download. */`, {
221
+ status: 404,
222
+ headers: { 'content-type': 'application/javascript; charset=utf-8' },
223
+ });
224
+ }
225
+ }
@@ -0,0 +1,132 @@
1
+ import { buildModuleGraph, reachableBareSpecifiers } from '../module-graph.js';
2
+ import { browserEntryFiles } from '../browser-entries.js';
3
+ import { scanComponents } from '../component-scanner.js';
4
+ import { buildRouteTable } from '../router.js';
5
+
6
+ /**
7
+ * Set of package names whose importmap entries are populated by the
8
+ * framework, not by the vendor scanner. The scanner skips these to
9
+ * keep `@webjsdev/core` (and any future framework-internal package)
10
+ * off the jspm.io path: their bytes are served by the dev server's
11
+ * dedicated `/__webjs/core/*` route, and `buildCoreEntries()` in
12
+ * `importmap.js` derives one importmap line per exported subpath
13
+ * directly from the package's own `exports` field.
14
+ *
15
+ * The `'@webjsdev/core/'` prefix entry is here so that `extractPackageName`
16
+ * returning the bare name is enough to recognise core-subpath imports
17
+ * (`@webjsdev/core/directives`, `@webjsdev/core/task`, …) and skip
18
+ * them; the prefix form catches anything whose extractPackageName
19
+ * returns null but whose specifier starts with the prefix. Same
20
+ * mechanism, no special casing per subpath.
21
+ */
22
+ export const BUILTIN = new Set(['@webjsdev/core', '@webjsdev/core/']);
23
+
24
+ /**
25
+ * Server-only framework packages that must NEVER be vendored to the browser.
26
+ * Unlike `@webjsdev/core` (browser-bound, served locally via `/__webjs/core/*`),
27
+ * these are pure server packages: the CLI, the SSR runtime, the MCP server. A
28
+ * stray browser-graph scan
29
+ * that surfaces one of them must not push it onto the jspm path (#713). Matched
30
+ * on the extracted package name, so subpaths (`@webjsdev/cli/bin/webjs.js`) are
31
+ * covered. `@webjsdev/ui` is intentionally absent: its components ARE
32
+ * browser-bound, so it stays vendorable.
33
+ */
34
+ export const FRAMEWORK_SERVER_ONLY = new Set(['@webjsdev/cli', '@webjsdev/server', '@webjsdev/mcp']);
35
+
36
+ /**
37
+ * Extract the package name from a bare specifier.
38
+ * `'dayjs'` → `'dayjs'`
39
+ * `'dayjs/locale/en'` → `'dayjs'`
40
+ * `'@tanstack/query'` → `'@tanstack/query'`
41
+ * `'@tanstack/query/x'` → `'@tanstack/query'`
42
+ * `'./foo'`, `'../bar'`, `'/baz'` → `null` (relative/absolute)
43
+ * `'#components/x.ts'`, `'#lib/db'` → `null` (`#` path alias, #555: resolves to
44
+ * a LOCAL file via `package.json` "imports", never an npm package, so it must
45
+ * not be sent to the vendor resolver, #623)
46
+ *
47
+ * @param {string} spec
48
+ * @returns {string | null}
49
+ */
50
+ export function extractPackageName(spec) {
51
+ if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('__') || spec.startsWith('#')) return null;
52
+ if (/^[a-z]+:/.test(spec)) return null;
53
+ if (spec.startsWith('@')) {
54
+ const parts = spec.split('/');
55
+ return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null;
56
+ }
57
+ return spec.split('/')[0];
58
+ }
59
+
60
+ /**
61
+ * Bare npm specifiers that could reach the browser, filtered of the framework
62
+ * packages that must never be vendored.
63
+ *
64
+ * The scan is ROOTED IN THE MODULE GRAPH, not in a filesystem walk. Only files
65
+ * reachable from a browser-bound entry (page / layout / error / loading /
66
+ * not-found / forbidden / unauthorized / instrumentation-client / any
67
+ * component) contribute, which is the same authorization gate the dev server
68
+ * uses to decide what it will serve at all. A file nothing imports contributes
69
+ * nothing, so a build script under `scripts/`, a tooling config, a test helper
70
+ * and an unreferenced module all drop out by reachability rather than by name
71
+ * (there is no exclusion LIST any more, which is the point: the old one was
72
+ * open-ended and went stale).
73
+ *
74
+ * Excluded on top of unreachability:
75
+ * - `.server.{js,ts,mjs,mts}` files. They are REACHED (the browser fetches an
76
+ * RPC or throw-at-load stub at that URL) but their source never ships, so
77
+ * their bare imports must not enter the importmap.
78
+ * - `@webjsdev/core` and its subpaths (BUILTIN, served locally).
79
+ * - `@webjsdev/cli` / `@webjsdev/server` / `@webjsdev/mcp` (#713).
80
+ * - `import type` statements and any `import` written inside a comment,
81
+ * string, template literal or regex body. Both come free from the module
82
+ * graph's blanked-mask scanner (#753 / #805); this function no longer has
83
+ * a scanner of its own.
84
+ *
85
+ * This is the OUT-OF-PROCESS entry point (`pinAll`, `webjs doctor`). It builds
86
+ * its own graph, route table and component scan, roughly 70-200ms on the
87
+ * in-repo apps, and applies NO elision pruning, so the result is a SUPERSET of
88
+ * what the running server serves. That superset relation is what lets
89
+ * `prunePinToReachable` intersect a committed pin down to the runtime answer
90
+ * (#197). The dev server calls `reachedBareImports` instead, with the graph it
91
+ * already has and its elision skip set.
92
+ *
93
+ * @param {string} appDir
94
+ * @returns {Promise<Set<string>>}
95
+ */
96
+ export async function scanBareImports(appDir) {
97
+ let graph, components, routeTable;
98
+ try {
99
+ graph = await buildModuleGraph(appDir);
100
+ components = await scanComponents(appDir);
101
+ routeTable = await buildRouteTable(appDir);
102
+ } catch {
103
+ // An app the analysis cannot process yields no vendor specifiers rather
104
+ // than a throw, matching how check.js and elision-report.js degrade. The
105
+ // dev server surfaces the real problem.
106
+ return new Set();
107
+ }
108
+ return reachedBareImports(graph, [...browserEntryFiles(routeTable, components)], appDir);
109
+ }
110
+
111
+ /**
112
+ * The in-process form: the caller already has a module graph, the entry set,
113
+ * and (on the runtime path) the elision skip set. Used by the dev server so a
114
+ * vendor resolve does not rebuild analysis it just built.
115
+ *
116
+ * @param {import('../module-graph.js').ModuleGraph} graph
117
+ * @param {string[]} entryFiles
118
+ * @param {string} appDir
119
+ * @param {Set<string>} [skip] elided / inert / import-only modules
120
+ * @returns {Set<string>}
121
+ */
122
+ export function reachedBareImports(graph, entryFiles, appDir, skip) {
123
+ const found = reachableBareSpecifiers(graph, entryFiles, appDir, skip);
124
+ for (const b of BUILTIN) found.delete(b);
125
+ // Drop core subpaths (served locally via `/__webjs/core/*`) and server-only
126
+ // framework packages (#713) so neither reaches the importmap / jspm path.
127
+ for (const spec of found) {
128
+ const p = extractPackageName(spec);
129
+ if (p && (BUILTIN.has(p) || FRAMEWORK_SERVER_ONLY.has(p))) found.delete(spec);
130
+ }
131
+ return found;
132
+ }