Production server for bini-router apps.
Serves the output of vite build: pre-rendered pages, static assets, and the compiled API bundle. It uses Node.js built-in modules and bini-env for .env loading.
- Features
- Requirements
- Install
- Usage
- Keyboard Shortcuts
- Environment Variables
- Project Structure
- API Routes
- Static File Serving
- Deployment
- Security
- Verifying the Server
- Configuration Examples
- API Reference
- Contributing
- License
- Static file serving. Streams
dist/with MIME types, ETag, and cache headers. - API routes. Serves
/api/*from the compiled bundledist/api/index.mjs, whichvite buildproduces fromsrc/app/api/. - Route-aware fallbacks. Pre-rendered pages first, then shell pages for dynamic routes, then
404.html, thenindex.html. - ETag support.
304 Not Modifiedresponses for unchanged static files. - Sub-path deployments. Serve the app under a
BASEpath.
- Body limits. Configurable request body size limit for API requests (default 10 MB).
- Timeouts. Configurable body read and handler timeouts (default 30 s each).
- Path traversal protection. Requests containing a
..segment or a null byte are not served, anddist/api/is never served as a static file. - Error hiding in production. With
NODE_ENV=production, error responses say onlyInternal server error. - Port auto-increment. Starts at
3000and tries the next port while one is busy, up to 99 higher. Each busy port is logged.
- Auto env loading.
.envfiles are loaded at startup and listed in the banner. - Interactive shortcuts.
hfor help,oto open the browser,qto quit. - Cross-platform browser opening.
ousesstart,open, orxdg-opendepending on the platform. - Graceful shutdown. Handles
SIGTERMandSIGINTwith a timeout fallback. - Flexible config. Supports
BINI_*,VITE_*, or unprefixed environment variables.
- Node.js ≥ 20.19.0
- A bini-router project with a finished
vite build:dist/(required; the server exits if it is missing)dist/api/index.mjs(only for API routes; it must exportfetch())- pre-rendered pages,
404.html, and shell pages from bini-ssg (optional)
npm install bini-server
# or
pnpm add bini-server
# or
yarn add bini-server{
"scripts": {
"build": "vite build",
"start": "bini-server"
}
}npm run build # Build your app
npm start # Serve in production ß Bini.js (production)
➜ Environments: .env.local, .env
➜ Local: http://localhost:3000/
➜ Network: http://192.168.1.5:3000/
➜ press h + enter to show help
Environmentslists the.envfiles that were found, relative to the working directory, and is omitted when there are none.Networkis the first private IPv4 address of a non-internal interface, skipping virtual interfaces such as Docker and VPN adapters. If there is none, it falls back to the first non-internal IPv4 address, and it is omitted when no address is found.Baseappears as an extra line whenBASEis set.
The module exports a single function:
import { start } from 'bini-server'
await start()- Loads
.envfiles from the working directory and re-reads the configuration. - Exits with code 1 when the
distdirectory does not exist. - Loads
dist/api/index.mjs(a warning is printed if it is missing, see API Routes). - Scans
distonce for shell pages. - Finds a free port, starting at
PORT(see Port selection). - Starts the HTTP server on
0.0.0.0, prints the banner, and starts the keyboard shortcuts.
While the server is running, type a key and press enter:
| Key | Action |
|---|---|
h |
Show available shortcuts |
o |
Open http://localhost:<port><base>/ in the default browser |
q |
Quit the server |
Keyboard shortcuts are disabled when stdin is not a terminal (for example in CI/CD or most hosted platforms). When stdin closes, the process exits.
At startup, bini-server loads these files from the working directory, when they exist. They are listed from highest to lowest priority, with [mode] being NODE_ENV (or production when it is not set):
.env.[mode].local.env.[mode].env.local.env
A variable that is already set in the environment is never overridden by a file, and a higher-priority file wins over a lower one. The loaded values are available through process.env, which is also what API routes receive as env. The server settings below can come from these files too.
All settings support three naming conventions:
| Convention | Example | Priority |
|---|---|---|
BINI_* |
BINI_PORT=3000 |
Highest |
VITE_* |
VITE_PORT=3000 |
Medium |
| No prefix | PORT=3000 |
Lowest |
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
HTTP port to listen on. If it is in use, the next free port is used (see Port selection) |
DIST_DIR |
dist |
Build output folder, relative to the working directory |
BASE |
(empty) | Sub-path the app is served under. '', /, ., and ./ mean no base; any other value is normalized to a leading slash with no trailing slash (app/ becomes /app) |
BODY_TIMEOUT_SECS |
30 |
Max seconds to read an API request body |
HANDLER_TIMEOUT_SECS |
30 |
Max seconds for the API handler to respond |
BODY_SIZE_LIMIT |
10485760 |
Max API request body size in bytes (10 MB) |
Values that are not numbers fall back to the default. Setting a limit or timeout to 0 does not disable it: a body limit of 0 rejects any request body, and a timeout of 0 expires immediately.
The server also reads NODE_ENV. When it is production, error responses hide the underlying message.
The server checks PORT, then each following port, until it finds one that is free on 0.0.0.0. It logs one line for every busy port it skips:
Port 3000 is in use, trying another one...
The banner then shows the port that was actually used in the Local and Network URLs. The search covers 100 ports (PORT through PORT + 99); if none is free, start() fails with No free port found in range <start>–<end>.
# .env file
PORT=8080
BODY_SIZE_LIMIT=5242880 # 5MB
# Or inline
PORT=3001 bini-server
# Or with the BINI prefix (takes precedence over PORT)
BINI_PORT=3002 bini-servermy-app/
├── dist/ # Built output (required)
│ ├── index.html
│ ├── about/index.html # Pre-rendered pages (bini-ssg)
│ ├── blog/[slug]/index.html # Shell page for a dynamic route (bini-ssg)
│ ├── 404.html
│ ├── assets/
│ └── api/
│ └── index.mjs # Compiled API bundle (not served as a file)
├── src/
│ ├── app/
│ │ ├── api/ # API handlers, compiled into dist/api at build time
│ │ │ ├── users.ts
│ │ │ └── posts/
│ │ │ ├── index.ts
│ │ │ └── [id].ts
│ │ └── layout.tsx
│ └── main.tsx
├── .env # Environment variables
├── package.json
└── vite.config.ts
Requests to <base>/api and <base>/api/... are passed to the fetch export of dist/api/index.mjs, which vite build produces when src/app/api exists. bini-server does not read src/ at runtime. The bundle is loaded once, at startup.
fetch(request: Request, env: NodeJS.ProcessEnv, ctx: { waitUntil(p: Promise<unknown>): void })request: a standardRequestwhose URL ishttp://<host header><original request URL>. Header arrays are joined with a comma and a space (thecookieheader with a semicolon and a space), and the body is read for every method exceptGETandHEAD.env:process.env, so bini-env'sgetEnv(c, key)sees the same values, including those loaded from.envfiles.ctx.waitUntil: accepts a promise and swallows its rejection; the server does not wait for it.
The response status and headers (including every Set-Cookie) are copied, and the body is streamed to the client. HEAD requests receive headers only.
How handlers are written, routed, and matched is defined by bini-router. For example:
// src/app/api/hello.ts
// 1. Hono app (default export with .fetch)
import { Hono } from 'hono'
const app = new Hono()
app.get('/hello', (c) => c.json({ message: 'Hello' }))
export default app
// 2. Plain function (default export)
export default (req: Request) => Response.json({ message: 'Hello' })
// 3. Per-method exports
export function GET(req: Request, { params }) {
return Response.json({ id: params.id })
}Only .ts and .js files in src/app/api/ are used.
src/app/api/
users/
[id].ts → /api/users/:id
posts/
[...slug].ts → /api/posts/*
Route parameters arrive in the second argument of a handler ({ params, searchParams, env, waitUntil }). See the bini-router documentation for the full routing rules.
bini-server does not add CORS headers or answer preflight requests itself. CORS is handled by the compiled bundle and is controlled by the cors option of biniroute() in vite.config.ts (it is off by default).
bini-server and the compiled API each check the base path, so set BASE to the sub-path your app was built for.
The server's BODY_SIZE_LIMIT applies, and so does the bundle's own bodySizeLimit (the biniroute() option, 1 MB by default), so the lower of the two limits is the one you hit.
If dist/api/index.mjs is missing or does not export fetch(), the server prints a warning at startup, and every API request returns 404 with {"error":"No API routes compiled"}.
For a path with a file extension, the server looks for that file under dist/. Anything under dist/api/ is never served. If the file does not exist, the response is a plain-text 404 Not found.
For a path without an extension, the server uses the first of these that applies:
| Order | Looks for | Status |
|---|---|---|
| 1 | dist/<path>/index.html (pre-rendered page) |
200 |
| 2 | A shell page whose folder name matches the path | 200 |
| 3 | dist/404.html |
404 |
| 4 | dist/index.html (client-side fallback) |
200 |
| 5 | Nothing found | plain-text 404 |
When BASE is set, only paths under it are served.
At startup the server scans dist once for folders whose names are wrapped in square brackets and that contain an index.html, such as dist/blog/[slug]/index.html. A new build requires a restart to be picked up. Folders named api, dot-folders, and node_modules are skipped.
| Folder | Matches |
|---|---|
blog/[slug] |
/blog/anything (exactly one segment in that position) |
docs/[...path] |
/docs/a, /docs/a/b (at least one segment) |
docs/[[...path]] |
/docs, /docs/a, /docs/a/b (zero or more segments) |
Bracketed segments match any value, other segments must match exactly, and the first matching shell wins; there is no ranking by specificity.
| Type | Extensions |
|---|---|
| Documents and code | .html, .js, .mjs, .css, .json, .map, .txt, .xml, .webmanifest |
| Images | .png, .jpg, .jpeg, .gif, .svg, .webp, .avif, .ico |
| Fonts | .woff, .woff2, .ttf, .eot |
Any other extension is served as application/octet-stream. Every file response also carries Content-Length and X-Powered-By: Bini.js.
| File Type | Cache Policy |
|---|---|
Files inside an assets/ folder |
public, max-age=31536000, immutable (1 year) |
| All other files | no-cache |
ETags are generated from the file size and mtimeMs:
- An
ETagheader is sent on every file response (an MD5 hash of those two values, 16 characters). - A request whose
If-None-Matchequals the ETag exactly receives304 Not Modified(for200responses).
bini-server does not compress responses.
bini-server needs the build output, not your source: deploy dist/ (including dist/api/index.mjs), package.json, and the installed dependencies. The compiled API leaves packages from node_modules external, so dependencies must be installed on the server.
- VPS / pm2: deploy the project directory, install dependencies, and build
- Railway, Render, Fly.io: build as part of the deploy
- Docker: build inside the image, as shown below
npm run build
npm start
# With pm2 (recommended)
npm install -g pm2
pm2 start "npm start" --name my-app
pm2 save
pm2 startup| Platform | Start Command | Notes |
|---|---|---|
| Railway | npm start |
PORT injected automatically |
| Render | npm start |
PORT injected automatically |
| Fly.io | npm start |
See the fly.toml example below |
| Heroku | npm start |
PORT injected automatically |
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npm", "start"]# fly.toml
[processes]
app = "npm start"| Feature | Default | Configured with |
|---|---|---|
| Body size limit (API) | 10 MB | BODY_SIZE_LIMIT |
| Body read timeout (API) | 30 s | BODY_TIMEOUT_SECS |
| Handler timeout (API) | 30 s | HANDLER_TIMEOUT_SECS |
| Path traversal | .. segments and null bytes are not served |
Not configurable |
| Compiled API files | dist/api/ is never served as static files |
Not configurable |
| Error messages | Hidden when NODE_ENV=production |
NODE_ENV |
| CORS | Handled by the compiled API | biniroute({ cors }) |
# Check static files
curl http://localhost:3000/
# Check API routes
curl http://localhost:3000/api/hello
# Check ETag
curl -I http://localhost:3000/assets/app.js
# Test 304 Not Modified
curl -I http://localhost:3000/assets/app.js \
-H "If-None-Match: [etag_from_previous_request]"NODE_ENV=production
PORT=3000
BODY_TIMEOUT_SECS=30
HANDLER_TIMEOUT_SECS=30
BODY_SIZE_LIMIT=10485760BASE=/appBODY_SIZE_LIMIT=1048576 # 1MBBODY_SIZE_LIMIT=104857600 # 100MB
BODY_TIMEOUT_SECS=300 # 5 minutesRaise bodySizeLimit in biniroute() too, otherwise the bundle's own limit still applies.
BINI_*(highest)VITE_*(medium)- No prefix (lowest)
These are produced by bini-server itself. API handlers may return any status.
| Code | Description |
|---|---|
200 |
Success |
304 |
Not Modified (ETag match) |
400 |
Bad request URL (API requests) |
404 |
Not found (missing file, unknown path, or no compiled API), or 404.html for unknown page paths |
408 |
API request body not received in time, or a read error |
413 |
API request body too large |
500 |
Internal server error (including API handler timeout) |
In production (NODE_ENV=production) the message in 500 responses is always Internal server error.
All methods are passed to the API bundle, which decides which it accepts. Static responses do not check the method, and HEAD receives headers only.
- Fork the repository
- Create your feature branch
- Commit your changes
- Push to the branch
- Open a Pull Request
MIT © Binidu Ranasinghe
Maintained by Binidu Ranasinghe