@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.
- package/CHANGELOG.md +45 -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 +5 -117
- package/dist/http-api-controller.js +5 -430
- package/dist/http-server-controller.d.ts +1 -1
- package/dist/http-server-controller.js +5 -3
- package/package.json +8 -4
- package/src/fastify-reply-sink.ts +67 -0
- package/src/http-api-controller.ts +15 -540
- package/src/http-server-controller.ts +12 -9
- package/tests/fastify-reply-sink-contract.test.ts +179 -0
- package/tsconfig.json +1 -1
- package/tsconfig.spec.json +3 -3
|
@@ -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,43 +18,7 @@ import {
|
|
|
9
18
|
ResourceInstance,
|
|
10
19
|
} from "@telorun/sdk";
|
|
11
20
|
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
12
|
-
import
|
|
13
|
-
import { Readable } from "stream";
|
|
14
|
-
import { pipeline } from "stream/promises";
|
|
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
|
-
|
|
30
|
-
const ReturnEntry = Type.Object({
|
|
31
|
-
status: Type.Integer({ minimum: 100, maximum: 599 }),
|
|
32
|
-
when: Type.Optional(Type.String()),
|
|
33
|
-
mode: Type.Optional(Type.Union([Type.Literal("buffer"), Type.Literal("stream")])),
|
|
34
|
-
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
35
|
-
content: Type.Optional(Type.Record(Type.String(), ContentEntry)),
|
|
36
|
-
});
|
|
37
|
-
type ReturnEntry = Static<typeof ReturnEntry>;
|
|
38
|
-
|
|
39
|
-
const CatchEntry = Type.Object({
|
|
40
|
-
status: Type.Integer({ minimum: 100, maximum: 599 }),
|
|
41
|
-
when: Type.Optional(Type.String()),
|
|
42
|
-
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
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)),
|
|
47
|
-
});
|
|
48
|
-
type CatchEntry = Static<typeof CatchEntry>;
|
|
21
|
+
import { fastifyReplySink } from "./fastify-reply-sink.js";
|
|
49
22
|
|
|
50
23
|
const HttpApiRouteManifest = Type.Object({
|
|
51
24
|
request: Type.Object({
|
|
@@ -74,323 +47,10 @@ type HttpApiManifest = Static<typeof HttpApiManifest>;
|
|
|
74
47
|
|
|
75
48
|
export async function register(_ctx: ControllerContext): Promise<void> {}
|
|
76
49
|
|
|
77
|
-
export type {
|
|
78
|
-
|
|
79
|
-
type ModuleLikeContext = {
|
|
80
|
-
expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
type ValidateSchema = (value: unknown, schema: unknown) => void;
|
|
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;
|
|
50
|
+
export type { CatchEntry, ContentEntry, ReturnEntry };
|
|
93
51
|
|
|
94
52
|
type HandlerRef = { kind: string; name: string };
|
|
95
53
|
|
|
96
|
-
/** Pick the first entry whose `when:` evaluates truthy, falling back to the
|
|
97
|
-
* first entry with no `when:` (the list's catch-all). */
|
|
98
|
-
function matchEntry<T extends { when?: string }>(
|
|
99
|
-
entries: T[],
|
|
100
|
-
celCtx: Record<string, unknown>,
|
|
101
|
-
moduleContext: ModuleLikeContext,
|
|
102
|
-
): T | undefined {
|
|
103
|
-
let fallback: T | undefined;
|
|
104
|
-
for (const entry of entries) {
|
|
105
|
-
if (!entry.when) {
|
|
106
|
-
fallback ??= entry;
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (moduleContext.expandWith(entry.when, celCtx) === true) return entry;
|
|
110
|
-
}
|
|
111
|
-
return fallback;
|
|
112
|
-
}
|
|
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
|
-
|
|
199
|
-
export async function dispatchReturns(
|
|
200
|
-
returns: ReturnEntry[],
|
|
201
|
-
result: unknown,
|
|
202
|
-
requestContext: Record<string, unknown>,
|
|
203
|
-
acceptHeader: string | undefined,
|
|
204
|
-
moduleContext: ModuleLikeContext,
|
|
205
|
-
validateSchema: ValidateSchema,
|
|
206
|
-
reply: FastifyReply,
|
|
207
|
-
streamError?: StreamErrorHook,
|
|
208
|
-
): Promise<void> {
|
|
209
|
-
const celCtx = { result, ...requestContext };
|
|
210
|
-
const entry = matchEntry(returns, celCtx, moduleContext);
|
|
211
|
-
|
|
212
|
-
if (!entry) {
|
|
213
|
-
// Unreachable when the analyzer has run — every route's returns: list must
|
|
214
|
-
// cover its handler's return values (explicit when: or catch-all). Hitting
|
|
215
|
-
// this at runtime means something bypassed analysis; surface it loudly
|
|
216
|
-
// via Fastify's error handler rather than quietly render a 500.
|
|
217
|
-
throw new Error(
|
|
218
|
-
"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)",
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
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
|
-
}
|
|
229
|
-
|
|
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;
|
|
245
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
280
|
-
reply.hijack();
|
|
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
|
-
}
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
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);
|
|
310
|
-
reply.send(mappedBody);
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
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);
|
|
318
|
-
reply.send(result);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/** Render an InvokeError through a `catches:` list. Falls back to a structured
|
|
322
|
-
* 500 when no entry matches. Plain (non-InvokeError) throws never reach this
|
|
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. */
|
|
329
|
-
export async function dispatchCatches(
|
|
330
|
-
catches: CatchEntry[] | undefined,
|
|
331
|
-
error: { code: string; message: string; data?: unknown },
|
|
332
|
-
requestContext: Record<string, unknown>,
|
|
333
|
-
acceptHeader: string | undefined,
|
|
334
|
-
moduleContext: ModuleLikeContext,
|
|
335
|
-
validateSchema: ValidateSchema,
|
|
336
|
-
reply: FastifyReply,
|
|
337
|
-
): Promise<void> {
|
|
338
|
-
const celCtx = { error, ...requestContext };
|
|
339
|
-
const entry = catches ? matchEntry(catches, celCtx, moduleContext) : undefined;
|
|
340
|
-
|
|
341
|
-
if (!entry) {
|
|
342
|
-
reply.code(500);
|
|
343
|
-
reply.header("Content-Type", "application/json");
|
|
344
|
-
reply.send({
|
|
345
|
-
error: { code: error.code, message: error.message, data: error.data },
|
|
346
|
-
});
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
|
|
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
|
-
}
|
|
356
|
-
|
|
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;
|
|
371
|
-
}
|
|
372
|
-
|
|
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);
|
|
381
|
-
reply.send(mappedBody);
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
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");
|
|
391
|
-
reply.send({ error: { code: error.code, message: error.message, data: error.data } });
|
|
392
|
-
}
|
|
393
|
-
|
|
394
54
|
export class HttpServerApi implements ResourceInstance {
|
|
395
55
|
constructor(
|
|
396
56
|
private readonly ctx: ResourceContext,
|
|
@@ -477,6 +137,8 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
477
137
|
inputs: resolvedInputs,
|
|
478
138
|
};
|
|
479
139
|
|
|
140
|
+
const sink = fastifyReplySink(reply);
|
|
141
|
+
|
|
480
142
|
let result: unknown;
|
|
481
143
|
try {
|
|
482
144
|
result = handler
|
|
@@ -491,7 +153,7 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
491
153
|
acceptHeader,
|
|
492
154
|
this.ctx.moduleContext,
|
|
493
155
|
this.ctx.validateSchema.bind(this.ctx),
|
|
494
|
-
|
|
156
|
+
sink,
|
|
495
157
|
);
|
|
496
158
|
}
|
|
497
159
|
|
|
@@ -502,7 +164,7 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
502
164
|
acceptHeader,
|
|
503
165
|
this.ctx.moduleContext,
|
|
504
166
|
this.ctx.validateSchema.bind(this.ctx),
|
|
505
|
-
|
|
167
|
+
sink,
|
|
506
168
|
(err, errCtx) =>
|
|
507
169
|
this.ctx.emitEvent("Http.Api.streamFailed", {
|
|
508
170
|
path: route.request.path,
|
|
@@ -523,7 +185,6 @@ export class HttpServerApi implements ResourceInstance {
|
|
|
523
185
|
export async function create(resource: any, ctx: ResourceContext): Promise<HttpServerApi> {
|
|
524
186
|
ctx.validateSchema(resource, HttpApiManifest);
|
|
525
187
|
validateNoContentTypeHeader(resource);
|
|
526
|
-
validateContentEntryShape(resource);
|
|
527
188
|
validateStreamWhenDoesNotReferenceResult(resource);
|
|
528
189
|
// Capture handler {kind, name} before Phase 5 injection overwrites the ref
|
|
529
190
|
// with a live Invocable instance. invokeResolved() needs the kind/name to
|
|
@@ -544,192 +205,6 @@ export async function create(resource: any, ctx: ResourceContext): Promise<HttpS
|
|
|
544
205
|
return new HttpServerApi(ctx, resource, handlerRefs);
|
|
545
206
|
}
|
|
546
207
|
|
|
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
|
-
|
|
733
208
|
/**
|
|
734
209
|
* Translates OpenAPI path format {paramName} to Fastify format :paramName
|
|
735
210
|
* Example: /api/v1/users/{userId} -> /api/v1/users/:userId
|
|
@@ -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[];
|
|
@@ -218,6 +219,8 @@ class HttpServer implements ResourceInstance {
|
|
|
218
219
|
| undefined
|
|
219
220
|
)?.toString();
|
|
220
221
|
|
|
222
|
+
const sink = fastifyReplySink(reply);
|
|
223
|
+
|
|
221
224
|
let result: any;
|
|
222
225
|
try {
|
|
223
226
|
result = await this.ctx.invoke(handler.kind, handler.name, requestContext);
|
|
@@ -230,7 +233,7 @@ class HttpServer implements ResourceInstance {
|
|
|
230
233
|
acceptHeader,
|
|
231
234
|
this.ctx.moduleContext,
|
|
232
235
|
this.ctx.validateSchema.bind(this.ctx),
|
|
233
|
-
|
|
236
|
+
sink,
|
|
234
237
|
);
|
|
235
238
|
}
|
|
236
239
|
|
|
@@ -242,7 +245,7 @@ class HttpServer implements ResourceInstance {
|
|
|
242
245
|
acceptHeader,
|
|
243
246
|
this.ctx.moduleContext,
|
|
244
247
|
this.ctx.validateSchema.bind(this.ctx),
|
|
245
|
-
|
|
248
|
+
sink,
|
|
246
249
|
);
|
|
247
250
|
}
|
|
248
251
|
const status = result?.status ?? 200;
|