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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Fixed

- Enforce a stricter request-size limit on cached body reads. Abort stalled reads
promptly, preserve their cause through model binding, and release reader locks
without waiting for application cancellation hooks.
- Cancel suppressed HEAD bodies and downstream responses discarded by middleware,
including responses that arrive after a middleware failure. Preserve transferred
bodies and delivered clones.
- Retire middleware continuations after completion, including synchronous returns
followed by queued microtasks.
- Reject invalid route, fallback, access-denial, and error-handler response values.
Invalid access-denial values no longer fall through to the protected handler.
Propagate a failing error handler once per request instead of invoking it again
at enclosing middleware boundaries.
- Report invalid health-probe return values as unavailable.
- Make benchmark fixtures dispatch actual registered routes and check their types.

### Added

- Coverage floors for request dispatch/body ownership and the broader unit suite;
normal packed-install runtime and strict declaration checks. See
[0.5 hardening evidence](docs/0.5.0-hardening.md) for the exercised scenarios,
observed costs, and qualification still pending.

## 0.4.1 - 2026-09-30

### Fixed
Expand Down
29 changes: 17 additions & 12 deletions benches/binding.bench.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import assert from "node:assert/strict";
import { createRouter } from "../src/router";
import { bench } from "vitest";
import { createServerApp } from "../src/application";
import { bind } from "../src/binding";

let sink: unknown;
const response = new Response(null, { status: 204 });
const app = createServerApp({
routes: [
{
path: "/items/{id}",
method: "POST",
handler: async (ctx) => {
sink = await ctx.bind();
return response;
},
},
],
});
const app = createServerApp(
createRouter().post("/items/{id}", async (ctx) => {
sink = await ctx.bind();
return response;
}),
);
const proof = await app.fetch(
new Request("http://example.test/items/42?view=full", {
method: "POST",
headers: { "content-type": "application/json" },
body: '{"name":"Ada"}',
}),
);
assert.equal(proof.status, 204);
assert.deepEqual(sink, { name: "Ada", view: "full", id: "42" });

const queryRequest = new Request(
"http://example.test/items/42?tag=one&tag=two&view=full&locale=en",
Expand Down
24 changes: 11 additions & 13 deletions benches/request-dispatch.bench.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
import assert from "node:assert/strict";
import { createRouter } from "../src/router";
import { bench } from "vitest";
import { createServerApp } from "../src/index";

const app = createServerApp({
routes: [
{
path: "/health",
method: "GET",
handler: (ctx) => ctx.ok({ ok: true }),
},
],
});
const app = createServerApp(createRouter().get("/health", (ctx) => ctx.ok({ ok: true })));
const response = new Response(null, { status: 204 });
const prebuiltResponseApp = createServerApp({
routes: [{ path: "/health", method: "GET", handler: () => response }],
});
const prebuiltResponseApp = createServerApp(createRouter().get("/health", () => response));
const middlewareApp = createServerApp({
middleware: [(_context, next) => next(), (_context, next) => next()],
routes: [{ path: "/health", method: "GET", handler: () => response }],
router: createRouter().get("/health", () => response),
});
for (const [target, status] of [
[app, 200],
[prebuiltResponseApp, 204],
[middlewareApp, 204],
] as const)
assert.equal((await target.fetch(new Request("http://example.test/health"))).status, status);
const request = new Request("http://example.test/health");

bench("dispatch a server request", async () => {
Expand Down
2 changes: 1 addition & 1 deletion benches/router-matcher.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const methodMatcher = createMatcher([
{ path: "/items/{*path}", method: "POST", handler },
]);

let sink: MatchResult;
let sink: MatchResult | undefined;

bench("match a static route", () => {
sink = staticMatcher.match("/items/1023", "GET");
Expand Down
40 changes: 24 additions & 16 deletions benches/router-scale.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,51 @@
import { bench } from "vitest";
import assert from "node:assert/strict";
import { createRouter } from "../src/router";
import { bench, beforeAll } from "vitest";
import { createServerApp, type Handler } from "../src/index";

export function defineRouterScaleBench(tier: string, routeCount: number): void {
const handler: Handler = (context) => context.ok({ ok: true });
const routes = Array.from({ length: routeCount }, (_, index) => ({
path: `/items/${index}`,
handler,
}));
const app = createServerApp({ routes });
const router = createRouter();
for (let index = 0; index < routeCount; index++) router.get(`/items/${index}`, handler);
const app = createServerApp(router);
const request = new Request(`http://example.test/items/${routeCount - 1}`);

beforeAll(async () => {
assert.equal((await app.fetch(request)).status, 200);
});

bench(`${tier}: dispatch through ${routeCount} static routes`, async () => {
await app.fetch(request);
});
}

export function defineDynamicRouterScaleBench(tier: string, routeCount: number): void {
const handler: Handler = (context) => context.ok({ ok: true });
const routes = Array.from({ length: routeCount }, (_, index) => ({
path: `/buckets/${index}/{key}`,
handler,
}));
const app = createServerApp({ routes });
const router = createRouter();
for (let index = 0; index < routeCount; index++) router.get(`/buckets/${index}/{key}`, handler);
const app = createServerApp(router);
const request = new Request(`http://example.test/buckets/${routeCount - 1}/object`);

beforeAll(async () => {
assert.equal((await app.fetch(request)).status, 200);
});

bench(`${tier}: dispatch through ${routeCount} dynamic routes`, async () => {
await app.fetch(request);
});
}

export function defineWildcardRouterScaleBench(tier: string, routeCount: number): void {
const handler: Handler = (context) => context.ok({ ok: true });
const routes = Array.from({ length: routeCount }, (_, index) => ({
path: `/objects/${index}/{*key}`,
handler,
}));
const app = createServerApp({ routes });
const router = createRouter();
for (let index = 0; index < routeCount; index++) router.get(`/objects/${index}/{*key}`, handler);
const app = createServerApp(router);
const request = new Request(`http://example.test/objects/${routeCount - 1}/a/b/c.json`);

beforeAll(async () => {
assert.equal((await app.fetch(request)).status, 200);
});

bench(`${tier}: dispatch through ${routeCount} wildcard routes`, async () => {
await app.fetch(request);
});
Expand Down
96 changes: 96 additions & 0 deletions docs/0.5.0-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# 0.5.0 Server hardening evidence

Preparation baseline: `6dc2c4377e0069ca4a9765d9c40259a7a9ed83f3`.
The version remains 0.4.1 during preparation. This is not publication approval.
API contraction, the complete 0.5 package graph, and final documentation remain
separate release gates.

## Confirmed failures and ownership

One request owns its buffered body, each middleware invocation owns its downstream
response until it transfers or discards it, and a failed error handler propagates
its original failure through enclosing boundaries once.

| Boundary | Reproducer and resulting behavior |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Cached body | Buffer four bytes, then request a three-byte maximum. The second read rejects with 413; a later four-byte read still reuses the same bytes. |
| Oversized stream | Five bytes with no Content-Length and a four-byte maximum. Throwing or never-settling cancellation hooks cannot stall the 413 result or retain the reader lock. |
| Request abort | Abort an in-progress, stalled read with throwing or never-settling cancellation. The low-level read rejects with the exact signal reason and releases its lock. A pre-aborted request does not pull its source. |
| Model binding | JSON, URL-encoded and multipart aborts preserve the original cause in BindingError. Public application dispatch maps unreadable bodies to the existing 400 Problem response. |
| HEAD | Suppressed response bodies are canceled once; cancellation failure or a pending cancellation promise cannot replace the HEAD response. Headers survive. |
| Middleware | A thrown failure after next(), or before a late downstream response arrives, disposes the discarded stream. Returning the original response, transferring its body, or returning its clone preserves delivered bytes. A replacement response remains usable. |
| Continuation | Saved next() calls are rejected after completion. Synchronous short circuits retire next() before queued microtasks execute. Existing double-next and ordered composition assertions remain. |
| Authorization | Undefined, null, string and object denial results are rejected before protected handler execution. |
| Response contracts | Invalid route and fallback results reach the owning error boundary with corrective TypeErrors, with and without global middleware. |
| Error handler | Invalid onError results reject with a corrective TypeError. Thrown/rejected callbacks propagate the exact failure with one callback invocation, including overlapping requests. |
| Probes | Undefined and booleans retain their documented meaning. Invalid values report 503 with no-store; an unrelated liveness request remains healthy. |
| Host rejection tracking | Repeated cached failures remain handled while a caller delays awaiting them. This and the synchronous microtask edge were caught during review of the patch before integration. |

Cancellation is best effort: the application does not await an arbitrary
application-owned cancel hook. Middleware that locks a stream itself owns that
reader and must release it. Handler work that never completes cannot be forcibly
terminated by the Web Request/Response core.

## Retained assertions and characterization

The review inspected assertion bodies, rather than relying on test names:

- Routing: literal/parameter/wildcard precedence, equal-priority order, static
descendants, empty named wildcards, method aggregation, explicit HEAD/OPTIONS,
malformed parameter escapes and capture isolation.
- Binding and headers: JSON/form/multipart precedence, repeated values and cookie
order, mixed-case media types, consumed/cached streams, prototype-shaped keys,
native source failures, application and route body limits.
- Policy: one auth resolution, request-local auth identity, denial before route
middleware/upgrade, CSRF form-buffer reuse, encoded redirect allowlists, invalid
redirect cookie suppression, and rate-limit/CORS order.
- Metadata: escaped HTML/JSON-LD and control sanitation; five new characterization
cases retain ASCII and Latin-1 raw headers and round-trip Japanese, emoji and a
40,000-character payload through base64url-v1. The prior Unicode fix remains
a regression target; these cases did not identify a new encoding defect.
- Streams: SSE write quotas and serialization recovery, heartbeat coalescing,
abort cleanup, deferred SSR chunks, streamed style-prefix cancellation.
- Protocols: MCP negotiation, Origin/Host/media checks, malformed initialization,
bounded session capacity/expiry/deletion, tool/schema/auth failures, invalid
cursors and URI escapes; OpenAPI duplicate/invalid metadata and references,
required/malformed input, source-prefixed issues and opt-in response validation.

Coverage remains partial for the broader MCP/resource/prompt and auth-helper
surfaces. Adapter-conformance runner tests exercise faulty exercise functions,
deadlines and cancellation; they do not prove real socket/listener cleanup.
That transport qualification belongs to the Node adapter pass. The generated
website and Vite decoder/real-HTTP header-size parity remain final candidate
checks. In particular, the large metadata unit case makes no claim that an HTTP
server accepts headers of that size.

## Validation and costs

Node 24.21.0: npm ci; npm run fmt -- --check; npm run check.
The gate checks source and benchmark types, unit coverage, strict built
declarations, publint, packed files and an ordinary installed tarball. The latter
imports all nine current entrypoints and executes HEAD/replacement cleanup,
denial/probe guards, error-handler failure and request-abort binding. Unexported
implementation paths remain inaccessible.

Whole-source coverage measured approximately 89% lines, 88% statements, 81%
branches and 87% functions, with explicit regression floors. Affected-file floors
are stronger where established: application 96% lines/branches, body reader 91%
lines and 100% functions, dispatch 94% lines/89% branches, and the private disposer
100% lines/functions. Coverage is not proof of every policy combination.

The prior route-array benchmark fixtures silently dispatched 404s. The corrected
fixtures register a router and assert actual status/bound values before timing;
benchmark sources now participate in typechecking. Baseline timing uses the same
corrected fixtures and dependency/toolchain set as the candidate.

Local diagnostic pairs measured approximately 0.5-0.6 microseconds more for JSON/form
binding and 0.1-0.15 microseconds more for two-middleware dispatch. Body reads now
register/remove cancellation and race stalled work; middleware tracks and disposes
owned downstream responses. These are correctness costs, with no optimization,
throughput or capacity claim. The causal path stays explicit, with no cache or
alternate fast path added. Raw results and exact candidate provenance belong in
the maintainer review packet.

The production dependency lock entries are unchanged. Compatible development
tooling updates remove the reported source-map-js/tinypool advisories; npm audit
reported zero findings after the update.
Loading
Loading