@ontrails/hono 1.0.0-beta.17 → 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 +14 -0
- package/README.md +2 -2
- package/package.json +3 -3
- package/src/caught-error.ts +65 -0
- package/src/surface.ts +27 -506
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 1.0.0-beta.17
|
|
4
18
|
|
|
5
19
|
### 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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
c: HonoContext
|
|
374
|
-
): Response => {
|
|
375
|
-
if (result.isOk()) {
|
|
376
|
-
return c.json({ data: result.value }, 200);
|
|
62
|
+
const materializeHonoRequest = async (c: HonoContext): Promise<Request> => {
|
|
63
|
+
if (!c.req.raw.bodyUsed) {
|
|
64
|
+
return c.req.raw;
|
|
377
65
|
}
|
|
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
66
|
|
|
392
|
-
const
|
|
393
|
-
c.
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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);
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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));
|
|
@@ -643,7 +164,7 @@ export const createApp = (
|
|
|
643
164
|
): Hono => {
|
|
644
165
|
const hono = new Hono();
|
|
645
166
|
const runtimeOptions = {
|
|
646
|
-
maxJsonBodyBytes:
|
|
167
|
+
maxJsonBodyBytes: options.maxJsonBodyBytes,
|
|
647
168
|
};
|
|
648
169
|
|
|
649
170
|
registerErrorHandler(hono);
|