@ontrails/http 1.0.0-beta.17 → 1.0.0-beta.19

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,602 @@
1
+ import {
2
+ CancelledError,
3
+ isTrailsError,
4
+ NotFoundError,
5
+ projectErrorDiagnostics,
6
+ projectPublicSurfaceError,
7
+ ValidationError,
8
+ } from '@ontrails/core';
9
+ import type { Topo } from '@ontrails/core';
10
+
11
+ import { deriveHttpRoutes } from './build.js';
12
+ import type { DeriveHttpRoutesOptions, HttpRouteDefinition } from './build.js';
13
+
14
+ export interface CreateRouteHandlerOptions {
15
+ /** Maximum JSON request body size in bytes. Defaults to 1 MiB. */
16
+ readonly maxJsonBodyBytes?: number | undefined;
17
+ }
18
+
19
+ export interface CreateFetchHandlerOptions
20
+ extends DeriveHttpRoutesOptions, CreateRouteHandlerOptions {}
21
+
22
+ interface RuntimeOptions {
23
+ readonly maxJsonBodyBytes: number;
24
+ }
25
+
26
+ interface JsonObject {
27
+ readonly [key: string]: JsonValue;
28
+ }
29
+
30
+ type JsonValue =
31
+ | null
32
+ | boolean
33
+ | number
34
+ | string
35
+ | readonly JsonValue[]
36
+ | JsonObject;
37
+ type JsonBodyReadResult =
38
+ | JsonValue
39
+ | typeof JSON_BODY_INVALID_CONTENT_LENGTH
40
+ | typeof JSON_BODY_TOO_LARGE
41
+ | typeof JSON_PARSE_ERROR;
42
+ type JsonBodyTextReadResult = string | typeof JSON_BODY_TOO_LARGE;
43
+ type InputReadResult = Record<string, unknown> | JsonBodyReadResult;
44
+ type ParsedContentLength =
45
+ | number
46
+ | typeof JSON_BODY_INVALID_CONTENT_LENGTH
47
+ | undefined;
48
+
49
+ const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
50
+ const CONTENT_LENGTH_DECIMAL_PATTERN = /^\d+$/;
51
+
52
+ const JSON_PARSE_ERROR = Symbol('JSON_PARSE_ERROR');
53
+ const JSON_BODY_TOO_LARGE = Symbol('JSON_BODY_TOO_LARGE');
54
+ const JSON_BODY_INVALID_CONTENT_LENGTH = Symbol(
55
+ 'JSON_BODY_INVALID_CONTENT_LENGTH'
56
+ );
57
+
58
+ const LOG_UNSAFE_LABEL_CHARACTERS = /[^\w:.-]/g;
59
+ const MAX_DIAGNOSTIC_LABEL_VALUE_LENGTH = 128;
60
+
61
+ const routeKey = (method: string, path: string): `${string} ${string}` =>
62
+ `${method.toUpperCase()} ${path}`;
63
+
64
+ const parseQueryParams = (request: Request): Record<string, unknown> => {
65
+ const result: Record<string, unknown> = {};
66
+ const url = new URL(request.url);
67
+ const seenKeys = new Set<string>();
68
+
69
+ for (const key of url.searchParams.keys()) {
70
+ if (seenKeys.has(key)) {
71
+ continue;
72
+ }
73
+ seenKeys.add(key);
74
+ const all = url.searchParams.getAll(key);
75
+ result[key] = all.length > 1 ? all : all[0];
76
+ }
77
+
78
+ return result;
79
+ };
80
+
81
+ const parseContentLength = (
82
+ contentLength: string | null | undefined
83
+ ): ParsedContentLength => {
84
+ if (contentLength === null || contentLength === undefined) {
85
+ return undefined;
86
+ }
87
+ if (!CONTENT_LENGTH_DECIMAL_PATTERN.test(contentLength)) {
88
+ return JSON_BODY_INVALID_CONTENT_LENGTH;
89
+ }
90
+ const size = Number(contentLength);
91
+ return Number.isSafeInteger(size) ? size : Number.MAX_SAFE_INTEGER;
92
+ };
93
+
94
+ const isEmptyBody = (request: Request): boolean => {
95
+ const contentLength = parseContentLength(
96
+ request.headers.get('Content-Length')
97
+ );
98
+ if (contentLength === JSON_BODY_INVALID_CONTENT_LENGTH) {
99
+ return false;
100
+ }
101
+ if (contentLength !== undefined) {
102
+ return contentLength === 0;
103
+ }
104
+ return request.headers.get('Content-Type') === null;
105
+ };
106
+
107
+ const resolveMaxJsonBodyBytes = (value: number | undefined): number => {
108
+ const maxJsonBodyBytes = value ?? DEFAULT_MAX_JSON_BODY_BYTES;
109
+
110
+ if (!Number.isFinite(maxJsonBodyBytes) || maxJsonBodyBytes < 1) {
111
+ throw new ValidationError(
112
+ 'maxJsonBodyBytes must be a positive finite number'
113
+ );
114
+ }
115
+
116
+ return maxJsonBodyBytes;
117
+ };
118
+
119
+ const hasOversizedContentLength = (
120
+ request: Request,
121
+ maxJsonBodyBytes: number
122
+ ): boolean => {
123
+ const contentLength = request.headers.get('Content-Length');
124
+ if (contentLength === null) {
125
+ return false;
126
+ }
127
+ const size = parseContentLength(contentLength);
128
+ if (size === JSON_BODY_INVALID_CONTENT_LENGTH) {
129
+ return false;
130
+ }
131
+ return size !== undefined && size > maxJsonBodyBytes;
132
+ };
133
+
134
+ const measureBodyTextBytes = (text: string): number => new Blob([text]).size;
135
+
136
+ const validateBodyText = (
137
+ text: string,
138
+ maxJsonBodyBytes: number
139
+ ): JsonBodyTextReadResult =>
140
+ measureBodyTextBytes(text) > maxJsonBodyBytes ? JSON_BODY_TOO_LARGE : text;
141
+
142
+ const cancelBodyReader = async (
143
+ reader: ReadableStreamDefaultReader<Uint8Array>,
144
+ reason?: unknown
145
+ ): Promise<void> => {
146
+ try {
147
+ await reader.cancel(reason);
148
+ } catch {
149
+ // The request is already being cancelled; preserve the surface-level
150
+ // cancelled response instead of replacing it with a reader cleanup error.
151
+ }
152
+ };
153
+
154
+ const assertRequestNotAborted = async (
155
+ request: Request,
156
+ reader: ReadableStreamDefaultReader<Uint8Array>
157
+ ): Promise<void> => {
158
+ if (!request.signal.aborted) {
159
+ return;
160
+ }
161
+ await cancelBodyReader(reader, request.signal.reason);
162
+ throw new CancelledError('Request aborted');
163
+ };
164
+
165
+ const readBodyText = async (
166
+ request: Request,
167
+ maxJsonBodyBytes: number
168
+ ): Promise<JsonBodyTextReadResult> => {
169
+ const { body } = request;
170
+ if (body === null) {
171
+ return '';
172
+ }
173
+
174
+ const reader = body.getReader();
175
+ const chunks: Uint8Array[] = [];
176
+ let totalBytes = 0;
177
+
178
+ try {
179
+ while (true) {
180
+ await assertRequestNotAborted(request, reader);
181
+ let read: Awaited<ReturnType<typeof reader.read>>;
182
+ try {
183
+ read = await reader.read();
184
+ } catch (error) {
185
+ await assertRequestNotAborted(request, reader);
186
+ throw error;
187
+ }
188
+ await assertRequestNotAborted(request, reader);
189
+ const { done, value } = read;
190
+ if (done) {
191
+ break;
192
+ }
193
+ if (value === undefined) {
194
+ continue;
195
+ }
196
+ totalBytes += value.byteLength;
197
+ if (totalBytes > maxJsonBodyBytes) {
198
+ await cancelBodyReader(reader);
199
+ return JSON_BODY_TOO_LARGE;
200
+ }
201
+ chunks.push(value);
202
+ }
203
+ } finally {
204
+ reader.releaseLock();
205
+ }
206
+
207
+ const bytes = new Uint8Array(totalBytes);
208
+ let offset = 0;
209
+ for (const chunk of chunks) {
210
+ bytes.set(chunk, offset);
211
+ offset += chunk.byteLength;
212
+ }
213
+
214
+ return new TextDecoder().decode(bytes);
215
+ };
216
+
217
+ const readJsonBody = async (
218
+ request: Request,
219
+ maxJsonBodyBytes: number
220
+ ): Promise<JsonBodyReadResult> => {
221
+ if (
222
+ parseContentLength(request.headers.get('Content-Length')) ===
223
+ JSON_BODY_INVALID_CONTENT_LENGTH
224
+ ) {
225
+ return JSON_BODY_INVALID_CONTENT_LENGTH;
226
+ }
227
+
228
+ if (hasOversizedContentLength(request, maxJsonBodyBytes)) {
229
+ return JSON_BODY_TOO_LARGE;
230
+ }
231
+
232
+ const text = await readBodyText(request, maxJsonBodyBytes);
233
+ if (text === JSON_BODY_TOO_LARGE) {
234
+ return JSON_BODY_TOO_LARGE;
235
+ }
236
+
237
+ const validated = validateBodyText(text, maxJsonBodyBytes);
238
+ if (validated === JSON_BODY_TOO_LARGE) {
239
+ return JSON_BODY_TOO_LARGE;
240
+ }
241
+
242
+ try {
243
+ return JSON.parse(validated) as JsonValue;
244
+ } catch {
245
+ return JSON_PARSE_ERROR;
246
+ }
247
+ };
248
+
249
+ const parseJsonBodyText = (text: string): JsonBodyReadResult => {
250
+ try {
251
+ return JSON.parse(text) as JsonValue;
252
+ } catch {
253
+ return JSON_PARSE_ERROR;
254
+ }
255
+ };
256
+
257
+ const parseWebhookBodyText = (
258
+ request: Request,
259
+ text: string
260
+ ): JsonBodyReadResult =>
261
+ isEmptyBody(request) || text.length === 0 ? {} : parseJsonBodyText(text);
262
+
263
+ const readWebhookBodyText = async (
264
+ request: Request,
265
+ maxJsonBodyBytes: number
266
+ ): Promise<
267
+ string | typeof JSON_BODY_INVALID_CONTENT_LENGTH | typeof JSON_BODY_TOO_LARGE
268
+ > => {
269
+ if (
270
+ parseContentLength(request.headers.get('Content-Length')) ===
271
+ JSON_BODY_INVALID_CONTENT_LENGTH
272
+ ) {
273
+ return JSON_BODY_INVALID_CONTENT_LENGTH;
274
+ }
275
+ if (hasOversizedContentLength(request, maxJsonBodyBytes)) {
276
+ return JSON_BODY_TOO_LARGE;
277
+ }
278
+ return await readBodyText(request, maxJsonBodyBytes);
279
+ };
280
+
281
+ const readInput = async (
282
+ request: Request,
283
+ inputSource: 'body' | 'query',
284
+ options: RuntimeOptions
285
+ ): Promise<InputReadResult> => {
286
+ if (inputSource === 'query') {
287
+ return parseQueryParams(request);
288
+ }
289
+ if (isEmptyBody(request)) {
290
+ return {};
291
+ }
292
+ return await readJsonBody(request, options.maxJsonBodyBytes);
293
+ };
294
+
295
+ const json = (body: Record<string, unknown>, status: number): Response =>
296
+ Response.json(body, { status });
297
+
298
+ const mapErrorResponse = (error: Error): Response => {
299
+ const projection = projectPublicSurfaceError('http', error);
300
+ return json(
301
+ {
302
+ error: {
303
+ category: projection.category,
304
+ code: projection.name,
305
+ message: projection.message,
306
+ },
307
+ },
308
+ projection.code
309
+ );
310
+ };
311
+
312
+ const sanitizeDiagnosticLabelValue = (value: string): string =>
313
+ value
314
+ .replace(LOG_UNSAFE_LABEL_CHARACTERS, '_')
315
+ .slice(0, MAX_DIAGNOSTIC_LABEL_VALUE_LENGTH);
316
+
317
+ const reportInternalDiagnostics = (error: Error, request: Request): void => {
318
+ if (isTrailsError(error)) {
319
+ return;
320
+ }
321
+
322
+ const requestId = request.headers.get('X-Request-ID') ?? undefined;
323
+ const safeRequestId =
324
+ requestId === undefined
325
+ ? undefined
326
+ : sanitizeDiagnosticLabelValue(requestId);
327
+ const label =
328
+ safeRequestId === undefined
329
+ ? '[ontrails:http/fetch] Internal error'
330
+ : `[ontrails:http/fetch] Internal error (${safeRequestId})`;
331
+ console.error(label, projectErrorDiagnostics(error));
332
+ };
333
+
334
+ interface ResultLike {
335
+ readonly error?: Error | undefined;
336
+ isOk(): boolean;
337
+ readonly value?: unknown;
338
+ }
339
+
340
+ const mapResultToResponse = (
341
+ result: ResultLike,
342
+ request: Request
343
+ ): Response => {
344
+ if (result.isOk()) {
345
+ return json({ data: result.value }, 200);
346
+ }
347
+ const error = result.error ?? new Error('Unknown error');
348
+ reportInternalDiagnostics(error, request);
349
+ return mapErrorResponse(error);
350
+ };
351
+
352
+ const handleCaughtError = (error: unknown, request: Request): Response => {
353
+ const err = error instanceof Error ? error : new Error(String(error));
354
+ reportInternalDiagnostics(err, request);
355
+ return mapErrorResponse(err);
356
+ };
357
+
358
+ const invalidJsonResponse = (): Response =>
359
+ json(
360
+ {
361
+ error: {
362
+ category: 'validation',
363
+ code: 'ValidationError',
364
+ message: 'Invalid JSON in request body',
365
+ },
366
+ },
367
+ 400
368
+ );
369
+
370
+ const invalidContentLengthResponse = (): Response =>
371
+ json(
372
+ {
373
+ error: {
374
+ category: 'validation',
375
+ code: 'ValidationError',
376
+ message: 'Invalid Content-Length header',
377
+ },
378
+ },
379
+ 400
380
+ );
381
+
382
+ const oversizedJsonBodyResponse = (options: RuntimeOptions): Response =>
383
+ json(
384
+ {
385
+ error: {
386
+ category: 'validation',
387
+ code: 'ValidationError',
388
+ message: `JSON request body exceeds ${options.maxJsonBodyBytes} bytes`,
389
+ },
390
+ },
391
+ 413
392
+ );
393
+
394
+ const notFoundResponse = (request: Request): Response => {
395
+ const path = new URL(request.url).pathname;
396
+ return mapErrorResponse(new NotFoundError(`HTTP route not found: ${path}`));
397
+ };
398
+
399
+ const collectHeaders = (request: Request): Record<string, string> => {
400
+ const headers: Record<string, string> = {};
401
+ for (const [key, value] of request.headers) {
402
+ headers[key] = value;
403
+ }
404
+ return headers;
405
+ };
406
+
407
+ const createWebhookVerifyRequest = (
408
+ request: Request,
409
+ body: string
410
+ ): {
411
+ readonly body: string;
412
+ readonly headers: Record<string, string>;
413
+ readonly method: string;
414
+ readonly path: string;
415
+ } => ({
416
+ body,
417
+ headers: collectHeaders(request),
418
+ method: request.method,
419
+ path: new URL(request.url).pathname,
420
+ });
421
+
422
+ const recordInvalidWebhook = async (
423
+ route: HttpRouteDefinition,
424
+ errorCategory = 'validation'
425
+ ): Promise<void> => {
426
+ await route.recordWebhookInvalid?.(errorCategory);
427
+ };
428
+
429
+ const errorCategoryForWebhookFailure = (error: Error | undefined): string =>
430
+ error !== undefined && isTrailsError(error) ? error.category : 'internal';
431
+
432
+ const handleWebhookRoute = async (
433
+ route: HttpRouteDefinition,
434
+ options: RuntimeOptions,
435
+ request: Request
436
+ ): Promise<Response> => {
437
+ const rawBody = await readWebhookBodyText(request, options.maxJsonBodyBytes);
438
+
439
+ if (rawBody === JSON_BODY_INVALID_CONTENT_LENGTH) {
440
+ await recordInvalidWebhook(route);
441
+ return invalidContentLengthResponse();
442
+ }
443
+
444
+ if (rawBody === JSON_BODY_TOO_LARGE) {
445
+ await recordInvalidWebhook(route);
446
+ return oversizedJsonBodyResponse(options);
447
+ }
448
+
449
+ const verified = await route.verifyWebhook?.(
450
+ createWebhookVerifyRequest(request, rawBody)
451
+ );
452
+ if (verified?.isErr()) {
453
+ await recordInvalidWebhook(
454
+ route,
455
+ errorCategoryForWebhookFailure(verified.error)
456
+ );
457
+ return mapResultToResponse(verified, request);
458
+ }
459
+
460
+ const jsonBody = parseWebhookBodyText(request, rawBody);
461
+ if (jsonBody === JSON_PARSE_ERROR) {
462
+ await recordInvalidWebhook(route);
463
+ return invalidJsonResponse();
464
+ }
465
+
466
+ const parsed = route.parseWebhookInput?.(jsonBody);
467
+ if (parsed === undefined) {
468
+ await recordInvalidWebhook(route, 'internal');
469
+ return mapResultToResponse(
470
+ {
471
+ error: new Error('Webhook route is missing parse handler'),
472
+ isOk: () => false,
473
+ },
474
+ request
475
+ );
476
+ }
477
+ if (parsed.isErr()) {
478
+ await recordInvalidWebhook(route);
479
+ return mapResultToResponse(parsed, request);
480
+ }
481
+
482
+ const requestId = request.headers.get('X-Request-ID') ?? undefined;
483
+ const result = await route.execute(parsed.value, requestId, request.signal, {
484
+ headers: request.headers,
485
+ });
486
+ return mapResultToResponse(result, request);
487
+ };
488
+
489
+ /**
490
+ * Build a Web Fetch handler for one framework-agnostic HTTP route.
491
+ *
492
+ * @example
493
+ * ```ts
494
+ * import { deriveHttpRoutes } from '@ontrails/http';
495
+ * import { createRouteHandler } from '@ontrails/http/fetch';
496
+ *
497
+ * const routes = deriveHttpRoutes(graph, { basePath: '/api' });
498
+ * if (routes.isErr()) throw routes.error;
499
+ *
500
+ * const route = routes.value[0];
501
+ * if (!route) throw new Error('No routes derived');
502
+ *
503
+ * const handle = createRouteHandler(route);
504
+ * const response = await handle(new Request('https://example.test/api/hello'));
505
+ * ```
506
+ */
507
+ export const createRouteHandler = (
508
+ route: HttpRouteDefinition,
509
+ options: CreateRouteHandlerOptions = {}
510
+ ): ((request: Request) => Promise<Response>) => {
511
+ const runtimeOptions = {
512
+ maxJsonBodyBytes: resolveMaxJsonBodyBytes(options.maxJsonBodyBytes),
513
+ };
514
+
515
+ return async (request) => {
516
+ try {
517
+ if (route.inputSource === 'webhook') {
518
+ return await handleWebhookRoute(route, runtimeOptions, request);
519
+ }
520
+
521
+ const rawInput = await readInput(
522
+ request,
523
+ route.inputSource,
524
+ runtimeOptions
525
+ );
526
+
527
+ if (rawInput === JSON_PARSE_ERROR) {
528
+ return invalidJsonResponse();
529
+ }
530
+
531
+ if (rawInput === JSON_BODY_INVALID_CONTENT_LENGTH) {
532
+ return invalidContentLengthResponse();
533
+ }
534
+
535
+ if (rawInput === JSON_BODY_TOO_LARGE) {
536
+ return oversizedJsonBodyResponse(runtimeOptions);
537
+ }
538
+
539
+ const requestId = request.headers.get('X-Request-ID') ?? undefined;
540
+ const result = await route.execute(rawInput, requestId, request.signal, {
541
+ headers: request.headers,
542
+ });
543
+ return mapResultToResponse(result, request);
544
+ } catch (error: unknown) {
545
+ return handleCaughtError(error, request);
546
+ }
547
+ };
548
+ };
549
+
550
+ /**
551
+ * Build a Web Fetch dispatcher for all HTTP routes in a topo.
552
+ *
553
+ * @example
554
+ * ```ts
555
+ * import { createFetchHandler } from '@ontrails/http/fetch';
556
+ *
557
+ * const fetch = createFetchHandler(graph, { basePath: '/api' });
558
+ * const response = await fetch(
559
+ * new Request('https://example.test/api/hello?name=Matt')
560
+ * );
561
+ * ```
562
+ */
563
+ export const createFetchHandler = (
564
+ graph: Topo,
565
+ options: CreateFetchHandlerOptions = {}
566
+ ): ((request: Request) => Promise<Response>) => {
567
+ const routesResult = deriveHttpRoutes(graph, {
568
+ basePath: options.basePath,
569
+ configValues: options.configValues,
570
+ createContext: options.createContext,
571
+ exclude: options.exclude,
572
+ include: options.include,
573
+ intent: options.intent,
574
+ layers: options.layers,
575
+ resolvePermit: options.resolvePermit,
576
+ resources: options.resources,
577
+ validate: options.validate,
578
+ });
579
+
580
+ if (routesResult.isErr()) {
581
+ throw routesResult.error;
582
+ }
583
+
584
+ const routeHandlers = new Map<
585
+ string,
586
+ (request: Request) => Promise<Response>
587
+ >();
588
+ for (const route of routesResult.value) {
589
+ routeHandlers.set(
590
+ routeKey(route.method, route.path),
591
+ createRouteHandler(route, {
592
+ maxJsonBodyBytes: options.maxJsonBodyBytes,
593
+ })
594
+ );
595
+ }
596
+
597
+ return async (request) => {
598
+ const path = new URL(request.url).pathname;
599
+ const handler = routeHandlers.get(routeKey(request.method, path));
600
+ return handler === undefined ? notFoundResponse(request) : handler(request);
601
+ };
602
+ };
package/src/index.ts CHANGED
@@ -16,6 +16,12 @@ export {
16
16
  httpMethodByIntent,
17
17
  } from './method.js';
18
18
  export type { HttpMethod, HttpOperationMethod, InputSource } from './method.js';
19
+ export {
20
+ createFetchHandler,
21
+ createRouteHandler,
22
+ type CreateFetchHandlerOptions,
23
+ type CreateRouteHandlerOptions,
24
+ } from './fetch.js';
19
25
 
20
26
  // OpenAPI
21
27
  export { deriveOpenApiSpec } from './openapi.js';
package/src/method.ts CHANGED
@@ -6,6 +6,17 @@ export type HttpOperationMethod = Lowercase<HttpMethod>;
6
6
 
7
7
  export type InputSource = 'query' | 'body' | 'webhook';
8
8
 
9
+ /**
10
+ * Owner table for projecting trail intent onto HTTP methods.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { httpMethodByIntent } from '@ontrails/http';
15
+ *
16
+ * const readMethod = httpMethodByIntent.read;
17
+ * // readMethod === 'GET'
18
+ * ```
19
+ */
9
20
  export const httpMethodByIntent = {
10
21
  destroy: 'DELETE',
11
22
  read: 'GET',