@webjsdev/server 0.8.11 → 0.8.13
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 +19 -0
- package/package.json +5 -2
- package/src/actions.js +112 -11
- 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/sitemap.js +161 -0
- package/src/ssr.js +108 -7
- package/src/testing.js +400 -0
- package/src/vendor.js +143 -6
- 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 {
|
|
@@ -26,6 +27,7 @@ export {
|
|
|
26
27
|
listPinned,
|
|
27
28
|
auditPinned,
|
|
28
29
|
findOutdated,
|
|
30
|
+
hasVendorPin,
|
|
29
31
|
updatePinned,
|
|
30
32
|
readPinFile,
|
|
31
33
|
serveDownloadedBundle,
|
|
@@ -45,8 +47,25 @@ export { revalidatePath, revalidateAll } from './src/html-cache.js';
|
|
|
45
47
|
export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
|
|
46
48
|
export { broadcast } from './src/broadcast.js';
|
|
47
49
|
export { json, readBody } from './src/json.js';
|
|
50
|
+
export { sitemap, sitemapIndex } from './src/sitemap.js';
|
|
48
51
|
export { attachWebSocket } from './src/websocket.js';
|
|
49
52
|
export { getSerializer, setSerializer, defaultSerializer } from './src/serializer.js';
|
|
50
53
|
|
|
51
54
|
// Auth (NextAuth-style)
|
|
52
55
|
export { createAuth, Credentials, Google, GitHub } from './src/auth.js';
|
|
56
|
+
|
|
57
|
+
// Test harness helpers (issue #267): thin builders over handle()
|
|
58
|
+
export {
|
|
59
|
+
testRequest,
|
|
60
|
+
toRequest,
|
|
61
|
+
getCsrf,
|
|
62
|
+
readCsrfCookie,
|
|
63
|
+
getSetCookies,
|
|
64
|
+
cookiesToHeader,
|
|
65
|
+
withCookies,
|
|
66
|
+
withSessionCookie,
|
|
67
|
+
loginAndGetCookies,
|
|
68
|
+
actionEndpoint,
|
|
69
|
+
invokeActionForTest,
|
|
70
|
+
rawActionRequest,
|
|
71
|
+
} from './src/testing.js';
|
package/package.json
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.13",
|
|
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
|
+
"./testing": "./src/testing.js",
|
|
11
|
+
"./webjs-config.schema.json": "./webjs-config.schema.json"
|
|
10
12
|
},
|
|
11
13
|
"files": [
|
|
12
14
|
"index.js",
|
|
13
15
|
"src",
|
|
16
|
+
"webjs-config.schema.json",
|
|
14
17
|
"README.md"
|
|
15
18
|
],
|
|
16
19
|
"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
|
|
@@ -31,6 +33,60 @@ function jsonBodyLimit() {
|
|
|
31
33
|
*/
|
|
32
34
|
export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Run an attached input validator against an action's input, shared by the RPC
|
|
38
|
+
* path (`invokeAction`) and the `expose()` REST path (`invokeExposedAction`) so
|
|
39
|
+
* the contract is identical across transports (#245). The framework only CALLS
|
|
40
|
+
* the validator and shapes the result; it ships no validation library.
|
|
41
|
+
*
|
|
42
|
+
* @param {(input: any) => any} validate the attached validator
|
|
43
|
+
* @param {any} input the value handed to the validator (the action's first arg
|
|
44
|
+
* on RPC, the merged query/params/body object on REST)
|
|
45
|
+
* @returns {{ ok: true, value: any } | { ok: false, result: { success: false, fieldErrors?: Record<string,string>, error?: string, status: number }, thrown?: unknown }}
|
|
46
|
+
* `ok: true` carries the value to pass to the action (the validated `data`,
|
|
47
|
+
* the transformed return, or the original input). `ok: false` carries the
|
|
48
|
+
* structured failure result the caller serializes back; on a THROWN validator
|
|
49
|
+
* it also carries the original error on `thrown` so a caller can salvage a
|
|
50
|
+
* schema lib's structured `issues` (the REST path does, for back-compat).
|
|
51
|
+
*/
|
|
52
|
+
export function runValidate(validate, input) {
|
|
53
|
+
let out;
|
|
54
|
+
try {
|
|
55
|
+
out = validate(input);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
// Throw-to-reject (the classic `Schema.parse` contract). Map to a sanitized
|
|
58
|
+
// failure result; a thrown validator is a 400 (bad input), not a 500.
|
|
59
|
+
// Surface only the message; `thrown` lets the REST caller recover `issues`.
|
|
60
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
61
|
+
return { ok: false, result: { success: false, error: msg, status: 400 }, thrown: e };
|
|
62
|
+
}
|
|
63
|
+
// Disambiguate an ActionResult envelope from a plain transformed value: a
|
|
64
|
+
// return is an envelope ONLY when it is an object carrying a boolean
|
|
65
|
+
// `success` OR a `fieldErrors`. Anything else is a transformed input.
|
|
66
|
+
if (out && typeof out === 'object' && !Array.isArray(out) &&
|
|
67
|
+
(typeof (/** @type any */ (out).success) === 'boolean' || 'fieldErrors' in out)) {
|
|
68
|
+
const env = /** @type any */ (out);
|
|
69
|
+
if (env.success === true) {
|
|
70
|
+
// `{ success: true, data? }` → valid; use `data` when present, else input.
|
|
71
|
+
return { ok: true, value: 'data' in env ? env.data : input };
|
|
72
|
+
}
|
|
73
|
+
// Failure: `{ success: false, ... }` or `{ fieldErrors }` (no literal
|
|
74
|
+
// success). Shape the structured field-error result.
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
result: {
|
|
78
|
+
success: false,
|
|
79
|
+
...(env.fieldErrors ? { fieldErrors: env.fieldErrors } : {}),
|
|
80
|
+
...(env.message || env.error ? { error: env.message || env.error } : {}),
|
|
81
|
+
status: typeof env.status === 'number' ? env.status : 422,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// A non-envelope return transforms the input (back-compat with
|
|
86
|
+
// `validate: Schema.parse`); `undefined` means "no transform", keep input.
|
|
87
|
+
return { ok: true, value: out === undefined ? input : out };
|
|
88
|
+
}
|
|
89
|
+
|
|
34
90
|
/** Build a serialized Response with webjs content-type. */
|
|
35
91
|
async function rpcResponse(payload, init = {}) {
|
|
36
92
|
const s = getSerializer();
|
|
@@ -143,6 +199,12 @@ export async function buildActionIndex(appDir, dev) {
|
|
|
143
199
|
if (typeof fn !== 'function') continue;
|
|
144
200
|
const http = getExposed(fn);
|
|
145
201
|
if (!http) continue;
|
|
202
|
+
// A `validateInput(fn, ...)` attachment writes `__webjsHttp` with a
|
|
203
|
+
// `validate` but NO `method`/`path` (it does not create a REST route),
|
|
204
|
+
// so it is not an exposed route. Only `expose()` sets a path; skip a
|
|
205
|
+
// validate-only attachment here (its validator is read at RPC call
|
|
206
|
+
// time via getExposed in invokeAction).
|
|
207
|
+
if (!http.path || !http.method) continue;
|
|
146
208
|
const { pattern, paramNames } = pathToPattern(http.path);
|
|
147
209
|
httpRoutes.push({
|
|
148
210
|
method: http.method,
|
|
@@ -257,6 +319,12 @@ export async function serveActionStub(idx, absFile) {
|
|
|
257
319
|
if (typeof mod.default === 'function' && !fnNames.includes('default')) {
|
|
258
320
|
fnNames.push('default');
|
|
259
321
|
}
|
|
322
|
+
// The RPC endpoint is a framework-emitted same-origin URL, so it must
|
|
323
|
+
// carry the basePath prefix under a sub-path deploy (#256), exactly like
|
|
324
|
+
// the importmap targets and the boot module specifiers. Without this the
|
|
325
|
+
// stub would POST to a bare /__webjs/action/... that the ingress strip
|
|
326
|
+
// 404s, breaking every server action when webjs.basePath is set.
|
|
327
|
+
const actionUrl = withBasePath(`/__webjs/action/${hash}/`, basePath());
|
|
260
328
|
const body = `// webjs: generated server-action stub for ${relative(idx.appDir, absFile)}\n` +
|
|
261
329
|
`import { stringify as __wjStringify, parse as __wjParse } from '@webjsdev/core';\n` +
|
|
262
330
|
`function __csrf() {\n` +
|
|
@@ -265,7 +333,7 @@ export async function serveActionStub(idx, absFile) {
|
|
|
265
333
|
`}\n` +
|
|
266
334
|
`async function __rpc(fn, args) {\n` +
|
|
267
335
|
` const body = await __wjStringify(args);\n` +
|
|
268
|
-
` const res = await fetch(${JSON.stringify(
|
|
336
|
+
` const res = await fetch(${JSON.stringify(actionUrl)} + fn, {\n` +
|
|
269
337
|
` method: 'POST',\n` +
|
|
270
338
|
` headers: {\n` +
|
|
271
339
|
` 'content-type': ${JSON.stringify(RPC_CONTENT_TYPE)},\n` +
|
|
@@ -327,6 +395,30 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
|
|
|
327
395
|
const mod = await loadModule(file, idx.dev);
|
|
328
396
|
const fn = fnName === 'default' ? mod.default : mod[fnName];
|
|
329
397
|
if (typeof fn !== 'function') return rpcResponse({ error: `Unknown action ${fnName}` }, { status: 404 });
|
|
398
|
+
// Input validation (#245): a validator attached via `validateInput(fn, ...)`
|
|
399
|
+
// or `expose(spec, fn, { validate })` runs SERVER-SIDE before the body on
|
|
400
|
+
// this RPC path too, not just the REST path. The RPC stub sends an args
|
|
401
|
+
// array; an action conventionally takes one input object, so validate the
|
|
402
|
+
// FIRST arg (matching how the REST path validates its single merged object).
|
|
403
|
+
const attached = getExposed(fn);
|
|
404
|
+
if (attached && typeof attached.validate === 'function') {
|
|
405
|
+
const v = runValidate(attached.validate, args[0]);
|
|
406
|
+
if (!v.ok) {
|
|
407
|
+
if (v.thrown !== undefined) {
|
|
408
|
+
// A THROWN validator behaves like a thrown action: a sanitized error
|
|
409
|
+
// response (non-200, so the client stub throws), with prod stack
|
|
410
|
+
// sanitization. The onError sink sees the original error too.
|
|
411
|
+
if (typeof onError === 'function') onError(v.thrown);
|
|
412
|
+
return actionErrorResponse(v.thrown, idx.dev);
|
|
413
|
+
}
|
|
414
|
+
// A structured `{ success: false, fieldErrors }` envelope is a NORMAL
|
|
415
|
+
// result the action author renders (`result.fieldErrors`): serialized as
|
|
416
|
+
// a 200 RPC payload so the client stub returns it as a real object rather
|
|
417
|
+
// than throwing, the failure status riding inside the envelope.
|
|
418
|
+
return rpcResponse(v.result);
|
|
419
|
+
}
|
|
420
|
+
args = [v.value, ...args.slice(1)];
|
|
421
|
+
}
|
|
330
422
|
try {
|
|
331
423
|
const result = await fn(...args);
|
|
332
424
|
return rpcResponse(result ?? null);
|
|
@@ -462,17 +554,26 @@ export async function invokeExposedAction(idx, route, params, req, onError) {
|
|
|
462
554
|
}
|
|
463
555
|
let arg = { ...query, ...params, ...body };
|
|
464
556
|
if (route.validate) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
:
|
|
474
|
-
|
|
557
|
+
// Run the validator through the SHARED contract (#245) so the REST path and
|
|
558
|
+
// the RPC path interpret a `{ success, fieldErrors }` envelope, a throw, and
|
|
559
|
+
// a transform-return identically. A structured failure becomes a 422 JSON
|
|
560
|
+
// `{ error?, fieldErrors }` (not just a thrown 400); a throw stays a 400; a
|
|
561
|
+
// transform-return keeps replacing the input (back-compat).
|
|
562
|
+
const v = runValidate(route.validate, arg);
|
|
563
|
+
if (!v.ok) {
|
|
564
|
+
if (v.thrown !== undefined) {
|
|
565
|
+
// A thrown validator (the classic `Schema.parse` style): keep the exact
|
|
566
|
+
// legacy REST shape, including a schema lib's structured `issues` array.
|
|
567
|
+
const msg = v.result.error || 'Invalid input';
|
|
568
|
+
const issues = v.thrown && typeof v.thrown === 'object' && 'issues' in v.thrown
|
|
569
|
+
? /** @type any */ (v.thrown).issues
|
|
570
|
+
: undefined;
|
|
571
|
+
return Response.json({ error: msg, issues }, { status: 400 });
|
|
572
|
+
}
|
|
573
|
+
const { status, ...payload } = v.result;
|
|
574
|
+
return Response.json(payload, { status });
|
|
475
575
|
}
|
|
576
|
+
arg = v.value;
|
|
476
577
|
}
|
|
477
578
|
const mod = await loadModule(route.file, idx.dev);
|
|
478
579
|
const fn = mod[route.fnName];
|
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
|
+
}
|