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
17 changes: 17 additions & 0 deletions .changeset/ihttpserver-contract-codify.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/qa/http-conformance/src/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
42 changes: 41 additions & 1 deletion packages/spec/src/contracts/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

/**
* End a streaming response started with {@link write}. Required whenever
* `write` is provided — see the streaming contract on `write`.
*/
end?(): void | Promise<void>;
}

/**
Expand All @@ -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 {
/**
Expand Down Expand Up @@ -146,4 +176,14 @@ export interface IHttpServer {
* @returns Promise that resolves when server is stopped
*/
close?(): Promise<void>;

/**
* 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;
}
Loading