Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/app/docs/configuration/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ webjs db studio # prisma studio</pre>
&#125;
&#125;</code></pre>

<h2>Trailing slash</h2>
<p>webjs's file router matches <code>/about</code> and <code>/about/</code> 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 <code>webjs.trailingSlash</code> key in <code>package.json</code>: <code>"never"</code> strips a trailing slash (<code>/about/</code> &rarr; <code>/about</code>, the recommended form for most apps), <code>"always"</code> adds one (<code>/about</code> &rarr; <code>/about/</code>), and <code>"ignore"</code> (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 <strong>308</strong> Permanent Redirect (link equity transfers, and a redirected POST stays a POST). The root <code>/</code> is always left alone; under <code>"always"</code> a path whose last segment looks like a file (has a dot, e.g. <code>/logo.png</code>) is not given a trailing slash. The query string is preserved. Canonicalization runs right after the <code>webjs.redirects</code> rules (so an explicit redirect wins first), at the very start of request handling. There is no server-side loop guard: a redirect whose <code>destination</code> contradicts the slash policy (e.g. <code>"never"</code> with a destination of <code>/x/</code>) creates an infinite redirect loop, so keeping redirect destinations consistent with the policy is your responsibility.</p>
<pre><code>&#123; "webjs": &#123; "trailingSlash": "never" &#125; &#125;</code></pre>

<h2>Request limits &amp; server timeouts</h2>
<p>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 <code>package.json</code> (env overrides win, and a value of <code>0</code> disables that limit / timeout).</p>
<p><strong>Body-size limit (413).</strong> Every request body the server reads (the action RPC endpoint, <code>route.&#123;js,ts&#125;</code> handlers via <code>readBody</code>, and the no-JS page-action form path) is capped. A JSON / RPC body defaults to 1 MiB (<code>webjs.maxBodyBytes</code> or <code>WEBJS_MAX_BODY_BYTES</code>); a form / multipart body defaults to 10 MiB (<code>webjs.maxMultipartBytes</code> or <code>WEBJS_MAX_MULTIPART_BYTES</code>). An over-limit body responds <code>413 Payload Too Large</code> and is never buffered whole: a <code>Content-Length</code> 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.</p>
Expand Down
2 changes: 1 addition & 1 deletion packages/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
43 changes: 42 additions & 1 deletion packages/server/src/dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading