@telorun/http-server 0.3.1 → 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.
@@ -1,35 +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 { Readable } from "stream";
4
- import { pipeline } from "stream/promises";
5
- /** Per-MIME content-map entry. Buffer-mode responses use `body` (with optional
6
- * `schema` for AJV validation); stream-mode responses use `encoder` (a ref to
7
- * any `Codec.Encoder` implementation). The two are mutually exclusive per
8
- * value — see dispatch logic. `headers` here merge over the entry-level
9
- * `headers` (per-MIME wins on conflict). `Content-Type` is forbidden in
10
- * headers — the map key IS the canonical Content-Type. */
11
- const ContentEntry = Type.Object({
12
- body: Type.Optional(Type.Any()),
13
- schema: Type.Optional(Type.Any()),
14
- encoder: Type.Optional(Type.Unsafe(Ref("std/codec#Encoder"))),
15
- headers: Type.Optional(Type.Record(Type.String(), Type.String())),
16
- });
17
- const ReturnEntry = Type.Object({
18
- status: Type.Integer({ minimum: 100, maximum: 599 }),
19
- when: Type.Optional(Type.String()),
20
- mode: Type.Optional(Type.Union([Type.Literal("buffer"), Type.Literal("stream")])),
21
- headers: Type.Optional(Type.Record(Type.String(), Type.String())),
22
- content: Type.Optional(Type.Record(Type.String(), ContentEntry)),
23
- });
24
- const CatchEntry = Type.Object({
25
- status: Type.Integer({ minimum: 100, maximum: 599 }),
26
- when: Type.Optional(Type.String()),
27
- headers: Type.Optional(Type.Record(Type.String(), Type.String())),
28
- // Catches are buffer-mode only — no `mode` or `encoder` fields. By the time
29
- // a catch fires, the response is committed pre-stream and there's no upstream
30
- // iterable to feed an encoder. content[mime] carries body/schema/headers only.
31
- content: Type.Optional(Type.Record(Type.String(), ContentEntry)),
32
- });
4
+ import { fastifyReplySink } from "./fastify-reply-sink.js";
33
5
  const HttpApiRouteManifest = Type.Object({
34
6
  request: Type.Object({
35
7
  path: Type.String(),
@@ -50,249 +22,6 @@ const HttpApiManifest = Type.Object({
50
22
  routes: Type.Array(HttpApiRouteManifest),
51
23
  });
52
24
  export async function register(_ctx) { }
53
- /** Pick the first entry whose `when:` evaluates truthy, falling back to the
54
- * first entry with no `when:` (the list's catch-all). */
55
- function matchEntry(entries, celCtx, moduleContext) {
56
- let fallback;
57
- for (const entry of entries) {
58
- if (!entry.when) {
59
- fallback ??= entry;
60
- continue;
61
- }
62
- if (moduleContext.expandWith(entry.when, celCtx) === true)
63
- return entry;
64
- }
65
- return fallback;
66
- }
67
- /** Parse an `Accept` header into an array of `{type, q}` entries. Missing or
68
- * empty headers default to `*\/*; q=1`. */
69
- function parseAccept(header) {
70
- if (!header || !header.trim())
71
- return [{ type: "*/*", q: 1 }];
72
- return header.split(",").map((part) => {
73
- const [mediaType, ...params] = part.trim().split(";").map((s) => s.trim());
74
- let q = 1;
75
- for (const p of params) {
76
- if (p.toLowerCase().startsWith("q=")) {
77
- const parsed = parseFloat(p.slice(2));
78
- if (!Number.isNaN(parsed))
79
- q = parsed;
80
- }
81
- }
82
- return { type: (mediaType ?? "").toLowerCase(), q };
83
- });
84
- }
85
- /** Returns the highest q-value an `Accept` entry assigns to `mime`, or
86
- * `undefined` if no Accept entry matches (or every match has q=0). Supports
87
- * exact (`type/sub`), type-wildcard (`type/*`) and full-wildcard (`*\/*`). */
88
- function matchAcceptForMime(mime, accepts) {
89
- const lc = mime.toLowerCase();
90
- const top = lc.split(";")[0];
91
- const slash = top.indexOf("/");
92
- const major = slash === -1 ? top : top.slice(0, slash);
93
- let best;
94
- for (const a of accepts) {
95
- if (a.q <= 0)
96
- continue;
97
- if (a.type === top || a.type === `${major}/*` || a.type === "*/*") {
98
- if (best === undefined || a.q > best)
99
- best = a.q;
100
- }
101
- }
102
- return best;
103
- }
104
- /** Pick the best content[mime] key per RFC 9110 §12.5.1 — highest q-value wins,
105
- * declaration order breaks ties, q=0 excludes. Returns `undefined` when no
106
- * available key matches the Accept header at any positive q-value. */
107
- function negotiateContent(contentKeys, acceptHeader) {
108
- if (contentKeys.length === 0)
109
- return undefined;
110
- if (contentKeys.length === 1) {
111
- // Single-key map: skip negotiation entirely. The author has committed to
112
- // one Content-Type; either the client accepts it (any way) or 406 falls
113
- // out below.
114
- const accepts = parseAccept(acceptHeader);
115
- const q = matchAcceptForMime(contentKeys[0], accepts);
116
- return q === undefined ? undefined : contentKeys[0];
117
- }
118
- const accepts = parseAccept(acceptHeader);
119
- let best;
120
- for (let i = 0; i < contentKeys.length; i++) {
121
- const mime = contentKeys[i];
122
- const q = matchAcceptForMime(mime, accepts);
123
- if (q === undefined)
124
- continue;
125
- if (!best || q > best.q || (q === best.q && i < best.index)) {
126
- best = { mime, q, index: i };
127
- }
128
- }
129
- return best?.mime;
130
- }
131
- /** Apply entry-level + per-MIME headers, with per-MIME winning on conflict.
132
- * CEL templates in either map are expanded against `celCtx`. */
133
- function applyHeaders(entryHeaders, contentHeaders, celCtx, moduleContext, reply) {
134
- for (const headers of [entryHeaders, contentHeaders]) {
135
- if (!headers)
136
- continue;
137
- const expanded = moduleContext.expandWith(headers, celCtx);
138
- for (const [key, value] of Object.entries(expanded)) {
139
- reply.header(key, value);
140
- }
141
- }
142
- }
143
- export async function dispatchReturns(returns, result, requestContext, acceptHeader, moduleContext, validateSchema, reply, streamError) {
144
- const celCtx = { result, ...requestContext };
145
- const entry = matchEntry(returns, celCtx, moduleContext);
146
- if (!entry) {
147
- // Unreachable when the analyzer has run — every route's returns: list must
148
- // cover its handler's return values (explicit when: or catch-all). Hitting
149
- // this at runtime means something bypassed analysis; surface it loudly
150
- // via Fastify's error handler rather than quietly render a 500.
151
- 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)");
152
- }
153
- // Status codes with no body (204, 304, etc.) — entry has no `content:` map.
154
- if (!entry.content || Object.keys(entry.content).length === 0) {
155
- reply.code(entry.status);
156
- applyHeaders(entry.headers, undefined, celCtx, moduleContext, reply);
157
- reply.send();
158
- return;
159
- }
160
- const contentKeys = Object.keys(entry.content);
161
- const matchedMime = negotiateContent(contentKeys, acceptHeader);
162
- if (!matchedMime) {
163
- // No Accept-header match at any positive q-value — RFC 9110 §15.5.7.
164
- reply.code(406);
165
- reply.header("Content-Type", "application/json");
166
- reply.send({
167
- error: {
168
- code: "NOT_ACCEPTABLE",
169
- message: "No representation matched the Accept header.",
170
- available: contentKeys,
171
- },
172
- });
173
- return;
174
- }
175
- const contentEntry = entry.content[matchedMime];
176
- reply.code(entry.status);
177
- reply.header("Content-Type", matchedMime);
178
- applyHeaders(entry.headers, contentEntry.headers, celCtx, moduleContext, reply);
179
- if (entry.mode === "stream") {
180
- if (!contentEntry.encoder) {
181
- throw new Error(`Stream-mode return for status ${entry.status} content[${matchedMime}] is missing an encoder.`);
182
- }
183
- const encoderInstance = contentEntry.encoder;
184
- if (typeof encoderInstance.invoke !== "function") {
185
- throw new Error(`Encoder ref for status ${entry.status} content[${matchedMime}] is not a live Invocable — Phase 5 injection may have failed.`);
186
- }
187
- const handlerOutput = result
188
- ?.output;
189
- if (!handlerOutput || typeof handlerOutput[Symbol.asyncIterator] !== "function") {
190
- throw new Error(`Stream-mode handler did not return { output: AsyncIterable<...> } for status ${entry.status} content[${matchedMime}]; got ${typeof handlerOutput}.`);
191
- }
192
- const encoded = (await encoderInstance.invoke({ input: handlerOutput }));
193
- if (!encoded || typeof encoded.output?.[Symbol.asyncIterator] !== "function") {
194
- throw new Error(`Encoder for status ${entry.status} content[${matchedMime}] did not return { output: AsyncIterable<Uint8Array> }.`);
195
- }
196
- reply.hijack();
197
- reply.raw.writeHead(entry.status, reply.getHeaders());
198
- // Readable.from wraps the AsyncIterable; pipeline() handles backpressure
199
- // and propagates client disconnect back through the iterator chain via
200
- // .return(), which unwinds the encoder's `for await` and reaches the
201
- // source's `.return()` (e.g. cancelling a provider's streamText call).
202
- //
203
- // Once headers are flushed, mid-stream failures (encoder throw, broken
204
- // pipe, etc.) cannot trigger `catches:` — the response is committed.
205
- // Surface them via `streamError` so operators can see what's bypassing
206
- // the catch chain by design. The socket will close; we don't rethrow
207
- // because Fastify can't render anything useful past hijack().
208
- try {
209
- await pipeline(Readable.from(encoded.output), reply.raw);
210
- }
211
- catch (err) {
212
- if (streamError) {
213
- try {
214
- await streamError(err, { status: entry.status, mime: matchedMime });
215
- }
216
- catch {
217
- /* operator hook should never fail the response — swallow */
218
- }
219
- }
220
- }
221
- return;
222
- }
223
- // Buffer mode (default).
224
- if (contentEntry.body !== undefined) {
225
- const mappedBody = moduleContext.expandWith(contentEntry.body, celCtx);
226
- if (contentEntry.schema)
227
- validateSchema(mappedBody, contentEntry.schema);
228
- reply.send(mappedBody);
229
- return;
230
- }
231
- // No explicit body — fall through to sending the handler's result as-is.
232
- // Preserves the legacy "reply.send(result)" shorthand for routes whose
233
- // handler already returns the right shape.
234
- if (contentEntry.schema)
235
- validateSchema(result, contentEntry.schema);
236
- reply.send(result);
237
- }
238
- /** Render an InvokeError through a `catches:` list. Falls back to a structured
239
- * 500 when no entry matches. Plain (non-InvokeError) throws never reach this
240
- * function — the caller re-throws them to Fastify.
241
- *
242
- * Catches are buffer-mode only by design: by the time a catch fires the
243
- * response is committed pre-stream and there's no upstream iterable to feed
244
- * an encoder. content[mime] entries carry body/schema/headers; encoder/mode
245
- * fields are not part of the catch schema. */
246
- export async function dispatchCatches(catches, error, requestContext, acceptHeader, moduleContext, validateSchema, reply) {
247
- const celCtx = { error, ...requestContext };
248
- const entry = catches ? matchEntry(catches, celCtx, moduleContext) : undefined;
249
- if (!entry) {
250
- reply.code(500);
251
- reply.header("Content-Type", "application/json");
252
- reply.send({
253
- error: { code: error.code, message: error.message, data: error.data },
254
- });
255
- return;
256
- }
257
- if (!entry.content || Object.keys(entry.content).length === 0) {
258
- reply.code(entry.status);
259
- applyHeaders(entry.headers, undefined, celCtx, moduleContext, reply);
260
- reply.send();
261
- return;
262
- }
263
- const contentKeys = Object.keys(entry.content);
264
- const matchedMime = negotiateContent(contentKeys, acceptHeader);
265
- if (!matchedMime) {
266
- reply.code(406);
267
- reply.header("Content-Type", "application/json");
268
- reply.send({
269
- error: {
270
- code: "NOT_ACCEPTABLE",
271
- message: "No catch representation matched the Accept header.",
272
- available: contentKeys,
273
- },
274
- });
275
- return;
276
- }
277
- const contentEntry = entry.content[matchedMime];
278
- reply.code(entry.status);
279
- reply.header("Content-Type", matchedMime);
280
- applyHeaders(entry.headers, contentEntry.headers, celCtx, moduleContext, reply);
281
- if (contentEntry.body !== undefined) {
282
- const mappedBody = moduleContext.expandWith(contentEntry.body, celCtx);
283
- if (contentEntry.schema)
284
- validateSchema(mappedBody, contentEntry.schema);
285
- reply.send(mappedBody);
286
- return;
287
- }
288
- // Default error envelope when no body is given. The matched MIME may be
289
- // text/plain (or any non-JSON shape) — the negotiated Content-Type would
290
- // lie about a JSON payload. Override to application/json to keep the
291
- // envelope honest. Authors who want the matched MIME on the wire must
292
- // provide an explicit `body:` for that content[mime] entry.
293
- reply.header("Content-Type", "application/json");
294
- reply.send({ error: { code: error.code, message: error.message, data: error.data } });
295
- }
296
25
  export class HttpServerApi {
297
26
  ctx;
298
27
  manifest;
@@ -372,6 +101,7 @@ export class HttpServerApi {
372
101
  ...resolvedInputs,
373
102
  inputs: resolvedInputs,
374
103
  };
104
+ const sink = fastifyReplySink(reply);
375
105
  let result;
376
106
  try {
377
107
  result = handler
@@ -381,9 +111,9 @@ export class HttpServerApi {
381
111
  catch (err) {
382
112
  if (!isInvokeError(err))
383
113
  throw err;
384
- return dispatchCatches(route.catches, { code: err.code, message: err.message, data: err.data }, requestContext, acceptHeader, 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);
385
115
  }
386
- return dispatchReturns(route.returns, result, requestContext, acceptHeader, this.ctx.moduleContext, this.ctx.validateSchema.bind(this.ctx), reply, (err, errCtx) => this.ctx.emitEvent("Http.Api.streamFailed", {
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", {
387
117
  path: route.request.path,
388
118
  method: route.request.method,
389
119
  status: errCtx.status,
@@ -399,7 +129,6 @@ export class HttpServerApi {
399
129
  export async function create(resource, ctx) {
400
130
  ctx.validateSchema(resource, HttpApiManifest);
401
131
  validateNoContentTypeHeader(resource);
402
- validateContentEntryShape(resource);
403
132
  validateStreamWhenDoesNotReferenceResult(resource);
404
133
  // Capture handler {kind, name} before Phase 5 injection overwrites the ref
405
134
  // with a live Invocable instance. invokeResolved() needs the kind/name to
@@ -421,160 +150,6 @@ export async function create(resource, ctx) {
421
150
  }
422
151
  return new HttpServerApi(ctx, resource, handlerRefs);
423
152
  }
424
- /** Rejects `Content-Type` (case-insensitive) anywhere in entry-level or
425
- * per-MIME `headers:` blocks. The matched `content[mime]` map key IS the
426
- * canonical Content-Type — declaring it again in `headers:` would either be
427
- * redundant or contradictory. */
428
- export function validateNoContentTypeHeader(resource) {
429
- for (const route of resource.routes ?? []) {
430
- const path = route.request?.path ?? "<unknown>";
431
- for (const list of [route.returns, route.catches]) {
432
- if (!list)
433
- continue;
434
- for (const entry of list) {
435
- rejectContentTypeIn(entry.headers, `${path} entry-level headers`);
436
- if (!entry.content)
437
- continue;
438
- for (const [mime, c] of Object.entries(entry.content)) {
439
- rejectContentTypeIn(c.headers, `${path} content[${mime}].headers`);
440
- }
441
- }
442
- }
443
- }
444
- }
445
- function rejectContentTypeIn(headers, where) {
446
- if (!headers)
447
- return;
448
- for (const key of Object.keys(headers)) {
449
- if (key.toLowerCase() === "content-type") {
450
- throw new Error(`Http.Api: '${where}' declares 'Content-Type' — forbidden. The matched content[mime] map key is the only Content-Type source.`);
451
- }
452
- }
453
- }
454
- /** Enforces per-mode shape rules on every `content[mime]` value:
455
- * - `body` and `encoder` are mutually exclusive. Declaring both is rejected
456
- * because dispatch would silently pick one based on entry `mode:` and the
457
- * other becomes a no-op — a correctness footgun.
458
- * - `mode: stream` requires every content[mime] to declare `encoder` (and
459
- * forbids `body`). Without this check, an entry with one encoder-bearing
460
- * key plus a body-only key would pass at load and only fail at runtime
461
- * when the body-only key won negotiation — leaving a half-broken route.
462
- * - `mode: buffer` (the default) forbids `encoder` (which would never run). */
463
- export function validateContentEntryShape(resource) {
464
- for (const route of resource.routes ?? []) {
465
- const path = route.request?.path ?? "<unknown>";
466
- for (const entry of route.returns ?? []) {
467
- const isStream = entry.mode === "stream";
468
- // Stream mode requires a non-empty content map — without one the
469
- // dispatcher would silently send an empty 200, masking the misconfig.
470
- // Buffer mode (default) tolerates missing content (e.g. 204/304-style
471
- // empty responses); the dispatcher renders status-only in that case.
472
- if (isStream && (!entry.content || Object.keys(entry.content).length === 0)) {
473
- throw new Error(`Http.Api: '${path}' status ${entry.status} mode: stream is missing 'content:'. ` +
474
- `Stream-mode entries must declare at least one content[mime] with an encoder.`);
475
- }
476
- if (!entry.content)
477
- continue;
478
- for (const [mime, c] of Object.entries(entry.content)) {
479
- const value = c;
480
- const hasBody = value.body !== undefined;
481
- const hasEncoder = value.encoder !== undefined;
482
- if (hasBody && hasEncoder) {
483
- throw new Error(`Http.Api: '${path}' content[${mime}] declares both 'body' and 'encoder' — forbidden. ` +
484
- `Buffer-mode entries use 'body'; stream-mode entries use 'encoder'.`);
485
- }
486
- if (isStream && !hasEncoder) {
487
- throw new Error(`Http.Api: '${path}' status ${entry.status} mode: stream content[${mime}] is missing 'encoder'. ` +
488
- `Every content[mime] under a stream-mode return must declare an encoder.`);
489
- }
490
- if (isStream && hasBody) {
491
- throw new Error(`Http.Api: '${path}' status ${entry.status} mode: stream content[${mime}] declares 'body' — forbidden. ` +
492
- `Stream-mode entries use 'encoder', not 'body'.`);
493
- }
494
- if (!isStream && hasEncoder) {
495
- throw new Error(`Http.Api: '${path}' status ${entry.status} content[${mime}] declares 'encoder' but mode is 'buffer' (default) — ` +
496
- `the encoder would never run. Set mode: stream, or use 'body' for buffer-mode responses.`);
497
- }
498
- }
499
- }
500
- }
501
- }
502
- /** Rejects `when:` CEL expressions on stream-mode `returns:` entries that
503
- * reference the root `result` identifier. The handler result in stream mode
504
- * is an unconsumed `Stream<...>`; iterating it to evaluate the predicate
505
- * would either fail or consume the stream before bytes flow to the response.
506
- * References to `request.*` are fine — they don't touch the stream.
507
- *
508
- * This is a runtime safety net; the analyzer's static chain validator is
509
- * authoritative. The check here is intentionally token-aware (skips string
510
- * literals and `.result` member access) so it doesn't false-positive on
511
- * benign expressions like `request.headers["x-result"]`. */
512
- export function validateStreamWhenDoesNotReferenceResult(resource) {
513
- for (const route of resource.routes ?? []) {
514
- const path = route.request?.path ?? "<unknown>";
515
- for (const entry of route.returns ?? []) {
516
- if (entry.mode !== "stream" || !entry.when)
517
- continue;
518
- // `when:` survived precompilation as a CompiledValue (or a raw string
519
- // when no template was applied). Inspect the source text via a token
520
- // walker — naive `\bresult\b` would false-positive inside string
521
- // literals and on `.result` property paths.
522
- const source = typeof entry.when === "string"
523
- ? entry.when
524
- : entry.when?.source ?? "";
525
- if (referencesRootIdentifier(source, "result")) {
526
- throw new Error(`Http.Api: '${path}' returns entry with mode: stream — 'when:' references the root 'result' identifier. ` +
527
- `The handler result is an unconsumed Stream and cannot be inspected from CEL. ` +
528
- `Reference only request.* in stream-mode 'when:' predicates.`);
529
- }
530
- }
531
- }
532
- }
533
- /** True if `source` contains a free-standing CEL identifier `target` —
534
- * i.e. it appears as a root identifier (not preceded by `.`, not part of
535
- * another word) and not inside a single- or double-quoted string literal.
536
- * Token-aware so members like `request.result_count` and string literals
537
- * like `'my result'` don't trigger a match. */
538
- function referencesRootIdentifier(source, target) {
539
- let i = 0;
540
- let inString = null;
541
- while (i < source.length) {
542
- const ch = source[i];
543
- if (inString) {
544
- if (ch === "\\") {
545
- i += 2;
546
- continue;
547
- }
548
- if (ch === inString)
549
- inString = null;
550
- i++;
551
- continue;
552
- }
553
- if (ch === '"' || ch === "'") {
554
- inString = ch;
555
- i++;
556
- continue;
557
- }
558
- if (/[A-Za-z_]/.test(ch)) {
559
- let j = i;
560
- while (j < source.length && /[A-Za-z0-9_]/.test(source[j]))
561
- j++;
562
- const word = source.slice(i, j);
563
- // Walk back over whitespace; if the previous non-whitespace char is `.`,
564
- // this identifier is a member access, not a root identifier.
565
- let k = i - 1;
566
- while (k >= 0 && /\s/.test(source[k]))
567
- k--;
568
- const isMember = k >= 0 && source[k] === ".";
569
- if (word === target && !isMember)
570
- return true;
571
- i = j;
572
- continue;
573
- }
574
- i++;
575
- }
576
- return false;
577
- }
578
153
  /**
579
154
  * Translates OpenAPI path format {paramName} to Fastify format :paramName
580
155
  * Example: /api/v1/users/{userId} -> /api/v1/users/:userId
@@ -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;
@@ -136,6 +137,7 @@ class HttpServer {
136
137
  },
137
138
  };
138
139
  const acceptHeader = request.headers["accept"]?.toString();
140
+ const sink = fastifyReplySink(reply);
139
141
  let result;
140
142
  try {
141
143
  result = await this.ctx.invoke(handler.kind, handler.name, requestContext);
@@ -143,10 +145,10 @@ class HttpServer {
143
145
  catch (err) {
144
146
  if (!isInvokeError(err))
145
147
  throw err;
146
- return dispatchCatches(handler.catches, { code: err.code, message: err.message, data: err.data }, requestContext, acceptHeader, 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);
147
149
  }
148
150
  if (handler.returns) {
149
- return dispatchReturns(handler.returns, result, requestContext, acceptHeader, 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);
150
152
  }
151
153
  const status = result?.status ?? 200;
152
154
  reply.code(status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.3.1",
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.7.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
+ }