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
65 changes: 65 additions & 0 deletions docs/integrations/nodejs-bun-deno.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,68 @@ console.log(await response.json()); // { message: "Hello from Node.js!" }
```

This is useful for testing, embedding in Express/Fastify, or running in serverless environments that accept a `fetch` handler.

### Serving the client build from somewhere else

By default, `app.js` serves the client build from `dist/client`, next to `dist/ssr`. To serve it from elsewhere, such as from inside a [single executable application](https://nodejs.org/api/single-executable-applications.html), pass an `ASSETS` object with a `fetch(request)` method in the env. Void then reads the client manifest, scripts, styles and `public/` files through it, and doesn't look for `dist/client`.

This server reads the client build from a single executable's assets, stored under their URL paths (`/.vite/manifest.json`, `/assets/…`, and `public/` files):

```ts
import { getAsset } from 'node:sea';
import { serve } from '@hono/node-server';
import app from './dist/ssr/app.js';

const types: Record<string, string> = {
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
};

const ASSETS = {
async fetch(request: Request): Promise<Response> {
const { pathname } = new URL(request.url);
try {
const type = types[pathname.slice(pathname.lastIndexOf('.'))] ?? 'application/octet-stream';
return new Response(getAsset(pathname), { headers: { 'content-type': type } });
} catch {
return new Response('Not Found', { status: 404 });
}
},
};

serve({ fetch: (request, env) => app.fetch(request, { ...env, ASSETS }), port: 3000 });
```

Keep the env the server passes in: on Node.js it carries the incoming request, which `getConnInfo()` reads.

With Bun, `bun build --compile` embeds a file only when the code imports it with `{ type: 'file' }`. Listing `dist/client` files as extra entry points doesn't work, because Bun bundles JavaScript entry points as code. Client file names change with every build, so write the imports into a module after `vite build`, one per file in `dist/client`:

```ts
// client-files.ts, written after each `vite build`
import manifest from './dist/client/.vite/manifest.json' with { type: 'file' };
import pagesClient from './dist/client/assets/pages-client-BZk7n9j7.js' with { type: 'file' };

const files: Record<string, string> = {
'/.vite/manifest.json': manifest,
'/assets/pages-client-BZk7n9j7.js': pagesClient,
};
export default files;
```

Then serve them from the compiled executable, passing Bun's `Server` as `server` so `getConnInfo()` still works:

```ts
import files from './client-files.ts';
// server.ts, compiled with `bun build --compile server.ts --outfile server`
import app from './dist/ssr/app.js';

const ASSETS = {
async fetch(request: Request): Promise<Response> {
const path = files[new URL(request.url).pathname];
return path ? new Response(Bun.file(path)) : new Response('Not Found', { status: 404 });
},
};

Bun.serve({ fetch: (request, server) => app.fetch(request, { server, ASSETS }), port: 3000 });
```
2 changes: 1 addition & 1 deletion packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ voidPlugin() → [routingPlugin, prerenderPlugin, nodeTargetPlugin()]

Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/sandbox`, `void/env`; `CF_ONLY_IMPORTS`) at compile time. `void/ws` stays importable for client code. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. Its dev server, Vitest and build inline `void`, `@void/*` and installed packages that depend on either (`ssr.noExternal`), as a workspace link does. Vite externalizes a bare import that resolves into node_modules before any plugin sees it: an installed `void/kv` would skip the guard, and a build would load a second void beside the copy the generated routes bundle, so a `ValidationError` from `void/handler` fails their `instanceof` check. The build keeps a dynamic `cloudflare:` import external, as Void's runtime loads one only in workerd, and fails on a static one, which Node cannot load. `findVoidDependentPackages` reads the app's dependencies and recurses only into packages that depend on void, as vite-plugin-svelte does for Svelte libraries. A Yarn Plug'n'Play install has no node_modules, so there it asks the PnP runtime that `yarn` loads (`findPnpApi` on `node:module`) for each package directory. Vite's module runner cannot evaluate CommonJS, so it keeps a package only when the file an import loads (`exports` ".", where Vite also matches `development` or, when NODE_ENV is production, `production`; then `module`, then `main`) is an ES module (`.mjs`, or `.js` in a `type: "module"` scope) that imports no relative CommonJS file, unlike the wrapper in Node's dual-package guide. A build bundles a bare import that does not resolve from the app root, so with pnpm an inlined package's own dependencies are bundled too; the Node target docs ask users to list one that loads a native addon or reads files next to its code in the app's dependencies, which leaves it to Node (`ssr.external` alone fails at runtime, as Node cannot find the package from `dist/ssr`).

Production non-CF Pages pass a filesystem-backed `ASSETS` provider to the generated routes so manifest loading, hydration tags, public assets, and prerendering share the production path. `nodeTargetPlugin.renderChunk` resolves the client directory relative to the actual emitted chunk (including shared chunks under `ssr/assets/`), independently of `process.cwd()`. Keep `dist/ssr` and `dist/client` together.
Production non-CF Pages pass a filesystem-backed `ASSETS` provider to the generated routes so manifest loading, hydration tags, public assets, and prerendering share the production path. A server that passes its own `ASSETS` in the Hono env (an embedded client build, as in a single executable) replaces it for every one of those; the filesystem provider is created on first use, so such a server never touches `dist/client`. `nodeTargetPlugin.renderChunk` resolves the client directory relative to the actual emitted chunk (including shared chunks under `ssr/assets/`), independently of `process.cwd()`. Keep `dist/ssr` and `dist/client` together.

### Runtime Framework

Expand Down
23 changes: 16 additions & 7 deletions packages/void/src/router/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1428,18 +1428,27 @@ import { Hono } from ${JSON.stringify(honoPath)};
import innerApp, { __voidCollectPrerenderPaths } from "virtual:void-routes";
import { prerenderPathsResponse as __prerenderPathsResponse } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};
${envSchemaImport}
// Resolved by nodeTargetPlugin for this emitted chunk, including shared chunks.
const clientRoot = fileURLToPath(import.meta.voidClientDirectory);
const assetApp = new Hono();
assetApp.get("*", serveStatic({ root: clientRoot, allowPercentInPath: true }));
const assets = { fetch: (request) => assetApp.fetch(request) };
// dist/client, created on first use so a server that supplies its own ASSETS
// (a single executable, for one) never looks for a directory it doesn't have.
let fileAssets;
const clientAssets = () => {
if (!fileAssets) {
// Resolved by nodeTargetPlugin for this emitted chunk, including shared chunks.
const clientRoot = fileURLToPath(import.meta.voidClientDirectory);
const assetApp = new Hono();
assetApp.get("*", serveStatic({ root: clientRoot, allowPercentInPath: true }));
fileAssets = { fetch: (request) => assetApp.fetch(request) };
}
return fileAssets;
};
const assetsFor = (c) => (typeof c.env?.ASSETS?.fetch === "function" ? c.env.ASSETS : clientAssets());
const app = new Hono();
${envMiddleware}app.post("/__void/prerender-paths", async (c) => {
if (c.env?.__VOID_PRERENDER !== "1") return c.notFound();
return __prerenderPathsResponse(() => __voidCollectPrerenderPaths(c.req.raw));
});
app.get("/assets/*", (c) => assets.fetch(c.req.raw));
app.all("*", (c) => innerApp.fetch(c.req.raw, { ...c.env, ASSETS: assets }));
app.get("/assets/*", (c) => assetsFor(c).fetch(c.req.raw));
app.all("*", (c) => innerApp.fetch(c.req.raw, { ...c.env, ASSETS: assetsFor(c) }));
export default app;`;
}

Expand Down
79 changes: 78 additions & 1 deletion packages/void/test/integration/pages-target-assets.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import spawn from 'cross-spawn';
import { chromium } from 'playwright';
Expand Down Expand Up @@ -151,6 +151,83 @@ export default function Home() {
}
});

// A server that embeds the client build (a Node single executable, or `bun build --compile`)
// has no dist/client to read from. Moving it away proves the manifest, scripts and public files
// all come from the supplied provider rather than the filesystem.
it.skipIf(target === 'deno')(
'serves the client build through an ASSETS provider the server supplies',
async () => {
const client = join(root, 'dist/client');
const supplied = join(root, 'supplied-client');
renameSync(client, supplied);
const serve =
target === 'bun'
? `const server = Bun.serve({ fetch: (request, server) => app.fetch(request, { server, ASSETS }), port: Number(process.env.PORT) });
console.log('Listening on http://localhost:' + server.port);`
: `serve({ fetch: (request, env) => app.fetch(request, { ...env, ASSETS }), port: Number(process.env.PORT) }, (info) => {
console.log('Listening on http://localhost:' + info.port);
});`;
writeFileSync(
join(root, 'supplied-server.mjs'),
`
import { readFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
${target === 'bun' ? '' : "import { serve } from '@hono/node-server';"}
import app from './dist/ssr/app.js';

const types = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.txt': 'text/plain' };
const ASSETS = {
async fetch(request) {
const { pathname } = new URL(request.url);
try {
const body = await readFile(join(${JSON.stringify(supplied)}, pathname));
return new Response(body, {
headers: { 'content-type': types[extname(pathname)] ?? 'application/octet-stream', 'x-assets': 'supplied' },
});
} catch {
return new Response('Not Found', { status: 404 });
}
},
};

${serve}
`,
);
try {
const server = await startServer(
(port) =>
spawn(command, [...args, 'supplied-server.mjs'], {
cwd: root,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, PORT: String(port) },
}),
20_000,
);
try {
const html = await (await fetch(server.base)).text();
const scripts = Array.from(
html.matchAll(/<script[^>]*src="([^"]+)"/g),
(match) => match[1],
);
expect(scripts.length).toBeGreaterThan(0);
for (const url of scripts) {
const asset = await fetch(new URL(url, server.base));
expect(asset.status, url).toBe(200);
expect(asset.headers.get('x-assets'), url).toBe('supplied');
}
expect(await (await fetch(`${server.base}/health.txt`)).text()).toBe('public asset');
expect((await fetch(`${server.base}/api/hello`)).status).toBe(200);
// The default provider is never created, so nothing warns about a missing client directory.
expect(server.output()).not.toContain('is not found');
} finally {
await killChild(server.child);
}
} finally {
renameSync(supplied, client);
}
},
);

it('hydrates an interactive page and applies its emitted CSS in a real browser', async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
Expand Down