@webjsdev/server 0.8.11 → 0.8.12
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/index.js +1 -0
- package/package.json +4 -2
- package/src/actions.js +9 -1
- package/src/base-path.js +149 -0
- package/src/dev.js +262 -23
- package/src/importmap.js +54 -3
- package/src/redirects.js +389 -0
- package/src/route-types.js +176 -0
- package/src/ssr.js +108 -7
- package/webjs-config.schema.json +147 -0
package/src/importmap.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { digestHex } from './crypto-utils.js';
|
|
4
4
|
import { jsonForScriptTag } from './script-tag-json.js';
|
|
5
|
+
import { withBasePath } from './base-path.js';
|
|
5
6
|
|
|
6
7
|
// Local attribute escaper. Matches ssr.js's escapeAttr (the source
|
|
7
8
|
// of truth for HTML attribute escaping in this package). Kept inline
|
|
@@ -26,6 +27,45 @@ function escapeAttr(s) {
|
|
|
26
27
|
/** @type {Record<string, string>} */
|
|
27
28
|
let _extraEntries = {};
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The normalized `webjs.basePath` for a sub-path deployment (issue #256),
|
|
32
|
+
* `''` (the default) for a root mount. When non-empty, every same-origin
|
|
33
|
+
* absolute importmap TARGET (the `/__webjs/core/*` core entries and any
|
|
34
|
+
* same-origin `/__webjs/vendor/*` local vendor target) is prefixed with it
|
|
35
|
+
* so module resolution works under the prefix. A cross-origin `https://`
|
|
36
|
+
* CDN vendor target is absolute and is left untouched. Set once at boot by
|
|
37
|
+
* `setBasePath`, which recomputes the importmap hash so `importMapHash()`
|
|
38
|
+
* stays synchronous on the hot path.
|
|
39
|
+
*
|
|
40
|
+
* @type {string}
|
|
41
|
+
*/
|
|
42
|
+
let _basePath = '';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Bind the importmap to a sub-path deployment's base path (issue #256).
|
|
46
|
+
* Called once at boot by `dev.js`. With the empty default the map is
|
|
47
|
+
* byte-identical to a root mount. The importmap hash is recomputed eagerly
|
|
48
|
+
* (like `setCoreInstall` / `setVendorEntries`) so `importMapHash()` stays
|
|
49
|
+
* synchronous on the per-request SSR path.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} basePath the normalized base path (`''` = root mount)
|
|
52
|
+
* @returns {Promise<void>}
|
|
53
|
+
*/
|
|
54
|
+
export async function setBasePath(basePath) {
|
|
55
|
+
_basePath = basePath || '';
|
|
56
|
+
_importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The active base path, for callers that prefix their own emitted URLs
|
|
61
|
+
* against the same value (ssr.js's boot specifiers, preloads, reload src).
|
|
62
|
+
*
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function basePath() {
|
|
66
|
+
return _basePath;
|
|
67
|
+
}
|
|
68
|
+
|
|
29
69
|
/**
|
|
30
70
|
* SRI integrity hashes keyed by FINAL URL (post-importmap-rewrite).
|
|
31
71
|
* Populated only when a pin file with `integrity` is present;
|
|
@@ -292,7 +332,12 @@ export function buildImportMap() {
|
|
|
292
332
|
// even though the content didn't actually change.
|
|
293
333
|
/** @type {Record<string, string>} */
|
|
294
334
|
const imports = {};
|
|
295
|
-
|
|
335
|
+
// Prefix every same-origin absolute target with the sub-path base
|
|
336
|
+
// (issue #256). `withBasePath` is a no-op when the base path is empty
|
|
337
|
+
// (so a root-mounted app's map is byte-identical) and leaves a
|
|
338
|
+
// cross-origin `https://` CDN vendor target untouched (only the
|
|
339
|
+
// framework's own `/__webjs/*` and same-origin vendor targets move).
|
|
340
|
+
for (const k of Object.keys(merged).sort()) imports[k] = withBasePath(merged[k], _basePath);
|
|
296
341
|
|
|
297
342
|
// Emit `integrity` per the importmap-integrity spec (Chrome 132+,
|
|
298
343
|
// Safari 18.4+, Firefox flagged). Browsers without support ignore
|
|
@@ -305,11 +350,17 @@ export function buildImportMap() {
|
|
|
305
350
|
// unrelated pin file edits, and leak removed URLs to the wire.
|
|
306
351
|
const out = { imports };
|
|
307
352
|
const usedUrls = new Set(Object.values(imports));
|
|
308
|
-
|
|
353
|
+
// Integrity keys are the FINAL post-rewrite URLs, so prefix a same-origin
|
|
354
|
+
// local vendor key with the base path to match its (now prefixed) imports
|
|
355
|
+
// value. A cross-origin CDN key is untouched by `withBasePath` and lines
|
|
356
|
+
// up with its unprefixed imports value.
|
|
357
|
+
const intKeys = Object.keys(_vendorIntegrity)
|
|
358
|
+
.filter(k => usedUrls.has(withBasePath(k, _basePath)))
|
|
359
|
+
.sort();
|
|
309
360
|
if (intKeys.length) {
|
|
310
361
|
/** @type {Record<string, string>} */
|
|
311
362
|
const integrity = {};
|
|
312
|
-
for (const k of intKeys) integrity[k] = _vendorIntegrity[k];
|
|
363
|
+
for (const k of intKeys) integrity[withBasePath(k, _basePath)] = _vendorIntegrity[k];
|
|
313
364
|
out.integrity = integrity;
|
|
314
365
|
}
|
|
315
366
|
return out;
|
package/src/redirects.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative permanent / temporary redirects (issue #254).
|
|
3
|
+
*
|
|
4
|
+
* webjs already ships `redirect(url)` (a per-request throw sentinel) for
|
|
5
|
+
* imperative, request-time redirects. This module adds the missing
|
|
6
|
+
* DECLARATIVE surface: a config of old-path -> new-path rules an app
|
|
7
|
+
* declares once and the framework applies at the very start of request
|
|
8
|
+
* handling, before routing / SSR / asset serving. This is what SEO wants
|
|
9
|
+
* for a moved URL, a permanent 308 (or legacy 301) so link equity
|
|
10
|
+
* transfers and search engines update their index.
|
|
11
|
+
*
|
|
12
|
+
* Config lives in `package.json` -> `webjs.redirects`, an array of
|
|
13
|
+
* `{ source, destination, permanent?, statusCode? }`, cohesive with the
|
|
14
|
+
* #232 `webjs.headers` config (same `source` URLPattern matching, same
|
|
15
|
+
* fail-safe "a malformed entry is dropped at config-load, never a
|
|
16
|
+
* throw" posture, patterns compiled ONCE at boot):
|
|
17
|
+
*
|
|
18
|
+
* "webjs": { "redirects": [
|
|
19
|
+
* { "source": "/old", "destination": "/new" },
|
|
20
|
+
* { "source": "/blog/:slug", "destination": "/posts/:slug" },
|
|
21
|
+
* { "source": "/legacy", "destination": "/", "permanent": false },
|
|
22
|
+
* { "source": "/docs", "destination": "https://docs.example.com" }
|
|
23
|
+
* ] }
|
|
24
|
+
*
|
|
25
|
+
* Status code. `permanent` defaults to true (308 Permanent Redirect);
|
|
26
|
+
* `permanent: false` is 307 (Temporary Redirect). 308 / 307 are the
|
|
27
|
+
* MODERN choice because they preserve the request method and body
|
|
28
|
+
* (a redirected POST stays a POST). The legacy 301 / 302 do not
|
|
29
|
+
* guarantee that. An app that needs a specific legacy code (e.g. a 301
|
|
30
|
+
* for a tool that only understands it) can set `statusCode` explicitly,
|
|
31
|
+
* which wins over `permanent`.
|
|
32
|
+
*
|
|
33
|
+
* Destination. A `destination` may be:
|
|
34
|
+
* - a path (`/new`), optionally referencing named groups captured by
|
|
35
|
+
* the source pattern (`/posts/:slug` filled from `/blog/:slug`),
|
|
36
|
+
* - an absolute URL (`https://docs.example.com`) for an external
|
|
37
|
+
* redirect (group substitution applies there too).
|
|
38
|
+
*
|
|
39
|
+
* Query string. The incoming query string is PRESERVED by default and
|
|
40
|
+
* appended to the destination (a destination that carries its own query
|
|
41
|
+
* is merged, the destination's keys winning). This matches Next.js's
|
|
42
|
+
* default redirect behavior.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Trailing-slash canonicalization (issue #255).
|
|
47
|
+
*
|
|
48
|
+
* A page reachable at BOTH `/about` and `/about/` is duplicate content:
|
|
49
|
+
* webjs's file router matches both (every route pattern ends with `/?$`,
|
|
50
|
+
* so the slashed and unslashed forms render IDENTICAL HTML), but search
|
|
51
|
+
* engines treat them as two URLs that split link equity, and the client
|
|
52
|
+
* router caches them under two keys. The trailing-slash policy picks ONE
|
|
53
|
+
* canonical form and 308-redirects the other to it, exactly like the
|
|
54
|
+
* `webjs.redirects` config does for a moved URL.
|
|
55
|
+
*
|
|
56
|
+
* Config lives in `package.json` -> `webjs.trailingSlash`, cohesive with
|
|
57
|
+
* `webjs.redirects` / `webjs.headers` / `webjs.csp`:
|
|
58
|
+
*
|
|
59
|
+
* "webjs": { "trailingSlash": "never" } // /about/ -> /about (recommended)
|
|
60
|
+
* "webjs": { "trailingSlash": "always" } // /about -> /about/
|
|
61
|
+
* "webjs": { "trailingSlash": "ignore" } // no canonicalization (default)
|
|
62
|
+
*
|
|
63
|
+
* Default. Absent or `"ignore"` means NO redirect (current behavior, so an
|
|
64
|
+
* existing app is unchanged). Most apps want `"never"`; it is the
|
|
65
|
+
* recommendation, but it is opt-in so adding the feature never silently
|
|
66
|
+
* starts 308-ing an app that was happy serving both forms.
|
|
67
|
+
*
|
|
68
|
+
* Rules (a redirect is a permanent 308, so the SEO equity transfers and a
|
|
69
|
+
* redirected POST stays a POST):
|
|
70
|
+
* - `never`: a path ending in `/` (other than the root `/`) redirects to
|
|
71
|
+
* the same path without the trailing slash.
|
|
72
|
+
* - `always`: a path with NO trailing slash redirects to the same path
|
|
73
|
+
* WITH one, UNLESS the last segment looks like a file (has a dot in it,
|
|
74
|
+
* e.g. `/foo.js`, `/image.png`); a file path is left alone, since
|
|
75
|
+
* `/foo.js/` is not a sensible canonical form.
|
|
76
|
+
* - The ROOT path `/` is ALWAYS left alone under either policy.
|
|
77
|
+
* - The query string and hash are preserved on the redirect.
|
|
78
|
+
* - `/__webjs/*` framework paths are exempt (handled by the caller).
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/** Permanent (308) is the canonicalization status, like a moved URL. */
|
|
82
|
+
const CANONICAL_STATUS = 308;
|
|
83
|
+
|
|
84
|
+
/** The valid `webjs.trailingSlash` policy values. */
|
|
85
|
+
const TRAILING_SLASH_POLICIES = new Set(['never', 'always', 'ignore']);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read the trailing-slash policy from the app's package.json
|
|
89
|
+
* (`webjs.trailingSlash`). Returns `'never'` / `'always'` / `'ignore'`,
|
|
90
|
+
* defaulting to `'ignore'` (no canonicalization) for an absent, malformed,
|
|
91
|
+
* or unrecognized value, so a missing or typo'd config is a no-op rather
|
|
92
|
+
* than a throw or an accidental redirect.
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} pkg parsed package.json (or any object)
|
|
95
|
+
* @returns {'never' | 'always' | 'ignore'}
|
|
96
|
+
*/
|
|
97
|
+
export function readTrailingSlashPolicy(pkg) {
|
|
98
|
+
const raw =
|
|
99
|
+
pkg &&
|
|
100
|
+
typeof pkg === 'object' &&
|
|
101
|
+
/** @type {any} */ (pkg).webjs &&
|
|
102
|
+
/** @type {any} */ (pkg).webjs.trailingSlash;
|
|
103
|
+
if (typeof raw === 'string' && TRAILING_SLASH_POLICIES.has(raw)) {
|
|
104
|
+
return /** @type {'never' | 'always' | 'ignore'} */ (raw);
|
|
105
|
+
}
|
|
106
|
+
return 'ignore';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Apply the trailing-slash canonicalization policy to an incoming request.
|
|
111
|
+
* Returns a 308 redirect Response to the canonical form when the request
|
|
112
|
+
* path is non-canonical under the policy, else null so the request falls
|
|
113
|
+
* through to normal routing (or to the declarative redirects). Runs AFTER
|
|
114
|
+
* `applyRedirects` (see the wiring in `dev.js`): an explicit `webjs.redirects`
|
|
115
|
+
* rule wins first, then the survivor is slash-canonicalized. This does NOT
|
|
116
|
+
* guarantee loop-freedom. A redirect whose `destination` CONTRADICTS the slash
|
|
117
|
+
* policy (e.g. policy `never` with `{ destination: '/x/' }`) ping-pongs forever
|
|
118
|
+
* (`/x` -> 308 `/x/` -> 308 `/x` -> ...). There is no server-side loop guard
|
|
119
|
+
* (matching `applyRedirects`), so keeping a redirect destination consistent with
|
|
120
|
+
* the policy is the app author's responsibility.
|
|
121
|
+
*
|
|
122
|
+
* Framework-internal `/__webjs/*` paths are never canonicalized (the caller
|
|
123
|
+
* also guards this, defense in depth here).
|
|
124
|
+
*
|
|
125
|
+
* SECURITY: the canonical path is built from `url.pathname` and emitted as the
|
|
126
|
+
* `Location`, so a request whose path is a NETWORK-PATH REFERENCE (begins with
|
|
127
|
+
* `//`, or `/\` which the URL parser normalizes to `//`) would otherwise emit a
|
|
128
|
+
* protocol-relative `Location` (`//attacker.com`) that the browser resolves to a
|
|
129
|
+
* FOREIGN origin, an open redirect. Such a path is not a normal route, so we
|
|
130
|
+
* REFUSE to canonicalize it (return null) and let the router 404 it, rather than
|
|
131
|
+
* emit a cross-origin redirect. The sibling `applyRedirects` avoids this by only
|
|
132
|
+
* ever emitting app-authored `destination` literals (and keeping user-controlled
|
|
133
|
+
* `:slug` captures percent-encoded so they cannot escape the origin); here the
|
|
134
|
+
* Location is derived from the request path, so the guard is on the path itself.
|
|
135
|
+
*
|
|
136
|
+
* @param {Request} req
|
|
137
|
+
* @param {'never' | 'always' | 'ignore'} policy
|
|
138
|
+
* @returns {Response | null}
|
|
139
|
+
*/
|
|
140
|
+
export function applyTrailingSlash(req, policy) {
|
|
141
|
+
if (policy !== 'never' && policy !== 'always') return null;
|
|
142
|
+
let url;
|
|
143
|
+
try {
|
|
144
|
+
url = new URL(req.url);
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const path = url.pathname;
|
|
149
|
+
// The root path is always canonical under either policy.
|
|
150
|
+
if (path === '/') return null;
|
|
151
|
+
if (path.startsWith('/__webjs/')) return null;
|
|
152
|
+
// Refuse a network-path reference (`//host`, or `/\host` normalized to
|
|
153
|
+
// `//host`): canonicalizing it would emit a protocol-relative, cross-origin
|
|
154
|
+
// Location (an open redirect). Let the router handle the weird path instead.
|
|
155
|
+
if (!isSameOriginPath(path)) return null;
|
|
156
|
+
|
|
157
|
+
/** @type {string | null} */
|
|
158
|
+
let canonical = null;
|
|
159
|
+
if (policy === 'never') {
|
|
160
|
+
// Strip a single trailing slash. (A multi-slash path like `/about//`
|
|
161
|
+
// collapses one slash per redirect; the next request re-canonicalizes,
|
|
162
|
+
// and the common single-slash case settles in one hop.)
|
|
163
|
+
if (path.endsWith('/')) canonical = path.replace(/\/+$/, '') || '/';
|
|
164
|
+
} else {
|
|
165
|
+
// policy === 'always'
|
|
166
|
+
if (!path.endsWith('/') && !lastSegmentLooksLikeFile(path)) {
|
|
167
|
+
canonical = path + '/';
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (canonical === null || canonical === path) return null;
|
|
171
|
+
|
|
172
|
+
// Preserve the query string and hash on the canonical URL.
|
|
173
|
+
const location = canonical + url.search + url.hash;
|
|
174
|
+
return new Response(null, {
|
|
175
|
+
status: CANONICAL_STATUS,
|
|
176
|
+
headers: { location: location },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether the LAST path segment looks like a file (contains a dot), e.g.
|
|
182
|
+
* `/foo.js` or `/assets/logo.png`. Such a path must NOT get a trailing
|
|
183
|
+
* slash added under the `always` policy: a file is a leaf, not a "page"
|
|
184
|
+
* directory, so `/foo.js/` is never a sensible canonical form.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} path a pathname (no query / hash)
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
function lastSegmentLooksLikeFile(path) {
|
|
190
|
+
const lastSlash = path.lastIndexOf('/');
|
|
191
|
+
const segment = path.slice(lastSlash + 1);
|
|
192
|
+
return segment.includes('.');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Whether a pathname is a SAFE same-origin path (a single leading slash, not a
|
|
197
|
+
* network-path reference). A path beginning with `//` or `/\` resolves to a
|
|
198
|
+
* foreign origin when emitted as a `Location`, so it is rejected. The URL parser
|
|
199
|
+
* normalizes a backslash to a forward slash in the pathname, so `/\evil.com` is
|
|
200
|
+
* seen here as `//evil.com`; the explicit backslash check is belt-and-braces in
|
|
201
|
+
* case a caller passes a raw, unparsed path.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} path a pathname
|
|
204
|
+
* @returns {boolean}
|
|
205
|
+
*/
|
|
206
|
+
function isSameOriginPath(path) {
|
|
207
|
+
if (path[0] !== '/') return false;
|
|
208
|
+
const second = path[1];
|
|
209
|
+
return second !== '/' && second !== '\\';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Default status for `permanent: true` (the SEO permanent redirect). */
|
|
213
|
+
const PERMANENT_STATUS = 308;
|
|
214
|
+
/** Default status for `permanent: false` (a temporary redirect). */
|
|
215
|
+
const TEMPORARY_STATUS = 307;
|
|
216
|
+
|
|
217
|
+
/** Redirect status codes a `statusCode` override may legitimately set. */
|
|
218
|
+
const ALLOWED_STATUS = new Set([301, 302, 303, 307, 308]);
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Read the redirect config from the app's package.json
|
|
222
|
+
* (`webjs.redirects`) and compile it to a cached array of rules, each
|
|
223
|
+
* pairing a URLPattern (matched on the pathname) against a destination
|
|
224
|
+
* template + resolved status. A malformed or absent config yields an
|
|
225
|
+
* empty array (no redirects), never a throw: a broken redirect config
|
|
226
|
+
* must not take the request pipeline down. Each malformed entry is
|
|
227
|
+
* DROPPED with a one-line warning, so a single typo never disables the
|
|
228
|
+
* valid rules around it.
|
|
229
|
+
*
|
|
230
|
+
* @param {unknown} pkg parsed package.json (or any object)
|
|
231
|
+
* @returns {Array<{ pattern: URLPattern, destination: string, status: number }>}
|
|
232
|
+
*/
|
|
233
|
+
export function compileRedirectRules(pkg) {
|
|
234
|
+
const raw =
|
|
235
|
+
pkg &&
|
|
236
|
+
typeof pkg === 'object' &&
|
|
237
|
+
/** @type {any} */ (pkg).webjs &&
|
|
238
|
+
/** @type {any} */ (pkg).webjs.redirects;
|
|
239
|
+
if (!Array.isArray(raw)) return [];
|
|
240
|
+
/** @type {Array<{ pattern: URLPattern, destination: string, status: number }>} */
|
|
241
|
+
const rules = [];
|
|
242
|
+
for (const entry of raw) {
|
|
243
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
244
|
+
const source = /** @type {any} */ (entry).source;
|
|
245
|
+
const destination = /** @type {any} */ (entry).destination;
|
|
246
|
+
if (typeof source !== 'string' || !source) {
|
|
247
|
+
warnDrop('source must be a non-empty string', entry);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (typeof destination !== 'string' || !destination) {
|
|
251
|
+
warnDrop('destination must be a non-empty string', entry);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
let pattern;
|
|
255
|
+
try {
|
|
256
|
+
// Match on the pathname only, like the #232 header config. A bare
|
|
257
|
+
// path string is the common Next-style usage; URLPattern treats it
|
|
258
|
+
// as the pathname component, so `:slug` / `:rest*` syntax works.
|
|
259
|
+
pattern = new URLPattern({ pathname: source });
|
|
260
|
+
} catch {
|
|
261
|
+
warnDrop(`invalid source pattern "${source}"`, entry);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const status = resolveStatus(entry);
|
|
265
|
+
if (status === null) {
|
|
266
|
+
warnDrop(`invalid statusCode on "${source}"`, entry);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
rules.push({ pattern, destination, status });
|
|
270
|
+
}
|
|
271
|
+
return rules;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resolve the redirect status for one entry. `statusCode` wins when set
|
|
276
|
+
* (must be one of the allowed redirect codes), else `permanent` chooses
|
|
277
|
+
* 308 (default true) vs 307.
|
|
278
|
+
*
|
|
279
|
+
* @param {any} entry
|
|
280
|
+
* @returns {number | null} the status, or null if `statusCode` is invalid
|
|
281
|
+
*/
|
|
282
|
+
function resolveStatus(entry) {
|
|
283
|
+
const raw = entry.statusCode;
|
|
284
|
+
if (raw !== undefined && raw !== null) {
|
|
285
|
+
const n = Number(raw);
|
|
286
|
+
if (!Number.isInteger(n) || !ALLOWED_STATUS.has(n)) return null;
|
|
287
|
+
return n;
|
|
288
|
+
}
|
|
289
|
+
// `permanent` defaults to TRUE (the SEO permanent redirect). Only an
|
|
290
|
+
// explicit `false` opts into the temporary 307.
|
|
291
|
+
return entry.permanent === false ? TEMPORARY_STATUS : PERMANENT_STATUS;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** @param {string} reason @param {unknown} entry */
|
|
295
|
+
function warnDrop(reason, entry) {
|
|
296
|
+
// eslint-disable-next-line no-console
|
|
297
|
+
console.warn(`[webjs] dropping invalid webjs.redirects entry (${reason}):`, entry);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Substitute named groups captured by the source pattern into the
|
|
302
|
+
* destination template. A `:name` token in the destination is replaced
|
|
303
|
+
* by the matching group's value (URL-pathname-encoded by URLPattern's
|
|
304
|
+
* own capture). An undefined group leaves the literal token in place
|
|
305
|
+
* (a misconfigured destination, not a crash).
|
|
306
|
+
*
|
|
307
|
+
* @param {string} destination the destination template
|
|
308
|
+
* @param {Record<string, string | undefined>} groups URLPattern exec groups
|
|
309
|
+
* @returns {string}
|
|
310
|
+
*/
|
|
311
|
+
function fillGroups(destination, groups) {
|
|
312
|
+
if (!groups) return destination;
|
|
313
|
+
// Replace `:name` tokens. The name charset matches URLPattern's
|
|
314
|
+
// (letters, digits, underscore). A token with no matching group is
|
|
315
|
+
// left untouched.
|
|
316
|
+
return destination.replace(/:([A-Za-z0-9_]+)/g, (whole, name) => {
|
|
317
|
+
const v = groups[name];
|
|
318
|
+
return v === undefined ? whole : v;
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Build the final redirect Location for a matched rule: fill named
|
|
324
|
+
* groups, then merge the incoming query string onto the destination
|
|
325
|
+
* (preserved by default, the destination's own query keys winning).
|
|
326
|
+
*
|
|
327
|
+
* @param {{ destination: string, status: number }} rule
|
|
328
|
+
* @param {Record<string, string | undefined>} groups
|
|
329
|
+
* @param {URL} url the incoming request URL
|
|
330
|
+
* @returns {string} the Location header value
|
|
331
|
+
*/
|
|
332
|
+
function buildLocation(rule, groups, url) {
|
|
333
|
+
let dest = fillGroups(rule.destination, groups);
|
|
334
|
+
const incoming = url.search; // includes the leading '?', or '' when absent
|
|
335
|
+
if (!incoming) return dest;
|
|
336
|
+
// Preserve the incoming query string. If the destination already
|
|
337
|
+
// carries its own query, merge, with the destination's keys winning
|
|
338
|
+
// (an explicit redirect target is intentional).
|
|
339
|
+
const hashIdx = dest.indexOf('#');
|
|
340
|
+
const hash = hashIdx === -1 ? '' : dest.slice(hashIdx);
|
|
341
|
+
const noHash = hashIdx === -1 ? dest : dest.slice(0, hashIdx);
|
|
342
|
+
const qIdx = noHash.indexOf('?');
|
|
343
|
+
const base = qIdx === -1 ? noHash : noHash.slice(0, qIdx);
|
|
344
|
+
const destQuery = qIdx === -1 ? '' : noHash.slice(qIdx + 1);
|
|
345
|
+
const merged = new URLSearchParams(incoming.slice(1));
|
|
346
|
+
for (const [k, v] of new URLSearchParams(destQuery)) merged.set(k, v);
|
|
347
|
+
const qs = merged.toString();
|
|
348
|
+
return base + (qs ? '?' + qs : '') + hash;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Apply the declarative redirect rules to an incoming request. Returns a
|
|
353
|
+
* redirect Response (308 / 307 / the configured status, with the
|
|
354
|
+
* computed `Location`) on the FIRST matching rule, else null so the
|
|
355
|
+
* request falls through to normal routing. Framework-internal paths
|
|
356
|
+
* (`/__webjs/*`) are never redirected.
|
|
357
|
+
*
|
|
358
|
+
* Compiled rules are passed in (built once at boot), so this is O(rules)
|
|
359
|
+
* per request with no per-request pattern compilation.
|
|
360
|
+
*
|
|
361
|
+
* @param {Request} req
|
|
362
|
+
* @param {Array<{ pattern: URLPattern, destination: string, status: number }>} rules
|
|
363
|
+
* @returns {Response | null}
|
|
364
|
+
*/
|
|
365
|
+
export function applyRedirects(req, rules) {
|
|
366
|
+
if (!rules || !rules.length) return null;
|
|
367
|
+
let url;
|
|
368
|
+
try {
|
|
369
|
+
url = new URL(req.url);
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
// Never redirect framework-internal paths (probes, the core runtime,
|
|
374
|
+
// the action endpoint, the dev reload stream). They are infrastructure,
|
|
375
|
+
// not app URLs.
|
|
376
|
+
if (url.pathname.startsWith('/__webjs/')) return null;
|
|
377
|
+
|
|
378
|
+
for (const rule of rules) {
|
|
379
|
+
const match = rule.pattern.exec({ pathname: url.pathname });
|
|
380
|
+
if (!match) continue;
|
|
381
|
+
const groups = (match.pathname && match.pathname.groups) || {};
|
|
382
|
+
const location = buildLocation(rule, groups, url);
|
|
383
|
+
return new Response(null, {
|
|
384
|
+
status: rule.status,
|
|
385
|
+
headers: { location: location },
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route-types generator (#258).
|
|
3
|
+
*
|
|
4
|
+
* `generateRouteTypes(appDir)` walks `app/` (reusing `buildRouteTable`, the
|
|
5
|
+
* one route enumerator) and emits the `.d.ts` TEXT that augments
|
|
6
|
+
* `@webjsdev/core`, narrowing the `Route` href union and the per-route
|
|
7
|
+
* `params` shape. It is the opt-in codegen behind `webjs types`; the static
|
|
8
|
+
* types in `@webjsdev/core` (`PageProps`, `LayoutProps`, `Route`, …) work
|
|
9
|
+
* without it (un-generated apps see `Route = string`).
|
|
10
|
+
*
|
|
11
|
+
* Design choices:
|
|
12
|
+
* - PAGES ONLY. A `route.{js,ts}` handler is an API endpoint, not a
|
|
13
|
+
* navigable HTML page, so its path is excluded from the navigable `Route`
|
|
14
|
+
* union. Pages (including page-action pages) are what a valid href points
|
|
15
|
+
* at.
|
|
16
|
+
* - The route KEY is the literal pattern (`/blog/[slug]`), derived from the
|
|
17
|
+
* page's directory with route groups `(group)` stripped and `_private`
|
|
18
|
+
* dirs excluded, matching `buildRouteTable`'s own URL normalization.
|
|
19
|
+
* - The optional catch-all `[[...slug]]` emits TWO `WebjsRoutes` keys (the
|
|
20
|
+
* with-segment `/docs/[[...slug]]` and the without-segment `/docs`), so a
|
|
21
|
+
* bare `/docs` and a `/docs/a/b` both type-check. Keeping the with/without
|
|
22
|
+
* split in the generator lets the pure `RoutePattern` type stay simple.
|
|
23
|
+
* - Param object shapes are known here: `[slug]` -> `{ slug: string }`,
|
|
24
|
+
* `[...rest]` -> `{ rest: string[] }`, `[[...rest]]` -> `{ rest?: string[] }`.
|
|
25
|
+
* - Deterministic: keys are sorted so re-running yields a byte-identical
|
|
26
|
+
* file (clean diffs, idempotent).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { buildRouteTable } from './router.js';
|
|
30
|
+
|
|
31
|
+
/** @param {string} seg */
|
|
32
|
+
function isUrlSegment(seg) {
|
|
33
|
+
if (seg.startsWith('(') && seg.endsWith(')')) return false; // route group
|
|
34
|
+
if (seg.startsWith('_')) return false; // private
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The literal route key for a page directory, e.g. `app/blog/[slug]/page.ts`
|
|
40
|
+
* (routeDir `blog/[slug]`) -> `/blog/[slug]`. The root page (routeDir `.`)
|
|
41
|
+
* -> `/`. Route groups and private segments are dropped from the path.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} routeDir POSIX-style, `.` for the app root.
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function routeKeyFromDir(routeDir) {
|
|
47
|
+
if (routeDir === '.' || routeDir === '') return '/';
|
|
48
|
+
const segs = routeDir.split('/').filter(isUrlSegment);
|
|
49
|
+
if (segs.length === 0) return '/';
|
|
50
|
+
return '/' + segs.join('/');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {{ name: string, kind: 'single' | 'catchAll' | 'optionalCatchAll' }} DynSeg
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Extract the dynamic segments of a route key in order.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} key A literal route key like `/blog/[slug]`.
|
|
61
|
+
* @returns {DynSeg[]}
|
|
62
|
+
*/
|
|
63
|
+
export function dynamicSegments(key) {
|
|
64
|
+
/** @type {DynSeg[]} */
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const seg of key.split('/')) {
|
|
67
|
+
if (seg.startsWith('[[...') && seg.endsWith(']]')) {
|
|
68
|
+
out.push({ name: seg.slice(5, -2), kind: 'optionalCatchAll' });
|
|
69
|
+
} else if (seg.startsWith('[...') && seg.endsWith(']')) {
|
|
70
|
+
out.push({ name: seg.slice(4, -1), kind: 'catchAll' });
|
|
71
|
+
} else if (seg.startsWith('[') && seg.endsWith(']')) {
|
|
72
|
+
out.push({ name: seg.slice(1, -1), kind: 'single' });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The TypeScript params-object literal for a route key, e.g.
|
|
80
|
+
* `{ slug: string }` / `{ rest: string[] }` / `{ slug?: string[] }`. Returns
|
|
81
|
+
* null for a static route (no entry needed in `RouteParamMap`).
|
|
82
|
+
*
|
|
83
|
+
* @param {string} key
|
|
84
|
+
* @returns {string | null}
|
|
85
|
+
*/
|
|
86
|
+
export function paramTypeForKey(key) {
|
|
87
|
+
const dyn = dynamicSegments(key);
|
|
88
|
+
if (dyn.length === 0) return null;
|
|
89
|
+
const fields = dyn.map((d) => {
|
|
90
|
+
if (d.kind === 'single') return `${d.name}: string`;
|
|
91
|
+
if (d.kind === 'catchAll') return `${d.name}: string[]`;
|
|
92
|
+
return `${d.name}?: string[]`; // optionalCatchAll
|
|
93
|
+
});
|
|
94
|
+
return `{ ${fields.join('; ')} }`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build the set of `WebjsRoutes` HREF keys for a route key. These keys drive
|
|
99
|
+
* the `Route` href union, so each must be a form the pure `RoutePattern` type
|
|
100
|
+
* can expand cleanly (it only understands the single `[x]` and catch-all
|
|
101
|
+
* `[...x]` segments, NOT the doubled `[[...x]]`). So:
|
|
102
|
+
* - A static route yields itself.
|
|
103
|
+
* - A normal dynamic route (`[x]` / `[...x]`) yields itself.
|
|
104
|
+
* - An OPTIONAL catch-all `[[...x]]` yields TWO keys: the WITHOUT-segment
|
|
105
|
+
* form (the segment elided, the `//` collapsed) so a bare path matches,
|
|
106
|
+
* and a NORMALIZED with-segment form where `[[...x]]` is rewritten to the
|
|
107
|
+
* plain catch-all `[...x]` so the deep path matches. The author-facing
|
|
108
|
+
* literal `[[...x]]` stays the `RouteParamMap` key (that is what a page
|
|
109
|
+
* passes to `PageProps`), but it is NOT a Route-union key.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} key
|
|
112
|
+
* @returns {string[]}
|
|
113
|
+
*/
|
|
114
|
+
export function webjsRoutesKeysForKey(key) {
|
|
115
|
+
if (!key.includes('[[...')) return [key];
|
|
116
|
+
// With-segment: rewrite each `[[...name]]` to the plain catch-all `[...name]`.
|
|
117
|
+
const withSeg = key.replace(/\[\[\.\.\.([^\]]+)\]\]/g, '[...$1]');
|
|
118
|
+
// Without-segment: drop the optional catch-all segment entirely.
|
|
119
|
+
let without = key.replace(/\/\[\[\.\.\.[^\]]+\]\]/g, '');
|
|
120
|
+
if (without === '') without = '/';
|
|
121
|
+
const out = new Set([withSeg]);
|
|
122
|
+
out.add(without);
|
|
123
|
+
return [...out];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Generate the augmentation `.d.ts` text for an app's routes.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} appDir The app root (the dir containing `app/`).
|
|
130
|
+
* @returns {Promise<string>}
|
|
131
|
+
*/
|
|
132
|
+
export async function generateRouteTypes(appDir) {
|
|
133
|
+
const table = await buildRouteTable(appDir);
|
|
134
|
+
|
|
135
|
+
/** @type {Set<string>} */
|
|
136
|
+
const routeKeys = new Set();
|
|
137
|
+
/** @type {Map<string, string>} */
|
|
138
|
+
const paramEntries = new Map();
|
|
139
|
+
|
|
140
|
+
for (const page of table.pages) {
|
|
141
|
+
const key = routeKeyFromDir(page.routeDir);
|
|
142
|
+
const paramType = paramTypeForKey(key);
|
|
143
|
+
if (paramType) paramEntries.set(key, paramType);
|
|
144
|
+
for (const k of webjsRoutesKeysForKey(key)) routeKeys.add(k);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sortedKeys = [...routeKeys].sort();
|
|
148
|
+
const sortedParamKeys = [...paramEntries.keys()].sort();
|
|
149
|
+
|
|
150
|
+
const routeLines = sortedKeys.map((k) => ` ${JSON.stringify(k)}: true;`);
|
|
151
|
+
const paramLines = sortedParamKeys.map(
|
|
152
|
+
(k) => ` ${JSON.stringify(k)}: ${paramEntries.get(k)};`,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// The without-segment optional-catch-all key has no dynamic segment, so it
|
|
156
|
+
// gets no RouteParamMap entry (its params are optional anyway); the
|
|
157
|
+
// with-segment key carries the `{ name?: string[] }` shape.
|
|
158
|
+
|
|
159
|
+
const out = `// AUTO-GENERATED by \`webjs types\`. Do not edit. Regenerated from app/ routes.
|
|
160
|
+
//
|
|
161
|
+
// Augments @webjsdev/core so the Route href union, navigate(), and per-route
|
|
162
|
+
// params are typed for this app. Regenerated per machine (gitignored, like
|
|
163
|
+
// Next's .next/types). Re-run \`webjs types\` after adding or removing a route.
|
|
164
|
+
import '@webjsdev/core';
|
|
165
|
+
|
|
166
|
+
declare module '@webjsdev/core' {
|
|
167
|
+
interface WebjsRoutes {
|
|
168
|
+
${routeLines.join('\n')}
|
|
169
|
+
}
|
|
170
|
+
interface RouteParamMap {
|
|
171
|
+
${paramLines.join('\n')}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
`;
|
|
175
|
+
return out;
|
|
176
|
+
}
|