Skip to content

Node.js

Node.js is the only runtime that needs an adapter. Node's HTTP server uses IncomingMessage/ServerResponse, not the standard Request/Response APIs. The adapter bridges the gap in about 15 lines of code.

Server Entry

The Node.js server entry wraps Juice's fetch handler in a standard http.createServer callback:

// server.ts
import { createServer } from 'node:http';
import { createRouter } from '@cmj/juice/runtime';
import manifest from './flight-manifest.json';

const handler = createRouter(manifest, {
  root: import.meta.url,
});

const server = createServer(async (req, res) => {
  const url = new URL(req.url!, `http://${req.headers.host}`);
  const headers = new Headers();
  for (const [key, value] of Object.entries(req.headers)) {
    if (value) headers.set(key, Array.isArray(value) ? value.join(', ') : value);
  }

  const request = new Request(url, {
    method: req.method,
    headers,
    body: req.method !== 'GET' && req.method !== 'HEAD' ? req : undefined,
    // @ts-expect-error Node.js duplex option
    duplex: 'half',
  });

  const response = await handler(request);

  res.writeHead(response.status, Object.fromEntries(response.headers));
  if (response.body) {
    const reader = response.body.getReader();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      res.write(value);
    }
  }
  res.end();
});

server.listen(3000, () => {
  console.log('Listening on http://localhost:3000');
});

The Adapter

The adapter performs two conversions on every request:

  1. IncomingMessage to Request. Construct a standard URL from req.url and req.headers.host. Copy headers into a Headers object. Create a Request with the method, headers, and body stream. The duplex: 'half' option is required for Node.js to allow streaming the request body.
  2. Response to ServerResponse. Write the status code and headers with res.writeHead(). Stream the response body chunk by chunk using getReader(). Call res.end() when done.

This adapter is boilerplate, but it is explicit. You can see exactly how the conversion happens. If you need to modify the conversion -- for example, to handle WebSocket upgrades or add body size limits -- you change the adapter code directly. The scaffolder generates this for you when you choose Node.js as your target.

Build and Run

juice build produces dist/server.js (CommonJS or ESM depending on your tsconfig):

juice build
node dist/server.js

For TypeScript development without a build step, use Node 22's built-in TypeScript support:

node --experimental-strip-types server.ts

Why Node.js Needs an Adapter

Node's http module was designed in 2009. It uses IncomingMessage and ServerResponse -- stream-based objects from a pre-fetch() era.

The WinterCG standard (used by Workers, Bun, and Deno) uses Request and Response -- the same APIs the browser provides via fetch(). These were standardized in 2015.

The adapter bridges the 6-year gap in about 15 lines of code. Every other runtime Juice supports -- Cloudflare Workers, Bun, and Deno -- accepts a fetch handler natively.

Environment Variables

Access environment variables through process.env:

const dbUrl = process.env.DATABASE_URL;

For .env file support, use Node 20's --env-file flag:

node --env-file=.env dist/server.js

On older Node versions, use the dotenv package:

import 'dotenv/config';
// process.env.DATABASE_URL is now available

Docker

A multi-stage Dockerfile for smaller production images:

# Build stage
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx juice build

# Production stage
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./

EXPOSE 3000
CMD ["node", "dist/server.js"]

The multi-stage build keeps the final image lean by excluding source files, dev dependencies, and build tooling.

Process Managers

PM2

npm install -g pm2
pm2 start dist/server.js --name my-juice-app
pm2 save

systemd

A systemd service file for running Juice in production on Linux:

# /etc/systemd/system/juice-app.service
[Unit]
Description=Juice App
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/my-juice-app
ExecStart=/usr/bin/node dist/server.js
Restart=on-failure
Environment=NODE_ENV=production
EnvironmentFile=/opt/my-juice-app/.env

[Install]
WantedBy=multi-user.target
sudo systemctl enable juice-app
sudo systemctl start juice-app

For production workloads, consider using Bun instead -- the same code runs with better performance and no process manager needed for automatic restarts (Bun's built-in --watch handles it in development).

When to Use Node.js

  • Existing infrastructure requires it. Your deployment platform only supports Node.js, or your CI/CD pipeline is built around it.
  • Native Node.js addons. Libraries like sharp (image processing), bcrypt (password hashing), or canvas (server-side rendering) use native C++ addons that only work on Node.js.
  • Node-specific library compatibility. Some libraries depend on Node-specific APIs that other runtimes do not fully implement.
  • For everything else -- prefer Bun (faster, same API surface) or Cloudflare Workers (edge deployment, smallest bundle).