@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 +45 -0
- package/README.md +48 -13
- package/package.json +21 -2
- package/src/build.ts +93 -9
- package/src/bun.ts +270 -0
- package/src/fetch.ts +602 -0
- package/src/index.ts +6 -0
- package/src/method.ts +11 -0
- package/src/testing.ts +339 -0
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
+
};
|