@sourceregistry/node-webserver
v1.3.0
Published
TypeScript web server for Node.js with web-standard Request and Response APIs
Maintainers
Readme
@sourceregistry/node-webserver
TypeScript web server for Node.js built around the web platform Request and Response APIs.
It provides:
- A typed router with path params
- Middleware support
- Route enhancers for typed request-scoped context
- Router lifecycle hooks with
pre()andpost() - WebSocket routing
- Cookie helpers
- Built-in CORS and rate limiting middleware
- Safer defaults for host handling and WebSocket upgrade validation
Installation
npm install @sourceregistry/node-webserverNode.js 18+ is required.
Quick Start
import { WebServer, json, text } from "@sourceregistry/node-webserver";
const app = new WebServer();
app.GET("/", () => text("hello world"));
app.GET("/health", () => json({
ok: true
}));
app.listen(3000, () => {
console.log("listening on http://127.0.0.1:3000");
});Core Concepts
Create a server
import { WebServer } from "@sourceregistry/node-webserver";
const app = new WebServer();WebServer extends Router, so you can register routes and middleware directly on app.
You can also pass handler callbacks for locals and platform:
const app = new WebServer({
locals: (event) => ({
requestId: crypto.randomUUID(),
ip: event.getClientAddress()
}),
platform: () => ({
name: "node"
})
});Register routes
app.GET("/users", async () => {
return new Response("all users");
});
app.GET("/users/[id]", async (event) => {
return new Response(`user ${event.params.id}`);
});
app.POST("/users", async (event) => {
const body = await event.request.json();
return json({ created: true, body }, { status: 201 });
});Supported HTTP methods:
GETPOSTPUTPATCHDELETEHEADOPTIONSUSEto register the same handler for all methods
Nested routers
import { Router } from "@sourceregistry/node-webserver";
const api = new Router();
api.GET("/status", () => new Response("ok"));
app.use("/api", api);Response helpers
The library exports helpers for common content types:
import { html, json, text } from "@sourceregistry/node-webserver";
app.GET("/", () => html("<h1>Hello</h1>"));
app.GET("/message", () => text("plain text"));
app.GET("/data", () => json({ ok: true }));It also exports redirect() and error() for control flow. These helpers throw a Response, and the router immediately returns that response without continuing route resolution. This works in normal routes, middleware, lifecycle hooks, and nested routers.
import { error, redirect } from "@sourceregistry/node-webserver";
app.GET("/old", () => {
redirect(302, "/new");
});
app.GET("/admin", (event) => {
if (!event.locals.userId) {
error(401, { message: "Unauthorized" });
}
return new Response("secret");
});Nested routers short-circuit the same way:
const api = new Router();
api.GET("/legacy", () => {
redirect(301, "/api/v2");
});
app.use("/api", api);It also exports sse() for Server-Sent Events. The helper creates a streaming response and passes your callback an emit() function. You can also pass a ResponseInit object to override status or headers.
import { sse } from "@sourceregistry/node-webserver";
app.GET("/events", sse((event, emit) => {
emit({ connected: true }, { event: "ready", id: "1" });
emit(`hello ${event.getClientAddress()}`);
}, {
status: 200,
headers: {
"x-stream": "enabled"
}
}));emit(data, options) supports:
eventfor the SSE event nameidfor the SSE event idretryfor the reconnection delaycommentfor SSE comment lines
Objects are serialized as JSON automatically. Strings are sent as plain data: lines.
Request Handling
Route handlers receive a web-standard Request plus extra routing data:
app.GET("/posts/[slug]", async (event) => {
const userAgent = event.request.headers.get("user-agent");
const slug = event.params.slug;
const ip = event.getClientAddress();
event.setHeaders({
"Cache-Control": "no-store"
});
return json({
slug,
userAgent,
ip
});
});Available fields include:
event.requestevent.urlevent.fetch(...)event.paramsevent.localsevent.platformevent.cookiesevent.getClientAddress()event.setHeaders(...)
event.fetch(...) is a server-aware variant of the native Fetch API:
- it resolves relative URLs against the current request URL
- it forwards
cookieandauthorizationheaders by default - it dispatches same-origin requests internally through the router when possible
app.GET("/posts", async (event) => {
const response = await event.fetch("/api/posts");
return new Response(await response.text(), {
headers: {
"content-type": response.headers.get("content-type") ?? "text/plain"
}
});
});App Typings
You can extend the request-local and platform typings by adding your own app.d.ts file in your project:
declare global {
namespace App {
interface Locals {
userId?: string;
requestId: string;
}
interface Platform {
name: string;
}
}
}
export {};The server will use those App.Locals and App.Platform definitions automatically in route handlers, middleware, and lifecycle hooks.
Middleware
Middleware wraps request handling and can short-circuit the chain.
app.useMiddleware(async (event, next) => {
const startedAt = Date.now();
const response = await next();
if (!response) {
return new Response("No response", { status: 500 });
}
const nextResponse = new Response(response.body, response);
nextResponse.headers.set("x-response-time", String(Date.now() - startedAt));
return nextResponse;
});Route-specific middleware:
const requireApiKey = async (event, next) => {
if (event.request.headers.get("x-api-key") !== process.env.API_KEY) {
return new Response("Unauthorized", { status: 401 });
}
return next();
};
app.GET("/admin", () => new Response("secret"), requireApiKey);Route Enhancers
Use enhance() when you want to derive typed request-scoped data for a single handler without putting everything on event.locals.
Each enhancer receives the normal request event and can:
- return an object to merge into
event.context - return
undefinedto contribute nothing - return a
Responseto short-circuit the route early - throw
error(...),redirect(...), ornew Response(...)for the same control flow used elsewhere in the router
import { enhance, error } from "@sourceregistry/node-webserver";
app.GET("/admin", enhance(
async (event) => {
return new Response(JSON.stringify({
userId: event.context.user.id,
requestId: event.context.requestId
}), {
headers: {
"content-type": "application/json"
}
});
},
async (event) => {
const token = event.request.headers.get("authorization");
if (!token) {
error(401, { message: "Unauthorized" });
}
return {
user: { id: "u_1", role: "admin" }
};
},
async (event) => {
return {
requestId: event.locals.requestId
};
}
));Router Lifecycle Hooks
Use pre() for logic that should run before route resolution, and post() for logic that should run after a response has been produced.
pre()
pre() can short-circuit the request by returning a Response.
app.pre(async (event) => {
if (!event.request.headers.get("authorization")) {
return new Response("Unauthorized", { status: 401 });
}
});post()
post() receives the final response and may replace it.
app.post(async (_event, response) => {
const nextResponse = new Response(response.body, response);
nextResponse.headers.set("x-powered-by", "node-webserver");
return nextResponse;
});Cookies
app.GET("/login", async (event) => {
event.cookies.set("session", "abc123", {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: true
});
return new Response("logged in");
});
app.GET("/me", async (event) => {
const session = event.cookies.get("session");
return json({ session });
});
app.POST("/logout", async (event) => {
event.cookies.delete("session", {
path: "/",
httpOnly: true,
secure: true
});
return new Response("logged out");
});WebSocket Routes
app.WS("/ws/chat/[room]", async (event) => {
const room = event.params.room;
const ws = event.websocket;
ws.send(`joined:${room}`);
ws.on("message", (message) => {
ws.send(`echo:${message.toString()}`);
});
});Static Files
Use dir() to expose a directory through a route, or serveStatic() directly if you want manual control.
import { dir } from "@sourceregistry/node-webserver";
app.GET("/assets/[...path]", dir("./public/assets"));
app.GET("/", dir("./public"));Manual usage:
import { serveStatic } from "@sourceregistry/node-webserver";
app.GET("/downloads/[...path]", (event) => {
return serveStatic("./downloads", event, {
cacheControl: "public, max-age=3600"
});
});The helper canonicalizes and validates the requested path, rejects traversal attempts such as ../secret.txt and encoded variants like ..%2fsecret.txt, and verifies that symlinks cannot escape the configured root.
Security Options
The server includes a security config block for safer defaults.
const app = new WebServer({
type: "http",
options: {},
security: {
maxRequestBodySize: 1024 * 1024,
maxWebSocketPayload: 64 * 1024,
allowedWebSocketOrigins: [
"https://app.example.com",
"https://admin.example.com"
]
}
});Available options:
trustHostHeaderallowedHostsallowedWebSocketOriginsmaxRequestBodySizemaxWebSocketPayload
trustHostHeader defaults to false. That is the safer default for public-facing services unless you are explicitly validating proxy behavior.
Built-in Middleware
CORS
import { CORS } from "@sourceregistry/node-webserver";
app.useMiddleware(CORS.policy({
origin: ["https://app.example.com"],
credentials: true,
methods: ["GET", "POST", "DELETE"]
}));Rate Limiting
import { RateLimiter } from "@sourceregistry/node-webserver";
app.useMiddleware(RateLimiter.fixedWindowLimit({
windowMs: 60_000,
max: 100
}));HTTPS Server
import { readFileSync } from "node:fs";
import { WebServer } from "@sourceregistry/node-webserver";
const app = new WebServer({
type: "https",
options: {
key: readFileSync("./certs/server.key"),
cert: readFileSync("./certs/server.crt")
}
});
app.GET("/", () => new Response("secure"));
app.listen(3443);Full Example
import {
CORS,
RateLimiter,
WebServer,
json,
text
} from "@sourceregistry/node-webserver";
const app = new WebServer({
type: "http",
options: {},
locals: () => ({
startedAt: Date.now()
}),
security: {
maxRequestBodySize: 1024 * 1024,
allowedWebSocketOrigins: "https://app.example.com"
}
});
app.pre(async (event) => {
if (event.url.pathname.startsWith("/private")) {
const auth = event.request.headers.get("authorization");
if (!auth) {
return new Response("Unauthorized", { status: 401 });
}
}
});
app.useMiddleware(
CORS.policy({
origin: "https://app.example.com",
credentials: true
}),
RateLimiter.fixedWindowLimit({
max: 60,
windowMs: 60_000
})
);
app.GET("/", () => text("hello"));
app.GET("/users/[id]", (event) => {
return json({
id: event.params.id,
requestId: event.locals.startedAt
});
});
app.post(async (_event, response) => {
const nextResponse = new Response(response.body, response);
nextResponse.headers.set("x-server", "node-webserver");
return nextResponse;
});
app.listen(3000, () => {
console.log("server listening on port 3000");
});Development
npm test
npm run buildLicense
Apache-2.0. See LICENSE.
