Docs

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 ships every target's build + server entry and config. Pass --deploy <target> to make one the default - it points build/deploy at that target and fills in your project name. The per-target build:*/deploy:* scripts stay, so you can switch any time.

TS
# Scaffold the multi-target site with a chosen default deploy target:
bun create nifra my-app --deploy vercel     # or: bun | node | deno | cf-pages
# add --framework to pick the UI too (react default):
bun create nifra my-app --framework svelte --deploy vercel

cd my-app && bun install
bun run dev       # local preview
bun run build     # builds for the chosen target
bun run deploy    # runs that target's deploy CLI (you stay logged-in to the vendor)

# Or add --ci github to also emit a deploy-on-push GitHub Actions workflow:
bun create nifra my-app --deploy cf-pages --ci github
#   → .github/workflows/deploy.yml (builds on every push/PR, deploys on push to main)
targetbuild →deployconfig scaffolded
Bun (flagship)dist-bun/bun run start (any host)-
Nodedist-node/docker build … && docker runDockerfile + .dockerignore
Deno Deploydist-deno/deployctl deploydeno.json
Cloudflare Pagesdist/wrangler pages deploywrangler.toml
Vercel Edge.vercel/output/vercel deploy --prebuiltBuild Output API v3

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.

TS
// 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.
  • 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, not no-store. Anything else goes straight to the network.
  • The cache name carries the build id, and activation deletes every older cache - so a stale worker cannot pin an old build indefinitely.

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. cf-pages 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 to node:http.
  • @nifrajs/deno - serves app.fetch on Deno.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.

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.

TS
// 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)

buildServer bundles with edge conditions (workerd, edge-light) into a _worker.js; toFetchHandler(app) is the handler. For Pages, a _routes.json serves the client bundle statically. SSR verified on real workerd - this very site runs there.

TS
// _worker.ts - the edge SSR entry
import { toFetchHandler } from "@nifrajs/core/server"
import { createWebApp } from "@nifrajs/web"
import { reactAdapter } from "@nifrajs/web-react"
import { clientEntry, manifest } from "./server-manifest"  // generated by buildServer

const app = createWebApp({ adapter: reactAdapter, manifest, clientEntry })
export default toFetchHandler(app)   // a Workers/Pages fetch handler

// build.ts - buildClient (→ /assets) + buildServer (edge conditions → _worker.js)
import { buildClient, buildServer } from "@nifrajs/web/build"

Deploy: wrangler pages deploy dist (Pages) or wrangler deploy (Workers). Deno Deploy and Vercel Edge reuse the same build with their conditions.