node-webserver
    Preparing search index...

    node-webserver

    @sourceregistry/node-webserver

    npm version License CI

    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() and post()
    • WebSocket routing
    • Cookie helpers
    • Built-in CORS and rate limiting middleware
    • Safer defaults for host handling and WebSocket upgrade validation
    npm install @sourceregistry/node-webserver
    

    Node.js 18+ is required.

    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");
    });
    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"
    })
    });
    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:

    • GET
    • POST
    • PUT
    • PATCH
    • DELETE
    • HEAD
    • OPTIONS
    • USE to register the same handler for all methods
    import { Router } from "@sourceregistry/node-webserver";

    const api = new Router();

    api.GET("/status", () => new Response("ok"));

    app.use("/api", api);

    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:

    • event for the SSE event name
    • id for the SSE event id
    • retry for the reconnection delay
    • comment for SSE comment lines

    Objects are serialized as JSON automatically. Strings are sent as plain data: lines.

    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.request
    • event.url
    • event.fetch(...)
    • event.params
    • event.locals
    • event.platform
    • event.cookies
    • event.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 cookie and authorization headers 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"
    }
    });
    });

    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 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);

    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 undefined to contribute nothing
    • return a Response to short-circuit the route early
    • throw error(...), redirect(...), or new 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
    };
    }
    ));

    Use pre() for logic that should run before route resolution, and post() for logic that should run after a response has been produced.

    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() 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;
    });
    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");
    });
    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()}`);
    });
    });

    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.

    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:

    • trustHostHeader
    • allowedHosts
    • allowedWebSocketOrigins
    • maxRequestBodySize
    • maxWebSocketPayload

    trustHostHeader defaults to false. That is the safer default for public-facing services unless you are explicitly validating proxy behavior.

    import { CORS } from "@sourceregistry/node-webserver";

    app.useMiddleware(CORS.policy({
    origin: ["https://app.example.com"],
    credentials: true,
    methods: ["GET", "POST", "DELETE"]
    }));
    import { RateLimiter } from "@sourceregistry/node-webserver";

    app.useMiddleware(RateLimiter.fixedWindowLimit({
    windowMs: 60_000,
    max: 100
    }));
    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);
    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");
    });
    npm test
    npm run build

    Apache-2.0. See LICENSE.