Skip to content

Latest commit

 

History

History
227 lines (157 loc) · 8.16 KB

nodejs-bun-deno.md

File metadata and controls

227 lines (157 loc) · 8.16 KB
outline deep

Node.js, Bun, and Deno

Set target in void.config.ts to build for Node.js, Bun, or Deno. You can run the resulting server on your own machine, in a container, or with a hosting provider.

{ "target": "node" }

Getting Started

1. Configure the target

// void.config.ts
{ "target": "node" }

2. Install the server dependency (Node.js only)

Node.js requires @hono/node-server as an additional dependency. Bun and Deno use their built-in HTTP servers.

npm install @hono/node-server

3. Develop

npx vite dev

The development server reloads routes and pages when you edit them.

4. Build and run

npx vite build
node dist/ssr/index.js

For Bun:

bunx vite build
bun dist/ssr/index.js

For Deno:

deno run -A npm:vite build
deno run -A dist/ssr/index.js

Run these commands from your app directory. The server listens on PORT (env variable) or 3000 by default.

Deploy dist/ssr and dist/client together.

Build Output

The build produces two entry points:

dist/
  ssr/
    app.js       ← Hono app with static asset middleware (default export)
    index.js     ← imports app.js and starts the HTTP server
  client/        ← static assets (pages mode only)
    .vite/manifest.json
    assets/
      ...
  • app.js: exports the Hono app instance. Use it for programmatic embedding, testing, or custom server setups.
  • index.js: imports app.js and starts the server. This is what you run in production.

vite preview also uses app.js to serve your built app locally.

Installed packages that depend on void are bundled into dist/ssr. If one of their dependencies uses a native addon or reads files next to its own code, add that dependency, in the version the package uses, to your app's dependencies. The build then leaves it to Node.

What Works

These Void features work identically across all targets:

What's Different

Environment variables

Supply server values through your host's environment and read them with process.env in Node.js or Bun, or Deno.env.get() in Deno. The void/env schema and secret workflow requires Cloudflare.

For browser code, use Vite's import.meta.env.VITE_* values. These are embedded at build time and must not contain secrets.

No Cloudflare bindings

The following imports are not available with a non-CF target and produce a compile-time error:

Import CF Feature
void/db D1 (SQL database)
void/kv KV (key-value storage)
void/storage R2 (blob storage)
void/auth Void-managed Better Auth
void/ai Workers AI
void/sandbox Cloudflare Sandboxes
void/env Typed environment schema

If you need a database or storage, use an external provider and connect via standard Node.js libraries.

WebSocket route files (*.ws.ts) require Cloudflare. You can still use the void/ws browser client to connect to a separately hosted WebSocket server.

No cron job runtime

You can still define cron jobs in crons/ and they will compile into the bundle, but there is no built-in scheduler to invoke them. On Cloudflare, Workers Cron Triggers call the scheduled handler automatically. On Node.js, you'll need an external scheduler (e.g. node-cron, systemd timers, or your hosting platform's cron) to trigger the exported handler.

No inbound email

Inbound email handlers and sendEmail require Cloudflare. Use an external mail provider on other targets.

No prerendering

Edge prerendering requires Cloudflare. Build-time static generation is available on all targets.

No void deploy

void deploy handles Cloudflare and Void platform deployments. For Node.js, Bun, or Deno, run vite build and deploy dist/ using your hosting provider's workflow.

Ignored config fields

Void warns and ignores these Cloudflare-specific void.config.ts fields on other targets:

  • inference.bindings: Cloudflare binding configuration
  • remote: remote binding proxy
  • worker: Cloudflare-specific config
  • routing.revalidate: edge caching, only on Cloudflare

If you enable auth through void/auth, void/client, or auth.ts, Void fails the build for non-CF targets. Use Better Auth directly for Node/Bun/Deno deployments.

Programmatic Usage

Since app.js exports the Hono app, you can import it into a custom server:

import app from './dist/ssr/app.js';

// Use with any Node.js HTTP framework
const response = await app.fetch(new Request('http://localhost/api/hello'));
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, 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):

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:

// 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:

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 });