Route DSL
The route DSL is just functions. Compose them into a route tree; the extension flattens the tree into a list of registrations the backend understands.
import { path, get, post, concat, completeJson, entity, redirect, reject, Status } from 'actor-ts/http';
const orders = path('orders', concat( get(async () => completeJson(200, { orders: [] })), post(async (req) => { const body = entity<NewOrder>(req); if (!body.sku) reject(Status.BadRequest, 'missing sku'); return completeJson(201, { id: 'o-1' }); }), path(':id', get(async (req) => { const id = parseId(req); return completeJson(200, { id, status: 'ok' }); }), ), ),);
const api = path('api', orders);Three core combinators (path, concat, method-combinators)
plus three response helpers (complete*, redirect, reject)
cover ~95 % of routing.
Core combinators
Section titled “Core combinators”path(segment, child)
Section titled “path(segment, child)”path('api', orders) // → /api/<orders' paths>path('api/v1', users) // → /api/v1/<users' paths>Prepends a static path segment to every child route. Composes:
path('api', path('v1', path('users', get(handler), // → GET /api/v1/users ), ),);Path segments with slashes are flattened — path('api/v1', ...)
is equivalent to path('api', path('v1', ...)).
concat(...routes)
Section titled “concat(...routes)”concat( get(getHandler), // GET this path post(postHandler), // POST this path);OR-combinator — multiple routes at the same level. Matching is first-match-wins; order matters when patterns overlap.
// Multiple methods on the same path:path('orders', concat( get(listOrders), post(createOrder), put(updateOrder), ),);
// Multiple paths at the same scope:concat( path('orders', get(listOrders)), path('customers', get(listCustomers)),);Method combinators
Section titled “Method combinators”get(handler); // GETpost(handler); // POSTput(handler); // PUTdel(handler); // DELETE (named `del` because `delete` is a JS keyword)patch(handler); // PATCHhead(handler); // HEADoptions(handler); // OPTIONSEach takes a handler (req) => Promise<HttpResponse> | HttpResponse.
The handler can be async — the framework awaits the return.
Response helpers
Section titled “Response helpers”completeJson(status, body, headers?)
Section titled “completeJson(status, body, headers?)”return completeJson(200, { ok: true });return completeJson(201, { id: 'new' }, { 'x-custom': 'value' });Returns an HttpResponse with Content-Type: application/json.
The body is JSON-serialized at write time.
completeText(status, body, headers?)
Section titled “completeText(status, body, headers?)”return completeText(200, 'hello');Content-Type: text/plain; charset=utf-8.
complete(status, body?, headers?)
Section titled “complete(status, body?, headers?)”return complete(204); // No contentreturn complete(200, 'raw body'); // No automatic Content-TypeThe generic form — pass body / headers / content type explicitly.
redirect(url, status?)
Section titled “redirect(url, status?)”return redirect('/login'); // 302return redirect('/new-home', Status.MovedPermanently); // 301Sets Location: <url> and the appropriate status. Default is
Status.Found (302).
reject(status, message, extra?)
Section titled “reject(status, message, extra?)”if (!auth) reject(Status.Unauthorized, 'missing token');if (!entity.valid) reject(Status.BadRequest, 'invalid payload', { field: 'sku' });Throws an HttpError. The framework catches it and produces a
response with the matching status + a JSON body containing the
message + any extras.
This is the cleanest way to short-circuit a handler — no need to return early; just throw.
Status
Section titled “Status”import { Status } from 'actor-ts/http';
Status.OK; // 200Status.Created; // 201Status.NoContent; // 204Status.MovedPermanently; // 301Status.Found; // 302Status.BadRequest; // 400Status.Unauthorized; // 401Status.Forbidden; // 403Status.NotFound; // 404Status.UnprocessableEntity; // 422Status.TooManyRequests; // 429Status.InternalServerError; // 500Status.ServiceUnavailable; // 503Named constants for the common status codes. Use these over literal numbers — easier to read at the call site.
Working with the request
Section titled “Working with the request”The handler receives an HttpRequest:
interface HttpRequest { readonly method: HttpMethod; readonly path: string; readonly headers: Record<string, string>; readonly query: Record<string, string>; readonly body: Uint8Array | null;}Path parameters
Section titled “Path parameters”The current DSL doesn’t have a typed path-parameter extractor —
parameters are part of the raw req.path. Extract by hand:
path('orders', path(':id', get(async (req) => { // req.path is e.g. "/api/orders/o-42" const id = req.path.split('/').pop()!; return completeJson(200, { id }); }), ),);(A typed-extractor DSL is on the roadmap; see FAQ.)
Query parameters
Section titled “Query parameters”get(async (req) => { const limit = req.query.limit ?? '10'; return completeJson(200, { limit: parseInt(limit) });});req.query is already parsed — no manual URLSearchParams.
Headers
Section titled “Headers”const auth = req.headers['authorization'] ?? '';if (!auth.startsWith('Bearer ')) reject(Status.Unauthorized, 'no token');Lowercased keys.
import { entity } from 'actor-ts/http';
post(async (req) => { const order = entity<NewOrder>(req); // ↑ decodes JSON / CBOR by Content-Type, throws 400 on malformed.});See Marshalling for the full body story.
A larger example
Section titled “A larger example”import { path, concat, get, post, put, del, completeJson, complete, reject, Status, entity,} from 'actor-ts/http';
interface Customer { id: string; name: string; }
const customers = path('customers', concat( get(async () => { const all = await ask(registry, { kind: 'list', replyTo: undefined as any }); return completeJson(Status.OK, all); }), post(async (req) => { const body = entity<{ name: string }>(req); if (!body.name) reject(Status.BadRequest, 'missing name'); const created = await ask(registry, { kind: 'create', name: body.name, replyTo: undefined as any, }); return completeJson(Status.Created, created); }), path(':id', concat( get(async (req) => { const id = idOf(req); const c = await ask(registry, { kind: 'get', id, replyTo: undefined as any }); if (!c) reject(Status.NotFound, `no customer ${id}`); return completeJson(Status.OK, c); }), put(async (req) => { const id = idOf(req); const body = entity<Partial<Customer>>(req); await ask(registry, { kind: 'update', id, ...body, replyTo: undefined as any }); return complete(Status.NoContent); }), del(async (req) => { const id = idOf(req); await ask(registry, { kind: 'delete', id, replyTo: undefined as any }); return complete(Status.NoContent); }), ), ), ),);
const root = path('api', path('v1', concat(customers, ...)),);
await http.newServerAt('0.0.0.0', 8080).bind(root);The pattern: routes call into actors via ask; HTTP handlers stay
thin; business logic lives in actors.
Error handling
Section titled “Error handling”get(async () => { if (somethingWrong) reject(Status.BadRequest, 'bad input'); if (notFound) reject(Status.NotFound, 'not here');
try { return completeJson(200, await heavyWork()); } catch (e) { if (e instanceof MyDomainError) reject(Status.UnprocessableEntity, e.message); throw e; // unknown errors fall through to a 500 }});Two paths for errors:
reject(status, message)— for known error cases. Returns a structured JSON error to the client.- Throw anything else — caught by the backend, turned into a 500 with a generic message.
Where to next
Section titled “Where to next”- HTTP overview — the bigger picture: backends, middleware, extension setup.
- Marshalling —
entity<T>(req)decoding, response encoding via Accept. - Backends — Fastify — the default backend’s options.
The Route API reference covers the
full type surface.