diff --git a/.changeset/ihttpserver-contract-codify.md b/.changeset/ihttpserver-contract-codify.md new file mode 100644 index 0000000000..e93bc83808 --- /dev/null +++ b/.changeset/ihttpserver-contract-codify.md @@ -0,0 +1,17 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): codify the IHttpServer soft extensions and unmatched-request semantics (#3607, ADR-0076 OQ#10 follow-up) + +Three behaviors every adapter already implements — locked until now only by +the `@objectstack/http-conformance` cross-adapter suite — become formal +contract on the interface: `IHttpResponse.write`/`end` (SSE/chunked +streaming: headers flush on first write, no buffering until end, `end` +required wherever `write` exists), `IHttpServer.getPort()` (real bound port +after `listen()`, incl. ephemeral `listen(0)`), and the unmatched-request +semantics (path-miss → 404 with the shared not-found body; method-miss → +405 with an `Allow` header). All members optional with feature-detect +guidance — zero behavior change, both adapters already conform; the +conformance assertions now cite the contract instead of merely observing +parity. diff --git a/packages/qa/http-conformance/src/adapter.test.ts b/packages/qa/http-conformance/src/adapter.test.ts index 19e3ee49f3..79a10bfa7e 100644 --- a/packages/qa/http-conformance/src/adapter.test.ts +++ b/packages/qa/http-conformance/src/adapter.test.ts @@ -112,6 +112,9 @@ describe('NodeHttpServer (live socket)', () => { expect((await res.json()).error).toBe('No response from handler'); }); + // Locks the formal streaming contract on `IHttpResponse.write`/`end` + // (spec/src/contracts/http-server.ts, #3607): headers flush on first + // write, chunks are not buffered until end. it('streams SSE via the res.write/res.end extension', async () => { const res = await fetch(`${base}/sse`); expect(res.status).toBe(200); diff --git a/packages/qa/http-conformance/src/conformance.integration.test.ts b/packages/qa/http-conformance/src/conformance.integration.test.ts index c611d0b365..9ddc4b4d59 100644 --- a/packages/qa/http-conformance/src/conformance.integration.test.ts +++ b/packages/qa/http-conformance/src/conformance.integration.test.ts @@ -150,6 +150,9 @@ describe.each(ADAPTERS)('IHttpServer conformance on $label adapter', ({ makePlug }); // ── Error semantics ─────────────────────────────────────────────────── + // These lock the FORMAL unmatched-request contract on `IHttpServer` + // (spec/src/contracts/http-server.ts, #3607) — no longer just observed + // parity: new adapters must satisfy the interface JSDoc verbatim. it('404s unknown paths with the shared not-found body', async () => { const res = await fetch(`${base}/api/v1/this-route-does-not-exist`); diff --git a/packages/spec/src/contracts/http-server.ts b/packages/spec/src/contracts/http-server.ts index beb59a741d..f2bcea5e40 100644 --- a/packages/spec/src/contracts/http-server.ts +++ b/packages/spec/src/contracts/http-server.ts @@ -66,6 +66,24 @@ export interface IHttpResponse { * @param value - Header value (string or array of strings for multi-value headers) */ header(name: string, value: string | string[]): IHttpResponse; + + /** + * Stream a chunk to the client without ending the response (SSE / chunked + * transfer). CONTRACT (#3607, ADR-0076 OQ#10): consumers that emit + * streaming results (the dispatcher's `type: 'stream'` result for AI + * routes, SSE endpoints) feature-detect this member and fall back to + * buffered `send()` when absent. Implementations that provide `write` + * MUST also provide `end`, MUST flush headers on first write, and MUST + * NOT buffer chunks until `end`. Cross-adapter behavior is locked by + * `@objectstack/http-conformance` (SSE assertions). + */ + write?(chunk: string | Uint8Array): void | Promise; + + /** + * End a streaming response started with {@link write}. Required whenever + * `write` is provided — see the streaming contract on `write`. + */ + end?(): void | Promise; } /** @@ -87,9 +105,21 @@ export type Middleware = ( /** * IHttpServer - HTTP Server capability interface - * + * * Defines the contract for HTTP server implementations. * Concrete implementations (Express, Fastify, Hono) should implement this interface. + * + * ## Unmatched-request semantics (CONTRACT, #3607 / ADR-0076 OQ#10) + * + * Validated identically across adapters by `@objectstack/http-conformance` + * (packages/qa/http-conformance) — new adapters MUST pass that suite: + * + * - A request whose PATH matches no registered route answers **404** with the + * shared not-found error body (the `errors.zod` envelope), never an + * adapter-native error page. + * - A request whose path matches a route but whose METHOD does not answers + * **405** and MUST include an `Allow` header listing the methods registered + * for that path. */ export interface IHttpServer { /** @@ -146,4 +176,14 @@ export interface IHttpServer { * @returns Promise that resolves when server is stopped */ close?(): Promise; + + /** + * The port the server is actually bound to. CONTRACT (#3607, ADR-0076 + * OQ#10): after `listen()` resolves, implementations that provide this + * member MUST return the real bound port — in particular when `listen(0)` + * requested an ephemeral port (test harnesses and the conformance suite + * depend on this to address the server). Before `listen()` the return + * value is unspecified. + */ + getPort?(): number; }