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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Changelog

## Unreleased

### Breaking changes

- Name the supported root exports explicitly. Remove `normalizeClientAddress`,
`ConnectNext` and `NodeWebSocketOptions`; use the peer header or derive nested
types from `NodeHandler` and `ListenOptions` as described in
[the complete API inventory](docs/0.5.0-api.md).

### Fixed

- Dispose startup abort hooks and WebSocket ownership when native `listen` throws.
- Release failed response writers and bounded WebSocket rejections without waiting
for application cancellation promises; cancel stalled or late upgrade bodies.
- Cancel late HTTP responses and bodies rejected by Node's header validation;
clear partial application headers before writing the minimal error response.
- Allocate the bounded MCP line buffer before installing abort hooks, so a setup allocation error leaves no hooks behind.
- Prevent MCP dispatch after shutdown or cancellation while authentication waits.
- Contain asynchronous diagnostic and protocol-output stream failures, including writes pending when
the MCP connection closes.
- Correct the documented default WebSocket origin policy and the README router example.

### Validation

- Add real-socket ownership regressions, MCP framing/recovery cases, V8 source
coverage and strict installed-package checks with TypeScript 6 and 7.
- Ship migration and hardening evidence with the package. Align qualification
types to Node 24 and update compatible development tooling advisories.
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ npm install @askrjs/server @askrjs/node

```ts
import { createServer } from "node:http";
import { createServerApp, json } from "@askrjs/server";
import { createServerApp } from "@askrjs/server";
import { createRouter } from "@askrjs/server/router";
import { json } from "@askrjs/server/http";
import { createNodeHandler } from "@askrjs/node";

const app = createServerApp({
routes: [{ path: "/health", handler: () => json({ status: "ok" }) }],
router: createRouter().get("/health", () => json({ status: "ok" })),
});

createServer(createNodeHandler(app, { baseUrl: "http://localhost:3000" })).listen(3000);
Expand Down Expand Up @@ -140,3 +142,8 @@ Protocol messages use stdin/stdout; diagnostics remain isolated on stderr. Authe
provided directly or resolved from the process environment for each message. Closing stdin, calling
`connection.close()`, or aborting its signal detaches the transport, cancels active requests,
terminates the MCP session, and prevents late protocol output.

## 0.5.0 preparation

See [API decisions and migration](docs/0.5.0-api.md) and
[transport hardening evidence](docs/0.5.0-hardening.md).
12 changes: 9 additions & 3 deletions benches/http.bench.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http";
import { bench, describe, type BenchOptions } from "vitest";
import type { ServedApplication } from "../src/contracts.js";
Expand Down Expand Up @@ -145,6 +146,7 @@ describe("Node HTTP server", () => {
"should serve an empty application response over HTTP",
async () => {
const response = await fetch(served.url);
assert.equal(response.status, 204);
await response.arrayBuffer();
},
{
Expand All @@ -165,6 +167,7 @@ describe("Node HTTP server", () => {
"should serve a dynamic route with an asset root over HTTP",
async () => {
const response = await fetch(`${served.url}/route`);
assert.equal(response.status, 204);
await response.arrayBuffer();
},
{
Expand All @@ -184,15 +187,16 @@ describe("Node HTTP server", () => {
bench(
"should serve a small static asset over HTTP",
async () => {
const response = await fetch(`${served.url}/package.json`);
await response.arrayBuffer();
const response = await fetch(`${served.url}/bench-asset.txt`);
assert.equal(response.status, 200);
assert.equal((await response.arrayBuffer()).byteLength, 1024);
},
{
...BENCH_OPTIONS,
setup: async () => {
served = await serve(
{ fetch: async () => new Response(null, { status: 500 }) },
{ assets: { root: "." }, signals: false },
{ assets: { root: "tests/fixtures" }, signals: false },
);
},
teardown: async () => {
Expand All @@ -205,6 +209,7 @@ describe("Node HTTP server", () => {
"should reject a missing static asset over HTTP",
async () => {
const response = await fetch(`${served.url}/missing.js`);
assert.equal(response.status, 404);
await response.arrayBuffer();
},
{
Expand All @@ -228,6 +233,7 @@ describe("Node HTTP server", () => {
body: JSON_BODY,
method: "POST",
});
assert.equal(response.status, 204);
await response.arrayBuffer();
},
{
Expand Down
65 changes: 65 additions & 0 deletions docs/0.5.0-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Node adapter API decisions for 0.5.0

The root now names its supported contracts explicitly: **16 → 13 entrypoint/name
pairs** (13 → 10 at the root, all three MCP contracts retained). No deprecated
aliases remain. Package versions and dependency ranges remain on 0.4.x until the
coordinated candidate is frozen; this document describes the intended 0.5 break.

## Entrypoints

| Export key | Decision | Consumer contract |
| ---------------- | -------- | --------------------------------------------------------------- |
| `.` | KEEP | Node HTTP adapters and their configuration/lifecycle contracts. |
| `./mcp` | KEEP | Optional Node stdio transport, isolated from HTTP imports. |
| `./package.json` | KEEP | Tooling reads package identity and supported runtime/exports. |

## Complete named export inventory

| Entrypoint | Name | Decision | Reason or migration |
| ---------- | ------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| root | `CLIENT_ADDRESS_HEADER` | KEEP | Destroyer reads the adapter-written peer identity; applications need this key to distinguish the TCP peer from untrusted forwarded headers. |
| root | `createNodeHandler` | KEEP | Vite integrates the fetch application with its existing Node/Connect listener. |
| root | `listen` | KEEP | API-only and MCP examples own the HTTP server and its shutdown. |
| root | `serve` | KEEP | CLI full-stack/SSR templates, examples and Destroyer serve assets plus application shutdown. |
| root | `NodeHandler` | KEEP | Consumers type a Node/Connect handler, including its optional error callback. |
| root | `NodeHandlerOptions` | KEEP | Integrators name the trusted URL/host configuration passed to `createNodeHandler`. |
| root | `ListenOptions` | KEEP | Applications share typed binding, timeout, abort and WebSocket configuration. |
| root | `ListeningServer` | KEEP | Applications name the listening handle returned by `listen` and use native HTTP lifecycle operations. |
| root | `ServeOptions` | KEEP | Applications share typed static-asset and process-signal configuration for `serve`. |
| root | `ServedApplication` | KEEP | Applications store the returned URL and idempotent application shutdown handle. |
| root | `normalizeClientAddress` | REMOVE | Internal normalization of socket peers has no external imports. Read `request.headers.get(CLIENT_ADDRESS_HEADER)` for adapter-supplied identity; normalize unrelated addresses in their owning layer. |
| root | `ConnectNext` | REMOVE | Redundant nested handler type with no consumers. Use `NonNullable<Parameters<NodeHandler>[2]>`. |
| root | `NodeWebSocketOptions` | REMOVE | Redundant nested configuration with no consumers. Derive the object member of `ListenOptions["websocket"]` as shown below. |
| mcp | `connectMcpStdio` | KEEP | The MCP example bridges a server to stdin/stdout without an HTTP listener. |
| mcp | `McpStdioOptions` | KEEP | Embedders supply typed dependencies, streams, authentication, cancellation and framing bounds. |
| mcp | `McpStdioConnection` | KEEP | Embedders own explicit `close()` and await `closed` independently of process exit. |

```ts
type WebSocketOptions = Exclude<ListenOptions["websocket"], boolean | undefined>;
```

The nested names remain implementation declarations where needed, but are not
importable package contracts. There is no deep-import replacement.

## Consumer audit and migration

The release's TypeScript 6 compiler inventory and source searches cover sibling
packages, CLI templates, examples, Destroyer, docs and tests. The three removed
names have no external source imports; only Node's internal address tests use the
normalizer directly. The authored website has no import of those names. Its 0.4
API snapshot still lists them and must be regenerated for the final candidate.

Node's own router tests and README now import `createRouter` from
`@askrjs/server/router`; README response helpers come from `@askrjs/server/http`.
The README's obsolete `routes` array has been replaced by an actual router.
`serve` uses its private content-type parser for its HTML cache policy instead of
probing the Server HTTP namespace for an implementation helper. Mixed-case media
types with parameters are exercised by the packed real-HTTP check.

## Package verification

`npm run check` includes a normal install of the actual tarball, exact export-key
and declaration-name inventories, all three removed-import failures, fourteen
private-path failures, strict TypeScript 6.0.2 and 7.0.2 checks with Node 24 types,
and real HTTP/MCP requests. No forced peer installation is used. Both type
replacements above are compiled in that installed consumer.
Loading
Loading