A small, zero-dependency HTTP application core that runs the same code across local runtimes (Bun, Deno, Node.js, txiki.js), cloud providers (Cloudflare Workers, Deno Deploy, Netlify, Vercel), and browser service workers.
npm install @litejs/server
// server.mjs
import { App, Server } from "@litejs/server"
const app = App()
// Routes and middleware run in registration order
// Only middleware registered before the matching route runs
// Without a matching route, no middleware runs
app.use((req, env) => {
// Return a truthy response to stop further execution
})
// Write route paths without leading or trailing `/`
// Only the first matching route is executed
app.get("hello/world", (req, env) => "Hello MOON!")
app.get("hello/{name}", (req, env) => "Hello " + req.param.name)
app.get("bye/{name}", (req, env) => "Bye " + req.param.name)
app.get("bye/moon", (req, env) => { /* Never executed because the previous handler matches */ })
app.get("teapot", (req) => (req.resStatus = 418, "no coffee"))
app.get("notFound", () => 404) // Return a number to send a status code
// Middleware does not accept a path; add it to a sub-app to scope it to the mount prefix.
// Mount the sub-app under a prefix written without leading or trailing `/`.
const subApp = App()
.use(auth)
.post("", (req, env) => {
// POST /api -> req.path == "/" and req.fullPath == "/api"
return { data: [] }
})
.post("echo", async (req, env) => {
// POST /api/echo -> req.path == "/echo" and req.fullPath == "/api/echo"
return await req.json()
})
app.mount("api", subApp)
// A common entry point that handles runtime differences.
// On Cloudflare and Vercel, it returns `{ fetch }`; on Netlify, the handler itself;
// on Node.js, Bun, Deno, and txiki.js, it starts the server.
export default Server(app)Handlers receive (req, env, ctx) and may return
a native Response,
a number (status only),
an object or array (serialized to JSON),
or any value accepted as the body of a new Response.
Set the status with req.resStatus = 409 and add headers with req.resHeaders.allow = "GET, PUT".
Thrown errors map to err.code || 500; 5xx bodies are kept generic.
Requests include param, path, fullPath, query, searchParams, and header(name).
Routes match against path, the raw, percent-encoded pathname;
fullPath and param values are decoded.
user/{username}matches one path segment (no/)post/{id+}matches one or more digitsfiles/{rest*}.extgreedily matches all charactersa/{dir/}{name}matches zero or more slash-terminated directoriespub/\{x}matches the literal pathpub/{x}
More complex setups require manually configured environments.
For example, env.KV is provided natively on Cloudflare but must be configured for local runtimes.
Runtime-specific environments can be selected in several ways.
One option is to use a separate entry file for each runtime.
Another is to use conditional imports in package.json, allowing runtimes to share an entry point:
{
"imports": {
"#env": {
"workerd": "./env/workerd.mjs",
"default": "./env/local.mjs"
}
}
}Configure ASSETS and KV bindings in wrangler.jsonc for Cloudflare.
On local runtimes, serveStatic provides ASSETS, while a SQLite-backed shim provides KV.
// env/workerd.mjs
export { env } from "cloudflare:workers"// env/local.mjs
import { DB, KV, serveStatic } from "@litejs/server"
var db = new DB(":memory:")
, env = {
ASSETS: serveStatic("public"),
KV: KV(db, "kv"),
}
export { env }The same server entry point runs on Cloudflare, Bun, Deno, Node.js, and txiki.js:
// server.mjs
import { Server } from "@litejs/server"
import { env } from "#env"
import { app } from "./app.mjs"
export default Server(app, env)Runnable examples are in demo/ and test/server/.
Copyright (c) 2026 Lauri Rooden <lauri@rooden.ee>
MIT License | GitHub repo | npm package | Buy Me A Tea