Skip to content

Commit 1b49316

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/vercel-trailing-slash-overrides
# Conflicts: # src/presets/vercel/utils.ts
2 parents 95248bb + 77b77ff commit 1b49316

72 files changed

Lines changed: 10323 additions & 5564 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ All prefixed `#nitro/virtual/<name>`:
8888
- `app.ts` — NitroApp creation, H3 app setup
8989
- `cache.ts` — Response caching
9090
- `context.ts` — Async context
91-
- `route-rules.ts`Route rule middleware (headers, redirect, proxy, cache, cors)
91+
- `route-rule-handlers.ts`Rule handlers for the compiled matcher: h3-rules built-ins (headers, redirect, proxy, basicAuth) plus a `cache` handler bound to Nitro's cache runtime. Rule matching/normalization live in the [`h3-rules`](https://github.com/h3js/h3-rules) package.
9292
- `static.ts` — Static file serving
9393
- `task.ts` — Task execution
9494
- `plugin.ts` — Plugin helpers

.agents/vite-dev.md

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# Vite Dev Middleware — Request Routing
2+
3+
> Scope: the request-routing logic in `src/build/vite/dev.ts` — the two
4+
> `server.middlewares.use(...)` registrations (`nitroDevMiddlewarePre` and the
5+
> catch-all `nitroDevMiddleware`): why the current design looks the way it does.
6+
7+
## 1. Where it lives & how it is wired
8+
9+
- Logic: `src/build/vite/dev.ts``configureViteDevServer(ctx, server)`.
10+
- Entry: `src/build/vite/plugin.ts:266` — the plugin's `configureServer` hook
11+
`return`s the result of `configureViteDevServer`.
12+
13+
Vite's `configureServer` contract: middleware registered **synchronously**
14+
inside the hook runs **before** Vite's internal middlewares; a function
15+
**returned** from the hook runs **after** them (the "post" hook). Nitro uses
16+
both ends:
17+
18+
| Registration | Site | Runs | Role |
19+
|---|---|---|---|
20+
| `nitroDevMiddlewarePre` (`const` form) | `dev.ts:284` | **before** Vite static/transform | Classifier. Route explicit-Nitro + definite navigations to Nitro immediately; let definite assets fall through to Vite, marking them `_nitroHandled` (transparent catch-all) or `_nitroAssetCheck` (opaque catch-all / no match). |
21+
| `nitroDevMiddleware` | defined at `dev.ts:213`, registered inside the returned `() => { ... }` | **after** Vite static/transform | Catch-all fallback. Wraps req as web `Request`, tries `ctx.devApp.fetch` then `nitroEnv.dispatchFetch`, honoring `baseURL`; inspects the response for `_nitroAssetCheck` requests. Skipped for `_nitroHandled`. |
22+
23+
**Why two, and why pre?** Without the pre-pass, Vite's static/transform
24+
middleware serves files from the project root and would answer server routes
25+
before Nitro sees them (see upstream **vitejs/vite#20866**, which made Vite
26+
consult `sec-fetch-dest` to let document requests fall through — the same
27+
header Nitro's classifier leans on).
28+
29+
Facts about Vite's side (verified against vite@8.1.4): Nitro defaults
30+
`appType` to `"custom"` (`plugin.ts:199` — user-overridable; the reasoning
31+
below assumes the default), so Vite registers none of
32+
`htmlFallback`/`indexHtml`/`notFound` middlewares, and **every plain miss
33+
`next()`s** (sirv and transformMiddleware never self-emit a 404 for a missing
34+
file/module). The only terminal 404 is connect's `finalhandler`, which sits
35+
**after** the Nitro post-hook. So Vite genuinely hands off every request it
36+
cannot serve — the post catch-all is the last handler before `finalhandler`.
37+
38+
## 2. Classification
39+
40+
`nitro.routing.routes` in an SSR app **always** contains a catch-all `/**`, so
41+
"does a Nitro route match?" is nearly always *yes*. The design distinguishes:
42+
43+
- **Explicit route**`route` set, `!== "/**"`, not `startsWith("/**:")` (a
44+
prefixed splat like `/api/photos/**` is explicit). Deterministic → **always
45+
Nitro**, no heuristic may override (#4108, #4241, #4252, #4270).
46+
- **Explicit public asset** — a public-asset dir under a non-root `baseURL`
47+
with `fallthrough: false` owns its subtree → Nitro, like an explicit route.
48+
- **Transparent catch-all** — a *user route file* at root level
49+
(`routes/[...].ts``/**`, `routes/[...slug].ts``/**:slug`). Nitro sees
50+
everything it can handle, so Vite stays the definitive asset handler: an
51+
asset-tagged request is marked `_nitroHandled` and a Vite miss must **not**
52+
fall back into it (#4234/#4266).
53+
- **Opaque catch-all** — the SSR renderer `/**` or a custom `serverEntry` `/**`
54+
(both registered in `routing.ts` with identifiable `handler` paths). Their
55+
*real* routes are invisible to the host (`server.ts` H3 app routes, framework
56+
routers like TanStack Start), so an asset-tagged miss from Vite must still be
57+
**dispatched** — marked `_nitroAssetCheck`, decided by response inspection.
58+
- **None** — no match. Extensionless → page navigation → Nitro; asset-tagged →
59+
same as opaque (`_nitroAssetCheck`, dispatch after Vite).
60+
61+
## 3. The asset heuristic (catch-all / none case only)
62+
63+
Set `Vary: sec-fetch-dest, accept`, then:
64+
65+
- **`Sec-Fetch-Dest` present & concrete** (not `empty`): `document`/`iframe`/
66+
`frame` = navigation → Nitro; anything else (`image`, `video`, `style`, …) =
67+
asset.
68+
- **Absent or `empty`**: a Vite `?import` module-graph marker
69+
(`/[?&]import(?:[&=]|$)/` on the URL) ⇒ asset — only Vite's module graph emits
70+
it, never a page navigation, so it stays authoritative for extensions left out
71+
of `ASSET_EXT_RE` like imported `.json` (#4433). Otherwise fall back to
72+
extension — `ASSET_EXT_RE.test(ext)` **and** no `text/html` in `Accept`
73+
asset. (`empty` = fetch/XHR is ambiguous: it tags both API calls and
74+
`fetch()`ed assets.)
75+
- **Only `GET`/`HEAD` can be assets at all** — a `POST /upload.png` is never a
76+
browser asset load, so other methods bypass the heuristic entirely.
77+
- Non-asset + (matched or extensionless) → Nitro immediately (pre-Vite).
78+
79+
Two regexes to keep intact:
80+
- `ASSET_EXT_RE` (`dev.ts:24`) — narrow on purpose, so dotted Nitro params like
81+
`/foo.bar.1` still reach Nitro (#4108).
82+
- Extension is extracted from the path only (query/hash stripped) so
83+
`?file=bar.png` does not misclassify.
84+
85+
## 4. Response inspection (`_nitroAssetCheck`)
86+
87+
An asset-tagged request that only an opaque catch-all could handle cannot be
88+
classified **a priori**`/image.png` may be a real custom-entry route (#4252)
89+
or a genuinely missing asset that a naive SSR `/**` would render as a 200 page
90+
(#4234). It *can* be classified from the **response**: after Vite declines and
91+
the post catch-all dispatches to Nitro, a 2xx with a `text/html` content-type
92+
(inline check in the post middleware) means the catch-all rendered a page for a
93+
missing asset → the response is discarded via `next()` (connect `finalhandler`
94+
404, same as before). Anything else — real asset types, JSON, `text/plain`, no
95+
content-type, non-2xx (framework 404 pages, redirects) — passes through
96+
verbatim.
97+
98+
Deny-list rationale (all verified empirically):
99+
100+
- Only `text/html` counts as a swallow. `application/json` was originally
101+
denied too, but that broke real opaque frameworks: TanStack Start answers
102+
API routes tagged as asset loads with JSON on purpose
103+
(`<img src="/api/.../thumbnail">`, TanStack/router#7403, nitro PR #4274),
104+
and sourcemaps (`.map``ASSET_EXT_RE`) are legitimately JSON. The
105+
accepted trade-off: an SSR entry that swallows missing assets with *JSON*
106+
(pathological — real naive SSR renders HTML) now returns 200 JSON instead
107+
of 404 in dev.
108+
- `text/plain` is excluded because a bare string returned from an h3 handler
109+
has **no** content-type at the h3 layer but arrives as `text/plain;
110+
charset=UTF-8` — srvx's node-adapter default applied on the worker's HTTP hop
111+
(`srvx/dist/adapters/node.mjs`; runner-dependent but converges) — and must
112+
pass through (custom-entry handlers returning strings).
113+
- Transparent (user-file) catch-alls can **not** use inspection instead of the
114+
divert: their string 200s arrive with no distinguishing content-type.
115+
116+
This keeps everything zero-config, host-side only (no runtime/bundle change,
117+
no production behavior change). Known leftovers:
118+
119+
- **Production** SSR still renders 200 HTML for missing-asset URLs —
120+
pre-existing behavior; changing it is a separate, deliberate breaking-change
121+
discussion.
122+
- Opaque semantics require registration via `renderer` / `serverEntry`. A `/**`
123+
catch-all added through `routes:` / `handlers:` config or a module is
124+
classified **transparent** and stays pre-empted for asset-tagged requests
125+
(#4252-class limitation) — a framework integrating its renderer that way
126+
should use `renderer` instead.
127+
- Dev-only cost: a missing asset matching an opaque catch-all triggers a full
128+
(discarded) SSR render before the 404.
129+
130+
## 5. Issue / PR lineage (chronological)
131+
132+
- **#3649** first crude "has extension ⇒ Vite" rule; **#3804/#3805/#3817**
133+
middleware improvements, mounted-path skip, internal-prefix skip (survives as
134+
the `^\/(?:__|@)` guard); **#4098** `sec-websocket-protocol` to tell Vite HMR
135+
sockets from Nitro websockets (the `upgrade` handler).
136+
- **#4108** baseURL-aware matching for dotted Nitro routes
137+
(TanStack/router#6903).
138+
- **#4234** non-loopback plain-HTTP origins omit `Sec-Fetch-*` → splat swallowed
139+
`<script src>` loads. Fixed by **#4238**: `Accept` + `ASSET_EXT_RE` fallback +
140+
the `_nitroHandled` marker.
141+
- **#4241** #4238 over-eager: `sec-fetch-dest: image` on a real route
142+
(`/api/image`) sent to Vite → 404. Fixed by `7d49dcae` + `08f2ec69`
143+
(query-string stripped before extension matching).
144+
- **#4252 / #4270** even explicit routes lost when the URL had an asset
145+
extension. Fixed by **#4272** (`5b7e152b`): explicit vs catch-all vs none
146+
classification — **an explicit route is a deterministic win no heuristic can
147+
touch**.
148+
- `7765bcb7` added `isExplicitPublicAsset`.
149+
- **#4252 follow-ups** (katywings, jantimon/TanStack/router#7403): routes the
150+
classifier cannot see — a custom `server.ts` H3 app serving `/image.png`, or
151+
an SSR framework serving assets — were pre-empted by `_nitroHandled` and
152+
could never run. Fixed by the opaque-catch-all + response-inspection design
153+
above (§2, §4): the pre-emption now applies only to transparent user-file
154+
catch-alls, and opaque dispatches are judged by their response content-type.
155+
156+
## 6. Runtime dispatch flow
157+
158+
The catch-all `nitroDevMiddleware` dispatches in two stages:
159+
160+
1. **`ctx.devApp.fetch(req)`**`NitroDevApp` (`src/dev/app.ts`), host-side:
161+
`devHandlers`, `/_vfs/**`, `/_nitro/tasks`, public asset dirs, `devProxy`.
162+
No catch-all → a 404 means "not mine, hand off"; any non-404 is returned
163+
directly (never inspected — deterministic host serves are authoritative).
164+
2. **`nitroEnv.dispatchFetch(req)`** → env-runner → worker → `nitroApp.fetch`
165+
full route table, middleware, catch-alls.
166+
167+
Catch-all registration (`src/routing.ts`): a custom `serverEntry` is pushed as
168+
`/**` with `handler = nitro.options.serverEntry.handler`; the SSR renderer as
169+
`/**` with `handler = nitro.options.renderer.handler` (set by `plugin.ts`
170+
`configResolved` to `internal/vite/ssr-renderer` when an `ssr` service exists).
171+
These handler paths are how the pre-pass identifies opaque catch-alls.
172+
173+
## 7. Test coverage map
174+
175+
All under `test/vite/`; run via `test:rollup` and `test:rolldown`. Each starts
176+
a real Vite dev server and `fetch()`es with hand-set headers.
177+
178+
- `app.test.ts` (`app-fixture/`) — SSR `/**` path. #4234 swallow contracts
179+
(missing `.css`/`.js` under `style`/absent/`empty` dests must not 200 — the
180+
fixture entry renders an HTML page for extensioned misses), JSON API routes
181+
under `sec-fetch-dest: image` pass through (TanStack/router#7403), #4252
182+
deliberate asset serve (`/dynamic-asset.png``image/png` passes
183+
inspection), `HTTPError` propagation, navigations, storage/config sharing.
184+
- `server-entry.test.ts` (`server-entry-fixture/`) — #4252 custom `server.ts`
185+
H3 app: asset-extensioned routes reachable under `image`/absent/`script`
186+
dests (including a no-content-type string return), JSON sourcemap
187+
(`/generated.js.map`) passes through, `POST` to an asset-extensioned route
188+
reaches its handler (non-GET/HEAD are never assets), missing-asset 404
189+
contract, navigation.
190+
- `root-wildcard.test.ts` — transparent root catch-all `/**:path`: must never
191+
200 for `/entry-client.ts` under `script`/`style`/`image`/absent dests
192+
(#4234/#4266), navigations still reach it.
193+
- `baseurl-dotted-param.test.ts` — explicit splat routes under `baseURL:
194+
/subdir/`: #4241, #4252, #4270, query-string extension, unmatched asset →
195+
Vite.
196+
- Tangential: `hmr.test.ts` (existing module served by Vite under `script`
197+
dest), `openapi.test.ts` (explicit routes), others (build/env, not routing).
198+
199+
## 8. Gotchas
200+
201+
- The `server.middlewares.stack` scan in `nitroDevMiddleware` skips requests
202+
whose URL starts with any other middleware's mounted `base` (#3805).
203+
- `_nitroHandled` is a one-way latch set by the pre-pass (transparent
204+
catch-all asset) and by the post catch-all itself (re-entry guard). Never set
205+
it for a request a real or opaque handler should still see (#4252 root
206+
cause).
207+
- Extension detection must stay path-only (strip `?#`) and `ASSET_EXT_RE` must
208+
stay narrow, or dotted Nitro params regress (#4108).
209+
- The inspection's html-only check must not grow `text/plain` (bridge default
210+
for string returns) or `application/json` (deliberate API serves, #7403), and it must only apply to `envRes.ok` — framework 404 pages
211+
and redirects pass through verbatim.
212+
- The websocket `upgrade` handler is a separate concern sharing the "Vite's or
213+
Nitro's?" theme.

.gitattributes

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1+
# Files imported verbatim (`raw:`, `bytes:`, `text:`) must not have their line endings rewritten
12
*.txt text eol=lf
3+
*.json text eol=lf
4+
*.sql text eol=lf
5+
*.bin binary

.github/workflows/ci.yml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ on:
77
permissions: {}
88

99
jobs:
10+
lint:
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 10
13+
steps:
14+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
15+
with: { persist-credentials: false }
16+
- run: npm i -g --force corepack && corepack enable
17+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
18+
with: { node-version: lts/*, cache: pnpm }
19+
- run: pnpm install
20+
- run: pnpm stub && pnpm lint
1021
tests-checks:
1122
runs-on: ${{ matrix.os }}
1223
timeout-minutes: 10
@@ -48,8 +59,6 @@ jobs:
4859
with: { deno-version: 2.8.3 }
4960
- run: node scripts/vite7.ts
5061
- run: pnpm install
51-
- run: pnpm stub && pnpm lint
52-
if: ${{ matrix.os != 'windows-latest' }}
5362
- run: pnpm build
5463
- parallel:
5564
- run: pnpm vitest run test/examples
@@ -86,7 +95,7 @@ jobs:
8695
publish-pkg-pr-new:
8796
runs-on: ubuntu-latest
8897
timeout-minutes: 10
89-
needs: [tests-checks, tests-rollup, tests-rolldown]
98+
needs: [lint]
9099
steps:
91100
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
92101
with: { fetch-depth: 0, persist-credentials: false }

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Add server routes, deploy across multiple platforms, and enjoy a **zero-config**
1313

1414
## Contributing
1515

16-
See Check out the [Contribution Guide](./CONTRIBUTING.md) to get started.
16+
Check out the [Contribution Guide](./CONTRIBUTING.md) to get started.
1717

1818
## License
1919

docs/.docs/components/AppHeroLinks.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
:href="`${baseURL}llms.txt`"
2222
target="_blank"
2323
class="basis-full text-sm text-muted hover:text-default transition-colors inline-flex items-center justify-center gap-1"
24-
@click.prevent="copyPrompt"
24+
@click="copyPrompt"
2525
>
2626
<UIcon :name="copied ? 'i-lucide-clipboard-check' : 'i-lucide-bot'" />
2727
Docs for AI

docs/.npmrc

Lines changed: 0 additions & 1 deletion
This file was deleted.

docs/1.docs/5.routing.md

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ See [`inlineDynamicImports`](/config#inlinedynamicimports) to bundle everything
435435

436436
Nitro allows you to add logic at the top-level for each route of your configuration. It can be used for redirecting, proxying, caching, authentication, and adding headers to routes.
437437

438-
It is a map from route pattern (following [rou3](https://github.com/h3js/rou3)) to route options.
438+
It is a map from route pattern (following [rou3](https://github.com/h3js/rou3)) to route options and based on [`h3-rules`](https://github.com/h3js/h3-rules).
439439

440440
When `cache` option is set, handlers matching pattern will be automatically wrapped with `defineCachedHandler`. See the [cache guide](/docs/cache) to learn more about this function.
441441

@@ -484,6 +484,29 @@ export default defineConfig({
484484
});
485485
```
486486

487+
### Method-scoped rules
488+
489+
Prefix a rule key with an uppercase HTTP method (followed by a space) to scope it to requests using that method. Keys without a method prefix apply to every method. A method-scoped rule is merged on top of the method-agnostic rules that match the same path, so you can layer method-specific behavior over shared defaults:
490+
491+
```ts [nitro.config.ts]
492+
import { defineConfig } from "nitro";
493+
494+
export default defineConfig({
495+
routeRules: {
496+
// Applies to every method
497+
'/api/**': { headers: { 'x-api': 'true' } },
498+
// Only POST requests to /api/** additionally require auth
499+
'POST /api/**': { basicAuth: { username: 'admin', password: 'secret' } },
500+
// Cache GET requests to /feed only
501+
'GET /feed': { swr: 600 },
502+
}
503+
});
504+
```
505+
506+
::note
507+
Method matching is resolved per request by the server runtime. Platform-native static generation (e.g. Netlify/Cloudflare `_headers` & `_redirects`, Vercel `config.json`) does not split by method, so prefer method-agnostic keys for `headers`/`redirect`/`proxy` rules you expect a platform to emit into its static config.
508+
::
509+
487510
### Headers
488511

489512
Set custom response headers for matching routes:
@@ -501,18 +524,24 @@ export default defineConfig({
501524

502525
### CORS
503526

504-
Enable CORS headers with the `cors: true` shortcut. This sets `access-control-allow-origin: *`, `access-control-allow-methods: *`, `access-control-allow-headers: *`, and `access-control-max-age: 0`.
527+
Handle CORS at runtime with the `cors` rule. `cors: true` applies permissive defaults (origin, methods, and allowed headers `*`): a simple request gets `access-control-allow-origin: *` and `access-control-expose-headers: *`, and an `OPTIONS` preflight is answered directly (`204`) with the matching `access-control-allow-*` headers.
528+
529+
> [!NOTE]
530+
> CORS is applied by the running server (h3's [`handleCors`](https://h3.dev/utils/security#handlecorsevent-options)), and not from the static/CDN config.
531+
> On platforms that can serve prerendered/static assets straight from the edge, CORS headers are only added when the request reaches the server handler.
505532
506-
You can override individual CORS headers using `headers`:
533+
Pass an object for finer control — an origin allowlist, `credentials`, `maxAge`, etc. (h3 `CorsOptions`). Combining `credentials: true` with a wildcard origin is invalid and throws at build time:
507534

508535
```ts [nitro.config.ts]
509536
import { defineConfig } from "nitro";
510537

511538
export default defineConfig({
512539
routeRules: {
540+
// Permissive defaults
541+
'/api/public/**': { cors: true },
542+
// Restrict to specific origins with credentials
513543
'/api/v1/**': {
514-
cors: true,
515-
headers: { 'access-control-allow-methods': 'GET' },
544+
cors: { origin: ['https://app.example.com'], credentials: true },
516545
},
517546
}
518547
});
@@ -660,9 +689,9 @@ export default defineConfig({
660689
| `headers` | `Record<string, string>` | Custom response headers |
661690
| `redirect` | `string \| { to: string, status?: number }` | Redirect to another URL (default status: `307`) |
662691
| `proxy` | `string \| { to: string, ...proxyOptions }` | Proxy requests to another URL |
663-
| `cors` | `boolean` | Enable permissive CORS headers |
692+
| `cors` | `boolean \| CorsOptions` | Handle CORS via h3's [`handleCors`](https://h3.dev/utils/security#handlecorsevent-options) (`true` = permissive) |
664693
| `cache` | `object \| false` | Cache options (see [cache guide](/docs/cache)) |
665-
| `swr` | `boolean \| number` | Shortcut for `cache: { swr: true, maxAge: number }` |
694+
| `swr` | `boolean \| number` | Shortcut for `cache: { swr: true, maxAge: number }` (`false` resets an inherited cache rule) |
666695
| `static` | `boolean \| number` | Shortcut for static caching |
667696
| `basicAuth` | `{ username, password, realm? } \| false` | HTTP Basic Authentication |
668697
| `prerender` | `boolean` | Enable/disable prerendering |

0 commit comments

Comments
 (0)