@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 +46 -0
- package/README.md +6 -14
- package/package.json +8 -3
- package/src/caught-error.ts +65 -0
- package/src/surface.ts +33 -506
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
53
|
+
readonly maxJsonBodyBytes?: number | undefined;
|
|
58
54
|
}
|
|
59
55
|
|
|
60
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
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);
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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);
|
|
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:
|
|
173
|
+
maxJsonBodyBytes: options.maxJsonBodyBytes,
|
|
647
174
|
};
|
|
648
175
|
|
|
649
176
|
registerErrorHandler(hono);
|