@ontrails/hono 1.0.0-beta.15
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/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +9 -0
- package/README.md +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/surface.d.ts +45 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +219 -0
- package/dist/surface.js.map +1 -0
- package/dist/trailhead.d.ts +52 -0
- package/dist/trailhead.d.ts.map +1 -0
- package/dist/trailhead.js +258 -0
- package/dist/trailhead.js.map +1 -0
- package/package.json +24 -0
- package/src/__tests__/surface.test.ts +77 -0
- package/src/index.ts +6 -0
- package/src/surface.ts +324 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tests.json +10 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
$ tsc -b
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
$ tsc --noEmit
|
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @ontrails/hono
|
|
2
|
+
|
|
3
|
+
Hono surface connector 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.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { surface } from '@ontrails/hono';
|
|
9
|
+
import { graph } from './app';
|
|
10
|
+
|
|
11
|
+
await surface(graph, { port: 3000 });
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
For custom HTTP integrations or route inspection, keep using `deriveHttpRoutes()` from `@ontrails/http`.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bun add @ontrails/http @ontrails/hono
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Migration
|
|
23
|
+
|
|
24
|
+
This package replaces the old `@ontrails/http/hono` subpath.
|
|
25
|
+
|
|
26
|
+
- Before: `import { trailhead } from '@ontrails/http/hono'`
|
|
27
|
+
- After: `import { surface } from '@ontrails/hono'`
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,OAAO,GAGR,MAAM,cAAc,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono connector for Trails HTTP routes.
|
|
3
|
+
*
|
|
4
|
+
* Takes framework-agnostic HttpRouteDefinition[] and wires them into a
|
|
5
|
+
* Hono application, handling request parsing, response mapping, and errors.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const graph = topo("myapp", entity);
|
|
9
|
+
* await surface(graph, { port: 3000 });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import type { Intent, Layer, ResourceOverrideMap, Topo, TrailContextInit } from '@ontrails/core';
|
|
13
|
+
import { Hono } from 'hono';
|
|
14
|
+
export interface CreateAppOptions {
|
|
15
|
+
readonly basePath?: string | undefined;
|
|
16
|
+
/** Config values for resources that declare a `config` schema, keyed by resource ID. */
|
|
17
|
+
readonly configValues?: Readonly<Record<string, Record<string, unknown>>> | undefined;
|
|
18
|
+
readonly createContext?: (() => TrailContextInit | Promise<TrailContextInit>) | undefined;
|
|
19
|
+
readonly exclude?: readonly string[] | undefined;
|
|
20
|
+
readonly hostname?: string | undefined;
|
|
21
|
+
readonly include?: readonly string[] | undefined;
|
|
22
|
+
readonly intent?: readonly Intent[] | undefined;
|
|
23
|
+
readonly layers?: readonly Layer[] | undefined;
|
|
24
|
+
readonly name?: string | undefined;
|
|
25
|
+
readonly port?: number | undefined;
|
|
26
|
+
readonly resources?: ResourceOverrideMap | undefined;
|
|
27
|
+
/** Set to `false` to skip topo validation at startup. Defaults to `true`. */
|
|
28
|
+
readonly validate?: boolean | undefined;
|
|
29
|
+
}
|
|
30
|
+
export interface SurfaceHttpResult {
|
|
31
|
+
readonly close: () => Promise<void>;
|
|
32
|
+
readonly url: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build HTTP routes from a topo and register them on a Hono app.
|
|
36
|
+
*/
|
|
37
|
+
export declare const createApp: (graph: Topo, options?: CreateAppOptions) => Hono;
|
|
38
|
+
/**
|
|
39
|
+
* Build a Hono app from a topo and start serving it with Bun.
|
|
40
|
+
*
|
|
41
|
+
* @remarks Always starts a Bun server. Use `createApp(graph)` for an
|
|
42
|
+
* unserved Hono app that you can wire into your own server.
|
|
43
|
+
*/
|
|
44
|
+
export declare const surface: (graph: Topo, options?: CreateAppOptions) => Promise<SurfaceHttpResult>;
|
|
45
|
+
//# sourceMappingURL=surface.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"surface.d.ts","sourceRoot":"","sources":["../src/surface.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EACV,MAAM,EACN,KAAK,EACL,mBAAmB,EACnB,IAAI,EACJ,gBAAgB,EACjB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAU5B,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,wFAAwF;IACxF,QAAQ,CAAC,YAAY,CAAC,EAClB,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,GACjD,SAAS,CAAC;IACd,QAAQ,CAAC,aAAa,CAAC,EACnB,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC,GACpD,SAAS,CAAC;IACd,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IACjD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IACjD,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,KAAK,EAAE,GAAG,SAAS,CAAC;IAC/C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,mBAAmB,GAAG,SAAS,CAAC;IACrD,6EAA6E;IAC7E,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACzC;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AA2MD;;GAEG;AACH,eAAO,MAAM,SAAS,GACpB,OAAO,IAAI,EACX,UAAS,gBAAqB,KAC7B,IAuBF,CAAC;AAwBF;;;;;GAKG;AACH,eAAO,MAAM,OAAO,GAClB,OAAO,IAAI,EACX,UAAS,gBAAqB,KAC7B,OAAO,CAAC,iBAAiB,CAI3B,CAAC"}
|
package/dist/surface.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono connector for Trails HTTP routes.
|
|
3
|
+
*
|
|
4
|
+
* Takes framework-agnostic HttpRouteDefinition[] and wires them into a
|
|
5
|
+
* Hono application, handling request parsing, response mapping, and errors.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const graph = topo("myapp", entity);
|
|
9
|
+
* await surface(graph, { port: 3000 });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { isTrailsError, mapTransportError } from '@ontrails/core';
|
|
13
|
+
import { Hono } from 'hono';
|
|
14
|
+
import { deriveHttpRoutes } from '@ontrails/http';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Request parsing
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
/**
|
|
19
|
+
* Parse query params into a plain object, preserving scalar-vs-array shape.
|
|
20
|
+
*
|
|
21
|
+
* A single `?tag=one` stays a scalar string, while repeated keys like
|
|
22
|
+
* `?tag=one&tag=two` become arrays. Schema validation owns whether that shape
|
|
23
|
+
* is accepted; the connector does not coerce singleton values into arrays.
|
|
24
|
+
*/
|
|
25
|
+
const parseQueryParams = (c) => {
|
|
26
|
+
const result = {};
|
|
27
|
+
const url = new URL(c.req.url);
|
|
28
|
+
const seenKeys = new Set();
|
|
29
|
+
for (const key of url.searchParams.keys()) {
|
|
30
|
+
if (seenKeys.has(key)) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
seenKeys.add(key);
|
|
34
|
+
const all = url.searchParams.getAll(key);
|
|
35
|
+
result[key] = all.length > 1 ? all : all[0];
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
/** Sentinel indicating a JSON parse failure. */
|
|
40
|
+
const JSON_PARSE_ERROR = Symbol('JSON_PARSE_ERROR');
|
|
41
|
+
/** Return true when the request has no body content. */
|
|
42
|
+
const isEmptyBody = (c) => {
|
|
43
|
+
const contentLength = c.req.header('Content-Length');
|
|
44
|
+
if (contentLength !== undefined) {
|
|
45
|
+
return Number.parseInt(contentLength, 10) === 0;
|
|
46
|
+
}
|
|
47
|
+
// No Content-Length header — treat as empty when Content-Type is also absent.
|
|
48
|
+
return c.req.header('Content-Type') === undefined;
|
|
49
|
+
};
|
|
50
|
+
/** Read input from request based on input source. */
|
|
51
|
+
const readInput = async (c, inputSource) => {
|
|
52
|
+
if (inputSource === 'query') {
|
|
53
|
+
return parseQueryParams(c);
|
|
54
|
+
}
|
|
55
|
+
if (isEmptyBody(c)) {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return await c.req.json();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return JSON_PARSE_ERROR;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Response mapping
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
/** Map a TrailsError or generic Error to an HTTP error response. */
|
|
69
|
+
const mapErrorResponse = (error) => {
|
|
70
|
+
if (isTrailsError(error)) {
|
|
71
|
+
return {
|
|
72
|
+
body: {
|
|
73
|
+
error: {
|
|
74
|
+
category: error.category,
|
|
75
|
+
code: error.name,
|
|
76
|
+
message: error.message,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
status: mapTransportError('http', error),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
body: {
|
|
84
|
+
error: {
|
|
85
|
+
category: 'internal',
|
|
86
|
+
code: 'InternalError',
|
|
87
|
+
message: error.message,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
status: 500,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Route registration
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
/** Map a Result to an HTTP response via Hono context. */
|
|
97
|
+
const mapResultToResponse = (result, c) => {
|
|
98
|
+
if (result.isOk()) {
|
|
99
|
+
return c.json({ data: result.value }, 200);
|
|
100
|
+
}
|
|
101
|
+
const { body, status } = mapErrorResponse(result.error ?? new Error('Unknown error'));
|
|
102
|
+
return c.json(body, status);
|
|
103
|
+
};
|
|
104
|
+
/** Convert a caught unknown value to an error response. */
|
|
105
|
+
const handleCaughtError = (error, c) => {
|
|
106
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
107
|
+
const { body, status } = mapErrorResponse(err);
|
|
108
|
+
return c.json(body, status);
|
|
109
|
+
};
|
|
110
|
+
/** Create a Hono handler from a route definition. */
|
|
111
|
+
const createHonoHandler = (route) => async (c) => {
|
|
112
|
+
const rawInput = await readInput(c, route.inputSource);
|
|
113
|
+
if (rawInput === JSON_PARSE_ERROR) {
|
|
114
|
+
return c.json({
|
|
115
|
+
error: {
|
|
116
|
+
category: 'validation',
|
|
117
|
+
code: 'ValidationError',
|
|
118
|
+
message: 'Invalid JSON in request body',
|
|
119
|
+
},
|
|
120
|
+
}, 400);
|
|
121
|
+
}
|
|
122
|
+
const requestId = c.req.header('X-Request-ID') ?? undefined;
|
|
123
|
+
const { signal: abortSignal } = c.req.raw;
|
|
124
|
+
try {
|
|
125
|
+
const result = await route.execute(rawInput, requestId, abortSignal);
|
|
126
|
+
return mapResultToResponse(result, c);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
return handleCaughtError(error, c);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
/** Route registration keyed by HTTP method. */
|
|
133
|
+
const routeRegistrars = {
|
|
134
|
+
DELETE: (hono, path, handler) => {
|
|
135
|
+
hono.delete(path, handler);
|
|
136
|
+
},
|
|
137
|
+
GET: (hono, path, handler) => {
|
|
138
|
+
hono.get(path, handler);
|
|
139
|
+
},
|
|
140
|
+
POST: (hono, path, handler) => {
|
|
141
|
+
hono.post(path, handler);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
const registerRoutes = (hono, routes) => {
|
|
145
|
+
for (const route of routes) {
|
|
146
|
+
const handler = createHonoHandler(route);
|
|
147
|
+
routeRegistrars[route.method](hono, route.path, handler);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Global error handler
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
const registerErrorHandler = (hono) => {
|
|
154
|
+
// oxlint-disable-next-line prefer-await-to-callbacks -- Hono's onError API requires a callback
|
|
155
|
+
hono.onError((err, c) => c.json({
|
|
156
|
+
error: {
|
|
157
|
+
category: 'internal',
|
|
158
|
+
code: 'InternalError',
|
|
159
|
+
message: err.message,
|
|
160
|
+
},
|
|
161
|
+
}, 500));
|
|
162
|
+
};
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Validation
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// createApp
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
/**
|
|
170
|
+
* Build HTTP routes from a topo and register them on a Hono app.
|
|
171
|
+
*/
|
|
172
|
+
export const createApp = (graph, options = {}) => {
|
|
173
|
+
const hono = new Hono();
|
|
174
|
+
registerErrorHandler(hono);
|
|
175
|
+
const routesResult = deriveHttpRoutes(graph, {
|
|
176
|
+
basePath: options.basePath,
|
|
177
|
+
configValues: options.configValues,
|
|
178
|
+
createContext: options.createContext,
|
|
179
|
+
exclude: options.exclude,
|
|
180
|
+
include: options.include,
|
|
181
|
+
intent: options.intent,
|
|
182
|
+
layers: options.layers,
|
|
183
|
+
resources: options.resources,
|
|
184
|
+
validate: options.validate,
|
|
185
|
+
});
|
|
186
|
+
if (routesResult.isErr()) {
|
|
187
|
+
throw routesResult.error;
|
|
188
|
+
}
|
|
189
|
+
registerRoutes(hono, routesResult.value);
|
|
190
|
+
return hono;
|
|
191
|
+
};
|
|
192
|
+
const startServer = (hono, options) => {
|
|
193
|
+
const server = Bun.serve({
|
|
194
|
+
fetch: hono.fetch,
|
|
195
|
+
hostname: options.hostname ?? '0.0.0.0',
|
|
196
|
+
port: options.port ?? 3000,
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
close: async () => {
|
|
200
|
+
await server.stop(true);
|
|
201
|
+
},
|
|
202
|
+
url: String(server.url),
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// surface
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
/**
|
|
209
|
+
* Build a Hono app from a topo and start serving it with Bun.
|
|
210
|
+
*
|
|
211
|
+
* @remarks Always starts a Bun server. Use `createApp(graph)` for an
|
|
212
|
+
* unserved Hono app that you can wire into your own server.
|
|
213
|
+
*/
|
|
214
|
+
export const surface = async (graph, options = {}) => {
|
|
215
|
+
// oxlint-disable-next-line require-await -- async ensures createApp() throws become rejected promises, not uncaught exceptions
|
|
216
|
+
const hono = createApp(graph, options);
|
|
217
|
+
return startServer(hono, options);
|
|
218
|
+
};
|
|
219
|
+
//# sourceMappingURL=surface.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"surface.js","sourceRoot":"","sources":["../src/surface.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono connector for Trails HTTP routes.
|
|
3
|
+
*
|
|
4
|
+
* Takes framework-agnostic HttpRouteDefinition[] and wires them into a
|
|
5
|
+
* Hono application, handling request parsing, response mapping, and errors.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const graph = topo("myapp", entity);
|
|
9
|
+
* await surface(graph, { port: 3000 });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import type { Intent, Layer, ResourceOverrideMap, Topo, TrailContextInit } from '@ontrails/core';
|
|
13
|
+
import { Hono } from 'hono';
|
|
14
|
+
export interface TrailheadHttpOptions {
|
|
15
|
+
readonly basePath?: string | undefined;
|
|
16
|
+
/** Config values for resources that declare a `config` schema, keyed by resource ID. */
|
|
17
|
+
readonly configValues?: Readonly<Record<string, Record<string, unknown>>> | undefined;
|
|
18
|
+
readonly createContext?: (() => TrailContextInit | Promise<TrailContextInit>) | undefined;
|
|
19
|
+
readonly exclude?: readonly string[] | undefined;
|
|
20
|
+
readonly hostname?: string | undefined;
|
|
21
|
+
readonly include?: readonly string[] | undefined;
|
|
22
|
+
readonly intent?: readonly Intent[] | undefined;
|
|
23
|
+
readonly layers?: readonly Layer[] | undefined;
|
|
24
|
+
readonly name?: string | undefined;
|
|
25
|
+
readonly port?: number | undefined;
|
|
26
|
+
readonly resources?: ResourceOverrideMap | undefined;
|
|
27
|
+
/** Set false to return the Hono app without starting a server. */
|
|
28
|
+
readonly serve?: boolean | undefined;
|
|
29
|
+
/** Set to `false` to skip topo validation at startup. Defaults to `true`. */
|
|
30
|
+
readonly validate?: boolean | undefined;
|
|
31
|
+
}
|
|
32
|
+
export type CreateAppOptions = Omit<TrailheadHttpOptions, 'hostname' | 'port' | 'serve'>;
|
|
33
|
+
export interface SurfaceHttpResult {
|
|
34
|
+
readonly close: () => Promise<void>;
|
|
35
|
+
readonly url: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build HTTP routes from a topo and register them on a Hono app.
|
|
39
|
+
*/
|
|
40
|
+
export declare const createApp: (app: Topo, options?: CreateAppOptions) => Hono;
|
|
41
|
+
/**
|
|
42
|
+
* Build a Hono app from a topo and start serving it with Bun.
|
|
43
|
+
*/
|
|
44
|
+
export declare const surface: (app: Topo, options?: TrailheadHttpOptions) => Promise<SurfaceHttpResult>;
|
|
45
|
+
/**
|
|
46
|
+
* Build HTTP routes from a topo, create a Hono app, and optionally start serving.
|
|
47
|
+
*
|
|
48
|
+
* Topo validation runs before route construction — pass `validate: false`
|
|
49
|
+
* to skip it (e.g. during hot-reload or progressive startup).
|
|
50
|
+
*/
|
|
51
|
+
export declare const trailhead: (app: Topo, options?: TrailheadHttpOptions) => Promise<Hono>;
|
|
52
|
+
//# sourceMappingURL=trailhead.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trailhead.d.ts","sourceRoot":"","sources":["../src/trailhead.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EACV,MAAM,EACN,KAAK,EACL,mBAAmB,EACnB,IAAI,EACJ,gBAAgB,EACjB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAY5B,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,wFAAwF;IACxF,QAAQ,CAAC,YAAY,CAAC,EAClB,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,GACjD,SAAS,CAAC;IACd,QAAQ,CAAC,aAAa,CAAC,EACnB,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC,GACpD,SAAS,CAAC;IACd,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IACjD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IACjD,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,KAAK,EAAE,GAAG,SAAS,CAAC;IAC/C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,mBAAmB,GAAG,SAAS,CAAC;IACrD,kEAAkE;IAClE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACrC,6EAA6E;IAC7E,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACzC;AAED,MAAM,MAAM,gBAAgB,GAAG,IAAI,CACjC,oBAAoB,EACpB,UAAU,GAAG,MAAM,GAAG,OAAO,CAC9B,CAAC;AAEF,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AA2PD;;GAEG;AACH,eAAO,MAAM,SAAS,GAAI,KAAK,IAAI,EAAE,UAAS,gBAAqB,KAAG,IAuBrE,CAAC;AAwBF;;GAEG;AACH,eAAO,MAAM,OAAO,GAClB,KAAK,IAAI,EACT,UAAS,oBAAyB,KACjC,OAAO,CAAC,iBAAiB,CAU3B,CAAC;AAMF;;;;;GAKG;AAEH,eAAO,MAAM,SAAS,GACpB,KAAK,IAAI,EACT,UAAS,oBAAyB,KACjC,OAAO,CAAC,IAAI,CAQd,CAAC"}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono connector for Trails HTTP routes.
|
|
3
|
+
*
|
|
4
|
+
* Takes framework-agnostic HttpRouteDefinition[] and wires them into a
|
|
5
|
+
* Hono application, handling request parsing, response mapping, and errors.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const graph = topo("myapp", entity);
|
|
9
|
+
* await surface(graph, { port: 3000 });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { isTrailsError, mapTransportError } from '@ontrails/core';
|
|
13
|
+
import { Hono } from 'hono';
|
|
14
|
+
import { deriveHttpRoutes } from '@ontrails/http';
|
|
15
|
+
/** Unwrap optional/default wrappers to reach the underlying Zod type name. */
|
|
16
|
+
const unwrapZodType = (node) => {
|
|
17
|
+
let current = node;
|
|
18
|
+
while (current._zod.def['type'] === 'optional' ||
|
|
19
|
+
current._zod.def['type'] === 'default') {
|
|
20
|
+
current = current._zod.def['innerType'];
|
|
21
|
+
}
|
|
22
|
+
return current._zod.def['type'];
|
|
23
|
+
};
|
|
24
|
+
/** Extract the object shape from a Zod schema, or undefined if not an object. */
|
|
25
|
+
const extractShape = (schema) => {
|
|
26
|
+
const s = schema;
|
|
27
|
+
if (s._zod.def['type'] !== 'object') {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return s._zod.def['shape'];
|
|
31
|
+
};
|
|
32
|
+
/** Collect top-level field names whose underlying type is array. */
|
|
33
|
+
const collectArrayKeys = (inputSchema) => {
|
|
34
|
+
const shape = inputSchema ? extractShape(inputSchema) : undefined;
|
|
35
|
+
if (!shape) {
|
|
36
|
+
return new Set();
|
|
37
|
+
}
|
|
38
|
+
const keys = new Set();
|
|
39
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
40
|
+
if (unwrapZodType(value) === 'array') {
|
|
41
|
+
keys.add(key);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return keys;
|
|
45
|
+
};
|
|
46
|
+
/** Parse query params into a plain object, preserving raw strings for Zod. */
|
|
47
|
+
const parseQueryParams = (c, inputSchema) => {
|
|
48
|
+
const result = {};
|
|
49
|
+
const url = new URL(c.req.url);
|
|
50
|
+
const arrayKeys = collectArrayKeys(inputSchema);
|
|
51
|
+
for (const key of url.searchParams.keys()) {
|
|
52
|
+
// Already collected via getAll
|
|
53
|
+
if (key in result) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const all = url.searchParams.getAll(key);
|
|
57
|
+
result[key] = all.length > 1 || arrayKeys.has(key) ? all : all[0];
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
};
|
|
61
|
+
/** Sentinel indicating a JSON parse failure. */
|
|
62
|
+
const JSON_PARSE_ERROR = Symbol('JSON_PARSE_ERROR');
|
|
63
|
+
/** Return true when the request has no body content. */
|
|
64
|
+
const isEmptyBody = (c) => {
|
|
65
|
+
const contentLength = c.req.header('Content-Length');
|
|
66
|
+
if (contentLength !== undefined) {
|
|
67
|
+
return Number.parseInt(contentLength, 10) === 0;
|
|
68
|
+
}
|
|
69
|
+
// No Content-Length header — treat as empty when Content-Type is also absent.
|
|
70
|
+
return c.req.header('Content-Type') === undefined;
|
|
71
|
+
};
|
|
72
|
+
/** Read input from request based on input source. */
|
|
73
|
+
const readInput = async (c, inputSource, inputSchema) => {
|
|
74
|
+
if (inputSource === 'query') {
|
|
75
|
+
return parseQueryParams(c, inputSchema);
|
|
76
|
+
}
|
|
77
|
+
if (isEmptyBody(c)) {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return await c.req.json();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return JSON_PARSE_ERROR;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Response mapping
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
/** Map a TrailsError or generic Error to an HTTP error response. */
|
|
91
|
+
const mapErrorResponse = (error) => {
|
|
92
|
+
if (isTrailsError(error)) {
|
|
93
|
+
return {
|
|
94
|
+
body: {
|
|
95
|
+
error: {
|
|
96
|
+
category: error.category,
|
|
97
|
+
code: error.name,
|
|
98
|
+
message: error.message,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
status: mapTransportError('http', error),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
body: {
|
|
106
|
+
error: {
|
|
107
|
+
category: 'internal',
|
|
108
|
+
code: 'InternalError',
|
|
109
|
+
message: error.message,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
status: 500,
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Route registration
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
/** Map a Result to an HTTP response via Hono context. */
|
|
119
|
+
const mapResultToResponse = (result, c) => {
|
|
120
|
+
if (result.isOk()) {
|
|
121
|
+
return c.json({ data: result.value }, 200);
|
|
122
|
+
}
|
|
123
|
+
const { body, status } = mapErrorResponse(result.error ?? new Error('Unknown error'));
|
|
124
|
+
return c.json(body, status);
|
|
125
|
+
};
|
|
126
|
+
/** Convert a caught unknown value to an error response. */
|
|
127
|
+
const handleCaughtError = (error, c) => {
|
|
128
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
129
|
+
const { body, status } = mapErrorResponse(err);
|
|
130
|
+
return c.json(body, status);
|
|
131
|
+
};
|
|
132
|
+
/** Create a Hono handler from a route definition. */
|
|
133
|
+
const createHonoHandler = (route) => async (c) => {
|
|
134
|
+
const rawInput = await readInput(c, route.inputSource, route.trail.input);
|
|
135
|
+
if (rawInput === JSON_PARSE_ERROR) {
|
|
136
|
+
return c.json({
|
|
137
|
+
error: {
|
|
138
|
+
category: 'validation',
|
|
139
|
+
code: 'ValidationError',
|
|
140
|
+
message: 'Invalid JSON in request body',
|
|
141
|
+
},
|
|
142
|
+
}, 400);
|
|
143
|
+
}
|
|
144
|
+
const requestId = c.req.header('X-Request-ID') ?? undefined;
|
|
145
|
+
const { signal: abortSignal } = c.req.raw;
|
|
146
|
+
try {
|
|
147
|
+
const result = await route.execute(rawInput, requestId, abortSignal);
|
|
148
|
+
return mapResultToResponse(result, c);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
return handleCaughtError(error, c);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
/** Route registration keyed by HTTP method. */
|
|
155
|
+
const routeRegistrars = {
|
|
156
|
+
DELETE: (hono, path, handler) => {
|
|
157
|
+
hono.delete(path, handler);
|
|
158
|
+
},
|
|
159
|
+
GET: (hono, path, handler) => {
|
|
160
|
+
hono.get(path, handler);
|
|
161
|
+
},
|
|
162
|
+
POST: (hono, path, handler) => {
|
|
163
|
+
hono.post(path, handler);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
const registerRoutes = (hono, routes) => {
|
|
167
|
+
for (const route of routes) {
|
|
168
|
+
const handler = createHonoHandler(route);
|
|
169
|
+
routeRegistrars[route.method](hono, route.path, handler);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Global error handler
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
const registerErrorHandler = (hono) => {
|
|
176
|
+
// oxlint-disable-next-line prefer-await-to-callbacks -- Hono's onError API requires a callback
|
|
177
|
+
hono.onError((err, c) => c.json({
|
|
178
|
+
error: {
|
|
179
|
+
category: 'internal',
|
|
180
|
+
code: 'InternalError',
|
|
181
|
+
message: err.message,
|
|
182
|
+
},
|
|
183
|
+
}, 500));
|
|
184
|
+
};
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Validation
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// createApp
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
/**
|
|
192
|
+
* Build HTTP routes from a topo and register them on a Hono app.
|
|
193
|
+
*/
|
|
194
|
+
export const createApp = (app, options = {}) => {
|
|
195
|
+
const hono = new Hono();
|
|
196
|
+
registerErrorHandler(hono);
|
|
197
|
+
const routesResult = deriveHttpRoutes(app, {
|
|
198
|
+
basePath: options.basePath,
|
|
199
|
+
configValues: options.configValues,
|
|
200
|
+
createContext: options.createContext,
|
|
201
|
+
exclude: options.exclude,
|
|
202
|
+
include: options.include,
|
|
203
|
+
intent: options.intent,
|
|
204
|
+
layers: options.layers,
|
|
205
|
+
resources: options.resources,
|
|
206
|
+
validate: options.validate,
|
|
207
|
+
});
|
|
208
|
+
if (routesResult.isErr()) {
|
|
209
|
+
throw routesResult.error;
|
|
210
|
+
}
|
|
211
|
+
registerRoutes(hono, routesResult.value);
|
|
212
|
+
return hono;
|
|
213
|
+
};
|
|
214
|
+
const startServer = (hono, options) => {
|
|
215
|
+
const server = Bun.serve({
|
|
216
|
+
fetch: hono.fetch,
|
|
217
|
+
hostname: options.hostname ?? '0.0.0.0',
|
|
218
|
+
port: options.port ?? 3000,
|
|
219
|
+
});
|
|
220
|
+
return {
|
|
221
|
+
close: async () => {
|
|
222
|
+
await server.stop(true);
|
|
223
|
+
},
|
|
224
|
+
url: String(server.url),
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// surface
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
/**
|
|
231
|
+
* Build a Hono app from a topo and start serving it with Bun.
|
|
232
|
+
*/
|
|
233
|
+
export const surface = async (app, options = {}) => {
|
|
234
|
+
if (options.serve === false) {
|
|
235
|
+
throw new Error('surface() always serves the HTTP app; use createApp(graph) for an unserved Hono app');
|
|
236
|
+
}
|
|
237
|
+
// oxlint-disable-next-line require-await -- async ensures createApp() throws become rejected promises, not uncaught exceptions
|
|
238
|
+
const hono = createApp(app, options);
|
|
239
|
+
return startServer(hono, options);
|
|
240
|
+
};
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// trailhead
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
/**
|
|
245
|
+
* Build HTTP routes from a topo, create a Hono app, and optionally start serving.
|
|
246
|
+
*
|
|
247
|
+
* Topo validation runs before route construction — pass `validate: false`
|
|
248
|
+
* to skip it (e.g. during hot-reload or progressive startup).
|
|
249
|
+
*/
|
|
250
|
+
// oxlint-disable-next-line require-await -- async for consistency with other trailhead() entrypoints
|
|
251
|
+
export const trailhead = async (app, options = {}) => {
|
|
252
|
+
const hono = createApp(app, options);
|
|
253
|
+
if (options.serve !== false) {
|
|
254
|
+
startServer(hono, options);
|
|
255
|
+
}
|
|
256
|
+
return hono;
|
|
257
|
+
};
|
|
258
|
+
//# sourceMappingURL=trailhead.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trailhead.js","sourceRoot":"","sources":["../src/trailhead.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ontrails/hono",
|
|
3
|
+
"version": "1.0.0-beta.15",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./package.json": "./package.json"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -b",
|
|
11
|
+
"test": "bun test",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"lint": "oxlint ./src",
|
|
14
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@ontrails/core": "^1.0.0-beta.14",
|
|
18
|
+
"hono": "^4.7.0"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@ontrails/http": "^1.0.0-beta.14",
|
|
22
|
+
"zod": "^4.3.5"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { Result, trail, topo } from '@ontrails/core';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import { createApp, surface } from '../surface.js';
|
|
7
|
+
|
|
8
|
+
const echoTrail = trail('echo', {
|
|
9
|
+
blaze: (input) => Result.ok({ reply: input.message }),
|
|
10
|
+
input: z.object({ message: z.string() }),
|
|
11
|
+
intent: 'read',
|
|
12
|
+
output: z.object({ reply: z.string() }),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const tagsTrail = trail('tags', {
|
|
16
|
+
blaze: (input) => Result.ok({ tags: input.tags }),
|
|
17
|
+
input: z.object({ tags: z.array(z.string()) }),
|
|
18
|
+
intent: 'read',
|
|
19
|
+
output: z.object({ tags: z.array(z.string()) }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('surface API (Hono connector)', () => {
|
|
23
|
+
test('createApp materializes the Hono surface without serving', async () => {
|
|
24
|
+
const graph = topo('surface-api', { echoTrail });
|
|
25
|
+
const app = createApp(graph);
|
|
26
|
+
|
|
27
|
+
const response = await app.request('/echo?message=hello', {
|
|
28
|
+
method: 'GET',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(response.status).toBe(200);
|
|
32
|
+
expect(await response.json()).toEqual({ data: { reply: 'hello' } });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('surface starts a Bun server and returns a close handle', async () => {
|
|
36
|
+
const graph = topo('surface-api', { echoTrail });
|
|
37
|
+
const handle = await surface(graph, { port: 0 });
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(new URL('/echo?message=hello', handle.url));
|
|
41
|
+
expect(response.status).toBe(200);
|
|
42
|
+
expect(await response.json()).toEqual({ data: { reply: 'hello' } });
|
|
43
|
+
} finally {
|
|
44
|
+
await handle.close();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('repeated query keys arrive as arrays for GET routes', async () => {
|
|
49
|
+
const graph = topo('surface-api', { tagsTrail });
|
|
50
|
+
const app = createApp(graph);
|
|
51
|
+
|
|
52
|
+
const response = await app.request('/tags?tags=red&tags=blue', {
|
|
53
|
+
method: 'GET',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(response.status).toBe(200);
|
|
57
|
+
expect(await response.json()).toEqual({
|
|
58
|
+
data: { tags: ['red', 'blue'] },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('single query values stay scalar even when the schema expects an array', async () => {
|
|
63
|
+
const graph = topo('surface-api', { tagsTrail });
|
|
64
|
+
const app = createApp(graph);
|
|
65
|
+
|
|
66
|
+
const response = await app.request('/tags?tags=solo', {
|
|
67
|
+
method: 'GET',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(response.status).toBe(400);
|
|
71
|
+
expect(await response.json()).toMatchObject({
|
|
72
|
+
error: {
|
|
73
|
+
category: 'validation',
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/index.ts
ADDED
package/src/surface.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono connector for Trails HTTP routes.
|
|
3
|
+
*
|
|
4
|
+
* Takes framework-agnostic HttpRouteDefinition[] and wires them into a
|
|
5
|
+
* Hono application, handling request parsing, response mapping, and errors.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const graph = topo("myapp", entity);
|
|
9
|
+
* await surface(graph, { port: 3000 });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { isTrailsError, mapTransportError } from '@ontrails/core';
|
|
14
|
+
import type {
|
|
15
|
+
Intent,
|
|
16
|
+
Layer,
|
|
17
|
+
ResourceOverrideMap,
|
|
18
|
+
Topo,
|
|
19
|
+
TrailContextInit,
|
|
20
|
+
} from '@ontrails/core';
|
|
21
|
+
import { Hono } from 'hono';
|
|
22
|
+
import type { Context as HonoContext } from 'hono';
|
|
23
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
24
|
+
import { deriveHttpRoutes } from '@ontrails/http';
|
|
25
|
+
import type { HttpMethod, HttpRouteDefinition } from '@ontrails/http';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Options
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export interface CreateAppOptions {
|
|
32
|
+
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
|
+
readonly createContext?:
|
|
38
|
+
| (() => TrailContextInit | Promise<TrailContextInit>)
|
|
39
|
+
| undefined;
|
|
40
|
+
readonly exclude?: readonly string[] | undefined;
|
|
41
|
+
readonly hostname?: string | undefined;
|
|
42
|
+
readonly include?: readonly string[] | undefined;
|
|
43
|
+
readonly intent?: readonly Intent[] | undefined;
|
|
44
|
+
readonly layers?: readonly Layer[] | undefined;
|
|
45
|
+
readonly name?: string | undefined;
|
|
46
|
+
readonly port?: number | undefined;
|
|
47
|
+
readonly resources?: ResourceOverrideMap | undefined;
|
|
48
|
+
/** Set to `false` to skip topo validation at startup. Defaults to `true`. */
|
|
49
|
+
readonly validate?: boolean | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SurfaceHttpResult {
|
|
53
|
+
readonly close: () => Promise<void>;
|
|
54
|
+
readonly url: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Request parsing
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse query params into a plain object, preserving scalar-vs-array shape.
|
|
63
|
+
*
|
|
64
|
+
* A single `?tag=one` stays a scalar string, while repeated keys like
|
|
65
|
+
* `?tag=one&tag=two` become arrays. Schema validation owns whether that shape
|
|
66
|
+
* is accepted; the connector does not coerce singleton values into arrays.
|
|
67
|
+
*/
|
|
68
|
+
const parseQueryParams = (c: HonoContext): Record<string, unknown> => {
|
|
69
|
+
const result: Record<string, unknown> = {};
|
|
70
|
+
const url = new URL(c.req.url);
|
|
71
|
+
const seenKeys = new Set<string>();
|
|
72
|
+
|
|
73
|
+
for (const key of url.searchParams.keys()) {
|
|
74
|
+
if (seenKeys.has(key)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
seenKeys.add(key);
|
|
78
|
+
const all = url.searchParams.getAll(key);
|
|
79
|
+
result[key] = all.length > 1 ? all : all[0];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Sentinel indicating a JSON parse failure. */
|
|
86
|
+
const JSON_PARSE_ERROR = Symbol('JSON_PARSE_ERROR');
|
|
87
|
+
|
|
88
|
+
/** Return true when the request has no body content. */
|
|
89
|
+
const isEmptyBody = (c: HonoContext): boolean => {
|
|
90
|
+
const contentLength = c.req.header('Content-Length');
|
|
91
|
+
if (contentLength !== undefined) {
|
|
92
|
+
return Number.parseInt(contentLength, 10) === 0;
|
|
93
|
+
}
|
|
94
|
+
// No Content-Length header — treat as empty when Content-Type is also absent.
|
|
95
|
+
return c.req.header('Content-Type') === undefined;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Read input from request based on input source. */
|
|
99
|
+
const readInput = async (
|
|
100
|
+
c: HonoContext,
|
|
101
|
+
inputSource: 'query' | 'body'
|
|
102
|
+
): Promise<unknown> => {
|
|
103
|
+
if (inputSource === 'query') {
|
|
104
|
+
return parseQueryParams(c);
|
|
105
|
+
}
|
|
106
|
+
if (isEmptyBody(c)) {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return await c.req.json();
|
|
111
|
+
} catch {
|
|
112
|
+
return JSON_PARSE_ERROR;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Response mapping
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** Map a TrailsError or generic Error to an HTTP error response. */
|
|
121
|
+
const mapErrorResponse = (
|
|
122
|
+
error: Error
|
|
123
|
+
): { body: Record<string, unknown>; status: ContentfulStatusCode } => {
|
|
124
|
+
if (isTrailsError(error)) {
|
|
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
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
body: {
|
|
138
|
+
error: {
|
|
139
|
+
category: 'internal',
|
|
140
|
+
code: 'InternalError',
|
|
141
|
+
message: error.message,
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
status: 500,
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Route registration
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/** Map a Result to an HTTP response via Hono context. */
|
|
153
|
+
const mapResultToResponse = (
|
|
154
|
+
result: { isOk(): boolean; value?: unknown; error?: Error },
|
|
155
|
+
c: HonoContext
|
|
156
|
+
): Response => {
|
|
157
|
+
if (result.isOk()) {
|
|
158
|
+
return c.json({ data: result.value }, 200);
|
|
159
|
+
}
|
|
160
|
+
const { body, status } = mapErrorResponse(
|
|
161
|
+
result.error ?? new Error('Unknown error')
|
|
162
|
+
);
|
|
163
|
+
return c.json(body, status);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Convert a caught unknown value to an error response. */
|
|
167
|
+
const handleCaughtError = (error: unknown, c: HonoContext): Response => {
|
|
168
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
169
|
+
const { body, status } = mapErrorResponse(err);
|
|
170
|
+
return c.json(body, status);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** Create a Hono handler from a route definition. */
|
|
174
|
+
const createHonoHandler =
|
|
175
|
+
(route: HttpRouteDefinition) =>
|
|
176
|
+
async (c: HonoContext): Promise<Response> => {
|
|
177
|
+
const rawInput = await readInput(c, route.inputSource);
|
|
178
|
+
|
|
179
|
+
if (rawInput === JSON_PARSE_ERROR) {
|
|
180
|
+
return c.json(
|
|
181
|
+
{
|
|
182
|
+
error: {
|
|
183
|
+
category: 'validation',
|
|
184
|
+
code: 'ValidationError',
|
|
185
|
+
message: 'Invalid JSON in request body',
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
400
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const requestId = c.req.header('X-Request-ID') ?? undefined;
|
|
193
|
+
const { signal: abortSignal } = c.req.raw;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const result = await route.execute(rawInput, requestId, abortSignal);
|
|
197
|
+
return mapResultToResponse(result, c);
|
|
198
|
+
} catch (error: unknown) {
|
|
199
|
+
return handleCaughtError(error, c);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** Route registration keyed by HTTP method. */
|
|
204
|
+
const routeRegistrars: Record<
|
|
205
|
+
HttpMethod,
|
|
206
|
+
(
|
|
207
|
+
hono: Hono,
|
|
208
|
+
path: string,
|
|
209
|
+
handler: (c: HonoContext) => Promise<Response>
|
|
210
|
+
) => void
|
|
211
|
+
> = {
|
|
212
|
+
DELETE: (hono, path, handler) => {
|
|
213
|
+
hono.delete(path, handler);
|
|
214
|
+
},
|
|
215
|
+
GET: (hono, path, handler) => {
|
|
216
|
+
hono.get(path, handler);
|
|
217
|
+
},
|
|
218
|
+
POST: (hono, path, handler) => {
|
|
219
|
+
hono.post(path, handler);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const registerRoutes = (hono: Hono, routes: HttpRouteDefinition[]): void => {
|
|
224
|
+
for (const route of routes) {
|
|
225
|
+
const handler = createHonoHandler(route);
|
|
226
|
+
routeRegistrars[route.method](hono, route.path, handler);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Global error handler
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
const registerErrorHandler = (hono: Hono): void => {
|
|
235
|
+
// 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
|
+
);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Validation
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// createApp
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Build HTTP routes from a topo and register them on a Hono app.
|
|
260
|
+
*/
|
|
261
|
+
export const createApp = (
|
|
262
|
+
graph: Topo,
|
|
263
|
+
options: CreateAppOptions = {}
|
|
264
|
+
): Hono => {
|
|
265
|
+
const hono = new Hono();
|
|
266
|
+
|
|
267
|
+
registerErrorHandler(hono);
|
|
268
|
+
|
|
269
|
+
const routesResult = deriveHttpRoutes(graph, {
|
|
270
|
+
basePath: options.basePath,
|
|
271
|
+
configValues: options.configValues,
|
|
272
|
+
createContext: options.createContext,
|
|
273
|
+
exclude: options.exclude,
|
|
274
|
+
include: options.include,
|
|
275
|
+
intent: options.intent,
|
|
276
|
+
layers: options.layers,
|
|
277
|
+
resources: options.resources,
|
|
278
|
+
validate: options.validate,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (routesResult.isErr()) {
|
|
282
|
+
throw routesResult.error;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
registerRoutes(hono, routesResult.value);
|
|
286
|
+
return hono;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const startServer = (
|
|
290
|
+
hono: Hono,
|
|
291
|
+
options: CreateAppOptions
|
|
292
|
+
): SurfaceHttpResult => {
|
|
293
|
+
const server = Bun.serve({
|
|
294
|
+
fetch: hono.fetch,
|
|
295
|
+
hostname: options.hostname ?? '0.0.0.0',
|
|
296
|
+
port: options.port ?? 3000,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
close: async () => {
|
|
301
|
+
await server.stop(true);
|
|
302
|
+
},
|
|
303
|
+
url: String(server.url),
|
|
304
|
+
};
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// surface
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Build a Hono app from a topo and start serving it with Bun.
|
|
313
|
+
*
|
|
314
|
+
* @remarks Always starts a Bun server. Use `createApp(graph)` for an
|
|
315
|
+
* unserved Hono app that you can wire into your own server.
|
|
316
|
+
*/
|
|
317
|
+
export const surface = async (
|
|
318
|
+
graph: Topo,
|
|
319
|
+
options: CreateAppOptions = {}
|
|
320
|
+
): Promise<SurfaceHttpResult> => {
|
|
321
|
+
// oxlint-disable-next-line require-await -- async ensures createApp() throws become rejected promises, not uncaught exceptions
|
|
322
|
+
const hono = createApp(graph, options);
|
|
323
|
+
return startServer(hono, options);
|
|
324
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["./src/index.ts","./src/surface.ts"],"version":"5.9.3"}
|