@ontrails/hono 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/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # @ontrails/hono
2
2
 
3
+ ## 1.0.0-beta.19
4
+
5
+ ### Patch Changes
6
+
7
+ - e41c382: Document beta-channel install guidance in package and adapter README install snippets so consumers use explicit `@beta` (or pinned `1.0.0-beta.N`) tags instead of accidental `latest` resolution during the prerelease line. Adds the policy doc at `docs/releases/beta-channel-policy.md`, prints both `latest` and `beta` dist-tags in `bun run publish:registry-check`, and aligns plugin/skill install snippets.
8
+ - ed5926b: Add missing TSDoc for public adapter and sink boundary types.
9
+ - 8105f53: Declare the Hono package as an extracted HTTP adapter and dogfood the shared
10
+ adapter authoring check path against its owner conformance test.
11
+ - Updated dependencies [e41c382]
12
+ - Updated dependencies [1eb5bdc]
13
+ - Updated dependencies [f8d80b9]
14
+ - Updated dependencies [94a8380]
15
+ - Updated dependencies [94a8380]
16
+ - Updated dependencies [846a597]
17
+ - Updated dependencies [223aaad]
18
+ - Updated dependencies [3125f4d]
19
+ - Updated dependencies [2494dc6]
20
+ - Updated dependencies [2d53717]
21
+ - Updated dependencies [16cb740]
22
+ - Updated dependencies [8894ecb]
23
+ - Updated dependencies [fdf7ec9]
24
+ - Updated dependencies [d76be13]
25
+ - Updated dependencies [84f56a5]
26
+ - Updated dependencies [431b04c]
27
+ - Updated dependencies [5d88104]
28
+ - Updated dependencies [f04a9ef]
29
+ - Updated dependencies [fc00aeb]
30
+ - Updated dependencies [ab1c77c]
31
+ - Updated dependencies [8ca5b85]
32
+ - @ontrails/core@1.0.0-beta.19
33
+ - @ontrails/http@1.0.0-beta.19
34
+
35
+ ## 1.0.0-beta.18
36
+
37
+ ### Patch Changes
38
+
39
+ - bc2d327: Close HTTP package documentation around the shared `@ontrails/http/fetch` kernel, Bun-native `@ontrails/http/bun` subpath, and Hono adapter boundary before versioning.
40
+ - 20cb72c: Refactor Hono route handling to delegate Web request parsing, response
41
+ projection, diagnostics, permits, and webhook handling through
42
+ `@ontrails/http/fetch`.
43
+ - Updated dependencies [c0b2948]
44
+ - Updated dependencies [fc3219c]
45
+ - Updated dependencies [bc2d327]
46
+ - @ontrails/http@1.0.0-beta.18
47
+ - @ontrails/core@1.0.0-beta.18
48
+
3
49
  ## 1.0.0-beta.17
4
50
 
5
51
  ### 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
 
@@ -11,8 +11,7 @@ import { graph } from './app';
11
11
  await surface(graph, { port: 3000 });
12
12
  ```
13
13
 
14
- JSON request bodies are capped at 1 MiB by default. Override the cap with
15
- `maxJsonBodyBytes` when a surface intentionally accepts larger JSON payloads:
14
+ JSON request bodies are capped at 1 MiB by default. Override the cap with `maxJsonBodyBytes` when a surface intentionally accepts larger JSON payloads:
16
15
 
17
16
  ```typescript
18
17
  await surface(graph, {
@@ -21,23 +20,16 @@ await surface(graph, {
21
20
  });
22
21
  ```
23
22
 
24
- Pass `resolvePermit` to resolve HTTP `Authorization: Bearer ...` credentials
25
- into `ctx.permit` before protected trails execute. The adapter forwards request
26
- headers to the framework-agnostic HTTP route executor, so malformed
27
- Authorization headers return `401` and resolved permits with insufficient scopes
28
- return `403`.
23
+ Pass `resolvePermit` to resolve HTTP `Authorization: Bearer ...` credentials into `ctx.permit` before protected trails execute. The adapter forwards request headers to the framework-agnostic HTTP route executor, so malformed Authorization headers return `401` and resolved permits with insufficient scopes return `403`.
29
24
 
30
- Generic non-TrailsError failures return a redacted 500 response while a
31
- redacted diagnostic projection is written to server diagnostics. `TrailsError`
32
- responses keep their taxonomy category and class name but redact sensitive
33
- message fragments before writing the public body.
25
+ Generic non-TrailsError failures return a redacted 500 response while a redacted diagnostic projection is written to server diagnostics. `TrailsError` responses keep their taxonomy category and class name but redact sensitive message fragments before writing the public body.
34
26
 
35
- For custom HTTP integrations or route inspection, keep using `deriveHttpRoutes()` from `@ontrails/http`.
27
+ 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
28
 
37
29
  ## Installation
38
30
 
39
31
  ```bash
40
- bun add @ontrails/http @ontrails/hono
32
+ bun add @ontrails/http@beta @ontrails/hono@beta
41
33
  ```
42
34
 
43
35
  ## Migration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/hono",
3
- "version": "1.0.0-beta.17",
3
+ "version": "1.0.0-beta.19",
4
4
  "files": [
5
5
  "src/**/*.ts",
6
6
  "!src/**/__tests__/**",
@@ -22,11 +22,16 @@
22
22
  "clean": "rm -rf dist *.tsbuildinfo"
23
23
  },
24
24
  "dependencies": {
25
- "@ontrails/core": "^1.0.0-beta.16",
25
+ "@ontrails/core": "^1.0.0-beta.19",
26
26
  "hono": "^4.7.0"
27
27
  },
28
28
  "peerDependencies": {
29
- "@ontrails/http": "^1.0.0-beta.16",
29
+ "@ontrails/http": "^1.0.0-beta.19",
30
30
  "zod": "^4.3.5"
31
+ },
32
+ "trails": {
33
+ "adapter": {
34
+ "target": "http"
35
+ }
31
36
  }
32
37
  }
@@ -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,19 +19,21 @@ 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
39
32
  // ---------------------------------------------------------------------------
40
33
 
34
+ /**
35
+ * Options for building a Trails HTTP app on Hono.
36
+ */
41
37
  export interface CreateAppOptions extends BaseSurfaceOptions {
42
38
  readonly basePath?: string | undefined;
43
39
  readonly createContext?:
@@ -54,520 +50,46 @@ export interface CreateAppOptions extends BaseSurfaceOptions {
54
50
  }
55
51
 
56
52
  interface RuntimeOptions {
57
- readonly maxJsonBodyBytes: number;
53
+ readonly maxJsonBodyBytes?: number | undefined;
58
54
  }
59
55
 
60
- const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
61
-
56
+ /**
57
+ * Runtime handle returned by the Hono surface.
58
+ */
62
59
  export interface SurfaceHttpResult {
63
60
  readonly close: () => Promise<void>;
64
61
  readonly url: string;
65
62
  }
66
63
 
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
64
  // ---------------------------------------------------------------------------
367
65
  // Route registration
368
66
  // ---------------------------------------------------------------------------
369
67
 
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);
68
+ const materializeHonoRequest = async (c: HonoContext): Promise<Request> => {
69
+ if (!c.req.raw.bodyUsed) {
70
+ return c.req.raw;
377
71
  }
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
72
 
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;
435
- }
436
- return headers;
437
- };
438
-
439
- const createHttpExecutionContext = (c: HonoContext): HttpExecutionContext => ({
440
- headers: c.req.raw.headers,
441
- });
442
-
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);
73
+ const body = await c.req.text();
74
+ return new Request(c.req.raw.url, {
75
+ body,
76
+ headers: c.req.raw.headers,
77
+ method: c.req.raw.method,
78
+ signal: c.req.raw.signal,
79
+ });
463
80
  };
464
81
 
465
- const errorCategoryForWebhookFailure = (error: Error | undefined): string =>
466
- error !== undefined && isTrailsError(error) ? error.category : 'internal';
467
-
468
- const handleWebhookRoute = async (
82
+ /** Create a Hono handler from a route definition. */
83
+ const createHonoHandler = (
469
84
  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);
85
+ options: RuntimeOptions
86
+ ): ((c: HonoContext) => Promise<Response>) => {
87
+ const handler = createRouteHandler(route, {
88
+ maxJsonBodyBytes: options.maxJsonBodyBytes,
89
+ });
90
+ return async (c) => handler(await materializeHonoRequest(c));
527
91
  };
528
92
 
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
93
  /** Route registration keyed by HTTP method. */
572
94
  const routeRegistrars: Record<
573
95
  HttpMethod,
@@ -609,6 +131,11 @@ const registerRoutes = (
609
131
  // Global error handler
610
132
  // ---------------------------------------------------------------------------
611
133
 
134
+ const handleCaughtError = async (
135
+ error: unknown,
136
+ c: HonoContext
137
+ ): Promise<Response> => handleCaughtHonoError(error, c.req.raw);
138
+
612
139
  const registerErrorHandler = (hono: Hono): void => {
613
140
  // oxlint-disable-next-line prefer-await-to-callbacks -- Hono's onError API requires a callback
614
141
  hono.onError((err, c) => handleCaughtError(err, c));
@@ -643,7 +170,7 @@ export const createApp = (
643
170
  ): Hono => {
644
171
  const hono = new Hono();
645
172
  const runtimeOptions = {
646
- maxJsonBodyBytes: resolveMaxJsonBodyBytes(options.maxJsonBodyBytes),
173
+ maxJsonBodyBytes: options.maxJsonBodyBytes,
647
174
  };
648
175
 
649
176
  registerErrorHandler(hono);