@ontrails/hono 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,29 @@
1
1
  # @ontrails/hono
2
2
 
3
+ ## 1.0.0-beta.18
4
+
5
+ ### Patch Changes
6
+
7
+ - bc2d327: Close HTTP package documentation around the shared `@ontrails/http/fetch` kernel, Bun-native `@ontrails/http/bun` subpath, and Hono adapter boundary before versioning.
8
+ - 20cb72c: Refactor Hono route handling to delegate Web request parsing, response
9
+ projection, diagnostics, permits, and webhook handling through
10
+ `@ontrails/http/fetch`.
11
+ - Updated dependencies [c0b2948]
12
+ - Updated dependencies [fc3219c]
13
+ - Updated dependencies [bc2d327]
14
+ - @ontrails/http@1.0.0-beta.18
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
+ - Updated dependencies [61497c5]
24
+ - @ontrails/core@1.0.0-beta.17
25
+ - @ontrails/http@1.0.0-beta.17
26
+
3
27
  ## 1.0.0-beta.16
4
28
 
5
29
  ### Patch Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @ontrails/hono
2
2
 
3
- Hono surface adapter for Trails. Use this package when you want to serve a topo over HTTP with Hono while keeping `@ontrails/http` focused on framework-agnostic route building.
3
+ Hono surface adapter for Trails. Use this package when you want to serve a topo over HTTP with Hono while keeping `@ontrails/http` focused on framework-agnostic route building and the shared Web Fetch kernel.
4
4
 
5
5
  ## Usage
6
6
 
@@ -32,7 +32,7 @@ redacted diagnostic projection is written to server diagnostics. `TrailsError`
32
32
  responses keep their taxonomy category and class name but redact sensitive
33
33
  message fragments before writing the public body.
34
34
 
35
- For custom HTTP integrations or route inspection, keep using `deriveHttpRoutes()` from `@ontrails/http`.
35
+ For custom HTTP integrations or route inspection, keep using `deriveHttpRoutes()` from `@ontrails/http`. For a framework-neutral runtime handler, use `createRouteHandler()` or `createFetchHandler()` from `@ontrails/http/fetch`. For Bun-native serving without Hono, use `@ontrails/http/bun`.
36
36
 
37
37
  ## Installation
38
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/hono",
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__/**",
@@ -22,11 +22,11 @@
22
22
  "clean": "rm -rf dist *.tsbuildinfo"
23
23
  },
24
24
  "dependencies": {
25
- "@ontrails/core": "^1.0.0-beta.15",
25
+ "@ontrails/core": "^1.0.0-beta.17",
26
26
  "hono": "^4.7.0"
27
27
  },
28
28
  "peerDependencies": {
29
- "@ontrails/http": "^1.0.0-beta.15",
29
+ "@ontrails/http": "^1.0.0-beta.17",
30
30
  "zod": "^4.3.5"
31
31
  }
32
32
  }
@@ -0,0 +1,65 @@
1
+ import { InternalError, Result, trail } from '@ontrails/core';
2
+ import type { Trail } from '@ontrails/core';
3
+ import { createRouteHandler } from '@ontrails/http';
4
+ import type { HttpRouteDefinition } from '@ontrails/http';
5
+ import { z } from 'zod';
6
+
7
+ const caughtErrors = new Map<string, Error>();
8
+ const caughtErrorInput = z.object({ errorId: z.string() });
9
+ const caughtErrorTrail = trail('__ontrails.hono.error', {
10
+ blaze: () =>
11
+ Result.err(new InternalError('Hono error fallback executed directly')),
12
+ input: caughtErrorInput,
13
+ intent: 'read',
14
+ output: z.object({}),
15
+ }) as Trail<unknown, unknown, unknown>;
16
+
17
+ const caughtErrorRoute: HttpRouteDefinition = {
18
+ execute: async (input) => {
19
+ const parsed = caughtErrorInput.safeParse(input);
20
+ if (!parsed.success) {
21
+ return Result.err(
22
+ new InternalError('Hono error fallback missing error id')
23
+ );
24
+ }
25
+ const error =
26
+ caughtErrors.get(parsed.data.errorId) ??
27
+ new Error('Hono error fallback missing caught error');
28
+ return Result.err(error);
29
+ },
30
+ inputSource: 'query',
31
+ method: 'GET',
32
+ path: '/__ontrails/hono/error',
33
+ trail: caughtErrorTrail,
34
+ trailId: '__ontrails.hono.error',
35
+ };
36
+ const caughtErrorHandler = createRouteHandler(caughtErrorRoute);
37
+
38
+ const materializeCaughtErrorRequest = (
39
+ request: Request,
40
+ errorId: string
41
+ ): Request => {
42
+ const url = new URL('/__ontrails/hono/error', request.url);
43
+ url.searchParams.set('errorId', errorId);
44
+ return new Request(url, {
45
+ headers: request.headers,
46
+ method: 'GET',
47
+ signal: request.signal,
48
+ });
49
+ };
50
+
51
+ export const handleCaughtHonoError = async (
52
+ error: unknown,
53
+ request: Request
54
+ ): Promise<Response> => {
55
+ const err = error instanceof Error ? error : new Error(String(error));
56
+ const errorId = crypto.randomUUID();
57
+ caughtErrors.set(errorId, err);
58
+ try {
59
+ return await caughtErrorHandler(
60
+ materializeCaughtErrorRequest(request, errorId)
61
+ );
62
+ } finally {
63
+ caughtErrors.delete(errorId);
64
+ }
65
+ };
package/src/surface.ts CHANGED
@@ -10,12 +10,6 @@
10
10
  * ```
11
11
  */
12
12
 
13
- import {
14
- isTrailsError,
15
- projectErrorDiagnostics,
16
- projectPublicSurfaceError,
17
- ValidationError,
18
- } from '@ontrails/core';
19
13
  import type {
20
14
  BaseSurfaceOptions,
21
15
  Layer,
@@ -25,14 +19,13 @@ import type {
25
19
  } from '@ontrails/core';
26
20
  import { Hono } from 'hono';
27
21
  import type { Context as HonoContext } from 'hono';
28
- import type { ContentfulStatusCode } from 'hono/utils/http-status';
29
- import { deriveHttpRoutes } from '@ontrails/http';
22
+ import { createRouteHandler, deriveHttpRoutes } from '@ontrails/http';
30
23
  import type {
31
- HttpExecutionContext,
32
24
  HttpMethod,
33
25
  HttpRouteDefinition,
34
26
  ResolveHttpPermit,
35
27
  } from '@ontrails/http';
28
+ import { handleCaughtHonoError } from './caught-error.js';
36
29
 
37
30
  // ---------------------------------------------------------------------------
38
31
  // Options
@@ -54,520 +47,43 @@ export interface CreateAppOptions extends BaseSurfaceOptions {
54
47
  }
55
48
 
56
49
  interface RuntimeOptions {
57
- readonly maxJsonBodyBytes: number;
50
+ readonly maxJsonBodyBytes?: number | undefined;
58
51
  }
59
52
 
60
- const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
61
-
62
53
  export interface SurfaceHttpResult {
63
54
  readonly close: () => Promise<void>;
64
55
  readonly url: string;
65
56
  }
66
57
 
67
- // ---------------------------------------------------------------------------
68
- // Request parsing
69
- // ---------------------------------------------------------------------------
70
-
71
- /**
72
- * Parse query params into a plain object, preserving scalar-vs-array shape.
73
- *
74
- * A single `?tag=one` stays a scalar string, while repeated keys like
75
- * `?tag=one&tag=two` become arrays. Schema validation owns whether that shape
76
- * is accepted; the adapter does not coerce singleton values into arrays.
77
- */
78
- const parseQueryParams = (c: HonoContext): Record<string, unknown> => {
79
- const result: Record<string, unknown> = {};
80
- const url = new URL(c.req.url);
81
- const seenKeys = new Set<string>();
82
-
83
- for (const key of url.searchParams.keys()) {
84
- if (seenKeys.has(key)) {
85
- continue;
86
- }
87
- seenKeys.add(key);
88
- const all = url.searchParams.getAll(key);
89
- result[key] = all.length > 1 ? all : all[0];
90
- }
91
-
92
- return result;
93
- };
94
-
95
- /** Sentinel indicating a JSON parse failure. */
96
- const JSON_PARSE_ERROR = Symbol('JSON_PARSE_ERROR');
97
-
98
- /** Sentinel indicating a JSON body rejected before parsing. */
99
- const JSON_BODY_TOO_LARGE = Symbol('JSON_BODY_TOO_LARGE');
100
-
101
- /** Sentinel indicating malformed body metadata. */
102
- const JSON_BODY_INVALID_CONTENT_LENGTH = Symbol(
103
- 'JSON_BODY_INVALID_CONTENT_LENGTH'
104
- );
105
-
106
- interface JsonObject {
107
- readonly [key: string]: JsonValue;
108
- }
109
-
110
- type JsonValue =
111
- | null
112
- | boolean
113
- | number
114
- | string
115
- | readonly JsonValue[]
116
- | JsonObject;
117
- type JsonBodyReadResult =
118
- | JsonValue
119
- | typeof JSON_BODY_INVALID_CONTENT_LENGTH
120
- | typeof JSON_PARSE_ERROR
121
- | typeof JSON_BODY_TOO_LARGE;
122
- type JsonBodyTextReadResult = string | typeof JSON_BODY_TOO_LARGE;
123
- type InputReadResult = Record<string, unknown> | JsonBodyReadResult;
124
-
125
- const CONTENT_LENGTH_DECIMAL_PATTERN = /^\d+$/;
126
-
127
- /** Return true when the request has no body content. */
128
- type ParsedContentLength =
129
- | number
130
- | typeof JSON_BODY_INVALID_CONTENT_LENGTH
131
- | undefined;
132
-
133
- const parseContentLength = (
134
- contentLength: string | undefined
135
- ): ParsedContentLength => {
136
- if (contentLength === undefined) {
137
- return undefined;
138
- }
139
- if (!CONTENT_LENGTH_DECIMAL_PATTERN.test(contentLength)) {
140
- return JSON_BODY_INVALID_CONTENT_LENGTH;
141
- }
142
- const size = Number(contentLength);
143
- return Number.isSafeInteger(size) ? size : Number.MAX_SAFE_INTEGER;
144
- };
145
-
146
- const isEmptyBody = (c: HonoContext): boolean => {
147
- const contentLength = parseContentLength(c.req.header('Content-Length'));
148
- if (contentLength === JSON_BODY_INVALID_CONTENT_LENGTH) {
149
- return false;
150
- }
151
- if (contentLength !== undefined) {
152
- return contentLength === 0;
153
- }
154
- // No Content-Length header — treat as empty when Content-Type is also absent.
155
- return c.req.header('Content-Type') === undefined;
156
- };
157
-
158
- const resolveMaxJsonBodyBytes = (value: number | undefined): number => {
159
- const maxJsonBodyBytes = value ?? DEFAULT_MAX_JSON_BODY_BYTES;
160
-
161
- if (!Number.isFinite(maxJsonBodyBytes) || maxJsonBodyBytes < 1) {
162
- throw new ValidationError(
163
- 'maxJsonBodyBytes must be a positive finite number'
164
- );
165
- }
166
-
167
- return maxJsonBodyBytes;
168
- };
169
-
170
- const hasOversizedContentLength = (
171
- c: HonoContext,
172
- maxJsonBodyBytes: number
173
- ): boolean => {
174
- const contentLength = c.req.header('Content-Length');
175
- if (contentLength === undefined) {
176
- return false;
177
- }
178
- const size = parseContentLength(contentLength);
179
- if (size === JSON_BODY_INVALID_CONTENT_LENGTH) {
180
- return false;
181
- }
182
- return size !== undefined && size > maxJsonBodyBytes;
183
- };
184
-
185
- const measureBodyTextBytes = (text: string): number => new Blob([text]).size;
186
-
187
- const validateCachedBodyText = (
188
- text: string,
189
- maxJsonBodyBytes: number
190
- ): JsonBodyTextReadResult =>
191
- measureBodyTextBytes(text) > maxJsonBodyBytes ? JSON_BODY_TOO_LARGE : text;
192
-
193
- const readCachedBodyText = async (
194
- c: HonoContext,
195
- maxJsonBodyBytes: number
196
- ): Promise<JsonBodyTextReadResult> =>
197
- validateCachedBodyText(await c.req.text(), maxJsonBodyBytes);
198
-
199
- const readBodyText = async (
200
- c: HonoContext,
201
- maxJsonBodyBytes: number
202
- ): Promise<string | typeof JSON_BODY_TOO_LARGE> => {
203
- if (c.req.raw.bodyUsed) {
204
- return await readCachedBodyText(c, maxJsonBodyBytes);
205
- }
206
-
207
- const { body } = c.req.raw;
208
- if (body === null) {
209
- return '';
210
- }
211
-
212
- const reader = body.getReader();
213
- const chunks: Uint8Array[] = [];
214
- let totalBytes = 0;
215
-
216
- try {
217
- while (true) {
218
- const { done, value } = await reader.read();
219
- if (done) {
220
- break;
221
- }
222
- if (value === undefined) {
223
- continue;
224
- }
225
- totalBytes += value.byteLength;
226
- if (totalBytes > maxJsonBodyBytes) {
227
- await reader.cancel();
228
- return JSON_BODY_TOO_LARGE;
229
- }
230
- chunks.push(value);
231
- }
232
- } finally {
233
- reader.releaseLock();
234
- }
235
-
236
- const bytes = new Uint8Array(totalBytes);
237
- let offset = 0;
238
- for (const chunk of chunks) {
239
- bytes.set(chunk, offset);
240
- offset += chunk.byteLength;
241
- }
242
-
243
- return new TextDecoder().decode(bytes);
244
- };
245
-
246
- const readJsonBody = async (
247
- c: HonoContext,
248
- maxJsonBodyBytes: number
249
- ): Promise<JsonBodyReadResult> => {
250
- if (
251
- parseContentLength(c.req.header('Content-Length')) ===
252
- JSON_BODY_INVALID_CONTENT_LENGTH
253
- ) {
254
- return JSON_BODY_INVALID_CONTENT_LENGTH;
255
- }
256
-
257
- if (hasOversizedContentLength(c, maxJsonBodyBytes)) {
258
- return JSON_BODY_TOO_LARGE;
259
- }
260
-
261
- const text = await readBodyText(c, maxJsonBodyBytes);
262
- if (text === JSON_BODY_TOO_LARGE) {
263
- return JSON_BODY_TOO_LARGE;
264
- }
265
-
266
- try {
267
- return JSON.parse(text) as JsonValue;
268
- } catch {
269
- return JSON_PARSE_ERROR;
270
- }
271
- };
272
-
273
- const parseJsonBodyText = (text: string): JsonBodyReadResult => {
274
- try {
275
- return JSON.parse(text) as JsonValue;
276
- } catch {
277
- return JSON_PARSE_ERROR;
278
- }
279
- };
280
-
281
- const parseWebhookBodyText = (
282
- c: HonoContext,
283
- text: string
284
- ): JsonBodyReadResult =>
285
- isEmptyBody(c) || text.length === 0 ? {} : parseJsonBodyText(text);
286
-
287
- const readWebhookBodyText = async (
288
- c: HonoContext,
289
- maxJsonBodyBytes: number
290
- ): Promise<
291
- string | typeof JSON_BODY_INVALID_CONTENT_LENGTH | typeof JSON_BODY_TOO_LARGE
292
- > => {
293
- if (
294
- parseContentLength(c.req.header('Content-Length')) ===
295
- JSON_BODY_INVALID_CONTENT_LENGTH
296
- ) {
297
- return JSON_BODY_INVALID_CONTENT_LENGTH;
298
- }
299
- if (hasOversizedContentLength(c, maxJsonBodyBytes)) {
300
- return JSON_BODY_TOO_LARGE;
301
- }
302
- return await readBodyText(c, maxJsonBodyBytes);
303
- };
304
-
305
- /** Read input from request based on input source. */
306
- const readInput = async (
307
- c: HonoContext,
308
- inputSource: 'query' | 'body',
309
- options: RuntimeOptions
310
- ): Promise<InputReadResult> => {
311
- if (inputSource === 'query') {
312
- return parseQueryParams(c);
313
- }
314
- if (isEmptyBody(c)) {
315
- return {};
316
- }
317
- return await readJsonBody(c, options.maxJsonBodyBytes);
318
- };
319
-
320
- // ---------------------------------------------------------------------------
321
- // Response mapping
322
- // ---------------------------------------------------------------------------
323
-
324
- /** Map a TrailsError or generic Error to an HTTP error response. */
325
- const mapErrorResponse = (
326
- error: Error
327
- ): { body: Record<string, unknown>; status: ContentfulStatusCode } => {
328
- const projection = projectPublicSurfaceError('http', error);
329
- return {
330
- body: {
331
- error: {
332
- category: projection.category,
333
- code: projection.name,
334
- message: projection.message,
335
- },
336
- },
337
- status: projection.code as ContentfulStatusCode,
338
- };
339
- };
340
-
341
- const LOG_UNSAFE_LABEL_CHARACTERS = /[^\w:.-]/g;
342
- const MAX_DIAGNOSTIC_LABEL_VALUE_LENGTH = 128;
343
-
344
- const sanitizeDiagnosticLabelValue = (value: string): string =>
345
- value
346
- .replace(LOG_UNSAFE_LABEL_CHARACTERS, '_')
347
- .slice(0, MAX_DIAGNOSTIC_LABEL_VALUE_LENGTH);
348
-
349
- const reportInternalDiagnostics = (error: Error, c: HonoContext): void => {
350
- if (isTrailsError(error)) {
351
- return;
352
- }
353
-
354
- const requestId = c.req.header('X-Request-ID');
355
- const safeRequestId =
356
- requestId === undefined
357
- ? undefined
358
- : sanitizeDiagnosticLabelValue(requestId);
359
- const label =
360
- safeRequestId === undefined
361
- ? '[ontrails:hono] Internal error'
362
- : `[ontrails:hono] Internal error (${safeRequestId})`;
363
- console.error(label, projectErrorDiagnostics(error));
364
- };
365
-
366
58
  // ---------------------------------------------------------------------------
367
59
  // Route registration
368
60
  // ---------------------------------------------------------------------------
369
61
 
370
- /** Map a Result to an HTTP response via Hono context. */
371
- const mapResultToResponse = (
372
- result: { isOk(): boolean; value?: unknown; error?: Error },
373
- c: HonoContext
374
- ): Response => {
375
- if (result.isOk()) {
376
- return c.json({ data: result.value }, 200);
377
- }
378
- const error = result.error ?? new Error('Unknown error');
379
- reportInternalDiagnostics(error, c);
380
- const { body, status } = mapErrorResponse(error);
381
- return c.json(body, status);
382
- };
383
-
384
- /** Convert a caught unknown value to an error response. */
385
- const handleCaughtError = (error: unknown, c: HonoContext): Response => {
386
- const err = error instanceof Error ? error : new Error(String(error));
387
- reportInternalDiagnostics(err, c);
388
- const { body, status } = mapErrorResponse(err);
389
- return c.json(body, status);
390
- };
391
-
392
- const invalidJsonResponse = (c: HonoContext): Response =>
393
- c.json(
394
- {
395
- error: {
396
- category: 'validation',
397
- code: 'ValidationError',
398
- message: 'Invalid JSON in request body',
399
- },
400
- },
401
- 400
402
- );
403
-
404
- const invalidContentLengthResponse = (c: HonoContext): Response =>
405
- c.json(
406
- {
407
- error: {
408
- category: 'validation',
409
- code: 'ValidationError',
410
- message: 'Invalid Content-Length header',
411
- },
412
- },
413
- 400
414
- );
415
-
416
- const oversizedJsonBodyResponse = (
417
- c: HonoContext,
418
- options: RuntimeOptions
419
- ): Response =>
420
- c.json(
421
- {
422
- error: {
423
- category: 'validation',
424
- code: 'ValidationError',
425
- message: `JSON request body exceeds ${options.maxJsonBodyBytes} bytes`,
426
- },
427
- },
428
- 413
429
- );
430
-
431
- const collectHeaders = (c: HonoContext): Record<string, string> => {
432
- const headers: Record<string, string> = {};
433
- for (const [key, value] of c.req.raw.headers) {
434
- headers[key] = value;
62
+ const materializeHonoRequest = async (c: HonoContext): Promise<Request> => {
63
+ if (!c.req.raw.bodyUsed) {
64
+ return c.req.raw;
435
65
  }
436
- return headers;
437
- };
438
-
439
- const createHttpExecutionContext = (c: HonoContext): HttpExecutionContext => ({
440
- headers: c.req.raw.headers,
441
- });
442
66
 
443
- const createWebhookVerifyRequest = (
444
- c: HonoContext,
445
- body: string
446
- ): {
447
- readonly body: string;
448
- readonly headers: Record<string, string>;
449
- readonly method: string;
450
- readonly path: string;
451
- } => ({
452
- body,
453
- headers: collectHeaders(c),
454
- method: c.req.method,
455
- path: new URL(c.req.url).pathname,
456
- });
457
-
458
- const recordInvalidWebhook = async (
459
- route: HttpRouteDefinition,
460
- errorCategory = 'validation'
461
- ): Promise<void> => {
462
- await route.recordWebhookInvalid?.(errorCategory);
67
+ const body = await c.req.text();
68
+ return new Request(c.req.raw.url, {
69
+ body,
70
+ headers: c.req.raw.headers,
71
+ method: c.req.raw.method,
72
+ signal: c.req.raw.signal,
73
+ });
463
74
  };
464
75
 
465
- const errorCategoryForWebhookFailure = (error: Error | undefined): string =>
466
- error !== undefined && isTrailsError(error) ? error.category : 'internal';
467
-
468
- const handleWebhookRoute = async (
76
+ /** Create a Hono handler from a route definition. */
77
+ const createHonoHandler = (
469
78
  route: HttpRouteDefinition,
470
- options: RuntimeOptions,
471
- c: HonoContext
472
- ): Promise<Response> => {
473
- const rawBody = await readWebhookBodyText(c, options.maxJsonBodyBytes);
474
-
475
- if (rawBody === JSON_BODY_INVALID_CONTENT_LENGTH) {
476
- await recordInvalidWebhook(route);
477
- return invalidContentLengthResponse(c);
478
- }
479
-
480
- if (rawBody === JSON_BODY_TOO_LARGE) {
481
- await recordInvalidWebhook(route);
482
- return oversizedJsonBodyResponse(c, options);
483
- }
484
-
485
- const verified = await route.verifyWebhook?.(
486
- createWebhookVerifyRequest(c, rawBody)
487
- );
488
- if (verified?.isErr()) {
489
- await recordInvalidWebhook(
490
- route,
491
- errorCategoryForWebhookFailure(verified.error)
492
- );
493
- return mapResultToResponse(verified, c);
494
- }
495
-
496
- const jsonBody = parseWebhookBodyText(c, rawBody);
497
- if (jsonBody === JSON_PARSE_ERROR) {
498
- await recordInvalidWebhook(route);
499
- return invalidJsonResponse(c);
500
- }
501
-
502
- const parsed = route.parseWebhookInput?.(jsonBody);
503
- if (parsed === undefined) {
504
- await recordInvalidWebhook(route);
505
- return mapResultToResponse(
506
- {
507
- error: new ValidationError('Webhook route is missing parse handler'),
508
- isOk: () => false,
509
- },
510
- c
511
- );
512
- }
513
- if (parsed.isErr()) {
514
- await recordInvalidWebhook(route);
515
- return mapResultToResponse(parsed, c);
516
- }
517
-
518
- const requestId = c.req.header('X-Request-ID') ?? undefined;
519
- const { signal: abortSignal } = c.req.raw;
520
- const result = await route.execute(
521
- parsed.value,
522
- requestId,
523
- abortSignal,
524
- createHttpExecutionContext(c)
525
- );
526
- return mapResultToResponse(result, c);
79
+ options: RuntimeOptions
80
+ ): ((c: HonoContext) => Promise<Response>) => {
81
+ const handler = createRouteHandler(route, {
82
+ maxJsonBodyBytes: options.maxJsonBodyBytes,
83
+ });
84
+ return async (c) => handler(await materializeHonoRequest(c));
527
85
  };
528
86
 
529
- /** Create a Hono handler from a route definition. */
530
- const createHonoHandler =
531
- (route: HttpRouteDefinition, options: RuntimeOptions) =>
532
- async (c: HonoContext): Promise<Response> => {
533
- if (route.inputSource === 'webhook') {
534
- try {
535
- return await handleWebhookRoute(route, options, c);
536
- } catch (error: unknown) {
537
- return handleCaughtError(error, c);
538
- }
539
- }
540
-
541
- const rawInput = await readInput(c, route.inputSource, options);
542
-
543
- if (rawInput === JSON_PARSE_ERROR) {
544
- return invalidJsonResponse(c);
545
- }
546
-
547
- if (rawInput === JSON_BODY_INVALID_CONTENT_LENGTH) {
548
- return invalidContentLengthResponse(c);
549
- }
550
-
551
- if (rawInput === JSON_BODY_TOO_LARGE) {
552
- return oversizedJsonBodyResponse(c, options);
553
- }
554
-
555
- const requestId = c.req.header('X-Request-ID') ?? undefined;
556
- const { signal: abortSignal } = c.req.raw;
557
-
558
- try {
559
- const result = await route.execute(
560
- rawInput,
561
- requestId,
562
- abortSignal,
563
- createHttpExecutionContext(c)
564
- );
565
- return mapResultToResponse(result, c);
566
- } catch (error: unknown) {
567
- return handleCaughtError(error, c);
568
- }
569
- };
570
-
571
87
  /** Route registration keyed by HTTP method. */
572
88
  const routeRegistrars: Record<
573
89
  HttpMethod,
@@ -609,6 +125,11 @@ const registerRoutes = (
609
125
  // Global error handler
610
126
  // ---------------------------------------------------------------------------
611
127
 
128
+ const handleCaughtError = async (
129
+ error: unknown,
130
+ c: HonoContext
131
+ ): Promise<Response> => handleCaughtHonoError(error, c.req.raw);
132
+
612
133
  const registerErrorHandler = (hono: Hono): void => {
613
134
  // oxlint-disable-next-line prefer-await-to-callbacks -- Hono's onError API requires a callback
614
135
  hono.onError((err, c) => handleCaughtError(err, c));
@@ -628,6 +149,14 @@ const registerErrorHandler = (hono: Hono): void => {
628
149
  * @remarks This is a host materialization boundary. Derivation failures are
629
150
  * thrown for HTTP bootstrap code after `deriveHttpRoutes` has already
630
151
  * represented the framework error as a Result.
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * import { createApp } from '@ontrails/hono';
156
+ *
157
+ * const app = createApp(graph, { basePath: '/api' });
158
+ * Bun.serve({ fetch: app.fetch, port: 3000 });
159
+ * ```
631
160
  */
632
161
  export const createApp = (
633
162
  graph: Topo,
@@ -635,7 +164,7 @@ export const createApp = (
635
164
  ): Hono => {
636
165
  const hono = new Hono();
637
166
  const runtimeOptions = {
638
- maxJsonBodyBytes: resolveMaxJsonBodyBytes(options.maxJsonBodyBytes),
167
+ maxJsonBodyBytes: options.maxJsonBodyBytes,
639
168
  };
640
169
 
641
170
  registerErrorHandler(hono);
@@ -688,6 +217,14 @@ const startServer = (
688
217
  *
689
218
  * @remarks Always starts a Bun server. Use `createApp(graph)` for an
690
219
  * unserved Hono app that you can wire into your own server.
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * import { surface } from '@ontrails/hono';
224
+ *
225
+ * const server = await surface(graph, { port: 3000 });
226
+ * console.log(server.url);
227
+ * ```
691
228
  */
692
229
  export const surface = async (
693
230
  graph: Topo,