@telorun/http-server 0.3.1 → 0.3.3

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,57 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.3.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [1a3c226]
8
+ - @telorun/http-dispatch@0.2.1
9
+
10
+ ## 0.3.2
11
+
12
+ ### Patch Changes
13
+
14
+ - 1662260: Address four review findings against the new transport-neutral dispatch package:
15
+
16
+ - **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.
17
+ - **`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.
18
+ - **`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.
19
+ - **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.
20
+
21
+ `@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.
22
+
23
+ - 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.
24
+
25
+ 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.
26
+
27
+ `HttpDispatch.Request` is required as a dependency — already in `modules/http-dispatch/telo.yaml`'s exports; the existing `Telo.Import` of `HttpDispatch` at the top of `modules/http-server/telo.yaml` covers it.
28
+
29
+ When http-dispatch evolves the matcher (adds segment annotations, content-encoding hooks, etc.), http-server picks the change up automatically.
30
+
31
+ - 1662260: Extract `returns:` / `catches:` rendering into a transport-neutral package.
32
+
33
+ `@telorun/http-dispatch` — new package, initial publish. Ships:
34
+
35
+ - `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.
36
+ - `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.
37
+ - TypeBox `ReturnEntry` / `CatchEntry` / `ContentEntry` schemas, re-exportable by any transport that consumes the dispatcher.
38
+ - Runtime validators `validateNoContentTypeHeader` and `validateStreamWhenDoesNotReferenceResult` — defense-in-depth checks the dispatcher runs against the outcome lists.
39
+ - `@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.
40
+
41
+ `@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.
42
+
43
+ `@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.
44
+
45
+ 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).
46
+
47
+ - Updated dependencies [1662260]
48
+ - Updated dependencies [07c881a]
49
+ - Updated dependencies [f1c35bc]
50
+ - Updated dependencies [47f7d83]
51
+ - Updated dependencies [1662260]
52
+ - @telorun/http-dispatch@0.2.0
53
+ - @telorun/sdk@0.10.0
54
+
3
55
  ## 0.3.1
4
56
 
5
57
  ### 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,44 +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
- /** Per-MIME content-map entry. Buffer-mode responses use `body` (with optional
5
- * `schema` for AJV validation); stream-mode responses use `encoder` (a ref to
6
- * any `Codec.Encoder` implementation). The two are mutually exclusive per
7
- * value — see dispatch logic. `headers` here merge over the entry-level
8
- * `headers` (per-MIME wins on conflict). `Content-Type` is forbidden in
9
- * headers — the map key IS the canonical Content-Type. */
10
- declare const ContentEntry: import("@sinclair/typebox").TObject<{
11
- body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
12
- schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
13
- encoder: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnsafe<KindRef<Invocable<Record<string, any>, any>>>>;
14
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
15
- }>;
16
- type ContentEntry = Static<typeof ContentEntry>;
17
- declare const ReturnEntry: import("@sinclair/typebox").TObject<{
18
- status: import("@sinclair/typebox").TInteger;
19
- when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
20
- mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"buffer">, import("@sinclair/typebox").TLiteral<"stream">]>>;
21
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
22
- content: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
23
- body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
24
- schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
25
- encoder: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnsafe<KindRef<Invocable<Record<string, any>, any>>>>;
26
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
27
- }>>>;
28
- }>;
29
- type ReturnEntry = Static<typeof ReturnEntry>;
30
- declare const CatchEntry: import("@sinclair/typebox").TObject<{
31
- status: import("@sinclair/typebox").TInteger;
32
- when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
33
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
34
- content: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
35
- body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
36
- schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
37
- encoder: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnsafe<KindRef<Invocable<Record<string, any>, any>>>>;
38
- headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
39
- }>>>;
40
- }>;
41
- type CatchEntry = Static<typeof CatchEntry>;
4
+ import { FastifyInstance } from "fastify";
42
5
  declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
43
6
  routes: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
44
7
  request: import("@sinclair/typebox").TObject<{
@@ -55,7 +18,7 @@ declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
55
18
  inputs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TAny>>;
56
19
  returns: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
57
20
  status: import("@sinclair/typebox").TInteger;
58
- when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
21
+ when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnknown>;
59
22
  mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"buffer">, import("@sinclair/typebox").TLiteral<"stream">]>>;
60
23
  headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
61
24
  content: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
@@ -67,12 +30,11 @@ declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
67
30
  }>>;
68
31
  catches: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
69
32
  status: import("@sinclair/typebox").TInteger;
70
- when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
33
+ when: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnknown>;
71
34
  headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
72
35
  content: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
73
36
  body: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
74
37
  schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TAny>;
75
- encoder: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnsafe<KindRef<Invocable<Record<string, any>, any>>>>;
76
38
  headers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
77
39
  }>>>;
78
40
  }>>>;
@@ -80,37 +42,11 @@ declare const HttpApiManifest: import("@sinclair/typebox").TObject<{
80
42
  }>;
81
43
  type HttpApiManifest = Static<typeof HttpApiManifest>;
82
44
  export declare function register(_ctx: ControllerContext): Promise<void>;
83
- export type { ReturnEntry, CatchEntry, ContentEntry };
84
- type ModuleLikeContext = {
85
- expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown;
86
- };
87
- type ValidateSchema = (value: unknown, schema: unknown) => void;
88
- /** Hook invoked when `pipeline()` rejects after `reply.hijack()` — at that
89
- * point headers are flushed, the response is committed, and `catches:`
90
- * cannot fire. Surfacing the failure here lets operators observe mid-stream
91
- * failures that are otherwise silent. */
92
- type StreamErrorHook = (err: unknown, ctx: {
93
- status: number;
94
- mime: string;
95
- }) => Promise<void> | void;
45
+ export type { CatchEntry, ContentEntry, ReturnEntry };
96
46
  type HandlerRef = {
97
47
  kind: string;
98
48
  name: string;
99
49
  };
100
- export declare function dispatchReturns(returns: ReturnEntry[], result: unknown, requestContext: Record<string, unknown>, acceptHeader: string | undefined, moduleContext: ModuleLikeContext, validateSchema: ValidateSchema, reply: FastifyReply, streamError?: StreamErrorHook): Promise<void>;
101
- /** Render an InvokeError through a `catches:` list. Falls back to a structured
102
- * 500 when no entry matches. Plain (non-InvokeError) throws never reach this
103
- * function — the caller re-throws them to Fastify.
104
- *
105
- * Catches are buffer-mode only by design: by the time a catch fires the
106
- * response is committed pre-stream and there's no upstream iterable to feed
107
- * an encoder. content[mime] entries carry body/schema/headers; encoder/mode
108
- * fields are not part of the catch schema. */
109
- export declare function dispatchCatches(catches: CatchEntry[] | undefined, error: {
110
- code: string;
111
- message: string;
112
- data?: unknown;
113
- }, requestContext: Record<string, unknown>, acceptHeader: string | undefined, moduleContext: ModuleLikeContext, validateSchema: ValidateSchema, reply: FastifyReply): Promise<void>;
114
50
  export declare class HttpServerApi implements ResourceInstance {
115
51
  private readonly ctx;
116
52
  readonly manifest: HttpApiManifest;
@@ -122,51 +58,3 @@ export declare class HttpServerApi implements ResourceInstance {
122
58
  private registerRoute;
123
59
  }
124
60
  export declare function create(resource: any, ctx: ResourceContext): Promise<HttpServerApi>;
125
- /** Rejects `Content-Type` (case-insensitive) anywhere in entry-level or
126
- * per-MIME `headers:` blocks. The matched `content[mime]` map key IS the
127
- * canonical Content-Type — declaring it again in `headers:` would either be
128
- * redundant or contradictory. */
129
- export declare function validateNoContentTypeHeader(resource: {
130
- routes?: Array<{
131
- request?: {
132
- path?: string;
133
- };
134
- returns?: ReturnEntry[];
135
- catches?: CatchEntry[];
136
- }>;
137
- }): void;
138
- /** Enforces per-mode shape rules on every `content[mime]` value:
139
- * - `body` and `encoder` are mutually exclusive. Declaring both is rejected
140
- * because dispatch would silently pick one based on entry `mode:` and the
141
- * other becomes a no-op — a correctness footgun.
142
- * - `mode: stream` requires every content[mime] to declare `encoder` (and
143
- * forbids `body`). Without this check, an entry with one encoder-bearing
144
- * key plus a body-only key would pass at load and only fail at runtime
145
- * when the body-only key won negotiation — leaving a half-broken route.
146
- * - `mode: buffer` (the default) forbids `encoder` (which would never run). */
147
- export declare function validateContentEntryShape(resource: {
148
- routes?: Array<{
149
- request?: {
150
- path?: string;
151
- };
152
- returns?: ReturnEntry[];
153
- }>;
154
- }): void;
155
- /** Rejects `when:` CEL expressions on stream-mode `returns:` entries that
156
- * reference the root `result` identifier. The handler result in stream mode
157
- * is an unconsumed `Stream<...>`; iterating it to evaluate the predicate
158
- * would either fail or consume the stream before bytes flow to the response.
159
- * References to `request.*` are fine — they don't touch the stream.
160
- *
161
- * This is a runtime safety net; the analyzer's static chain validator is
162
- * authoritative. The check here is intentionally token-aware (skips string
163
- * literals and `.result` member access) so it doesn't false-positive on
164
- * benign expressions like `request.headers["x-result"]`. */
165
- export declare function validateStreamWhenDoesNotReferenceResult(resource: {
166
- routes?: Array<{
167
- request?: {
168
- path?: string;
169
- };
170
- returns?: ReturnEntry[];
171
- }>;
172
- }): void;