@webjsdev/server 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/index.d.ts +793 -125
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +97 -15
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +269 -0
- package/src/core-exports.js +190 -0
- package/src/csrf.js +21 -3
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +177 -129
- package/src/dev/server.js +92 -33
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +2 -2
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +142 -66
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +71 -2
- package/src/testing.js +103 -21
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -101
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
425
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
320
|
-
|
|
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(
|
|
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` / `
|
|
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
|
*
|
package/src/ssr/document.js
CHANGED
|
@@ -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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
@@ -339,9 +363,9 @@ export function buildDocumentParts(body, wrapOpts) {
|
|
|
339
363
|
const owned = hoistOwnedStylesheets(hoist.body, stylesheetKeys(rootHead, wrapOpts.pageUrl), wrapOpts.pageUrl);
|
|
340
364
|
const composedHead = [rootHead, owned.tags.join('\n')].filter(Boolean).join('\n');
|
|
341
365
|
const prefix =
|
|
342
|
-
`<!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` +
|
|
343
367
|
composedHead +
|
|
344
|
-
`\n</head>\n<body${shell.bodyAttrs}>\n`;
|
|
368
|
+
`\n</head>\n<body${markRootAttrs(shell.bodyAttrs, 'data-wj-body')}>\n`;
|
|
345
369
|
return { prefix, streamBody: owned.body, closer: `\n</body>\n</html>` };
|
|
346
370
|
}
|
|
347
371
|
// No user shell: framework owns the wrapper.
|