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:
- IncomingMessage to Request. Construct a standard
URLfromreq.urlandreq.headers.host. Copy headers into aHeadersobject. Create aRequestwith the method, headers, and body stream. Theduplex: 'half'option is required for Node.js to allow streaming the request body. - Response to ServerResponse. Write the status code and headers with
res.writeHead(). Stream the response body chunk by chunk usinggetReader(). Callres.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.jsFor TypeScript development without a build step, use Node 22's built-in TypeScript support:
node --experimental-strip-types server.tsWhy 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.jsOn older Node versions, use the dotenv package:
import 'dotenv/config';
// process.env.DATABASE_URL is now availableDocker
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 savesystemd
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.targetsudo systemctl enable juice-app
sudo systemctl start juice-appFor 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), orcanvas(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).