diff --git a/package-lock.json b/package-lock.json index 6ab6f5a..16d382c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@askrjs/node", - "version": "0.0.9", + "version": "0.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@askrjs/node", - "version": "0.0.9", + "version": "0.0.10", "license": "Apache-2.0", "dependencies": { "@askrjs/auth": ">=0.0.8 <0.1.0", diff --git a/package.json b/package.json index 711a6a6..a6ab2e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@askrjs/node", - "version": "0.0.9", + "version": "0.0.10", "description": "Node http adapter for @askrjs/server", "keywords": [ "askr", diff --git a/src/contracts.ts b/src/contracts.ts index 91672de..f040088 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -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; } +/** 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, diff --git a/src/handler.ts b/src/handler.ts index eed91ad..c48b83b 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -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) => { diff --git a/src/listen.ts b/src/listen.ts index 141a0ad..ba386c3 100644 --- a/src/listen.ts +++ b/src/listen.ts @@ -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 { options.signal?.throwIfAborted(); const host = resolveBindHost(options); diff --git a/src/mcp.ts b/src/mcp.ts index 3834024..2de1cd9 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -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 { + /** 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); + /** 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; + /** Closes the connection, aborting in-flight requests and terminating the MCP session. */ close(): Promise; } @@ -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( mcp: McpServer, options: McpStdioOptions, diff --git a/src/serve.ts b/src/serve.ts index e9e919a..7df40ef 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -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 }, options: ServeOptions = {},