diff --git a/AGENTS.md b/AGENTS.md index 6460b537b..c119b9bb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -857,6 +857,25 @@ webjs already has `redirect(url)` (an imperative, request-time throw sentinel). --- +## Trailing-slash policy: `webjs.trailingSlash` in package.json (#255) + +webjs's file router matches `/about` AND `/about/` against the same route (every route pattern ends with `/?$`, so both render IDENTICAL HTML). That is fine for serving but bad for SEO (search engines treat the two URLs as duplicate content that splits link equity) and for the client-router cache (two keys for one page). The trailing-slash policy picks ONE canonical form and 308-redirects the other to it. Declare it under `package.json` `"webjs": { "trailingSlash": ... }`, cohesive with `webjs.redirects` / `webjs.headers` / `webjs.csp`: + +```jsonc +{ "webjs": { "trailingSlash": "never" } } // /about/ -> /about (recommended) +{ "webjs": { "trailingSlash": "always" } } // /about -> /about/ +{ "webjs": { "trailingSlash": "ignore" } } // no canonicalization (the default) +``` + +- **Values.** `"never"` strips a trailing slash, `"always"` adds one, `"ignore"` (or absence, or any unrecognized value) does nothing. +- **Default is `"ignore"` (non-breaking).** An app that set no policy keeps serving both forms exactly as before; the feature is purely opt-in. **The recommendation for most apps is `"never"`** (the cleaner canonical form), but webjs does not impose it, so adding the feature never silently starts 308-ing an existing app. +- **Status is 308 Permanent Redirect**, so SEO link equity transfers and a redirected POST stays a POST. +- **Exemptions.** The ROOT path `/` is always left alone under either policy. Under `"always"`, a path whose last segment looks like a FILE (contains a dot, e.g. `/foo.js`, `/image.png`) is NOT given a trailing slash, since a file is a leaf, not a page directory. Framework-internal `/__webjs/*` paths are exempt. The query string and hash are preserved on the redirect. + +**Order vs `webjs.redirects`.** The declarative redirects run FIRST, then the survivor is slash-canonicalized. So an explicit `webjs.redirects` rule always wins. This is NOT loop-free: a redirect whose `destination` CONTRADICTS the slash policy creates an infinite loop. For example `{ trailingSlash: 'never', redirects: [{ source: '/x', destination: '/x/' }] }` ping-pongs forever (`/x` -> 308 `/x/` -> 308 `/x` -> ...). There is no server-side loop guard (matching the `webjs.redirects` warning above); keeping a redirect destination consistent with the slash policy is the author's responsibility. Applied at the very START of request handling (in `dev.js`'s `produce()`, right after `applyRedirects`, before routing / SSR), so the canonical URL reaches the router. Mechanism: `readTrailingSlashPolicy` / `applyTrailingSlash` in `packages/server/src/redirects.js`. + +--- + ## Conditional GET: ETag + If-None-Match -> 304 (on by default) (#240) Every CACHEABLE response carries a content-hash `ETag`, and a repeat request whose `If-None-Match` matches it gets a `304 Not Modified` with no body (RFC 7232). So a client holding an identical copy revalidates with a tiny 304 instead of re-transferring the whole body. Wired once at the response funnel in `dev.js`'s `handle()` (mechanism: `applyConditionalGet` in `packages/server/src/conditional-get.js`), so it covers SSR HTML pages, static assets in `public/`, app source modules, and the core / vendor runtime modules uniformly. diff --git a/docs/app/docs/configuration/page.ts b/docs/app/docs/configuration/page.ts index 84482f73e..fa17c579f 100644 --- a/docs/app/docs/configuration/page.ts +++ b/docs/app/docs/configuration/page.ts @@ -78,6 +78,10 @@ webjs db studio # prisma studio } } +

Trailing slash

+

webjs's file router matches /about and /about/ against the same route, so both render identical HTML. That is duplicate content for SEO (the two URLs split link equity) and two keys in the client-router cache. Pick one canonical form with a webjs.trailingSlash key in package.json: "never" strips a trailing slash (/about//about, the recommended form for most apps), "always" adds one (/about/about/), and "ignore" (the default, also the behavior when the key is absent) does nothing, so an existing app is unchanged unless it opts in. The non-canonical URL gets a 308 Permanent Redirect (link equity transfers, and a redirected POST stays a POST). The root / is always left alone; under "always" a path whose last segment looks like a file (has a dot, e.g. /logo.png) is not given a trailing slash. The query string is preserved. Canonicalization runs right after the webjs.redirects rules (so an explicit redirect wins first), at the very start of request handling. There is no server-side loop guard: a redirect whose destination contradicts the slash policy (e.g. "never" with a destination of /x/) creates an infinite redirect loop, so keeping redirect destinations consistent with the policy is your responsibility.

+
{ "webjs": { "trailingSlash": "never" } }
+

Request limits & server timeouts

The server caps inbound request bodies and bounds connection lifetimes by default, so an uncapped body is not a memory-exhaustion vector and a slow connection is not a slowloris vector. Both apply with secure defaults when unset and are configurable in package.json (env overrides win, and a value of 0 disables that limit / timeout).

Body-size limit (413). Every request body the server reads (the action RPC endpoint, route.{js,ts} handlers via readBody, and the no-JS page-action form path) is capped. A JSON / RPC body defaults to 1 MiB (webjs.maxBodyBytes or WEBJS_MAX_BODY_BYTES); a form / multipart body defaults to 10 MiB (webjs.maxMultipartBytes or WEBJS_MAX_MULTIPART_BYTES). An over-limit body responds 413 Payload Too Large and is never buffered whole: a Content-Length over the cap is rejected before the body is read, and a chunked body with no declared length is abandoned the instant it crosses the cap.

diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index e5cab816e..4661d581d 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -49,7 +49,7 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | `broadcast.js` | `broadcast(topic, msg)` for fan-out messaging | | `context.js` | AsyncLocalStorage per-request context (`getRequest`, `withRequest`, `headers`, `cookies`). The per-user readers `headers()` / `cookies()` (plus `getSession()` in `session.js` and `readSession()` behind `auth()` in `auth.js`) call `markDynamicAccess()`, so the HTML cache's commit step reads `dynamicAccessed()` and refuses to cache a per-user page that wrongly set `revalidate` (#241). Also exposes the per-request correlation id via `requestId()` (set by the handler with `setRequestId`, #239) and wires the server-side `cspNonce()` provider: returns the per-request nonce `setCspNonce` stored (minted when CSP is on, #233), else falls back to parsing an inbound `Content-Security-Policy` request header | | `build-info.js` | Build-info / version probe payload (#239). `buildInfo()` composes `{ version, build, node, uptime }` (framework version read once from this package's own `package.json`, `build` from `publishedBuildId()`, no secrets); `buildInfoResponse()` wraps it as the `no-store` JSON `GET /__webjs/version` response. Served in `handle()` before `ensureReady`, like the health / ready probes | -| `redirects.js` | Declarative permanent / temporary redirects (#254). `compileRedirectRules(pkg)` normalizes the `webjs.redirects` package.json key (an array of `{ source, destination, permanent?, statusCode? }`) into URLPattern rules compiled ONCE at boot, dropping any malformed entry with a warning (the #232 fail-safe posture). `applyRedirects(req, rules)` matches the request pathname against the rules, fills `:name` groups from the source into the destination, preserves (and merges) the incoming query string, and returns a 308 (permanent default) / 307 (temporary) / configured-`statusCode` redirect Response on the first match, else null so the request falls through to routing. Skips `/__webjs/*`. Wired in `dev.js`: `readRedirectRules` reads it at boot and `produce()` applies it at the very start of request handling (before the probes / routing / SSR), the secure-header + conditional-GET funnel in `handle()` still wrapping the redirect Response | +| `redirects.js` | Declarative permanent / temporary redirects (#254). `compileRedirectRules(pkg)` normalizes the `webjs.redirects` package.json key (an array of `{ source, destination, permanent?, statusCode? }`) into URLPattern rules compiled ONCE at boot, dropping any malformed entry with a warning (the #232 fail-safe posture). `applyRedirects(req, rules)` matches the request pathname against the rules, fills `:name` groups from the source into the destination, preserves (and merges) the incoming query string, and returns a 308 (permanent default) / 307 (temporary) / configured-`statusCode` redirect Response on the first match, else null so the request falls through to routing. Skips `/__webjs/*`. Wired in `dev.js`: `readRedirectRules` reads it at boot and `produce()` applies it at the very start of request handling (before the probes / routing / SSR), the secure-header + conditional-GET funnel in `handle()` still wrapping the redirect Response. ALSO hosts the trailing-slash policy (#255): `readTrailingSlashPolicy(pkg)` normalizes the `webjs.trailingSlash` package.json key to `'never'` / `'always'` / `'ignore'` (default `'ignore'`, the non-breaking no-op); `applyTrailingSlash(req, policy)` 308-redirects a non-canonical path to the canonical form (`never` strips a trailing slash, `always` adds one), exempting the root `/`, file paths (last segment has a dot, under `always`), `/__webjs/*`, and network-path references (a path starting with `//` or `/\`, which would otherwise emit a protocol-relative cross-origin `Location`, an open redirect), and preserving query + hash. Wired in `dev.js` right AFTER `applyRedirects` in `produce()` (`readTrailingSlashFromApp` reads it at boot), so an explicit redirect wins first. NOT loop-free: a redirect destination that contradicts the slash policy loops forever (no server guard, the author's responsibility, matching `applyRedirects`) | | `csp.js` | CSP nonce minting + `Content-Security-Policy` header building (#233). `readCspConfig` normalizes the `webjs.csp` package.json key (off by default; `true` = strict default policy, object = custom directives + `reportOnly`); `mintNonce` is the per-request CSPRNG nonce; `buildCspHeader` substitutes the nonce into the policy. Plugs into the #232 `applySecurityHeaders` seam in `dev.js`'s `handle()` | | `env-schema.js` | Boot-time env-var validation (#236). `validateEnv(schema, env)` is the PURE validator: it checks an env object against a schema (an object of `name -> type-name | options`, supporting `string`/`number`/`boolean`/`url`/`enum`, `required`/`optional`/`default`, `minLength`/`pattern`), collecting ALL errors at once and returning the coerced + defaulted values to write back. A schema may instead be a FUNCTION `(env) => void` (the escape hatch for zod etc.), whose throw becomes the single error. `loadEnvSchema(appDir)` reads the optional app-root `env.{js,ts}` (null when absent, so opt-in); `applyEnvValidation(appDir)` is the side-effecting boot wrapper called from `createRequestHandler` right after the `.env` auto-load: it validates `process.env`, applies coerced values back, and THROWS a clear aggregated Error on failure (CLI exits non-zero, embedded host rejects), consistent with the Node-version preflight. `formatEnvErrors` composes the aggregated message. | | `node-version.js` | Node-version preflight guard (#238). `checkNodeVersion(current, requiredMajor)` is the PURE comparison; `assertNodeVersion({ onFail })` is the side-effecting wrapper that throws a clear Error (embedded server, called at the top of `createRequestHandler`) or exits non-zero (CLI). The minimum is sourced from this package's own `engines.node` via `requiredNodeMajor()` so it never drifts. Fails fast on an older Node with a message naming the found + required version (the built-in TS strip + recursive `fs.watch` need 24+), instead of a cryptic late failure. For the embedded-host throw to actually fire, `dev.js` namespace-imports `node:module` (`import * as nodeModule`) rather than name-importing `stripTypeScriptTypes`, so importing `@webjsdev/server` LINKS on old Node instead of link-failing before the guard runs (PR #282 fix; the CLI carries its own dependency-free inline guard in `cli/lib/node-preflight.js` for the same reason). | diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js index 331e2006d..1aa9c10e9 100644 --- a/packages/server/src/dev.js +++ b/packages/server/src/dev.js @@ -115,7 +115,12 @@ function shouldAccessLog(pathname) { import { setVendorEntries, setCoreInstall, publishBuildId } from './importmap.js'; import { urlFromRequest } from './forwarded.js'; import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js'; -import { compileRedirectRules, applyRedirects } from './redirects.js'; +import { + compileRedirectRules, + applyRedirects, + readTrailingSlashPolicy, + applyTrailingSlash, +} from './redirects.js'; import { readBodyLimits, computeServerTimeouts } from './body-limit.js'; import { applyConditionalGet, BUFFERED_MARKER } from './conditional-get.js'; import { commitHtmlCache } from './html-cache.js'; @@ -280,6 +285,24 @@ export async function readRedirectRules(appDir) { } } +/** + * Read the trailing-slash policy (`webjs.trailingSlash`) from the app's + * package.json (issue #255). A missing, malformed, or unreadable config + * yields `'ignore'` (no canonicalization), never a throw, so an + * unconfigured app is unchanged. + * + * @param {string} appDir + * @returns {Promise<'never' | 'always' | 'ignore'>} + */ +export async function readTrailingSlashFromApp(appDir) { + try { + const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8')); + return readTrailingSlashPolicy(pkg); + } catch { + return 'ignore'; + } +} + /** * Read the CSP config (`webjs.csp`) from the app's package.json and * normalize it (issue #233). A missing, malformed, or unreadable config @@ -478,6 +501,14 @@ export async function createRequestHandler(opts) { // immediately. const redirectRules = await readRedirectRules(appDir); + // Trailing-slash policy (issue #255), read once from the app's package.json + // `webjs.trailingSlash`. Default `'ignore'` (no canonicalization), so an + // unconfigured app is unchanged; `'never'` (recommended) strips a trailing + // slash and `'always'` adds one, each via a 308 to the canonical form. + // Applied in produce() AFTER the declarative redirects, so an explicit + // redirect rule wins first and the two never loop. + const trailingSlashPolicy = await readTrailingSlashFromApp(appDir); + // CSP config (issue #233), read once from the app's package.json // `webjs.csp`. OFF by default: when disabled no nonce is minted and no // Content-Security-Policy header is set, so an unconfigured app is @@ -904,6 +935,16 @@ export async function createRequestHandler(opts) { const redirectResp = applyRedirects(req, redirectRules); if (redirectResp) return redirectResp; + // Trailing-slash canonicalization (issue #255): after the explicit + // redirects above (so an explicit rule wins first and the two never + // form a loop), 308-redirect a non-canonical path to the policy's + // canonical form (`never` strips a trailing slash, `always` adds one). + // Default `'ignore'` is a no-op. The root `/` and file paths are + // exempt; `/__webjs/*` is exempt too (defense in depth, the redirects + // above already skip it). The funnel in handle() still wraps this. + const slashResp = applyTrailingSlash(req, trailingSlashPolicy); + if (slashResp) return slashResp; + // Health and readiness probes are answered BEFORE ensureReady so a probe // never blocks on the analysis. `/__webjs/health` is liveness (the // process is up and accepting connections). `/__webjs/ready` is 503 until diff --git a/packages/server/src/redirects.js b/packages/server/src/redirects.js index aee186ee1..8ce5eb7c3 100644 --- a/packages/server/src/redirects.js +++ b/packages/server/src/redirects.js @@ -42,6 +42,173 @@ * default redirect behavior. */ +/** + * Trailing-slash canonicalization (issue #255). + * + * A page reachable at BOTH `/about` and `/about/` is duplicate content: + * webjs's file router matches both (every route pattern ends with `/?$`, + * so the slashed and unslashed forms render IDENTICAL HTML), but search + * engines treat them as two URLs that split link equity, and the client + * router caches them under two keys. The trailing-slash policy picks ONE + * canonical form and 308-redirects the other to it, exactly like the + * `webjs.redirects` config does for a moved URL. + * + * Config lives in `package.json` -> `webjs.trailingSlash`, cohesive with + * `webjs.redirects` / `webjs.headers` / `webjs.csp`: + * + * "webjs": { "trailingSlash": "never" } // /about/ -> /about (recommended) + * "webjs": { "trailingSlash": "always" } // /about -> /about/ + * "webjs": { "trailingSlash": "ignore" } // no canonicalization (default) + * + * Default. Absent or `"ignore"` means NO redirect (current behavior, so an + * existing app is unchanged). Most apps want `"never"`; it is the + * recommendation, but it is opt-in so adding the feature never silently + * starts 308-ing an app that was happy serving both forms. + * + * Rules (a redirect is a permanent 308, so the SEO equity transfers and a + * redirected POST stays a POST): + * - `never`: a path ending in `/` (other than the root `/`) redirects to + * the same path without the trailing slash. + * - `always`: a path with NO trailing slash redirects to the same path + * WITH one, UNLESS the last segment looks like a file (has a dot in it, + * e.g. `/foo.js`, `/image.png`); a file path is left alone, since + * `/foo.js/` is not a sensible canonical form. + * - The ROOT path `/` is ALWAYS left alone under either policy. + * - The query string and hash are preserved on the redirect. + * - `/__webjs/*` framework paths are exempt (handled by the caller). + */ + +/** Permanent (308) is the canonicalization status, like a moved URL. */ +const CANONICAL_STATUS = 308; + +/** The valid `webjs.trailingSlash` policy values. */ +const TRAILING_SLASH_POLICIES = new Set(['never', 'always', 'ignore']); + +/** + * Read the trailing-slash policy from the app's package.json + * (`webjs.trailingSlash`). Returns `'never'` / `'always'` / `'ignore'`, + * defaulting to `'ignore'` (no canonicalization) for an absent, malformed, + * or unrecognized value, so a missing or typo'd config is a no-op rather + * than a throw or an accidental redirect. + * + * @param {unknown} pkg parsed package.json (or any object) + * @returns {'never' | 'always' | 'ignore'} + */ +export function readTrailingSlashPolicy(pkg) { + const raw = + pkg && + typeof pkg === 'object' && + /** @type {any} */ (pkg).webjs && + /** @type {any} */ (pkg).webjs.trailingSlash; + if (typeof raw === 'string' && TRAILING_SLASH_POLICIES.has(raw)) { + return /** @type {'never' | 'always' | 'ignore'} */ (raw); + } + return 'ignore'; +} + +/** + * Apply the trailing-slash canonicalization policy to an incoming request. + * Returns a 308 redirect Response to the canonical form when the request + * path is non-canonical under the policy, else null so the request falls + * through to normal routing (or to the declarative redirects). Runs AFTER + * `applyRedirects` (see the wiring in `dev.js`): an explicit `webjs.redirects` + * rule wins first, then the survivor is slash-canonicalized. This does NOT + * guarantee loop-freedom. A redirect whose `destination` CONTRADICTS the slash + * policy (e.g. policy `never` with `{ destination: '/x/' }`) ping-pongs forever + * (`/x` -> 308 `/x/` -> 308 `/x` -> ...). There is no server-side loop guard + * (matching `applyRedirects`), so keeping a redirect destination consistent with + * the policy is the app author's responsibility. + * + * Framework-internal `/__webjs/*` paths are never canonicalized (the caller + * also guards this, defense in depth here). + * + * SECURITY: the canonical path is built from `url.pathname` and emitted as the + * `Location`, so a request whose path is a NETWORK-PATH REFERENCE (begins with + * `//`, or `/\` which the URL parser normalizes to `//`) would otherwise emit a + * protocol-relative `Location` (`//attacker.com`) that the browser resolves to a + * FOREIGN origin, an open redirect. Such a path is not a normal route, so we + * REFUSE to canonicalize it (return null) and let the router 404 it, rather than + * emit a cross-origin redirect. The sibling `applyRedirects` avoids this by only + * ever emitting app-authored `destination` literals (and keeping user-controlled + * `:slug` captures percent-encoded so they cannot escape the origin); here the + * Location is derived from the request path, so the guard is on the path itself. + * + * @param {Request} req + * @param {'never' | 'always' | 'ignore'} policy + * @returns {Response | null} + */ +export function applyTrailingSlash(req, policy) { + if (policy !== 'never' && policy !== 'always') return null; + let url; + try { + url = new URL(req.url); + } catch { + return null; + } + const path = url.pathname; + // The root path is always canonical under either policy. + if (path === '/') return null; + if (path.startsWith('/__webjs/')) return null; + // Refuse a network-path reference (`//host`, or `/\host` normalized to + // `//host`): canonicalizing it would emit a protocol-relative, cross-origin + // Location (an open redirect). Let the router handle the weird path instead. + if (!isSameOriginPath(path)) return null; + + /** @type {string | null} */ + let canonical = null; + if (policy === 'never') { + // Strip a single trailing slash. (A multi-slash path like `/about//` + // collapses one slash per redirect; the next request re-canonicalizes, + // and the common single-slash case settles in one hop.) + if (path.endsWith('/')) canonical = path.replace(/\/+$/, '') || '/'; + } else { + // policy === 'always' + if (!path.endsWith('/') && !lastSegmentLooksLikeFile(path)) { + canonical = path + '/'; + } + } + if (canonical === null || canonical === path) return null; + + // Preserve the query string and hash on the canonical URL. + const location = canonical + url.search + url.hash; + return new Response(null, { + status: CANONICAL_STATUS, + headers: { location: location }, + }); +} + +/** + * Whether the LAST path segment looks like a file (contains a dot), e.g. + * `/foo.js` or `/assets/logo.png`. Such a path must NOT get a trailing + * slash added under the `always` policy: a file is a leaf, not a "page" + * directory, so `/foo.js/` is never a sensible canonical form. + * + * @param {string} path a pathname (no query / hash) + * @returns {boolean} + */ +function lastSegmentLooksLikeFile(path) { + const lastSlash = path.lastIndexOf('/'); + const segment = path.slice(lastSlash + 1); + return segment.includes('.'); +} + +/** + * Whether a pathname is a SAFE same-origin path (a single leading slash, not a + * network-path reference). A path beginning with `//` or `/\` resolves to a + * foreign origin when emitted as a `Location`, so it is rejected. The URL parser + * normalizes a backslash to a forward slash in the pathname, so `/\evil.com` is + * seen here as `//evil.com`; the explicit backslash check is belt-and-braces in + * case a caller passes a raw, unparsed path. + * + * @param {string} path a pathname + * @returns {boolean} + */ +function isSameOriginPath(path) { + if (path[0] !== '/') return false; + const second = path[1]; + return second !== '/' && second !== '\\'; +} + /** Default status for `permanent: true` (the SEO permanent redirect). */ const PERMANENT_STATUS = 308; /** Default status for `permanent: false` (a temporary redirect). */ diff --git a/packages/server/test/redirects/trailing-slash.test.js b/packages/server/test/redirects/trailing-slash.test.js new file mode 100644 index 000000000..abfb07b54 --- /dev/null +++ b/packages/server/test/redirects/trailing-slash.test.js @@ -0,0 +1,295 @@ +/** + * Integration tests for the trailing-slash policy (issue #255). Exercised + * through createRequestHandler so they cover the real response pipeline (the + * canonicalization runs at the start of produce(), after the declarative + * redirects, before routing / SSR), not the matcher in isolation. + * Web-standard Request/Response, no real HTTP server. + * + * The contract under test: + * - `never`: /about/ -> 308 /about; /about and / stay; query preserved + * - `always`: /about -> 308 /about/; /about/ and / stay; a file path + * (/foo.js) is NOT redirected + * - `ignore` / absent: no canonicalization (both forms render 200) + * - an explicit webjs.redirects rule wins first, then the survivor is + * slash-canonicalized, with no loop + * - /__webjs/* framework paths are exempt + * - SECURITY: a network-path-reference path (`//attacker.com/`, `/\evil.com/`) + * is NOT canonicalized into a protocol-relative cross-origin redirect + */ +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { createRequestHandler } from '../../src/dev.js'; +import { + readTrailingSlashPolicy, + applyTrailingSlash, +} from '../../src/redirects.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const HTML_URL = pathToFileURL( + resolve(__dirname, '../../../core/src/html.js') +).toString(); + +let tmpRoot; +before(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'webjs-trailing-')); }); +after(() => { rmSync(tmpRoot, { recursive: true, force: true }); }); + +function makeApp(files) { + const appDir = mkdtempSync(join(tmpRoot, 'app-')); + for (const [rel, body] of Object.entries(files)) { + const abs = join(appDir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, body); + } + return appDir; +} + +function page(body) { + return ( + `import { html } from ${JSON.stringify(HTML_URL)};\n` + + `export default function P() { return html\`${body}\`; }\n` + ); +} + +function pkg(extra) { + return JSON.stringify({ name: 'slash-app', webjs: extra }); +} + +/* ------------------------------ never ------------------------------ */ + +test('never: /about/ 308-redirects to /about', async () => { + const appDir = makeApp({ + 'package.json': pkg({ trailingSlash: 'never' }), + 'app/about/page.js': page('

about

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + const resp = await app.handle(new Request('http://x/about/')); + // COUNTERFACTUAL anchor: remove the applyTrailingSlash call in produce() + // and /about/ renders 200 (the router matches it) instead of 308-ing, + // so this assertion fails. + assert.equal(resp.status, 308); + assert.equal(resp.headers.get('location'), '/about'); +}); + +test('never: /about (no slash) is canonical, stays 200', async () => { + const appDir = makeApp({ + 'package.json': pkg({ trailingSlash: 'never' }), + 'app/about/page.js': page('

about

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + const resp = await app.handle(new Request('http://x/about')); + assert.equal(resp.status, 200); +}); + +test('never: the root / is always left alone', async () => { + const appDir = makeApp({ + 'package.json': pkg({ trailingSlash: 'never' }), + 'app/page.js': page('

home

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + const resp = await app.handle(new Request('http://x/')); + assert.equal(resp.status, 200); +}); + +test('never: the query string is preserved on the redirect', async () => { + const appDir = makeApp({ + 'package.json': pkg({ trailingSlash: 'never' }), + 'app/about/page.js': page('

about

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + const resp = await app.handle(new Request('http://x/about/?x=1&y=2')); + assert.equal(resp.status, 308); + assert.equal(resp.headers.get('location'), '/about?x=1&y=2'); +}); + +/* ------------------------------ always ------------------------------ */ + +test('always: /about (no slash) 308-redirects to /about/', async () => { + const appDir = makeApp({ + 'package.json': pkg({ trailingSlash: 'always' }), + 'app/about/page.js': page('

about

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + const resp = await app.handle(new Request('http://x/about')); + assert.equal(resp.status, 308); + assert.equal(resp.headers.get('location'), '/about/'); +}); + +test('always: /about/ is canonical, stays 200', async () => { + const appDir = makeApp({ + 'package.json': pkg({ trailingSlash: 'always' }), + 'app/about/page.js': page('

about

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + const resp = await app.handle(new Request('http://x/about/')); + assert.equal(resp.status, 200); +}); + +test('always: a file path (/foo.js) is NOT given a trailing slash', async () => { + const appDir = makeApp({ + 'package.json': pkg({ trailingSlash: 'always' }), + 'app/page.js': page('

home

'), + 'public/foo.js': 'export const x = 1;\n', + }); + const app = await createRequestHandler({ appDir, dev: true }); + const resp = await app.handle(new Request('http://x/foo.js')); + assert.notEqual(resp.status, 308); +}); + +test('always: the root / is always left alone', async () => { + const appDir = makeApp({ + 'package.json': pkg({ trailingSlash: 'always' }), + 'app/page.js': page('

home

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + const resp = await app.handle(new Request('http://x/')); + assert.equal(resp.status, 200); +}); + +/* ------------------------------ ignore / absent ------------------------------ */ + +test('ignore: /about/ is NOT canonicalized (renders 200)', async () => { + const appDir = makeApp({ + 'package.json': pkg({ trailingSlash: 'ignore' }), + 'app/about/page.js': page('

about

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + const resp = await app.handle(new Request('http://x/about/')); + assert.equal(resp.status, 200); +}); + +test('absent config: no canonicalization (both forms render 200)', async () => { + const appDir = makeApp({ + 'package.json': JSON.stringify({ name: 'no-config-app' }), + 'app/about/page.js': page('

about

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + const slashed = await app.handle(new Request('http://x/about/')); + const bare = await app.handle(new Request('http://x/about')); + assert.equal(slashed.status, 200); + assert.equal(bare.status, 200); +}); + +/* --------------------- interaction with webjs.redirects --------------------- */ + +test('an explicit redirect wins first, then the survivor is slash-canonicalized (no loop)', async () => { + const appDir = makeApp({ + // /old/ -> (redirect) /new -> (already canonical under never) served + 'package.json': pkg({ + trailingSlash: 'never', + redirects: [{ source: '/old', destination: '/new' }], + }), + 'app/new/page.js': page('

new

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + // /old (no slash) matches the explicit redirect and goes straight to /new, + // never reaching the slash policy. The explicit redirect wins first. + const r1 = await app.handle(new Request('http://x/old')); + assert.equal(r1.status, 308); + assert.equal(r1.headers.get('location'), '/new'); + // A non-redirected, non-canonical path is still slash-canonicalized: the + // two surfaces compose without a loop. + const r2 = await app.handle(new Request('http://x/new/')); + assert.equal(r2.status, 308); + assert.equal(r2.headers.get('location'), '/new'); +}); + +/* ------------------------------ exemptions ------------------------------ */ + +test('/__webjs/* framework paths are exempt from canonicalization', async () => { + const appDir = makeApp({ + 'package.json': pkg({ trailingSlash: 'never' }), + 'app/page.js': page('

home

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + const resp = await app.handle(new Request('http://x/__webjs/health/')); + // The health probe path is infrastructure, not an app URL: it must not be + // 308-redirected by the slash policy. + assert.notEqual(resp.status, 308); +}); + +/* ------------------- SECURITY: open-redirect guard ------------------- */ + +// A request path that is a network-path reference (`//host`, or `/\host` which +// the URL parser normalizes to `//host`) must NOT be canonicalized: stripping +// the trailing slash off `//attacker.com/` would emit `Location: //attacker.com`, +// a protocol-relative URL the browser resolves to a FOREIGN origin (an open +// redirect). The guard returns null so the path falls through to the router. + +test('never: //attacker.com/ does NOT produce a cross-origin redirect', async () => { + const appDir = makeApp({ + 'package.json': pkg({ trailingSlash: 'never' }), + 'app/page.js': page('

home

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + const resp = await app.handle(new Request('http://victim.example//attacker.com/')); + // The headline assertion: no 3xx with a `//`-prefixed / cross-origin Location. + // COUNTERFACTUAL: drop the isSameOriginPath guard and this 308s to + // `//attacker.com`, an open redirect, so this fails. + assert.notEqual(resp.status, 308); + const loc = resp.headers.get('location'); + if (loc) assert.ok(!loc.startsWith('//'), `unexpected protocol-relative Location: ${loc}`); +}); + +test('never: /\\evil.com/ (backslash) does NOT produce a cross-origin redirect', async () => { + const appDir = makeApp({ + 'package.json': pkg({ trailingSlash: 'never' }), + 'app/page.js': page('

home

'), + }); + const app = await createRequestHandler({ appDir, dev: true }); + // The URL parser normalizes the backslash to `/`, so the path is `//evil.com/`. + const resp = await app.handle(new Request('http://victim.example/\\evil.com/')); + assert.notEqual(resp.status, 308); + const loc = resp.headers.get('location'); + if (loc) assert.ok(!loc.startsWith('//'), `unexpected protocol-relative Location: ${loc}`); +}); + +test('applyTrailingSlash returns null for a network-path-reference path (no open redirect)', () => { + // Direct-call coverage of the guard under both active policies. + assert.equal( + applyTrailingSlash(new Request('http://victim.example//attacker.com/'), 'never'), + null + ); + assert.equal( + applyTrailingSlash(new Request('http://victim.example//attacker.com'), 'always'), + null + ); + // The backslash form (parser-normalized to `//evil.com/`) is rejected too. + assert.equal( + applyTrailingSlash(new Request('http://victim.example/\\evil.com/'), 'never'), + null + ); +}); + +/* ----------------------------- unit-level ----------------------------- */ + +test('readTrailingSlashPolicy normalizes config values', () => { + assert.equal(readTrailingSlashPolicy({ webjs: { trailingSlash: 'never' } }), 'never'); + assert.equal(readTrailingSlashPolicy({ webjs: { trailingSlash: 'always' } }), 'always'); + assert.equal(readTrailingSlashPolicy({ webjs: { trailingSlash: 'ignore' } }), 'ignore'); + // absent / malformed / unknown -> the non-breaking 'ignore' default + assert.equal(readTrailingSlashPolicy({}), 'ignore'); + assert.equal(readTrailingSlashPolicy({ webjs: {} }), 'ignore'); + assert.equal(readTrailingSlashPolicy({ webjs: { trailingSlash: 'maybe' } }), 'ignore'); + assert.equal(readTrailingSlashPolicy(null), 'ignore'); +}); + +test('applyTrailingSlash returns null for ignore policy and for canonical paths', () => { + assert.equal(applyTrailingSlash(new Request('http://x/about/'), 'ignore'), null); + assert.equal(applyTrailingSlash(new Request('http://x/about'), 'never'), null); + assert.equal(applyTrailingSlash(new Request('http://x/about/'), 'always'), null); + assert.equal(applyTrailingSlash(new Request('http://x/'), 'never'), null); + assert.equal(applyTrailingSlash(new Request('http://x/'), 'always'), null); +}); + +test('applyTrailingSlash preserves the hash on the redirect', () => { + // A hash never reaches the server in a real request, but the helper keeps + // it for completeness / direct callers. + const resp = applyTrailingSlash(new Request('http://x/about/?q=1'), 'never'); + assert.equal(resp.status, 308); + assert.equal(resp.headers.get('location'), '/about?q=1'); +});