@ontrails/http 0.2.0
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 +570 -0
- package/README.md +169 -0
- package/package.json +59 -0
- package/src/blob-output.ts +31 -0
- package/src/build.ts +1552 -0
- package/src/bun.ts +270 -0
- package/src/fetch.ts +1047 -0
- package/src/index.ts +28 -0
- package/src/method.ts +68 -0
- package/src/openapi.ts +383 -0
- package/src/query-coercion.ts +150 -0
- package/src/testing.ts +378 -0
package/src/bun.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InternalError,
|
|
3
|
+
NotFoundError,
|
|
4
|
+
Result,
|
|
5
|
+
renderPublicSurfaceError,
|
|
6
|
+
trail,
|
|
7
|
+
} from '@ontrails/core';
|
|
8
|
+
import type {
|
|
9
|
+
BaseSurfaceOptions,
|
|
10
|
+
Layer,
|
|
11
|
+
ResourceOverrideMap,
|
|
12
|
+
Topo,
|
|
13
|
+
Trail,
|
|
14
|
+
TrailContextInit,
|
|
15
|
+
} from '@ontrails/core';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
|
|
18
|
+
import { deriveHttpRoutes } from './build.js';
|
|
19
|
+
import type {
|
|
20
|
+
HttpMethod,
|
|
21
|
+
HttpRouteDefinition,
|
|
22
|
+
ResolveHttpPermit,
|
|
23
|
+
} from './build.js';
|
|
24
|
+
import { createRouteHandler } from './fetch.js';
|
|
25
|
+
import type { CreateRouteHandlerOptions } from './fetch.js';
|
|
26
|
+
|
|
27
|
+
export interface CreateAppOptions extends BaseSurfaceOptions {
|
|
28
|
+
readonly basePath?: string | undefined;
|
|
29
|
+
readonly createContext?:
|
|
30
|
+
| (() => TrailContextInit | Promise<TrailContextInit>)
|
|
31
|
+
| undefined;
|
|
32
|
+
readonly hostname?: string | undefined;
|
|
33
|
+
readonly layers?: readonly Layer[] | undefined;
|
|
34
|
+
/** Maximum JSON request body size in bytes. Defaults to 1 MiB. */
|
|
35
|
+
readonly maxJsonBodyBytes?: number | undefined;
|
|
36
|
+
readonly port?: number | undefined;
|
|
37
|
+
readonly resources?: ResourceOverrideMap | undefined;
|
|
38
|
+
readonly resolvePermit?: ResolveHttpPermit | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SurfaceHttpResult {
|
|
42
|
+
readonly close: () => Promise<void>;
|
|
43
|
+
readonly url: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type RouteHandler = (request: Request) => Promise<Response>;
|
|
47
|
+
|
|
48
|
+
type BunRouteMethod = HttpMethod | 'HEAD';
|
|
49
|
+
type BunRouteRecord = Record<
|
|
50
|
+
string,
|
|
51
|
+
Partial<Record<BunRouteMethod, RouteHandler>>
|
|
52
|
+
>;
|
|
53
|
+
|
|
54
|
+
export interface BunHttpApp {
|
|
55
|
+
readonly fetch: RouteHandler;
|
|
56
|
+
readonly onError: (error: Error) => Promise<Response>;
|
|
57
|
+
readonly routes: BunRouteRecord;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const json = (body: Record<string, unknown>, status: number): Response =>
|
|
61
|
+
Response.json(body, { status });
|
|
62
|
+
|
|
63
|
+
const mapErrorResponse = (error: Error): Response => {
|
|
64
|
+
const rendering = renderPublicSurfaceError('http', error);
|
|
65
|
+
return json(
|
|
66
|
+
{
|
|
67
|
+
error: {
|
|
68
|
+
category: rendering.category,
|
|
69
|
+
code: rendering.name,
|
|
70
|
+
message: rendering.message,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
rendering.code
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const notFoundResponse = (request: Request): Response => {
|
|
78
|
+
const path = new URL(request.url).pathname;
|
|
79
|
+
return mapErrorResponse(new NotFoundError(`HTTP route not found: ${path}`));
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const methodNotAllowedResponse = (
|
|
83
|
+
request: Request,
|
|
84
|
+
route: Partial<Record<BunRouteMethod, RouteHandler>>
|
|
85
|
+
): Response => {
|
|
86
|
+
const path = new URL(request.url).pathname;
|
|
87
|
+
return Response.json(
|
|
88
|
+
{
|
|
89
|
+
error: {
|
|
90
|
+
category: 'validation',
|
|
91
|
+
code: 'MethodNotAllowed',
|
|
92
|
+
message: `HTTP method not allowed: ${request.method.toUpperCase()} ${path}`,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
headers: { Allow: Object.keys(route).toSorted().join(', ') },
|
|
97
|
+
status: 405,
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const bodylessHeadResponse = (response: Response): Response =>
|
|
103
|
+
new Response(null, {
|
|
104
|
+
headers: response.headers,
|
|
105
|
+
status: response.status,
|
|
106
|
+
statusText: response.statusText,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const caughtErrors = new Map<string, Error>();
|
|
110
|
+
const caughtErrorInput = z.object({ errorId: z.string() });
|
|
111
|
+
const caughtErrorTrail = trail('__ontrails.http.bun.error', {
|
|
112
|
+
implementation: () =>
|
|
113
|
+
Result.err(new InternalError('Bun error fallback executed directly')),
|
|
114
|
+
input: caughtErrorInput,
|
|
115
|
+
intent: 'read',
|
|
116
|
+
output: z.object({}),
|
|
117
|
+
}) as Trail<unknown, unknown, unknown>;
|
|
118
|
+
|
|
119
|
+
const caughtErrorRoute: HttpRouteDefinition = {
|
|
120
|
+
execute: async (input) => {
|
|
121
|
+
const parsed = caughtErrorInput.safeParse(input);
|
|
122
|
+
if (!parsed.success) {
|
|
123
|
+
return Result.err(
|
|
124
|
+
new InternalError('Bun error fallback missing error id')
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const error =
|
|
128
|
+
caughtErrors.get(parsed.data.errorId) ??
|
|
129
|
+
new Error('Bun error fallback missing caught error');
|
|
130
|
+
return Result.err(error);
|
|
131
|
+
},
|
|
132
|
+
inputSource: 'query',
|
|
133
|
+
method: 'GET',
|
|
134
|
+
path: '/__ontrails/http/bun/error',
|
|
135
|
+
trail: caughtErrorTrail,
|
|
136
|
+
trailId: '__ontrails.http.bun.error',
|
|
137
|
+
};
|
|
138
|
+
const caughtErrorHandler = createRouteHandler(caughtErrorRoute);
|
|
139
|
+
|
|
140
|
+
const materializeCaughtErrorRequest = (errorId: string): Request => {
|
|
141
|
+
const url = new URL('/__ontrails/http/bun/error', 'http://localhost');
|
|
142
|
+
url.searchParams.set('errorId', errorId);
|
|
143
|
+
return new Request(url);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const deriveOptions = (options: CreateAppOptions) => ({
|
|
147
|
+
basePath: options.basePath,
|
|
148
|
+
configValues: options.configValues,
|
|
149
|
+
createContext: options.createContext,
|
|
150
|
+
exclude: options.exclude,
|
|
151
|
+
include: options.include,
|
|
152
|
+
intent: options.intent,
|
|
153
|
+
layers: options.layers,
|
|
154
|
+
resolvePermit: options.resolvePermit,
|
|
155
|
+
resources: options.resources,
|
|
156
|
+
validate: options.validate,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const routeHandlerOptions = (
|
|
160
|
+
options: CreateAppOptions
|
|
161
|
+
): CreateRouteHandlerOptions => ({
|
|
162
|
+
maxJsonBodyBytes: options.maxJsonBodyBytes,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const registerRoute = (
|
|
166
|
+
routes: BunRouteRecord,
|
|
167
|
+
route: HttpRouteDefinition,
|
|
168
|
+
options: CreateRouteHandlerOptions
|
|
169
|
+
): void => {
|
|
170
|
+
const methods = routes[route.path] ?? {};
|
|
171
|
+
const handler = createRouteHandler(route, options);
|
|
172
|
+
methods[route.method] = handler;
|
|
173
|
+
if (route.method === 'GET') {
|
|
174
|
+
methods.HEAD = async (request) => {
|
|
175
|
+
const response = await handler(request);
|
|
176
|
+
return bodylessHeadResponse(response);
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
routes[route.path] = methods;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const routeForRequest = (
|
|
183
|
+
routes: BunRouteRecord,
|
|
184
|
+
request: Request
|
|
185
|
+
): Partial<Record<BunRouteMethod, RouteHandler>> | undefined => {
|
|
186
|
+
const path = new URL(request.url).pathname;
|
|
187
|
+
return routes[path];
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build Bun-compatible HTTP route handlers from a topo.
|
|
192
|
+
*
|
|
193
|
+
* @remarks This materializes `deriveHttpRoutes` onto Bun's native `routes`
|
|
194
|
+
* table while preserving `fetch` as the fallback path for unmatched requests.
|
|
195
|
+
*/
|
|
196
|
+
export const createApp = (
|
|
197
|
+
graph: Topo,
|
|
198
|
+
options: CreateAppOptions = {}
|
|
199
|
+
): BunHttpApp => {
|
|
200
|
+
const routesResult = deriveHttpRoutes(graph, deriveOptions(options));
|
|
201
|
+
|
|
202
|
+
if (routesResult.isErr()) {
|
|
203
|
+
throw routesResult.error;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const handlerOptions = routeHandlerOptions(options);
|
|
207
|
+
const routes: BunRouteRecord = {};
|
|
208
|
+
for (const route of routesResult.value) {
|
|
209
|
+
registerRoute(routes, route, handlerOptions);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
fetch: async (request) => {
|
|
214
|
+
const method = request.method.toUpperCase() as BunRouteMethod;
|
|
215
|
+
const route = routeForRequest(routes, request);
|
|
216
|
+
if (route === undefined) {
|
|
217
|
+
const response = notFoundResponse(request);
|
|
218
|
+
return method === 'HEAD' ? bodylessHeadResponse(response) : response;
|
|
219
|
+
}
|
|
220
|
+
const methodHandler = route[method];
|
|
221
|
+
const response =
|
|
222
|
+
methodHandler === undefined
|
|
223
|
+
? methodNotAllowedResponse(request, route)
|
|
224
|
+
: await methodHandler(request);
|
|
225
|
+
return method === 'HEAD' ? bodylessHeadResponse(response) : response;
|
|
226
|
+
},
|
|
227
|
+
onError: async (error) => {
|
|
228
|
+
const errorId = crypto.randomUUID();
|
|
229
|
+
caughtErrors.set(errorId, error);
|
|
230
|
+
try {
|
|
231
|
+
return await caughtErrorHandler(materializeCaughtErrorRequest(errorId));
|
|
232
|
+
} finally {
|
|
233
|
+
caughtErrors.delete(errorId);
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
routes,
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const startServer = (
|
|
241
|
+
app: BunHttpApp,
|
|
242
|
+
options: CreateAppOptions
|
|
243
|
+
): SurfaceHttpResult => {
|
|
244
|
+
const server = Bun.serve({
|
|
245
|
+
error: app.onError,
|
|
246
|
+
fetch: app.fetch,
|
|
247
|
+
hostname: options.hostname ?? '0.0.0.0',
|
|
248
|
+
port: options.port ?? 3000,
|
|
249
|
+
routes: app.routes,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
close: async () => {
|
|
254
|
+
await server.stop(true);
|
|
255
|
+
},
|
|
256
|
+
url: String(server.url),
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Build a Bun-native HTTP app from a topo and start serving it.
|
|
262
|
+
*/
|
|
263
|
+
export const surface = async (
|
|
264
|
+
graph: Topo,
|
|
265
|
+
options: CreateAppOptions = {}
|
|
266
|
+
): Promise<SurfaceHttpResult> => {
|
|
267
|
+
// oxlint-disable-next-line require-await -- async ensures createApp() throws become rejected promises, not uncaught exceptions
|
|
268
|
+
const app = createApp(graph, options);
|
|
269
|
+
return startServer(app, options);
|
|
270
|
+
};
|