@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 +24 -0
- package/README.md +2 -2
- package/package.json +3 -3
- package/src/caught-error.ts +65 -0
- package/src/surface.ts +43 -506
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.
|
|
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);
|
|
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
|
|
444
|
-
c
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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));
|
|
@@ -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:
|
|
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,
|