@ontrails/hono 1.0.0-beta.15 → 1.0.0-beta.17
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 +53 -0
- package/README.md +24 -1
- package/package.json +11 -3
- package/src/surface.ts +462 -71
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/surface.d.ts +0 -45
- package/dist/surface.d.ts.map +0 -1
- package/dist/surface.js +0 -219
- package/dist/surface.js.map +0 -1
- package/dist/trailhead.d.ts +0 -52
- package/dist/trailhead.d.ts.map +0 -1
- package/dist/trailhead.js +0 -258
- package/dist/trailhead.js.map +0 -1
- package/src/__tests__/surface.test.ts +0 -77
- package/tsconfig.json +0 -9
- package/tsconfig.tests.json +0 -10
- package/tsconfig.tsbuildinfo +0 -1
package/src/surface.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hono
|
|
2
|
+
* Hono adapter for Trails HTTP routes.
|
|
3
3
|
*
|
|
4
4
|
* Takes framework-agnostic HttpRouteDefinition[] and wires them into a
|
|
5
5
|
* Hono application, handling request parsing, response mapping, and errors.
|
|
@@ -10,9 +10,14 @@
|
|
|
10
10
|
* ```
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
isTrailsError,
|
|
15
|
+
projectErrorDiagnostics,
|
|
16
|
+
projectPublicSurfaceError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
} from '@ontrails/core';
|
|
14
19
|
import type {
|
|
15
|
-
|
|
20
|
+
BaseSurfaceOptions,
|
|
16
21
|
Layer,
|
|
17
22
|
ResourceOverrideMap,
|
|
18
23
|
Topo,
|
|
@@ -22,33 +27,38 @@ import { Hono } from 'hono';
|
|
|
22
27
|
import type { Context as HonoContext } from 'hono';
|
|
23
28
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
24
29
|
import { deriveHttpRoutes } from '@ontrails/http';
|
|
25
|
-
import type {
|
|
30
|
+
import type {
|
|
31
|
+
HttpExecutionContext,
|
|
32
|
+
HttpMethod,
|
|
33
|
+
HttpRouteDefinition,
|
|
34
|
+
ResolveHttpPermit,
|
|
35
|
+
} from '@ontrails/http';
|
|
26
36
|
|
|
27
37
|
// ---------------------------------------------------------------------------
|
|
28
38
|
// Options
|
|
29
39
|
// ---------------------------------------------------------------------------
|
|
30
40
|
|
|
31
|
-
export interface CreateAppOptions {
|
|
41
|
+
export interface CreateAppOptions extends BaseSurfaceOptions {
|
|
32
42
|
readonly basePath?: string | undefined;
|
|
33
|
-
/** Config values for resources that declare a `config` schema, keyed by resource ID. */
|
|
34
|
-
readonly configValues?:
|
|
35
|
-
| Readonly<Record<string, Record<string, unknown>>>
|
|
36
|
-
| undefined;
|
|
37
43
|
readonly createContext?:
|
|
38
44
|
| (() => TrailContextInit | Promise<TrailContextInit>)
|
|
39
45
|
| undefined;
|
|
40
|
-
readonly exclude?: readonly string[] | undefined;
|
|
41
46
|
readonly hostname?: string | undefined;
|
|
42
|
-
readonly include?: readonly string[] | undefined;
|
|
43
|
-
readonly intent?: readonly Intent[] | undefined;
|
|
44
47
|
readonly layers?: readonly Layer[] | undefined;
|
|
48
|
+
/** Maximum JSON request body size in bytes. Defaults to 1 MiB. */
|
|
49
|
+
readonly maxJsonBodyBytes?: number | undefined;
|
|
45
50
|
readonly name?: string | undefined;
|
|
46
51
|
readonly port?: number | undefined;
|
|
47
52
|
readonly resources?: ResourceOverrideMap | undefined;
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
readonly resolvePermit?: ResolveHttpPermit | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface RuntimeOptions {
|
|
57
|
+
readonly maxJsonBodyBytes: number;
|
|
50
58
|
}
|
|
51
59
|
|
|
60
|
+
const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
|
|
61
|
+
|
|
52
62
|
export interface SurfaceHttpResult {
|
|
53
63
|
readonly close: () => Promise<void>;
|
|
54
64
|
readonly url: string;
|
|
@@ -63,7 +73,7 @@ export interface SurfaceHttpResult {
|
|
|
63
73
|
*
|
|
64
74
|
* A single `?tag=one` stays a scalar string, while repeated keys like
|
|
65
75
|
* `?tag=one&tag=two` become arrays. Schema validation owns whether that shape
|
|
66
|
-
* is accepted; the
|
|
76
|
+
* is accepted; the adapter does not coerce singleton values into arrays.
|
|
67
77
|
*/
|
|
68
78
|
const parseQueryParams = (c: HonoContext): Record<string, unknown> => {
|
|
69
79
|
const result: Record<string, unknown> = {};
|
|
@@ -85,32 +95,226 @@ const parseQueryParams = (c: HonoContext): Record<string, unknown> => {
|
|
|
85
95
|
/** Sentinel indicating a JSON parse failure. */
|
|
86
96
|
const JSON_PARSE_ERROR = Symbol('JSON_PARSE_ERROR');
|
|
87
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
|
+
|
|
88
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
|
+
|
|
89
146
|
const isEmptyBody = (c: HonoContext): boolean => {
|
|
90
|
-
const contentLength = c.req.header('Content-Length');
|
|
147
|
+
const contentLength = parseContentLength(c.req.header('Content-Length'));
|
|
148
|
+
if (contentLength === JSON_BODY_INVALID_CONTENT_LENGTH) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
91
151
|
if (contentLength !== undefined) {
|
|
92
|
-
return
|
|
152
|
+
return contentLength === 0;
|
|
93
153
|
}
|
|
94
154
|
// No Content-Length header — treat as empty when Content-Type is also absent.
|
|
95
155
|
return c.req.header('Content-Type') === undefined;
|
|
96
156
|
};
|
|
97
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
|
+
|
|
98
305
|
/** Read input from request based on input source. */
|
|
99
306
|
const readInput = async (
|
|
100
307
|
c: HonoContext,
|
|
101
|
-
inputSource: 'query' | 'body'
|
|
102
|
-
|
|
308
|
+
inputSource: 'query' | 'body',
|
|
309
|
+
options: RuntimeOptions
|
|
310
|
+
): Promise<InputReadResult> => {
|
|
103
311
|
if (inputSource === 'query') {
|
|
104
312
|
return parseQueryParams(c);
|
|
105
313
|
}
|
|
106
314
|
if (isEmptyBody(c)) {
|
|
107
315
|
return {};
|
|
108
316
|
}
|
|
109
|
-
|
|
110
|
-
return await c.req.json();
|
|
111
|
-
} catch {
|
|
112
|
-
return JSON_PARSE_ERROR;
|
|
113
|
-
}
|
|
317
|
+
return await readJsonBody(c, options.maxJsonBodyBytes);
|
|
114
318
|
};
|
|
115
319
|
|
|
116
320
|
// ---------------------------------------------------------------------------
|
|
@@ -121,30 +325,44 @@ const readInput = async (
|
|
|
121
325
|
const mapErrorResponse = (
|
|
122
326
|
error: Error
|
|
123
327
|
): { body: Record<string, unknown>; status: ContentfulStatusCode } => {
|
|
124
|
-
|
|
125
|
-
return {
|
|
126
|
-
body: {
|
|
127
|
-
error: {
|
|
128
|
-
category: error.category,
|
|
129
|
-
code: error.name,
|
|
130
|
-
message: error.message,
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
status: mapTransportError('http', error) as ContentfulStatusCode,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
328
|
+
const projection = projectPublicSurfaceError('http', error);
|
|
136
329
|
return {
|
|
137
330
|
body: {
|
|
138
331
|
error: {
|
|
139
|
-
category:
|
|
140
|
-
code:
|
|
141
|
-
message:
|
|
332
|
+
category: projection.category,
|
|
333
|
+
code: projection.name,
|
|
334
|
+
message: projection.message,
|
|
142
335
|
},
|
|
143
336
|
},
|
|
144
|
-
status:
|
|
337
|
+
status: projection.code as ContentfulStatusCode,
|
|
145
338
|
};
|
|
146
339
|
};
|
|
147
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
|
+
|
|
148
366
|
// ---------------------------------------------------------------------------
|
|
149
367
|
// Route registration
|
|
150
368
|
// ---------------------------------------------------------------------------
|
|
@@ -157,43 +375,193 @@ const mapResultToResponse = (
|
|
|
157
375
|
if (result.isOk()) {
|
|
158
376
|
return c.json({ data: result.value }, 200);
|
|
159
377
|
}
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
);
|
|
378
|
+
const error = result.error ?? new Error('Unknown error');
|
|
379
|
+
reportInternalDiagnostics(error, c);
|
|
380
|
+
const { body, status } = mapErrorResponse(error);
|
|
163
381
|
return c.json(body, status);
|
|
164
382
|
};
|
|
165
383
|
|
|
166
384
|
/** Convert a caught unknown value to an error response. */
|
|
167
385
|
const handleCaughtError = (error: unknown, c: HonoContext): Response => {
|
|
168
386
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
387
|
+
reportInternalDiagnostics(err, c);
|
|
169
388
|
const { body, status } = mapErrorResponse(err);
|
|
170
389
|
return c.json(body, status);
|
|
171
390
|
};
|
|
172
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;
|
|
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);
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const errorCategoryForWebhookFailure = (error: Error | undefined): string =>
|
|
466
|
+
error !== undefined && isTrailsError(error) ? error.category : 'internal';
|
|
467
|
+
|
|
468
|
+
const handleWebhookRoute = async (
|
|
469
|
+
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);
|
|
527
|
+
};
|
|
528
|
+
|
|
173
529
|
/** Create a Hono handler from a route definition. */
|
|
174
530
|
const createHonoHandler =
|
|
175
|
-
(route: HttpRouteDefinition) =>
|
|
531
|
+
(route: HttpRouteDefinition, options: RuntimeOptions) =>
|
|
176
532
|
async (c: HonoContext): Promise<Response> => {
|
|
177
|
-
|
|
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);
|
|
178
542
|
|
|
179
543
|
if (rawInput === JSON_PARSE_ERROR) {
|
|
180
|
-
return c
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
);
|
|
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);
|
|
190
553
|
}
|
|
191
554
|
|
|
192
555
|
const requestId = c.req.header('X-Request-ID') ?? undefined;
|
|
193
556
|
const { signal: abortSignal } = c.req.raw;
|
|
194
557
|
|
|
195
558
|
try {
|
|
196
|
-
const result = await route.execute(
|
|
559
|
+
const result = await route.execute(
|
|
560
|
+
rawInput,
|
|
561
|
+
requestId,
|
|
562
|
+
abortSignal,
|
|
563
|
+
createHttpExecutionContext(c)
|
|
564
|
+
);
|
|
197
565
|
return mapResultToResponse(result, c);
|
|
198
566
|
} catch (error: unknown) {
|
|
199
567
|
return handleCaughtError(error, c);
|
|
@@ -215,14 +583,24 @@ const routeRegistrars: Record<
|
|
|
215
583
|
GET: (hono, path, handler) => {
|
|
216
584
|
hono.get(path, handler);
|
|
217
585
|
},
|
|
586
|
+
PATCH: (hono, path, handler) => {
|
|
587
|
+
hono.patch(path, handler);
|
|
588
|
+
},
|
|
218
589
|
POST: (hono, path, handler) => {
|
|
219
590
|
hono.post(path, handler);
|
|
220
591
|
},
|
|
592
|
+
PUT: (hono, path, handler) => {
|
|
593
|
+
hono.put(path, handler);
|
|
594
|
+
},
|
|
221
595
|
};
|
|
222
596
|
|
|
223
|
-
const registerRoutes = (
|
|
597
|
+
const registerRoutes = (
|
|
598
|
+
hono: Hono,
|
|
599
|
+
routes: HttpRouteDefinition[],
|
|
600
|
+
options: RuntimeOptions
|
|
601
|
+
): void => {
|
|
224
602
|
for (const route of routes) {
|
|
225
|
-
const handler = createHonoHandler(route);
|
|
603
|
+
const handler = createHonoHandler(route, options);
|
|
226
604
|
routeRegistrars[route.method](hono, route.path, handler);
|
|
227
605
|
}
|
|
228
606
|
};
|
|
@@ -233,18 +611,7 @@ const registerRoutes = (hono: Hono, routes: HttpRouteDefinition[]): void => {
|
|
|
233
611
|
|
|
234
612
|
const registerErrorHandler = (hono: Hono): void => {
|
|
235
613
|
// oxlint-disable-next-line prefer-await-to-callbacks -- Hono's onError API requires a callback
|
|
236
|
-
hono.onError((err, c) =>
|
|
237
|
-
c.json(
|
|
238
|
-
{
|
|
239
|
-
error: {
|
|
240
|
-
category: 'internal',
|
|
241
|
-
code: 'InternalError',
|
|
242
|
-
message: err.message,
|
|
243
|
-
},
|
|
244
|
-
},
|
|
245
|
-
500
|
|
246
|
-
)
|
|
247
|
-
);
|
|
614
|
+
hono.onError((err, c) => handleCaughtError(err, c));
|
|
248
615
|
};
|
|
249
616
|
|
|
250
617
|
// ---------------------------------------------------------------------------
|
|
@@ -257,12 +624,27 @@ const registerErrorHandler = (hono: Hono): void => {
|
|
|
257
624
|
|
|
258
625
|
/**
|
|
259
626
|
* Build HTTP routes from a topo and register them on a Hono app.
|
|
627
|
+
*
|
|
628
|
+
* @remarks This is a host materialization boundary. Derivation failures are
|
|
629
|
+
* thrown for HTTP bootstrap code after `deriveHttpRoutes` has already
|
|
630
|
+
* represented the framework error as a Result.
|
|
631
|
+
*
|
|
632
|
+
* @example
|
|
633
|
+
* ```ts
|
|
634
|
+
* import { createApp } from '@ontrails/hono';
|
|
635
|
+
*
|
|
636
|
+
* const app = createApp(graph, { basePath: '/api' });
|
|
637
|
+
* Bun.serve({ fetch: app.fetch, port: 3000 });
|
|
638
|
+
* ```
|
|
260
639
|
*/
|
|
261
640
|
export const createApp = (
|
|
262
641
|
graph: Topo,
|
|
263
642
|
options: CreateAppOptions = {}
|
|
264
643
|
): Hono => {
|
|
265
644
|
const hono = new Hono();
|
|
645
|
+
const runtimeOptions = {
|
|
646
|
+
maxJsonBodyBytes: resolveMaxJsonBodyBytes(options.maxJsonBodyBytes),
|
|
647
|
+
};
|
|
266
648
|
|
|
267
649
|
registerErrorHandler(hono);
|
|
268
650
|
|
|
@@ -274,6 +656,7 @@ export const createApp = (
|
|
|
274
656
|
include: options.include,
|
|
275
657
|
intent: options.intent,
|
|
276
658
|
layers: options.layers,
|
|
659
|
+
resolvePermit: options.resolvePermit,
|
|
277
660
|
resources: options.resources,
|
|
278
661
|
validate: options.validate,
|
|
279
662
|
});
|
|
@@ -282,7 +665,7 @@ export const createApp = (
|
|
|
282
665
|
throw routesResult.error;
|
|
283
666
|
}
|
|
284
667
|
|
|
285
|
-
registerRoutes(hono, routesResult.value);
|
|
668
|
+
registerRoutes(hono, routesResult.value, runtimeOptions);
|
|
286
669
|
return hono;
|
|
287
670
|
};
|
|
288
671
|
|
|
@@ -313,6 +696,14 @@ const startServer = (
|
|
|
313
696
|
*
|
|
314
697
|
* @remarks Always starts a Bun server. Use `createApp(graph)` for an
|
|
315
698
|
* unserved Hono app that you can wire into your own server.
|
|
699
|
+
*
|
|
700
|
+
* @example
|
|
701
|
+
* ```ts
|
|
702
|
+
* import { surface } from '@ontrails/hono';
|
|
703
|
+
*
|
|
704
|
+
* const server = await surface(graph, { port: 3000 });
|
|
705
|
+
* console.log(server.url);
|
|
706
|
+
* ```
|
|
316
707
|
*/
|
|
317
708
|
export const surface = async (
|
|
318
709
|
graph: Topo,
|
package/.turbo/turbo-build.log
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
$ tsc -b
|
package/.turbo/turbo-lint.log
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
$ tsc --noEmit
|
package/dist/index.d.ts
DELETED
package/dist/index.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,OAAO,EACP,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,GACvB,MAAM,cAAc,CAAC"}
|