From 26b38870f0054cd6239600d5071a468caad1362b Mon Sep 17 00:00:00 2001 From: Drake Costa Date: Sun, 8 Nov 2026 12:25:21 -0800 Subject: [PATCH 1/2] feat(node): let a server supply the ASSETS that serve the client build --- docs/integrations/nodejs-bun-deno.md | 34 +++++++++ packages/void/AGENTS.md | 2 +- packages/void/src/router/compile.ts | 23 ++++-- .../integration/pages-target-assets.test.ts | 74 ++++++++++++++++++- 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/docs/integrations/nodejs-bun-deno.md b/docs/integrations/nodejs-bun-deno.md index 0d992023f..57076be7a 100644 --- a/docs/integrations/nodejs-bun-deno.md +++ b/docs/integrations/nodejs-bun-deno.md @@ -160,3 +160,37 @@ 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 = { + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', +}; + +const ASSETS = { + async fetch(request: Request): Promise { + 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. diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 7639115f8..86de417f0 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -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 diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index af540577f..809580d5e 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -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;`; } diff --git a/packages/void/test/integration/pages-target-assets.test.ts b/packages/void/test/integration/pages-target-assets.test.ts index d640e9361..ab2970690 100644 --- a/packages/void/test/integration/pages-target-assets.test.ts +++ b/packages/void/test/integration/pages-target-assets.test.ts @@ -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'; @@ -151,6 +151,78 @@ export default function Home() { } }); + // A server that embeds the client build (a single executable, for one) 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 !== 'node')( + '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); + writeFileSync( + join(root, 'supplied-server.mjs'), + ` +import { readFile } from 'node:fs/promises'; +import { extname, join } from 'node:path'; +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({ fetch: (request, env) => app.fetch(request, { ...env, ASSETS }), port: Number(process.env.PORT) }, (info) => { + console.log('Listening on http://localhost:' + info.port); +}); +`, + ); + try { + const server = await startServer( + (port) => + spawn(command, ['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(/]*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(); From d0289c89365d7d0b31e6c50e4b83c44a224066b1 Mon Sep 17 00:00:00 2001 From: Drake Costa Date: Sun, 8 Nov 2026 12:46:45 -0800 Subject: [PATCH 2/2] test(node): cover a server-supplied ASSETS on Bun, and document Bun executables --- docs/integrations/nodejs-bun-deno.md | 31 +++++++++++++++++++ .../integration/pages-target-assets.test.ts | 23 ++++++++------ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/docs/integrations/nodejs-bun-deno.md b/docs/integrations/nodejs-bun-deno.md index 57076be7a..785fa030a 100644 --- a/docs/integrations/nodejs-bun-deno.md +++ b/docs/integrations/nodejs-bun-deno.md @@ -194,3 +194,34 @@ serve({ fetch: (request, env) => app.fetch(request, { ...env, ASSETS }), port: 3 ``` 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 = { + '/.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 { + 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 }); +``` diff --git a/packages/void/test/integration/pages-target-assets.test.ts b/packages/void/test/integration/pages-target-assets.test.ts index ab2970690..9f19e9a9a 100644 --- a/packages/void/test/integration/pages-target-assets.test.ts +++ b/packages/void/test/integration/pages-target-assets.test.ts @@ -151,21 +151,28 @@ export default function Home() { } }); - // A server that embeds the client build (a single executable, for one) 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 !== 'node')( + // 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'; -import { serve } from '@hono/node-server'; +${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' }; @@ -183,15 +190,13 @@ const ASSETS = { }, }; -serve({ fetch: (request, env) => app.fetch(request, { ...env, ASSETS }), port: Number(process.env.PORT) }, (info) => { - console.log('Listening on http://localhost:' + info.port); -}); +${serve} `, ); try { const server = await startServer( (port) => - spawn(command, ['supplied-server.mjs'], { + spawn(command, [...args, 'supplied-server.mjs'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PORT: String(port) },