@ontrails/http 0.2.0

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/src/fetch.ts ADDED
@@ -0,0 +1,1047 @@
1
+ import {
2
+ CancelledError,
3
+ isBlobRef,
4
+ isTrailsError,
5
+ matchWebhookPath,
6
+ NotFoundError,
7
+ parseWebhookPathParams,
8
+ renderErrorDiagnostics,
9
+ renderPublicSurfaceError,
10
+ ValidationError,
11
+ } from '@ontrails/core';
12
+ import type { BlobRef, Topo } from '@ontrails/core';
13
+
14
+ import { isBlobOutputSchema } from './blob-output.js';
15
+ import { deriveHttpRoutes, resolveHttpQueryInput } from './build.js';
16
+ import type { DeriveHttpRoutesOptions, HttpRouteDefinition } from './build.js';
17
+
18
+ export interface CreateRouteHandlerOptions {
19
+ /** Maximum JSON request body size in bytes. Defaults to 1 MiB. */
20
+ readonly maxJsonBodyBytes?: number | undefined;
21
+ }
22
+
23
+ export interface CreateFetchHandlerOptions
24
+ extends DeriveHttpRoutesOptions, CreateRouteHandlerOptions {}
25
+
26
+ interface RuntimeOptions {
27
+ readonly maxJsonBodyBytes: number;
28
+ }
29
+
30
+ interface JsonObject {
31
+ readonly [key: string]: JsonValue;
32
+ }
33
+
34
+ type JsonValue =
35
+ | null
36
+ | boolean
37
+ | number
38
+ | string
39
+ | readonly JsonValue[]
40
+ | JsonObject;
41
+ type JsonBodyReadResult =
42
+ | JsonValue
43
+ | typeof JSON_BODY_INVALID_CONTENT_LENGTH
44
+ | typeof JSON_BODY_TOO_LARGE
45
+ | typeof JSON_PARSE_ERROR;
46
+ type JsonBodyTextReadResult = string | typeof JSON_BODY_TOO_LARGE;
47
+ type InputReadResult = Record<string, unknown> | JsonBodyReadResult;
48
+ type ParsedContentLength =
49
+ | number
50
+ | typeof JSON_BODY_INVALID_CONTENT_LENGTH
51
+ | undefined;
52
+
53
+ const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
54
+ const CONTENT_LENGTH_DECIMAL_PATTERN = /^\d+$/;
55
+ const JSON_NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
56
+
57
+ const JSON_PARSE_ERROR = Symbol('JSON_PARSE_ERROR');
58
+ const JSON_BODY_TOO_LARGE = Symbol('JSON_BODY_TOO_LARGE');
59
+ const JSON_BODY_INVALID_CONTENT_LENGTH = Symbol(
60
+ 'JSON_BODY_INVALID_CONTENT_LENGTH'
61
+ );
62
+
63
+ const LOG_UNSAFE_LABEL_CHARACTERS = /[^\w:.-]/g;
64
+ const MAX_DIAGNOSTIC_LABEL_VALUE_LENGTH = 128;
65
+
66
+ const routeKey = (method: string, path: string): `${string} ${string}` =>
67
+ `${method.toUpperCase()} ${path}`;
68
+
69
+ const isJsonSchemaObject = (
70
+ value: unknown
71
+ ): value is Readonly<Record<string, unknown>> =>
72
+ typeof value === 'object' && value !== null && !Array.isArray(value);
73
+
74
+ const parseQueryNumber = (value: string): number | string => {
75
+ if (!JSON_NUMBER_PATTERN.test(value)) {
76
+ return value;
77
+ }
78
+ const parsed = Number(value);
79
+ return Number.isFinite(parsed) ? parsed : value;
80
+ };
81
+
82
+ type QueryScalarKind = 'boolean' | 'null' | 'number' | 'string';
83
+
84
+ type QueryValueConversion =
85
+ | { readonly kind: 'array'; readonly itemKind: QueryScalarKind }
86
+ | { readonly kind: 'scalar'; readonly scalarKind: QueryScalarKind };
87
+
88
+ const queryScalarKindFromConst = (
89
+ value: unknown
90
+ ): QueryScalarKind | undefined => {
91
+ if (value === null) {
92
+ return 'null';
93
+ }
94
+ if (typeof value === 'number') {
95
+ return Number.isFinite(value) ? 'number' : undefined;
96
+ }
97
+ if (typeof value === 'boolean') {
98
+ return 'boolean';
99
+ }
100
+ return typeof value === 'string' ? 'string' : undefined;
101
+ };
102
+
103
+ const queryScalarKind = (
104
+ schema: Readonly<Record<string, unknown>>
105
+ ): QueryScalarKind | undefined => {
106
+ const { anyOf, type } = schema;
107
+ if (type === 'number' || type === 'integer') {
108
+ return 'number';
109
+ }
110
+ if (type === 'boolean' || type === 'string' || type === 'null') {
111
+ return type;
112
+ }
113
+ if (Object.hasOwn(schema, 'const')) {
114
+ return queryScalarKindFromConst(schema['const']);
115
+ }
116
+
117
+ if (!Array.isArray(anyOf) || anyOf.length === 0) {
118
+ return undefined;
119
+ }
120
+ const kinds = anyOf.map((branch) =>
121
+ isJsonSchemaObject(branch) ? queryScalarKind(branch) : undefined
122
+ );
123
+ if (kinds.some((kind) => kind === undefined)) {
124
+ return undefined;
125
+ }
126
+ const nonNullKinds = kinds.filter((kind) => kind !== 'null');
127
+ const [first] = nonNullKinds;
128
+ return first !== undefined && nonNullKinds.every((kind) => kind === first)
129
+ ? first
130
+ : undefined;
131
+ };
132
+
133
+ const convertQueryScalar = (
134
+ value: string,
135
+ kind: QueryScalarKind
136
+ ): boolean | number | string => {
137
+ if (kind === 'number') {
138
+ return parseQueryNumber(value);
139
+ }
140
+ if (kind === 'boolean') {
141
+ if (value === 'true') {
142
+ return true;
143
+ }
144
+ if (value === 'false') {
145
+ return false;
146
+ }
147
+ }
148
+ return value;
149
+ };
150
+
151
+ const queryArrayItemKind = (
152
+ schema: Readonly<Record<string, unknown>>
153
+ ): QueryScalarKind | undefined => {
154
+ const { anyOf, items, type } = schema;
155
+ if (type === 'array') {
156
+ return isJsonSchemaObject(items) ? queryScalarKind(items) : undefined;
157
+ }
158
+ if (!Array.isArray(anyOf) || anyOf.length === 0) {
159
+ return undefined;
160
+ }
161
+ const kinds: (QueryScalarKind | undefined)[] = [];
162
+ for (const branch of anyOf) {
163
+ if (!isJsonSchemaObject(branch)) {
164
+ return undefined;
165
+ }
166
+ kinds.push(
167
+ queryScalarKind(branch) === 'null' ? 'null' : queryArrayItemKind(branch)
168
+ );
169
+ }
170
+ if (kinds.some((kind) => kind === undefined)) {
171
+ return undefined;
172
+ }
173
+ const nonNullKinds = kinds.filter((kind) => kind !== 'null');
174
+ const [first] = nonNullKinds;
175
+ return first !== undefined && nonNullKinds.every((kind) => kind === first)
176
+ ? first
177
+ : undefined;
178
+ };
179
+
180
+ const queryValueConversion = (
181
+ schema: unknown
182
+ ): QueryValueConversion | undefined => {
183
+ if (!isJsonSchemaObject(schema)) {
184
+ return undefined;
185
+ }
186
+ const scalarKind = queryScalarKind(schema);
187
+ if (scalarKind !== undefined) {
188
+ return { kind: 'scalar', scalarKind };
189
+ }
190
+ const itemKind = queryArrayItemKind(schema);
191
+ return itemKind === undefined ? undefined : { itemKind, kind: 'array' };
192
+ };
193
+
194
+ const queryValueConversionsAgree = (
195
+ left: QueryValueConversion | undefined,
196
+ right: QueryValueConversion | undefined
197
+ ): left is QueryValueConversion => {
198
+ if (left === undefined || right === undefined || left.kind !== right.kind) {
199
+ return false;
200
+ }
201
+ if (left.kind === 'array') {
202
+ return right.kind === 'array' && left.itemKind === right.itemKind;
203
+ }
204
+ return right.kind === 'scalar' && left.scalarKind === right.scalarKind;
205
+ };
206
+
207
+ const convertQueryValue = (
208
+ value: string | string[],
209
+ conversion: QueryValueConversion | undefined
210
+ ): unknown => {
211
+ if (conversion === undefined) {
212
+ return value;
213
+ }
214
+ if (!Array.isArray(value)) {
215
+ return conversion.kind === 'scalar'
216
+ ? convertQueryScalar(value, conversion.scalarKind)
217
+ : value;
218
+ }
219
+ if (conversion.kind !== 'array') {
220
+ return value;
221
+ }
222
+ return value.map((item) => convertQueryScalar(item, conversion.itemKind));
223
+ };
224
+
225
+ const queryRequiredFields = (
226
+ schema: Readonly<Record<string, unknown>>
227
+ ): readonly string[] | undefined => {
228
+ const { required } = schema;
229
+ if (required === undefined) {
230
+ return [];
231
+ }
232
+ return Array.isArray(required) &&
233
+ required.every((key) => typeof key === 'string')
234
+ ? required
235
+ : undefined;
236
+ };
237
+
238
+ const queryObjectBranches = (
239
+ schema: Readonly<Record<string, unknown>>
240
+ ): readonly Readonly<Record<string, unknown>>[] | undefined => {
241
+ const { anyOf, properties, type } = schema;
242
+ if (!Array.isArray(anyOf) || anyOf.length === 0) {
243
+ if (type !== 'object') {
244
+ return undefined;
245
+ }
246
+ return isJsonSchemaObject(properties) ? [schema] : undefined;
247
+ }
248
+
249
+ let sharedProperties: Readonly<Record<string, unknown>> = {};
250
+ if (properties !== undefined) {
251
+ if (!isJsonSchemaObject(properties)) {
252
+ return undefined;
253
+ }
254
+ sharedProperties = properties;
255
+ }
256
+ const sharedRequired = queryRequiredFields(schema);
257
+ if (sharedRequired === undefined) {
258
+ return undefined;
259
+ }
260
+
261
+ const branches: Readonly<Record<string, unknown>>[] = [];
262
+ for (const branch of anyOf) {
263
+ if (!isJsonSchemaObject(branch)) {
264
+ return undefined;
265
+ }
266
+ if (queryScalarKind(branch) === 'null') {
267
+ continue;
268
+ }
269
+ const nested = queryObjectBranches(branch);
270
+ if (nested === undefined) {
271
+ return undefined;
272
+ }
273
+ for (const nestedBranch of nested) {
274
+ const nestedProperties = nestedBranch['properties'];
275
+ const nestedRequired = queryRequiredFields(nestedBranch);
276
+ if (!isJsonSchemaObject(nestedProperties) || !nestedRequired) {
277
+ return undefined;
278
+ }
279
+ const required = [...new Set([...nestedRequired, ...sharedRequired])];
280
+ const mergedProperties = { ...nestedProperties };
281
+ for (const [key, sharedProperty] of Object.entries(sharedProperties)) {
282
+ if (!Object.hasOwn(nestedProperties, key)) {
283
+ mergedProperties[key] = sharedProperty;
284
+ continue;
285
+ }
286
+ const nestedProperty = nestedProperties[key];
287
+ mergedProperties[key] = queryValueConversionsAgree(
288
+ queryValueConversion(nestedProperty),
289
+ queryValueConversion(sharedProperty)
290
+ )
291
+ ? nestedProperty
292
+ : {};
293
+ }
294
+ branches.push({
295
+ ...nestedBranch,
296
+ properties: mergedProperties,
297
+ ...(required.length > 0 ? { required } : {}),
298
+ type: 'object',
299
+ });
300
+ }
301
+ }
302
+ return branches.length > 0 ? branches : undefined;
303
+ };
304
+
305
+ const branchCanAcceptPresentKeys = (
306
+ branch: Readonly<Record<string, unknown>>,
307
+ presentKeys: ReadonlySet<string>
308
+ ): boolean => {
309
+ const { required } = branch;
310
+ return (
311
+ required === undefined ||
312
+ (Array.isArray(required) &&
313
+ required.every((key) => typeof key === 'string' && presentKeys.has(key)))
314
+ );
315
+ };
316
+
317
+ const queryFieldConversion = (
318
+ inputSchema: Readonly<Record<string, unknown>> | undefined,
319
+ key: string,
320
+ presentKeys: ReadonlySet<string>
321
+ ): QueryValueConversion | undefined => {
322
+ if (inputSchema === undefined) {
323
+ return undefined;
324
+ }
325
+ const branches = queryObjectBranches(inputSchema)?.filter((branch) =>
326
+ branchCanAcceptPresentKeys(branch, presentKeys)
327
+ );
328
+ if (branches === undefined || branches.length === 0) {
329
+ return undefined;
330
+ }
331
+
332
+ const conversions = branches.map((branch) => {
333
+ const { properties } = branch;
334
+ return isJsonSchemaObject(properties)
335
+ ? queryValueConversion(properties[key])
336
+ : undefined;
337
+ });
338
+ const [first] = conversions;
339
+ return conversions.every((conversion) =>
340
+ queryValueConversionsAgree(first, conversion)
341
+ )
342
+ ? first
343
+ : undefined;
344
+ };
345
+
346
+ const convertQueryParams = (
347
+ input: Readonly<Record<string, string | string[]>>,
348
+ inputSchema: Readonly<Record<string, unknown>> | undefined,
349
+ preserveRawFields: ReadonlySet<string>
350
+ ): Record<string, unknown> => {
351
+ const result: Record<string, unknown> = {};
352
+ const presentKeys = new Set(Object.keys(input));
353
+
354
+ for (const [key, value] of Object.entries(input)) {
355
+ result[key] = preserveRawFields.has(key)
356
+ ? value
357
+ : convertQueryValue(
358
+ value,
359
+ queryFieldConversion(inputSchema, key, presentKeys)
360
+ );
361
+ }
362
+
363
+ return result;
364
+ };
365
+
366
+ const parseQueryParams = (
367
+ request: Request
368
+ ): Record<string, string | string[]> => {
369
+ const result: Record<string, string | string[]> = {};
370
+ const url = new URL(request.url);
371
+ const seenKeys = new Set<string>();
372
+
373
+ for (const key of url.searchParams.keys()) {
374
+ if (seenKeys.has(key)) {
375
+ continue;
376
+ }
377
+ seenKeys.add(key);
378
+ const all = url.searchParams.getAll(key);
379
+ const value = all.length > 1 ? all : all[0];
380
+ if (value !== undefined) {
381
+ result[key] = value;
382
+ }
383
+ }
384
+
385
+ return result;
386
+ };
387
+
388
+ const parseContentLength = (
389
+ contentLength: string | null | undefined
390
+ ): ParsedContentLength => {
391
+ if (contentLength === null || contentLength === undefined) {
392
+ return undefined;
393
+ }
394
+ if (!CONTENT_LENGTH_DECIMAL_PATTERN.test(contentLength)) {
395
+ return JSON_BODY_INVALID_CONTENT_LENGTH;
396
+ }
397
+ const size = Number(contentLength);
398
+ return Number.isSafeInteger(size) ? size : Number.MAX_SAFE_INTEGER;
399
+ };
400
+
401
+ const isEmptyBody = (request: Request): boolean => {
402
+ const contentLength = parseContentLength(
403
+ request.headers.get('Content-Length')
404
+ );
405
+ if (contentLength === JSON_BODY_INVALID_CONTENT_LENGTH) {
406
+ return false;
407
+ }
408
+ if (contentLength !== undefined) {
409
+ return contentLength === 0;
410
+ }
411
+ return request.headers.get('Content-Type') === null;
412
+ };
413
+
414
+ const resolveMaxJsonBodyBytes = (value: number | undefined): number => {
415
+ const maxJsonBodyBytes = value ?? DEFAULT_MAX_JSON_BODY_BYTES;
416
+
417
+ if (!Number.isFinite(maxJsonBodyBytes) || maxJsonBodyBytes < 1) {
418
+ throw new ValidationError(
419
+ 'maxJsonBodyBytes must be a positive finite number'
420
+ );
421
+ }
422
+
423
+ return maxJsonBodyBytes;
424
+ };
425
+
426
+ const hasOversizedContentLength = (
427
+ request: Request,
428
+ maxJsonBodyBytes: number
429
+ ): boolean => {
430
+ const contentLength = request.headers.get('Content-Length');
431
+ if (contentLength === null) {
432
+ return false;
433
+ }
434
+ const size = parseContentLength(contentLength);
435
+ if (size === JSON_BODY_INVALID_CONTENT_LENGTH) {
436
+ return false;
437
+ }
438
+ return size !== undefined && size > maxJsonBodyBytes;
439
+ };
440
+
441
+ const measureBodyTextBytes = (text: string): number => new Blob([text]).size;
442
+
443
+ const validateBodyText = (
444
+ text: string,
445
+ maxJsonBodyBytes: number
446
+ ): JsonBodyTextReadResult =>
447
+ measureBodyTextBytes(text) > maxJsonBodyBytes ? JSON_BODY_TOO_LARGE : text;
448
+
449
+ const cancelBodyReader = async (
450
+ reader: ReadableStreamDefaultReader<Uint8Array>,
451
+ reason?: unknown
452
+ ): Promise<void> => {
453
+ try {
454
+ await reader.cancel(reason);
455
+ } catch {
456
+ // The request is already being cancelled; preserve the surface-level
457
+ // cancelled response instead of replacing it with a reader cleanup error.
458
+ }
459
+ };
460
+
461
+ const assertRequestNotAborted = async (
462
+ request: Request,
463
+ reader: ReadableStreamDefaultReader<Uint8Array>
464
+ ): Promise<void> => {
465
+ if (!request.signal.aborted) {
466
+ return;
467
+ }
468
+ await cancelBodyReader(reader, request.signal.reason);
469
+ throw new CancelledError('Request aborted');
470
+ };
471
+
472
+ const readBodyText = async (
473
+ request: Request,
474
+ maxJsonBodyBytes: number
475
+ ): Promise<JsonBodyTextReadResult> => {
476
+ const { body } = request;
477
+ if (body === null) {
478
+ return '';
479
+ }
480
+
481
+ const reader = body.getReader();
482
+ const chunks: Uint8Array[] = [];
483
+ let totalBytes = 0;
484
+
485
+ try {
486
+ while (true) {
487
+ await assertRequestNotAborted(request, reader);
488
+ let read: Awaited<ReturnType<typeof reader.read>>;
489
+ try {
490
+ read = await reader.read();
491
+ } catch (error) {
492
+ await assertRequestNotAborted(request, reader);
493
+ throw error;
494
+ }
495
+ await assertRequestNotAborted(request, reader);
496
+ const { done, value } = read;
497
+ if (done) {
498
+ break;
499
+ }
500
+ if (value === undefined) {
501
+ continue;
502
+ }
503
+ totalBytes += value.byteLength;
504
+ if (totalBytes > maxJsonBodyBytes) {
505
+ await cancelBodyReader(reader);
506
+ return JSON_BODY_TOO_LARGE;
507
+ }
508
+ chunks.push(value);
509
+ }
510
+ } finally {
511
+ reader.releaseLock();
512
+ }
513
+
514
+ const bytes = new Uint8Array(totalBytes);
515
+ let offset = 0;
516
+ for (const chunk of chunks) {
517
+ bytes.set(chunk, offset);
518
+ offset += chunk.byteLength;
519
+ }
520
+
521
+ return new TextDecoder().decode(bytes);
522
+ };
523
+
524
+ const readJsonBody = async (
525
+ request: Request,
526
+ maxJsonBodyBytes: number
527
+ ): Promise<JsonBodyReadResult> => {
528
+ if (
529
+ parseContentLength(request.headers.get('Content-Length')) ===
530
+ JSON_BODY_INVALID_CONTENT_LENGTH
531
+ ) {
532
+ return JSON_BODY_INVALID_CONTENT_LENGTH;
533
+ }
534
+
535
+ if (hasOversizedContentLength(request, maxJsonBodyBytes)) {
536
+ return JSON_BODY_TOO_LARGE;
537
+ }
538
+
539
+ const text = await readBodyText(request, maxJsonBodyBytes);
540
+ if (text === JSON_BODY_TOO_LARGE) {
541
+ return JSON_BODY_TOO_LARGE;
542
+ }
543
+
544
+ const validated = validateBodyText(text, maxJsonBodyBytes);
545
+ if (validated === JSON_BODY_TOO_LARGE) {
546
+ return JSON_BODY_TOO_LARGE;
547
+ }
548
+
549
+ try {
550
+ return JSON.parse(validated) as JsonValue;
551
+ } catch {
552
+ return JSON_PARSE_ERROR;
553
+ }
554
+ };
555
+
556
+ const parseJsonBodyText = (text: string): JsonBodyReadResult => {
557
+ try {
558
+ return JSON.parse(text) as JsonValue;
559
+ } catch {
560
+ return JSON_PARSE_ERROR;
561
+ }
562
+ };
563
+
564
+ const parseWebhookBodyText = (
565
+ request: Request,
566
+ text: string
567
+ ): JsonBodyReadResult =>
568
+ isEmptyBody(request) || text.length === 0 ? {} : parseJsonBodyText(text);
569
+
570
+ const readWebhookBodyText = async (
571
+ request: Request,
572
+ maxJsonBodyBytes: number
573
+ ): Promise<
574
+ string | typeof JSON_BODY_INVALID_CONTENT_LENGTH | typeof JSON_BODY_TOO_LARGE
575
+ > => {
576
+ if (
577
+ parseContentLength(request.headers.get('Content-Length')) ===
578
+ JSON_BODY_INVALID_CONTENT_LENGTH
579
+ ) {
580
+ return JSON_BODY_INVALID_CONTENT_LENGTH;
581
+ }
582
+ if (hasOversizedContentLength(request, maxJsonBodyBytes)) {
583
+ return JSON_BODY_TOO_LARGE;
584
+ }
585
+ return await readBodyText(request, maxJsonBodyBytes);
586
+ };
587
+
588
+ const readInput = async (
589
+ request: Request,
590
+ route: HttpRouteDefinition,
591
+ options: RuntimeOptions
592
+ ): Promise<InputReadResult> => {
593
+ if (route.inputSource === 'query') {
594
+ const raw = parseQueryParams(request);
595
+ const { inputSchema, preserveRawFields } = resolveHttpQueryInput(
596
+ route,
597
+ raw,
598
+ {
599
+ headers: request.headers,
600
+ }
601
+ );
602
+ return convertQueryParams(raw, inputSchema, preserveRawFields);
603
+ }
604
+ if (isEmptyBody(request)) {
605
+ return {};
606
+ }
607
+ return await readJsonBody(request, options.maxJsonBodyBytes);
608
+ };
609
+
610
+ const json = (body: Record<string, unknown>, status: number): Response =>
611
+ Response.json(body, { status });
612
+
613
+ const mapErrorResponse = (error: Error): Response => {
614
+ const rendering = renderPublicSurfaceError('http', error);
615
+ return json(
616
+ {
617
+ error: {
618
+ category: rendering.category,
619
+ code: rendering.name,
620
+ message: rendering.message,
621
+ },
622
+ },
623
+ rendering.code
624
+ );
625
+ };
626
+
627
+ const sanitizeDiagnosticLabelValue = (value: string): string =>
628
+ value
629
+ .replace(LOG_UNSAFE_LABEL_CHARACTERS, '_')
630
+ .slice(0, MAX_DIAGNOSTIC_LABEL_VALUE_LENGTH);
631
+
632
+ const reportInternalDiagnostics = (error: Error, request: Request): void => {
633
+ if (isTrailsError(error)) {
634
+ return;
635
+ }
636
+
637
+ const requestId = request.headers.get('X-Request-ID') ?? undefined;
638
+ const safeRequestId =
639
+ requestId === undefined
640
+ ? undefined
641
+ : sanitizeDiagnosticLabelValue(requestId);
642
+ const label =
643
+ safeRequestId === undefined
644
+ ? '[ontrails:http/fetch] Internal error'
645
+ : `[ontrails:http/fetch] Internal error (${safeRequestId})`;
646
+ console.error(label, renderErrorDiagnostics(error));
647
+ };
648
+
649
+ interface ResultLike {
650
+ readonly error?: Error | undefined;
651
+ isOk(): boolean;
652
+ readonly value?: unknown;
653
+ }
654
+
655
+ /**
656
+ * True when the route's trail declares a BlobRef output schema — the
657
+ * authored fact that selects byte streaming over the JSON envelope.
658
+ */
659
+ const rendersBlobOutput = (route: HttpRouteDefinition): boolean =>
660
+ isBlobOutputSchema(route.trail.output);
661
+
662
+ /**
663
+ * Narrow blob bytes for `Response`. `BlobRef.data` is typed `Uint8Array`
664
+ * (ArrayBufferLike backing) while `BodyInit` wants a plain-ArrayBuffer
665
+ * view; blob producers construct views over plain buffers, so narrowing
666
+ * here avoids copying the bytes.
667
+ */
668
+ const blobBody = (data: BlobRef['data']): BodyInit =>
669
+ data instanceof ReadableStream ? data : (data as Uint8Array<ArrayBuffer>);
670
+
671
+ /** Stream a BlobRef's bytes with its declared content type and length. */
672
+ const blobResponse = (blob: BlobRef): Response =>
673
+ new Response(blobBody(blob.data), {
674
+ headers: {
675
+ 'Content-Length': String(blob.size),
676
+ 'Content-Type': blob.mimeType,
677
+ },
678
+ status: 200,
679
+ });
680
+
681
+ const mapResultToResponse = (
682
+ result: ResultLike,
683
+ request: Request,
684
+ options?: { readonly rendersBlob?: boolean }
685
+ ): Response => {
686
+ if (result.isOk()) {
687
+ if (options?.rendersBlob === true && isBlobRef(result.value)) {
688
+ return blobResponse(result.value);
689
+ }
690
+ return json({ data: result.value }, 200);
691
+ }
692
+ const error = result.error ?? new Error('Unknown error');
693
+ reportInternalDiagnostics(error, request);
694
+ return mapErrorResponse(error);
695
+ };
696
+
697
+ const handleCaughtError = (error: unknown, request: Request): Response => {
698
+ const err = error instanceof Error ? error : new Error(String(error));
699
+ reportInternalDiagnostics(err, request);
700
+ return mapErrorResponse(err);
701
+ };
702
+
703
+ const invalidJsonResponse = (): Response =>
704
+ json(
705
+ {
706
+ error: {
707
+ category: 'validation',
708
+ code: 'ValidationError',
709
+ message: 'Invalid JSON in request body',
710
+ },
711
+ },
712
+ 400
713
+ );
714
+
715
+ const invalidContentLengthResponse = (): Response =>
716
+ json(
717
+ {
718
+ error: {
719
+ category: 'validation',
720
+ code: 'ValidationError',
721
+ message: 'Invalid Content-Length header',
722
+ },
723
+ },
724
+ 400
725
+ );
726
+
727
+ const oversizedJsonBodyResponse = (options: RuntimeOptions): Response =>
728
+ json(
729
+ {
730
+ error: {
731
+ category: 'validation',
732
+ code: 'ValidationError',
733
+ message: `JSON request body exceeds ${options.maxJsonBodyBytes} bytes`,
734
+ },
735
+ },
736
+ 413
737
+ );
738
+
739
+ const notFoundResponse = (request: Request): Response => {
740
+ const path = new URL(request.url).pathname;
741
+ return mapErrorResponse(new NotFoundError(`HTTP route not found: ${path}`));
742
+ };
743
+
744
+ const collectHeaders = (request: Request): Record<string, string> => {
745
+ const headers: Record<string, string> = {};
746
+ for (const [key, value] of request.headers) {
747
+ headers[key] = value;
748
+ }
749
+ return headers;
750
+ };
751
+
752
+ const createWebhookVerifyRequest = (
753
+ request: Request,
754
+ body: string
755
+ ): {
756
+ readonly body: string;
757
+ readonly headers: Record<string, string>;
758
+ readonly method: string;
759
+ readonly path: string;
760
+ } => ({
761
+ body,
762
+ headers: collectHeaders(request),
763
+ method: request.method,
764
+ path: new URL(request.url).pathname,
765
+ });
766
+
767
+ const recordInvalidWebhook = async (
768
+ route: HttpRouteDefinition,
769
+ errorCategory = 'validation'
770
+ ): Promise<void> => {
771
+ await route.recordWebhookInvalid?.(errorCategory);
772
+ };
773
+
774
+ const errorCategoryForWebhookFailure = (error: Error | undefined): string =>
775
+ error !== undefined && isTrailsError(error) ? error.category : 'internal';
776
+
777
+ /**
778
+ * True when the route's webhook source opts into ingress-envelope
779
+ * delivery: dynamic path segments, raw body, or allowlisted headers.
780
+ */
781
+ const usesWebhookEnvelope = (route: HttpRouteDefinition): boolean => {
782
+ const source = route.webhookSource;
783
+ return (
784
+ source !== undefined &&
785
+ (source.rawBody === true ||
786
+ source.headers !== undefined ||
787
+ parseWebhookPathParams(source.path).length > 0)
788
+ );
789
+ };
790
+
791
+ const pickAllowlistedHeaders = (
792
+ headers: Headers,
793
+ allowlist: readonly string[]
794
+ ): Record<string, string> => {
795
+ const allowed = new Set(allowlist.map((name) => name.toLowerCase()));
796
+ const kept: Record<string, string> = {};
797
+ for (const [name, value] of headers) {
798
+ const normalized = name.toLowerCase();
799
+ if (allowed.has(normalized)) {
800
+ kept[normalized] = value;
801
+ }
802
+ }
803
+ return kept;
804
+ };
805
+
806
+ /**
807
+ * Assemble the delivered webhook value.
808
+ *
809
+ * Classic webhooks deliver the parsed JSON body directly. Envelope-mode
810
+ * webhooks deliver `{ ...pathParams, body?, headers?, rawBody? }` — the
811
+ * schema-declared boundary shape TRL-1194 lifts from the hand-mounted
812
+ * ingress routes.
813
+ */
814
+ const buildWebhookDeliveredValue = (
815
+ route: HttpRouteDefinition,
816
+ request: Request,
817
+ rawBody: string,
818
+ jsonBody: unknown,
819
+ pathParams: Readonly<Record<string, string>> | undefined
820
+ ): unknown => {
821
+ const source = route.webhookSource;
822
+ if (source === undefined || !usesWebhookEnvelope(route)) {
823
+ return jsonBody;
824
+ }
825
+ return {
826
+ ...(jsonBody === undefined ? {} : { body: jsonBody }),
827
+ ...(source.headers === undefined
828
+ ? {}
829
+ : { headers: pickAllowlistedHeaders(request.headers, source.headers) }),
830
+ ...(source.rawBody === true ? { rawBody } : {}),
831
+ ...pathParams,
832
+ };
833
+ };
834
+
835
+ const handleWebhookRoute = async (
836
+ route: HttpRouteDefinition,
837
+ options: RuntimeOptions,
838
+ request: Request
839
+ ): Promise<Response> => {
840
+ const envelope = usesWebhookEnvelope(route);
841
+ // Self-derive dynamic segment values from the route's own pattern so
842
+ // every adapter (fetch dispatcher, Bun routes, Hono) gets pattern
843
+ // support without threading params through handler signatures.
844
+ const pathParams = envelope
845
+ ? matchWebhookPath(route.path, new URL(request.url).pathname)
846
+ : undefined;
847
+ const rawBody = await readWebhookBodyText(request, options.maxJsonBodyBytes);
848
+
849
+ if (rawBody === JSON_BODY_INVALID_CONTENT_LENGTH) {
850
+ await recordInvalidWebhook(route);
851
+ return invalidContentLengthResponse();
852
+ }
853
+
854
+ if (rawBody === JSON_BODY_TOO_LARGE) {
855
+ await recordInvalidWebhook(route);
856
+ return oversizedJsonBodyResponse(options);
857
+ }
858
+
859
+ const verified = await route.verifyWebhook?.(
860
+ createWebhookVerifyRequest(request, rawBody)
861
+ );
862
+ if (verified?.isErr()) {
863
+ await recordInvalidWebhook(
864
+ route,
865
+ errorCategoryForWebhookFailure(verified.error)
866
+ );
867
+ return mapResultToResponse(verified, request);
868
+ }
869
+
870
+ const jsonBody = parseWebhookBodyText(request, rawBody);
871
+ // With rawBody delivery the trail owns payload interpretation, so a
872
+ // non-JSON body is not a surface-level failure.
873
+ if (jsonBody === JSON_PARSE_ERROR && route.webhookSource?.rawBody !== true) {
874
+ await recordInvalidWebhook(route);
875
+ return invalidJsonResponse();
876
+ }
877
+
878
+ const delivered = buildWebhookDeliveredValue(
879
+ route,
880
+ request,
881
+ rawBody,
882
+ jsonBody === JSON_PARSE_ERROR ? undefined : jsonBody,
883
+ pathParams
884
+ );
885
+
886
+ const parsed = route.parseWebhookInput?.(delivered);
887
+ if (parsed === undefined) {
888
+ await recordInvalidWebhook(route, 'internal');
889
+ return mapResultToResponse(
890
+ {
891
+ error: new Error('Webhook route is missing parse handler'),
892
+ isOk: () => false,
893
+ },
894
+ request
895
+ );
896
+ }
897
+ if (parsed.isErr()) {
898
+ await recordInvalidWebhook(route);
899
+ return mapResultToResponse(parsed, request);
900
+ }
901
+
902
+ const requestId = request.headers.get('X-Request-ID') ?? undefined;
903
+ const result = await route.execute(parsed.value, requestId, request.signal, {
904
+ headers: request.headers,
905
+ });
906
+ // Envelope-mode ingress acknowledges accepted work with 202, matching
907
+ // the accepted-for-processing semantics of webhook receivers.
908
+ if (envelope && result.isOk()) {
909
+ return json({ data: result.value }, 202);
910
+ }
911
+ return mapResultToResponse(result, request);
912
+ };
913
+
914
+ /**
915
+ * Build a Web Fetch handler for one framework-agnostic HTTP route.
916
+ *
917
+ * @example
918
+ * ```ts
919
+ * import { deriveHttpRoutes } from '@ontrails/http';
920
+ * import { createRouteHandler } from '@ontrails/http/fetch';
921
+ *
922
+ * const routes = deriveHttpRoutes(graph, { basePath: '/api' });
923
+ * if (routes.isErr()) throw routes.error;
924
+ *
925
+ * const route = routes.value[0];
926
+ * if (!route) throw new Error('No routes derived');
927
+ *
928
+ * const handle = createRouteHandler(route);
929
+ * const response = await handle(new Request('https://example.test/api/hello'));
930
+ * ```
931
+ */
932
+ export const createRouteHandler = (
933
+ route: HttpRouteDefinition,
934
+ options: CreateRouteHandlerOptions = {}
935
+ ): ((request: Request) => Promise<Response>) => {
936
+ const runtimeOptions = {
937
+ maxJsonBodyBytes: resolveMaxJsonBodyBytes(options.maxJsonBodyBytes),
938
+ };
939
+ const rendersBlob = rendersBlobOutput(route);
940
+
941
+ return async (request) => {
942
+ try {
943
+ if (route.inputSource === 'webhook') {
944
+ return await handleWebhookRoute(route, runtimeOptions, request);
945
+ }
946
+
947
+ const rawInput = await readInput(request, route, runtimeOptions);
948
+
949
+ if (rawInput === JSON_PARSE_ERROR) {
950
+ return invalidJsonResponse();
951
+ }
952
+
953
+ if (rawInput === JSON_BODY_INVALID_CONTENT_LENGTH) {
954
+ return invalidContentLengthResponse();
955
+ }
956
+
957
+ if (rawInput === JSON_BODY_TOO_LARGE) {
958
+ return oversizedJsonBodyResponse(runtimeOptions);
959
+ }
960
+
961
+ const requestId = request.headers.get('X-Request-ID') ?? undefined;
962
+ const result = await route.execute(rawInput, requestId, request.signal, {
963
+ headers: request.headers,
964
+ });
965
+ return mapResultToResponse(result, request, { rendersBlob });
966
+ } catch (error: unknown) {
967
+ return handleCaughtError(error, request);
968
+ }
969
+ };
970
+ };
971
+
972
+ /**
973
+ * Build a Web Fetch dispatcher for all HTTP routes in a topo.
974
+ *
975
+ * @example
976
+ * ```ts
977
+ * import { createFetchHandler } from '@ontrails/http/fetch';
978
+ *
979
+ * const fetch = createFetchHandler(graph, { basePath: '/api' });
980
+ * const response = await fetch(
981
+ * new Request('https://example.test/api/hello?name=Matt')
982
+ * );
983
+ * ```
984
+ */
985
+ export const createFetchHandler = (
986
+ graph: Topo,
987
+ options: CreateFetchHandlerOptions = {}
988
+ ): ((request: Request) => Promise<Response>) => {
989
+ const routesResult = deriveHttpRoutes(graph, {
990
+ basePath: options.basePath,
991
+ configValues: options.configValues,
992
+ createContext: options.createContext,
993
+ exclude: options.exclude,
994
+ include: options.include,
995
+ intent: options.intent,
996
+ layers: options.layers,
997
+ resolvePermit: options.resolvePermit,
998
+ resources: options.resources,
999
+ validate: options.validate,
1000
+ });
1001
+
1002
+ if (routesResult.isErr()) {
1003
+ throw routesResult.error;
1004
+ }
1005
+
1006
+ type BoundRouteHandler = (request: Request) => Promise<Response>;
1007
+
1008
+ const routeHandlers = new Map<string, BoundRouteHandler>();
1009
+ const patternRoutes: {
1010
+ readonly handler: BoundRouteHandler;
1011
+ readonly method: string;
1012
+ readonly pattern: string;
1013
+ }[] = [];
1014
+ for (const route of routesResult.value) {
1015
+ const handler = createRouteHandler(route, {
1016
+ maxJsonBodyBytes: options.maxJsonBodyBytes,
1017
+ });
1018
+ if (parseWebhookPathParams(route.path).length > 0) {
1019
+ patternRoutes.push({
1020
+ handler,
1021
+ method: route.method.toUpperCase(),
1022
+ pattern: route.path,
1023
+ });
1024
+ continue;
1025
+ }
1026
+ routeHandlers.set(routeKey(route.method, route.path), handler);
1027
+ }
1028
+
1029
+ return async (request) => {
1030
+ const path = new URL(request.url).pathname;
1031
+ const handler = routeHandlers.get(routeKey(request.method, path));
1032
+ if (handler !== undefined) {
1033
+ return handler(request);
1034
+ }
1035
+ // Exact routes win; dynamic-segment routes match in registration order.
1036
+ const method = request.method.toUpperCase();
1037
+ for (const candidate of patternRoutes) {
1038
+ if (candidate.method !== method) {
1039
+ continue;
1040
+ }
1041
+ if (matchWebhookPath(candidate.pattern, path) !== undefined) {
1042
+ return candidate.handler(request);
1043
+ }
1044
+ }
1045
+ return notFoundResponse(request);
1046
+ };
1047
+ };