@ontrails/http
v0.2.3
Published
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.
Downloads
1,008
Readme
@ontrails/http
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.
Usage
import { trail, topo, Result } from '@ontrails/core';
import { surface } from '@ontrails/hono';
import { z } from 'zod';
const greet = trail('greet', {
input: z.object({ name: z.string().describe('Who to greet') }),
output: z.object({ message: z.string() }),
intent: 'read',
implementation: (input) => Result.ok({ message: `Hello, ${input.name}!` }),
});
const graph = topo('myapp', { greet });
await surface(graph, { port: 3000 });This starts a Hono-based HTTP server. The greet trail becomes GET /greet?name=... because its intent is 'read'.
For Bun-native HTTP without Hono, use the Bun-native HTTP binding subpath:
import { surface } from '@ontrails/http/bun';
await surface(graph, { port: 3000 });@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.
Rendering and runtime binding
The HTTP package follows the surface API naming split:
derive*exports are pure renderings from the topo. UsederiveHttpRoutes()for route definitions andderiveOpenApiSpec()for the OpenAPI contract.create*exports build runtime objects without opening a network boundary.@ontrails/http/fetchexportscreateRouteHandler()for one route andcreateFetchHandler()for a full topo dispatcher.surface()opens the runtime boundary.@ontrails/honoopens an adapter binding to Hono;@ontrails/http/bunopens the native Bun HTTP binding.
The shared @ontrails/http/fetch kernel owns query/body parsing, content-length validation, public error rendering, diagnostics, request IDs, headers, abort propagation, and webhook verification/parsing behavior. Hono and Bun both consume that kernel so route semantics stay aligned.
For more control, build the routes yourself:
import { deriveHttpRoutes } from '@ontrails/http';
const result = deriveHttpRoutes(graph);
if (result.isErr()) throw result.error; // ValidationError on route collision
for (const route of result.value) {
console.log(`${route.method} ${route.path} → ${route.trailId}`);
}deriveHttpRoutes returns Result<HttpRouteDefinition[], Error> rather than a bare array. It returns Result.err(ValidationError) if two trails derive the same (method, path) pair.
OpenAPI is the HTTP surface's persisted client contract rendering:
import { deriveOpenApiSpec } from '@ontrails/http';
const spec = deriveOpenApiSpec(graph, { basePath: '/api' });deriveOpenApiSpec() emits an OpenAPI 3.1 document from the same trail contracts used by deriveHttpRoutes().
API
| Export | What it does |
| --- | --- |
| deriveHttpRoutes(graph, options?) | Build framework-agnostic route definitions from a topo |
| deriveOpenApiSpec(graph, options?) | Generate an OpenAPI 3.1 document for the HTTP surface |
| @ontrails/http/fetch | Shared Web Fetch createRouteHandler() and createFetchHandler() kernel |
| @ontrails/http/bun | Bun-native createApp() and surface() binding |
| @ontrails/http/testing | Owner-owned adapter conformance factory for HTTP adapter authors |
Adapter authoring
HTTP adapter authors should validate adapters through the owner-owned testing subpath instead of copying conformance behavior into each adapter:
import {
createHttpAdapterConformanceCases,
runConformance,
} from '@ontrails/http/testing';
import { myHttpAdapter } from './adapter.js';
runConformance(myHttpAdapter, createHttpAdapterConformanceCases());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 rendering, validation envelopes, public error redaction, request context, abort propagation, and webhook verification/parsing behavior.
Route derivation
Trail intent maps directly to HTTP method and input source:
| Trail field | HTTP method | Input source |
| --- | --- | --- |
| intent: 'read' | GET | Query string |
| intent: 'write' | POST | JSON body |
| intent: 'destroy' | DELETE | JSON body |
| (none) | POST | JSON body |
Trail IDs map to paths: entity.show becomes /entity/show. Dots become slashes, everything lowercase.
Collision detection
deriveHttpRoutes detects when two trails would produce the same (method, path) pair and returns Result.err(ValidationError) describing both trail IDs. The surface() helper from @ontrails/hono throws on collision.
Resource resolution
Declared resources on each trail are resolved into the context before the implementation receives input.
Filtering
const result = deriveHttpRoutes(graph, {
include: ['entity.**'],
exclude: ['dev.**'],
});* matches one dotted segment and ** matches any depth. Trails declared with visibility: 'internal' stay hidden unless you include their exact trail ID intentionally.
Request context and abort propagation
The execute function on each HttpRouteDefinition accepts optional requestId, abortSignal, and request context arguments. HTTP adapters should pass the request's AbortSignal so client disconnects propagate into trail execution, and pass headers in the request context when Bearer auth should resolve into ctx.permit.
HttpRouteDefinition
Each route definition produced by deriveHttpRoutes includes:
| Field | Type | What it is |
| --- | --- | --- |
| method | 'GET' \| 'POST' \| 'DELETE' | HTTP method |
| path | string | Derived path (e.g. /entity/show) |
| trailId | string | The trail ID this route was derived from |
| inputSource | 'query' \| 'body' | Where to read input |
| trail | Trail | The original trail definition |
| execute | (input, requestId?, abortSignal?, context?) => Promise<Result> | Validates, layers, resolves request auth when configured, and runs the trail |
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.
GET query values declared as numbers or booleans are converted at the HTTP boundary before schema validation. This includes primitive literals and union or nullable schemas whose non-null branches all resolve to the same primitive kind. Root object unions convert a field only when every branch that has its required fields present explicitly owns the field with the same primitive shape; otherwise the raw value is preserved. This keeps unknown and passthrough fields unchanged without choosing a union branch. Fields authored with Zod coercion receive the raw query value so their authored parser retains the same behavior as direct and library invocation; if any supported union branch for a field uses coercion, the boundary conservatively preserves that field. Numbers use JSON number syntax and must be finite; booleans accept the exact spellings true and false. Malformed values and the string null continue through normal validation and return a 400 response when the authored schema rejects them. Declared strings remain strings. Repeated keys for declared primitive arrays, including homogeneous union or nullable array schemas, apply the same conversion to each element, while a singleton remains a scalar and must satisfy the authored schema as-is.
For versioned trails, query conversion resolves the selected version's input schema. X-Trails-Version and X-Trail-Version headers take precedence over the trailVersion query field, matching execution. If a historical input field conflicts with a layer parameter name rendered for the current version, the boundary preserves raw query strings so it does not guess which schema owns the field.
Installation
These installation examples target Trails 0.2.1 on the normal npm release line.
bun add --exact @ontrails/[email protected] @ontrails/[email protected]
# or, for Bun-native serving:
bun add --exact @ontrails/[email protected]Migration
Hono integration now lives in @ontrails/hono.
- Replace
import { trailhead } from '@ontrails/http/hono'withimport { surface } from '@ontrails/hono' - Keep
deriveHttpRoutes()and the route model imports on@ontrails/http
