@webjsdev/server 0.8.91 → 0.10.0

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 (105) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +461 -102
  3. package/index.js +9 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-input.js +208 -0
  7. package/src/action-params.js +156 -0
  8. package/src/action-route.js +8 -9
  9. package/src/action-seed.js +44 -5
  10. package/src/actions.js +112 -15
  11. package/src/api.js +67 -10
  12. package/src/app-layout.js +133 -0
  13. package/src/asset-hash.js +82 -49
  14. package/src/auth.js +612 -108
  15. package/src/browser-entries.js +2 -17
  16. package/src/build-epoch.js +1 -1
  17. package/src/cache-tags.js +5 -1
  18. package/src/check/helpers.js +85 -0
  19. package/src/check/rules-actions.js +37 -1
  20. package/src/check/rules-components.js +133 -28
  21. package/src/check/rules-imports.js +175 -4
  22. package/src/check/rules-route-modules.js +200 -0
  23. package/src/check/rules-routes.js +55 -0
  24. package/src/check/rules-routing.js +143 -95
  25. package/src/check/rules-typescript.js +24 -2
  26. package/src/check/rules.js +109 -9
  27. package/src/check/runner-support.js +53 -171
  28. package/src/check/runner.js +24 -6
  29. package/src/client-signals.js +171 -0
  30. package/src/component-scanner.js +22 -16
  31. package/src/context.js +152 -16
  32. package/src/cookie-store.js +262 -0
  33. package/src/core-exports.js +190 -0
  34. package/src/dev/bun-app-source.js +95 -5
  35. package/src/dev/config.js +7 -51
  36. package/src/dev/dev-service-worker.js +48 -0
  37. package/src/dev/dev-status.js +122 -0
  38. package/src/dev/esm-hot-hooks.js +373 -0
  39. package/src/dev/handler.js +632 -155
  40. package/src/dev/helpers.js +127 -96
  41. package/src/dev/hot-host.js +33 -6
  42. package/src/dev/poke-state.js +110 -0
  43. package/src/dev/serve.js +191 -133
  44. package/src/dev/server.js +109 -37
  45. package/src/dev/specifier-versions.js +125 -0
  46. package/src/dev/watch-ignore.js +49 -4
  47. package/src/dev/watch-poll.js +78 -0
  48. package/src/dev/watch-tree.js +59 -22
  49. package/src/dev-classify.js +59 -26
  50. package/src/dev-error.js +104 -3
  51. package/src/dev-hot-classify.js +291 -0
  52. package/src/dev-hot-client.js +245 -0
  53. package/src/dev-import.js +22 -5
  54. package/src/dev-overlay.js +26 -0
  55. package/src/dev-ready-probe.js +69 -0
  56. package/src/dev-regenerate.js +322 -78
  57. package/src/dev-reload-hold.js +49 -20
  58. package/src/dev-reload-reason.js +61 -0
  59. package/src/dev-reload-worker.js +62 -13
  60. package/src/dev-secret.js +73 -0
  61. package/src/dev-source-locations-hook.js +4 -1
  62. package/src/dev-source-locations.js +33 -2
  63. package/src/dev-styles.js +64 -6
  64. package/src/dev.js +0 -1
  65. package/src/env-files.js +124 -0
  66. package/src/file-storage.js +2 -2
  67. package/src/foreign-imports.js +228 -0
  68. package/src/form-action-identity.js +52 -25
  69. package/src/form-dispatch.js +150 -69
  70. package/src/html-cache.js +35 -4
  71. package/src/importmap.js +2 -2
  72. package/src/instrumentation.js +59 -3
  73. package/src/js-scan.js +5 -4
  74. package/src/jwe.js +155 -0
  75. package/src/lazy-components.js +166 -0
  76. package/src/listener-core.js +4 -2
  77. package/src/lit-hints.js +172 -0
  78. package/src/middleware-load.js +248 -0
  79. package/src/module-graph.js +73 -47
  80. package/src/module-side-effect.js +284 -0
  81. package/src/public-files.js +73 -0
  82. package/src/redirect-location.js +19 -0
  83. package/src/redirects.js +9 -1
  84. package/src/route-frontier.js +151 -0
  85. package/src/route-report.js +130 -0
  86. package/src/router.js +120 -13
  87. package/src/session.js +32 -10
  88. package/src/ssr/client-router-flag.js +1 -1
  89. package/src/ssr/document.js +49 -17
  90. package/src/ssr/head.js +228 -25
  91. package/src/ssr/preloads.js +31 -45
  92. package/src/ssr/render.js +243 -55
  93. package/src/ssr/responses.js +51 -4
  94. package/src/ssr/shipped-modules.js +52 -16
  95. package/src/testing.d.ts +31 -2
  96. package/src/testing.js +85 -17
  97. package/src/thenable-params.js +26 -0
  98. package/src/vendor/jspm.js +12 -1
  99. package/src/vendor/pins.js +9 -8
  100. package/src/vendor/scanner.js +14 -9
  101. package/src/vendor.js +5 -3
  102. package/webjs-config.schema.json +3 -8
  103. package/src/component-elision.js +0 -1852
  104. package/src/elision-differential.js +0 -101
  105. package/src/elision-report.js +0 -193
@@ -0,0 +1,130 @@
1
+ /**
2
+ * What each route costs the browser (#1686).
3
+ *
4
+ * A page, layout or boundary never ships; the boot loads the components it
5
+ * renders, its frontier (`route-frontier.js`). This report runs the same walk
6
+ * the dev server runs and projects it into a sorted, app-relative,
7
+ * JSON-serializable object:
8
+ *
9
+ * - `routeModules`: every page, layout and boundary with its kind, the
10
+ * component modules the boot imports in its place (`emits`, empty means
11
+ * the route loads no JavaScript), and `hints`: code in it that looks meant
12
+ * for the browser but runs only on the server (`routeModuleBrowserHints`).
13
+ * - `orphans`: a `class X extends WebComponent` with no literal-tag
14
+ * registration, either because there is no registration call or because
15
+ * the tag is computed. The scanner never sees it (invariant 3 requires a
16
+ * literal tag), so it is never booted and never upgrades unless something
17
+ * that ships imports it.
18
+ *
19
+ * Consumed by `webjs doctor` (`ROUTE_BROWSER_CODE` and `ORPHAN_COMPONENTS`,
20
+ * which share ONE call so the module graph is built once per run). Advisory
21
+ * only: it never fails anything.
22
+ *
23
+ * Nothing is cached here. Every out-of-process consumer runs the analysis once
24
+ * and exits, so a cache would never hit and would only add a staleness bug.
25
+ */
26
+ import { readFile, stat } from 'node:fs/promises';
27
+ import { join, relative, resolve } from 'node:path';
28
+
29
+ import { buildModuleGraph } from './module-graph.js';
30
+ import { scanComponents, findOrphanComponents } from './component-scanner.js';
31
+ import { buildRouteTable } from './router.js';
32
+ import { routeModuleBrowserHints } from './client-signals.js';
33
+ import { routeFrontiers, routeModuleKinds, leadsToComponent } from './route-frontier.js';
34
+
35
+ /**
36
+ * @typedef {'page'|'layout'|'error'|'loading'|'not-found'|'forbidden'|'unauthorized'|'global-not-found'} RouteModuleKind
37
+ * @typedef {{ file: string, kind: RouteModuleKind, emits: string[], hints: string[] }} RouteModuleRow
38
+ * @typedef {{ file: string, className: string }} OrphanRow
39
+ * @typedef {{ routeModules: number, zeroJs: number, carriers: number, orphans: number }} RouteReportSummary
40
+ * @typedef {{ analysed: boolean, skipped: 'no-app'|'unanalysable'|null, routeModules: RouteModuleRow[], orphans: OrphanRow[], summary: RouteReportSummary }} RouteReport
41
+ */
42
+
43
+ /**
44
+ * @param {string} appDir
45
+ * @returns {Promise<RouteReport>}
46
+ */
47
+ export async function analyzeRouteModules(appDir) {
48
+ // Every path the analysis compares is absolute, so a relative app dir would
49
+ // match nothing and report empty frontiers.
50
+ appDir = resolve(appDir);
51
+ // No `app/` means this is not a routable app (a bare component library, a
52
+ // lib-only fixture); there is nothing to report on.
53
+ if (!(await pathExists(join(appDir, 'app')))) return empty('no-app');
54
+
55
+ let moduleGraph, components, routeTable, orphans;
56
+ try {
57
+ moduleGraph = await buildModuleGraph(appDir);
58
+ components = await scanComponents(appDir);
59
+ routeTable = await buildRouteTable(appDir);
60
+ orphans = await findOrphanComponents(appDir);
61
+ } catch {
62
+ // A malformed app the analysis cannot process degrades to no report (the
63
+ // dev server and `webjs check` surface the real problem).
64
+ return empty('unanalysable');
65
+ }
66
+
67
+ const rel = (/** @type {string} */ f) => relative(appDir, f) || f;
68
+ const byFile = (/** @type {{ file: string }} */ a, /** @type {{ file: string }} */ b) => a.file.localeCompare(b.file);
69
+
70
+ // The same frontier walk the dev server runs, so `emits` is exactly what the
71
+ // boot imports in each route module's place.
72
+ const kinds = routeModuleKinds(routeTable);
73
+ const routeFiles = new Set(kinds.keys());
74
+ const componentFileSet = new Set(components.map((c) => c.file));
75
+ const frontiers = routeFrontiers([...routeFiles], moduleGraph, componentFileSet, routeFiles);
76
+ const carriesComponent = leadsToComponent(moduleGraph, componentFileSet);
77
+ /** @type {RouteModuleRow[]} */
78
+ const routeRows = [];
79
+ for (const [f, kind] of kinds) {
80
+ /** @type {string[]} */
81
+ let hints = [];
82
+ try {
83
+ hints = routeModuleBrowserHints(await readFile(f, 'utf8'), {
84
+ file: f, appDir, isComponent: (abs) => componentFileSet.has(abs), carriesComponent,
85
+ });
86
+ } catch { /* unreadable: no advice */ }
87
+ routeRows.push({
88
+ file: rel(f),
89
+ kind,
90
+ emits: (frontiers.get(f) || []).map(rel).sort(),
91
+ hints,
92
+ });
93
+ }
94
+ routeRows.sort(byFile);
95
+
96
+ /** @type {OrphanRow[]} */
97
+ const orphanRows = orphans
98
+ .map((o) => ({ file: rel(o.file), className: o.className }))
99
+ .sort((a, b) => byFile(a, b) || a.className.localeCompare(b.className));
100
+
101
+ return {
102
+ analysed: true, skipped: null,
103
+ routeModules: routeRows, orphans: orphanRows,
104
+ summary: {
105
+ routeModules: routeRows.length,
106
+ zeroJs: routeRows.filter((r) => r.emits.length === 0).length,
107
+ carriers: routeRows.filter((r) => r.emits.length > 0).length,
108
+ orphans: orphanRows.length,
109
+ },
110
+ };
111
+ }
112
+
113
+ /**
114
+ * The empty report, naming WHY nothing was analysed so a machine consumer can
115
+ * tell "this is not an app" from "the analysis could not run".
116
+ * @param {'no-app'|'unanalysable'} skipped
117
+ * @returns {RouteReport}
118
+ */
119
+ function empty(skipped) {
120
+ return {
121
+ analysed: false, skipped,
122
+ routeModules: [], orphans: [],
123
+ summary: { routeModules: 0, zeroJs: 0, carriers: 0, orphans: 0 },
124
+ };
125
+ }
126
+
127
+ /** @param {string} p @returns {Promise<boolean>} */
128
+ async function pathExists(p) {
129
+ try { await stat(p); return true; } catch { return false; }
130
+ }
package/src/router.js CHANGED
@@ -6,6 +6,7 @@ import { findInstrumentationClient } from './instrumentation.js';
6
6
  * @typedef {{
7
7
  * pattern: RegExp,
8
8
  * paramNames: string[],
9
+ * paramKinds?: ParamKind[],
9
10
  * file: string,
10
11
  * routeDir: string,
11
12
  * layouts: string[],
@@ -37,6 +38,8 @@ import { findInstrumentationClient } from './instrumentation.js';
37
38
  * globalNotFound: string | null,
38
39
  * metadataRoutes: MetadataRoute[],
39
40
  * instrumentationClient: string | null,
41
+ * conflicts: { page: string, route: string, url: string }[],
42
+ * ignoredJsx: string[],
40
43
  * appDir: string
41
44
  * }} RouteTable
42
45
  */
@@ -96,6 +99,8 @@ export async function buildRouteTable(appDir) {
96
99
  let globalNotFound = null;
97
100
  /** @type {MetadataRoute[]} */
98
101
  const metadataRoutes = [];
102
+ /** @type {string[]} */
103
+ const ignoredJsx = [];
99
104
 
100
105
  /** @type {Set<string>} */
101
106
  const METADATA_STEMS = new Set(['sitemap', 'robots', 'manifest', 'icon', 'apple-icon', 'opengraph-image', 'twitter-image']);
@@ -131,16 +136,25 @@ export async function buildRouteTable(appDir) {
131
136
  }
132
137
  }
133
138
 
139
+ // A routing file written as `.tsx` / `.jsx` (Next's default) is never a
140
+ // route here: JSX needs a build. Recorded so validateAppLayout refuses it
141
+ // loudly instead of the route silently not existing.
142
+ {
143
+ const jsx = /^([A-Za-z0-9_.-]+)\.(?:tsx|jsx)$/.exec(base);
144
+ if (jsx && ROUTING_STEMS.has(jsx[1])) { ignoredJsx.push(file); continue; }
145
+ }
146
+
134
147
  // Match `<name>.<js|mjs|ts|mts>` conventions. Stem is the name without ext.
135
148
  const stem = stemOf(base);
136
149
  if (!stem) continue;
137
150
 
138
151
  if (stem === 'page') {
139
152
  const segs = dir === '.' ? [] : dir.split('/');
140
- const { pattern, paramNames, isCatchAll } = segmentsToPattern(segs);
153
+ const { pattern, paramNames, paramKinds, isCatchAll } = segmentsToPattern(segs);
141
154
  pages.push({
142
155
  pattern,
143
156
  paramNames,
157
+ paramKinds,
144
158
  file,
145
159
  routeDir: dir,
146
160
  layouts: [],
@@ -182,8 +196,8 @@ export async function buildRouteTable(appDir) {
182
196
  } else if (stem === 'route') {
183
197
  // route.js / route.ts can live anywhere under app/ (matches NextJs).
184
198
  const segs = dir === '.' ? [] : dir.split('/');
185
- const { pattern, paramNames } = segmentsToPattern(segs);
186
- apis.push({ pattern, paramNames, file, routeDir: dir, middlewares: [] });
199
+ const { pattern, paramNames, paramKinds } = segmentsToPattern(segs);
200
+ apis.push({ pattern, paramNames, paramKinds, file, routeDir: dir, middlewares: [] });
187
201
  }
188
202
  }
189
203
 
@@ -221,7 +235,21 @@ export async function buildRouteTable(appDir) {
221
235
  // the entry, which inverted the pin-is-a-superset invariant. Deriving it in
222
236
  // the one place that builds the table makes that class of miss impossible.
223
237
  const instrumentationClient = await findInstrumentationClient(appDir);
224
- return { pages, apis, notFound, notFounds, globalError, globalNotFound, metadataRoutes, instrumentationClient, appDir };
238
+ // A page and a route handler answering the same URL (Next refuses this as
239
+ // "Conflicting route and page"). Compared by the compiled pattern, so a
240
+ // route group (`app/(a)/x/page.ts` beside `app/x/route.ts`) conflicts too.
241
+ // validateAppLayout turns each into a boot error and a check violation.
242
+ /** @type {{ page: string, route: string, url: string }[]} */
243
+ const conflicts = [];
244
+ const pageBySource = new Map(pages.map((p) => [p.pattern.source, p]));
245
+ for (const api of apis) {
246
+ const page = pageBySource.get(api.pattern.source);
247
+ if (page) {
248
+ const a = /** @type any */ (api);
249
+ conflicts.push({ page: page.file, route: api.file, url: '/' + urlSegmentsOf(a.routeDir).join('/') });
250
+ }
251
+ }
252
+ return { pages, apis, notFound, notFounds, globalError, globalNotFound, metadataRoutes, instrumentationClient, conflicts, ignoredJsx, appDir };
225
253
  }
226
254
 
227
255
  /**
@@ -263,6 +291,13 @@ export function staticMetadataFile(base) {
263
291
  return null;
264
292
  }
265
293
 
294
+ /** Every routing-file stem, for spotting one written as `.tsx` / `.jsx`. */
295
+ const ROUTING_STEMS = new Set([
296
+ 'page', 'layout', 'route', 'error', 'loading', 'not-found', 'forbidden', 'unauthorized',
297
+ 'global-error', 'global-not-found', 'middleware', 'template', 'default',
298
+ 'sitemap', 'robots', 'manifest', 'icon', 'apple-icon', 'opengraph-image', 'twitter-image',
299
+ ]);
300
+
266
301
  function stemOf(base) {
267
302
  const m = /^([A-Za-z0-9_.-]+)\.(?:m?[jt]s)$/.exec(base);
268
303
  return m ? m[1] : null;
@@ -354,6 +389,8 @@ export function compareSpecificity(a, b) {
354
389
  */
355
390
  function segmentsToPattern(segments, prefix = '') {
356
391
  const paramNames = [];
392
+ /** @type {ParamKind[]} parallel to paramNames */
393
+ const paramKinds = [];
357
394
  let isCatchAll = false;
358
395
  let isOptionalCatchAll = false;
359
396
  const parts = segments
@@ -362,17 +399,22 @@ function segmentsToPattern(segments, prefix = '') {
362
399
  // Optional catch-all: [[...slug]] matches with AND without params
363
400
  if (seg.startsWith('[[...') && seg.endsWith(']]')) {
364
401
  paramNames.push(seg.slice(5, -2));
402
+ paramKinds.push('optional');
365
403
  isCatchAll = true;
366
404
  isOptionalCatchAll = true;
367
405
  return '(.*)';
368
406
  }
369
407
  if (seg.startsWith('[...') && seg.endsWith(']')) {
370
408
  paramNames.push(seg.slice(4, -1));
409
+ paramKinds.push('catchall');
371
410
  isCatchAll = true;
372
- return '(.*)';
411
+ // At least one character: a required catch-all never matches its
412
+ // empty base (`/docs` is a 404 for `docs/[...slug]`, as in Next).
413
+ return '(.+)';
373
414
  }
374
415
  if (seg.startsWith('[') && seg.endsWith(']')) {
375
416
  paramNames.push(seg.slice(1, -1));
417
+ paramKinds.push('single');
376
418
  return '([^/]+)';
377
419
  }
378
420
  return escapeRe(seg);
@@ -387,9 +429,44 @@ function segmentsToPattern(segments, prefix = '') {
387
429
  const pattern = new RegExp(`^${escapeRe(prefix)}${regexBody}${isOptionalCatchAll ? suffix : '/?$'}`);
388
430
  if (!isOptionalCatchAll) {
389
431
  // Standard pattern needs end anchor
390
- return { pattern: new RegExp(`^${escapeRe(prefix)}${body}/?$`), paramNames, isCatchAll };
432
+ return { pattern: new RegExp(`^${escapeRe(prefix)}${body}/?$`), paramNames, paramKinds, isCatchAll };
391
433
  }
392
- return { pattern, paramNames, isCatchAll };
434
+ return { pattern, paramNames, paramKinds, isCatchAll };
435
+ }
436
+
437
+ /** @typedef {'single' | 'catchall' | 'optional'} ParamKind */
438
+
439
+ /** @param {string} s */
440
+ function safeDecode(s) {
441
+ try { return decodeURIComponent(s); } catch { return s; }
442
+ }
443
+
444
+ /**
445
+ * Build a route's params from a pattern match, Next.js style: a dynamic
446
+ * `[id]` is a string, a catch-all `[...slug]` is a `string[]` (the tail split
447
+ * on `/`, then each piece decoded), and an EMPTY optional catch-all
448
+ * `[[...slug]]` omits its key entirely. Returns null when a required
449
+ * catch-all came back empty (no match).
450
+ * @param {{ paramNames: string[], paramKinds?: ParamKind[] }} route
451
+ * @param {RegExpExecArray} m
452
+ * @returns {Record<string, string | string[]> | null}
453
+ */
454
+ export function fillParams(route, m) {
455
+ /** @type {Record<string, string | string[]>} */
456
+ const params = {};
457
+ for (let i = 0; i < route.paramNames.length; i++) {
458
+ const name = route.paramNames[i];
459
+ const kind = route.paramKinds ? route.paramKinds[i] : 'single';
460
+ const raw = m[i + 1] || '';
461
+ if (kind === 'single') { params[name] = safeDecode(raw); continue; }
462
+ const pieces = raw.replace(/\/+$/, '').split('/').filter((p, j, all) => p !== '' || (j > 0 && j < all.length - 1)).map(safeDecode);
463
+ if (!pieces.length) {
464
+ if (kind === 'catchall') return null;
465
+ continue; // empty optional catch-all: the key is absent, as in Next
466
+ }
467
+ params[name] = pieces;
468
+ }
469
+ return params;
393
470
  }
394
471
 
395
472
  /** @param {string} s */
@@ -405,9 +482,8 @@ export function matchPage(table, pathname) {
405
482
  for (const route of table.pages) {
406
483
  const m = route.pattern.exec(pathname);
407
484
  if (!m) continue;
408
- /** @type {Record<string,string>} */
409
- const params = {};
410
- route.paramNames.forEach((n, i) => (params[n] = decodeURIComponent(m[i + 1] || '')));
485
+ const params = fillParams(route, m);
486
+ if (!params) continue;
411
487
  return { route, params };
412
488
  }
413
489
  return null;
@@ -421,10 +497,41 @@ export function matchApi(table, pathname) {
421
497
  for (const route of table.apis) {
422
498
  const m = route.pattern.exec(pathname);
423
499
  if (!m) continue;
424
- /** @type {Record<string,string>} */
425
- const params = {};
426
- route.paramNames.forEach((n, i) => (params[n] = decodeURIComponent(m[i + 1] || '')));
500
+ const params = fillParams(route, m);
501
+ if (!params) continue;
427
502
  return { route, params };
428
503
  }
429
504
  return null;
430
505
  }
506
+
507
+ /**
508
+ * The URL paths of every STATIC page route in a route table, sorted and
509
+ * deduped. A DYNAMIC route is excluded rather than guessed at: listing it would
510
+ * mean inventing param values. A route group `(group)` and a `_private` segment
511
+ * drop out of the URL, and the root `.` is `/`.
512
+ *
513
+ * @param {{ pages?: Array<{ routeDir?: string, paramNames?: string[] }> } | undefined} table a `buildRouteTable(appDir)` result
514
+ * @returns {string[]}
515
+ */
516
+ export function staticPageRoutes(table) {
517
+ const out = new Set();
518
+ for (const r of table?.pages || []) {
519
+ if (r.paramNames && r.paramNames.length) continue;
520
+ out.add(staticRoutePath(r.routeDir));
521
+ }
522
+ return [...out].sort();
523
+ }
524
+
525
+ /**
526
+ * `blog` -> `/blog`, the root `.` -> `/`. Route groups and `_private` segments
527
+ * drop, the same normalization `buildRouteTable` uses for matching.
528
+ * @param {string|undefined} routeDir
529
+ * @returns {string}
530
+ */
531
+ function staticRoutePath(routeDir) {
532
+ if (!routeDir || routeDir === '.') return '/';
533
+ const segs = routeDir
534
+ .split('/')
535
+ .filter((s) => !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_'));
536
+ return segs.length ? '/' + segs.join('/') : '/';
537
+ }
package/src/session.js CHANGED
@@ -21,8 +21,9 @@
21
21
  */
22
22
 
23
23
  import { getStore } from './cache.js';
24
- import { markDynamicAccess } from './context.js';
24
+ import { markDynamicAccess, getRequest } from './context.js';
25
25
  import { shared } from './process-shared.js';
26
+ import { devSecret } from './dev-secret.js';
26
27
 
27
28
  // -- Web Crypto helpers ------------------------------------------------------
28
29
  // Same shape as auth.js. We duplicate here rather than share a module
@@ -104,10 +105,20 @@ export class Session {
104
105
  };
105
106
  }
106
107
 
107
- /** @param {string} key @returns {unknown} */
108
+ /**
109
+ * A value, or a flash value, which reading consumes (Remix's semantics): the
110
+ * next save drops it, so a "shown once" message is shown once. It used to be
111
+ * kept and re-serialized on every save, so it never went away.
112
+ * @param {string} key @returns {unknown}
113
+ */
108
114
  get(key) {
109
115
  if (this.#destroyed) return undefined;
110
- return this.#data.get(key) ?? this.#flash.get(key);
116
+ if (this.#data.has(key)) return this.#data.get(key);
117
+ if (!this.#flash.has(key)) return undefined;
118
+ const value = this.#flash.get(key);
119
+ this.#flash.delete(key);
120
+ this.#dirty = true;
121
+ return value;
111
122
  }
112
123
 
113
124
  /** @param {string} key @param {unknown} value */
@@ -132,7 +143,8 @@ export class Session {
132
143
  }
133
144
 
134
145
  /**
135
- * Set a value that exists for one request only.
146
+ * Set a value that lasts until the next `get()` of it, as in Remix: flash it
147
+ * in an action, read it once on the page the redirect lands on.
136
148
  * @param {string} key @param {unknown} value
137
149
  */
138
150
  flash(key, value) {
@@ -316,8 +328,16 @@ const sessionMap = shared('session-map', () => new WeakMap());
316
328
  * }} [opts]
317
329
  */
318
330
  export function session(opts = {}) {
319
- const secret = opts.secret || process.env.SESSION_SECRET;
320
- if (!secret) throw new Error('session() requires secret option or SESSION_SECRET env var');
331
+ // In development and tests a missing secret falls back to a generated one
332
+ // kept in .webjs/dev-secret (dev-secret.js), so a fresh clone or worktree works.
333
+ const secret = opts.secret || process.env.SESSION_SECRET || devSecret('SESSION_SECRET');
334
+ if (!secret) {
335
+ throw new Error(
336
+ 'session() needs a secret to sign its cookie: set SESSION_SECRET in .env (webjs create writes one; '
337
+ + 'generate another with node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))") '
338
+ + 'or pass session({ secret }). Only a development or test process (NODE_ENV=development or test) gets a generated one.',
339
+ );
340
+ }
321
341
 
322
342
  const storage = opts.storage || cookieSessionStorage();
323
343
  const cookieName = opts.cookieName || 'webjs.sid';
@@ -365,20 +385,22 @@ export function session(opts = {}) {
365
385
  // ---------------------------------------------------------------------------
366
386
 
367
387
  /**
368
- * Get the Session for the current request.
388
+ * Get the Session for the current request. With no argument it reads the
389
+ * request in scope, the way `cookies()` and `headers()` do, so a page, action
390
+ * or route handler calls `getSession()`; passing `getRequest()` works too.
369
391
  *
370
392
  * ```js
371
- * const s = getSession(req);
393
+ * const s = getSession();
372
394
  * s.get('userId');
373
395
  * s.set('userId', user.id);
374
396
  * s.flash('message', 'Saved!');
375
397
  * ```
376
398
  *
377
- * @param {Request} req
399
+ * @param {Request | null} [req] defaults to the request in scope
378
400
  * @returns {Session}
379
401
  */
380
402
  export function getSession(req) {
381
- const s = sessionMap.get(req);
403
+ const s = sessionMap.get(req ?? /** @type {Request} */ (getRequest()));
382
404
  if (!s) throw new Error('getSession() called outside of session middleware');
383
405
  // A session read is per-user, so mark the request dynamic so the server HTML
384
406
  // cache excludes it even when the page declared `revalidate` (#241).
@@ -8,7 +8,7 @@
8
8
  * `window.__WEBJS_CLIENT_ROUTER__=false` flag BEFORE the deferred boot module
9
9
  * so the bundle's module-end auto-enable skips.
10
10
  *
11
- * A module-level switch (mirroring `setBasePath` / `setElisionFingerprint`) so
11
+ * A module-level switch (mirroring `setBasePath` / `setServeFingerprint`) so
12
12
  * no opt has to thread through every render path. Default true keeps every
13
13
  * existing app and test byte-identical.
14
14
  *
@@ -1,4 +1,4 @@
1
- import { collectHoistedHeadTags, hoistHeadTags, wrapHead } from './head.js';
1
+ import { collectHoistedHeadTags, hoistHeadTags, hoistOwnedStylesheets, stylesheetKeys, wrapHead } from './head.js';
2
2
 
3
3
  /**
4
4
  * Derive a layout's segment path from its file path. The path identifies
@@ -150,7 +150,9 @@ export function regionRouteKey(segmentPath, params) {
150
150
  const p = params || {};
151
151
  /** Encode one substituted value, per slash-piece (catch-alls keep their
152
152
  * literal separators; each piece is comment-safe + delimiter-safe). */
153
- const enc = (v) => String(v).split('/').map((s) => encodeURIComponent(s)).join('/');
153
+ const enc = (v) => (Array.isArray(v) ? v.join('/') : String(v)).split('/').map((s) => encodeURIComponent(s)).join('/');
154
+ /** A catch-all value present and non-empty (a string[] since Next parity). */
155
+ const has = (v) => (Array.isArray(v) ? v.length > 0 : !!v);
154
156
  /** A STATIC piece is a folder name emitted as-is EXCEPT the boundary/header
155
157
  * delimiter characters (':' would mis-split the `segment:route-key` parses,
156
158
  * ',' the have-entry list, '%' the decode); percent-encode just those so
@@ -166,12 +168,12 @@ export function regionRouteKey(segmentPath, params) {
166
168
  // the already-slash-joined tail (may be '' for an empty optional one).
167
169
  if (seg.startsWith('[[...') && seg.endsWith(']]')) {
168
170
  const v = p[seg.slice(5, -2)];
169
- if (v) out.push(enc(v));
171
+ if (has(v)) out.push(enc(v));
170
172
  continue;
171
173
  }
172
174
  if (seg.startsWith('[...') && seg.endsWith(']')) {
173
175
  const v = p[seg.slice(4, -1)];
174
- if (v) out.push(enc(v));
176
+ if (has(v)) out.push(enc(v));
175
177
  continue;
176
178
  }
177
179
  // Dynamic `[name]`.
@@ -213,14 +215,14 @@ export function regionRouteKey(segmentPath, params) {
213
215
  */
214
216
  export function wrapWithChildrenMarker(tree, segmentPath, params) {
215
217
  const routeKey = regionRouteKey(segmentPath, params);
216
- return {
217
- _$webjs: 'template',
218
- strings: [
219
- `<!--wj:children:${segmentPath}:${routeKey}-->`,
220
- `<!--/wj:children:${segmentPath}-->`,
221
- ],
222
- values: [tree],
223
- };
218
+ const strings = [
219
+ `<!--wj:children:${segmentPath}:${routeKey}-->`,
220
+ `<!--/wj:children:${segmentPath}-->`,
221
+ ];
222
+ // `raw` marks the strings as the framework's own markup, which is what the
223
+ // renderers require of a template result (a tagged template's strings have it).
224
+ Object.defineProperty(strings, 'raw', { value: strings.slice() });
225
+ return { _$webjs: 'template', strings, values: [tree] };
224
226
  }
225
227
 
226
228
  /** Internal helpers re-exported for unit testing. */
@@ -286,6 +288,28 @@ export function extractUserShell(body) {
286
288
  /** Re-export for unit testing. */
287
289
  export const _extractUserShell = extractUserShell;
288
290
 
291
+ /**
292
+ * The root layout's `<html>` / `<body>` attributes with a list of their names
293
+ * appended (`data-wj-html="lang class"`), so the client router can bring
294
+ * exactly those attributes up to date after a soft navigation or a form
295
+ * submission and leave alone any a script set in the browser (#1686). A
296
+ * changed theme cookie then shows without a reload, as it does in Next.js.
297
+ * @param {string} attrs the raw attribute text from the layout's tag
298
+ * @param {string} mark
299
+ * @returns {string}
300
+ */
301
+ export function markRootAttrs(attrs, mark) {
302
+ const re = /([^\s=/>"']+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/g;
303
+ /** @type {string[]} */
304
+ const list = [];
305
+ let m;
306
+ while ((m = re.exec(attrs))) {
307
+ const n = m[1].toLowerCase();
308
+ if (n !== mark && !list.includes(n)) list.push(n);
309
+ }
310
+ return `${attrs} ${mark}="${list.join(' ')}"`;
311
+ }
312
+
289
313
  /**
290
314
  * Inner-only variant of wrapHead: returns just the meta/title/link/script
291
315
  * tags that should live INSIDE <head>, without the surrounding
@@ -331,19 +355,27 @@ export function buildDocumentParts(body, wrapOpts) {
331
355
  if (shell) {
332
356
  const headInner = buildHeadInner(wrapOpts);
333
357
  const hoist = collectHoistedHeadTags(shell.userBody);
334
- const composedHead = [headInner, shell.userHead.trim(), hoist.tags.join('\n')]
358
+ const rootHead = [headInner, shell.userHead.trim(), hoist.tags.join('\n')]
335
359
  .filter(Boolean)
336
360
  .join('\n');
361
+ // A nested layout's or page's leading stylesheets join the head after the
362
+ // root's, so the cascade reads root to leaf (#1669).
363
+ const owned = hoistOwnedStylesheets(hoist.body, stylesheetKeys(rootHead, wrapOpts.pageUrl), wrapOpts.pageUrl);
364
+ const composedHead = [rootHead, owned.tags.join('\n')].filter(Boolean).join('\n');
337
365
  const prefix =
338
- `<!doctype html>\n<html${shell.htmlAttrs}>\n<head${shell.headAttrs}>\n` +
366
+ `<!doctype html>\n<html${markRootAttrs(shell.htmlAttrs, 'data-wj-html')}>\n<head${shell.headAttrs}>\n` +
339
367
  composedHead +
340
- `\n</head>\n<body${shell.bodyAttrs}>\n`;
341
- return { prefix, streamBody: hoist.body, closer: `\n</body>\n</html>` };
368
+ `\n</head>\n<body${markRootAttrs(shell.bodyAttrs, 'data-wj-body')}>\n`;
369
+ return { prefix, streamBody: owned.body, closer: `\n</body>\n</html>` };
342
370
  }
343
371
  // No user shell: framework owns the wrapper.
344
372
  const headHtml = wrapHead(wrapOpts);
345
373
  const { head, body: bodyOut } = hoistHeadTags(headHtml, body);
346
- return { prefix: head, streamBody: bodyOut, closer: `\n</body>\n</html>` };
374
+ const owned = hoistOwnedStylesheets(bodyOut, stylesheetKeys(head, wrapOpts.pageUrl), wrapOpts.pageUrl);
375
+ const prefix = owned.tags.length
376
+ ? head.replace('</head>', () => owned.tags.join('\n') + '\n</head>')
377
+ : head;
378
+ return { prefix, streamBody: owned.body, closer: `\n</body>\n</html>` };
347
379
  }
348
380
 
349
381
  /** Re-export for unit testing. */