diff --git a/docs/1.docs/6.server-entry.md b/docs/1.docs/6.server-entry.md index 3649727c89..f55b0870e3 100644 --- a/docs/1.docs/6.server-entry.md +++ b/docs/1.docs/6.server-entry.md @@ -137,7 +137,7 @@ For Node.js frameworks that use `(req, res)` style handlers (like [Express](http ## Server options -When the server entry's default export is a plain object, every property other than `fetch` is passed as-is to the [srvx](https://srvx.h3.dev/) server started by the [Node.js](/deploy/runtimes/node), [Bun](/deploy/runtimes/bun) and [Deno](/deploy/runtimes/deno) presets. This gives you control over the server itself: `middleware` and `plugins` that run for every request (before Nitro), `tls`, `maxRequestBodySize`, `trustProxy`, `gracefulShutdown`, runtime specific settings (`node`, `bun`, `deno`), and so on. +When the server entry's default export is a plain object, every property other than `fetch` is a [srvx](https://srvx.h3.dev/) server option. This gives you control over the server itself: `middleware` and `plugins` that run for every request (before Nitro), an `error` handler, `tls`, `maxRequestBodySize`, `trustProxy`, `gracefulShutdown`, runtime specific settings (`node`, `bun`, `deno`), and so on. Use the `defineServerEntry` helper for typed options: @@ -163,9 +163,15 @@ export default defineServerEntry({ ::read-more{to="https://srvx.h3.dev/guide/options" title="srvx server options"} :: +The [Node.js](/deploy/runtimes/node), [Bun](/deploy/runtimes/bun) and [Deno](/deploy/runtimes/deno) presets pass all options to the srvx server they start. + +`middleware`, `plugins` and `error` are also applied by every other preset, including serverless, edge and worker runtimes that do not start a server, and by direct `useNitroApp().fetch()` calls. Other options (such as `port`, `tls`, `maxRequestBodySize`, `trustProxy`, `gracefulShutdown`, `node`, `bun` and `deno`) have no effect there. + ::note - `NITRO_PORT`/`PORT`, `NITRO_HOST`/`HOST` and `NITRO_SSL_CERT`/`NITRO_SSL_KEY` environment variables take precedence over the `port`, `hostname` and `tls` options, so the server stays configurable at runtime. -- During development (`nitro dev`), options are applied to the dev worker server, except listener options (`port`, `hostname`, `protocol`, `tls`, `silent`, `gracefulShutdown`) which are controlled by the dev server and CLI (`--port`, `--host`). The Vite dev server applies none of them. +- During development (`nitro dev`), options are applied to the dev worker server, except listener options (`port`, `hostname`, `protocol`, `tls`, `silent`, `gracefulShutdown`) which are controlled by the dev server and CLI (`--port`, `--host`). The Vite dev server only applies `middleware`, `plugins` and `error`. +- On presets that do not start a server, plugins are called once, on the first request, with a minimal server object that only has `runtime` (`"generic"`) and `options`. Plugins that depend on a specific runtime adapter (such as `srvx/mtls`, which requires Node.js) only work on presets that start that server. +- Internal requests (`serverFetch()`, `fetch("/...")` from server code) and WebSocket upgrades do not run server entry `middleware`. - `manual` is not supported: presets start listening immediately. - Options are only read from plain object exports (not from framework instances like `export default app`) and never from Node.js format entries (`server.node.ts`). :: diff --git a/src/build/virtual/app.ts b/src/build/virtual/app.ts index a1500ab6c1..248f725cc9 100644 --- a/src/build/virtual/app.ts +++ b/src/build/virtual/app.ts @@ -11,6 +11,10 @@ export default function app(nitro: Nitro) { const hasPlugins = nitro.options.plugins.length > 0; const hasHooks = nitro.options.features?.runtimeHooks ?? hasPlugins; const hasAsyncContext = !!nitro.options.experimental.asyncContext; + const hasServerEntry = + !!nitro.options.serverEntry && + !!nitro.options.serverEntry.handler && + nitro.options.serverEntry.format !== "node"; const routingImports = [ hasRoutes && "findRoute", @@ -101,10 +105,15 @@ export default function app(nitro: Nitro) { ); } + if (hasServerEntry) { + imports.push(`import { withServerEntryOptions } from "#nitro/runtime/app-fetch";`); + } + code.push( ``, ` return {`, - ` fetch: appHandler,`, + ` fetch: ${hasServerEntry ? "withServerEntryOptions(appHandler)" : "appHandler"},`, + ` "~fetch": appHandler,`, ` h3: h3App,`, ` hooks: ${hasHooks ? "hooks" : "undefined"},`, ` captureError,`, diff --git a/src/presets/_nitro/runtime/nitro-dev.ts b/src/presets/_nitro/runtime/nitro-dev.ts index 6bae75145b..4d23cd9adb 100644 --- a/src/presets/_nitro/runtime/nitro-dev.ts +++ b/src/presets/_nitro/runtime/nitro-dev.ts @@ -4,6 +4,7 @@ import { useNitroApp, useNitroHooks } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; +import { appFetchPlugin } from "#nitro/runtime/serve"; import { tracingSrvxPlugins } from "#nitro/virtual/tracing"; import { serverEntryOptions } from "#nitro/virtual/server-entry"; @@ -27,8 +28,8 @@ const ws = import.meta._websocket export default { ...serverEntryOptions, - fetch: nitroApp.fetch, - plugins: [...tracingSrvxPlugins, ...(serverEntryOptions.plugins || [])], + fetch: nitroApp["~fetch"], + plugins: [...tracingSrvxPlugins, ...(serverEntryOptions.plugins || []), appFetchPlugin], upgrade: ws ? (context: { node: { req: any; socket: any; head: any } }) => { ws.handleUpgrade(context.node.req, context.node.socket, context.node.head); diff --git a/src/presets/bun/runtime/bun.ts b/src/presets/bun/runtime/bun.ts index 2b78fcb355..7daca4b835 100644 --- a/src/presets/bun/runtime/bun.ts +++ b/src/presets/bun/runtime/bun.ts @@ -12,7 +12,7 @@ import { setupCloseHooks } from "#nitro/runtime/shutdown"; const nitroApp = useNitroApp(); -let _fetch = nitroApp.fetch; +let _fetch = nitroApp["~fetch"]; const ws = import.meta._websocket ? wsAdapter({ resolve: resolveWebsocketHooks }) : undefined; @@ -21,7 +21,7 @@ if (import.meta._websocket) { if (req.headers.get("upgrade") === "websocket") { return ws!.handleUpgrade(req, req.runtime!.bun!.server) as Promise; } - return nitroApp.fetch(req); + return nitroApp["~fetch"](req); }; } diff --git a/src/presets/deno/runtime/deno-server.ts b/src/presets/deno/runtime/deno-server.ts index fe45ec783e..9b3650865a 100644 --- a/src/presets/deno/runtime/deno-server.ts +++ b/src/presets/deno/runtime/deno-server.ts @@ -12,7 +12,7 @@ import { setupCloseHooks } from "#nitro/runtime/shutdown"; const nitroApp = useNitroApp(); -let _fetch = nitroApp.fetch; +let _fetch = nitroApp["~fetch"]; if (import.meta._websocket) { const { handleUpgrade } = wsAdapter({ resolve: resolveWebsocketHooks }); @@ -20,7 +20,7 @@ if (import.meta._websocket) { if (req.headers.get("upgrade") === "websocket") { return handleUpgrade(req, req.runtime!.deno!.info); } - return nitroApp.fetch(req); + return nitroApp["~fetch"](req); }; } diff --git a/src/presets/node/runtime/node-cluster.ts b/src/presets/node/runtime/node-cluster.ts index 6fd840e6fb..accb1ae69e 100644 --- a/src/presets/node/runtime/node-cluster.ts +++ b/src/presets/node/runtime/node-cluster.ts @@ -19,7 +19,7 @@ const nitroApp = useNitroApp(); const server = serve( resolveServeOptions({ - fetch: nitroApp.fetch, + fetch: nitroApp["~fetch"], node: { exclusive: false }, ...(clusterId && clusterId !== "1" ? { silent: true } : {}), }) diff --git a/src/presets/node/runtime/node-server.ts b/src/presets/node/runtime/node-server.ts index e9fd5226ea..d0d188c041 100644 --- a/src/presets/node/runtime/node-server.ts +++ b/src/presets/node/runtime/node-server.ts @@ -11,7 +11,7 @@ import { setupCloseHooks } from "#nitro/runtime/shutdown"; const nitroApp = useNitroApp(); -const server = serve(resolveServeOptions({ fetch: nitroApp.fetch })); +const server = serve(resolveServeOptions({ fetch: nitroApp["~fetch"] })); if (import.meta._websocket) { const { handleUpgrade } = wsAdapter({ resolve: resolveWebsocketHooks }); diff --git a/src/runtime/internal/app-fetch.ts b/src/runtime/internal/app-fetch.ts new file mode 100644 index 0000000000..b747f2586f --- /dev/null +++ b/src/runtime/internal/app-fetch.ts @@ -0,0 +1,59 @@ +import type { Server, ServerHandler, ServerRequest } from "srvx"; +import { serverEntryOptions } from "#nitro/virtual/server-entry"; + +/** + * Apply server entry `middleware`, `plugins` and `error` options to the Nitro app fetch handler. + * + * Plugins are called with a minimal server object (`runtime` and `options`) on first request. + * Presets starting a srvx server replace it (see `appFetchPlugin`). + */ +export function withServerEntryOptions< + T extends (req: ServerRequest) => Response | Promise, +>(fetch: T): T { + const { middleware, plugins, error } = serverEntryOptions; + if (!middleware?.length && !plugins?.length && !error) { + return fetch; + } + let handler: ServerHandler | undefined; + return ((req: ServerRequest) => (handler ??= createGenericFetch(fetch))(req)) as T; +} + +function createGenericFetch(fetch: ServerHandler): ServerHandler { + const server = { + runtime: "generic", + options: { + ...serverEntryOptions, + fetch, + middleware: [...(serverEntryOptions.middleware || [])], + }, + } as unknown as Server; + try { + for (const plugin of serverEntryOptions.plugins || []) { + plugin(server); + } + } catch (error) { + return () => Promise.reject(error); + } + return composeFetch(server, true); +} + +export function composeFetch(server: Server, withError: boolean): ServerHandler { + const { middleware, error } = server.options; + let handler = server.options.fetch; + for (let i = middleware.length - 1; i >= 0; i--) { + const mw = middleware[i]!; + const next = handler; + handler = (req) => mw(req, () => next(req)); + } + if (withError && error) { + const next = handler; + handler = async (req) => { + try { + return await next(req); + } catch (error_) { + return error(error_); + } + }; + } + return handler; +} diff --git a/src/runtime/internal/app.ts b/src/runtime/internal/app.ts index 51c6d3df3c..5f72818b2e 100644 --- a/src/runtime/internal/app.ts +++ b/src/runtime/internal/app.ts @@ -45,7 +45,7 @@ export function serverFetch( ): Promise { const req = toRequest(resource, init); req.context = { ...req.context, ...context } as ServerRequestContext; - const appHandler = useNitroApp().fetch; + const appHandler = useNitroApp()["~fetch"]; try { return Promise.resolve(appHandler(req)); } catch (error) { diff --git a/src/runtime/internal/serve.ts b/src/runtime/internal/serve.ts index e10c0c5469..2b517a43a8 100644 --- a/src/runtime/internal/serve.ts +++ b/src/runtime/internal/serve.ts @@ -1,6 +1,8 @@ -import type { ServerOptions } from "srvx"; +import type { ServerHandler, ServerOptions, ServerPlugin } from "srvx"; import { serverEntryOptions } from "#nitro/virtual/server-entry"; import { tracingSrvxPlugins } from "#nitro/virtual/tracing"; +import { useNitroApp } from "./app.ts"; +import { composeFetch } from "./app-fetch.ts"; /** * Resolve srvx `serve()` options for a server preset. @@ -8,6 +10,8 @@ import { tracingSrvxPlugins } from "#nitro/virtual/tracing"; * Options exported from the server entry (`export default { fetch, ...options }`) are the base. * `NITRO_PORT`/`PORT`, `NITRO_HOST`/`HOST` and `NITRO_SSL_CERT`/`NITRO_SSL_KEY` take precedence * over them, and the preset's own options (`fetch` and runtime specific settings) win last. + * + * `useNitroApp().fetch` is pointed to the started server's middleware (see {@link appFetchPlugin}). */ export function resolveServeOptions(opts: ServerOptions): ServerOptions { const { port, hostname, tls, plugins, ...entryOptions } = serverEntryOptions; @@ -24,7 +28,7 @@ export function resolveServeOptions(opts: ServerOptions): ServerOptions { hostname: env.NITRO_HOST || env.HOST || hostname, tls: cert && key ? { cert, key } : tls, ...opts, - plugins: [...tracingSrvxPlugins, ...(plugins || []), ...(opts.plugins || [])], + plugins: [...tracingSrvxPlugins, ...(plugins || []), ...(opts.plugins || []), appFetchPlugin], }; for (const runtime of ["node", "bun", "deno"] as const) { @@ -35,3 +39,14 @@ export function resolveServeOptions(opts: ServerOptions): ServerOptions { return resolved; } + +/** + * srvx plugin for presets starting a srvx server: points `useNitroApp().fetch` to the server middleware + * (including middleware added by plugins) around its fetch handler, so direct calls get the same + * options without running plugins again. + */ +export const appFetchPlugin: ServerPlugin = (server) => { + let handler: ServerHandler | undefined; + // Bun and Deno pass `error` to the native server, the Node.js adapter registers it as middleware. + useNitroApp().fetch = (req) => (handler ??= composeFetch(server, server.runtime !== "node"))(req); +}; diff --git a/src/runtime/virtual/app.ts b/src/runtime/virtual/app.ts index a00f13b751..e21a240da0 100644 --- a/src/runtime/virtual/app.ts +++ b/src/runtime/virtual/app.ts @@ -18,12 +18,14 @@ export function createNitroApp(): NitroApp { } } }; + const appHandler = (req: ServerRequest) => { + req.context ||= {}; + req.context.nitro = req.context.nitro || { errors: [] }; + return h3App.fetch(req); + }; return { - fetch: (req: ServerRequest) => { - req.context ||= {}; - req.context.nitro = req.context.nitro || { errors: [] }; - return h3App.fetch(req); - }, + fetch: appHandler, + "~fetch": appHandler, h3: h3App, hooks: undefined, captureError, diff --git a/src/types/runtime/nitro.ts b/src/types/runtime/nitro.ts index c891816eaa..612ce5f268 100644 --- a/src/types/runtime/nitro.ts +++ b/src/types/runtime/nitro.ts @@ -8,7 +8,14 @@ import type { ServerRequest } from "srvx"; * @see https://nitro.build/docs/plugins */ export interface NitroApp { + /** + * Handle a request, including server entry `middleware`, `plugins` and `error` options. + */ fetch: (req: Request) => Response | Promise; + /** + * Handle a request without server entry options (used by srvx server presets, which apply them natively). + */ + "~fetch": (req: Request) => Response | Promise; h3?: H3Core; hooks?: HookableCore; captureError?: CaptureError; diff --git a/test/fixture/server.ts b/test/fixture/server.ts index de257575ec..6d5879dffd 100644 --- a/test/fixture/server.ts +++ b/test/fixture/server.ts @@ -1,4 +1,5 @@ import { defineServerEntry } from "nitro"; +import { srvxPluginRuns } from "./server/utils/srvx-plugin.ts"; export default defineServerEntry({ async fetch(req) { @@ -10,6 +11,7 @@ export default defineServerEntry({ }, // Passed to srvx (node, bun and deno servers) maxRequestBodySize: 64 * 1024, + // Applied by all presets middleware: [ (req, next) => { if (new URL(req.url).pathname === "/srvx-middleware") { @@ -20,10 +22,11 @@ export default defineServerEntry({ ], plugins: [ (server) => { + srvxPluginRuns.count++; server.options.middleware.unshift(async (req, next) => { const res = await next(); if (new URL(req.url).pathname === "/srvx-middleware") { - res.headers.set("x-srvx-plugin", "works"); + res.headers.append("x-srvx-plugin", "works"); } return res; }); diff --git a/test/fixture/server/routes/api/app-fetch.ts b/test/fixture/server/routes/api/app-fetch.ts new file mode 100644 index 0000000000..a3c9fcb3ba --- /dev/null +++ b/test/fixture/server/routes/api/app-fetch.ts @@ -0,0 +1,12 @@ +import { defineHandler } from "nitro"; +import { useNitroApp } from "nitro/app"; +import { srvxPluginRuns } from "../../utils/srvx-plugin.ts"; + +export default defineHandler(async () => { + const res = await useNitroApp().fetch(new Request("http://localhost/srvx-middleware")); + return { + body: await res.text(), + plugin: res.headers.get("x-srvx-plugin"), + pluginRuns: srvxPluginRuns.count, + }; +}); diff --git a/test/fixture/server/utils/srvx-plugin.ts b/test/fixture/server/utils/srvx-plugin.ts new file mode 100644 index 0000000000..e7ff727e90 --- /dev/null +++ b/test/fixture/server/utils/srvx-plugin.ts @@ -0,0 +1 @@ +export const srvxPluginRuns = { count: 0 }; diff --git a/test/minimal/minimal.test.ts b/test/minimal/minimal.test.ts index 4d369d726d..9549f06fa0 100644 --- a/test/minimal/minimal.test.ts +++ b/test/minimal/minimal.test.ts @@ -11,7 +11,7 @@ const tmpDir = fileURLToPath(new URL(".tmp", import.meta.url)); // Rounded up const bundleSizes: Record = { rollup: [19, 10], - rolldown: [19, 10], + rolldown: [20, 10], vite: [19, 10], vite7: [19, 10], }; diff --git a/test/presets/node.test.ts b/test/presets/node.test.ts index 1b8c39d3aa..eede138501 100644 --- a/test/presets/node.test.ts +++ b/test/presets/node.test.ts @@ -61,6 +61,12 @@ describe("nitro:preset:node-server", async () => { const res = await fetch(`http://127.0.0.1:${port}/srvx-middleware`); expect(await res.text()).toBe("server entry middleware works!"); expect(res.headers.get("x-srvx-plugin")).toBe("works"); + const appFetch = await fetch(`http://127.0.0.1:${port}/api/app-fetch`); + expect(await appFetch.json()).toEqual({ + body: "server entry middleware works!", + plugin: "works", + pluginRuns: 1, + }); const large = await fetch(`http://127.0.0.1:${port}/api/body-size`, { method: "POST", body: "x".repeat(128 * 1024), diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index c76f46f63b..46a75cdc53 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -414,6 +414,10 @@ describe("nitro:preset:vercel:web", async () => { "dest": "/api/body-size", "src": "/api/body-size", }, + { + "dest": "/api/app-fetch", + "src": "/api/app-fetch", + }, { "dest": "/500", "src": "/500", @@ -568,6 +572,7 @@ describe("nitro:preset:vercel:web", async () => { "functions/__server.func", "functions/_vercel", "functions/_ws.func (symlink)", + "functions/api/app-fetch.func (symlink)", "functions/api/body-size.func (symlink)", "functions/api/cached.func (symlink)", "functions/api/db.func (symlink)", diff --git a/test/tests.ts b/test/tests.ts index f0129bedd0..fa7a034753 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -249,13 +249,24 @@ export function testNitro( expect(headers["x-test"]).toBe("test"); }); + it("Server entry middleware and plugins are applied", async () => { + const { data, headers } = await callHandler({ url: "/srvx-middleware" }); + expect(data).toBe("server entry middleware works!"); + expect(headers["x-srvx-plugin"]).toBe("works"); + }); + + it("useNitroApp().fetch applies server entry middleware and plugins", async () => { + const { data } = await callHandler({ url: "/api/app-fetch" }); + expect(data).toEqual({ + body: "server entry middleware works!", + plugin: "works", + pluginRuns: 1, + }); + }); + it.runIf(["bun", "deno-server", "nitro-dev"].includes(ctx.preset))( "Server entry options are passed to srvx", async () => { - const { data, headers } = await callHandler({ url: "/srvx-middleware" }); - expect(data).toBe("server entry middleware works!"); - expect(headers["x-srvx-plugin"]).toBe("works"); - const small = await callHandler({ url: "/api/body-size", method: "POST",