@structure-ai/http
v0.4.1
Published
HTTP routes and handlers on @effect/platform HttpApi: OpenAPI docs, health probes, error mapping, CQRS bridge, Bun server.
Readme
@structure-ai/http
HTTP surface for @structure apps: schema-typed HttpApi routes over @effect/platform, OpenAPI docs, health probes, request correlation, problem-details error mapping, a CQRS endpoint bridge, graceful Bun serving — and rate-limiting middleware with shared or in-process buckets.
Quick start
import { Api, ApiEndpoint, ApiGroup, serve } from "@structure-ai/http";
import { Readiness } from "@structure-ai/runtime";
import { Effect, Layer, Schema } from "effect";
const hello = ApiGroup.make("hello").add(
ApiEndpoint.get("greet", "/hello").addSuccess(Schema.Struct({ hello: Schema.String })),
);
const api = Api.make("demo").add(hello);
serve({ port: 3000 }).pipe(
Layer.provide(HttpApiBuilder.api(api).pipe(Layer.provide(HelloLive))),
Layer.provide(Readiness.layer),
Layer.launch,
);serve installs the standard middleware stack — correlation ids, one structured log line per request, boundary metrics, problem mapping — and graceful shutdown (readiness flips unready before the listener stops).
Configurable middleware stack
The standard stack is the default; a middleware option on serve/serveTestWith adapts it without giving up the graceful Bun composition (mounts, static dispatch, readiness flip and drain stay in place):
serve({
port: 3000,
middleware: {
correlation: {
incoming: "reject", // never trust incoming ids
headers: { request: "X-Trace-Id", correlation: null }, // emit one, custom name
},
},
});middleware.correlation.incoming—"sanitize"(default, today's behavior),"reject"(always mint fresh ids), or a predicate(id) => booleankeeping only the ids it accepts. Header-safety (isSafeId) always applies: an unsafe id is never echoed, whatever the policy.middleware.correlation.generate— mint ids with your own generator (defaultCorrelation.newId).middleware.correlation.headers— response header names: a string overrides the standardx-request-id/x-correlation-id,nullsuppresses that header. Incoming ids are always read from the standard names.
To reproduce an existing boundary contract exactly, middleware.boundary replaces the whole standard stack (correlation, logging, metrics, problem mapping) with your own middleware function. It receives the app with mounts and static dispatch already composed, plus the mounted api (for Middleware.routeLabel), and whatever it produces is served by the same server:
serve({
port: 3000,
middleware: {
boundary: (app, api) =>
Middleware.standard(app, {
routeLabel: Middleware.routeLabel(api),
correlation: { incoming: (id) => id.startsWith("acc-") },
}),
},
});A replacement owns its failure mapping: unmatched routes fail RouteNotFound into the replacement, and the platform answers an unhandled one with its own empty 404 — bring Middleware.problems (as Middleware.standard does) when the problem taxonomy is wanted. correlation cannot be combined with boundary (the replacement owns its own); invalid options (malformed header names, duplicate names, unknown policies) fail the layer at startup with InvalidMiddlewareOptions.
Boundary telemetry
Every request produces one http request log line and a set of metrics, all labelled by the matched endpoint template (/things/:id), never by the requested path — a path segment can carry a share secret or an invitation token, and a template keeps metric cardinality bounded. Requests that match no endpoint are labelled (unmatched), so probed URLs never reach the logs either.
| Signal | Labels / annotations |
| --- | --- |
| log line http request (info) | method, route, status, durationMs, requestId, correlationId — never the path, query, headers or bodies |
| http_server_calls_total, http_server_errors_total, http_server_duration_ms | method, route |
| http_request_duration_seconds (histogram, seconds) | method, route, status |
| span http_server | http.method, http.route |
Middleware.layer derives the templates from the mounted api (HttpApi.reflect, prefixes included). Composing the stack by hand: Middleware.standard(app, { routeLabel: Middleware.routeLabel(api, { extra: ["/docs", "/openapi.json"] }) }) — extra lists templates for routes mounted next to the api (docs, static files); without a resolver every request logs (unmatched).
Propagated x-request-id / x-correlation-id headers are reused only when they match ^[A-Za-z0-9_-]{1,64}$ (Middleware.isSafeId); anything else is replaced by a fresh uuid, which is what the response headers and every log line carry — a client cannot inject bytes into the log stream or span attributes through those headers.
One listener: HttpApi + raw mounts + static assets
The self-hosted shape (one image, one port) serves the HttpApi, raw web handlers such as makeAuthHandler from @structure-ai/auth or an MCP transport, and an embedded SPA from the same serve layer:
serve({
port: 3000,
gracePeriod: Duration.seconds(5),
mounts: [
{ prefix: "/auth", handler: auth.handler }, // (Request) => Promise<Response>
{ prefix: "/mcp", handler: mcpHandler },
],
static: {
directory: "./dist", // the SPA build
spaFallback: "index.html", // client-side routes
cacheControl: (path) =>
path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache",
},
});Precedence is fixed and does not depend on layer build order:
- Mounts — evaluated before the router; the longest matching prefix wins. A prefix matches itself and everything below it on a segment boundary (
/apimatches/apiand/api/x, never/apix). Handlers get the request untouched (full path, query, body): prefix stripping is theirs, as it would be on their ownBun.serve. Prefixes must be absolute, without trailing slash (except/), query or fragment, and unique — violations fail the layer at startup withInvalidMounts. - HttpApi routes — including
Docs.layer()andHealth.group, so the SPA fallback never shadows/docsor/health/*. - Static assets — only for
GET/HEADthe router did not match, understatic.prefix(default/): an existing file belowdirectory(a directory answers with itsindex.html, never a listing), elsespaFallbackwhen the request'sAcceptliststext/html(a navigation), else nothing. - The 404 problem — every unmatched path ends in the HttpApi
NotFoundmapping.
Every branch runs inside the same middleware boundary: correlation headers are stamped on mount and static responses, each request gets its log line and metrics (api routes labelled by endpoint template, mounts by <prefix>/*, static files and the fallback as (unmatched) — see "Boundary telemetry"), and a mount handler that rejects is answered with the 500 problem carrying only the correlation id (the cause is logged). Mounts and static files share the readiness flip and the gracePeriod drain with the api routes. HttpApi-level middleware layers such as rateLimitLayer apply to the router only, not to mounts (auth handlers bring their own limits).
Static serving is bounded on purpose: decoded path segments only (no ./.., no dotfiles, no control bytes or backslashes), symlink targets are resolved and must stay inside the real root, every response carries x-content-type-options: nosniff, a weak etag (a matching if-none-match answers 304), last-modified and vary: accept-encoding; a precompressed sibling (<file>.br, then <file>.gz) is served with its content-encoding when the client accepts it. Content types come from the file extension. Range requests are not supported. Invalid options (prefix shape, unsafe spaFallback) fail the layer with InvalidStaticOptions.
Testing the composed surface
serveTest keeps its zero-config shape; serveTestWith({ mounts, static, gracePeriod }) builds the same stack as serve on a random port and exposes HttpServer for the address (it requires Readiness, like serve). packages/http/test/mounts.test.ts starts one server per composition and drives it with fetch.
Rate limiting
rateLimitLayer (or the rateLimit HttpApp middleware) enforces per-route-group budgets with points per sliding windowMillis and a blockMillis lockout (blockMillis: 0 means no lockout: a full window is denied until its oldest hit ages out, and Retry-After counts down to that moment):
import { makeInMemoryStore, rateLimitLayer, clientIp } from "@structure-ai/http";
const limiter = rateLimitLayer({
store: makeInMemoryStore(),
groups: [
{
label: "api", // bounded metric/log label
rule: { points: 100, windowMillis: 60_000, blockMillis: 30_000 },
match: (request) => request.url.startsWith("/api"),
key: (request) => principalIdOr(request) ?? clientIp(request, { trustProxy }),
},
{
// Login wall: keyed by ip AND email, charged only when the credential is refused.
label: "login",
rule: { points: 10, windowMillis: 15 * 60_000, blockMillis: 15 * 60_000 },
match: (request) => request.method === "POST" && request.url.startsWith("/auth/login"),
keys: (request) =>
request.json.pipe(
Effect.map((body) => [`ip:${clientIp(request, { trustProxy })}`, `email:${emailDigest(body)}`]),
Effect.catchAll(() => Effect.succeed([`ip:${clientIp(request, { trustProxy })}`])),
),
consumeWhen: (response) => response.status === 401,
},
],
});
// provide `limiter` next to Middleware.layer in serve()Semantics:
OPTIONSpreflights and/health/*probes never consume budget;- every counted response — success or error — carries
RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset(seconds until the budget is fully restored) and theirX-RateLimit-*twins; a fail-open response after a store failure carries none; - a denied request gets
429+Retry-After(seconds; problem body with the correlation id), the quota headers withremaining: 0, and a warning log tagged with the route label; - a group with
consumeWhenis a consume-on-failure wall: the store ispeeked before the handler (429 when a key is blocked, the handler never runs) and charged after it only when the predicate holds for the response. A handler failure is evaluated as the problem response the package would render for it (Unauthenticated/UnauthorizedProblem→ 401, unknown errors → 500) and re-raised unchanged; keyscharges several budgets together under the group's rule (each key its own budget; refused when any is exhausted) — prefix keys by kind, they share one namespace;undefinedentries are dropped and a request with no key is unlimited;- store failures fail open by default (counted as
http_rate_limit_store_errors_total);onStoreError: "deny"fails closed; - metrics:
http_rate_limit_consumed_total(actual charges) /http_rate_limit_blocked_total, taggedroute.
Stores (RateLimitStore = consume + peek, both returning a RateLimitDecision with allowed, retryAfterMillis, limit, remaining, resetMillis):
| Store | Use |
| --- | --- |
| makeInMemoryStore() | Single replica: bounded key map, lazy sweep, atomic in one event loop turn. |
| makeRedisStore({ url }) | Shared across replicas: atomic sliding window + block in one Lua EVAL (one script per operation) over a dependency-free RESP2 client (makeRedisClient). |
| storeFromUrl(url?) | No URL → in-memory + startup warning stating single-replica scope; URL → Redis store. |
peek never spends a point or installs a block: a full window is refused until its oldest hit ages out; a block installed by consume is reported with its remaining time.
Keys come from the app (principal id, IP, route+principal…). clientIp(request, { trustProxy }) extracts the client IP and the option is required so the spoofable path is never picked by accident: with trustProxy: true the rightmost x-forwarded-for hop (the one your proxy appended) wins, then x-real-ip, then the socket; with trustProxy: false both headers are ignored and only the socket address counts — any client can set them. Set it from a setting that is true only when a proxy you operate terminates every connection. Because @structure-ai/http never depends on @structure-ai/authorization, principal-keyed groups resolve their key through a closure the app owns (e.g. reading Principal.current from its own composition).
Errors
Failures render as problem-details responses ({ error, message, correlationId?, issues? }): ValidationFailed → 400, Unauthenticated → 401, Unauthorized/PermissionDenied → 403, NotFound → 404, ConcurrencyConflict/IdempotencyMismatch/IdempotencyInFlight → 409, TooManyRequests → 429 (+ Retry-After), DispatchTimeout → 504, anything else → 500 with the correlation id only. Use withDefaultErrors(endpoint) to declare the set on an endpoint.
Taxonomy mapping validates fields, not just _tag; platform errors use their constructors. The CQRS bridge maps typed errors without flattening compound causes. Any defect produces a safe 500, and cancellation remains interrupted even when another failure is present. Missing static files still fall through to 404; unexpected filesystem failures produce 500.
