@ontrails/http 1.0.0-beta.16 → 1.0.0-beta.18

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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # @ontrails/http
2
2
 
3
+ ## 1.0.0-beta.18
4
+
5
+ ### Minor Changes
6
+
7
+ - c0b2948: Add `@ontrails/http/fetch`, a shared Web Fetch request/response kernel for HTTP
8
+ surface materializers.
9
+ - fc3219c: Add `@ontrails/http/bun`, a Bun-native HTTP surface materializer backed by the
10
+ shared Web Fetch kernel.
11
+
12
+ ### Patch Changes
13
+
14
+ - bc2d327: Close HTTP package documentation around the shared `@ontrails/http/fetch` kernel, Bun-native `@ontrails/http/bun` subpath, and Hono adapter boundary before versioning.
15
+ - @ontrails/core@1.0.0-beta.18
16
+
17
+ ## 1.0.0-beta.17
18
+
19
+ ### Patch Changes
20
+
21
+ - 61497c5: Add v1-minimum public API examples for shipped surface entrypoints.
22
+ - Updated dependencies [3dc8254]
23
+ - @ontrails/core@1.0.0-beta.17
24
+
3
25
  ## 1.0.0-beta.16
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @ontrails/http
2
2
 
3
- Framework-agnostic HTTP route derivation for Trails. Pair this package with `@ontrails/hono` when you want the Hono surface adapter.
3
+ Framework-agnostic HTTP route derivation and Web Fetch request handling for Trails. Pair this package with `@ontrails/hono` when you want Hono portability, or use `@ontrails/http/bun` when you want Bun-native serving without a third-party framework.
4
4
 
5
5
  ## Usage
6
6
 
@@ -22,6 +22,36 @@ await surface(graph, { port: 3000 });
22
22
 
23
23
  This starts a Hono-based HTTP server. The `greet` trail becomes `GET /greet?name=...` because its `intent` is `'read'`.
24
24
 
25
+ For Bun-native HTTP without Hono, use the Bun runtime materializer subpath:
26
+
27
+ ```typescript
28
+ import { surface } from '@ontrails/http/bun';
29
+
30
+ await surface(graph, { port: 3000 });
31
+ ```
32
+
33
+ `@ontrails/http/bun` uses Bun's native `Bun.serve({ routes })` fast path and
34
+ keeps the shared Web Fetch handler as the fallback. It requires Bun `>=1.2.3`
35
+ and does not add a third-party runtime dependency.
36
+
37
+ ## Projection and materialization
38
+
39
+ The HTTP package follows the surface API naming split:
40
+
41
+ - `derive*` exports are pure projections from the topo. Use
42
+ `deriveHttpRoutes()` for route definitions and `deriveOpenApiSpec()` for the
43
+ OpenAPI contract.
44
+ - `create*` exports materialize runtime objects without opening a network
45
+ boundary. `@ontrails/http/fetch` exports `createRouteHandler()` for one
46
+ route and `createFetchHandler()` for a full topo dispatcher.
47
+ - `surface()` opens the runtime boundary. `@ontrails/hono` opens a Hono server;
48
+ `@ontrails/http/bun` opens Bun's native HTTP server.
49
+
50
+ The shared `@ontrails/http/fetch` kernel owns query/body parsing,
51
+ content-length validation, public error projection, diagnostics, request IDs,
52
+ headers, abort propagation, and webhook verification/parsing behavior. Hono and
53
+ Bun both consume that kernel so route semantics stay aligned.
54
+
25
55
  For more control, build the routes yourself:
26
56
 
27
57
  ```typescript
@@ -53,6 +83,8 @@ contracts used by `deriveHttpRoutes()`.
53
83
  | --- | --- |
54
84
  | `deriveHttpRoutes(graph, options?)` | Build framework-agnostic route definitions from a topo |
55
85
  | `deriveOpenApiSpec(graph, options?)` | Generate an OpenAPI 3.1 document for the HTTP surface |
86
+ | `@ontrails/http/fetch` | Shared Web Fetch `createRouteHandler()` and `createFetchHandler()` kernel |
87
+ | `@ontrails/http/bun` | Bun-native `createApp()` and `surface()` materializer |
56
88
 
57
89
  ## Route derivation
58
90
 
@@ -114,6 +146,8 @@ values into arrays.
114
146
 
115
147
  ```bash
116
148
  bun add @ontrails/http @ontrails/hono
149
+ # or, for Bun-native serving:
150
+ bun add @ontrails/http
117
151
  ```
118
152
 
119
153
  ## Migration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/http",
3
- "version": "1.0.0-beta.16",
3
+ "version": "1.0.0-beta.18",
4
4
  "files": [
5
5
  "src/**/*.ts",
6
6
  "!src/**/__tests__/**",
@@ -12,6 +12,8 @@
12
12
  "type": "module",
13
13
  "exports": {
14
14
  ".": "./src/index.ts",
15
+ "./bun": "./src/bun.ts",
16
+ "./fetch": "./src/fetch.ts",
15
17
  "./package.json": "./package.json"
16
18
  },
17
19
  "scripts": {
@@ -22,7 +24,7 @@
22
24
  "clean": "rm -rf dist *.tsbuildinfo"
23
25
  },
24
26
  "dependencies": {
25
- "@ontrails/core": "^1.0.0-beta.15"
27
+ "@ontrails/core": "^1.0.0-beta.17"
26
28
  },
27
29
  "peerDependencies": {
28
30
  "zod": "^4.3.5"
package/src/build.ts CHANGED
@@ -1279,6 +1279,18 @@ const accumulateRoutes = (
1279
1279
  *
1280
1280
  * Returns `Result.err(ValidationError)` if two trails derive the same
1281
1281
  * (method, path) pair. Returns `Result.ok(routes)` on success.
1282
+ *
1283
+ * @example
1284
+ * ```ts
1285
+ * import { deriveHttpRoutes } from '@ontrails/http';
1286
+ *
1287
+ * const routes = deriveHttpRoutes(graph, { basePath: '/api' });
1288
+ * if (routes.isErr()) throw routes.error;
1289
+ *
1290
+ * for (const route of routes.value) {
1291
+ * console.log(`${route.method} ${route.path}`);
1292
+ * }
1293
+ * ```
1282
1294
  */
1283
1295
  export const deriveHttpRoutes = (
1284
1296
  graph: Topo,
package/src/bun.ts ADDED
@@ -0,0 +1,270 @@
1
+ import {
2
+ InternalError,
3
+ NotFoundError,
4
+ Result,
5
+ projectPublicSurfaceError,
6
+ trail,
7
+ } from '@ontrails/core';
8
+ import type {
9
+ BaseSurfaceOptions,
10
+ Layer,
11
+ ResourceOverrideMap,
12
+ Topo,
13
+ Trail,
14
+ TrailContextInit,
15
+ } from '@ontrails/core';
16
+ import { z } from 'zod';
17
+
18
+ import { deriveHttpRoutes } from './build.js';
19
+ import type {
20
+ HttpMethod,
21
+ HttpRouteDefinition,
22
+ ResolveHttpPermit,
23
+ } from './build.js';
24
+ import { createRouteHandler } from './fetch.js';
25
+ import type { CreateRouteHandlerOptions } from './fetch.js';
26
+
27
+ export interface CreateAppOptions extends BaseSurfaceOptions {
28
+ readonly basePath?: string | undefined;
29
+ readonly createContext?:
30
+ | (() => TrailContextInit | Promise<TrailContextInit>)
31
+ | undefined;
32
+ readonly hostname?: string | undefined;
33
+ readonly layers?: readonly Layer[] | undefined;
34
+ /** Maximum JSON request body size in bytes. Defaults to 1 MiB. */
35
+ readonly maxJsonBodyBytes?: number | undefined;
36
+ readonly port?: number | undefined;
37
+ readonly resources?: ResourceOverrideMap | undefined;
38
+ readonly resolvePermit?: ResolveHttpPermit | undefined;
39
+ }
40
+
41
+ export interface SurfaceHttpResult {
42
+ readonly close: () => Promise<void>;
43
+ readonly url: string;
44
+ }
45
+
46
+ type RouteHandler = (request: Request) => Promise<Response>;
47
+
48
+ type BunRouteMethod = HttpMethod | 'HEAD';
49
+ type BunRouteRecord = Record<
50
+ string,
51
+ Partial<Record<BunRouteMethod, RouteHandler>>
52
+ >;
53
+
54
+ export interface BunHttpApp {
55
+ readonly fetch: RouteHandler;
56
+ readonly onError: (error: Error) => Promise<Response>;
57
+ readonly routes: BunRouteRecord;
58
+ }
59
+
60
+ const json = (body: Record<string, unknown>, status: number): Response =>
61
+ Response.json(body, { status });
62
+
63
+ const mapErrorResponse = (error: Error): Response => {
64
+ const projection = projectPublicSurfaceError('http', error);
65
+ return json(
66
+ {
67
+ error: {
68
+ category: projection.category,
69
+ code: projection.name,
70
+ message: projection.message,
71
+ },
72
+ },
73
+ projection.code
74
+ );
75
+ };
76
+
77
+ const notFoundResponse = (request: Request): Response => {
78
+ const path = new URL(request.url).pathname;
79
+ return mapErrorResponse(new NotFoundError(`HTTP route not found: ${path}`));
80
+ };
81
+
82
+ const methodNotAllowedResponse = (
83
+ request: Request,
84
+ route: Partial<Record<BunRouteMethod, RouteHandler>>
85
+ ): Response => {
86
+ const path = new URL(request.url).pathname;
87
+ return Response.json(
88
+ {
89
+ error: {
90
+ category: 'validation',
91
+ code: 'MethodNotAllowed',
92
+ message: `HTTP method not allowed: ${request.method.toUpperCase()} ${path}`,
93
+ },
94
+ },
95
+ {
96
+ headers: { Allow: Object.keys(route).toSorted().join(', ') },
97
+ status: 405,
98
+ }
99
+ );
100
+ };
101
+
102
+ const bodylessHeadResponse = (response: Response): Response =>
103
+ new Response(null, {
104
+ headers: response.headers,
105
+ status: response.status,
106
+ statusText: response.statusText,
107
+ });
108
+
109
+ const caughtErrors = new Map<string, Error>();
110
+ const caughtErrorInput = z.object({ errorId: z.string() });
111
+ const caughtErrorTrail = trail('__ontrails.http.bun.error', {
112
+ blaze: () =>
113
+ Result.err(new InternalError('Bun error fallback executed directly')),
114
+ input: caughtErrorInput,
115
+ intent: 'read',
116
+ output: z.object({}),
117
+ }) as Trail<unknown, unknown, unknown>;
118
+
119
+ const caughtErrorRoute: HttpRouteDefinition = {
120
+ execute: async (input) => {
121
+ const parsed = caughtErrorInput.safeParse(input);
122
+ if (!parsed.success) {
123
+ return Result.err(
124
+ new InternalError('Bun error fallback missing error id')
125
+ );
126
+ }
127
+ const error =
128
+ caughtErrors.get(parsed.data.errorId) ??
129
+ new Error('Bun error fallback missing caught error');
130
+ return Result.err(error);
131
+ },
132
+ inputSource: 'query',
133
+ method: 'GET',
134
+ path: '/__ontrails/http/bun/error',
135
+ trail: caughtErrorTrail,
136
+ trailId: '__ontrails.http.bun.error',
137
+ };
138
+ const caughtErrorHandler = createRouteHandler(caughtErrorRoute);
139
+
140
+ const materializeCaughtErrorRequest = (errorId: string): Request => {
141
+ const url = new URL('/__ontrails/http/bun/error', 'http://localhost');
142
+ url.searchParams.set('errorId', errorId);
143
+ return new Request(url);
144
+ };
145
+
146
+ const deriveOptions = (options: CreateAppOptions) => ({
147
+ basePath: options.basePath,
148
+ configValues: options.configValues,
149
+ createContext: options.createContext,
150
+ exclude: options.exclude,
151
+ include: options.include,
152
+ intent: options.intent,
153
+ layers: options.layers,
154
+ resolvePermit: options.resolvePermit,
155
+ resources: options.resources,
156
+ validate: options.validate,
157
+ });
158
+
159
+ const routeHandlerOptions = (
160
+ options: CreateAppOptions
161
+ ): CreateRouteHandlerOptions => ({
162
+ maxJsonBodyBytes: options.maxJsonBodyBytes,
163
+ });
164
+
165
+ const registerRoute = (
166
+ routes: BunRouteRecord,
167
+ route: HttpRouteDefinition,
168
+ options: CreateRouteHandlerOptions
169
+ ): void => {
170
+ const methods = routes[route.path] ?? {};
171
+ const handler = createRouteHandler(route, options);
172
+ methods[route.method] = handler;
173
+ if (route.method === 'GET') {
174
+ methods.HEAD = async (request) => {
175
+ const response = await handler(request);
176
+ return bodylessHeadResponse(response);
177
+ };
178
+ }
179
+ routes[route.path] = methods;
180
+ };
181
+
182
+ const routeForRequest = (
183
+ routes: BunRouteRecord,
184
+ request: Request
185
+ ): Partial<Record<BunRouteMethod, RouteHandler>> | undefined => {
186
+ const path = new URL(request.url).pathname;
187
+ return routes[path];
188
+ };
189
+
190
+ /**
191
+ * Build Bun-compatible HTTP route handlers from a topo.
192
+ *
193
+ * @remarks This materializes `deriveHttpRoutes` onto Bun's native `routes`
194
+ * table while preserving `fetch` as the fallback path for unmatched requests.
195
+ */
196
+ export const createApp = (
197
+ graph: Topo,
198
+ options: CreateAppOptions = {}
199
+ ): BunHttpApp => {
200
+ const routesResult = deriveHttpRoutes(graph, deriveOptions(options));
201
+
202
+ if (routesResult.isErr()) {
203
+ throw routesResult.error;
204
+ }
205
+
206
+ const handlerOptions = routeHandlerOptions(options);
207
+ const routes: BunRouteRecord = {};
208
+ for (const route of routesResult.value) {
209
+ registerRoute(routes, route, handlerOptions);
210
+ }
211
+
212
+ return {
213
+ fetch: async (request) => {
214
+ const method = request.method.toUpperCase() as BunRouteMethod;
215
+ const route = routeForRequest(routes, request);
216
+ if (route === undefined) {
217
+ const response = notFoundResponse(request);
218
+ return method === 'HEAD' ? bodylessHeadResponse(response) : response;
219
+ }
220
+ const methodHandler = route[method];
221
+ const response =
222
+ methodHandler === undefined
223
+ ? methodNotAllowedResponse(request, route)
224
+ : await methodHandler(request);
225
+ return method === 'HEAD' ? bodylessHeadResponse(response) : response;
226
+ },
227
+ onError: async (error) => {
228
+ const errorId = crypto.randomUUID();
229
+ caughtErrors.set(errorId, error);
230
+ try {
231
+ return await caughtErrorHandler(materializeCaughtErrorRequest(errorId));
232
+ } finally {
233
+ caughtErrors.delete(errorId);
234
+ }
235
+ },
236
+ routes,
237
+ };
238
+ };
239
+
240
+ const startServer = (
241
+ app: BunHttpApp,
242
+ options: CreateAppOptions
243
+ ): SurfaceHttpResult => {
244
+ const server = Bun.serve({
245
+ error: app.onError,
246
+ fetch: app.fetch,
247
+ hostname: options.hostname ?? '0.0.0.0',
248
+ port: options.port ?? 3000,
249
+ routes: app.routes,
250
+ });
251
+
252
+ return {
253
+ close: async () => {
254
+ await server.stop(true);
255
+ },
256
+ url: String(server.url),
257
+ };
258
+ };
259
+
260
+ /**
261
+ * Build a Bun-native HTTP app from a topo and start serving it.
262
+ */
263
+ export const surface = async (
264
+ graph: Topo,
265
+ options: CreateAppOptions = {}
266
+ ): Promise<SurfaceHttpResult> => {
267
+ // oxlint-disable-next-line require-await -- async ensures createApp() throws become rejected promises, not uncaught exceptions
268
+ const app = createApp(graph, options);
269
+ return startServer(app, options);
270
+ };
package/src/fetch.ts ADDED
@@ -0,0 +1,577 @@
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
+ export const createRouteHandler = (
493
+ route: HttpRouteDefinition,
494
+ options: CreateRouteHandlerOptions = {}
495
+ ): ((request: Request) => Promise<Response>) => {
496
+ const runtimeOptions = {
497
+ maxJsonBodyBytes: resolveMaxJsonBodyBytes(options.maxJsonBodyBytes),
498
+ };
499
+
500
+ return async (request) => {
501
+ try {
502
+ if (route.inputSource === 'webhook') {
503
+ return await handleWebhookRoute(route, runtimeOptions, request);
504
+ }
505
+
506
+ const rawInput = await readInput(
507
+ request,
508
+ route.inputSource,
509
+ runtimeOptions
510
+ );
511
+
512
+ if (rawInput === JSON_PARSE_ERROR) {
513
+ return invalidJsonResponse();
514
+ }
515
+
516
+ if (rawInput === JSON_BODY_INVALID_CONTENT_LENGTH) {
517
+ return invalidContentLengthResponse();
518
+ }
519
+
520
+ if (rawInput === JSON_BODY_TOO_LARGE) {
521
+ return oversizedJsonBodyResponse(runtimeOptions);
522
+ }
523
+
524
+ const requestId = request.headers.get('X-Request-ID') ?? undefined;
525
+ const result = await route.execute(rawInput, requestId, request.signal, {
526
+ headers: request.headers,
527
+ });
528
+ return mapResultToResponse(result, request);
529
+ } catch (error: unknown) {
530
+ return handleCaughtError(error, request);
531
+ }
532
+ };
533
+ };
534
+
535
+ /**
536
+ * Build a Web Fetch dispatcher for all HTTP routes in a topo.
537
+ */
538
+ export const createFetchHandler = (
539
+ graph: Topo,
540
+ options: CreateFetchHandlerOptions = {}
541
+ ): ((request: Request) => Promise<Response>) => {
542
+ const routesResult = deriveHttpRoutes(graph, {
543
+ basePath: options.basePath,
544
+ configValues: options.configValues,
545
+ createContext: options.createContext,
546
+ exclude: options.exclude,
547
+ include: options.include,
548
+ intent: options.intent,
549
+ layers: options.layers,
550
+ resolvePermit: options.resolvePermit,
551
+ resources: options.resources,
552
+ validate: options.validate,
553
+ });
554
+
555
+ if (routesResult.isErr()) {
556
+ throw routesResult.error;
557
+ }
558
+
559
+ const routeHandlers = new Map<
560
+ string,
561
+ (request: Request) => Promise<Response>
562
+ >();
563
+ for (const route of routesResult.value) {
564
+ routeHandlers.set(
565
+ routeKey(route.method, route.path),
566
+ createRouteHandler(route, {
567
+ maxJsonBodyBytes: options.maxJsonBodyBytes,
568
+ })
569
+ );
570
+ }
571
+
572
+ return async (request) => {
573
+ const path = new URL(request.url).pathname;
574
+ const handler = routeHandlers.get(routeKey(request.method, path));
575
+ return handler === undefined ? notFoundResponse(request) : handler(request);
576
+ };
577
+ };
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
@@ -12,13 +12,46 @@ export const httpMethodByIntent = {
12
12
  write: 'POST',
13
13
  } as const satisfies Record<Intent, HttpMethod>;
14
14
 
15
+ /**
16
+ * Derive the HTTP method used for a trail intent.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import { deriveHttpMethod } from '@ontrails/http';
21
+ *
22
+ * const method = deriveHttpMethod('read');
23
+ * // method === 'GET'
24
+ * ```
25
+ */
15
26
  export const deriveHttpMethod = (intent: Intent): HttpMethod =>
16
27
  (httpMethodByIntent as Partial<Record<string, HttpMethod>>)[intent] ?? 'POST';
17
28
 
29
+ /**
30
+ * Derive the lowercase OpenAPI operation method for a trail intent.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * import { deriveHttpOperationMethod } from '@ontrails/http';
35
+ *
36
+ * const operationMethod = deriveHttpOperationMethod('destroy');
37
+ * // operationMethod === 'delete'
38
+ * ```
39
+ */
18
40
  export const deriveHttpOperationMethod = (
19
41
  intent: Intent
20
42
  ): HttpOperationMethod =>
21
43
  deriveHttpMethod(intent).toLowerCase() as HttpOperationMethod;
22
44
 
45
+ /**
46
+ * Derive where request input should be read from for an HTTP method.
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * import { deriveHttpInputSource } from '@ontrails/http';
51
+ *
52
+ * const source = deriveHttpInputSource('GET');
53
+ * // source === 'query'
54
+ * ```
55
+ */
23
56
  export const deriveHttpInputSource = (method: HttpMethod): InputSource =>
24
57
  method === 'GET' ? 'query' : 'body';
package/src/openapi.ts CHANGED
@@ -331,6 +331,16 @@ const buildInfo = (
331
331
  * Iterates all trails, skipping signals and internal trails, and produces
332
332
  * paths, operations, parameters, and response schemas derived from
333
333
  * the trail contract.
334
+ *
335
+ * @example
336
+ * ```ts
337
+ * import { deriveOpenApiSpec } from '@ontrails/http';
338
+ *
339
+ * const spec = deriveOpenApiSpec(graph, {
340
+ * basePath: '/api',
341
+ * title: 'Demo API',
342
+ * });
343
+ * ```
334
344
  */
335
345
  export const deriveOpenApiSpec = (
336
346
  graph: Topo,