@telorun/http-server 0.2.4 → 0.3.1

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.
@@ -9,36 +9,41 @@ import {
9
9
  ResourceInstance,
10
10
  } from "@telorun/sdk";
11
11
  import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
12
- import { type Readable } from "stream";
12
+ import type { OutgoingHttpHeaders } from "http";
13
+ import { Readable } from "stream";
13
14
  import { pipeline } from "stream/promises";
14
15
 
16
+ /** Per-MIME content-map entry. Buffer-mode responses use `body` (with optional
17
+ * `schema` for AJV validation); stream-mode responses use `encoder` (a ref to
18
+ * any `Codec.Encoder` implementation). The two are mutually exclusive per
19
+ * value — see dispatch logic. `headers` here merge over the entry-level
20
+ * `headers` (per-MIME wins on conflict). `Content-Type` is forbidden in
21
+ * headers — the map key IS the canonical Content-Type. */
22
+ const ContentEntry = Type.Object({
23
+ body: Type.Optional(Type.Any()),
24
+ schema: Type.Optional(Type.Any()),
25
+ encoder: Type.Optional(Type.Unsafe<KindRef<Invocable>>(Ref("std/codec#Encoder"))),
26
+ headers: Type.Optional(Type.Record(Type.String(), Type.String())),
27
+ });
28
+ type ContentEntry = Static<typeof ContentEntry>;
29
+
15
30
  const ReturnEntry = Type.Object({
16
31
  status: Type.Integer({ minimum: 100, maximum: 599 }),
17
32
  when: Type.Optional(Type.String()),
18
33
  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
34
  headers: Type.Optional(Type.Record(Type.String(), Type.String())),
27
- body: Type.Optional(Type.Any()),
35
+ content: Type.Optional(Type.Record(Type.String(), ContentEntry)),
28
36
  });
29
37
  type ReturnEntry = Static<typeof ReturnEntry>;
30
38
 
31
39
  const CatchEntry = Type.Object({
32
40
  status: Type.Integer({ minimum: 100, maximum: 599 }),
33
41
  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
42
  headers: Type.Optional(Type.Record(Type.String(), Type.String())),
41
- body: Type.Optional(Type.Any()),
43
+ // Catches are buffer-mode only — no `mode` or `encoder` fields. By the time
44
+ // a catch fires, the response is committed pre-stream and there's no upstream
45
+ // iterable to feed an encoder. content[mime] carries body/schema/headers only.
46
+ content: Type.Optional(Type.Record(Type.String(), ContentEntry)),
42
47
  });
43
48
  type CatchEntry = Static<typeof CatchEntry>;
44
49
 
@@ -69,7 +74,7 @@ type HttpApiManifest = Static<typeof HttpApiManifest>;
69
74
 
70
75
  export async function register(_ctx: ControllerContext): Promise<void> {}
71
76
 
72
- export type { ReturnEntry, CatchEntry };
77
+ export type { ReturnEntry, CatchEntry, ContentEntry };
73
78
 
74
79
  type ModuleLikeContext = {
75
80
  expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown;
@@ -77,6 +82,15 @@ type ModuleLikeContext = {
77
82
 
78
83
  type ValidateSchema = (value: unknown, schema: unknown) => void;
79
84
 
85
+ /** Hook invoked when `pipeline()` rejects after `reply.hijack()` — at that
86
+ * point headers are flushed, the response is committed, and `catches:`
87
+ * cannot fire. Surfacing the failure here lets operators observe mid-stream
88
+ * failures that are otherwise silent. */
89
+ type StreamErrorHook = (
90
+ err: unknown,
91
+ ctx: { status: number; mime: string },
92
+ ) => Promise<void> | void;
93
+
80
94
  type HandlerRef = { kind: string; name: string };
81
95
 
82
96
  /** Pick the first entry whose `when:` evaluates truthy, falling back to the
@@ -97,13 +111,100 @@ function matchEntry<T extends { when?: string }>(
97
111
  return fallback;
98
112
  }
99
113
 
114
+ /** Parse an `Accept` header into an array of `{type, q}` entries. Missing or
115
+ * empty headers default to `*\/*; q=1`. */
116
+ function parseAccept(header: string | undefined): Array<{ type: string; q: number }> {
117
+ if (!header || !header.trim()) return [{ type: "*/*", q: 1 }];
118
+ return header.split(",").map((part) => {
119
+ const [mediaType, ...params] = part.trim().split(";").map((s) => s.trim());
120
+ let q = 1;
121
+ for (const p of params) {
122
+ if (p.toLowerCase().startsWith("q=")) {
123
+ const parsed = parseFloat(p.slice(2));
124
+ if (!Number.isNaN(parsed)) q = parsed;
125
+ }
126
+ }
127
+ return { type: (mediaType ?? "").toLowerCase(), q };
128
+ });
129
+ }
130
+
131
+ /** Returns the highest q-value an `Accept` entry assigns to `mime`, or
132
+ * `undefined` if no Accept entry matches (or every match has q=0). Supports
133
+ * exact (`type/sub`), type-wildcard (`type/*`) and full-wildcard (`*\/*`). */
134
+ function matchAcceptForMime(
135
+ mime: string,
136
+ accepts: ReadonlyArray<{ type: string; q: number }>,
137
+ ): number | undefined {
138
+ const lc = mime.toLowerCase();
139
+ const top = lc.split(";")[0]!;
140
+ const slash = top.indexOf("/");
141
+ const major = slash === -1 ? top : top.slice(0, slash);
142
+ let best: number | undefined;
143
+ for (const a of accepts) {
144
+ if (a.q <= 0) continue;
145
+ if (a.type === top || a.type === `${major}/*` || a.type === "*/*") {
146
+ if (best === undefined || a.q > best) best = a.q;
147
+ }
148
+ }
149
+ return best;
150
+ }
151
+
152
+ /** Pick the best content[mime] key per RFC 9110 §12.5.1 — highest q-value wins,
153
+ * declaration order breaks ties, q=0 excludes. Returns `undefined` when no
154
+ * available key matches the Accept header at any positive q-value. */
155
+ function negotiateContent(
156
+ contentKeys: string[],
157
+ acceptHeader: string | undefined,
158
+ ): string | undefined {
159
+ if (contentKeys.length === 0) return undefined;
160
+ if (contentKeys.length === 1) {
161
+ // Single-key map: skip negotiation entirely. The author has committed to
162
+ // one Content-Type; either the client accepts it (any way) or 406 falls
163
+ // out below.
164
+ const accepts = parseAccept(acceptHeader);
165
+ const q = matchAcceptForMime(contentKeys[0]!, accepts);
166
+ return q === undefined ? undefined : contentKeys[0];
167
+ }
168
+ const accepts = parseAccept(acceptHeader);
169
+ let best: { mime: string; q: number; index: number } | undefined;
170
+ for (let i = 0; i < contentKeys.length; i++) {
171
+ const mime = contentKeys[i]!;
172
+ const q = matchAcceptForMime(mime, accepts);
173
+ if (q === undefined) continue;
174
+ if (!best || q > best.q || (q === best.q && i < best.index)) {
175
+ best = { mime, q, index: i };
176
+ }
177
+ }
178
+ return best?.mime;
179
+ }
180
+
181
+ /** Apply entry-level + per-MIME headers, with per-MIME winning on conflict.
182
+ * CEL templates in either map are expanded against `celCtx`. */
183
+ function applyHeaders(
184
+ entryHeaders: Record<string, string> | undefined,
185
+ contentHeaders: Record<string, string> | undefined,
186
+ celCtx: Record<string, unknown>,
187
+ moduleContext: ModuleLikeContext,
188
+ reply: FastifyReply,
189
+ ): void {
190
+ for (const headers of [entryHeaders, contentHeaders]) {
191
+ if (!headers) continue;
192
+ const expanded = moduleContext.expandWith(headers, celCtx) as Record<string, unknown>;
193
+ for (const [key, value] of Object.entries(expanded)) {
194
+ reply.header(key, value as string);
195
+ }
196
+ }
197
+ }
198
+
100
199
  export async function dispatchReturns(
101
200
  returns: ReturnEntry[],
102
201
  result: unknown,
103
202
  requestContext: Record<string, unknown>,
203
+ acceptHeader: string | undefined,
104
204
  moduleContext: ModuleLikeContext,
105
205
  validateSchema: ValidateSchema,
106
206
  reply: FastifyReply,
207
+ streamError?: StreamErrorHook,
107
208
  ): Promise<void> {
108
209
  const celCtx = { result, ...requestContext };
109
210
  const entry = matchEntry(returns, celCtx, moduleContext);
@@ -118,42 +219,118 @@ export async function dispatchReturns(
118
219
  );
119
220
  }
120
221
 
121
- reply.code(entry.status);
222
+ // Status codes with no body (204, 304, etc.) — entry has no `content:` map.
223
+ if (!entry.content || Object.keys(entry.content).length === 0) {
224
+ reply.code(entry.status);
225
+ applyHeaders(entry.headers, undefined, celCtx, moduleContext, reply);
226
+ reply.send();
227
+ return;
228
+ }
122
229
 
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
- }
230
+ const contentKeys = Object.keys(entry.content);
231
+ const matchedMime = negotiateContent(contentKeys, acceptHeader);
232
+
233
+ if (!matchedMime) {
234
+ // No Accept-header match at any positive q-value — RFC 9110 §15.5.7.
235
+ reply.code(406);
236
+ reply.header("Content-Type", "application/json");
237
+ reply.send({
238
+ error: {
239
+ code: "NOT_ACCEPTABLE",
240
+ message: "No representation matched the Accept header.",
241
+ available: contentKeys,
242
+ },
243
+ });
244
+ return;
131
245
  }
132
246
 
247
+ const contentEntry = entry.content[matchedMime]!;
248
+
249
+ reply.code(entry.status);
250
+ reply.header("Content-Type", matchedMime);
251
+ applyHeaders(entry.headers, contentEntry.headers, celCtx, moduleContext, reply);
252
+
133
253
  if (entry.mode === "stream") {
254
+ if (!contentEntry.encoder) {
255
+ throw new Error(
256
+ `Stream-mode return for status ${entry.status} content[${matchedMime}] is missing an encoder.`,
257
+ );
258
+ }
259
+ const encoderInstance = contentEntry.encoder as unknown as ResourceInstance;
260
+ if (typeof (encoderInstance as { invoke?: unknown }).invoke !== "function") {
261
+ throw new Error(
262
+ `Encoder ref for status ${entry.status} content[${matchedMime}] is not a live Invocable — Phase 5 injection may have failed.`,
263
+ );
264
+ }
265
+ const handlerOutput = (result as { output?: AsyncIterable<unknown> } | null | undefined)
266
+ ?.output;
267
+ if (!handlerOutput || typeof (handlerOutput as any)[Symbol.asyncIterator] !== "function") {
268
+ throw new Error(
269
+ `Stream-mode handler did not return { output: AsyncIterable<...> } for status ${entry.status} content[${matchedMime}]; got ${typeof handlerOutput}.`,
270
+ );
271
+ }
272
+ const encoded = (await (encoderInstance as unknown as {
273
+ invoke: (i: { input: AsyncIterable<unknown> }) => Promise<{ output: AsyncIterable<Uint8Array> }>;
274
+ }).invoke({ input: handlerOutput }));
275
+ if (!encoded || typeof (encoded.output as any)?.[Symbol.asyncIterator] !== "function") {
276
+ throw new Error(
277
+ `Encoder for status ${entry.status} content[${matchedMime}] did not return { output: AsyncIterable<Uint8Array> }.`,
278
+ );
279
+ }
134
280
  reply.hijack();
135
- reply.raw.writeHead(entry.status, reply.getHeaders() as Record<string, string>);
136
- await pipeline(result as Readable, reply.raw);
281
+ reply.raw.writeHead(entry.status, reply.getHeaders() as OutgoingHttpHeaders);
282
+ // Readable.from wraps the AsyncIterable; pipeline() handles backpressure
283
+ // and propagates client disconnect back through the iterator chain via
284
+ // .return(), which unwinds the encoder's `for await` and reaches the
285
+ // source's `.return()` (e.g. cancelling a provider's streamText call).
286
+ //
287
+ // Once headers are flushed, mid-stream failures (encoder throw, broken
288
+ // pipe, etc.) cannot trigger `catches:` — the response is committed.
289
+ // Surface them via `streamError` so operators can see what's bypassing
290
+ // the catch chain by design. The socket will close; we don't rethrow
291
+ // because Fastify can't render anything useful past hijack().
292
+ try {
293
+ await pipeline(Readable.from(encoded.output), reply.raw);
294
+ } catch (err) {
295
+ if (streamError) {
296
+ try {
297
+ await streamError(err, { status: entry.status, mime: matchedMime });
298
+ } catch {
299
+ /* operator hook should never fail the response — swallow */
300
+ }
301
+ }
302
+ }
137
303
  return;
138
304
  }
139
305
 
140
- if (entry.body !== undefined) {
141
- const mappedBody = moduleContext.expandWith(entry.body, celCtx);
142
- if (entry.schema?.body) validateSchema(mappedBody, entry.schema.body);
306
+ // Buffer mode (default).
307
+ if (contentEntry.body !== undefined) {
308
+ const mappedBody = moduleContext.expandWith(contentEntry.body, celCtx);
309
+ if (contentEntry.schema) validateSchema(mappedBody, contentEntry.schema);
143
310
  reply.send(mappedBody);
144
311
  return;
145
312
  }
146
313
 
314
+ // No explicit body — fall through to sending the handler's result as-is.
315
+ // Preserves the legacy "reply.send(result)" shorthand for routes whose
316
+ // handler already returns the right shape.
317
+ if (contentEntry.schema) validateSchema(result, contentEntry.schema);
147
318
  reply.send(result);
148
319
  }
149
320
 
150
321
  /** Render an InvokeError through a `catches:` list. Falls back to a structured
151
322
  * 500 when no entry matches. Plain (non-InvokeError) throws never reach this
152
- * function — the caller re-throws them to Fastify. */
323
+ * function — the caller re-throws them to Fastify.
324
+ *
325
+ * Catches are buffer-mode only by design: by the time a catch fires the
326
+ * response is committed pre-stream and there's no upstream iterable to feed
327
+ * an encoder. content[mime] entries carry body/schema/headers; encoder/mode
328
+ * fields are not part of the catch schema. */
153
329
  export async function dispatchCatches(
154
330
  catches: CatchEntry[] | undefined,
155
331
  error: { code: string; message: string; data?: unknown },
156
332
  requestContext: Record<string, unknown>,
333
+ acceptHeader: string | undefined,
157
334
  moduleContext: ModuleLikeContext,
158
335
  validateSchema: ValidateSchema,
159
336
  reply: FastifyReply,
@@ -163,31 +340,54 @@ export async function dispatchCatches(
163
340
 
164
341
  if (!entry) {
165
342
  reply.code(500);
343
+ reply.header("Content-Type", "application/json");
166
344
  reply.send({
167
345
  error: { code: error.code, message: error.message, data: error.data },
168
346
  });
169
347
  return;
170
348
  }
171
349
 
172
- reply.code(entry.status);
350
+ if (!entry.content || Object.keys(entry.content).length === 0) {
351
+ reply.code(entry.status);
352
+ applyHeaders(entry.headers, undefined, celCtx, moduleContext, reply);
353
+ reply.send();
354
+ return;
355
+ }
173
356
 
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
- }
357
+ const contentKeys = Object.keys(entry.content);
358
+ const matchedMime = negotiateContent(contentKeys, acceptHeader);
359
+
360
+ if (!matchedMime) {
361
+ reply.code(406);
362
+ reply.header("Content-Type", "application/json");
363
+ reply.send({
364
+ error: {
365
+ code: "NOT_ACCEPTABLE",
366
+ message: "No catch representation matched the Accept header.",
367
+ available: contentKeys,
368
+ },
369
+ });
370
+ return;
182
371
  }
183
372
 
184
- if (entry.body !== undefined) {
185
- const mappedBody = moduleContext.expandWith(entry.body, celCtx);
186
- if (entry.schema?.body) validateSchema(mappedBody, entry.schema.body);
373
+ const contentEntry = entry.content[matchedMime]!;
374
+ reply.code(entry.status);
375
+ reply.header("Content-Type", matchedMime);
376
+ applyHeaders(entry.headers, contentEntry.headers, celCtx, moduleContext, reply);
377
+
378
+ if (contentEntry.body !== undefined) {
379
+ const mappedBody = moduleContext.expandWith(contentEntry.body, celCtx);
380
+ if (contentEntry.schema) validateSchema(mappedBody, contentEntry.schema);
187
381
  reply.send(mappedBody);
188
382
  return;
189
383
  }
190
384
 
385
+ // Default error envelope when no body is given. The matched MIME may be
386
+ // text/plain (or any non-JSON shape) — the negotiated Content-Type would
387
+ // lie about a JSON payload. Override to application/json to keep the
388
+ // envelope honest. Authors who want the matched MIME on the wire must
389
+ // provide an explicit `body:` for that content[mime] entry.
390
+ reply.header("Content-Type", "application/json");
191
391
  reply.send({ error: { code: error.code, message: error.message, data: error.data } });
192
392
  }
193
393
 
@@ -235,9 +435,18 @@ export class HttpServerApi implements ResourceInstance {
235
435
  if (route.request.schema?.body) schema.body = route.request.schema.body;
236
436
  if (route.request.schema?.headers) schema.headers = route.request.schema.headers;
237
437
 
438
+ // Response schemas: register the FIRST content[mime].schema we find for
439
+ // each status. Multiple MIMEs per status all get the same response shape
440
+ // (Fastify's response schema is per-status, not per-MIME); the per-MIME
441
+ // schema field is for AJV validation in dispatchReturns, separate from
442
+ // Fastify's per-status response schema registration.
238
443
  for (const entry of route.returns) {
239
- if (entry.schema?.body) schema.response[entry.status] = entry.schema.body;
240
- else if (entry.schema) schema.response[entry.status] = {};
444
+ if (!entry.content) continue;
445
+ for (const [, c] of Object.entries(entry.content)) {
446
+ if (c.schema && schema.response[entry.status] === undefined) {
447
+ schema.response[entry.status] = c.schema;
448
+ }
449
+ }
241
450
  }
242
451
 
243
452
  app.route({
@@ -255,6 +464,11 @@ export class HttpServerApi implements ResourceInstance {
255
464
  body: request.body,
256
465
  },
257
466
  };
467
+ const acceptHeader = (
468
+ (request.headers as Record<string, string | string[] | undefined>)["accept"] as
469
+ | string
470
+ | undefined
471
+ )?.toString();
258
472
  const resolvedInputs: Record<string, any> = route.inputs
259
473
  ? ((this.ctx.moduleContext.expandWith(route.inputs, requestContext) as any) ?? {})
260
474
  : requestContext;
@@ -274,6 +488,7 @@ export class HttpServerApi implements ResourceInstance {
274
488
  route.catches,
275
489
  { code: err.code, message: err.message, data: err.data },
276
490
  requestContext,
491
+ acceptHeader,
277
492
  this.ctx.moduleContext,
278
493
  this.ctx.validateSchema.bind(this.ctx),
279
494
  reply,
@@ -284,9 +499,21 @@ export class HttpServerApi implements ResourceInstance {
284
499
  route.returns,
285
500
  result,
286
501
  requestContext,
502
+ acceptHeader,
287
503
  this.ctx.moduleContext,
288
504
  this.ctx.validateSchema.bind(this.ctx),
289
505
  reply,
506
+ (err, errCtx) =>
507
+ this.ctx.emitEvent("Http.Api.streamFailed", {
508
+ path: route.request.path,
509
+ method: route.request.method,
510
+ status: errCtx.status,
511
+ mime: errCtx.mime,
512
+ error:
513
+ err instanceof Error
514
+ ? { message: err.message, stack: err.stack, code: (err as { code?: string }).code }
515
+ : { message: String(err) },
516
+ }),
290
517
  );
291
518
  },
292
519
  });
@@ -295,6 +522,9 @@ export class HttpServerApi implements ResourceInstance {
295
522
 
296
523
  export async function create(resource: any, ctx: ResourceContext): Promise<HttpServerApi> {
297
524
  ctx.validateSchema(resource, HttpApiManifest);
525
+ validateNoContentTypeHeader(resource);
526
+ validateContentEntryShape(resource);
527
+ validateStreamWhenDoesNotReferenceResult(resource);
298
528
  // Capture handler {kind, name} before Phase 5 injection overwrites the ref
299
529
  // with a live Invocable instance. invokeResolved() needs the kind/name to
300
530
  // emit properly-scoped Invoked / InvokeRejected events.
@@ -314,6 +544,192 @@ export async function create(resource: any, ctx: ResourceContext): Promise<HttpS
314
544
  return new HttpServerApi(ctx, resource, handlerRefs);
315
545
  }
316
546
 
547
+ /** Rejects `Content-Type` (case-insensitive) anywhere in entry-level or
548
+ * per-MIME `headers:` blocks. The matched `content[mime]` map key IS the
549
+ * canonical Content-Type — declaring it again in `headers:` would either be
550
+ * redundant or contradictory. */
551
+ export function validateNoContentTypeHeader(resource: {
552
+ routes?: Array<{
553
+ request?: { path?: string };
554
+ returns?: ReturnEntry[];
555
+ catches?: CatchEntry[];
556
+ }>;
557
+ }): void {
558
+ for (const route of resource.routes ?? []) {
559
+ const path = route.request?.path ?? "<unknown>";
560
+ for (const list of [route.returns, route.catches] as Array<
561
+ ReturnEntry[] | CatchEntry[] | undefined
562
+ >) {
563
+ if (!list) continue;
564
+ for (const entry of list) {
565
+ rejectContentTypeIn(entry.headers, `${path} entry-level headers`);
566
+ if (!entry.content) continue;
567
+ for (const [mime, c] of Object.entries(entry.content)) {
568
+ rejectContentTypeIn((c as ContentEntry).headers, `${path} content[${mime}].headers`);
569
+ }
570
+ }
571
+ }
572
+ }
573
+ }
574
+
575
+ function rejectContentTypeIn(
576
+ headers: Record<string, string> | undefined,
577
+ where: string,
578
+ ): void {
579
+ if (!headers) return;
580
+ for (const key of Object.keys(headers)) {
581
+ if (key.toLowerCase() === "content-type") {
582
+ throw new Error(
583
+ `Http.Api: '${where}' declares 'Content-Type' — forbidden. The matched content[mime] map key is the only Content-Type source.`,
584
+ );
585
+ }
586
+ }
587
+ }
588
+
589
+ /** Enforces per-mode shape rules on every `content[mime]` value:
590
+ * - `body` and `encoder` are mutually exclusive. Declaring both is rejected
591
+ * because dispatch would silently pick one based on entry `mode:` and the
592
+ * other becomes a no-op — a correctness footgun.
593
+ * - `mode: stream` requires every content[mime] to declare `encoder` (and
594
+ * forbids `body`). Without this check, an entry with one encoder-bearing
595
+ * key plus a body-only key would pass at load and only fail at runtime
596
+ * when the body-only key won negotiation — leaving a half-broken route.
597
+ * - `mode: buffer` (the default) forbids `encoder` (which would never run). */
598
+ export function validateContentEntryShape(resource: {
599
+ routes?: Array<{
600
+ request?: { path?: string };
601
+ returns?: ReturnEntry[];
602
+ }>;
603
+ }): void {
604
+ for (const route of resource.routes ?? []) {
605
+ const path = route.request?.path ?? "<unknown>";
606
+ for (const entry of route.returns ?? []) {
607
+ const isStream = entry.mode === "stream";
608
+ // Stream mode requires a non-empty content map — without one the
609
+ // dispatcher would silently send an empty 200, masking the misconfig.
610
+ // Buffer mode (default) tolerates missing content (e.g. 204/304-style
611
+ // empty responses); the dispatcher renders status-only in that case.
612
+ if (isStream && (!entry.content || Object.keys(entry.content).length === 0)) {
613
+ throw new Error(
614
+ `Http.Api: '${path}' status ${entry.status} mode: stream is missing 'content:'. ` +
615
+ `Stream-mode entries must declare at least one content[mime] with an encoder.`,
616
+ );
617
+ }
618
+ if (!entry.content) continue;
619
+ for (const [mime, c] of Object.entries(entry.content)) {
620
+ const value = c as ContentEntry;
621
+ const hasBody = value.body !== undefined;
622
+ const hasEncoder = value.encoder !== undefined;
623
+ if (hasBody && hasEncoder) {
624
+ throw new Error(
625
+ `Http.Api: '${path}' content[${mime}] declares both 'body' and 'encoder' — forbidden. ` +
626
+ `Buffer-mode entries use 'body'; stream-mode entries use 'encoder'.`,
627
+ );
628
+ }
629
+ if (isStream && !hasEncoder) {
630
+ throw new Error(
631
+ `Http.Api: '${path}' status ${entry.status} mode: stream content[${mime}] is missing 'encoder'. ` +
632
+ `Every content[mime] under a stream-mode return must declare an encoder.`,
633
+ );
634
+ }
635
+ if (isStream && hasBody) {
636
+ throw new Error(
637
+ `Http.Api: '${path}' status ${entry.status} mode: stream content[${mime}] declares 'body' — forbidden. ` +
638
+ `Stream-mode entries use 'encoder', not 'body'.`,
639
+ );
640
+ }
641
+ if (!isStream && hasEncoder) {
642
+ throw new Error(
643
+ `Http.Api: '${path}' status ${entry.status} content[${mime}] declares 'encoder' but mode is 'buffer' (default) — ` +
644
+ `the encoder would never run. Set mode: stream, or use 'body' for buffer-mode responses.`,
645
+ );
646
+ }
647
+ }
648
+ }
649
+ }
650
+ }
651
+
652
+ /** Rejects `when:` CEL expressions on stream-mode `returns:` entries that
653
+ * reference the root `result` identifier. The handler result in stream mode
654
+ * is an unconsumed `Stream<...>`; iterating it to evaluate the predicate
655
+ * would either fail or consume the stream before bytes flow to the response.
656
+ * References to `request.*` are fine — they don't touch the stream.
657
+ *
658
+ * This is a runtime safety net; the analyzer's static chain validator is
659
+ * authoritative. The check here is intentionally token-aware (skips string
660
+ * literals and `.result` member access) so it doesn't false-positive on
661
+ * benign expressions like `request.headers["x-result"]`. */
662
+ export function validateStreamWhenDoesNotReferenceResult(resource: {
663
+ routes?: Array<{
664
+ request?: { path?: string };
665
+ returns?: ReturnEntry[];
666
+ }>;
667
+ }): void {
668
+ for (const route of resource.routes ?? []) {
669
+ const path = route.request?.path ?? "<unknown>";
670
+ for (const entry of route.returns ?? []) {
671
+ if (entry.mode !== "stream" || !entry.when) continue;
672
+ // `when:` survived precompilation as a CompiledValue (or a raw string
673
+ // when no template was applied). Inspect the source text via a token
674
+ // walker — naive `\bresult\b` would false-positive inside string
675
+ // literals and on `.result` property paths.
676
+ const source =
677
+ typeof entry.when === "string"
678
+ ? entry.when
679
+ : (entry.when as { source?: string })?.source ?? "";
680
+ if (referencesRootIdentifier(source, "result")) {
681
+ throw new Error(
682
+ `Http.Api: '${path}' returns entry with mode: stream — 'when:' references the root 'result' identifier. ` +
683
+ `The handler result is an unconsumed Stream and cannot be inspected from CEL. ` +
684
+ `Reference only request.* in stream-mode 'when:' predicates.`,
685
+ );
686
+ }
687
+ }
688
+ }
689
+ }
690
+
691
+ /** True if `source` contains a free-standing CEL identifier `target` —
692
+ * i.e. it appears as a root identifier (not preceded by `.`, not part of
693
+ * another word) and not inside a single- or double-quoted string literal.
694
+ * Token-aware so members like `request.result_count` and string literals
695
+ * like `'my result'` don't trigger a match. */
696
+ function referencesRootIdentifier(source: string, target: string): boolean {
697
+ let i = 0;
698
+ let inString: '"' | "'" | null = null;
699
+ while (i < source.length) {
700
+ const ch = source[i]!;
701
+ if (inString) {
702
+ if (ch === "\\") {
703
+ i += 2;
704
+ continue;
705
+ }
706
+ if (ch === inString) inString = null;
707
+ i++;
708
+ continue;
709
+ }
710
+ if (ch === '"' || ch === "'") {
711
+ inString = ch as '"' | "'";
712
+ i++;
713
+ continue;
714
+ }
715
+ if (/[A-Za-z_]/.test(ch)) {
716
+ let j = i;
717
+ while (j < source.length && /[A-Za-z0-9_]/.test(source[j]!)) j++;
718
+ const word = source.slice(i, j);
719
+ // Walk back over whitespace; if the previous non-whitespace char is `.`,
720
+ // this identifier is a member access, not a root identifier.
721
+ let k = i - 1;
722
+ while (k >= 0 && /\s/.test(source[k]!)) k--;
723
+ const isMember = k >= 0 && source[k] === ".";
724
+ if (word === target && !isMember) return true;
725
+ i = j;
726
+ continue;
727
+ }
728
+ i++;
729
+ }
730
+ return false;
731
+ }
732
+
317
733
  /**
318
734
  * Translates OpenAPI path format {paramName} to Fastify format :paramName
319
735
  * Example: /api/v1/users/{userId} -> /api/v1/users/:userId
@@ -212,6 +212,11 @@ class HttpServer implements ResourceInstance {
212
212
  body: request.body,
213
213
  },
214
214
  };
215
+ const acceptHeader = (
216
+ (request.headers as Record<string, string | string[] | undefined>)["accept"] as
217
+ | string
218
+ | undefined
219
+ )?.toString();
215
220
 
216
221
  let result: any;
217
222
  try {
@@ -222,6 +227,7 @@ class HttpServer implements ResourceInstance {
222
227
  handler.catches,
223
228
  { code: err.code, message: err.message, data: err.data },
224
229
  requestContext,
230
+ acceptHeader,
225
231
  this.ctx.moduleContext,
226
232
  this.ctx.validateSchema.bind(this.ctx),
227
233
  reply,
@@ -233,6 +239,7 @@ class HttpServer implements ResourceInstance {
233
239
  handler.returns,
234
240
  result,
235
241
  requestContext,
242
+ acceptHeader,
236
243
  this.ctx.moduleContext,
237
244
  this.ctx.validateSchema.bind(this.ctx),
238
245
  reply,