@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/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { startServer, createRequestHandler } from './src/dev.js';
|
|
|
2
2
|
export { assertNodeVersion, checkNodeVersion, requiredNodeMajor, parseMajor, parseRequiredMajor } from './src/node-version.js';
|
|
3
3
|
export { validateEnv, formatEnvErrors, loadEnvSchema, applyEnvValidation } from './src/env-schema.js';
|
|
4
4
|
export { buildRouteTable, matchPage, matchApi } from './src/router.js';
|
|
5
|
+
export { generateRouteTypes } from './src/route-types.js';
|
|
5
6
|
export { ssrPage, ssrNotFound } from './src/ssr.js';
|
|
6
7
|
export { handleApi } from './src/api.js';
|
|
7
8
|
export {
|
package/package.json
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./index.js",
|
|
9
|
-
"./check": "./src/check.js"
|
|
9
|
+
"./check": "./src/check.js",
|
|
10
|
+
"./webjs-config.schema.json": "./webjs-config.schema.json"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"index.js",
|
|
13
14
|
"src",
|
|
15
|
+
"webjs-config.schema.json",
|
|
14
16
|
"README.md"
|
|
15
17
|
],
|
|
16
18
|
"dependencies": {
|
package/src/actions.js
CHANGED
|
@@ -9,6 +9,8 @@ import { getSerializer } from './serializer.js';
|
|
|
9
9
|
import { resolveOrigin } from './cors.js';
|
|
10
10
|
import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
|
|
11
11
|
import { getBodyLimits } from './context.js';
|
|
12
|
+
import { basePath } from './importmap.js';
|
|
13
|
+
import { withBasePath } from './base-path.js';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* The JSON / RPC body cap in effect for the current request: the per-request
|
|
@@ -257,6 +259,12 @@ export async function serveActionStub(idx, absFile) {
|
|
|
257
259
|
if (typeof mod.default === 'function' && !fnNames.includes('default')) {
|
|
258
260
|
fnNames.push('default');
|
|
259
261
|
}
|
|
262
|
+
// The RPC endpoint is a framework-emitted same-origin URL, so it must
|
|
263
|
+
// carry the basePath prefix under a sub-path deploy (#256), exactly like
|
|
264
|
+
// the importmap targets and the boot module specifiers. Without this the
|
|
265
|
+
// stub would POST to a bare /__webjs/action/... that the ingress strip
|
|
266
|
+
// 404s, breaking every server action when webjs.basePath is set.
|
|
267
|
+
const actionUrl = withBasePath(`/__webjs/action/${hash}/`, basePath());
|
|
260
268
|
const body = `// webjs: generated server-action stub for ${relative(idx.appDir, absFile)}\n` +
|
|
261
269
|
`import { stringify as __wjStringify, parse as __wjParse } from '@webjsdev/core';\n` +
|
|
262
270
|
`function __csrf() {\n` +
|
|
@@ -265,7 +273,7 @@ export async function serveActionStub(idx, absFile) {
|
|
|
265
273
|
`}\n` +
|
|
266
274
|
`async function __rpc(fn, args) {\n` +
|
|
267
275
|
` const body = await __wjStringify(args);\n` +
|
|
268
|
-
` const res = await fetch(${JSON.stringify(
|
|
276
|
+
` const res = await fetch(${JSON.stringify(actionUrl)} + fn, {\n` +
|
|
269
277
|
` method: 'POST',\n` +
|
|
270
278
|
` headers: {\n` +
|
|
271
279
|
` 'content-type': ${JSON.stringify(RPC_CONTENT_TYPE)},\n` +
|
package/src/base-path.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-path deployment support: `webjs.basePath` (issue #256).
|
|
3
|
+
*
|
|
4
|
+
* An app deployed under a sub-path (example.com/app/) behind a proxy that
|
|
5
|
+
* does NOT strip the prefix is broken without this: every
|
|
6
|
+
* framework-emitted absolute URL (the importmap targets, the modulepreload
|
|
7
|
+
* hints, the boot script's `/__webjs/core/*` specifiers and per-route
|
|
8
|
+
* module URLs, the dev reload `src`) assumes the app sits at the origin
|
|
9
|
+
* root, so they point at `/__webjs/core/*` instead of
|
|
10
|
+
* `/app/__webjs/core/*`, module resolution 404s, and the page never
|
|
11
|
+
* hydrates. `createRequestHandler` explicitly targets embedding, where a
|
|
12
|
+
* sub-path mount is the norm.
|
|
13
|
+
*
|
|
14
|
+
* The model is strip-at-ingress + prefix-on-emit, two seams only:
|
|
15
|
+
*
|
|
16
|
+
* 1. STRIP AT INGRESS. At the very start of request handling, when the
|
|
17
|
+
* request pathname starts with the basePath, strip it so all
|
|
18
|
+
* downstream logic (route matching, the `/__webjs/*` checks, the
|
|
19
|
+
* source-file gate, redirects, trailing-slash) sees a ROOT-relative
|
|
20
|
+
* path and works UNCHANGED. This single strip is why the rest of the
|
|
21
|
+
* framework needs no per-site changes. `stripBasePath` does the path
|
|
22
|
+
* computation; `dev.js` rewrites the Request URL with it.
|
|
23
|
+
*
|
|
24
|
+
* 2. PREFIX ON EMIT. Every framework-emitted same-origin absolute URL
|
|
25
|
+
* (begins with a single `/`) gets the basePath prepended via the one
|
|
26
|
+
* `withBasePath` helper. Applied to the importmap targets
|
|
27
|
+
* (`importmap.js`), the modulepreload hrefs + boot module specifiers +
|
|
28
|
+
* dev reload `src` (`ssr.js`). A cross-origin URL (a `https://` CDN
|
|
29
|
+
* vendor target) is absolute and is left untouched.
|
|
30
|
+
*
|
|
31
|
+
* Empty basePath (the default) makes both seams pure no-ops, so an
|
|
32
|
+
* unconfigured app is byte-identical to before this feature. That
|
|
33
|
+
* invariant is the #1 risk and is guarded differentially in the tests.
|
|
34
|
+
*
|
|
35
|
+
* OUT OF SCOPE (a documented follow-up, the same boundary Next draws):
|
|
36
|
+
* rewriting AUTHOR-written `<a href="/about">` links and client-router
|
|
37
|
+
* navigation prefixing. This module covers framework-emitted URLs and the
|
|
38
|
+
* ingress match only.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalize a raw `webjs.basePath` value to the canonical internal form:
|
|
43
|
+
* either `''` (the no-op default) or a string with exactly one leading
|
|
44
|
+
* `/` and no trailing `/`. So `'app'`, `'/app'`, and `'/app/'` all map to
|
|
45
|
+
* `'/app'`, and a nested `'/foo/bar'` is preserved.
|
|
46
|
+
*
|
|
47
|
+
* Empty / undefined / non-string / `'/'` all map to `''` (no base path).
|
|
48
|
+
* A value that cannot be a safe, single-origin path prefix is rejected to
|
|
49
|
+
* `''`: anything containing `..` (path traversal), a protocol (`://`), a
|
|
50
|
+
* backslash, whitespace, or a network-path `//host` reference. So a typo
|
|
51
|
+
* or a hostile value fails safe to "no base path" rather than poisoning
|
|
52
|
+
* every emitted URL.
|
|
53
|
+
*
|
|
54
|
+
* @param {unknown} raw the configured value
|
|
55
|
+
* @returns {string} `''` or `/segment[/segment...]`
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeBasePath(raw) {
|
|
58
|
+
if (typeof raw !== 'string') return '';
|
|
59
|
+
let v = raw.trim();
|
|
60
|
+
if (v === '' || v === '/') return '';
|
|
61
|
+
// Reject anything that is not a plain same-origin path prefix.
|
|
62
|
+
if (v.includes('..')) return '';
|
|
63
|
+
if (v.includes('://')) return '';
|
|
64
|
+
if (v.includes('\\')) return '';
|
|
65
|
+
if (/\s/.test(v)) return '';
|
|
66
|
+
// Reject a network-path reference (`//host`) BEFORE collapsing leading
|
|
67
|
+
// slashes: such a value would emit a protocol-relative, cross-origin URL
|
|
68
|
+
// once prefixed (an open redirect / origin escape), so it fails safe to
|
|
69
|
+
// "no base path" rather than being collapsed to `/host`.
|
|
70
|
+
if (v.startsWith('//')) return '';
|
|
71
|
+
// Ensure exactly one leading slash.
|
|
72
|
+
v = '/' + v.replace(/^\/+/, '');
|
|
73
|
+
// Strip any trailing slash(es).
|
|
74
|
+
v = v.replace(/\/+$/, '');
|
|
75
|
+
// A leading-slash-only value collapses to '' here (already handled as
|
|
76
|
+
// '/' above, but guard a value like '//' that slipped a different path).
|
|
77
|
+
if (v === '' || v === '/') return '';
|
|
78
|
+
return v;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read and normalize the `webjs.basePath` config from a parsed
|
|
83
|
+
* package.json (or any object). Pure; `dev.js` wraps it with the
|
|
84
|
+
* package.json read like the other `webjs.*` readers.
|
|
85
|
+
*
|
|
86
|
+
* @param {unknown} pkg parsed package.json (or any object)
|
|
87
|
+
* @returns {string} the normalized base path (`''` when unset)
|
|
88
|
+
*/
|
|
89
|
+
export function readBasePath(pkg) {
|
|
90
|
+
const raw =
|
|
91
|
+
pkg &&
|
|
92
|
+
typeof pkg === 'object' &&
|
|
93
|
+
/** @type {any} */ (pkg).webjs &&
|
|
94
|
+
/** @type {any} */ (pkg).webjs.basePath;
|
|
95
|
+
return normalizeBasePath(raw);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Prefix a framework-emitted same-origin absolute URL with the base path.
|
|
100
|
+
* Returns the URL unchanged when basePath is empty (the no-op default) or
|
|
101
|
+
* when the URL is not a same-origin absolute path (a cross-origin
|
|
102
|
+
* `https://` CDN target, a protocol-relative `//host` reference, or a
|
|
103
|
+
* relative URL), so only the framework's own `/`-rooted paths are moved.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} url the URL to (maybe) prefix
|
|
106
|
+
* @param {string} basePath the normalized base path (`''` = no-op)
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
export function withBasePath(url, basePath) {
|
|
110
|
+
if (!basePath) return url;
|
|
111
|
+
if (typeof url !== 'string') return url;
|
|
112
|
+
// Only a single-leading-slash same-origin path is prefixed. A
|
|
113
|
+
// protocol-relative `//host` or an absolute `scheme://` URL is left
|
|
114
|
+
// alone (a vendor CDN target), as is a relative URL.
|
|
115
|
+
if (url[0] !== '/' || url[1] === '/') return url;
|
|
116
|
+
return basePath + url;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Compute the root-relative pathname for an incoming request pathname
|
|
121
|
+
* under the base path (the ingress strip). Returns:
|
|
122
|
+
* - the stripped, root-relative pathname (always begins with `/`) when
|
|
123
|
+
* the request is for this app, OR
|
|
124
|
+
* - `null` when the request path is NOT under the base path, so the
|
|
125
|
+
* caller can 404 it (the request is not for this mounted app).
|
|
126
|
+
*
|
|
127
|
+
* Mapping: `<basePath>` and `<basePath>/` both map to the root `/`.
|
|
128
|
+
* `<basePath>/foo` maps to `/foo`. A path that merely shares a prefix but
|
|
129
|
+
* is not a real segment boundary (e.g. `/application` under basePath
|
|
130
|
+
* `/app`) is NOT under the base path and returns null. When basePath is
|
|
131
|
+
* empty this is a pure pass-through (returns the input unchanged).
|
|
132
|
+
*
|
|
133
|
+
* @param {string} pathname the incoming request pathname (begins with `/`)
|
|
134
|
+
* @param {string} basePath the normalized base path (`''` = no-op)
|
|
135
|
+
* @returns {string | null}
|
|
136
|
+
*/
|
|
137
|
+
export function stripBasePath(pathname, basePath) {
|
|
138
|
+
if (!basePath) return pathname;
|
|
139
|
+
if (pathname === basePath) return '/';
|
|
140
|
+
const withSlash = basePath + '/';
|
|
141
|
+
if (pathname.startsWith(withSlash)) {
|
|
142
|
+
// Slice off the base path; keep the leading slash of the remainder.
|
|
143
|
+
const rest = pathname.slice(basePath.length);
|
|
144
|
+
return rest || '/';
|
|
145
|
+
}
|
|
146
|
+
// Not under the base path (`/application` vs basePath `/app`, or an
|
|
147
|
+
// unrelated path): not this app.
|
|
148
|
+
return null;
|
|
149
|
+
}
|
package/src/dev.js
CHANGED
|
@@ -47,6 +47,7 @@ process.emitWarning = function (warning, type, code, ctor) {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
import { buildRouteTable, matchPage, matchApi } from './router.js';
|
|
50
|
+
import { generateRouteTypes } from './route-types.js';
|
|
50
51
|
import { ssrPage, ssrNotFound } from './ssr.js';
|
|
51
52
|
import { loadPageAction, runPageAction } from './page-action.js';
|
|
52
53
|
import { handleApi } from './api.js';
|
|
@@ -112,9 +113,16 @@ function resolveRequestId(req) {
|
|
|
112
113
|
function shouldAccessLog(pathname) {
|
|
113
114
|
return !pathname.startsWith('/__webjs/');
|
|
114
115
|
}
|
|
115
|
-
import { setVendorEntries, setCoreInstall, publishBuildId } from './importmap.js';
|
|
116
|
+
import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath } from './importmap.js';
|
|
117
|
+
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
|
|
116
118
|
import { urlFromRequest } from './forwarded.js';
|
|
117
119
|
import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
|
|
120
|
+
import {
|
|
121
|
+
compileRedirectRules,
|
|
122
|
+
applyRedirects,
|
|
123
|
+
readTrailingSlashPolicy,
|
|
124
|
+
applyTrailingSlash,
|
|
125
|
+
} from './redirects.js';
|
|
118
126
|
import { readBodyLimits, computeServerTimeouts } from './body-limit.js';
|
|
119
127
|
import { applyConditionalGet, BUFFERED_MARKER } from './conditional-get.js';
|
|
120
128
|
import { commitHtmlCache } from './html-cache.js';
|
|
@@ -261,6 +269,61 @@ export async function readHeaderRules(appDir) {
|
|
|
261
269
|
}
|
|
262
270
|
}
|
|
263
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Read the declarative redirect config (`webjs.redirects`) from the app's
|
|
274
|
+
* package.json and compile it to URLPattern rules (issue #254). A missing,
|
|
275
|
+
* malformed, or unreadable config yields an empty rule set (no redirects),
|
|
276
|
+
* never a throw. Patterns are compiled ONCE here at boot, not per request.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} appDir
|
|
279
|
+
* @returns {Promise<ReturnType<typeof compileRedirectRules>>}
|
|
280
|
+
*/
|
|
281
|
+
export async function readRedirectRules(appDir) {
|
|
282
|
+
try {
|
|
283
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
284
|
+
return compileRedirectRules(pkg);
|
|
285
|
+
} catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Read the trailing-slash policy (`webjs.trailingSlash`) from the app's
|
|
292
|
+
* package.json (issue #255). A missing, malformed, or unreadable config
|
|
293
|
+
* yields `'ignore'` (no canonicalization), never a throw, so an
|
|
294
|
+
* unconfigured app is unchanged.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} appDir
|
|
297
|
+
* @returns {Promise<'never' | 'always' | 'ignore'>}
|
|
298
|
+
*/
|
|
299
|
+
export async function readTrailingSlashFromApp(appDir) {
|
|
300
|
+
try {
|
|
301
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
302
|
+
return readTrailingSlashPolicy(pkg);
|
|
303
|
+
} catch {
|
|
304
|
+
return 'ignore';
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Read the sub-path base path (`webjs.basePath`) from the app's
|
|
310
|
+
* package.json (issue #256). A missing, malformed, or unreadable config
|
|
311
|
+
* yields `''` (root mount), never a throw, so an unconfigured app is
|
|
312
|
+
* byte-identical to before this feature. Normalized to `''` or
|
|
313
|
+
* `/segment[/segment...]` by `readBasePath`.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} appDir
|
|
316
|
+
* @returns {Promise<string>}
|
|
317
|
+
*/
|
|
318
|
+
export async function readBasePathFromApp(appDir) {
|
|
319
|
+
try {
|
|
320
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
321
|
+
return readBasePath(pkg);
|
|
322
|
+
} catch {
|
|
323
|
+
return '';
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
264
327
|
/**
|
|
265
328
|
* Read the CSP config (`webjs.csp`) from the app's package.json and
|
|
266
329
|
* normalize it (issue #233). A missing, malformed, or unreadable config
|
|
@@ -380,6 +443,15 @@ export async function createRequestHandler(opts) {
|
|
|
380
443
|
logger.error?.('[webjs] onError hook threw (ignored)', { err: String(e) });
|
|
381
444
|
}
|
|
382
445
|
}
|
|
446
|
+
// Sub-path deployment base path (issue #256), read once from the app's
|
|
447
|
+
// package.json `webjs.basePath` and bound into the importmap builder BEFORE
|
|
448
|
+
// setCoreInstall / setVendorEntries so every importmap target (and the
|
|
449
|
+
// recomputed hash) reflects the prefix. Empty (the default) makes both the
|
|
450
|
+
// ingress strip and the emit-side prefix pure no-ops, so an unconfigured app
|
|
451
|
+
// is byte-identical to before this feature.
|
|
452
|
+
const basePathValue = await readBasePathFromApp(appDir);
|
|
453
|
+
await setBasePath(basePathValue);
|
|
454
|
+
|
|
383
455
|
const coreDir = locateCoreDir(appDir);
|
|
384
456
|
// Switch the importmap between dist/ bundles and src/ per-file
|
|
385
457
|
// URLs depending on whether the resolved @webjsdev/core install
|
|
@@ -446,11 +518,53 @@ export async function createRequestHandler(opts) {
|
|
|
446
518
|
// Hints, and WebSocket lookups need it available before the first request.
|
|
447
519
|
const routeTable = await buildRouteTable(appDir);
|
|
448
520
|
|
|
521
|
+
// Emit `.webjs/routes.d.ts` (typed Route union + per-route params, #258) in
|
|
522
|
+
// dev so an editor's tsserver always has up-to-date route types without the
|
|
523
|
+
// developer remembering to run `webjs types`. Best-effort and fire-and-
|
|
524
|
+
// forget: a failure logs and never blocks boot. Re-emitted after each route
|
|
525
|
+
// rebuild (see doRebuild) so adding/removing a route refreshes the types.
|
|
526
|
+
/** @returns {Promise<void>} */
|
|
527
|
+
async function emitRouteTypes() {
|
|
528
|
+
try {
|
|
529
|
+
const { mkdir, writeFile, rename } = await import('node:fs/promises');
|
|
530
|
+
const text = await generateRouteTypes(appDir);
|
|
531
|
+
const outDir = join(appDir, '.webjs');
|
|
532
|
+
await mkdir(outDir, { recursive: true });
|
|
533
|
+
// Write to a temp sibling then rename, so tsserver (which reads this
|
|
534
|
+
// file) never observes a half-written body if two rebuilds race. rename
|
|
535
|
+
// is atomic within the same dir. Both paths sit under the watcher-ignored
|
|
536
|
+
// .webjs/, so neither the temp write nor the rename re-triggers a rebuild.
|
|
537
|
+
const dest = join(outDir, 'routes.d.ts');
|
|
538
|
+
const tmp = join(outDir, `routes.d.ts.${process.pid}.tmp`);
|
|
539
|
+
await writeFile(tmp, text);
|
|
540
|
+
await rename(tmp, dest);
|
|
541
|
+
} catch (e) {
|
|
542
|
+
logger.warn?.(`[webjs] could not write .webjs/routes.d.ts (route types): ${e?.message || e}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (dev) void emitRouteTypes();
|
|
546
|
+
|
|
449
547
|
// Per-path response-header rules (issue #232), read once from the
|
|
450
548
|
// app's package.json `webjs.headers`. Static config, so no rebuild
|
|
451
549
|
// re-read; the secure defaults need no config and apply regardless.
|
|
452
550
|
const headerRules = await readHeaderRules(appDir);
|
|
453
551
|
|
|
552
|
+
// Declarative redirect rules (issue #254), read once from the app's
|
|
553
|
+
// package.json `webjs.redirects` and compiled to URLPattern rules at
|
|
554
|
+
// boot (never per request). Empty when unconfigured, so an app with no
|
|
555
|
+
// redirects is unchanged. Applied at the very start of request handling,
|
|
556
|
+
// before routing / SSR / asset serving, so a moved URL returns a 308/307
|
|
557
|
+
// immediately.
|
|
558
|
+
const redirectRules = await readRedirectRules(appDir);
|
|
559
|
+
|
|
560
|
+
// Trailing-slash policy (issue #255), read once from the app's package.json
|
|
561
|
+
// `webjs.trailingSlash`. Default `'ignore'` (no canonicalization), so an
|
|
562
|
+
// unconfigured app is unchanged; `'never'` (recommended) strips a trailing
|
|
563
|
+
// slash and `'always'` adds one, each via a 308 to the canonical form.
|
|
564
|
+
// Applied in produce() AFTER the declarative redirects, so an explicit
|
|
565
|
+
// redirect rule wins first and the two never loop.
|
|
566
|
+
const trailingSlashPolicy = await readTrailingSlashFromApp(appDir);
|
|
567
|
+
|
|
454
568
|
// CSP config (issue #233), read once from the app's package.json
|
|
455
569
|
// `webjs.csp`. OFF by default: when disabled no nonce is minted and no
|
|
456
570
|
// Content-Security-Policy header is set, so an unconfigured app is
|
|
@@ -715,6 +829,10 @@ export async function createRequestHandler(opts) {
|
|
|
715
829
|
// The route table is the only eager artifact (cheap directory scan); rebuild
|
|
716
830
|
// it so routing reflects added/removed route files immediately.
|
|
717
831
|
state.routeTable = await buildRouteTable(appDir);
|
|
832
|
+
// Refresh the generated route types (#258) so adding/removing a route file
|
|
833
|
+
// updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
|
|
834
|
+
// best-effort (see emitRouteTypes).
|
|
835
|
+
if (dev) void emitRouteTypes();
|
|
718
836
|
clearVendorCache();
|
|
719
837
|
state.tsCache.clear();
|
|
720
838
|
// Invalidate the lazy analysis; the next request rebuilds the graph,
|
|
@@ -762,6 +880,19 @@ export async function createRequestHandler(opts) {
|
|
|
762
880
|
|
|
763
881
|
let pathname = '/';
|
|
764
882
|
try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
|
|
883
|
+
// Sub-path deployment (issue #256): the per-path response-header rules
|
|
884
|
+
// (`webjs.headers`) author their `source` patterns app-root-relative,
|
|
885
|
+
// exactly like `webjs.redirects` / `webjs.trailingSlash` (which the
|
|
886
|
+
// ingress strip in produce() already sees root-relative). So match the
|
|
887
|
+
// header rules against the STRIPPED path too, keeping the whole config
|
|
888
|
+
// surface consistent under a base path. `pathname` itself (used for the
|
|
889
|
+
// access log) stays the RAW path the client hit. No-op when basePath is
|
|
890
|
+
// empty or the request is not under it.
|
|
891
|
+
let headerPathname = pathname;
|
|
892
|
+
if (basePathValue) {
|
|
893
|
+
const s = stripBasePath(pathname, basePathValue);
|
|
894
|
+
if (s !== null) headerPathname = s;
|
|
895
|
+
}
|
|
765
896
|
const startedAt = performance.now();
|
|
766
897
|
|
|
767
898
|
let res;
|
|
@@ -788,7 +919,7 @@ export async function createRequestHandler(opts) {
|
|
|
788
919
|
// Applied to every served response (documents, assets, the core
|
|
789
920
|
// runtime, probes), since the defaults are universally safe.
|
|
790
921
|
let merged = applySecurityHeaders(res, {
|
|
791
|
-
pathname,
|
|
922
|
+
pathname: headerPathname,
|
|
792
923
|
https: webRequestIsHttps(req),
|
|
793
924
|
prod: !dev,
|
|
794
925
|
rules: headerRules,
|
|
@@ -826,6 +957,14 @@ export async function createRequestHandler(opts) {
|
|
|
826
957
|
// stripped here regardless. Best-effort: a store failure is swallowed.
|
|
827
958
|
try {
|
|
828
959
|
const reqUrl = new URL(req.url);
|
|
960
|
+
// Sub-path deployment (issue #256): the HTML cache READ (in ssrPage)
|
|
961
|
+
// and `revalidatePath` both key on the app-root-relative path, so key
|
|
962
|
+
// the WRITE on the stripped path too, or a cached page would never
|
|
963
|
+
// hit. No-op when basePath is empty / the path is not under it.
|
|
964
|
+
if (basePathValue) {
|
|
965
|
+
const s = stripBasePath(reqUrl.pathname, basePathValue);
|
|
966
|
+
if (s !== null) reqUrl.pathname = s;
|
|
967
|
+
}
|
|
829
968
|
merged = await commitHtmlCache(req, merged, reqUrl);
|
|
830
969
|
} catch { /* never let the cache write crash the response */ }
|
|
831
970
|
|
|
@@ -847,7 +986,10 @@ export async function createRequestHandler(opts) {
|
|
|
847
986
|
// duration / requestId (no bodies, no secrets). Suppressed for the
|
|
848
987
|
// framework's own /__webjs/* probe + static traffic so it does not spam.
|
|
849
988
|
// Best-effort: a logger that throws must not take the response down.
|
|
850
|
-
|
|
989
|
+
// Use the STRIPPED path for the suppression check (issue #256) so a
|
|
990
|
+
// framework probe at `<basePath>/__webjs/*` is suppressed just like the
|
|
991
|
+
// root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL.
|
|
992
|
+
if (shouldAccessLog(headerPathname)) {
|
|
851
993
|
try {
|
|
852
994
|
logger.info?.('request', {
|
|
853
995
|
requestId: reqId,
|
|
@@ -866,6 +1008,69 @@ export async function createRequestHandler(opts) {
|
|
|
866
1008
|
/** @param {Request} req */
|
|
867
1009
|
function produce(req) {
|
|
868
1010
|
return (async () => {
|
|
1011
|
+
// Sub-path deployment ingress strip (issue #256). When `webjs.basePath`
|
|
1012
|
+
// is set and the request path is under it, STRIP the prefix and rewrite
|
|
1013
|
+
// the Request so EVERYTHING downstream (redirects, trailing-slash, the
|
|
1014
|
+
// probes, the `/__webjs/*` checks, the source-file gate, route matching,
|
|
1015
|
+
// SSR) sees a ROOT-relative path and works UNCHANGED. This single strip
|
|
1016
|
+
// is why the rest of the framework needs no per-site changes. A request
|
|
1017
|
+
// whose path is NOT under the base path is not for this mounted app, so
|
|
1018
|
+
// return a 404 (the safe default for a mounted app). Empty basePath (the
|
|
1019
|
+
// default) is a pure pass-through, so an unconfigured app is unchanged.
|
|
1020
|
+
if (basePathValue) {
|
|
1021
|
+
let reqUrl;
|
|
1022
|
+
try { reqUrl = new URL(req.url); } catch { reqUrl = null; }
|
|
1023
|
+
if (reqUrl) {
|
|
1024
|
+
const stripped = stripBasePath(reqUrl.pathname, basePathValue);
|
|
1025
|
+
if (stripped === null) {
|
|
1026
|
+
// Not under the base path: this request is not for this app.
|
|
1027
|
+
return new Response('Not found', {
|
|
1028
|
+
status: 404,
|
|
1029
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
if (stripped !== reqUrl.pathname) {
|
|
1033
|
+
reqUrl.pathname = stripped;
|
|
1034
|
+
// Rewrite the Request with the stripped URL, preserving method,
|
|
1035
|
+
// headers, and body. `duplex: 'half'` is required by the spec when
|
|
1036
|
+
// a body stream is present on a non-GET/HEAD request.
|
|
1037
|
+
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
1038
|
+
req = new Request(
|
|
1039
|
+
reqUrl.toString(),
|
|
1040
|
+
/** @type {any} */ ({
|
|
1041
|
+
method: req.method,
|
|
1042
|
+
headers: req.headers,
|
|
1043
|
+
body: hasBody ? req.body : undefined,
|
|
1044
|
+
duplex: hasBody ? 'half' : undefined,
|
|
1045
|
+
redirect: req.redirect,
|
|
1046
|
+
signal: req.signal,
|
|
1047
|
+
}),
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Declarative redirects (issue #254): apply the configured old-path ->
|
|
1054
|
+
// new-path rules at the VERY START of request handling, before the
|
|
1055
|
+
// probes, routing, SSR, or asset serving. A matched source returns a
|
|
1056
|
+
// 308 (permanent, the SEO default) / 307 (temporary) / configured
|
|
1057
|
+
// status immediately, so a moved URL never reaches the router.
|
|
1058
|
+
// `applyRedirects` skips /__webjs/* itself, so the framework probes /
|
|
1059
|
+
// runtime below are never redirected. The secure-header + conditional-GET
|
|
1060
|
+
// funnel in handle() still wraps this Response, like any other.
|
|
1061
|
+
const redirectResp = applyRedirects(req, redirectRules);
|
|
1062
|
+
if (redirectResp) return redirectResp;
|
|
1063
|
+
|
|
1064
|
+
// Trailing-slash canonicalization (issue #255): after the explicit
|
|
1065
|
+
// redirects above (so an explicit rule wins first and the two never
|
|
1066
|
+
// form a loop), 308-redirect a non-canonical path to the policy's
|
|
1067
|
+
// canonical form (`never` strips a trailing slash, `always` adds one).
|
|
1068
|
+
// Default `'ignore'` is a no-op. The root `/` and file paths are
|
|
1069
|
+
// exempt; `/__webjs/*` is exempt too (defense in depth, the redirects
|
|
1070
|
+
// above already skip it). The funnel in handle() still wraps this.
|
|
1071
|
+
const slashResp = applyTrailingSlash(req, trailingSlashPolicy);
|
|
1072
|
+
if (slashResp) return slashResp;
|
|
1073
|
+
|
|
869
1074
|
// Health and readiness probes are answered BEFORE ensureReady so a probe
|
|
870
1075
|
// never blocks on the analysis. `/__webjs/health` is liveness (the
|
|
871
1076
|
// process is up and accepting connections). `/__webjs/ready` is 503 until
|
|
@@ -950,14 +1155,25 @@ export async function createRequestHandler(opts) {
|
|
|
950
1155
|
* BEFORE running SSR: resolves a pathname to its page-route module URLs
|
|
951
1156
|
* without loading them. Returns null for non-page paths.
|
|
952
1157
|
*
|
|
1158
|
+
* Sub-path deployment (issue #256): the HTTP layer passes the RAW request
|
|
1159
|
+
* pathname (still carrying the base path, since the ingress strip happens
|
|
1160
|
+
* inside `produce`, not here), so strip it for route matching and prefix
|
|
1161
|
+
* the emitted module URLs so the early-hint preloads resolve under the
|
|
1162
|
+
* prefix. A path not under the base path yields null (no hints).
|
|
1163
|
+
*
|
|
953
1164
|
* @param {string} pathname
|
|
954
1165
|
*/
|
|
955
1166
|
function routeFor(pathname) {
|
|
956
|
-
const
|
|
1167
|
+
const matchPathname = basePathValue
|
|
1168
|
+
? stripBasePath(pathname, basePathValue)
|
|
1169
|
+
: pathname;
|
|
1170
|
+
if (matchPathname === null) return null;
|
|
1171
|
+
const page = matchPage(state.routeTable, matchPathname);
|
|
957
1172
|
if (!page) return null;
|
|
958
1173
|
const moduleUrls = [page.route.file, ...page.route.layouts].map((f) => {
|
|
959
1174
|
let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
|
|
960
|
-
|
|
1175
|
+
const url = rel.split('\\').join('/').replace(/^\/?/, '/');
|
|
1176
|
+
return withBasePath(url, basePathValue);
|
|
961
1177
|
});
|
|
962
1178
|
return { moduleUrls };
|
|
963
1179
|
}
|
|
@@ -997,6 +1213,25 @@ export async function createRequestHandler(opts) {
|
|
|
997
1213
|
* etc.) sitting in front of this process. See the deployment docs for
|
|
998
1214
|
* the recommended topology.
|
|
999
1215
|
*
|
|
1216
|
+
/**
|
|
1217
|
+
* Paths under the app root whose changes must NOT trigger a dev rebuild.
|
|
1218
|
+
* `node_modules` / `.git` are noise. `.webjs/` is the framework's generated
|
|
1219
|
+
* artefact dir (the #258 routes.d.ts and the vendor pin) that the dev server
|
|
1220
|
+
* itself writes on startup and on every rebuild, so without this skip the
|
|
1221
|
+
* write fires a watch event, triggers a rebuild, re-writes the file, and loops
|
|
1222
|
+
* forever. `prisma/dev*` / `prisma/migrations` churn during db:migrate. The
|
|
1223
|
+
* prisma branch is prefix-only (no trailing separator) so the SQLite sidecars
|
|
1224
|
+
* `prisma/dev.db` / `prisma/dev.db-journal` match too; the others stay
|
|
1225
|
+
* separator-anchored so an unrelated name like `node_modules.bak/foo` does not.
|
|
1226
|
+
*
|
|
1227
|
+
* @param {string} filename relative path from an fs.watch `event.filename`
|
|
1228
|
+
* @returns {boolean} true when the change should be ignored
|
|
1229
|
+
*/
|
|
1230
|
+
export function shouldIgnoreWatchPath(filename) {
|
|
1231
|
+
return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])prisma[\\/](?:dev|migrations)/.test(filename || '');
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1000
1235
|
* @param {{
|
|
1001
1236
|
* appDir: string,
|
|
1002
1237
|
* port?: number,
|
|
@@ -1032,18 +1267,9 @@ export async function startServer(opts) {
|
|
|
1032
1267
|
// `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
|
|
1033
1268
|
// Node 24. No external dep needed.
|
|
1034
1269
|
//
|
|
1035
|
-
// fs.watch returns relative paths in event.filename.
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
// dev.db-journal, migrations/) which the dev server writes
|
|
1039
|
-
// during db:migrate and would otherwise loop.
|
|
1040
|
-
//
|
|
1041
|
-
// The prisma branch uses prefix-only matching (no required
|
|
1042
|
-
// trailing separator) so the SQLite sidecar files like
|
|
1043
|
-
// `prisma/dev.db` and `prisma/dev.db-journal` are ignored too.
|
|
1044
|
-
// node_modules / .git stay separator-anchored so unrelated
|
|
1045
|
-
// names like `node_modules.bak/foo` don't get caught.
|
|
1046
|
-
const IGNORE = /(?:^|[\\/])(?:node_modules|\.git)(?:[\\/]|$)|(?:^|[\\/])prisma[\\/](?:dev|migrations)/;
|
|
1270
|
+
// fs.watch returns relative paths in event.filename. `shouldIgnoreWatchPath`
|
|
1271
|
+
// (module-level, exported for tests) skips node_modules, .git, .webjs/, and
|
|
1272
|
+
// prisma's dev artefacts so a file the dev server itself writes never loops.
|
|
1047
1273
|
const rebuild = debounce(() => app.rebuild(), 80);
|
|
1048
1274
|
watcherAbort = new AbortController();
|
|
1049
1275
|
(async () => {
|
|
@@ -1051,7 +1277,7 @@ export async function startServer(opts) {
|
|
|
1051
1277
|
const events = fsWatch(app.appDir, { recursive: true, signal: watcherAbort.signal });
|
|
1052
1278
|
for await (const event of events) {
|
|
1053
1279
|
const filename = event.filename || '';
|
|
1054
|
-
if (
|
|
1280
|
+
if (shouldIgnoreWatchPath(filename)) continue;
|
|
1055
1281
|
rebuild();
|
|
1056
1282
|
}
|
|
1057
1283
|
} catch (err) {
|
|
@@ -1075,8 +1301,11 @@ export async function startServer(opts) {
|
|
|
1075
1301
|
try {
|
|
1076
1302
|
const url = urlFromRequest(req);
|
|
1077
1303
|
|
|
1078
|
-
// SSE: handled specially; doesn't fit the req→Response model.
|
|
1079
|
-
|
|
1304
|
+
// SSE: handled specially; doesn't fit the req→Response model. Match the
|
|
1305
|
+
// base-path-stripped pathname so the reload stream answers at
|
|
1306
|
+
// `<basePath>/__webjs/events` under a sub-path deploy (#256). With no
|
|
1307
|
+
// basePath this is a pure pass-through (the bare path still matches).
|
|
1308
|
+
if (stripBasePath(url.pathname, basePath()) === '/__webjs/events') {
|
|
1080
1309
|
if (!dev) { res.writeHead(404); res.end(); return; }
|
|
1081
1310
|
res.writeHead(200, {
|
|
1082
1311
|
'content-type': 'text/event-stream',
|
|
@@ -1195,7 +1424,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
|
|
|
1195
1424
|
// Dev live-reload client.
|
|
1196
1425
|
if (path === '/__webjs/reload.js') {
|
|
1197
1426
|
if (!dev) return new Response('Not found', { status: 404 });
|
|
1198
|
-
return new Response(
|
|
1427
|
+
return new Response(reloadClientJs(basePath()), {
|
|
1199
1428
|
headers: { 'content-type': 'application/javascript; charset=utf-8' },
|
|
1200
1429
|
});
|
|
1201
1430
|
}
|
|
@@ -2012,7 +2241,17 @@ function locatePackageDir(appDir, pkgName) {
|
|
|
2012
2241
|
return null;
|
|
2013
2242
|
}
|
|
2014
2243
|
|
|
2015
|
-
|
|
2016
|
-
|
|
2244
|
+
/**
|
|
2245
|
+
* The dev live-reload client. The `EventSource` URL is a framework-emitted
|
|
2246
|
+
* same-origin path, so it must carry the base path under a sub-path deploy
|
|
2247
|
+
* (#256), like the importmap targets and the RPC stub. No-op when basePath
|
|
2248
|
+
* is empty.
|
|
2249
|
+
* @param {string} bp the normalized base path (`''` = no-op)
|
|
2250
|
+
* @returns {string}
|
|
2251
|
+
*/
|
|
2252
|
+
function reloadClientJs(bp) {
|
|
2253
|
+
return `// webjs dev reload client
|
|
2254
|
+
const es = new EventSource(${JSON.stringify(withBasePath('/__webjs/events', bp))});
|
|
2017
2255
|
es.addEventListener('reload', () => location.reload());
|
|
2018
2256
|
`;
|
|
2257
|
+
}
|