@telorun/http-server 0.2.4 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,110 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.3.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 1662260: Address four review findings against the new transport-neutral dispatch package:
8
+
9
+ - **Catch schema omits `encoder`.** `CatchEntry.content[mime]` is now typed as `CatchContentEntry` (a `Type.Omit` of `ContentEntry` that drops `encoder`). Catches are buffer-mode only — by the time a catch fires the response is committed pre-stream and there's no upstream iterable to feed an encoder, and `dispatchCatches` never reads it. Previously the TypeBox `CatchEntry` reused `ContentEntry` verbatim, so an `encoder:` on a catch passed validation and was silently ignored. The runtime check now matches the YAML manifest schema in `modules/http-server/telo.yaml` (which already uses `additionalProperties: false` on catch content entries). New `CatchContentEntry` value/type is exported from the package root.
10
+ - **`when:` absence check is `=== undefined`, not truthiness.** `matchEntry` previously treated any falsy `entry.when` as "no predicate," which meant a literal `when: false` was registered as the list's catch-all and could be selected when no other entry matched. The check is now explicit-undefined (matching the precaution already taken in `modules/mcp-server/nodejs/src/outcome.ts`). Generic constraint widened from `when?: string` to `when?: unknown` to reflect the post-CEL value shape.
11
+ - **`when:` schema type is `Unknown`, not `String`.** The TypeBox `ReturnEntry`/`CatchEntry` schemas declared `when` as `Type.Optional(Type.String())`, but the manifest declares `when` as `type: boolean` and the dispatcher receives either a literal boolean (`when: true` / `when: false`) or a CEL `CompiledValue` object (`when: ${{ ... }}`). Both shapes were rejected by the controller's `ctx.validateSchema(resource, HttpApiManifest)` check at load time. Switched to `Type.Optional(Type.Unknown())`; `expandWith` already knows how to evaluate either shape.
12
+ - **Accept negotiation honors media-range specificity.** `matchAcceptForMime` previously took the maximum q-value across all matching ranges, so `Accept: application/json;q=0, */*;q=1` would still serve `application/json` via the wildcard even though the client explicitly excluded it. The negotiator now picks the most specific matching range per RFC 9110 §12.5.1 (exact `type/subtype` > type-wildcard `type/*` > full-wildcard `*/*`), with q=0 on the most specific match correctly excluding the representation. Ties on specificity are broken by highest q.
13
+
14
+ `@telorun/http-server` — adds vitest as a devDependency and a `test` script that runs `fastifyReplySink` through `@telorun/http-dispatch/test-utils`'s shared `runSinkContract` harness against a real listening Fastify server (not `app.inject` — light-my-request rejects on the destroyed-stream path that mid-flight errors take, which would hide whether the partial body actually made it to the wire). Drift between the production transport and the dispatcher's contract now surfaces as a contract-test failure rather than a transport-specific bug discovered downstream. No production-code changes in this package.
15
+
16
+ - 07c881a: Migrate `Api.routes[].request` to anchor at the shared `HttpDispatch.Request/$defs/Matcher` carrier instead of inlining the matcher schema (`method` / `path` / `query` / `body` / `headers`). Field-level annotations (`x-telo-topology-role: matcher`) stay on the consuming side; only the value-shape moves to the carrier.
17
+
18
+ Same pattern as the earlier `Server.notFoundHandler.returns` / `.catches` migration to `HttpDispatch.Outcomes`. Zero behavioural change: the carrier reproduces the inline schema field-for-field, and validation goes through the same AJV path. The win is that `Lambda.HttpApi.routes[].request` (landing next) now shares one structural type-shape with http-server — no duplicated matcher schema across transports.
19
+
20
+ `HttpDispatch.Request` is required as a dependency — already in `packages/http-dispatch/telo.yaml`'s exports; the existing `Telo.Import` of `HttpDispatch` at the top of `modules/http-server/telo.yaml` covers it.
21
+
22
+ When http-dispatch evolves the matcher (adds segment annotations, content-encoding hooks, etc.), http-server picks the change up automatically.
23
+
24
+ - 1662260: Extract `returns:` / `catches:` rendering into a transport-neutral package.
25
+
26
+ `@telorun/http-dispatch` — new package, initial publish. Ships:
27
+
28
+ - `ResponseSink` interface — transport-neutral status / header / send / stream sink that the dispatcher writes through. HTTP-shaped transports (Fastify-backed http-server, AWS Lambda, future fetch-API / native http.Server adapters) implement this interface; the dispatcher does not know which one is underneath.
29
+ - `dispatchReturns` / `dispatchCatches` — the CEL `when:` matching, status branching, schema validation, per-MIME content negotiation, buffer/stream mode, encoder-ref-driven streaming, header merging, and error-path fall-through previously inlined in `http-api-controller.ts`. Encoder ref injection stays inside the dispatcher: when a `mode: stream` entry matches, the dispatcher calls `encoder.invoke({ input })` itself and hands the resulting `AsyncIterable<Uint8Array>` to the sink. The sink never sees the encoder, the `Invocable`, or any kernel/SDK type — it only ever takes bytes.
30
+ - TypeBox `ReturnEntry` / `CatchEntry` / `ContentEntry` schemas, re-exportable by any transport that consumes the dispatcher.
31
+ - Runtime validators `validateNoContentTypeHeader` and `validateStreamWhenDoesNotReferenceResult` — defense-in-depth checks the dispatcher runs against the outcome lists.
32
+ - `@telorun/http-dispatch/test-utils` — a `runSinkContract(name, factory)` vitest harness that exercises every method on the sink interface through a known sequence (status-only / empty body; buffered JSON; last-write-wins headers; streamed bytes byte-exact; mid-stream failure routed through `onError`; double-send + setStatus-after-send rejection). Both http-server's Fastify adapter and future transport adapters (Lambda, gRPC, …) feed their factory through this harness so drift between transports surfaces as a contract-test failure, not a transport-specific bug discovered downstream.
33
+
34
+ `@telorun/http-server` — controller-internal refactor onto the sink via `@telorun/http-dispatch` (added as a new workspace dependency). New `fastifyReplySink` adapter translates `ResponseSink` calls onto `FastifyReply` (`reply.code` / `reply.header` / `reply.send`; `reply.hijack` + `pipeline(Readable.from(...), reply.raw)` for streams). The local `validateContentEntryShape` runtime check is **deleted** — its rule (body/encoder mutual exclusion; stream-mode requires `encoder` everywhere and forbids `body`; stream-mode requires a non-empty `content:`) moves into `modules/http-server/telo.yaml` as a `oneOf`-on-`mode` discriminated union on `Api.routes[].returns[]` and `Server.notFoundHandler.returns[]`. The `mode` field stays optional; the buffer branch matches when `mode` is absent OR `mode: buffer`, so existing manifests without `mode:` continue to validate unchanged (the kernel's shared AJV config at `ctx.validateSchema` does not enable `useDefaults`). `validateNoContentTypeHeader` and `validateStreamWhenDoesNotReferenceResult` move into `@telorun/http-dispatch` as runtime guards; no observable behaviour change for valid manifests.
35
+
36
+ `@telorun/sdk` — no change. The dispatcher is not added to the SDK; the SDK retains its zero-runtime-deps posture. HTTP-shaped dispatch code does not belong in the install tree of every non-HTTP module (sql, ai, assert, console, …), and a future Go or Python SDK should not grow a `dispatch` subpath for symmetry — dispatch is a transport-adapter concern, not a module-author concern.
37
+
38
+ Polyglot contract: the YAML schema (`status` / `when` / `mode` / `headers` / `content[mime].{body,schema,encoder,headers}` plus the `x-telo-outcome-list` / `x-telo-catches-for` annotations) is what travels across languages, not this TS package. A future Go / Python implementation re-implements the dispatcher against the same schema, duplicated verbatim into each consuming module's manifest (`@telorun/lambda` lands next).
39
+
40
+ - Updated dependencies [1662260]
41
+ - Updated dependencies [07c881a]
42
+ - Updated dependencies [f1c35bc]
43
+ - Updated dependencies [47f7d83]
44
+ - Updated dependencies [1662260]
45
+ - @telorun/http-dispatch@0.2.0
46
+ - @telorun/sdk@0.10.0
47
+
48
+ ## 0.3.1
49
+
50
+ ### Patch Changes
51
+
52
+ - d3ed5a5: Tighten `Http.Api.routes[].request.headers` to declare `additionalProperties: { type: "string" }`. Header values are matched as strings against the incoming request, so the schema now reflects what the runtime actually accepts. The telo editor renders this field as a key/value map editor instead of the JSON Schema designer.
53
+
54
+ ## 1.0.0
55
+
56
+ ### Major Changes
57
+
58
+ - b62e535: Streaming-Invocable convention, format-codec packages, and `Http.Api` `content:` map rewrite.
59
+
60
+ **Breaking** (`@telorun/http-server`, `@telorun/ai`):
61
+
62
+ - `Http.Api.routes[].returns[]` and `routes[].catches[]` (and the equivalent `Http.Server.notFoundHandler` lists) drop top-level `body` / `schema` in favour of a per-MIME `content:` map. Buffer-mode entries use `content[<mime>].body` / `content[<mime>].schema`; stream-mode entries use `content[<mime>].encoder` (ref to any `Codec.Encoder`). The map key is the canonical `Content-Type` — declaring `Content-Type` in `headers:` is rejected at load time. Multi-key `content:` maps are negotiated against the request's `Accept` header (RFC 9110 §12.5.1). Mismatch → `406 Not Acceptable`.
63
+ - `mode: stream` is forbidden in `catches:` (catches fire pre-stream; no upstream iterable to feed an encoder).
64
+ - Migration: every existing `returns: [..., body: ..., schema: ..., headers: { Content-Type: ... }]` rewrites mechanically to `returns: [..., content: { <mime>: { body, schema } }]`. In-tree manifests (`apps/registry`, `examples/*`, `tests/*`, `benchmarks/*`) migrated.
65
+ - `Ai.TextStream`: `format` field removed; controller no longer encodes the wire — it returns `{ output: Stream<StreamPart> }`. Pair with a format-codec encoder (`Ndjson.Encoder`, `Sse.Encoder`, `PlainText.Encoder`) for HTTP responses or other byte transports. `text-stream-drain-controller.ts` removed (replaced by inline source → encoder → decoder steps).
66
+ - `StreamPart.error` shape changed from native `Error` to `{ message, code?, data? }` so generic encoders can JSON-serialize error frames without bespoke translation.
67
+
68
+ **New** (`@telorun/codec`, `@telorun/plain-text-codec`, `@telorun/ndjson-codec`, `@telorun/sse-codec`, `@telorun/octet-codec`):
69
+
70
+ - `@telorun/codec` ships the `Encoder` and `Decoder` abstracts (no controllers — pure contracts).
71
+ - Format-codec packages each carry one or both directions: `PlainText.Encoder/.Decoder` (UTF-8 collect + emit), `Ndjson.Encoder` (one JSON record per line), `Sse.Encoder` (Server-Sent Events frames), `Octet.Encoder/.Decoder` (raw bytes pass-through and collect).
72
+ - All encoders implement `invoke({input}): Promise<{output: Stream<Uint8Array>}>` per the streaming-Invocable convention.
73
+
74
+ **New** (`@telorun/sdk`):
75
+
76
+ - `Stream<T>` class wrapping `AsyncIterable<T>`. Producers wrap their iterables in `new Stream(...)` so the value's constructor is recognized by CEL's runtime type-checker (which rejects unrecognized constructors like `AsyncGenerator` and Node `Readable`). The analyzer registers `Stream` as a CEL object type.
77
+
78
+ **Annotation** (`@telorun/kernel`, `@telorun/analyzer`):
79
+
80
+ - `x-telo-stream: true` schema annotation on input/output properties marks them as carrying a `Stream<T>`. CEL passes the value through by reference; analyzer's chain validator rejects `.field` / `[index]` access past a stream-marked property. Convention: streaming Invocables put the stream on `input` (inputs) and `output` (result).
81
+ - `Self.<Abstract>` magic alias auto-registered for every Telo.Library/Application — lets concrete kinds in the same library use `extends: Self.<Abstract>` without a self-import that would loop the loader.
82
+ - Analyzer's `buildReferenceFieldMap`, `resolveFieldValues`, `extractInlinesAtPath`, and `injectAtPath` (Phase 5) now recurse into `additionalProperties` via a `{}` path-segment marker. Required for refs nested inside open-keyed maps like `content[<mime>].encoder`.
83
+ - `isInlineResource` widened: bare-kind refs (`{kind: X}` with no `name` and no extra config) are now treated as inline-singleton definitions and Phase 2 extracts them as fresh stateless resources. Previously `{kind: X}` raised `INVALID_REFERENCE` (treated as a malformed named ref). This matches the runtime-side `resolveChildren` semantics already documented for `Run.Throw`-style stateless inlines, and lets `encoder: {kind: Ndjson.Encoder}` work without boilerplate. Manifests that had `{kind: X}` with the (broken) intent of resolving to an existing named resource will now silently extract a fresh resource — extremely unlikely in practice (those refs were already failing analysis), but worth flagging for downstream consumers.
84
+
85
+ **Behaviour changes worth flagging** (`@telorun/http-server`):
86
+
87
+ - **Single-key `content:` maps now do `Accept` negotiation.** A route declaring only `content: { application/json: ... }` returns `406 Not Acceptable` for `Accept: image/png` — RFC 9110 §15.5.7 compliant. Pre-PR, the legacy top-level `body:` shape ignored `Accept` entirely. To preserve "always send" behaviour, declare `*/*` as an explicit key.
88
+ - **Accept matching ignores media-type parameters** beyond the first `;`. `Accept: text/plain; charset=ascii` matches `content: { 'text/plain; charset=utf-8': ... }`. Q-values are still parsed for ranking; only the matching predicate ignores params. Authors needing parameter-level preference must declare distinct keys per parameter combo.
89
+ - **Load-time validators reject misconfigured `content:` shapes.** `validateContentEntryShape` rejects `body+encoder` together (mutually exclusive), missing `encoder` under `mode: stream`, `body` under `mode: stream`, and `encoder` under `mode: buffer`. Previously some of these slipped through to runtime where they manifested as 500-on-negotiation.
90
+ - **Mid-stream `pipeline()` failures emit `Http.Api.streamFailed` events.** Once `reply.hijack()` runs, mid-stream errors (encoder throws, broken pipe) bypass `catches:` by design (response is committed). They now emit a structured event with `path`, `method`, `status`, `mime`, and the error so operators can observe failures that would otherwise be silent.
91
+
92
+ **Other** (`@telorun/http-client`, `@telorun/javascript`):
93
+
94
+ - `HttpClient.Request` `mode: stream` returns `{ output: Stream<Uint8Array> }` instead of a bare `Readable` — fits the streaming-Invocable convention, pairs with `Octet.Encoder` for HTTP pass-through.
95
+ - `JS.Script` injects `Stream` into every script's scope (via the second function argument, destructured at the top of the wrapper). User code can `new Stream(asyncGen)` directly.
96
+
97
+ **Tests**:
98
+
99
+ - New Layer 1 hermetic streaming-contract test (`modules/ai/tests/text-stream-streaming-contract.yaml`) — three sub-targets, byte-exact NDJSON / SSE / PlainText.
100
+ - New Layer 2 live OpenAI streaming smoke (`modules/ai-openai/tests/openai-live-text-stream.yaml`) — env-gated; exercises `Ai.TextStream → Ndjson.Encoder → PlainText.Decoder` against the real provider.
101
+ - New http-server integration test (`modules/http-server/tests/text-stream-via-http.yaml`) — exercises three single-format routes plus a four-format negotiated route with five Accept variants.
102
+
103
+ ### Patch Changes
104
+
105
+ - Updated dependencies [b62e535]
106
+ - @telorun/sdk@0.7.0
107
+
3
108
  ## 0.2.4
4
109
 
5
110
  ### Patch Changes
package/README.md CHANGED
@@ -1,7 +1,3 @@
1
- ---
2
- description: "v1.0 Http.Server and Http.Api spec: routing, normalized request/response I/O, OpenAPI path syntax, framework-agnostic"
3
- ---
4
-
5
1
  # Telo HTTP Standard Specification (v1.0 Draft)
6
2
 
7
3
  ## Overview
@@ -0,0 +1,12 @@
1
+ import type { ResponseSink } from "@telorun/http-dispatch";
2
+ import type { FastifyReply } from "fastify";
3
+ /** Adapts a Fastify `FastifyReply` to the transport-neutral `ResponseSink`
4
+ * interface from `@telorun/http-dispatch`. Status / header accumulation maps
5
+ * directly onto Fastify's setters; buffered bodies go through
6
+ * `reply.send(body)` (so Fastify's per-status fast-json-stringify dispatch
7
+ * still runs); streamed bodies hijack the reply and pipe through `reply.raw`.
8
+ *
9
+ * The sink owns the rule that `setStatus` / `setHeader` calls after the
10
+ * response is committed must throw — Fastify itself would silently no-op
11
+ * in some cases, so we enforce the contract here. */
12
+ export declare function fastifyReplySink(reply: FastifyReply): ResponseSink;
@@ -0,0 +1,64 @@
1
+ import { Readable } from "stream";
2
+ import { pipeline } from "stream/promises";
3
+ /** Adapts a Fastify `FastifyReply` to the transport-neutral `ResponseSink`
4
+ * interface from `@telorun/http-dispatch`. Status / header accumulation maps
5
+ * directly onto Fastify's setters; buffered bodies go through
6
+ * `reply.send(body)` (so Fastify's per-status fast-json-stringify dispatch
7
+ * still runs); streamed bodies hijack the reply and pipe through `reply.raw`.
8
+ *
9
+ * The sink owns the rule that `setStatus` / `setHeader` calls after the
10
+ * response is committed must throw — Fastify itself would silently no-op
11
+ * in some cases, so we enforce the contract here. */
12
+ export function fastifyReplySink(reply) {
13
+ let status = 200;
14
+ let sent = false;
15
+ function ensureOpen(method) {
16
+ if (sent) {
17
+ throw new Error(`fastifyReplySink: ${method} called after response was sent`);
18
+ }
19
+ }
20
+ return {
21
+ setStatus(code) {
22
+ ensureOpen("setStatus");
23
+ status = code;
24
+ reply.code(code);
25
+ },
26
+ setHeader(name, value) {
27
+ ensureOpen("setHeader");
28
+ // Fastify's reply.header is last-write-wins for the same name.
29
+ reply.header(name, value);
30
+ },
31
+ async send(body) {
32
+ ensureOpen("send");
33
+ sent = true;
34
+ if (body === undefined) {
35
+ reply.send();
36
+ }
37
+ else {
38
+ reply.send(body);
39
+ }
40
+ },
41
+ async stream(iter, onError) {
42
+ ensureOpen("stream");
43
+ sent = true;
44
+ reply.hijack();
45
+ reply.raw.writeHead(status, reply.getHeaders());
46
+ try {
47
+ await pipeline(Readable.from(iter), reply.raw);
48
+ }
49
+ catch (err) {
50
+ if (onError) {
51
+ try {
52
+ await onError(err);
53
+ }
54
+ catch {
55
+ /* operator hook should never fail the response — swallow */
56
+ }
57
+ }
58
+ // Headers are flushed at this point; the response is committed and
59
+ // there's nothing useful to rethrow into Fastify. The socket will
60
+ // close.
61
+ }
62
+ },
63
+ };
64
+ }
@@ -1,30 +1,7 @@
1
1
  import { Static } from "@sinclair/typebox";
2
+ import { CatchEntry, ContentEntry, ReturnEntry } from "@telorun/http-dispatch";
2
3
  import { ControllerContext, Invocable, KindRef, ResourceContext, ResourceInstance } from "@telorun/sdk";
3
- import { FastifyInstance, FastifyReply } from "fastify";
4
- declare const ReturnEntry: import("@sinclair/typebox").TObject<{
5
- status: import("@sinclair/typebox").TInteger;
6
- when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
7
- mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"buffer">, import("@sinclair/typebox").TLiteral<"stream">]>>;
8
- schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
9
- query: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
10
- body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
11
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
12
- }>>;
13
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
14
- body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
15
- }>;
16
- type ReturnEntry = Static<typeof ReturnEntry>;
17
- declare const CatchEntry: import("@sinclair/typebox").TObject<{
18
- status: import("@sinclair/typebox").TInteger;
19
- when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
20
- schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
21
- body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
22
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
23
- }>>;
24
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
25
- body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
26
- }>;
27
- type CatchEntry = Static<typeof CatchEntry>;
4
+ import { FastifyInstance } from "fastify";
28
5
  declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
29
6
  routes: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
30
7
  request: import("@sinclair/typebox").TObject<{
@@ -41,48 +18,35 @@ declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
41
18
  inputs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TAny>>;
42
19
  returns: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
43
20
  status: import("@sinclair/typebox").TInteger;
44
- when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
21
+ when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnknown>;
45
22
  mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"buffer">, import("@sinclair/typebox").TLiteral<"stream">]>>;
46
- schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
47
- query: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
48
- body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
49
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
50
- }>>;
51
23
  headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
52
- body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
24
+ content: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
25
+ body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
26
+ schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
27
+ encoder: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnsafe<KindRef<Invocable<Record<string, any>, any>>>>;
28
+ headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
29
+ }>>>;
53
30
  }>>;
54
31
  catches: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
55
32
  status: import("@sinclair/typebox").TInteger;
56
- when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
57
- schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
58
- body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
59
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
60
- }>>;
33
+ when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnknown>;
61
34
  headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
62
- body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
35
+ content: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
36
+ body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
37
+ schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
38
+ headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
39
+ }>>>;
63
40
  }>>>;
64
41
  }>>;
65
42
  }>;
66
43
  type HttpApiManifest = Static<typeof HttpApiManifest>;
67
44
  export declare function register(_ctx: ControllerContext): Promise<void>;
68
- export type { ReturnEntry, CatchEntry };
69
- type ModuleLikeContext = {
70
- expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown;
71
- };
72
- type ValidateSchema = (value: unknown, schema: unknown) => void;
45
+ export type { CatchEntry, ContentEntry, ReturnEntry };
73
46
  type HandlerRef = {
74
47
  kind: string;
75
48
  name: string;
76
49
  };
77
- export declare function dispatchReturns(returns: ReturnEntry[], result: unknown, requestContext: Record<string, unknown>, moduleContext: ModuleLikeContext, validateSchema: ValidateSchema, reply: FastifyReply): Promise<void>;
78
- /** Render an InvokeError through a `catches:` list. Falls back to a structured
79
- * 500 when no entry matches. Plain (non-InvokeError) throws never reach this
80
- * function — the caller re-throws them to Fastify. */
81
- export declare function dispatchCatches(catches: CatchEntry[] | undefined, error: {
82
- code: string;
83
- message: string;
84
- data?: unknown;
85
- }, requestContext: Record<string, unknown>, moduleContext: ModuleLikeContext, validateSchema: ValidateSchema, reply: FastifyReply): Promise<void>;
86
50
  export declare class HttpServerApi implements ResourceInstance {
87
51
  private readonly ctx;
88
52
  readonly manifest: HttpApiManifest;
@@ -1,28 +1,7 @@
1
1
  import { Type } from "@sinclair/typebox";
2
+ import { CatchEntry, dispatchCatches, dispatchReturns, ReturnEntry, validateNoContentTypeHeader, validateStreamWhenDoesNotReferenceResult, } from "@telorun/http-dispatch";
2
3
  import { isInvokeError, Ref, } from "@telorun/sdk";
3
- import { pipeline } from "stream/promises";
4
- const ReturnEntry = Type.Object({
5
- status: Type.Integer({ minimum: 100, maximum: 599 }),
6
- when: Type.Optional(Type.String()),
7
- mode: Type.Optional(Type.Union([Type.Literal("buffer"), Type.Literal("stream")])),
8
- schema: Type.Optional(Type.Object({
9
- query: Type.Optional(Type.Any()),
10
- body: Type.Optional(Type.Any()),
11
- headers: Type.Optional(Type.Any()),
12
- })),
13
- headers: Type.Optional(Type.Record(Type.String(), Type.String())),
14
- body: Type.Optional(Type.Any()),
15
- });
16
- const CatchEntry = Type.Object({
17
- status: Type.Integer({ minimum: 100, maximum: 599 }),
18
- when: Type.Optional(Type.String()),
19
- schema: Type.Optional(Type.Object({
20
- body: Type.Optional(Type.Any()),
21
- headers: Type.Optional(Type.Any()),
22
- })),
23
- headers: Type.Optional(Type.Record(Type.String(), Type.String())),
24
- body: Type.Optional(Type.Any()),
25
- });
4
+ import { fastifyReplySink } from "./fastify-reply-sink.js";
26
5
  const HttpApiRouteManifest = Type.Object({
27
6
  request: Type.Object({
28
7
  path: Type.String(),
@@ -43,81 +22,6 @@ const HttpApiManifest = Type.Object({
43
22
  routes: Type.Array(HttpApiRouteManifest),
44
23
  });
45
24
  export async function register(_ctx) { }
46
- /** Pick the first entry whose `when:` evaluates truthy, falling back to the
47
- * first entry with no `when:` (the list's catch-all). */
48
- function matchEntry(entries, celCtx, moduleContext) {
49
- let fallback;
50
- for (const entry of entries) {
51
- if (!entry.when) {
52
- fallback ??= entry;
53
- continue;
54
- }
55
- if (moduleContext.expandWith(entry.when, celCtx) === true)
56
- return entry;
57
- }
58
- return fallback;
59
- }
60
- export async function dispatchReturns(returns, result, requestContext, moduleContext, validateSchema, reply) {
61
- const celCtx = { result, ...requestContext };
62
- const entry = matchEntry(returns, celCtx, moduleContext);
63
- if (!entry) {
64
- // Unreachable when the analyzer has run — every route's returns: list must
65
- // cover its handler's return values (explicit when: or catch-all). Hitting
66
- // this at runtime means something bypassed analysis; surface it loudly
67
- // via Fastify's error handler rather than quietly render a 500.
68
- throw new Error("No matching returns entry for handler result — the route's returns: list must cover every return value (add a catch-all entry or widen a when: clause)");
69
- }
70
- reply.code(entry.status);
71
- if (entry.headers) {
72
- const mappedHeaders = moduleContext.expandWith(entry.headers, celCtx);
73
- for (const [key, value] of Object.entries(mappedHeaders)) {
74
- reply.header(key, value);
75
- }
76
- }
77
- if (entry.mode === "stream") {
78
- reply.hijack();
79
- reply.raw.writeHead(entry.status, reply.getHeaders());
80
- await pipeline(result, reply.raw);
81
- return;
82
- }
83
- if (entry.body !== undefined) {
84
- const mappedBody = moduleContext.expandWith(entry.body, celCtx);
85
- if (entry.schema?.body)
86
- validateSchema(mappedBody, entry.schema.body);
87
- reply.send(mappedBody);
88
- return;
89
- }
90
- reply.send(result);
91
- }
92
- /** Render an InvokeError through a `catches:` list. Falls back to a structured
93
- * 500 when no entry matches. Plain (non-InvokeError) throws never reach this
94
- * function — the caller re-throws them to Fastify. */
95
- export async function dispatchCatches(catches, error, requestContext, moduleContext, validateSchema, reply) {
96
- const celCtx = { error, ...requestContext };
97
- const entry = catches ? matchEntry(catches, celCtx, moduleContext) : undefined;
98
- if (!entry) {
99
- reply.code(500);
100
- reply.send({
101
- error: { code: error.code, message: error.message, data: error.data },
102
- });
103
- return;
104
- }
105
- reply.code(entry.status);
106
- if (entry.headers) {
107
- const mappedHeaders = moduleContext.expandWith(entry.headers, celCtx);
108
- for (const [key, value] of Object.entries(mappedHeaders)) {
109
- reply.header(key, value);
110
- }
111
- }
112
- if (entry.body !== undefined) {
113
- const mappedBody = moduleContext.expandWith(entry.body, celCtx);
114
- if (entry.schema?.body)
115
- validateSchema(mappedBody, entry.schema.body);
116
- reply.send(mappedBody);
117
- return;
118
- }
119
- reply.send({ error: { code: error.code, message: error.message, data: error.data } });
120
- }
121
25
  export class HttpServerApi {
122
26
  ctx;
123
27
  manifest;
@@ -160,11 +64,19 @@ export class HttpServerApi {
160
64
  schema.body = route.request.schema.body;
161
65
  if (route.request.schema?.headers)
162
66
  schema.headers = route.request.schema.headers;
67
+ // Response schemas: register the FIRST content[mime].schema we find for
68
+ // each status. Multiple MIMEs per status all get the same response shape
69
+ // (Fastify's response schema is per-status, not per-MIME); the per-MIME
70
+ // schema field is for AJV validation in dispatchReturns, separate from
71
+ // Fastify's per-status response schema registration.
163
72
  for (const entry of route.returns) {
164
- if (entry.schema?.body)
165
- schema.response[entry.status] = entry.schema.body;
166
- else if (entry.schema)
167
- schema.response[entry.status] = {};
73
+ if (!entry.content)
74
+ continue;
75
+ for (const [, c] of Object.entries(entry.content)) {
76
+ if (c.schema && schema.response[entry.status] === undefined) {
77
+ schema.response[entry.status] = c.schema;
78
+ }
79
+ }
168
80
  }
169
81
  app.route({
170
82
  method: route.request.method,
@@ -181,6 +93,7 @@ export class HttpServerApi {
181
93
  body: request.body,
182
94
  },
183
95
  };
96
+ const acceptHeader = request.headers["accept"]?.toString();
184
97
  const resolvedInputs = route.inputs
185
98
  ? (this.ctx.moduleContext.expandWith(route.inputs, requestContext) ?? {})
186
99
  : requestContext;
@@ -188,6 +101,7 @@ export class HttpServerApi {
188
101
  ...resolvedInputs,
189
102
  inputs: resolvedInputs,
190
103
  };
104
+ const sink = fastifyReplySink(reply);
191
105
  let result;
192
106
  try {
193
107
  result = handler
@@ -197,15 +111,25 @@ export class HttpServerApi {
197
111
  catch (err) {
198
112
  if (!isInvokeError(err))
199
113
  throw err;
200
- return dispatchCatches(route.catches, { code: err.code, message: err.message, data: err.data }, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
114
+ return dispatchCatches(route.catches, { code: err.code, message: err.message, data: err.data }, requestContext, acceptHeader, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), sink);
201
115
  }
202
- return dispatchReturns(route.returns, result, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
116
+ return dispatchReturns(route.returns, result, requestContext, acceptHeader, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), sink, (err, errCtx) => this.ctx.emitEvent("Http.Api.streamFailed", {
117
+ path: route.request.path,
118
+ method: route.request.method,
119
+ status: errCtx.status,
120
+ mime: errCtx.mime,
121
+ error: err instanceof Error
122
+ ? { message: err.message, stack: err.stack, code: err.code }
123
+ : { message: String(err) },
124
+ }));
203
125
  },
204
126
  });
205
127
  }
206
128
  }
207
129
  export async function create(resource, ctx) {
208
130
  ctx.validateSchema(resource, HttpApiManifest);
131
+ validateNoContentTypeHeader(resource);
132
+ validateStreamWhenDoesNotReferenceResult(resource);
209
133
  // Capture handler {kind, name} before Phase 5 injection overwrites the ref
210
134
  // with a live Invocable instance. invokeResolved() needs the kind/name to
211
135
  // emit properly-scoped Invoked / InvokeRejected events.
@@ -1,5 +1,5 @@
1
+ import { CatchEntry, ReturnEntry } from "@telorun/http-dispatch";
1
2
  import { type Invocable, type KindRef, type ResourceContext, type ResourceInstance, type RuntimeResource } from "@telorun/sdk";
2
- import { CatchEntry, ReturnEntry } from "./http-api-controller.js";
3
3
  type CorsOptions = {
4
4
  origin?: string | boolean | string[];
5
5
  methods?: string | string[];
@@ -1,10 +1,11 @@
1
1
  import cors from "@fastify/cors";
2
2
  import swagger from "@fastify/swagger";
3
3
  import apiReference from "@scalar/fastify-api-reference";
4
+ import { dispatchCatches, dispatchReturns, } from "@telorun/http-dispatch";
4
5
  import { isInvokeError, } from "@telorun/sdk";
5
6
  import addFormats from "ajv-formats";
6
7
  import Fastify from "fastify";
7
- import { dispatchCatches, dispatchReturns, } from "./http-api-controller.js";
8
+ import { fastifyReplySink } from "./fastify-reply-sink.js";
8
9
  class HttpServer {
9
10
  releaseHold = null;
10
11
  pluginsInitialized = false;
@@ -135,6 +136,8 @@ class HttpServer {
135
136
  body: request.body,
136
137
  },
137
138
  };
139
+ const acceptHeader = request.headers["accept"]?.toString();
140
+ const sink = fastifyReplySink(reply);
138
141
  let result;
139
142
  try {
140
143
  result = await this.ctx.invoke(handler.kind, handler.name, requestContext);
@@ -142,10 +145,10 @@ class HttpServer {
142
145
  catch (err) {
143
146
  if (!isInvokeError(err))
144
147
  throw err;
145
- return dispatchCatches(handler.catches, { code: err.code, message: err.message, data: err.data }, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
148
+ return dispatchCatches(handler.catches, { code: err.code, message: err.message, data: err.data }, requestContext, acceptHeader, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), sink);
146
149
  }
147
150
  if (handler.returns) {
148
- return dispatchReturns(handler.returns, result, requestContext, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply);
151
+ return dispatchReturns(handler.returns, result, requestContext, acceptHeader, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), sink);
149
152
  }
150
153
  const status = result?.status ?? 200;
151
154
  reply.code(status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.2.4",
3
+ "version": "0.3.2",
4
4
  "description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -43,13 +43,17 @@
43
43
  "ajv": "^8.17.1",
44
44
  "ajv-formats": "^3.0.1",
45
45
  "fastify": "^5.7.2",
46
- "@telorun/sdk": "0.6.0"
46
+ "@telorun/http-dispatch": "0.2.0",
47
+ "@telorun/sdk": "0.10.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/node": "^20.0.0",
50
- "typescript": "^5.0.0"
51
+ "typescript": "^5.0.0",
52
+ "vitest": "^2.1.8"
51
53
  },
52
54
  "scripts": {
53
- "build": "tsc -p tsconfig.lib.json"
55
+ "build": "tsc -p tsconfig.lib.json",
56
+ "test": "vitest run",
57
+ "test:watch": "vitest"
54
58
  }
55
59
  }
@@ -0,0 +1,67 @@
1
+ import type { ResponseSink } from "@telorun/http-dispatch";
2
+ import type { FastifyReply } from "fastify";
3
+ import type { OutgoingHttpHeaders } from "http";
4
+ import { Readable } from "stream";
5
+ import { pipeline } from "stream/promises";
6
+
7
+ /** Adapts a Fastify `FastifyReply` to the transport-neutral `ResponseSink`
8
+ * interface from `@telorun/http-dispatch`. Status / header accumulation maps
9
+ * directly onto Fastify's setters; buffered bodies go through
10
+ * `reply.send(body)` (so Fastify's per-status fast-json-stringify dispatch
11
+ * still runs); streamed bodies hijack the reply and pipe through `reply.raw`.
12
+ *
13
+ * The sink owns the rule that `setStatus` / `setHeader` calls after the
14
+ * response is committed must throw — Fastify itself would silently no-op
15
+ * in some cases, so we enforce the contract here. */
16
+ export function fastifyReplySink(reply: FastifyReply): ResponseSink {
17
+ let status = 200;
18
+ let sent = false;
19
+
20
+ function ensureOpen(method: string): void {
21
+ if (sent) {
22
+ throw new Error(`fastifyReplySink: ${method} called after response was sent`);
23
+ }
24
+ }
25
+
26
+ return {
27
+ setStatus(code) {
28
+ ensureOpen("setStatus");
29
+ status = code;
30
+ reply.code(code);
31
+ },
32
+ setHeader(name, value) {
33
+ ensureOpen("setHeader");
34
+ // Fastify's reply.header is last-write-wins for the same name.
35
+ reply.header(name, value);
36
+ },
37
+ async send(body) {
38
+ ensureOpen("send");
39
+ sent = true;
40
+ if (body === undefined) {
41
+ reply.send();
42
+ } else {
43
+ reply.send(body);
44
+ }
45
+ },
46
+ async stream(iter, onError) {
47
+ ensureOpen("stream");
48
+ sent = true;
49
+ reply.hijack();
50
+ reply.raw.writeHead(status, reply.getHeaders() as OutgoingHttpHeaders);
51
+ try {
52
+ await pipeline(Readable.from(iter), reply.raw);
53
+ } catch (err) {
54
+ if (onError) {
55
+ try {
56
+ await onError(err);
57
+ } catch {
58
+ /* operator hook should never fail the response — swallow */
59
+ }
60
+ }
61
+ // Headers are flushed at this point; the response is committed and
62
+ // there's nothing useful to rethrow into Fastify. The socket will
63
+ // close.
64
+ }
65
+ },
66
+ };
67
+ }
@@ -1,4 +1,13 @@
1
1
  import { Static, Type } from "@sinclair/typebox";
2
+ import {
3
+ CatchEntry,
4
+ ContentEntry,
5
+ dispatchCatches,
6
+ dispatchReturns,
7
+ ReturnEntry,
8
+ validateNoContentTypeHeader,
9
+ validateStreamWhenDoesNotReferenceResult,
10
+ } from "@telorun/http-dispatch";
2
11
  import {
3
12
  ControllerContext,
4
13
  Invocable,
@@ -9,38 +18,7 @@ import {
9
18
  ResourceInstance,
10
19
  } from "@telorun/sdk";
11
20
  import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
12
- import { type Readable } from "stream";
13
- import { pipeline } from "stream/promises";
14
-
15
- const ReturnEntry = Type.Object({
16
- status: Type.Integer({ minimum: 100, maximum: 599 }),
17
- when: Type.Optional(Type.String()),
18
- mode: Type.Optional(Type.Union([Type.Literal("buffer"), Type.Literal("stream")])),
19
- schema: Type.Optional(
20
- Type.Object({
21
- query: Type.Optional(Type.Any()),
22
- body: Type.Optional(Type.Any()),
23
- headers: Type.Optional(Type.Any()),
24
- }),
25
- ),
26
- headers: Type.Optional(Type.Record(Type.String(), Type.String())),
27
- body: Type.Optional(Type.Any()),
28
- });
29
- type ReturnEntry = Static<typeof ReturnEntry>;
30
-
31
- const CatchEntry = Type.Object({
32
- status: Type.Integer({ minimum: 100, maximum: 599 }),
33
- when: Type.Optional(Type.String()),
34
- schema: Type.Optional(
35
- Type.Object({
36
- body: Type.Optional(Type.Any()),
37
- headers: Type.Optional(Type.Any()),
38
- }),
39
- ),
40
- headers: Type.Optional(Type.Record(Type.String(), Type.String())),
41
- body: Type.Optional(Type.Any()),
42
- });
43
- type CatchEntry = Static<typeof CatchEntry>;
21
+ import { fastifyReplySink } from "./fastify-reply-sink.js";
44
22
 
45
23
  const HttpApiRouteManifest = Type.Object({
46
24
  request: Type.Object({
@@ -69,128 +47,10 @@ type HttpApiManifest = Static<typeof HttpApiManifest>;
69
47
 
70
48
  export async function register(_ctx: ControllerContext): Promise<void> {}
71
49
 
72
- export type { ReturnEntry, CatchEntry };
73
-
74
- type ModuleLikeContext = {
75
- expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown;
76
- };
77
-
78
- type ValidateSchema = (value: unknown, schema: unknown) => void;
50
+ export type { CatchEntry, ContentEntry, ReturnEntry };
79
51
 
80
52
  type HandlerRef = { kind: string; name: string };
81
53
 
82
- /** Pick the first entry whose `when:` evaluates truthy, falling back to the
83
- * first entry with no `when:` (the list's catch-all). */
84
- function matchEntry<T extends { when?: string }>(
85
- entries: T[],
86
- celCtx: Record<string, unknown>,
87
- moduleContext: ModuleLikeContext,
88
- ): T | undefined {
89
- let fallback: T | undefined;
90
- for (const entry of entries) {
91
- if (!entry.when) {
92
- fallback ??= entry;
93
- continue;
94
- }
95
- if (moduleContext.expandWith(entry.when, celCtx) === true) return entry;
96
- }
97
- return fallback;
98
- }
99
-
100
- export async function dispatchReturns(
101
- returns: ReturnEntry[],
102
- result: unknown,
103
- requestContext: Record<string, unknown>,
104
- moduleContext: ModuleLikeContext,
105
- validateSchema: ValidateSchema,
106
- reply: FastifyReply,
107
- ): Promise<void> {
108
- const celCtx = { result, ...requestContext };
109
- const entry = matchEntry(returns, celCtx, moduleContext);
110
-
111
- if (!entry) {
112
- // Unreachable when the analyzer has run — every route's returns: list must
113
- // cover its handler's return values (explicit when: or catch-all). Hitting
114
- // this at runtime means something bypassed analysis; surface it loudly
115
- // via Fastify's error handler rather than quietly render a 500.
116
- throw new Error(
117
- "No matching returns entry for handler result — the route's returns: list must cover every return value (add a catch-all entry or widen a when: clause)",
118
- );
119
- }
120
-
121
- reply.code(entry.status);
122
-
123
- if (entry.headers) {
124
- const mappedHeaders = moduleContext.expandWith(entry.headers, celCtx) as Record<
125
- string,
126
- unknown
127
- >;
128
- for (const [key, value] of Object.entries(mappedHeaders)) {
129
- reply.header(key, value as string);
130
- }
131
- }
132
-
133
- if (entry.mode === "stream") {
134
- reply.hijack();
135
- reply.raw.writeHead(entry.status, reply.getHeaders() as Record<string, string>);
136
- await pipeline(result as Readable, reply.raw);
137
- return;
138
- }
139
-
140
- if (entry.body !== undefined) {
141
- const mappedBody = moduleContext.expandWith(entry.body, celCtx);
142
- if (entry.schema?.body) validateSchema(mappedBody, entry.schema.body);
143
- reply.send(mappedBody);
144
- return;
145
- }
146
-
147
- reply.send(result);
148
- }
149
-
150
- /** Render an InvokeError through a `catches:` list. Falls back to a structured
151
- * 500 when no entry matches. Plain (non-InvokeError) throws never reach this
152
- * function — the caller re-throws them to Fastify. */
153
- export async function dispatchCatches(
154
- catches: CatchEntry[] | undefined,
155
- error: { code: string; message: string; data?: unknown },
156
- requestContext: Record<string, unknown>,
157
- moduleContext: ModuleLikeContext,
158
- validateSchema: ValidateSchema,
159
- reply: FastifyReply,
160
- ): Promise<void> {
161
- const celCtx = { error, ...requestContext };
162
- const entry = catches ? matchEntry(catches, celCtx, moduleContext) : undefined;
163
-
164
- if (!entry) {
165
- reply.code(500);
166
- reply.send({
167
- error: { code: error.code, message: error.message, data: error.data },
168
- });
169
- return;
170
- }
171
-
172
- reply.code(entry.status);
173
-
174
- if (entry.headers) {
175
- const mappedHeaders = moduleContext.expandWith(entry.headers, celCtx) as Record<
176
- string,
177
- unknown
178
- >;
179
- for (const [key, value] of Object.entries(mappedHeaders)) {
180
- reply.header(key, value as string);
181
- }
182
- }
183
-
184
- if (entry.body !== undefined) {
185
- const mappedBody = moduleContext.expandWith(entry.body, celCtx);
186
- if (entry.schema?.body) validateSchema(mappedBody, entry.schema.body);
187
- reply.send(mappedBody);
188
- return;
189
- }
190
-
191
- reply.send({ error: { code: error.code, message: error.message, data: error.data } });
192
- }
193
-
194
54
  export class HttpServerApi implements ResourceInstance {
195
55
  constructor(
196
56
  private readonly ctx: ResourceContext,
@@ -235,9 +95,18 @@ export class HttpServerApi implements ResourceInstance {
235
95
  if (route.request.schema?.body) schema.body = route.request.schema.body;
236
96
  if (route.request.schema?.headers) schema.headers = route.request.schema.headers;
237
97
 
98
+ // Response schemas: register the FIRST content[mime].schema we find for
99
+ // each status. Multiple MIMEs per status all get the same response shape
100
+ // (Fastify's response schema is per-status, not per-MIME); the per-MIME
101
+ // schema field is for AJV validation in dispatchReturns, separate from
102
+ // Fastify's per-status response schema registration.
238
103
  for (const entry of route.returns) {
239
- if (entry.schema?.body) schema.response[entry.status] = entry.schema.body;
240
- else if (entry.schema) schema.response[entry.status] = {};
104
+ if (!entry.content) continue;
105
+ for (const [, c] of Object.entries(entry.content)) {
106
+ if (c.schema && schema.response[entry.status] === undefined) {
107
+ schema.response[entry.status] = c.schema;
108
+ }
109
+ }
241
110
  }
242
111
 
243
112
  app.route({
@@ -255,6 +124,11 @@ export class HttpServerApi implements ResourceInstance {
255
124
  body: request.body,
256
125
  },
257
126
  };
127
+ const acceptHeader = (
128
+ (request.headers as Record<string, string | string[] | undefined>)["accept"] as
129
+ | string
130
+ | undefined
131
+ )?.toString();
258
132
  const resolvedInputs: Record<string, any> = route.inputs
259
133
  ? ((this.ctx.moduleContext.expandWith(route.inputs, requestContext) as any) ?? {})
260
134
  : requestContext;
@@ -263,6 +137,8 @@ export class HttpServerApi implements ResourceInstance {
263
137
  inputs: resolvedInputs,
264
138
  };
265
139
 
140
+ const sink = fastifyReplySink(reply);
141
+
266
142
  let result: unknown;
267
143
  try {
268
144
  result = handler
@@ -274,9 +150,10 @@ export class HttpServerApi implements ResourceInstance {
274
150
  route.catches,
275
151
  { code: err.code, message: err.message, data: err.data },
276
152
  requestContext,
153
+ acceptHeader,
277
154
  this.ctx.moduleContext,
278
155
  this.ctx.validateSchema.bind(this.ctx),
279
- reply,
156
+ sink,
280
157
  );
281
158
  }
282
159
 
@@ -284,9 +161,21 @@ export class HttpServerApi implements ResourceInstance {
284
161
  route.returns,
285
162
  result,
286
163
  requestContext,
164
+ acceptHeader,
287
165
  this.ctx.moduleContext,
288
166
  this.ctx.validateSchema.bind(this.ctx),
289
- reply,
167
+ sink,
168
+ (err, errCtx) =>
169
+ this.ctx.emitEvent("Http.Api.streamFailed", {
170
+ path: route.request.path,
171
+ method: route.request.method,
172
+ status: errCtx.status,
173
+ mime: errCtx.mime,
174
+ error:
175
+ err instanceof Error
176
+ ? { message: err.message, stack: err.stack, code: (err as { code?: string }).code }
177
+ : { message: String(err) },
178
+ }),
290
179
  );
291
180
  },
292
181
  });
@@ -295,6 +184,8 @@ export class HttpServerApi implements ResourceInstance {
295
184
 
296
185
  export async function create(resource: any, ctx: ResourceContext): Promise<HttpServerApi> {
297
186
  ctx.validateSchema(resource, HttpApiManifest);
187
+ validateNoContentTypeHeader(resource);
188
+ validateStreamWhenDoesNotReferenceResult(resource);
298
189
  // Capture handler {kind, name} before Phase 5 injection overwrites the ref
299
190
  // with a live Invocable instance. invokeResolved() needs the kind/name to
300
191
  // emit properly-scoped Invoked / InvokeRejected events.
@@ -1,6 +1,12 @@
1
1
  import cors from "@fastify/cors";
2
2
  import swagger from "@fastify/swagger";
3
3
  import apiReference from "@scalar/fastify-api-reference";
4
+ import {
5
+ CatchEntry,
6
+ dispatchCatches,
7
+ dispatchReturns,
8
+ ReturnEntry,
9
+ } from "@telorun/http-dispatch";
4
10
  import {
5
11
  isInvokeError,
6
12
  type Invocable,
@@ -11,13 +17,8 @@ import {
11
17
  } from "@telorun/sdk";
12
18
  import addFormats from "ajv-formats";
13
19
  import Fastify, { FastifyInstance } from "fastify";
14
- import {
15
- CatchEntry,
16
- dispatchCatches,
17
- dispatchReturns,
18
- HttpServerApi,
19
- ReturnEntry,
20
- } from "./http-api-controller.js";
20
+ import { fastifyReplySink } from "./fastify-reply-sink.js";
21
+ import { HttpServerApi } from "./http-api-controller.js";
21
22
 
22
23
  type CorsOptions = {
23
24
  origin?: string | boolean | string[];
@@ -212,6 +213,13 @@ class HttpServer implements ResourceInstance {
212
213
  body: request.body,
213
214
  },
214
215
  };
216
+ const acceptHeader = (
217
+ (request.headers as Record<string, string | string[] | undefined>)["accept"] as
218
+ | string
219
+ | undefined
220
+ )?.toString();
221
+
222
+ const sink = fastifyReplySink(reply);
215
223
 
216
224
  let result: any;
217
225
  try {
@@ -222,9 +230,10 @@ class HttpServer implements ResourceInstance {
222
230
  handler.catches,
223
231
  { code: err.code, message: err.message, data: err.data },
224
232
  requestContext,
233
+ acceptHeader,
225
234
  this.ctx.moduleContext,
226
235
  this.ctx.validateSchema.bind(this.ctx),
227
- reply,
236
+ sink,
228
237
  );
229
238
  }
230
239
 
@@ -233,9 +242,10 @@ class HttpServer implements ResourceInstance {
233
242
  handler.returns,
234
243
  result,
235
244
  requestContext,
245
+ acceptHeader,
236
246
  this.ctx.moduleContext,
237
247
  this.ctx.validateSchema.bind(this.ctx),
238
- reply,
248
+ sink,
239
249
  );
240
250
  }
241
251
  const status = result?.status ?? 200;
@@ -0,0 +1,179 @@
1
+ import { runSinkContract, type SinkHandle } from "@telorun/http-dispatch/test-utils";
2
+ import type { CapturedResponse } from "@telorun/http-dispatch/test-utils";
3
+ import type { ResponseSink } from "@telorun/http-dispatch";
4
+ import Fastify from "fastify";
5
+ import http from "node:http";
6
+ import type { AddressInfo } from "node:net";
7
+ import { fastifyReplySink } from "../src/fastify-reply-sink.js";
8
+
9
+ /** Drives `fastifyReplySink` through the shared `runSinkContract` harness so
10
+ * the production Fastify adapter is held to the same status / header / send /
11
+ * stream contract as every other transport adapter.
12
+ *
13
+ * The harness expects `setStatus`/`setHeader` to be callable synchronously the
14
+ * moment `makeSink()` returns, but Fastify only hands a real `FastifyReply`
15
+ * to a handler after a request lands. The wrapper queues sync calls until
16
+ * the route handler runs, then replays them against the real sink — async
17
+ * calls (`send` / `stream`) wire their returned promise to the real
18
+ * operation's outcome so awaiting code sees the correct success/failure.
19
+ *
20
+ * Uses a real listening server (not `app.inject`): light-my-request rejects
21
+ * on the destroyed-stream path that mid-flight errors take, which would
22
+ * hide whether the partial body actually made it to the wire — exactly the
23
+ * thing the contract's stream-failure case asserts. */
24
+ function makeFastifySink(): SinkHandle {
25
+ type Op = (real: ResponseSink) => void | Promise<void>;
26
+ const queue: Op[] = [];
27
+ let real: ResponseSink | undefined;
28
+ let usedStream = false;
29
+
30
+ let resolveResult!: (r: CapturedResponse) => void;
31
+ let rejectResult!: (e: unknown) => void;
32
+ const result = new Promise<CapturedResponse>((res, rej) => {
33
+ resolveResult = res;
34
+ rejectResult = rej;
35
+ });
36
+
37
+ async function flush(target: ResponseSink): Promise<void> {
38
+ while (queue.length > 0) {
39
+ const op = queue.shift()!;
40
+ await op(target);
41
+ }
42
+ }
43
+
44
+ const sink: ResponseSink = {
45
+ setStatus(code) {
46
+ if (real) {
47
+ real.setStatus(code);
48
+ return;
49
+ }
50
+ queue.push((s) => s.setStatus(code));
51
+ },
52
+ setHeader(name, value) {
53
+ if (real) {
54
+ real.setHeader(name, value);
55
+ return;
56
+ }
57
+ queue.push((s) => s.setHeader(name, value));
58
+ },
59
+ async send(body) {
60
+ if (real) {
61
+ await real.send(body);
62
+ return;
63
+ }
64
+ await new Promise<void>((res, rej) => {
65
+ queue.push(async (s) => {
66
+ try {
67
+ await s.send(body);
68
+ res();
69
+ } catch (e) {
70
+ rej(e);
71
+ }
72
+ });
73
+ });
74
+ },
75
+ async stream(iter, onError) {
76
+ usedStream = true;
77
+ if (real) {
78
+ await real.stream(iter, onError);
79
+ return;
80
+ }
81
+ await new Promise<void>((res, rej) => {
82
+ queue.push(async (s) => {
83
+ try {
84
+ await s.stream(iter, onError);
85
+ res();
86
+ } catch (e) {
87
+ rej(e);
88
+ }
89
+ });
90
+ });
91
+ },
92
+ };
93
+
94
+ const app = Fastify();
95
+ app.route({
96
+ method: "POST",
97
+ url: "/",
98
+ handler: async (_req, reply) => {
99
+ real = fastifyReplySink(reply);
100
+ await flush(real);
101
+ },
102
+ });
103
+
104
+ void (async () => {
105
+ try {
106
+ await app.listen({ host: "127.0.0.1", port: 0 });
107
+ const addr = app.server.address() as AddressInfo;
108
+ await new Promise<void>((settle) => {
109
+ let settled = false;
110
+ const finish = (build: () => CapturedResponse) => {
111
+ if (settled) return;
112
+ settled = true;
113
+ try {
114
+ resolveResult(build());
115
+ } catch (e) {
116
+ rejectResult(e);
117
+ }
118
+ settle();
119
+ };
120
+
121
+ const req = http.request({
122
+ host: addr.address,
123
+ port: addr.port,
124
+ path: "/",
125
+ method: "POST",
126
+ });
127
+ req.on("error", (e) => {
128
+ if (settled) return;
129
+ settled = true;
130
+ rejectResult(e);
131
+ settle();
132
+ });
133
+ req.on("response", (res) => {
134
+ const chunks: Buffer[] = [];
135
+ res.on("data", (c) => chunks.push(c as Buffer));
136
+ // Both `end` (clean) and `close`/`aborted` (mid-stream destroy) end
137
+ // the response from the client's perspective. The contract treats
138
+ // both as terminal: whatever bytes arrived are the captured body,
139
+ // and `onError` (if provided) was already invoked on the server.
140
+ const buildCaptured = (): CapturedResponse => {
141
+ let total = 0;
142
+ for (const c of chunks) total += c.byteLength;
143
+ const body = new Uint8Array(total);
144
+ let off = 0;
145
+ for (const c of chunks) {
146
+ body.set(c, off);
147
+ off += c.byteLength;
148
+ }
149
+ const headers: Record<string, string> = {};
150
+ for (const [k, v] of Object.entries(res.headers)) {
151
+ if (v == null) continue;
152
+ headers[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : String(v);
153
+ }
154
+ return {
155
+ status: res.statusCode ?? 0,
156
+ headers,
157
+ body,
158
+ isStream: usedStream,
159
+ };
160
+ };
161
+ res.on("end", () => finish(buildCaptured));
162
+ res.on("close", () => finish(buildCaptured));
163
+ res.on("aborted", () => finish(buildCaptured));
164
+ });
165
+ req.end();
166
+ });
167
+ } catch (e) {
168
+ rejectResult(e);
169
+ } finally {
170
+ await app.close().catch(() => {
171
+ /* server close after partial-response abort can race; harmless */
172
+ });
173
+ }
174
+ })();
175
+
176
+ return { sink, result };
177
+ }
178
+
179
+ runSinkContract("fastifyReplySink", makeFastifySink);
package/tsconfig.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "extends": "../../../tsconfig.base.json",
3
3
  "files": [],
4
- "include": ["src/**/*.ts", "test/**/*.ts"],
4
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
5
5
  "compilerOptions": {
6
6
  "strict": true,
7
7
  "esModuleInterop": true
@@ -1,10 +1,10 @@
1
1
  {
2
- "extends": "../../tsconfig.base.json",
2
+ "extends": "../../../tsconfig.base.json",
3
3
  "compilerOptions": {
4
4
  "strict": true,
5
5
  "esModuleInterop": true,
6
- "types": ["node", "jest"]
6
+ "types": ["node"]
7
7
  },
8
8
  "files": [],
9
- "include": ["test/**/*.ts"]
9
+ "include": ["tests/**/*.ts"]
10
10
  }