@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 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.11",
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(`/__webjs/action/${hash}/`)} + fn, {\n` +
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` +
@@ -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
- if (shouldAccessLog(pathname)) {
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 page = matchPage(state.routeTable, pathname);
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
- return rel.split('\\').join('/').replace(/^\/?/, '/');
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. We apply
1036
- // the same ignore filter chokidar used before: skip
1037
- // node_modules, .git, and prisma's dev artefacts (dev.db,
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 (IGNORE.test(filename)) continue;
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
- if (url.pathname === '/__webjs/events') {
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(RELOAD_CLIENT_JS, {
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
- const RELOAD_CLIENT_JS = `// webjs dev reload client
2016
- const es = new EventSource('/__webjs/events');
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
+ }