Edit this page on GitHub

Programmatic API Reference

This document provides a comprehensive reference for the Setu-TS programmatic API. For decorator-based usage, see Decorators Guide.

Application

createApplication(options?)

Creates a new application instance.

import { createApplication } from '@setu-ts/kernel';

const app = createApplication();

Options: Plugins can be passed inline at creation time (equivalent to calling register() immediately after):

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';

const app = createApplication({
  plugins: [RuntimePlugin()],
});

Or registered individually:

const app = createApplication();
app.register(RuntimePlugin());

Application Methods

register(plugin)

Register a plugin with the application. Plugins register before start(); registering one after the application has started throws.

import { RuntimePlugin } from '@setu-ts/runtime';
import { LoggerPlugin } from '@setu-ts/logger-plugin';

app.register(RuntimePlugin());
app.register(LoggerPlugin({ level: 'info' }));

router.get(path, handler)

Register a GET route handler through the router.

import { CAPABILITIES } from '@setu-ts/common';
import type { IDatabaseService } from '@setu-ts/database-plugin';
app.router.get('/users', async (ctx) => {
  const usersRepo = ctx.services
    .get<IDatabaseService>(CAPABILITIES.DATABASE)
    .getRepository('users');
  const users = await usersRepo.findAll();
  return ctx.response.json(users);
});

router.post(path, handler), router.put(path, handler), router.patch(path, handler), router.delete(path, handler)

Register route handlers for other HTTP methods.

app.router.post('/users', async (ctx) => {
  // Capabilities are registered during start(), so resolve them inside the handler.
  const usersRepo = ctx.services
    .get<IDatabaseService>(CAPABILITIES.DATABASE)
    .getRepository<{ id: string; name: string }>('users');
  const user = await usersRepo.create({ name: 'alice' });
  return ctx.response.status(201).json(user);
});

middleware.add(middleware, options?)

Add global middleware to the pipeline. start() compiles the pipeline, so add middleware before it; adding one afterwards throws.

import type { MiddlewareFunction } from '@setu-ts/common';

const requestId: MiddlewareFunction = async (ctx, next) => {
  await next();
  ctx.response.header('x-request-id', ctx.id);
};

app.middleware.add(requestId);

start(options?)

Start the application and optionally listen on a port.

await app.start({ port: 3000, hostname: '0.0.0.0' });

Options:

  • port?: number - Port to listen on
  • hostname?: string - Address to bind to (default: the runtime’s own, which is every interface)

stop()

Stop the application gracefully.

await app.stop();

fetch(request)

Handle a web-standard Request. Used for testing and Workers deployments.

const response = await app.fetch(new Request('http://localhost:3000/health'));

inject(request)

Inject a request without a network socket (testing only).

import { createApplication } from '@setu-ts/kernel';
import { inject } from '@setu-ts/testing';
import { RuntimePlugin } from '@setu-ts/runtime';

const app = createApplication({ plugins: [RuntimePlugin()] });
app.router.get('/health', (ctx) => ctx.response.json({ status: 'up' }));
await app.start();

const response = await inject(app, {
  method: 'GET',
  url: '/health',
});
if (response.statusCode !== 200 || response.json<{ status: string }>().status !== 'up') {
  throw new Error('health injection failed');
}
await app.stop();

Router

Route Handler Context

Route handlers receive a context object with the following properties:

// The actual IRequestContext interface (from @setu-ts/common) — see the real
// declaration in packages/common/src/http.ts for the authoritative contract.
// Key members:
//   request: IRequest           (see IRequest below)
//   response: IResponse         (fluent terminal methods, no ResponseOptions bag)
//   services: IServiceRegistry  (typed capability resolution)
//   params: Readonly<Record<string, string>>
//   state: Map<string, unknown>
//   signal: AbortSignal         (aborts when the client disconnects)

Response Methods

// JSON response
ctx.response.json(data);

// Text response
ctx.response.text('Hello');

// Redirect
ctx.response.redirect('/other');

// Streaming response
ctx.response.stream(readableStream);

Response Methods (fluent API)

IResponse methods return HandlerResult (terminal) or IResponse (fluent). There is no separate ResponseOptions bag — each method carries its own parameters:

// Fluent setters (chainable)
ctx.response.status(200);
ctx.response.header('X-Custom', 'value');

// Terminal methods (return HandlerResult)
ctx.response.json({ key: 'value' });
ctx.response.text('Hello');
ctx.response.redirect('/other', 302);
ctx.response.stream(readableStream);

// Read the final snapshot after the handler completes
const snapshot = ctx.response.snapshot();

Service Registry

register<T>(token, service, options?)

Register a service.

ctx.services.register<IMyService>('my-service', new MyService());

get<T>(token)

Resolve a service.

const db = ctx.services.get<IDatabaseService>(CAPABILITIES.DATABASE);

has(token)

Check if a service is registered.

if (ctx.services.has(CAPABILITIES.CACHE)) {
  const cache = ctx.services.get<ICacheStore>(CAPABILITIES.CACHE);
}

getAll<T>(token)

Get all providers for a multi-provider capability.

const validators = ctx.services.getAll<IValidator>('validator');

Middleware

Creating Middleware

const myMiddleware: MiddlewareFunction = async (ctx, next) => {
  console.log('Before');
  await next();
  console.log('After');
};

Middleware Priority

app.middleware.add(myMiddleware, { priority: 25 });

Default priority is 500. Lower numbers run first.

Lifecycle Hooks

onRegister(handler)

Called during plugin registration.

ctx.lifecycle.onRegister(() => {
  console.log('Registering...');
});

onInit(handler)

Called after all plugins have registered.

ctx.lifecycle.onInit(() => {
  console.log('Initializing...');
});

onBootstrap(handler)

Called when the application is ready to accept requests.

ctx.lifecycle.onBootstrap(() => {
  console.log('Ready!');
});

onRequest(handler), onResponse(handler), onError(handler)

Per-request lifecycle hooks.

ctx.lifecycle.onRequest((ctx) => {
  console.log('Request started:', ctx.request.url);
});

ctx.lifecycle.onResponse((ctx) => {
  console.log('Response sent:', ctx.response.snapshot().status);
});

ctx.lifecycle.onError((error, ctx) => {
  console.error('Error:', error);
});

onStopping(handler), onShutdown(handler), onClose(handler)

Shutdown lifecycle hooks. The kernel owns the guard, drain, and socket close. The actual shutdown order is: stopping hooks while requests are still accepted → refuse new requests → drain in-flight requests → close the server socket → shutdown hooks → close hooks. A rejecting stopping hook is reported only after the remaining shutdown phases finish.

ctx.lifecycle.onStopping(() => {
  // Tell an external load balancer or service registry to stop sending traffic.
});

ctx.lifecycle.onShutdown(() => {
  // Flush buffers and close resources after the kernel drains and closes the socket.
});

ctx.lifecycle.onClose(() => {
  // Release resources
});

Request/Response

IRequest (exact contract from @setu-ts/common)

Transcribed exactly from the IRequest interface in @setu-ts/common; mutable middleware-owned fields deliberately remain mutable.

interface IRequest {
  readonly method: HttpMethod; // 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
  readonly url: string;
  readonly path: string;
  readonly headers: Headers;
  readonly ip?: string;
  user?: IPrincipal; // populated by auth middleware
  tenant?: ITenant; // populated by multi-tenancy middleware
  locale?: string; // populated by the localization middleware (M103); replaceLocale() to override
  signal?: AbortSignal; // fires on client disconnect
  readonly raw?: Request; // The undisturbed web-standard Request, preserved for WebSocket upgrade and gRPC dispatch after the middleware pipeline.

  // Body readers (consume the body exactly once)
  json<T = unknown>(): Promise<T>;
  text(): Promise<string>;
  bytes(): Promise<Uint8Array>;
  formData?(): Promise<FormBody>; // optional (M94b): both form encodings, shared parseFormBody
}

Note: IRequest has no query field (query parsing happens in the router), no body field (body is read through the dedicated methods above), and no bodyUsed property. formData is OPTIONAL on the signal/raw precedent. Nothing falls back automatically: the framework’s own form consumers (the upload middleware and the CSRF verifier) call parseFormBody(await request.bytes(), request.headers.get('content-type')) from @setu-ts/common themselves when a request omits the accessor, and application code reading a form from a custom IRequest does the same. Either route throws the 415-branded UnsupportedFormEncodingError for a non-form content-type; what the accessor adds over the fallback is memoization, so two middleware reading one body trigger a single parse.

IRequestContext

interface IRequestContext {
  readonly id: string;
  readonly request: IRequest;
  readonly response: IResponse;
  readonly services: IServiceRegistry;
  readonly params: Readonly<Record<string, string>>;
  readonly query: Readonly<Record<string, string>>;
  readonly state: Map<string, unknown>;
  readonly startTime: number;
  readonly signal: AbortSignal;
  readonly raw?: Request; // the undisturbed web Request, when the adapter supplies one
}

IResponse

interface IResponse {
  status(code: number): IResponse;
  header(name: string, value: string): IResponse;
  appendHeader(name: string, value: string): IResponse;
  json<T>(body: T): HandlerResult;
  text(body: string): HandlerResult;
  html(body: string): HandlerResult;
  send(body?: Uint8Array): HandlerResult;
  redirect(url: string, status?: number): HandlerResult;
  stream(body: ReadableStream<Uint8Array>): HandlerResult;
  snapshot(): ResponseSnapshot;
}

Health Checks

register(name, check)

Register a health check.

import { CAPABILITIES } from '@setu-ts/common';
import type { IRuntimeServices } from '@setu-ts/common';

const runtime = ctx.services.get<IRuntimeServices>(CAPABILITIES.RUNTIME);
ctx.health.register('database', async () => {
  return { status: 'up', data: { timestamp: runtime.now() } };
});

Metrics

Obtaining the Metrics Service

Resolve IMetricsService through the capability token:

import { CAPABILITIES } from '@setu-ts/common';
import type { ICounter, IMetricsService } from '@setu-ts/common';

const metrics = ctx.services.get<IMetricsService>(CAPABILITIES.METRICS);

Creating a Custom Counter

Use counter() to get or create an ICounter, then call inc() with labels:

const counter = metrics.counter('my_requests_total', {
  help: 'Total requests handled by my service',
  labels: ['method', 'path'],
});

// Increment the counter explicitly in your middleware or handler
counter.inc(1, { method: 'GET', path: '/users' });

Note: A custom contributed counter is NOT automatically observed by any built-in HTTP collector. The built-in HTTP collectors track their own metrics (http_requests_total, http_request_duration_seconds, etc.). To record a custom counter you must call inc() (or observe()) explicitly.

OpenAPI Contributions

addSchema(name, schema)

Contribute an OpenAPI schema.

ctx.openapi.addSchema('User', {
  type: 'object',
  properties: {
    id: { type: 'string' },
    name: { type: 'string' },
  },
});

CLI Contributions

register(name, handler)

Register a CLI command.

ctx.cli.register('my-plugin:greet', async (args) => {
  console.log('Command executed with args:', args);
});

Decorator Contributions

register(name, handler)

Register a decorator handler.

ctx.decorators.register('MyDecorator', async (metadata, target) => {
  // Handle decorator application
});

Runtime Services

IRuntimeServices

type RuntimeSignal = 'SIGTERM' | 'SIGINT';

interface IRuntimeServices {
  reportError?(record: Readonly<Record<string, unknown>>): void;
  platform(): RuntimePlatform;
  version(): string;
  hostname(): string;
  uuid(): string;
  randomBytes(length: number): Uint8Array;
  readonly subtle: SubtleCrypto;
  now(): number;
  hrtime(): number;
  setTimeout(fn: () => void, ms: number): TimerHandle;
  clearTimeout(handle: TimerHandle): void;
  setInterval(fn: () => void, ms: number): TimerHandle;
  clearInterval(handle: TimerHandle): void;
  readonly env: Readonly<Record<string, string | undefined>>;
  exit(code?: number): never;
  readonly fs?: IFileSystem;
  readonly workers?: IWorkerHost;
  readonly dns?: IDnsResolver;
  onSignal?(signal: RuntimeSignal, handler: () => void): void;
}

reportError is the optional last-resort error sink: one JSON line to stderr, or the platform log channel on Workers. Serialization and write failures never escape. The kernel uses it for unhandled errors and errorHandler for handled 5xx, only when no logger is registered; handled 4xx stays silent without a logger.

onSignal registers a graceful-shutdown handler, so application code never needs a runtime’s own signal API. It is optional in two directions, and both matter: Cloudflare Workers omits it because an isolate is evicted rather than signalled, and the Deno adapter omits it on Windows, where registering for SIGTERM throws. Call it as runtime.onSignal?.(...) — a present key means “this runtime can register one”, never “this platform raises signals”.

Runtime Platform

type RuntimePlatform = 'deno' | 'node' | 'bun' | 'cloudflare-workers';

Every value has a runtime implementation — there is no 'unknown' arm. Use detectRuntime() to resolve the current platform, or pass RuntimePlugin({ platform }) to force one.

Testing Utilities

createTestApp(options?)

Create a test application.

import { createTestApp } from '@setu-ts/testing';

const app = await createTestApp({
  plugins: [RuntimePlugin()],
});

inject(app, request)

Inject a request.

import { createApplication } from '@setu-ts/kernel';
import { inject } from '@setu-ts/testing';
import { RuntimePlugin } from '@setu-ts/runtime';

const app = createApplication({ plugins: [RuntimePlugin()] });
app.router.post('/test', async (ctx) => {
  return ctx.response.status(201).json(await ctx.request.json<{ foo: string }>());
});
await app.start();

const response = await inject(app, {
  method: 'POST',
  url: '/test',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ foo: 'bar' }),
});
if (response.statusCode !== 201 || response.json<{ foo: string }>().foo !== 'bar') {
  throw new Error('request injection failed');
}
await app.stop();

createMockPlugin(options)

Create a mock plugin.

import { createMockPlugin } from '@setu-ts/testing';

const mockPlugin = createMockPlugin({
  name: 'my-service',
  service: mockMyService,
});

Streaming

Streaming Responses

import { CAPABILITIES } from '@setu-ts/common';
import type { IRuntimeServices } from '@setu-ts/common';

app.router.get('/stream', (ctx) => {
  // RuntimePlugin registers the runtime during start(), so resolve it per request.
  const runtime = ctx.services.get<IRuntimeServices>(CAPABILITIES.RUNTIME);
  const delay = (ms: number) =>
    new Promise<void>((resolve) => {
      runtime.setTimeout(resolve, ms);
    });
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        controller.enqueue(new TextEncoder().encode(`Line ${i}\n`));
        await delay(100);
      }
      controller.close();
    },
  });

  return ctx.response.stream(stream);
});

Client Disconnect Handling

import { CAPABILITIES } from '@setu-ts/common';
import type { IRuntimeServices } from '@setu-ts/common';

app.router.get('/long-running', (ctx) => {
  const runtime = ctx.services.get<IRuntimeServices>(CAPABILITIES.RUNTIME);
  const delay = (ms: number) =>
    new Promise<void>((resolve) => {
      runtime.setTimeout(resolve, ms);
    });
  const stream = new ReadableStream({
    async start(controller) {
      try {
        // ctx.signal aborts when the client disconnects.
        while (!ctx.signal.aborted) {
          controller.enqueue(new TextEncoder().encode('data\n'));
          await delay(1000);
        }
      } catch {
        // The client left between the check and the write.
      }
      // No close(): the loop ends only after a disconnect, which has already
      // cancelled the stream, and closing a cancelled stream throws.
    },
  });

  return ctx.response.stream(stream);
});

Next Steps