Deployment
app.fetch(request) is a pure Web-standard handler, so the same app runs anywhere. Pick a runtime; the code doesn't change.
One command per target
create-nifra's site template deploys to one target, chosen with --target (default bun). The app carries no server entry or build script: nifra build generates the target's entry from backend/ and routes/. The target is one line of nifra.config.ts (export const target = "vercel"), so nifra target <t> switches it and every command reads the same value.
# Scaffold a site for one deploy target (bun is the default):
bun create nifra my-app --target vercel # or: bun | node | deno | cloudflare
# add --framework to pick the UI too (react default), --docker for a bun/node image:
bun create nifra my-app --framework svelte --target node --docker
cd my-app && bun install
bun run dev # local preview
bun run build # nifra build: generates the target's server entry and bundles it
bun run deploy # runs that target's deploy CLI (you stay logged-in to the vendor)
# The target lives in nifra.config.ts; switch it with one command:
bunx nifra target cloudflare
# Or add --ci github to also emit a deploy-on-push GitHub Actions workflow:
bun create nifra my-app --target cloudflare --ci github
# → .github/workflows/deploy.yml (builds on every push/PR, deploys on push to main)| target | build → | deploy | config scaffolded |
|---|---|---|---|
| Bun (flagship) | dist/server.js | bun run start (any host), or a Docker image with --docker | Dockerfile + .dockerignore with --docker |
| Node | dist/server.js | bun run start (any host), or a Docker image with --docker | Dockerfile + .dockerignore with --docker |
| Deno Deploy | dist/server.js | deployctl deploy | deno.json |
| Cloudflare Pages | dist/_worker.js | wrangler pages deploy | wrangler.toml |
| Vercel Edge | .vercel/output/ | vercel deploy --prebuilt | none (Build Output API v3) |
The Bun, Node and Deno entries serve the client bundle from disk next to themselves; on Cloudflare and Vercel the platform serves it.
The Bun, Node and Deno entries pass the socket peer as c.clientIp. Cloudflare and Vercel expose no socket, so an edge build has no caller address unless nifra.config.ts declares export const clientIp = "platform": then the entry trusts the header that platform's edge overwrites (cf-connecting-ip, x-real-ip), and per-caller middleware such as rateLimit works there. The scaffold declares it. The trust holds only while that edge is the one way in: the same bundle served by wrangler pages dev or a self-hosted workerd takes the header from the client, so drop the line before serving it publicly any other way.
Nifra never runs the deploy or enters your cloud credentials - it scaffolds the config + a deploy script that shells out to the vendor CLI you've already authed.
Offline & service workers
@nifrajs/web/service-worker turns a build manifest into a service worker. It is opt-in and generated at build time, so an app that never calls it ships nothing.
// build.ts - after buildClient(), write the worker to the ORIGIN ROOT.
import { generateServiceWorker, serviceWorkerRegistration } from "@nifrajs/web/service-worker"
export async function emitServiceWorker(manifest: {
entry: string
assets: readonly string[]
css?: readonly string[]
}): Promise<string> {
const sw = generateServiceWorker(manifest, {
// Anything that changes exactly when the assets do: a content hash, a commit sha, a release.
buildId: Bun.env.GIT_SHA ?? "dev",
offlineUrl: "/offline",
})
await Bun.write("dist/sw.js", sw)
// Put this in a <script> in your document shell.
return serviceWorkerRegistration("/sw.js")
}Serve the file from the origin root. A worker's default scope is its own directory, so one served from /assets/ could never control the pages it exists for.
The generated worker is deliberately narrow, because a service worker outlives a deploy and can hand one visitor a response produced for another:
- Only content-hashed assets are precached. A hashed URL names its bytes, so serving it from cache forever is correct by construction. Unhashed URLs are left to the network.
- Only the build's own URLs are answered, from the build's own cache. Any other request, an API call included, goes to the network untouched, and a cache the page or another app stored on the origin is never read.
- Documents are never cached. Only navigations that FAIL are answered, and only with the offline page you nominate - which must be static and user-independent, since every visitor gets the same bytes. Caching HTML is how a worker serves one signed-in user the page rendered for another.
- GET, same-origin,
ok, notno-store. Anything else goes straight to the network. - The cache name carries the build id, and activation deletes every older cache named
<cacheName>:<buildId>- so a stale worker cannot pin an old build indefinitely, and the origin's other caches are left alone.
Omit offlineUrl and a failed navigation simply fails, which is the honest default: an offline page you have not written is not better than a browser error.
CI: deploy on push
Add --ci github to emit a .github/workflows/deploy.yml tuned to the chosen target - it builds on every push/PR and deploys on a push to main. cloudflare uses cloudflare/wrangler-action, vercel the prebuilt vercel deploy, deno deployctl (OIDC). The workflow's header comment lists the exact repo secrets to set (e.g. CLOUDFLARE_API_TOKEN, VERCEL_TOKEN). Self-hosted bun/node have no universal push-to-deploy, so their workflow builds + uploads the bundle as an artifact and leaves a clearly-marked, host-specific deploy step for you to fill in (Fly, a registry push, SSH, …).
Bun (its home)
app.listen(3000) - the native Bun server. Sits at the raw Bun.serve ceiling (see benchmarks).
Every core: Bun is single-threaded per process - for multi-core boxes, spawn one process per core, each binding the same port with app.listen(PORT, { reusePort: true }); the kernel load-balances connections across them (Linux balances ~evenly). A supervisor that spawns + restarts workers is ~20 lines - see examples/cluster.ts. Anything shared across workers (rate limits, sessions, pub/sub) needs a shared store, as in any multi-instance deploy.
Node & Deno
@nifrajs/node-serve(app, { port })bridges tonode:http.@nifrajs/deno- servesapp.fetchonDeno.serve.
Self-hosting (no CDN in front)? Hand @nifrajs/node's serve a static mount and it serves the client bundle from disk - traversal-guarded, content-typed, with an immutable cache - before the app runs, leaving the SSR fast path untouched: serve(app, { port, static: { dir: new URL("./assets/", import.meta.url) } }). On Cloudflare/Vercel the platform serves assets, so you don't need it there.
Every file under the mount is cached as immutable by default. If the directory also holds files that keep their name across deploys, pass the build manifest's assets as hashedFiles: only those URL paths stay immutable, and every other file is sent with its validators so the browser revalidates it. The server that nifra build --target bun or node generates does this already. It caches only the client build's own content-hashed outputs as immutable. A file copied from public/, even under public/assets/, gets a one-day policy.
HTTPS without a proxy
Most deploys end TLS in front of the app - a load balancer, a CDN, the platform - and the app speaks plain HTTP behind it. To serve HTTPS from the process itself, pass tls with the certificate chain and private key. Requests then arrive with https: URLs, and WebSocket upgrades ride the same port.
import { readFileSync } from "node:fs"
import { server } from "@nifrajs/core"
const app = server().get("/", () => ({ ok: true }))
// PEM text or the files' bytes.
const tls = { cert: readFileSync("cert.pem"), key: readFileSync("key.pem") }
app.listen(443, { tls }) // Bun: https://, and wss:// for WebSockets, on one port
Node and Deno take the same option: serve(app, { port: 443, tls }). On Node it accepts any node:tls server option beside cert and key - ca and requestCert for client certificates, minVersion, SNICallback for several hostnames. Bun also takes a passphrase for an encrypted key.
AWS Lambda
@nifrajs/aws-lambda - API Gateway HTTP APIs (payload v2) and Lambda Function URLs. handle(app) is the buffered handler; streamHandle(app) streams responses on Function URLs with InvokeMode: RESPONSE_STREAM. Build the app at module scope (once per container). The adapter decodes the body and checks its real size before a Request exists, assembles headers + the v2 cookies array in one place, decides response base64 by what the bytes actually are, and feeds sourceIp into c.clientIp. waitUntil work settles before the container freezes. REST APIs (payload v1) and ALB are not supported.
// handler.ts - one app per container, built at module scope
import { handle, streamHandle, type LambdaEnv } from "@nifrajs/aws-lambda"
import { server } from "@nifrajs/core"
const app = server<LambdaEnv>()
.get("/", (c) => c.json({ ip: c.clientIp, requestId: c.env.context?.awsRequestId }))
export const handler = handle(app) // API Gateway HTTP API (v2) + Function URLs
// or, on a Function URL with InvokeMode: RESPONSE_STREAM:
// export const handler = streamHandle(app)Cloudflare Workers / Pages (the edge)
For the cloudflare target, nifra build bundles the server with edge conditions (workerd, edge-light) into dist/_worker.js, wrapped in toFetchHandler(app), and writes a _routes.json so Pages serves the client bundle statically. SSR verified on real workerd - this very site runs there. A Worker you assemble yourself uses the same adapter:
// worker.ts - any nifra app as a Workers fetch handler
import { server, toFetchHandler } from "@nifrajs/core/server"
const app = server().get("/", () => ({ ok: true }))
export default toFetchHandler(app)Deploy: wrangler pages deploy dist (Pages) or wrangler deploy (Workers). The vercel target emits the same edge bundle as a Build Output API function under .vercel/output.