@webjsdev/server 0.8.63 → 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 (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 +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/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
@@ -561,13 +561,22 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
561
561
  // the SUCCESS-result path below.
562
562
  return new Response(null, { status: e.status || 307, headers: { location: e.url } });
563
563
  }
564
+ // `route` and `params` ride along so the boundary renders inside its
565
+ // layouts, exactly as it does on the page-render path (#1298).
564
566
  if (isNotFound(err)) {
565
- return ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url });
567
+ // NEAREST-wins, like the page-render path (#848). `ssrOpts.notFoundFile`
568
+ // is the ROOT one only, so a nested form action throwing notFound()
569
+ // rendered the root boundary while the same throw from the page render
570
+ // rendered the nested one. Two paths, one url, different 404s.
571
+ const nearestNotFound = route.notFounds && route.notFounds.length
572
+ ? route.notFounds[route.notFounds.length - 1]
573
+ : (ssrOpts.notFoundFile ?? null);
574
+ return ssrNotFound(nearestNotFound, { ...ssrOpts, req, url, route, params });
566
575
  }
567
576
  // forbidden()/unauthorized() from a form action render the same 403/401
568
577
  // boundary as the page-render path (#848), not a generic 500.
569
- if (isForbidden(err)) return ssrForbidden(route, { ...ssrOpts, req, url });
570
- if (isUnauthorized(err)) return ssrUnauthorized(route, { ...ssrOpts, req, url });
578
+ if (isForbidden(err)) return ssrForbidden(route, { ...ssrOpts, req, url, route, params });
579
+ if (isUnauthorized(err)) return ssrUnauthorized(route, { ...ssrOpts, req, url, route, params });
571
580
  if (typeof onError === 'function') onError(err);
572
581
  return await formActionErrorResponse(err, ssrOpts.dev);
573
582
  }
package/src/importmap.js CHANGED
@@ -6,9 +6,12 @@ import { jsonForScriptTag } from './script-tag-json.js';
6
6
  import { withBasePath } from './base-path.js';
7
7
  import { withAssetHash } from './asset-hash.js';
8
8
 
9
- // Local attribute escaper. Matches ssr.js's escapeAttr (the source
10
- // of truth for HTML attribute escaping in this package). Kept inline
11
- // to avoid a cross-file dependency for one small helper.
9
+ // Local attribute escaper, byte-identical to the pair in `ssr/escape.js`,
10
+ // which is the source of truth for HTML attribute escaping in this package.
11
+ // Kept inline to avoid a cross-file dependency for one small helper, so the
12
+ // two must stay in step: this one escapes an attribute in the served head
13
+ // (the importmap script's nonce), so a divergence here moves served bytes
14
+ // exactly as a divergence there would.
12
15
  function escapeAttr(s) {
13
16
  return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
14
17
  }
@@ -365,8 +365,22 @@ export class SseHub {
365
365
  }
366
366
  }
367
367
 
368
- /** Push a live-reload event to every open tab. */
369
- reload() { this._raw('event: reload\ndata: now\n\n'); }
368
+ /**
369
+ * Push a live-reload event to every open tab.
370
+ *
371
+ * The frame carries the change's `{ v, by, why }` classification (#1398) as a
372
+ * single-line JSON payload, matching `devError` below. The browser relay
373
+ * resolves ANY payload it cannot read to `reload`, and an absent or malformed
374
+ * verdict is emitted as `reload` here too, so the fail-safe holds on both ends
375
+ * of the wire and a tab running against a restarted server can never be
376
+ * skewed into a lighter response than the change deserves.
377
+ *
378
+ * @param {{ v?: string, by?: string, why?: string } | null} [verdict]
379
+ */
380
+ reload(verdict) {
381
+ const v = verdict && typeof verdict.v === 'string' ? verdict : { v: 'reload' };
382
+ this._raw(`event: reload\ndata: ${JSON.stringify(v)}\n\n`);
383
+ }
370
384
 
371
385
  /** Push a dev-error overlay frame (#264) to every open tab. @param {object} frame */
372
386
  devError(frame) { this._raw(`event: webjs-error\ndata: ${JSON.stringify(frame)}\n\n`); }
@@ -174,6 +174,28 @@ export function bareImports(graph) {
174
174
  return BARE_EDGES.get(graph) || new Map();
175
175
  }
176
176
 
177
+ /**
178
+ * Bare (npm vendor) specifiers imported DYNAMICALLY (`await import('dayjs')`),
179
+ * kept apart from `BARE_EDGES` for the same reason `DYNAMIC_EDGES` is kept
180
+ * apart from the static graph: a lazily-imported vendor must RESOLVE when the
181
+ * import finally runs, so it belongs in the importmap, but it must not be
182
+ * preloaded (that would fetch a bundle the author deliberately deferred). So
183
+ * `reachableBareSpecifiers` (the importmap) unions both maps while
184
+ * `bareImports` (the #754 modulepreload source) stays static-only.
185
+ * @type {WeakMap<ModuleGraph, Map<string, Set<string>>>}
186
+ */
187
+ const BARE_DYNAMIC_EDGES = new WeakMap();
188
+
189
+ /**
190
+ * The dynamically-imported bare specifiers per file for a built graph, or an
191
+ * empty map if none.
192
+ * @param {ModuleGraph} graph
193
+ * @returns {Map<string, Set<string>>}
194
+ */
195
+ export function dynamicBareImports(graph) {
196
+ return BARE_DYNAMIC_EDGES.get(graph) || new Map();
197
+ }
198
+
177
199
  /**
178
200
  * Every app source file walked while building a graph (#899). `graph` itself
179
201
  * holds only files that HAVE imports, so a leaf (e.g. a no-import `.server.ts`)
@@ -224,12 +246,16 @@ export async function buildModuleGraph(appDir) {
224
246
  const dynamic = new Map();
225
247
  /** @type {Map<string, Set<string>>} bare vendor specifiers per file (#754) */
226
248
  const bare = new Map();
249
+ /** @type {Map<string, Set<string>>} DYNAMICALLY-imported bare vendor specifiers
250
+ * per file: importmap-bound but never preloaded (#1399) */
251
+ const bareDynamic = new Map();
227
252
  /** @type {Set<string>} every file walked this build (graph holds only files
228
253
  * with deps, so a separate set is needed to know what is still live). */
229
254
  const seen = new Set();
230
- await walk(appDir, appDir, graph, seen, dynamic, bare);
255
+ await walk(appDir, appDir, graph, seen, dynamic, bare, bareDynamic);
231
256
  if (dynamic.size) DYNAMIC_EDGES.set(graph, dynamic);
232
257
  if (bare.size) BARE_EDGES.set(graph, bare);
258
+ if (bareDynamic.size) BARE_DYNAMIC_EDGES.set(graph, bareDynamic);
233
259
  if (seen.size) SEEN_FILES.set(graph, seen);
234
260
  // Evict parse-cache entries for files no longer in the tree (a rebuild after
235
261
  // a rename or delete), so a long dev session does not accumulate dead
@@ -374,13 +400,82 @@ export function reachableFromEntries(graph, entryFiles, appDir) {
374
400
  return visited;
375
401
  }
376
402
 
403
+ /**
404
+ * Bare npm vendor specifiers reachable from `entryFiles`, the importmap
405
+ * analogue of ssr.js's per-page `reachedVendorSpecifiers` (#754).
406
+ *
407
+ * Walks the SAME edges the authorization gate walks (static + dynamic, #751,
408
+ * stopping at `.server.*` boundaries) so the answer is exactly "what a browser
409
+ * could load from these entries", then unions each reached file's recorded
410
+ * bare edges, static AND dynamic. Both dynamic forms are included here even
411
+ * though `transitiveDeps` (preload) ignores them, for the same reason: a
412
+ * lazily imported module is not preloaded, but its bare specifier still has to
413
+ * resolve when the import finally runs, so it belongs in the importmap. That
414
+ * is why a dynamically-imported VENDOR gets its own map (`dynamicBareImports`)
415
+ * rather than joining `bareImports`, which feeds the preload hints.
416
+ *
417
+ * `.server.*` files are reached (the browser fetches a stub at that URL) but
418
+ * their bare imports are NOT collected: the source never ships, so a DB driver
419
+ * imported by a server file must never enter the importmap.
420
+ *
421
+ * `skip` files are neither collected nor traversed into, so a specifier
422
+ * reachable ONLY through an elided component drops out along with the subtree
423
+ * behind it (#197 / #170). Omit `skip` for the un-pruned superset the pin path
424
+ * needs.
425
+ *
426
+ * @param {ModuleGraph} graph
427
+ * @param {string[]} entryFiles absolute paths
428
+ * @param {string} appDir
429
+ * @param {Set<string>} [skip] absolute paths to exclude and not traverse
430
+ * @returns {Set<string>}
431
+ */
432
+ export function reachableBareSpecifiers(graph, entryFiles, appDir, skip) {
433
+ /** @type {Set<string>} */
434
+ const specs = new Set();
435
+ const bare = BARE_EDGES.get(graph);
436
+ const bareDynamic = BARE_DYNAMIC_EDGES.get(graph);
437
+ if (!bare?.size && !bareDynamic?.size) return specs;
438
+ const dynamic = DYNAMIC_EDGES.get(graph);
439
+ /** @type {Set<string>} */
440
+ const visited = new Set();
441
+ /** @type {string[]} */
442
+ const queue = [];
443
+ for (const entry of entryFiles) {
444
+ if (!entry || !entry.startsWith(appDir)) continue;
445
+ if (skip && skip.has(entry)) continue;
446
+ visited.add(entry);
447
+ queue.push(entry);
448
+ }
449
+ while (queue.length) {
450
+ const file = /** @type {string} */ (queue.shift());
451
+ if (SERVER_FILE_RE.test(file)) continue;
452
+ for (const map of [bare, bareDynamic]) {
453
+ const fileBare = map?.get(file);
454
+ if (fileBare) for (const spec of fileBare) specs.add(spec);
455
+ }
456
+ const staticDeps = graph.get(file);
457
+ const dynDeps = dynamic && dynamic.get(file);
458
+ for (const set of [staticDeps, dynDeps]) {
459
+ if (!set) continue;
460
+ for (const dep of set) {
461
+ if (visited.has(dep)) continue;
462
+ if (!dep.startsWith(appDir)) continue;
463
+ if (skip && skip.has(dep)) continue;
464
+ visited.add(dep);
465
+ queue.push(dep);
466
+ }
467
+ }
468
+ }
469
+ return specs;
470
+ }
471
+
377
472
  /**
378
473
  * Recursively walk a directory, parse imports, and populate the graph.
379
474
  * @param {string} dir
380
475
  * @param {string} appDir
381
476
  * @param {ModuleGraph} graph
382
477
  */
383
- async function walk(dir, appDir, graph, seen, dynamic, bare) {
478
+ async function walk(dir, appDir, graph, seen, dynamic, bare, bareDynamic) {
384
479
  let entries;
385
480
  try { entries = await readdir(dir, { withFileTypes: true }); }
386
481
  catch { return; }
@@ -398,9 +493,9 @@ async function walk(dir, appDir, graph, seen, dynamic, bare) {
398
493
  if (e.name.startsWith('.')) continue;
399
494
  const full = join(dir, e.name);
400
495
  if (e.isDirectory()) {
401
- await walk(full, appDir, graph, seen, dynamic, bare);
496
+ await walk(full, appDir, graph, seen, dynamic, bare, bareDynamic);
402
497
  } else if (/\.(js|ts|mjs|mts)$/.test(e.name)) {
403
- await parseFile(full, appDir, graph, seen, dynamic, bare);
498
+ await parseFile(full, appDir, graph, seen, dynamic, bare, bareDynamic);
404
499
  }
405
500
  }
406
501
  }
@@ -428,7 +523,7 @@ export function _parseCacheHas(file) { return PARSE_CACHE.has(file); }
428
523
  * @param {string} appDir
429
524
  * @param {ModuleGraph} graph
430
525
  */
431
- async function parseFile(file, appDir, graph, seen, dynamic, bare) {
526
+ async function parseFile(file, appDir, graph, seen, dynamic, bare, bareDynamic) {
432
527
  let mtimeMs, size;
433
528
  try { const st = await stat(file); mtimeMs = st.mtimeMs; size = st.size; }
434
529
  catch { return; }
@@ -438,6 +533,7 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
438
533
  if (cached.deps.size) graph.set(file, cached.deps);
439
534
  if (cached.dynDeps && cached.dynDeps.size && dynamic) dynamic.set(file, cached.dynDeps);
440
535
  if (cached.bareDeps && cached.bareDeps.size && bare) bare.set(file, cached.bareDeps);
536
+ if (cached.bareDynDeps && cached.bareDynDeps.size && bareDynamic) bareDynamic.set(file, cached.bareDynDeps);
441
537
  return;
442
538
  }
443
539
 
@@ -541,19 +637,28 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
541
637
  // `,`/`)`), preserving the documented limitation.
542
638
  const { redacted: holeAware, literals } = redactToPlaceholders(src);
543
639
  const dynDeps = new Set();
640
+ const bareDynDeps = new Set();
544
641
  for (const m of holeAware.matchAll(DYNAMIC_IMPORT_RE)) {
545
642
  const token = m[1];
546
643
  const pm = /^__STR_(\d+)__$/.exec(token);
547
644
  const spec = pm ? literals[Number(pm[1])] : token;
548
645
  if (spec == null) continue;
549
- if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) continue;
646
+ if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) {
647
+ // A dynamically-imported VENDOR (`await import('dayjs')`). Not a graph
648
+ // edge (there is no local file to serve) and not a preload (lazy by
649
+ // intent), but it still needs an importmap entry or the import fails to
650
+ // resolve when it runs. Recorded in its own map for that one consumer.
651
+ if (isVendorSpecifier(spec)) bareDynDeps.add(spec);
652
+ continue;
653
+ }
550
654
  const resolved = resolveImport(spec, file, appDir);
551
655
  if (resolved && !deps.has(resolved)) dynDeps.add(resolved);
552
656
  }
553
- PARSE_CACHE.set(file, { mtimeMs, size, deps, dynDeps, bareDeps });
657
+ PARSE_CACHE.set(file, { mtimeMs, size, deps, dynDeps, bareDeps, bareDynDeps });
554
658
  if (deps.size) graph.set(file, deps);
555
659
  if (dynDeps.size && dynamic) dynamic.set(file, dynDeps);
556
660
  if (bareDeps.size && bare) bare.set(file, bareDeps);
661
+ if (bareDynDeps.size && bareDynamic) bareDynamic.set(file, bareDynDeps);
557
662
  }
558
663
 
559
664
  /**
package/src/router.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { join, relative, sep, posix } from 'node:path';
2
2
  import { walk } from './fs-walk.js';
3
+ import { findInstrumentationClient } from './instrumentation.js';
3
4
 
4
5
  /**
5
6
  * @typedef {{
@@ -35,6 +36,7 @@ import { walk } from './fs-walk.js';
35
36
  * globalError: string | null,
36
37
  * globalNotFound: string | null,
37
38
  * metadataRoutes: MetadataRoute[],
39
+ * instrumentationClient: string | null,
38
40
  * appDir: string
39
41
  * }} RouteTable
40
42
  */
@@ -185,7 +187,15 @@ export async function buildRouteTable(appDir) {
185
187
  }
186
188
 
187
189
  pages.sort(compareSpecificity);
188
- return { pages, apis, notFound, notFounds, globalError, globalNotFound, metadataRoutes, appDir };
190
+ // `instrumentation-client.*` is an app-ROOT convention file, not a router
191
+ // stem, but it is a browser-bound entry (the boot imports it first), so the
192
+ // table is where every consumer already looks for one. Resolved HERE rather
193
+ // than by each caller: it used to be attached by the dev server alone, and a
194
+ // second consumer that built its own table (the vendor scan) silently lost
195
+ // the entry, which inverted the pin-is-a-superset invariant. Deriving it in
196
+ // the one place that builds the table makes that class of miss impossible.
197
+ const instrumentationClient = await findInstrumentationClient(appDir);
198
+ return { pages, apis, notFound, notFounds, globalError, globalNotFound, metadataRoutes, instrumentationClient, appDir };
189
199
  }
190
200
 
191
201
  /**
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The app-wide client-router opt-out flag (#629).
3
+ *
4
+ * Default ON (the framework's automatic-nav thesis): the router auto-enables in
5
+ * the browser when `@webjsdev/core` loads. `webjs.clientRouter: false` flips it
6
+ * off app-wide; the dev server reads the config at boot and on each rebuild and
7
+ * calls `setClientRouterEnabled`, and `wrapHead` then emits a
8
+ * `window.__WEBJS_CLIENT_ROUTER__=false` flag BEFORE the deferred boot module
9
+ * so the bundle's module-end auto-enable skips.
10
+ *
11
+ * A module-level switch (mirroring `setBasePath` / `setElisionFingerprint`) so
12
+ * no opt has to thread through every render path. Default true keeps every
13
+ * existing app and test byte-identical.
14
+ *
15
+ * It sits in its own module rather than beside its readers because it has TWO
16
+ * of them, in `head.js` and `render.js`, and parking it in either made the
17
+ * other import across what was otherwise a one-way edge. That was the second
18
+ * half of the ssr import cycle. The rule the pre-split file stated, that module
19
+ * state belongs with the code that uses and writes it, is unchanged for state
20
+ * with a single reader: `_metadataIconRoutes` still lives in `head.js`, whose
21
+ * `wrapHead` is the only thing that reads it.
22
+ */
23
+
24
+ let _clientRouterEnabled = true;
25
+
26
+ /**
27
+ * Set the app-wide client-router flag.
28
+ *
29
+ * @param {boolean} enabled anything but `false` enables
30
+ */
31
+ export function setClientRouterEnabled(enabled) {
32
+ _clientRouterEnabled = enabled !== false;
33
+ }
34
+
35
+ /**
36
+ * Whether the client router is enabled app-wide.
37
+ *
38
+ * @returns {boolean}
39
+ */
40
+ export function clientRouterEnabled() {
41
+ return _clientRouterEnabled;
42
+ }