Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ await running.close();
```

`serve` handles static assets and closes both the HTTP server and the application during shutdown.
Both `listen` and `serve` bind to `127.0.0.1` by default. A non-loopback
`host` also requires `allowPublicBind: true` so public exposure is explicit.

## MCP over stdio

Expand Down
16 changes: 16 additions & 0 deletions src/bind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ListenOptions } from "./contracts.js";

export function resolveBindHost(options: Pick<ListenOptions, "allowPublicBind" | "host">): string {
const host = options.host ?? "127.0.0.1";
const loopback = host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host);
if (!loopback && options.allowPublicBind !== true) {
throw new TypeError(
`Refusing to bind non-loopback host ${host} without allowPublicBind: true.`,
);
}
return host;
}

export function formatHostForUrl(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
1 change: 1 addition & 0 deletions src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface NodeHandlerOptions {
export interface ListenOptions {
port?: number;
host?: string;
allowPublicBind?: boolean;
backlog?: number;
signal?: AbortSignal;
requestTimeout?: number;
Expand Down
6 changes: 4 additions & 2 deletions src/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import type { ServerApp } from "@askrjs/server";
import type { ListenOptions } from "./contracts.js";
import { resolveBindHost } from "./bind.js";
import { createNodeHandler } from "./handler.js";
import { installWebSockets } from "./websocket.js";

Expand All @@ -10,8 +11,9 @@ export type ListeningServer = Server & {
};

export function listen(app: ServerApp, options: ListenOptions = {}): Promise<ListeningServer> {
const host = resolveBindHost(options);
const handlerOptions = {
allowedHosts: [options.host ?? "127.0.0.1", "localhost"],
allowedHosts: [host, "localhost"],
};
const server = createServer(createNodeHandler(app, handlerOptions));
if (options.requestTimeout !== undefined) server.requestTimeout = options.requestTimeout;
Expand Down Expand Up @@ -41,7 +43,7 @@ export function listen(app: ServerApp, options: ListenOptions = {}): Promise<Lis
reject(error);
};
server.once("error", onError);
server.listen(options.port ?? 0, options.host, options.backlog, () => {
server.listen(options.port ?? 0, host, options.backlog, () => {
server.off("error", onError);
resolve(server as ListeningServer);
});
Expand Down
9 changes: 5 additions & 4 deletions src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createServer } from "node:http";
import { extname, resolve, sep } from "node:path";
import type { ServerApp } from "@askrjs/server";
import type { ServeOptions, ServedApplication } from "./contracts.js";
import { formatHostForUrl, resolveBindHost } from "./bind.js";
import { createNodeHandler } from "./handler.js";
import { installWebSockets } from "./websocket.js";

Expand Down Expand Up @@ -36,9 +37,10 @@ export async function serve(
app: ServerApp & { close?: () => void | Promise<void> },
options: ServeOptions = {},
): Promise<ServedApplication> {
const host = resolveBindHost(options);
const root = options.assets ? await realpath(resolve(options.assets.root)) : undefined;
const handlerOptions = {
allowedHosts: [options.host ?? "127.0.0.1", "localhost"],
allowedHosts: [host, "localhost"],
};
const applicationHandler = createNodeHandler(
{
Expand Down Expand Up @@ -152,7 +154,7 @@ export async function serve(

await new Promise<void>((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen(options.port ?? 0, options.host ?? "127.0.0.1", options.backlog, () => {
server.listen(options.port ?? 0, host, options.backlog, () => {
server.removeListener("error", rejectListen);
resolveListen();
});
Expand All @@ -165,6 +167,5 @@ export async function serve(
await close();
throw new Error("serve requires a TCP address.");
}
const host = options.host ?? "127.0.0.1";
return Object.freeze({ server, url: `http://${host}:${address.port}`, close });
return Object.freeze({ server, url: `http://${formatHostForUrl(host)}:${address.port}`, close });
}
21 changes: 21 additions & 0 deletions tests/node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { join } from "node:path";
import { createRouter, createServerApp } from "@askrjs/server";
import { describe, expect, it } from "vitest";
import WebSocket from "ws";
import { formatHostForUrl } from "../src/bind.js";
import { createNodeHandler, listen, serve } from "../src/index.js";
import { writeNodeResponse } from "../src/response.js";

Expand All @@ -24,6 +25,26 @@ async function withServer(
}

describe("Node adapter", () => {
it("should bind to loopback by default and require public bind opt-in", async () => {
const app = { fetch: async () => new Response() };
const local = await listen(app);
const localAddress = local.address();
if (!localAddress || typeof localAddress === "string") throw new Error("Expected TCP address");
expect(localAddress.address).toBe("127.0.0.1");
await new Promise<void>((resolve) => local.close(() => resolve()));

expect(() => listen(app, { host: "0.0.0.0" })).toThrow("without allowPublicBind: true");
const publicServer = await listen(app, { host: "0.0.0.0", allowPublicBind: true });
const publicAddress = publicServer.address();
if (!publicAddress || typeof publicAddress === "string")
throw new Error("Expected TCP address");
expect(publicAddress.address).toBe("0.0.0.0");
await new Promise<void>((resolve) => publicServer.close(() => resolve()));

expect(formatHostForUrl("::1")).toBe("[::1]");
expect(formatHostForUrl("127.0.0.1")).toBe("127.0.0.1");
});

it("should apply native timeout options", async () => {
const server = await listen(
{ fetch: async () => new Response() },
Expand Down