Skip to content

Commit 1e9172a

Browse files
committed
feat(platform): support Fetchable server entries with analog({ server })
The custom server entry can now be a Fetchable module (https://fetchable.org) whose default export has `fetch(request)`, in addition to `createApp()`. In dev, Analog serves the entry's `fetch` behind Vite through srvx's Node adapter. The build output keeps the default export, so platforms that invoke Fetchable modules can run `dist/server/main.js` directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3NKyLNGL2d5Df2wt4WNbA
1 parent 82061c9 commit 1e9172a

7 files changed

Lines changed: 112 additions & 5 deletions

File tree

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,30 @@ Pass your app's `fetch`, `createAnalogRequestHandler({ fetch: (request) => app.f
145145

146146
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.
147147

148+
## Fetchable entry
149+
150+
Instead of `createApp()`, the server entry can be a [Fetchable](https://fetchable.org) module: its default export has a `fetch(request)` method that returns a `Response`. Platforms and runtimes that invoke Fetchable modules, such as Netlify Functions, Cloudflare Workers, Deno and Bun, run the built entry without a server of your own.
151+
152+
```ts
153+
// src/server/main.ts
154+
import { createAnalogRequestHandler } from '@analogjs/router/server';
155+
156+
const handleAnalogRequest = createAnalogRequestHandler();
157+
158+
export default {
159+
async fetch(request: Request) {
160+
return (
161+
(await handleAnalogRequest(request)) ??
162+
new Response('Not Found', { status: 404 })
163+
);
164+
},
165+
};
166+
```
167+
168+
A Hono app is already Fetchable, so the entry can `export default app`.
169+
170+
In development, Analog serves the entry's `fetch` behind Vite. When the entry exports both, `createApp()` is used. The built `dist/server/main.js` keeps the default export and doesn't listen on a port, so the platform serves `dist/client`. Serve it without an index fallback so `/` is server-rendered.
171+
148172
## Streaming
149173

150174
[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 +197,7 @@ Two details of locale detection apply here as on Nitro:
173197

174198
## Building and running
175199

176-
`vite build` builds the client into `dist/client` and the server entry into `dist/server/main.js`. Run it with Node:
200+
`vite build` builds the client into `dist/client` and the server entry into `dist/server/main.js`. Run an entry that starts its own server with Node:
177201

178202
```shell
179203
node dist/server/main.js

‎packages/platform/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"ofetch": "catalog:",
4040
"oxc-parser": "catalog:",
4141
"rolldown": "catalog:",
42+
"srvx": "catalog:",
4243
"tinyglobby": "catalog:",
4344
"vitefu": "catalog:",
4445
"xmlbuilder2": "catalog:"

‎packages/platform/src/lib/options.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,9 @@ export interface ServerOptions {
9696
/**
9797
* The server entry, relative to the project root. It exports
9898
* `createApp(): Promise<{ handle, close? }>`, which the dev server mounts,
99-
* and starts its own server in production. TypeScript in the entry's
100-
* directory is compiled by Vite.
99+
* and starts its own server in production, or is a Fetchable module whose
100+
* default export has `fetch(request)`. TypeScript in the entry's directory
101+
* is compiled by Vite.
101102
*/
102103
entry: string;
103104
}

‎packages/platform/src/lib/server-plugin.spec.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2+
import { createServer as createHttpServer } from 'node:http';
3+
import type { AddressInfo } from 'node:net';
24
import { tmpdir } from 'node:os';
35
import { dirname, join } from 'node:path';
46
import type { Plugin } from 'vite';
@@ -150,6 +152,44 @@ describe('serverPlugin', () => {
150152
}
151153
});
152154

155+
it('serves requests from a Fetchable entry in dev', async () => {
156+
writeFileSync(
157+
join(root, 'src/main.ts'),
158+
[
159+
'export default {',
160+
' async fetch(request) {',
161+
' const { pathname } = new URL(request.url);',
162+
' const body = `${request.method} ${pathname} ${await request.text()}`;',
163+
" return new Response(body, { status: 201, headers: { 'x-entry': 'fetchable' } });",
164+
' },',
165+
'};',
166+
].join('\n'),
167+
);
168+
const vite = await createServer({
169+
root,
170+
configFile: false,
171+
logLevel: 'silent',
172+
server: { middlewareMode: true, ws: false },
173+
plugins: [
174+
serverPlugin({ entry: 'src/main.ts' }, { workspaceRoot: root }),
175+
],
176+
});
177+
const http = createHttpServer(vite.middlewares).listen(0);
178+
try {
179+
const { port } = http.address() as AddressInfo;
180+
const response = await fetch(`http://localhost:${port}/services/echo`, {
181+
method: 'POST',
182+
body: 'hello',
183+
});
184+
expect(response.status).toBe(201);
185+
expect(response.headers.get('x-entry')).toBe('fetchable');
186+
expect(await response.text()).toBe('POST /services/echo hello');
187+
} finally {
188+
http.close();
189+
await vite.close();
190+
}
191+
});
192+
153193
it('refuses to require locale workers', () => {
154194
const plugin = serverPlugin(
155195
{ entry: 'src/server/main.ts' },

‎packages/platform/src/lib/server-plugin.ts‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { existsSync, readFileSync } from 'node:fs';
22
import type { IncomingMessage, ServerResponse } from 'node:http';
33
import { relative, resolve } from 'node:path';
4+
import type { NodeHttp1Handler } from 'srvx';
5+
import { toNodeHandler } from 'srvx/node';
46
import {
57
normalizePath,
68
type Connect,
@@ -30,7 +32,11 @@ type ServerApp = {
3032
) => void;
3133
close?: () => Promise<void>;
3234
};
33-
type ServerEntry = { createApp: () => Promise<ServerApp> };
35+
type FetchHandler = (request: Request) => Response | Promise<Response>;
36+
type ServerEntry = {
37+
createApp?: () => Promise<ServerApp>;
38+
default?: { fetch?: FetchHandler };
39+
};
3440

3541
/**
3642
* Builds and serves the app from `server.entry` instead of Nitro: the client
@@ -229,7 +235,7 @@ function devMiddleware(
229235
const loaded = (await viteServer.ssrLoadModule(entry)) as ServerEntry;
230236
if (current?.entry !== loaded) {
231237
const previous = current;
232-
current = { entry: loaded, app: loaded.createApp() };
238+
current = { entry: loaded, app: createApp(loaded) };
233239
void previous?.app.then(
234240
(app) => app.close?.(),
235241
() => undefined,
@@ -248,6 +254,34 @@ function devMiddleware(
248254
};
249255
}
250256

257+
/**
258+
* The entry's `createApp()`, or a Node app around the `fetch` of a Fetchable
259+
* entry (https://fetchable.org).
260+
*/
261+
async function createApp(entry: ServerEntry): Promise<ServerApp> {
262+
if (entry.createApp) {
263+
return entry.createApp();
264+
}
265+
const fetchable = entry.default;
266+
const handleRequest = fetchable?.fetch;
267+
if (typeof handleRequest !== 'function') {
268+
throw new Error(
269+
'[@analogjs/platform] The server entry must export `createApp()` or a default export with `fetch(request)`.',
270+
);
271+
}
272+
// Connect middleware is typed with the HTTP/1 request and response.
273+
const handle = toNodeHandler((request) =>
274+
handleRequest.call(fetchable, request),
275+
) as NodeHttp1Handler;
276+
return {
277+
handle: (req, res, next) => {
278+
Promise.resolve()
279+
.then(() => handle(req, res))
280+
.catch(next);
281+
},
282+
};
283+
}
284+
251285
function removeHtmlMiddlewares(middlewares: Connect.Server): void {
252286
middlewares.stack = middlewares.stack.filter(
253287
(layer) =>

‎pnpm-lock.yaml‎

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pnpm-workspace.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ catalog:
173173
semantic-release-replace-plugin: ^1.2.7
174174
shiki: ^1.29.2 || ^4.0.2
175175
sharp: ^0.34.5
176+
srvx: ^1.0.8
176177
start-server-and-test: ^3.0.0
177178
storybook: 10.6.0
178179
tslib: ^2.3.0

0 commit comments

Comments
 (0)