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.
Defer work to be executed after a response is handled with req.defer(fn);
it runs through ctx.waitUntil, so a Worker stays alive for it.
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}
Parse application/json, application/x-www-form-urlencoded and multipart/form-data into object, with a[]=1&b[c]=2 syntax.
Multipart is read from the req.body one part at a time.
import { content } from "@litejs/server"
app.post("upload", async (req, env) => {
// Each file arrives as a File, capped by maxFileSize; a Blob carries its length, which R2 put needs
const { title, file } = await content(req)
await env.BUCKET.put(file.name, file, { httpMetadata: { contentType: file.type } })
return { title, key: file.name }
})To keep a large file out of memory, handle the part yourself:
content(req, { file: part => upload(part.body).then(() => part.filename) }).
The handler runs for each file part in order and its return value takes the file's place in the body.
A part has name, filename, type and body,
a ReadableStream that must be consumed before the next part is read;
what a handler leaves unread is dropped.
Limits maxBodySize, maxFields, maxFieldSize, maxFiles and maxFileSize throw a 413,
an unknown type a 415.
More types go in accept, an accept() rule to parser map merged over the built-in ones:
{ accept: { "text/csv;header=": (str, negod) => ... } }.
Handlers receive env as their second argument.
On Cloudflare it is the platform env with the bindings, elsewhere it is a plain object you must fill.
For example, Cloudflare provided env.ASSETS and env.KV needs a shim locally.
Keep that wiring in a conditional import in package.json, so every runtime shares the entry point:
{
"imports": {
"#env": {
"workerd": "./env/workerd.mjs",
"default": "./env/local.mjs"
}
}
}env/workerd.mjs may be an empty file if no custom env needed.
// env/workerd.mjs
import { env } from "@litejs/server"
// Add custom env value to use later
env.RUNTIME = "Cloudflare"The local file appends .env.json and the process environment with loadEnv(),
then adds what the platform would have bound:
// env/local.mjs
import { DB, KV, env, loadEnv, serveStatic } from "@litejs/server"
loadEnv(".env.json")
env.ASSETS = serveStatic("public")
env.KV = KV(new DB(env.DB_PATH || ":memory:"), "kv")
env.RUNTIME = "local"Call loadEnv() to read the process environment alone.
The same server entry point then runs on Cloudflare, Bun, Deno, Node.js, and txiki.js:
// server.mjs
import { Server } from "@litejs/server"
import "#env"
import { app } from "./app.mjs"
export default Server(app)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