Skip to content

Commit 2e8d867

Browse files
feat(platform): use Fetchable server entries for analog({ server }) (#2729)
The custom server entry is now a Fetchable module (https://fetchable.org): its default export has fetch(request), and optionally close(), which the dev server calls before reloading the entry. createApp() entries are no longer supported. Node frameworks (Express, NestJS, Fastify) wrap their app with srvx's toFetchHandler(). The NestJS template in create-analog and the custom server and NestJS guides use the new entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3NKyLNGL2d5Df2wt4WNbA
1 parent 82061c9 commit 2e8d867

11 files changed

Lines changed: 234 additions & 148 deletions

File tree

‎apps/docs-analog/src/content/features/server/custom-server.md‎

Lines changed: 76 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Custom Server
22

3-
Serve an Analog app from your own server, such as NestJS, Express, Fastify or Hono, instead of Nitro. Your server owns the process and its routes, and Analog handles server-side rendering, page endpoints and server functions inside it.
3+
Serve an Analog app from your own server entry instead of Nitro: a [Fetchable](https://fetchable.org) module that platforms and runtimes invoke directly, or a server built with a framework such as Hono, Express, NestJS or Fastify. Your entry owns its routes, and Analog handles server-side rendering, page endpoints and server functions inside it. A platform or runtime runs a Fetchable entry, while a Node server entry starts its own process.
44

55
:::info
66

@@ -55,49 +55,98 @@ Vite compiles the TypeScript in the entry's directory. Frameworks that use decor
5555

5656
## The server entry
5757

58-
The entry exports `createApp()`, which the dev server mounts behind Vite, and starts its own server in production. With Express:
58+
The entry is a [Fetchable](https://fetchable.org) module: its default export has a `fetch(request)` method that returns a `Response`. `createAnalogRequestHandler()` handles Analog's requests and returns `null` for anything else:
59+
60+
```ts
61+
// src/server/main.ts
62+
import { createAnalogRequestHandler } from '@analogjs/router/server';
63+
64+
const handleAnalogRequest = createAnalogRequestHandler();
65+
66+
export default {
67+
async fetch(request: Request) {
68+
return (
69+
(await handleAnalogRequest(request)) ??
70+
new Response('Not Found', { status: 404 })
71+
);
72+
},
73+
};
74+
```
75+
76+
Analog handles:
77+
78+
- server functions (`/_analog/fn/:id`) and page endpoints (`/api/_analog/pages/**`);
79+
- server-side rendering for `GET` requests outside `/api/`.
80+
81+
In development, Analog serves the entry's `fetch` behind Vite. In production, platforms and runtimes that invoke Fetchable modules, such as Netlify Functions, Deno and Bun, run the built entry without a server of your own.
82+
83+
### Fetch-native frameworks
84+
85+
Frameworks built on web `Request`/`Response`, such as Hono, are already Fetchable. Hand Analog the requests your routes don't handle, and export the app:
86+
87+
```ts
88+
// src/server/main.ts
89+
import { createAnalogRequestHandler } from '@analogjs/router/server';
90+
import { Hono } from 'hono';
91+
92+
const app = new Hono();
93+
const handleAnalogRequest = createAnalogRequestHandler({
94+
fetch: (request) => app.fetch(request),
95+
});
96+
97+
app.get('/api/health', (c) => c.json({ ok: true }));
98+
app.all(
99+
'*',
100+
async (c) => (await handleAnalogRequest(c.req.raw)) ?? c.notFound(),
101+
);
102+
103+
export default app;
104+
```
105+
106+
Passing the app's `fetch` lets [server-side fetches](#server-side-fetches) reach your Hono routes in-process.
107+
108+
### Node frameworks
109+
110+
Frameworks built on Node's request and response, such as Express, NestJS and Fastify, wrap their app with `toFetchHandler()` from [srvx](https://srvx.h3.dev). In development, the app gets the dev server's Node request and response directly. In production, the entry can start its own server. Install `srvx`, then with Express:
59111

60112
```ts
61113
// src/server/main.ts
62114
import { createAnalogHandler } from '@analogjs/router/server';
63115
import express from 'express';
64116
import { fileURLToPath } from 'node:url';
117+
import { toFetchHandler } from 'srvx/node';
65118

66-
function createServer() {
67-
const app = express();
68-
app.get('/api/health', (req, res) => res.json({ ok: true }));
119+
const app = express();
120+
app.get('/api/health', (req, res) => res.json({ ok: true }));
69121

70-
// Vite serves client files in development.
71-
if (!import.meta.env.DEV) {
72-
const clientDir = fileURLToPath(new URL('../client', import.meta.url));
73-
app.use(express.static(clientDir, { index: false }));
74-
}
75-
76-
app.use(createAnalogHandler());
77-
return app;
122+
// Vite serves client files in development.
123+
if (!import.meta.env.DEV) {
124+
const clientDir = fileURLToPath(new URL('../client', import.meta.url));
125+
app.use(express.static(clientDir, { index: false }));
78126
}
79127

80-
export async function createApp() {
81-
return { handle: createServer() };
82-
}
128+
app.use(createAnalogHandler());
83129

84130
if (!import.meta.env.DEV) {
85-
createServer().listen(process.env['PORT'] ?? 3000);
131+
app.listen(process.env['PORT'] ?? 3000);
86132
}
87-
```
88133

89-
`createApp()` returns `{ handle, close? }`, where `handle` is a Node `(req, res, next)` handler. When server code changes in development, Analog creates a new app and calls `close()` on the previous one.
134+
export default {
135+
fetch: toFetchHandler((req, res) => app(req, res)),
136+
};
137+
```
90138

91-
`createAnalogHandler()` handles Analog's own requests and passes everything else to `next()`:
139+
`(req, res) => app(req, res)` calls Express without a `next` callback, so a request nothing handles gets Express's `404` instead of an empty response.
92140

93-
- server functions (`/_analog/fn/:id`) and page endpoints (`/api/_analog/pages/**`);
94-
- server-side rendering for `GET` requests outside `/api/`.
141+
When server code changes in development, Analog reloads the entry. Add a `close()` next to `fetch` to release what the previous entry holds, such as database connections; Analog calls it before using the new entry. See [NestJS](/docs/integrations/nestjs) for an entry that closes its app.
95142

96-
Register your own routes before it. Routes under `/api/` that nothing handles reach your server's 404 handling instead of server-side rendering.
143+
`createAnalogHandler()` is the Node adapter for `createAnalogRequestHandler()`: it handles Analog's requests and passes everything else to `next()`. Register your own routes before it. Routes under `/api/` that nothing handles reach your server's 404 handling instead of server-side rendering. It also passes the Node request and response to Analog as the `REQUEST` and `RESPONSE` tokens; with `createAnalogRequestHandler()`, pass them yourself as `handleAnalogRequest(request, { req, res })`.
97144

98145
## Server-side fetches
99146

100-
Requests that server-side rendering and page endpoints make to the app's own origin, such as `HttpClient` calls to `/api/...` and the `fetch` passed to page endpoints, run in-process instead of over the network, as with Nitro. Analog serves its own routes. To serve your server's routes too, pass a `fetch` that handles a `Request`. It gets the requests Analog doesn't handle: routes under `/api/`, and non-`GET` requests elsewhere; other `GET` requests are server-rendered.
147+
Requests that server-side rendering and page endpoints make to the app's own origin, such as `HttpClient` calls to `/api/...` and the `fetch` passed to page endpoints, run in-process instead of over the network, as with Nitro. Analog serves its own routes. To serve your server's routes too, pass a `fetch` that handles a `Request`, as the Hono example does. It gets the requests Analog doesn't handle, such as your own routes under `/api/` and non-`GET` requests outside `/api/`; other `GET` requests are server-rendered.
148+
149+
With a Node framework, forward these requests to the server's address:
101150

102151
```ts
103152
// Your server's own address, never one taken from the request.
@@ -119,32 +168,11 @@ In development, set `SERVER_ORIGIN` to Vite's dev server origin, such as `http:/
119168

120169
## Static files
121170

122-
Serving client files is the server's job, with its own tools (`express.static`, `serve-static`, NestJS's `useStaticAssets()`, a CDN). In production they're in `dist/client`, next to the `dist/server` directory that holds the server entry:
171+
Vite serves client files in development. In production they're in `dist/client`, next to the `dist/server` directory that holds the server entry. A Fetchable entry leaves them to the platform, usually its CDN. A Node server serves them with its own tools (`express.static`, `serve-static`, NestJS's `useStaticAssets()`). Either way:
123172

124-
- Register them **before** the Analog handler. Otherwise server-side rendering answers asset URLs.
173+
- Serve them **before** the Analog handler. Otherwise server-side rendering answers asset URLs.
125174
- Disable the index fallback (`index: false`), so `/` is server-rendered instead of returning the client's `index.html`.
126175

127-
## Fetch-native servers
128-
129-
Servers built on web `Request`/`Response`, such as Hono, use `createAnalogRequestHandler()`. It returns a function that takes a `Request` and returns a `Response`, or `null` for requests Analog doesn't handle:
130-
131-
```ts
132-
import { createAnalogRequestHandler } from '@analogjs/router/server';
133-
import { Hono } from 'hono';
134-
135-
const handleAnalogRequest = createAnalogRequestHandler();
136-
const app = new Hono();
137-
138-
app.all(
139-
'*',
140-
async (c) => (await handleAnalogRequest(c.req.raw)) ?? c.notFound(),
141-
);
142-
```
143-
144-
Pass your app's `fetch`, `createAnalogRequestHandler({ fetch: (request) => app.fetch(request) })`, so [server-side fetches](#server-side-fetches) reach your Hono routes in-process.
145-
146-
Node servers that have the Node request and response can pass them as a second argument, `handleAnalogRequest(request, { req, res })`, so they become Analog's `REQUEST` and `RESPONSE` tokens. `createAnalogHandler()` does this for you.
147-
148176
## Streaming
149177

150178
[Streaming SSR](/docs/features/server/streaming-ssr) works with a custom server: enable `experimental.streaming` and export `renderStream` from `main.server.ts`. Analog returns the document as a stream with `Cache-Control: no-store, no-transform`, and stops rendering when the client disconnects.
@@ -173,7 +201,7 @@ Two details of locale detection apply here as on Nitro:
173201

174202
## Building and running
175203

176-
`vite build` builds the client into `dist/client` and the server entry into `dist/server/main.js`. Run it with Node:
204+
`vite build` builds the client into `dist/client` and the server entry into `dist/server/main.js`. A Fetchable entry keeps its default export and doesn't listen on a port; deploy it to a platform or runtime that invokes Fetchable modules. Run a Node entry that starts its own server with Node:
177205

178206
```shell
179207
node dist/server/main.js

‎apps/docs-analog/src/content/integrations/nestjs/index.md‎

Lines changed: 42 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,23 @@ To add NestJS to an existing project, follow the steps below.
2424
<TabItem value="npm">
2525

2626
```shell
27-
npm install @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata h3
27+
npm install @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata h3 srvx
2828
```
2929

3030
</TabItem>
3131

3232
<TabItem label="yarn" value="yarn">
3333

3434
```shell
35-
yarn add @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata h3
35+
yarn add @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata h3 srvx
3636
```
3737

3838
</TabItem>
3939

4040
<TabItem value="pnpm">
4141

4242
```shell
43-
pnpm add @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata h3
43+
pnpm add @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata h3 srvx
4444
```
4545

4646
</TabItem>
@@ -104,52 +104,49 @@ Server-side rendering and page endpoints fetch Analog's own routes in-process. T
104104

105105
## Step 4: Create the server entry
106106

107-
The entry exports `createApp()`, which the dev server mounts behind Vite, and listens in production:
107+
The entry is a [Fetchable](https://fetchable.org) module. srvx's `toFetchHandler()` turns NestJS's Express instance into the `fetch` the dev server calls, and the entry listens on its own port in production:
108108

109109
```ts
110110
// src/server/main.ts
111111
import 'reflect-metadata';
112112
import { NestFactory } from '@nestjs/core';
113113
import type { NestExpressApplication } from '@nestjs/platform-express';
114114
import { fileURLToPath } from 'node:url';
115+
import { toFetchHandler } from 'srvx/node';
115116

116117
import { AppModule } from './app.module';
117118

118-
async function createNestApp() {
119-
// Keeps the request body for Analog after Nest's body parsers have run.
120-
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
121-
rawBody: true,
122-
});
123-
124-
// Vite serves client files in development.
125-
if (!import.meta.env.DEV) {
126-
const clientDir = fileURLToPath(new URL('../client', import.meta.url));
127-
app.useStaticAssets(clientDir, { index: false });
128-
}
119+
// Keeps the request body for Analog after Nest's body parsers have run.
120+
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
121+
rawBody: true,
122+
});
129123

130-
return app.init();
124+
// Vite serves client files in development.
125+
if (!import.meta.env.DEV) {
126+
const clientDir = fileURLToPath(new URL('../client', import.meta.url));
127+
app.useStaticAssets(clientDir, { index: false });
131128
}
132129

133-
export async function createApp() {
134-
const app = await createNestApp();
135-
return {
136-
handle: app.getHttpAdapter().getInstance(),
137-
close: () => app.close(),
138-
};
139-
}
130+
await app.init();
131+
const server = app.getHttpAdapter().getInstance();
140132

141133
if (!import.meta.env.DEV) {
142-
const app = await createNestApp();
143134
await app.listen(process.env['PORT'] ?? 3000);
144135
}
136+
137+
export default {
138+
fetch: toFetchHandler((req, res) => server(req, res)),
139+
close: () => app.close(),
140+
};
145141
```
146142

147143
A few details matter here:
148144

149145
- `rawBody: true` keeps the original request bytes, so form actions and server functions can read bodies that NestJS's parsers already consumed.
150146
- Static files are registered before `app.init()`. Anything registered after it sits behind NestJS's 404 handler, and `init()` is also where the Analog handler is mounted.
151147
- `index: false` keeps `/` server-rendered instead of returning the client's `index.html`.
152-
- In development, Analog creates a new NestJS app when server code changes, and calls `close()` on the previous one.
148+
- `(req, res) => server(req, res)` calls Express without a `next` callback, so a request nothing handles gets a `404` instead of an empty response.
149+
- In development, Analog reloads the entry when server code changes, and calls `close()` on the previous one.
153150

154151
## Using the Fastify adapter
155152

@@ -181,7 +178,7 @@ pnpm add @nestjs/platform-fastify serve-static
181178
</TabItem>
182179
</Tabs>
183180

184-
The module from [Step 3](#step-3-mount-analog-in-your-module) stays the same. The entry creates a Fastify app and hands Vite Fastify's request handler:
181+
The module from [Step 3](#step-3-mount-analog-in-your-module) stays the same. The entry creates a Fastify app and exports Fastify's request handler as its `fetch`:
185182

186183
```ts
187184
// src/server/main.ts
@@ -193,39 +190,33 @@ import {
193190
} from '@nestjs/platform-fastify';
194191
import { fileURLToPath } from 'node:url';
195192
import serveStatic from 'serve-static';
193+
import { toFetchHandler } from 'srvx/node';
196194

197195
import { AppModule } from './app.module';
198196

199-
async function createNestApp() {
200-
const app = await NestFactory.create<NestFastifyApplication>(
201-
AppModule,
202-
new FastifyAdapter(),
203-
);
204-
205-
// Vite serves client files in development.
206-
if (!import.meta.env.DEV) {
207-
const clientDir = fileURLToPath(new URL('../client', import.meta.url));
208-
app.use(serveStatic(clientDir, { index: false }));
209-
}
197+
const app = await NestFactory.create<NestFastifyApplication>(
198+
AppModule,
199+
new FastifyAdapter(),
200+
);
210201

211-
await app.init();
212-
await app.getHttpAdapter().getInstance().ready();
213-
return app;
202+
// Vite serves client files in development.
203+
if (!import.meta.env.DEV) {
204+
const clientDir = fileURLToPath(new URL('../client', import.meta.url));
205+
app.use(serveStatic(clientDir, { index: false }));
214206
}
215207

216-
export async function createApp() {
217-
const app = await createNestApp();
218-
const fastify = app.getHttpAdapter().getInstance();
219-
return {
220-
handle: (req, res) => fastify.routing(req, res),
221-
close: () => app.close(),
222-
};
223-
}
208+
await app.init();
209+
const fastify = app.getHttpAdapter().getInstance();
210+
await fastify.ready();
224211

225212
if (!import.meta.env.DEV) {
226-
const app = await createNestApp();
227213
await app.listen(process.env['PORT'] ?? 3000, '0.0.0.0');
228214
}
215+
216+
export default {
217+
fetch: toFetchHandler((req, res) => fastify.routing(req, res)),
218+
close: () => app.close(),
219+
};
229220
```
230221

231222
How it differs from Express:
@@ -239,10 +230,10 @@ How it differs from Express:
239230

240231
Under `vite`, NestJS runs inside the dev server rather than listening on its own port:
241232

242-
- The first request Vite doesn't serve itself calls `createApp()`, which runs `NestFactory.create()` and `init()`.
233+
- The first request Vite doesn't serve itself loads the entry, which runs `NestFactory.create()` and `init()`.
243234
- Vite serves client files and HMR, and passes every other request to NestJS, which routes it as in production.
244235
- Vite compiles the NestJS code, so there's no `nest start --watch` or `tsc` step.
245-
- When server code changes, the next request creates a new NestJS app and calls `close()` on the previous one, so shutdown hooks run.
236+
- When server code changes, the next request reloads the entry, creating a new NestJS app, and calls `close()` on the previous one, so shutdown hooks run.
246237
- To reach controllers from [server-side fetches](/docs/features/server/custom-server#server-side-fetches), point the origin at Vite's dev server, such as `http://localhost:5173`.
247238

248239
## Using NestJS services from Angular code

‎packages/create-analog/__tests__/cli.spec.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ test(
242242
'@nestjs/platform-express',
243243
'h3',
244244
'reflect-metadata',
245+
'srvx',
245246
]),
246247
);
247248
expect(

0 commit comments

Comments
 (0)