@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 +105 -0
- package/README.md +0 -4
- package/dist/fastify-reply-sink.d.ts +12 -0
- package/dist/fastify-reply-sink.js +64 -0
- package/dist/http-api-controller.d.ts +16 -52
- package/dist/http-api-controller.js +28 -104
- package/dist/http-server-controller.d.ts +1 -1
- package/dist/http-server-controller.js +6 -3
- package/package.json +8 -4
- package/src/fastify-reply-sink.ts +67 -0
- package/src/http-api-controller.ts +46 -155
- package/src/http-server-controller.ts +19 -9
- package/tests/fastify-reply-sink-contract.test.ts +179 -0
- package/tsconfig.json +1 -1
- package/tsconfig.spec.json +3 -3
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
|
@@ -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
|
|
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").
|
|
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
|
-
|
|
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").
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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.
|
|
165
|
-
|
|
166
|
-
|
|
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),
|
|
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),
|
|
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 {
|
|
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),
|
|
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),
|
|
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
|
|
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/
|
|
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 {
|
|
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 {
|
|
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.
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/tsconfig.spec.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"extends": "
|
|
2
|
+
"extends": "../../../tsconfig.base.json",
|
|
3
3
|
"compilerOptions": {
|
|
4
4
|
"strict": true,
|
|
5
5
|
"esModuleInterop": true,
|
|
6
|
-
"types": ["node"
|
|
6
|
+
"types": ["node"]
|
|
7
7
|
},
|
|
8
8
|
"files": [],
|
|
9
|
-
"include": ["
|
|
9
|
+
"include": ["tests/**/*.ts"]
|
|
10
10
|
}
|