@arki/http

archive

for @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.1.0 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

  • checkfile-llms-txt
  • checkfile-agents-md
  • checkfile-agent-guide
  • checkreadme-h2-order
  • checkpkg-engines
  • checkfiles-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 boot where 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.

CodeMeaning
ARKI_HTTP_E001start could not bind the port
ARKI_HTTP_E002duplicate route: two bindings claim the same method+path (or a bundle token is listed twice)
ARKI_HTTP_E005stop exceeded drainTimeoutMs with requests in flight
ARKI_HTTP_E006malformed mount path
ARKI_HTTP_E007deriver key duplicates an earlier deriver or a base context key
ARKI_HTTP_E008a bundle token's service was missing at boot (erased composition only)
ARKI_HTTP_E400request validation failed (query/body) or body is not valid JSON
ARKI_HTTP_E401 / E403credential / permission rejection (thrown by your derivers)
ARKI_HTTP_E404no route matches
ARKI_HTTP_E500handler 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.

Benchmarks

Each router receives a real Request and returns a Response, in process, with no network. @arki/http is built on hono, so hono is the direct reference; itty-router is the small, hand-wired alternative. All three serve the same 20 routes and validate bodies with the same zod schema.

Node v22.13.1 on Apple M2 Max, 2026-10-13. Median time per operation, lower is better.

Static GET (one of 20 routes)

LibraryTime / opvs fastest
@arki/http (route contracts)46.83 µs1.41×
hono (+ @hono/zod-validator for bodies)33.17 µsfastest
itty-router v5 (hand-written validation)35.46 µs1.07×

GET with a path parameter

LibraryTime / opvs fastest
@arki/http (route contracts)28.75 µs1.33×
hono (+ @hono/zod-validator for bodies)21.54 µsfastest
itty-router v5 (hand-written validation)26.25 µs1.22×

POST with a valid JSON body

Body parsed and checked with the same zod schema in all three. @arki/http also checks the output.

LibraryTime / opvs fastest
@arki/http (route contracts)40.54 µs1.12×
hono (+ @hono/zod-validator for bodies)36.54 µs1.01×
itty-router v5 (hand-written validation)36.21 µsfastest

POST with an invalid body (400)

The failure path. @arki/http builds its typed error envelope; hono returns its validator error; itty returns a hand-written JSON error.

LibraryTime / opvs fastest
@arki/http (route contracts)55.17 µs1.23×
hono (+ @hono/zod-validator for bodies)53.08 µs1.18×
itty-router v5 (hand-written validation)44.96 µsfastest

Read the rows with the contracts in mind:

  • @arki/http runs on hono. The gap to plain hono is the price of the contract layer: every handler input (params, query, body) is validated against its contract, and every handler output is validated against the contract output schema, so a wrong response fails loudly instead of leaving the process.
  • The hono and itty-router rows do not validate handler output and have no route ids, no error envelope and no OpenAPI generation. Their Request and Response work is the same.
  • itty-router has no validation hook, so its rows parse the JSON and run the zod schema by hand, the way its docs suggest.

Reproduce with bun run bench in the package directory; regenerate this section with bun run bench --write-readme.

License

MIT

Public imports

This version has no API index.