@arki/http
archivefor @arki/[email protected] · Tier 1: the stack
Typed HTTP for ARKI — route contracts as data, zod-validated handlers, request-scope derivers, SSE streaming, and deterministic OpenAPI generation.
Version 0.0.3 archive
Install
bun add @arki/[email protected]Other package managers
npm install @arki/[email protected]
pnpm add @arki/[email protected]
yarn add @arki/[email protected]Release health for @arki/[email protected]
Doc examples
No example report was recorded for this version.
Open gaps
6 release checks fail
- check
file-llms-txt - check
file-agents-md - check
file-agent-guide - check
readme-h2-order - check
pkg-engines - check
files-ship-dist-docs
Used by demos
No demo uses this package yet.
Runtimes
No engines field, so no runtime requirement is declared.
Benchmarks
No benchmarks are published for this version.
README
Typed HTTP for ARKI — route contracts as data, zod-validated handlers,
request-scope derivers, SSE streaming, and deterministic OpenAPI
generation. Ships a DOT adapter (@arki/http/dot) whose http() plugin
turns a composed app into a running server with correct drain-on-shutdown
semantics.
No decorators, no controller scanning, no runtime DI container. Contracts are plain values; wiring is checked at compile time.
The model
A route is split in two:
- Contract — pure data at module scope: method + path + zod schemas +
a stable id. Registerable in the DOT manifest (sync
configure), renderable as OpenAPI without booting, and the source of the handler's inferred signature. - Binding — the handler, attached inside a plugin's
bootwhere the plugin's typed needs are already injected. Handlers close over real services; no service locator exists.
import { route, routes } from '@arki/http';
import { z } from 'zod';
export const listOrders = route.get('/orders', {
id: 'orders.list',
query: z.object({ status: z.enum(['open', 'shipped']).optional() }),
output: z.array(Order),
});
export const createOrder = route.post('/orders', {
id: 'orders.create',
body: CreateOrder,
output: Order,
});Input validation (query/body) happens before your handler runs; output is
validated against the contract on the way out. Failures serialize as a
coded envelope: { "error": { "code", "message", "remediation?", "docsUrl?" } }.
Inside a DOT app
import { plugin, provide, service, token } from '@arki/dot/plugin';
import { routes, type RouteBundle } from '@arki/http';
export const OrdersRoutes = token<RouteBundle>()('orders.routes');
export const ordersPlugin = plugin({
name: 'orders',
needs: { db: service<Db>() },
actions: [listOrders, createOrder], // manifest + OpenAPI metadata
async boot({ db }) {
return provide(
OrdersRoutes,
routes()
.bind(listOrders, async ({ query }) => db.orders.list(query))
.bind(createOrder, async ({ body }, ctx) => db.orders.create(body, ctx.requestId)),
);
},
});Mount the http() plugin last, naming the bundles it serves:
import { defineApp, hookSignals } from '@arki/dot';
import { http } from '@arki/http/dot';
const app = await defineApp('shop-api')
.use(db({ url: DATABASE_URL }))
.use(ordersPlugin)
.use(http({ port: 3000, bundles: [OrdersRoutes] }))
.start();
hookSignals(app);http() derives its needs from the tokens you pass — forget to mount
ordersPlugin and the .use(http(...)) line fails to typecheck. Because the
plugin is declared last, reverse-order stop halts ingress FIRST: the server
stops accepting, drains in-flight requests (drainTimeoutMs, default
10 s, ARKI_HTTP_E005 on overrun), and only then do feature plugins tear
down. boot builds the router without listening; start listens;
dispose severs remaining connections and releases the port.
The published httpServer service carries the composed
fetch: (Request) => Promise<Response> — also the unit-test seam: call
your whole app without a socket.
Request scope — derivers, not containers
Per-request values are typed context keys derived by plain functions.
Derivers run in declaration order; each adds a key later handlers (and
later derivers) see typed. A deriver that throws HttpError is the whole
"guard" story:
import { derive, HttpError, HTTP_ERROR_CODES } from '@arki/http';
// inside boot — `auth` is an injected singleton
const withPrincipal = derive('principal', async req => {
const p = await auth.verify(req.headers.get('authorization'));
if (!p) throw new HttpError(401, HTTP_ERROR_CODES.unauthorized, 'missing or invalid credentials');
return p;
});
routes()
.derive(withPrincipal)
.derive('db', (_req, ctx) => dbForTenant(ctx.principal.tenantId))
.bind(listOrders, async (_input, ctx) => ctx.db.orders.list());Request scope is function application over the compile-time-wired
singleton graph — no per-request container resolution, no infectious
scopes. For code deep in a call stack you don't control, the fenced-off
@arki/http/context entry exposes requestContext() (AsyncLocalStorage).
It is a last resort; prefer the explicit ctx.
App-wide middleware
For cross-cutting concerns that must see or touch the raw
request/response — CORS, request logging — pass fetch-shaped middleware
to http() (first = outermost). A middleware that throws HttpError
short-circuits into the coded envelope:
http({ port: 3000, bundles: [OrdersRoutes], middleware: [requestLog(logger), cors(origins)] })Middleware wraps the whole engine and is untyped by design; anything that
produces a per-request value belongs in a deriver instead. Standalone
users compose the same way with composeMiddleware(engine.fetch, [...]).
Streaming
Typed SSE. route.sse declares a text/event-stream contract; the
binding is an async generator. Every yield is validated against the event
schema and framed as a data: message; client disconnect aborts
ctx.signal and runs the generator's finally:
const progress = route.sse('/orders/:id/progress', {
id: 'orders.progress',
event: z.discriminatedUnion('type', [Queued, Shipped]),
heartbeatMs: 15_000, // optional :keepalive comments
});
routes().bind(progress, async function* ({ params }, ctx) {
for await (const step of tracker.watch(params.id, ctx.signal)) {
yield step;
}
});Raw streams. Any handler may return a Response wrapping a
ReadableStream — file downloads, AI token streams. The engine is
fetch-native end to end.
Mounting foreign handlers
An oRPC router, a tRPC fetch adapter, or any fetch-shaped handler mounts as one manifest route — the migration on-ramp for existing backends:
routes().mount('/rpc', rpcHandler, { id: 'rpc', transport: 'orpc' });OpenAPI
import { toOpenApi } from '@arki/http';
const document = toOpenApi([listOrders, createOrder, progress], {
title: 'shop-api',
version: '1.0.0',
});Deterministic — same contracts, byte-identical output. Inside a DOT app,
contract actions put the JSON Schemas into the manifest, so
dot explain --as openapi renders the document from the static manifest
without booting anything.
Without DOT
The core is framework-agnostic:
import { buildEngine, listen, routes } from '@arki/http';
const engine = buildEngine([bundle]);
const handle = await listen(engine.fetch, { port: 3000 });listen uses Bun.serve under Bun and @hono/node-server under Node.
Routing is Hono internally; no Hono type appears in the public API.
Error codes
Stable API — match on codes, never parse messages.
| Code | Meaning |
|---|---|
ARKI_HTTP_E001 | start could not bind the port |
ARKI_HTTP_E002 | duplicate route: two bindings claim the same method+path (or a bundle token is listed twice) |
ARKI_HTTP_E005 | stop exceeded drainTimeoutMs with requests in flight |
ARKI_HTTP_E006 | malformed mount path |
ARKI_HTTP_E007 | deriver key duplicates an earlier deriver or a base context key |
ARKI_HTTP_E008 | a bundle token's service was missing at boot (erased composition only) |
ARKI_HTTP_E400 | request validation failed (query/body) or body is not valid JSON |
ARKI_HTTP_E401 / E403 | credential / permission rejection (thrown by your derivers) |
ARKI_HTTP_E404 | no route matches |
ARKI_HTTP_E500 | handler crash or output-schema violation |
E0NN codes surface through the DOT lifecycle into app.diagnostics;
E4xx/E5xx codes surface on the wire in the error envelope. Unexpected
error messages are redacted when NODE_ENV=production.
License
MIT
Public imports
This version has no API index.