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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@askrjs/node",
"version": "0.0.9",
"version": "0.0.10",
"description": "Node http adapter for @askrjs/server",
"keywords": [
"askr",
Expand Down
28 changes: 28 additions & 0 deletions src/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,71 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { PerMessageDeflateOptions } from "ws";

/** Options controlling how WebSocket upgrades are handled on a Node server. */
export interface NodeWebSocketOptions {
/** Milliseconds to wait for a peer to acknowledge a close handshake before the socket is force-closed. */
readonly closeTimeout?: number;
/** Maximum allowed size, in bytes, of a single WebSocket message. */
readonly maxPayload?: number;
/** Maximum number of body bytes read from a rejected upgrade request before the connection is destroyed. */
readonly maxRejectionBodyBytes?: number;
/** Enables or configures the permessage-deflate WebSocket extension. */
readonly perMessageDeflate?: boolean | PerMessageDeflateOptions;
/** Origins allowed to open a WebSocket connection; when omitted, all origins are allowed. */
readonly allowedOrigins?: readonly string[];
}

/** Options shared by anything that turns Node HTTP requests into `@askrjs/server` fetch calls. */
export interface NodeHandlerOptions {
/** Base URL used to resolve request paths into absolute URLs. */
readonly baseUrl?: string;
/** Hosts allowed in the request's `Host` header; requests for other hosts are rejected. */
readonly allowedHosts?: readonly string[];
}

/** Options for {@link listen}, controlling how the Node HTTP server binds and behaves. */
export interface ListenOptions extends NodeHandlerOptions {
/** Port to listen on; defaults to an ephemeral port when omitted. */
port?: number;
/** Host/address to bind to. */
host?: string;
/** Allows binding to a non-loopback host without the usual safety check. */
allowPublicBind?: boolean;
/** Maximum length of the queue of pending connections. */
backlog?: number;
/** Aborting this signal stops the server. */
signal?: AbortSignal;
/** Node HTTP server `requestTimeout`, in milliseconds. */
requestTimeout?: number;
/** Node HTTP server `headersTimeout`, in milliseconds. */
headersTimeout?: number;
/** Node HTTP server `keepAliveTimeout`, in milliseconds. */
keepAliveTimeout?: number;
/** Enables WebSocket support, optionally with detailed options. */
websocket?: boolean | NodeWebSocketOptions;
}

/** Options for {@link serve}, extending {@link ListenOptions} with static asset serving and shutdown behavior. */
export interface ServeOptions extends ListenOptions {
/** Serves static files from this directory before falling back to the application. */
readonly assets?: { readonly root: string };
/** OS signals that trigger a graceful shutdown; pass `false` to disable automatic shutdown handling. */
readonly signals?: false | readonly NodeJS.Signals[];
}

/** A running application returned by {@link serve}. */
export interface ServedApplication {
/** The underlying Node HTTP server. */
readonly server: import("node:http").Server;
/** The base URL the server is listening on. */
readonly url: string;
/** Gracefully shuts down the server, any WebSocket connections, and the application. */
close(): Promise<void>;
}

/** Connect/Express-style `next` callback used to hand off unhandled requests. */
export type ConnectNext = (error?: unknown) => void;
/** A Node-style request handler compatible with `http.Server` and Connect-style middleware chains. */
export type NodeHandler = (
request: IncomingMessage,
response: ServerResponse,
Expand Down
12 changes: 12 additions & 0 deletions src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ async function handleNodeRequest(
}
}

/**
* Wraps an `@askrjs/server` application as a Node-style request handler.
*
* Converts each incoming `IncomingMessage`/`ServerResponse` pair into a web
* `Request`, dispatches it through `app.fetch`, and writes the resulting web
* `Response` back to Node. Errors are reported to `next` when provided,
* otherwise a minimal 400/500 response is written directly.
*
* @param app - The application to dispatch requests to.
* @param options - Options controlling base URL resolution and host validation.
* @returns A handler usable with `http.createServer` or Connect-style middleware.
*/
export function createNodeHandler(app: ServerApp, options: NodeHandlerOptions): NodeHandler {
const preparedOptions = prepareNodeHandlerOptions(options);
return (request, response, next) => {
Expand Down
14 changes: 14 additions & 0 deletions src/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,24 @@ import { createNodeHandler } from "./handler.js";
import { applyServerTimeouts } from "./server-options.js";
import { installWebSockets } from "./websocket.js";

/** A Node HTTP server that is guaranteed to be listening for connections. */
export type ListeningServer = Server & {
address(): AddressInfo | string | null;
};

/**
* Starts a Node HTTP server for an `@askrjs/server` application and resolves once it is listening.
*
* Optionally installs WebSocket support and wires up graceful shutdown on
* `options.signal`. Unlike {@link serve}, this does not serve static assets
* or install OS signal handlers.
*
* @param app - The application to serve.
* @param options - Listen options such as port, host, timeouts, and WebSocket support.
* @returns A promise resolving to the listening server once it has bound successfully.
* @example
* const server = await listen(app, { port: 3000 });
*/
export function listen(app: ServerApp, options: ListenOptions = {}): Promise<ListeningServer> {
options.signal?.throwIfAborted();
const host = resolveBindHost(options);
Expand Down
26 changes: 26 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,33 @@ import { randomUUID } from "node:crypto";
import { addAbortListener } from "node:events";
import type { Readable, Writable } from "node:stream";

/** Options for {@link connectMcpStdio}. */
export interface McpStdioOptions<Dependencies = undefined> {
/** Application dependencies passed through to each MCP request. */
dependencies: Dependencies;
/** Stream to read newline-delimited JSON-RPC requests from; defaults to `process.stdin`. */
input?: Readable;
/** Stream to write newline-delimited JSON-RPC responses to; defaults to `process.stdout`. */
output?: Writable;
/** Stream that non-protocol errors are reported to; defaults to `process.stderr`. */
diagnostics?: Writable;
/** Aborting this signal closes the connection. */
signal?: AbortSignal;
/** Auth context to use for requests, or a function that derives one from the environment. */
auth?: AuthContext | ((environment: NodeJS.ProcessEnv) => AuthContext | Promise<AuthContext>);
/** Environment passed to the `auth` function; defaults to `process.env`. */
environment?: NodeJS.ProcessEnv;
/** Maximum size, in bytes, of a single input line before it is rejected; defaults to 1 MiB. */
maxLineBytes?: number;
/** Maximum number of requests handled concurrently; defaults to 16. */
maxConcurrency?: number;
}

/** A live MCP stdio connection returned by {@link connectMcpStdio}. */
export interface McpStdioConnection {
/** Resolves once the connection has fully closed. */
readonly closed: Promise<void>;
/** Closes the connection, aborting in-flight requests and terminating the MCP session. */
close(): Promise<void>;
}

Expand All @@ -28,6 +41,19 @@ const anonymous: AuthContext = Object.freeze({
tenant: null,
});

/**
* Connects an MCP server to newline-delimited JSON-RPC over stdio (or any
* pair of readable/writable streams).
*
* Reads one JSON-RPC message per line, dispatches it to `mcp.handle`, and
* writes the response back as a line of JSON. Handles request cancellation
* notifications, enforces `maxConcurrency` and `maxLineBytes`, and cleans up
* the MCP session when the connection closes.
*
* @param mcp - The MCP server to dispatch requests to.
* @param options - Stdio connection options, including dependencies and stream overrides.
* @returns A handle exposing `closed` and `close()` for the connection's lifecycle.
*/
export function connectMcpStdio<Dependencies>(
mcp: McpServer<Dependencies>,
options: McpStdioOptions<Dependencies>,
Expand Down
20 changes: 20 additions & 0 deletions src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ function isWithinRoot(root: string, candidate: string): boolean {
return candidate === root || candidate.startsWith(prefix);
}

/**
* Serves an `@askrjs/server` application over Node HTTP, with optional static
* asset serving, WebSocket support, and graceful shutdown on OS signals or an
* abort signal.
*
* Requests for paths with a file extension are first checked against
* `options.assets.root` (path-traversal safe, following symlinks) and served
* directly with appropriate `content-type`/`cache-control` headers before
* falling back to the application handler. HTML responses from the
* application get a `no-cache` header when they don't already set
* `cache-control`.
*
* @param app - The application to serve; may expose an optional `close()` for cleanup.
* @param options - Serve options such as port, host, static assets, and shutdown signals.
* @returns The served application, including its bound `url` and a `close()` for shutdown.
* @example
* const app = await serve(myApp, { port: 3000, assets: { root: "./public" } });
* // ...
* await app.close();
*/
export async function serve(
app: ServerApp & { close?: () => void | Promise<void> },
options: ServeOptions = {},
Expand Down