@roostjs/core

Dependency injection container, configuration management, middleware pipeline, application lifecycle, and service provider base class.

Installation

bun add @roostjs/core

RoostContainer API

RoostContainer is a lightweight dependency injection container. Tokens are class constructors or symbols. Bindings are factories that receive the container as their first argument.

singleton<T>(token: Token<T>, factory: Factory<T>): void

Register a singleton binding. The factory is called once on first resolution. The same instance is returned on every subsequent resolve() call for the same token.

bind<T>(token: Token<T>, factory: Factory<T>): void

Register a transient binding. The factory is called on every resolve() call, returning a new instance each time.

resolve<T>(token: Token<T>): T

Resolve a dependency. Throws BindingNotFoundError if no binding is registered for the token.

has(token: Token<unknown>): boolean

Returns true if a binding exists for the token. Searches parent containers in the scope chain.

scoped(): Container

Create a child container that inherits all bindings from the parent but maintains its own singleton registry. Used for request-level isolation.

ConfigManager API

ConfigManager provides dot-notation access to nested configuration objects.

constructor(config?: Record<string, unknown>)

Create a new ConfigManager with optional initial configuration data.

get<T>(key: string, defaultValue?: T): T

Retrieve a value using dot-notation key (e.g., 'database.default'). Throws ConfigKeyNotFoundError if the key does not exist and no default is provided.

set(key: string, value: unknown): void

Set a configuration value. Creates intermediate objects for nested keys as needed.

has(key: string): boolean

Returns true if the dot-notation key exists in the configuration.

mergeEnv(env: Record<string, string | undefined>): void

Merge environment variables into configuration. Variable names are lowercased and underscores converted to dots before matching against existing keys. Only merges if the key already exists in the configuration.

// APP_DEBUG=true merges into app.debug
// APP_NAME=x is ignored if app.name was not in the initial config
config.mergeEnv({ APP_DEBUG: 'true' });

Pipeline API

Pipeline orchestrates ordered middleware execution. Each middleware receives a Request and a next function that calls the remaining middleware chain.

use(middleware: Middleware | MiddlewareClass, ...args: string[]): this

Add middleware to the pipeline. Supports plain functions and class-based middleware. Additional string arguments are passed to class middleware constructors. Returns this for chaining.

handle(request: Request, destination: Handler): Promise<Response>

Run the request through all registered middleware in registration order, then call the final destination handler.

withContainer(container: Container): this

Associate a container with the pipeline. Required for class-based middleware that needs dependency injection.

Application API

Application is the root orchestrator: it holds the container, config, service providers, and global middleware pipeline.

static create(env: Record<string, unknown>, config?: Record<string, unknown>): Application

Create a new Application instance with Cloudflare Worker environment bindings and optional configuration.

register(Provider: ServiceProviderClass): this

Register a service provider. Providers are instantiated and their register() method is called during boot(). Returns this for chaining.

useMiddleware(middleware: Middleware | MiddlewareClass, ...args: string[]): this

Add global middleware that runs on every request handled by this application.

onDispatch(handler: Handler): this

Set the request dispatcher called after all middleware. Replaces any previously set dispatcher.

async boot(): Promise<void>

Instantiate all registered service providers and call their register() then boot() methods in registration order. Called automatically on the first handle() invocation.

async handle(request: Request): Promise<Response>

Handle an incoming HTTP request. Creates a scoped container per request, runs global middleware, and calls the dispatcher.

ServiceProvider API

ServiceProvider is an abstract base class for bootstrapping application features. Extend it to register bindings and run initialization logic.

abstract register(): Promise<void> | void

Register bindings into the container. Called before any boot() methods run, so all providers have the opportunity to register before any boots.

boot(): Promise<void> | void

Optional. Runs after all providers have called register(). Safe to resolve dependencies registered by other providers here.

app: Application

The application instance. Available in both register() and boot(). Access the container via this.app.container.

Types

type Token<T = unknown> = (abstract new (...args: any[]) => T) | string | symbol;
type Factory<T> = (container: Container) => T;
type Handler = (request: Request) => Promise<Response>;
interface Middleware {
  handle(request: Request, next: Handler, ...args: string[]): Promise<Response>;
}
type MiddlewareClass = new (...args: any[]) => Middleware;

Application.defer()

defer(promise: Promise<unknown>): void

Register a promise as background work to complete after the response is sent. Delegates to ExecutionContext.waitUntil(). The Worker runtime will not terminate the isolate until all deferred promises have settled.

app.onDispatch(async (request) => {
  const result = await processRequest(request);
  // Fire-and-forget: analytics write completes after the response is returned
  app.defer(analytics.record({ path: new URL(request.url).pathname }));
  return Response.json(result);
});

defer() is a no-op when no ExecutionContext is present (e.g. in tests that call app.handle(request) directly without a second argument).

RoostExecutionContext

interface RoostExecutionContext {
  waitUntil(promise: Promise<unknown>): void;
  passThroughOnException(): void;
}

The execution context passed to Application.handle() as its second argument. Matches the shape of the Cloudflare Worker ExecutionContext — pass it through directly from the Worker fetch handler.

export default {
  fetch(request: Request, env: Env, ctx: ExecutionContext) {
    return app.handle(request, ctx);
  },
};

Logger API

Logger emits structured JSON log entries to console.log. Every entry carries a requestId trace identifier so log lines from the same request can be correlated.

constructor(context: LogContext)

interface LogContext {
  requestId: string;
  method: string;
  path: string;
  userId?: string;
}

info(message: string, data?: Record<string, unknown>): void

warn(message: string, data?: Record<string, unknown>): void

error(message: string, data?: Record<string, unknown>): void

debug(message: string, data?: Record<string, unknown>): void

Each method emits a LogEntry as a single-line JSON object. The optional data map is merged into the entry under the data key.

interface LogEntry extends LogContext {
  level: 'debug' | 'info' | 'warn' | 'error';
  message: string;
  timestamp: string; // ISO 8601
  data?: Record<string, unknown>;
}

static fake(context?: Partial<LogContext>): FakeLogger

Create a FakeLogger pre-filled with defaults for test use. See FakeLogger API below.

FakeLogger API

FakeLogger extends Logger but captures entries in memory instead of writing to console.log. Used in tests to assert on logged output.

readonly entries: LogEntry[]

All log entries recorded since construction or the last restore() call.

assertLogged(level: LogLevel, message: string): void

Throws if no entry matching level with a message containing message exists in entries. Useful as an assertion in unit tests.

assertNotLogged(level: LogLevel): void

Throws if any entry at the given level exists in entries.

restore(): void

Clear all recorded entries. Call between tests.

const logger = Logger.fake();

await handler(fakeRequest, logger);

logger.assertLogged('info', 'payment.processed');
logger.assertNotLogged('error');
logger.restore();

RequestIdMiddleware

Extracts or generates a request trace ID and binds a configured Logger instance into the request-scoped container.

import { RequestIdMiddleware } from '@roostjs/core/middleware/request-id';

Behavior

  1. Reads cf-ray from request headers; falls back to crypto.randomUUID().
  2. Constructs a Logger with { requestId, method, path }.
  3. Binds the logger into the request-scoped container under Logger.
  4. Sets X-Request-Id on the outgoing response.
import { Application } from '@roostjs/core';
import { RequestIdMiddleware } from '@roostjs/core/middleware/request-id';

const app = Application.create(env);
app.useMiddleware(RequestIdMiddleware);

Downstream handlers and middleware can then resolve a pre-configured Logger from the scoped container.

Webhook Verification API

verifyWebhook(request: Request, options: WebhookVerifyOptions): Promise<string>

Verify an incoming webhook's HMAC or Ed25519 signature. Returns the raw request body as a string on success. Throws WebhookVerificationError on any failure (missing header, invalid signature, expired timestamp).

interface WebhookVerifyOptions {
  secret: string | Uint8Array;
  headerName: string;
  algorithm: 'hmac-sha256' | 'hmac-sha512' | 'ed25519';
  timestampHeader?: string;
  parseTimestamp?: (headerValue: string) => number;
  parseSignature?: (headerValue: string) => string;
  buildSignedPayload?: (timestamp: number | null, body: string) => string;
  tolerance?: number; // seconds; default 300; 0 disables timestamp check
}

WebhookPresets

Pre-built WebhookVerifyOptions factories for common providers. Call the method and override secret with your signing secret.

WebhookPresets.stripe(): WebhookVerifyOptions

HMAC-SHA256 using the stripe-signature header. Extracts t and v1 from the comma-separated header. Tolerance: 300 seconds.

WebhookPresets.github(): WebhookVerifyOptions

HMAC-SHA256 using the x-hub-signature-256 header. Strips the sha256= prefix. No timestamp tolerance.

WebhookPresets.svix(): WebhookVerifyOptions

Ed25519 using svix-signature and svix-timestamp headers. Extracts the v1, prefixed signature. Tolerance: 300 seconds.

WebhookMiddleware

Route-level middleware that calls verifyWebhook and returns 401 on failure.

constructor(options: WebhookVerifyOptions)

import { WebhookMiddleware } from '@roostjs/core/webhooks/middleware';
import { WebhookPresets } from '@roostjs/core/webhooks/verify';

const stripeWebhook = new WebhookMiddleware({
  ...WebhookPresets.stripe(),
  secret: env.STRIPE_WEBHOOK_SECRET,
});

Pass WebhookMiddleware to a route-specific pipeline or via app.useMiddleware() for a prefix-matched group.

WebhookVerificationError

class WebhookVerificationError extends Error {
  name: 'WebhookVerificationError';
}

Thrown by verifyWebhook when verification fails. WebhookMiddleware catches this and responds with 401 { "error": "<message>" }.

Errors

The following error classes are thrown at runtime but are not exported from @roostjs/core. Catch them by name or use instanceof checks after importing the concrete class from its internal module path (not recommended for production code — prefer guarding with defaults or has() checks instead).

BindingNotFoundError

Thrown by resolve() when no binding is registered for the token.

CircularDependencyError

Thrown when a circular dependency is detected during resolution.

ConfigKeyNotFoundError

Thrown by ConfigManager.get() when the key does not exist and no default is provided.

WebhookVerificationError

Thrown by verifyWebhook() when the signature header is missing, the signature does not match, or the timestamp is outside the tolerance window.