@ontrails/http 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 CHANGED
@@ -1,5 +1,50 @@
1
1
  # @ontrails/http
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
+ - 1eb5bdc: Rename first-class trail composition from the `cross` API family to the `compose` family across core contracts, testing helpers, topo projections, Warden rules, CLI scaffolds, and docs. `composes`, `ctx.compose`, `composeInput`, and `Compose*` type names are now the public authoring vocabulary; topo persistence migrates legacy composition rows and graph keys forward.
9
+ - 94a8380: Add public API examples for the shared Web Fetch route and topo handlers.
10
+ - 94a8380: Add a public API example for the HTTP intent method table.
11
+ - 84f56a5: Project live trail-version metadata on CLI, HTTP, and MCP surfaces and thread explicit surface version selection into shared trail execution.
12
+ - 5d88104: Polish Trails blaze terminology across package docs and Warden guidance.
13
+ - fc00aeb: Add adapter target conformance metadata and scaffold extracted HTTP adapters through `trails create adapter`.
14
+ - ab1c77c: Advertise first-party adapter target metadata for catalog derivation.
15
+ - 8ca5b85: Expose owner-owned HTTP adapter conformance cases from `@ontrails/http/testing`.
16
+ - Updated dependencies [e41c382]
17
+ - Updated dependencies [1eb5bdc]
18
+ - Updated dependencies [f8d80b9]
19
+ - Updated dependencies [846a597]
20
+ - Updated dependencies [223aaad]
21
+ - Updated dependencies [3125f4d]
22
+ - Updated dependencies [2494dc6]
23
+ - Updated dependencies [2d53717]
24
+ - Updated dependencies [16cb740]
25
+ - Updated dependencies [8894ecb]
26
+ - Updated dependencies [fdf7ec9]
27
+ - Updated dependencies [d76be13]
28
+ - Updated dependencies [84f56a5]
29
+ - Updated dependencies [431b04c]
30
+ - Updated dependencies [5d88104]
31
+ - Updated dependencies [f04a9ef]
32
+ - @ontrails/core@1.0.0-beta.19
33
+
34
+ ## 1.0.0-beta.18
35
+
36
+ ### Minor Changes
37
+
38
+ - c0b2948: Add `@ontrails/http/fetch`, a shared Web Fetch request/response kernel for HTTP
39
+ surface materializers.
40
+ - fc3219c: Add `@ontrails/http/bun`, a Bun-native HTTP surface materializer backed by the
41
+ shared Web Fetch kernel.
42
+
43
+ ### Patch Changes
44
+
45
+ - bc2d327: Close HTTP package documentation around the shared `@ontrails/http/fetch` kernel, Bun-native `@ontrails/http/bun` subpath, and Hono adapter boundary before versioning.
46
+ - @ontrails/core@1.0.0-beta.18
47
+
3
48
  ## 1.0.0-beta.17
4
49
 
5
50
  ### Patch Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @ontrails/http
2
2
 
3
- Framework-agnostic HTTP route derivation for Trails. Pair this package with `@ontrails/hono` when you want the Hono surface adapter.
3
+ Framework-agnostic HTTP route derivation and Web Fetch request handling for Trails. Pair this package with `@ontrails/hono` when you want Hono portability, or use `@ontrails/http/bun` when you want Bun-native serving without a third-party framework.
4
4
 
5
5
  ## Usage
6
6
 
@@ -22,6 +22,26 @@ await surface(graph, { port: 3000 });
22
22
 
23
23
  This starts a Hono-based HTTP server. The `greet` trail becomes `GET /greet?name=...` because its `intent` is `'read'`.
24
24
 
25
+ For Bun-native HTTP without Hono, use the Bun runtime materializer subpath:
26
+
27
+ ```typescript
28
+ import { surface } from '@ontrails/http/bun';
29
+
30
+ await surface(graph, { port: 3000 });
31
+ ```
32
+
33
+ `@ontrails/http/bun` uses Bun's native `Bun.serve({ routes })` fast path and keeps the shared Web Fetch handler as the fallback. It requires Bun `>=1.2.3` and does not add a third-party runtime dependency.
34
+
35
+ ## Projection and materialization
36
+
37
+ The HTTP package follows the surface API naming split:
38
+
39
+ - `derive*` exports are pure projections from the topo. Use `deriveHttpRoutes()` for route definitions and `deriveOpenApiSpec()` for the OpenAPI contract.
40
+ - `create*` exports materialize runtime objects without opening a network boundary. `@ontrails/http/fetch` exports `createRouteHandler()` for one route and `createFetchHandler()` for a full topo dispatcher.
41
+ - `surface()` opens the runtime boundary. `@ontrails/hono` opens a Hono server; `@ontrails/http/bun` opens Bun's native HTTP server.
42
+
43
+ The shared `@ontrails/http/fetch` kernel owns query/body parsing, content-length validation, public error projection, diagnostics, request IDs, headers, abort propagation, and webhook verification/parsing behavior. Hono and Bun both consume that kernel so route semantics stay aligned.
44
+
25
45
  For more control, build the routes yourself:
26
46
 
27
47
  ```typescript
@@ -44,8 +64,7 @@ import { deriveOpenApiSpec } from '@ontrails/http';
44
64
  const spec = deriveOpenApiSpec(graph, { basePath: '/api' });
45
65
  ```
46
66
 
47
- `deriveOpenApiSpec()` emits an OpenAPI 3.1 document from the same trail
48
- contracts used by `deriveHttpRoutes()`.
67
+ `deriveOpenApiSpec()` emits an OpenAPI 3.1 document from the same trail contracts used by `deriveHttpRoutes()`.
49
68
 
50
69
  ## API
51
70
 
@@ -53,6 +72,25 @@ contracts used by `deriveHttpRoutes()`.
53
72
  | --- | --- |
54
73
  | `deriveHttpRoutes(graph, options?)` | Build framework-agnostic route definitions from a topo |
55
74
  | `deriveOpenApiSpec(graph, options?)` | Generate an OpenAPI 3.1 document for the HTTP surface |
75
+ | `@ontrails/http/fetch` | Shared Web Fetch `createRouteHandler()` and `createFetchHandler()` kernel |
76
+ | `@ontrails/http/bun` | Bun-native `createApp()` and `surface()` materializer |
77
+ | `@ontrails/http/testing` | Owner-owned adapter conformance factory for HTTP adapter authors |
78
+
79
+ ## Adapter authoring
80
+
81
+ HTTP adapter authors should validate adapters through the owner-owned testing subpath instead of copying conformance behavior into each adapter:
82
+
83
+ ```typescript
84
+ import {
85
+ createHttpAdapterConformanceCases,
86
+ runConformance,
87
+ } from '@ontrails/http/testing';
88
+ import { myHttpAdapter } from './adapter.js';
89
+
90
+ runConformance(myHttpAdapter, createHttpAdapterConformanceCases());
91
+ ```
92
+
93
+ The adapter under test provides a `name` and `createApp(graph, options)` method that returns an object with a Web Fetch-compatible `fetch(request)` handler. The conformance cases cover query and body input projection, validation envelopes, public error redaction, request context, abort propagation, and webhook verification/parsing behavior.
56
94
 
57
95
  ## Route derivation
58
96
 
@@ -73,7 +111,7 @@ Trail IDs map to paths: `entity.show` becomes `/entity/show`. Dots become slashe
73
111
 
74
112
  ## Resource resolution
75
113
 
76
- Declared resources on each trail are resolved into the context before the implementation runs.
114
+ Declared resources on each trail are resolved into the context before the blaze receives input.
77
115
 
78
116
  ## Filtering
79
117
 
@@ -84,9 +122,7 @@ const result = deriveHttpRoutes(graph, {
84
122
  });
85
123
  ```
86
124
 
87
- `*` matches one dotted segment and `**` matches any depth. Trails declared
88
- with `visibility: 'internal'` stay hidden unless you include their exact trail
89
- ID intentionally.
125
+ `*` matches one dotted segment and `**` matches any depth. Trails declared with `visibility: 'internal'` stay hidden unless you include their exact trail ID intentionally.
90
126
 
91
127
  ## Request context and abort propagation
92
128
 
@@ -103,17 +139,16 @@ Each route definition produced by `deriveHttpRoutes` includes:
103
139
  | `trailId` | `string` | The trail ID this route was derived from |
104
140
  | `inputSource` | `'query' \| 'body'` | Where to read input |
105
141
  | `trail` | `Trail` | The original trail definition |
106
- | `execute` | `(input, requestId?, abortSignal?, context?) => Promise<Result>` | Validates, layers, resolves request auth when configured, and runs the implementation |
142
+ | `execute` | `(input, requestId?, abortSignal?, context?) => Promise<Result>` | Validates, layers, resolves request auth when configured, and runs the blazed trail |
107
143
 
108
- For GET routes on the Hono surface, repeated query keys are passed through as
109
- arrays (`?tag=one&tag=two` -> `{ tag: ['one', 'two'] }`) while a single
110
- occurrence stays a scalar string. The adapter does not coerce singleton query
111
- values into arrays.
144
+ For GET routes on the Hono surface, repeated query keys are passed through as arrays (`?tag=one&tag=two` -> `{ tag: ['one', 'two'] }`) while a single occurrence stays a scalar string. The adapter does not coerce singleton query values into arrays.
112
145
 
113
146
  ## Installation
114
147
 
115
148
  ```bash
116
- bun add @ontrails/http @ontrails/hono
149
+ bun add @ontrails/http@beta @ontrails/hono@beta
150
+ # or, for Bun-native serving:
151
+ bun add @ontrails/http@beta
117
152
  ```
118
153
 
119
154
  ## Migration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/http",
3
- "version": "1.0.0-beta.17",
3
+ "version": "1.0.0-beta.19",
4
4
  "files": [
5
5
  "src/**/*.ts",
6
6
  "!src/**/__tests__/**",
@@ -12,6 +12,9 @@
12
12
  "type": "module",
13
13
  "exports": {
14
14
  ".": "./src/index.ts",
15
+ "./bun": "./src/bun.ts",
16
+ "./fetch": "./src/fetch.ts",
17
+ "./testing": "./src/testing.ts",
15
18
  "./package.json": "./package.json"
16
19
  },
17
20
  "scripts": {
@@ -22,9 +25,25 @@
22
25
  "clean": "rm -rf dist *.tsbuildinfo"
23
26
  },
24
27
  "dependencies": {
25
- "@ontrails/core": "^1.0.0-beta.16"
28
+ "@ontrails/core": "^1.0.0-beta.19"
26
29
  },
27
30
  "peerDependencies": {
28
31
  "zod": "^4.3.5"
32
+ },
33
+ "trails": {
34
+ "adapterTargets": {
35
+ "http": {
36
+ "conformance": {
37
+ "adapterType": "HttpAdapterConformanceAdapter",
38
+ "casesFactory": "createHttpAdapterConformanceCases",
39
+ "runner": "runConformance"
40
+ },
41
+ "placements": [
42
+ "extracted",
43
+ "subpath"
44
+ ],
45
+ "testingImport": "@ontrails/http/testing"
46
+ }
47
+ }
29
48
  }
30
49
  }
package/src/build.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  ValidationError,
13
13
  buildActivationProvenanceTraceAttrs,
14
14
  collectAttachedTypedLayers,
15
+ deriveSurfaceTrailVersionProjections,
15
16
  executeTrail,
16
17
  filterSurfaceTrails,
17
18
  getActivationWherePredicate,
@@ -39,9 +40,11 @@ import type {
39
40
  BaseSurfaceOptions,
40
41
  Layer,
41
42
  ResourceOverrideMap,
43
+ SurfaceTrailVersionProjection,
42
44
  TraceContext,
43
45
  Topo,
44
46
  Trail,
47
+ TrailVersionReference,
45
48
  TrailContextInit,
46
49
  WebhookSource,
47
50
  WebhookVerifyRequest,
@@ -72,6 +75,7 @@ export type HttpHeaderSource =
72
75
 
73
76
  export interface HttpExecutionContext {
74
77
  readonly headers?: HttpHeaderSource | undefined;
78
+ readonly version?: TrailVersionReference | undefined;
75
79
  }
76
80
 
77
81
  export interface ResolveHttpPermitInput {
@@ -93,6 +97,7 @@ export interface HttpRouteDefinition {
93
97
  readonly trailId: string;
94
98
  readonly inputSource: InputSource;
95
99
  readonly trail: Trail<unknown, unknown, unknown>;
100
+ readonly versions?: readonly SurfaceTrailVersionProjection[] | undefined;
96
101
  /**
97
102
  * JSON Schema for the merged request input (trail input + projected layer
98
103
  * input fields). Empty/undefined when the trail declares no input and no
@@ -126,7 +131,7 @@ export interface HttpRouteDefinition {
126
131
  | undefined;
127
132
  readonly webhookSource?: WebhookSource | undefined;
128
133
  /**
129
- * Validate input, compose layers, and execute the trail implementation.
134
+ * Validate input, compose layers, and run the blazed trail.
130
135
  *
131
136
  * The caller is responsible for parsing raw input from the request and
132
137
  * mapping the Result to an HTTP response. This function is framework-agnostic.
@@ -572,6 +577,67 @@ const mergeHttpInputSchemas = (
572
577
  return merged;
573
578
  };
574
579
 
580
+ const TRAIL_VERSION_INPUT_FIELD = 'trailVersion';
581
+ const TRAILS_VERSION_HEADERS = ['x-trails-version', 'x-trail-version'];
582
+
583
+ const versionInputSchema = (): Record<string, unknown> => ({
584
+ properties: {
585
+ [TRAIL_VERSION_INPUT_FIELD]: {
586
+ description: 'Live trail version number or marker prefix',
587
+ type: 'string',
588
+ },
589
+ },
590
+ type: 'object',
591
+ });
592
+
593
+ const addVersionInputSchema = (
594
+ trail: Trail<unknown, unknown, unknown>,
595
+ schema: Record<string, unknown> | undefined
596
+ ): Record<string, unknown> | undefined =>
597
+ trail.version === undefined
598
+ ? schema
599
+ : mergeHttpInputSchemas(schema, versionInputSchema());
600
+
601
+ const readVersionFromHeaders = (
602
+ headers: HttpHeaderSource | undefined
603
+ ): TrailVersionReference | undefined => {
604
+ for (const name of TRAILS_VERSION_HEADERS) {
605
+ const value = readHeader(headers, name);
606
+ if (value !== undefined && value.length > 0) {
607
+ return value;
608
+ }
609
+ }
610
+ return undefined;
611
+ };
612
+
613
+ const splitHttpSurfaceVersion = (
614
+ input: unknown,
615
+ context: HttpExecutionContext | undefined,
616
+ supportsVersions: boolean
617
+ ): {
618
+ readonly input: unknown;
619
+ readonly version: TrailVersionReference | undefined;
620
+ } => {
621
+ if (!supportsVersions) {
622
+ return { input, version: undefined };
623
+ }
624
+
625
+ const headerVersion =
626
+ context?.version ?? readVersionFromHeaders(context?.headers);
627
+ if (!isJsonObjectSchema(input)) {
628
+ return { input, version: headerVersion };
629
+ }
630
+
631
+ const record = input as Record<string, unknown>;
632
+ const { [TRAIL_VERSION_INPUT_FIELD]: fieldVersion, ...rest } = record;
633
+ const version =
634
+ headerVersion ??
635
+ (typeof fieldVersion === 'string' || typeof fieldVersion === 'number'
636
+ ? fieldVersion
637
+ : undefined);
638
+ return { input: rest, version };
639
+ };
640
+
575
641
  /**
576
642
  * Partition a parsed request input into the trail input plus per-layer
577
643
  * inputs, using each layer's routing table.
@@ -639,8 +705,13 @@ const createExecute =
639
705
  layerProjections: readonly HttpLayerInputProjection[]
640
706
  ): HttpRouteDefinition['execute'] =>
641
707
  async (input, requestId, abortSignal, request) => {
642
- const { trailInput, layerInputs } = partitionHttpInput(
708
+ const versionedInput = splitHttpSurfaceVersion(
643
709
  input,
710
+ request,
711
+ t.version !== undefined
712
+ );
713
+ const { trailInput, layerInputs } = partitionHttpInput(
714
+ versionedInput.input,
644
715
  layerProjections
645
716
  );
646
717
  const permitResolution = await resolveHttpPermit(
@@ -664,6 +735,9 @@ const createExecute =
664
735
  surfaceLayers: layers,
665
736
  topo: graph,
666
737
  topoLayers: graph.layers,
738
+ ...(versionedInput.version === undefined
739
+ ? {}
740
+ : { version: versionedInput.version }),
667
741
  });
668
742
  };
669
743
 
@@ -721,8 +795,13 @@ const createWebhookConsumerExecute =
721
795
  'activation.webhook',
722
796
  'ok'
723
797
  );
724
- const { trailInput, layerInputs } = partitionHttpInput(
798
+ const versionedInput = splitHttpSurfaceVersion(
725
799
  input,
800
+ request,
801
+ t.version !== undefined
802
+ );
803
+ const { trailInput, layerInputs } = partitionHttpInput(
804
+ versionedInput.input,
726
805
  layerProjections
727
806
  );
728
807
  const permitResolution = await resolveHttpPermit(
@@ -746,6 +825,9 @@ const createWebhookConsumerExecute =
746
825
  surfaceLayers: layers,
747
826
  topo: graph,
748
827
  topoLayers: graph.layers,
828
+ ...(versionedInput.version === undefined
829
+ ? {}
830
+ : { version: versionedInput.version }),
749
831
  });
750
832
  };
751
833
 
@@ -836,6 +918,8 @@ const buildRoute = (
836
918
  options.layers
837
919
  );
838
920
  const inputProjection = projectHttpInputSchema(trail, attachedLayers);
921
+ const inputSchema = addVersionInputSchema(trail, inputProjection.schema);
922
+ const versions = deriveSurfaceTrailVersionProjections(trail);
839
923
  return {
840
924
  execute: createExecute(
841
925
  graph,
@@ -844,9 +928,7 @@ const buildRoute = (
844
928
  options,
845
929
  inputProjection.projections
846
930
  ),
847
- ...(inputProjection.schema === undefined
848
- ? {}
849
- : { inputSchema: inputProjection.schema }),
931
+ ...(inputSchema === undefined ? {} : { inputSchema }),
850
932
  inputSource: deriveHttpInputSource(method),
851
933
  ...(inputProjection.projections.length === 0
852
934
  ? {}
@@ -855,6 +937,7 @@ const buildRoute = (
855
937
  path,
856
938
  trail,
857
939
  trailId: trail.id,
940
+ ...(versions === undefined ? {} : { versions }),
858
941
  };
859
942
  };
860
943
 
@@ -973,6 +1056,8 @@ const buildWebhookRoute = (
973
1056
  options.layers
974
1057
  );
975
1058
  const inputProjection = projectHttpInputSchema(trail, attachedLayers);
1059
+ const inputSchema = addVersionInputSchema(trail, inputProjection.schema);
1060
+ const versions = deriveSurfaceTrailVersionProjections(trail);
976
1061
  const consumerExecute = createWebhookConsumerExecute(
977
1062
  graph,
978
1063
  trail,
@@ -991,9 +1076,7 @@ const buildWebhookRoute = (
991
1076
  [WEBHOOK_CONSUMERS]: [consumerExecute],
992
1077
  [WEBHOOK_INVALID_RECORDERS]: [consumerInvalidRecorder],
993
1078
  execute: createWebhookExecute(consumerExecute),
994
- ...(inputProjection.schema === undefined
995
- ? {}
996
- : { inputSchema: inputProjection.schema }),
1079
+ ...(inputSchema === undefined ? {} : { inputSchema }),
997
1080
  inputSource: 'webhook',
998
1081
  ...(inputProjection.projections.length === 0
999
1082
  ? {}
@@ -1007,6 +1090,7 @@ const buildWebhookRoute = (
1007
1090
  trail,
1008
1091
  trailId: trail.id,
1009
1092
  verifyWebhook: (request) => verifyWebhookRequest(source.value, request),
1093
+ ...(versions === undefined ? {} : { versions }),
1010
1094
  webhookSource: source.value,
1011
1095
  };
1012
1096
  return Result.ok(route);
package/src/bun.ts ADDED
@@ -0,0 +1,270 @@
1
+ import {
2
+ InternalError,
3
+ NotFoundError,
4
+ Result,
5
+ projectPublicSurfaceError,
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 projection = projectPublicSurfaceError('http', error);
65
+ return json(
66
+ {
67
+ error: {
68
+ category: projection.category,
69
+ code: projection.name,
70
+ message: projection.message,
71
+ },
72
+ },
73
+ projection.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
+ blaze: () =>
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
+ };