Skip to content

Commit 9e2caf3

Browse files
os-zhuangclaude
andauthored
feat(spec): codify IHttpServer soft extensions + unmatched-request semantics (#3607) (#3628)
ADR-0076 OQ#10's follow-up: res.write/end (SSE streaming contract), getPort() (real bound port incl. listen(0)), and 404/405 semantics (shared not-found body; Allow header on method mismatch) move from "observed cross-adapter parity" to formal interface contract, with the conformance assertions now citing the spec JSDoc. Optional members + feature-detect guidance — zero behavior change; both adapters already conform (full forced build + conformance 41 green; api-surface unchanged). Closes #3607. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9f060e5 commit 9e2caf3

4 files changed

Lines changed: 64 additions & 1 deletion

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): codify the IHttpServer soft extensions and unmatched-request semantics (#3607, ADR-0076 OQ#10 follow-up)
6+
7+
Three behaviors every adapter already implements — locked until now only by
8+
the `@objectstack/http-conformance` cross-adapter suite — become formal
9+
contract on the interface: `IHttpResponse.write`/`end` (SSE/chunked
10+
streaming: headers flush on first write, no buffering until end, `end`
11+
required wherever `write` exists), `IHttpServer.getPort()` (real bound port
12+
after `listen()`, incl. ephemeral `listen(0)`), and the unmatched-request
13+
semantics (path-miss → 404 with the shared not-found body; method-miss →
14+
405 with an `Allow` header). All members optional with feature-detect
15+
guidance — zero behavior change, both adapters already conform; the
16+
conformance assertions now cite the contract instead of merely observing
17+
parity.

packages/qa/http-conformance/src/adapter.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ describe('NodeHttpServer (live socket)', () => {
112112
expect((await res.json()).error).toBe('No response from handler');
113113
});
114114

115+
// Locks the formal streaming contract on `IHttpResponse.write`/`end`
116+
// (spec/src/contracts/http-server.ts, #3607): headers flush on first
117+
// write, chunks are not buffered until end.
115118
it('streams SSE via the res.write/res.end extension', async () => {
116119
const res = await fetch(`${base}/sse`);
117120
expect(res.status).toBe(200);

packages/qa/http-conformance/src/conformance.integration.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ describe.each(ADAPTERS)('IHttpServer conformance on $label adapter', ({ makePlug
150150
});
151151

152152
// ── Error semantics ───────────────────────────────────────────────────
153+
// These lock the FORMAL unmatched-request contract on `IHttpServer`
154+
// (spec/src/contracts/http-server.ts, #3607) — no longer just observed
155+
// parity: new adapters must satisfy the interface JSDoc verbatim.
153156

154157
it('404s unknown paths with the shared not-found body', async () => {
155158
const res = await fetch(`${base}/api/v1/this-route-does-not-exist`);

packages/spec/src/contracts/http-server.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,24 @@ export interface IHttpResponse {
6666
* @param value - Header value (string or array of strings for multi-value headers)
6767
*/
6868
header(name: string, value: string | string[]): IHttpResponse;
69+
70+
/**
71+
* Stream a chunk to the client without ending the response (SSE / chunked
72+
* transfer). CONTRACT (#3607, ADR-0076 OQ#10): consumers that emit
73+
* streaming results (the dispatcher's `type: 'stream'` result for AI
74+
* routes, SSE endpoints) feature-detect this member and fall back to
75+
* buffered `send()` when absent. Implementations that provide `write`
76+
* MUST also provide `end`, MUST flush headers on first write, and MUST
77+
* NOT buffer chunks until `end`. Cross-adapter behavior is locked by
78+
* `@objectstack/http-conformance` (SSE assertions).
79+
*/
80+
write?(chunk: string | Uint8Array): void | Promise<void>;
81+
82+
/**
83+
* End a streaming response started with {@link write}. Required whenever
84+
* `write` is provided — see the streaming contract on `write`.
85+
*/
86+
end?(): void | Promise<void>;
6987
}
7088

7189
/**
@@ -87,9 +105,21 @@ export type Middleware = (
87105

88106
/**
89107
* IHttpServer - HTTP Server capability interface
90-
*
108+
*
91109
* Defines the contract for HTTP server implementations.
92110
* Concrete implementations (Express, Fastify, Hono) should implement this interface.
111+
*
112+
* ## Unmatched-request semantics (CONTRACT, #3607 / ADR-0076 OQ#10)
113+
*
114+
* Validated identically across adapters by `@objectstack/http-conformance`
115+
* (packages/qa/http-conformance) — new adapters MUST pass that suite:
116+
*
117+
* - A request whose PATH matches no registered route answers **404** with the
118+
* shared not-found error body (the `errors.zod` envelope), never an
119+
* adapter-native error page.
120+
* - A request whose path matches a route but whose METHOD does not answers
121+
* **405** and MUST include an `Allow` header listing the methods registered
122+
* for that path.
93123
*/
94124
export interface IHttpServer {
95125
/**
@@ -146,4 +176,14 @@ export interface IHttpServer {
146176
* @returns Promise that resolves when server is stopped
147177
*/
148178
close?(): Promise<void>;
179+
180+
/**
181+
* The port the server is actually bound to. CONTRACT (#3607, ADR-0076
182+
* OQ#10): after `listen()` resolves, implementations that provide this
183+
* member MUST return the real bound port — in particular when `listen(0)`
184+
* requested an ephemeral port (test harnesses and the conformance suite
185+
* depend on this to address the server). Before `listen()` the return
186+
* value is unspecified.
187+
*/
188+
getPort?(): number;
149189
}

0 commit comments

Comments
 (0)