Skip to content

Core Package API ​

Package: @onebun/core

OneBunApplication ​

Main application class that bootstraps and runs the HTTP server.

Constructor ​

typescript
// Single-service mode
new OneBunApplication(
  moduleClass: new (...args: unknown[]) => object,
  options?: Partial<ApplicationOptions>
)

// Multi-service mode
new OneBunApplication(
  options: MultiServiceApplicationOptions
)

ApplicationOptions ​

typescript
interface ApplicationOptions {
  /** Application name for metrics/tracing labels */
  name?: string;

  /** Port to listen on
   * Priority: explicit option > PORT env variable > default (3000)
   */
  port?: number;

  /** Host to listen on
   * Priority: explicit option > HOST env variable > default ('0.0.0.0')
   */
  host?: string;

  /** Maximum idle time (seconds) before the server closes a connection.
   * A connection is idle when no data is sent or received.
   * Set to 0 to disable. Default: 120.
   */
  idleTimeout?: number;

  /** Maximum request body size in bytes.
   * Enforced by Bun on the request headers — before routing, before middleware — so an
   * oversized body is refused with 413 and never read into the process.
   * Absent leaves Bun's default of 128 MiB (134217728).
   */
  maxRequestBodySize?: number;

  /** Base path prefix for all routes (e.g., '/api/v1') */
  basePath?: string;

  /** Route prefix to prepend to all routes (typically service name) */
  routePrefix?: string;

  /** Enable development mode (default: NODE_ENV !== 'production') */
  development?: boolean;

  /** Logger configuration options.
   * Provides a declarative way to configure logging.
   * Priority: loggerLayer > loggerOptions > LOG_LEVEL/LOG_FORMAT env > NODE_ENV defaults
   */
  loggerOptions?: LoggerOptions;

  /** Custom logger layer (advanced, takes precedence over loggerOptions) */
  loggerLayer?: Layer.Layer<Logger>;

  /** Environment configuration schema */
  envSchema?: TypedEnvSchema;

  /** Environment loading options */
  envOptions?: {
    envFilePath?: string;
    loadDotEnv?: boolean;
    envOverridesDotEnv?: boolean;
    strict?: boolean;
    defaultArraySeparator?: string;
    valueOverrides?: Record<string, string | number | boolean>;
  };

  /** Metrics configuration */
  metrics?: MetricsOptions;

  /** Tracing configuration */
  tracing?: TracingOptions;

  /** WebSocket configuration */
  websocket?: WebSocketApplicationOptions;

  /** Static file serving: serve files from a directory for requests not matched by API routes */
  static?: StaticApplicationOptions;

  /**
   * Application-wide middleware class constructors applied to every route
   * before module-level, controller-level and route-level middleware.
   * Classes must extend BaseMiddleware. DI is fully supported.
   * Execution order: global → module → controller → route → handler.
   * See Controllers API — Middleware for details.
   */
  middleware?: MiddlewareClass[];

  /** Enable graceful shutdown on SIGTERM/SIGINT (default: true) */
  gracefulShutdown?: boolean;

  /**
   * Deadline for the whole shutdown sequence in ms (default: 15000).
   * The first half bounds the in-flight request drain — connections still open when it
   * expires are force-closed and counted in a warning — and the rest bounds the destroy
   * hooks. On the signal path a shutdown that hits the deadline exits with code 1.
   */
  shutdownTimeout?: number;

  /** Global exception filters. Route/controller filters take priority. */
  filters?: ExceptionFilter[];

  /**
   * CORS shorthand — auto-prepends CorsMiddleware.
   * Pass `true` for permissive defaults, or a CorsOptions object for custom config.
   * See Security Middleware for details.
   */
  cors?: CorsOptions | true;

  /**
   * Rate limiting shorthand — auto-prepends RateLimitMiddleware.
   * Pass `true` for defaults (100 req / 60s, in-memory), or a RateLimitOptions object.
   * See Security Middleware for details.
   */
  rateLimit?: RateLimitOptions | true;

  /**
   * Whether proxy headers (x-forwarded-for, cf-connecting-ip, x-real-ip) sent by the
   * caller may override the transport peer when identifying the client.
   * Off by default — those headers are attacker-controlled on a direct connection.
   * Enable only when every request arrives through a proxy that overwrites them.
   * Consumed by the default rate-limit key and the remoteAddr span field.
   * @default false
   */
  trustProxy?: boolean;

  /**
   * Security headers shorthand — auto-appends SecurityHeadersMiddleware.
   * Pass `true` for all defaults, or a SecurityHeadersOptions object.
   * See Security Middleware for details.
   */
  security?: SecurityHeadersOptions | true;
}

Bounding the request body ​

maxRequestBodySize is the only place an application can lower the transport's body limit. Bun refuses an oversized request on its headers — before routing, before middleware, before any framework code runs — and answers 413 Request Entity Too Large:

typescript
const app = new OneBunApplication(AppModule, {
  maxRequestBodySize: 1024 * 1024, // 1 MiB; absent leaves Bun's 128 MiB default
});

Two things it is not interchangeable with:

  • A content-length check in middleware runs after the transport has accepted the body, so it bounds what reaches your domain layer rather than what reaches the process — and it trusts a header the caller writes, which makes it a guard against accident, not against an attacker.
  • rateLimit bounds how many requests arrive. One 100 MB upload is one request.

The refusal happens for unmatched paths too, since it precedes routing, and neither handlers nor middleware are invoked for a request that exceeds the limit.

StaticApplicationOptions ​

When static is set, the same HTTP server serves API routes (and /docs, /metrics, WebSocket) as usual; any request that does not match those routes is served from a filesystem directory.

typescript
interface StaticApplicationOptions {
  /** Filesystem path to the directory to serve (static root). Absolute or relative to cwd. */
  root: string;

  /**
   * URL path prefix under which static files are served.
   * Omit or '/' = serve static for all paths not matched by API.
   * Example: '/app' = only paths starting with /app are served (prefix stripped when resolving file).
   */
  pathPrefix?: string;

  /**
   * Fallback file name (e.g. 'index.html') for SPA-style client-side routing.
   * When the requested file is not found, this file under static root is returned.
   */
  fallbackFile?: string;

  /**
   * TTL in ms for caching file existence checks. Use 0 to disable. Default: 60000.
   * Uses @onebun/cache CacheService when available, otherwise in-memory cache.
   */
  fileExistenceCacheTtlMs?: number;
}

Example: SPA on same host

typescript
const app = new OneBunApplication(AppModule, {
  static: {
    root: './dist',
    fallbackFile: 'index.html',
  },
});
await app.start();
// GET /api/*, /docs, /metrics, /ws handled by framework; GET /, /dashboard, etc. serve dist/ or index.html

Static responses go through the global middleware chain. A served file carries the same security headers a controller route does — which is the reason to serve a SPA from the API origin at all — and it consumes rateLimit budget like any other request. Size max for the number of assets a page pulls, or put the assets behind a CDN. A path that matches neither a route nor a static file goes through the same chain: it carries the security headers too, and rate limiting counts it like a request to a controller.

Example: static under a path prefix

typescript
const app = new OneBunApplication(AppModule, {
  static: {
    root: './public',
    pathPrefix: '/assets',
  },
});
// Only GET /assets/* are served from ./public; e.g. /assets/logo.png -> public/logo.png

Methods ​

typescript
class OneBunApplication {
  /**
   * Start the HTTP server.
   * Rejects with the error that stopped the boot, after releasing everything the boot had
   * acquired — the same sequence as `stop()`. A failure in `onModuleInit` arrives wrapped by
   * Effect (`FiberFailure`, message intact). A retry may call it again on the same instance.
   */
  async start(): Promise<void>;

  /**
   * Drain in-flight requests, then stop the server and run the destroy hooks.
   * Idempotent: a second call awaits the first shutdown instead of repeating it.
   * Called while `start()` is still booting, it waits for the boot to settle first.
   * Always resolves within `shutdownTimeout`, that wait included.
   */
  async stop(options?: { 
    /** @deprecated Ignored — an application releases no shared Redis hold. */
    closeSharedRedis?: boolean; 
    signal?: string;  // e.g., 'SIGTERM', 'SIGINT'
  }): Promise<void>;

  /** Enable graceful shutdown signal handlers (SIGTERM, SIGINT). Registers at most once. */
  enableGracefulShutdown(): void;

  /** Get configuration service with full type inference via module augmentation */
  getConfig(): IConfig<OneBunAppConfig>;

  /** Get configuration value by path with full type inference via module augmentation */
  getConfigValue<P extends DeepPaths<OneBunAppConfig>>(path: P): DeepValue<OneBunAppConfig, P>;
  getConfigValue<T = unknown>(path: string): T;

  /** Get logger instance */
  getLogger(context?: Record<string, unknown>): SyncLogger;

  /** Get HTTP URL where application is listening */
  getHttpUrl(): string;

  /** Get root module layer */
  getLayer(selections?: ServiceSelection[]): Layer.Layer<never, never, unknown>;

  /** Get a service instance by class from the module container, optionally naming a registration */
  getService<T>(serviceClass: new (...args: unknown[]) => T, token?: symbol | string): T;

  /** Get a child OneBunApplication instance by service name (multi-service mode only) */
  getApplication(name: string): OneBunApplication | undefined;

  /** Get URL for a service — local if running, external if configured (multi-service mode only) */
  getServiceUrl(name: string): string;

  /** Get all running service names (multi-service mode only) */
  getRunningServices(): string[];

  /** Check if a specific service is running (multi-service mode only) */
  isServiceRunning(name: string): boolean;
}

Usage Example ​

typescript
import { OneBunApplication } from '@onebun/core';
import { AppModule } from './app.module';
import { envSchema } from './config';

const app = new OneBunApplication(AppModule, {
  basePath: '/api/v1',
  envSchema,
  metrics: {
    enabled: true,
    path: '/metrics',
    prefix: 'myapp_',
  },
  tracing: {
    enabled: true,
    serviceName: 'my-service',
    samplingRate: 1.0,
  },
});

app
  .start()
  .then(() => {
    // Access configuration - fully typed with module augmentation
    const port = app.getConfigValue('server.port');  // number (auto-inferred)
    const config = app.getConfig();
    const host = config.get('server.host');          // string (auto-inferred)

    const logger = app.getLogger({ className: 'AppBootstrap' });
    logger.info('Application started', { port, host });
  })
  .catch((error: unknown) => {
    const logger = app.getLogger({ className: 'AppBootstrap' });
    logger.error('Failed to start:', error instanceof Error ? error : new Error(String(error)));
    // The failed start() already released everything, so without this the process exits 0
    process.exit(1);
  });

// Application will automatically handle shutdown signals (SIGTERM, SIGINT)

Accessing Services Outside of Requests ​

Use getService() to access services outside of the request context, for example in background tasks or scripts:

typescript
const app = new OneBunApplication(AppModule, options);
await app.start();

// Get a service instance
const userService = app.getService(UserService);

// Use the service
await userService.performBackgroundTask();
await userService.sendScheduledEmails();

Service identity ​

Every @Service() class gets one Effect tag, keyed by the class NAME. Constructor injection and getService(Class, token) do not use that key — they resolve by tag identity at the module boundary — so this is invisible to most applications. It becomes visible wherever one application holds TWO instances of one service class.

Named registrations. DrizzleModule.forRoot({ ..., as: MAIN_DB }) and forRoot({ ..., as: ANALYTICS_DB }) both provide DrizzleService: one class, one key, two instances. Name the one you want and you always get it:

typescript
const main = app.getService(DrizzleService, MAIN_DB);

Ask without naming one and there is no correct answer, so app.getService(DrizzleService) throws rather than choosing. The instance it would otherwise return depends on the order the modules were imported in, which is not something your code should depend on.

getLayer() carries one instance per service class. The value it returns is an Effect Context, and a Context has exactly one slot per key. An application with two registrations of one service — or with two service classes that share a name — has more instances than the layer has slots, so getLayer() reports that instead of silently returning whichever instance was merged last.

Say which instance takes the slot. getLayer() accepts [ServiceClass, token] pairs, the layer counterpart of getService(Class, token):

typescript
const layer = app.getLayer([[MailerService, PRIMARY_MAILER]]);

await Effect.runPromise(Effect.provide(program, layer));

ServiceSelection is the [ServiceClass, token] pair type. The layer still holds one instance per class — that is a property of Context, not a choice — but which one is yours to state rather than a function of module import order. Every ambiguous class must be named; leaving one out still refuses, and the message names only what is still unresolved. Naming an unambiguous class is allowed and simply pins what was already going to be there. To reach a single instance without building a layer at all, getService(Class, token) or @Inject(token).

Two service classes with the same name. Two classes called CacheService from different packages are separate services to the framework, and injection resolves each of them correctly. They mint the same tag key, so they cannot both appear in a layer. If your application needs both in one layer, give one an explicit tag:

typescript
import { Context } from 'effect';

export const BillingCacheTag = Context.GenericTag<CacheService>('@acme/billing/CacheService');

@Service(BillingCacheTag)
export class CacheService extends BaseService {}

The convention the framework packages follow is @scope/package/ClassName.

One copy of @onebun/core per application. Decorator metadata is held per copy of the framework. If node_modules resolves two copies, classes decorated by one copy are invisible to the other and boot fails with a dependency error naming a service that is correctly decorated. Deduplicate the dependency; there is no runtime workaround.

Graceful Shutdown ​

OneBun enables graceful shutdown by default. On SIGTERM or SIGINT — and on any await app.stop() — it runs this sequence, in this order:

  1. Refuses new requests: every route answers 503 Service Unavailable ({"success": false, "error": "Service Unavailable", ...}). The listener stays open on purpose, so a load balancer sees a refusal instead of a dropped connection. A WebSocket upgrade attempted from here on is refused with 503 too, which matters because step 2 is itself an invitation to reconnect.
  2. Closes WebSocket connections: each open socket is closed with RFC 6455 code 1001 ("going away") and the reason Server shutting down, and every @OnDisconnect handler is awaited — while the gateway, its client storage and the DI scope are all still alive. Bounded at 5 seconds; anything still open after that is cut by step 4.
  3. Drains in-flight requests: waits for the requests already being served to finish. Anything still open when the drain deadline expires is force-closed, and a warn names how many connections were cut.
  4. Closes the HTTP listener — before any destroy hook runs.
  5. Calls beforeApplicationDestroy(signal) hooks on all services and controllers
  6. Releases the remaining WebSocket resources: ping timers and client storage
  7. Stops the queue service and disconnects the queue adapter, after waiting for a scheduled job that is mid-run (bounded at 30 seconds)
  8. Stops system-metrics collection — the 5-second sampler started at boot. Its setInterval would otherwise keep the event loop alive, so this step is what lets a script that boots and stops an application terminate
  9. Flushes traces
  10. Calls onModuleDestroy() hooks on all services and controllers
  11. Logs, at debug level, whoever still holds the shared Redis connection. The application releases nothing: the cache and the queue adapter give their holds back when they close, and the connection closes when the last holder lets go
  12. Calls onApplicationDestroy(signal) hooks on all services and controllers
  13. Flushes the logger transport

Steps 1–4 are what keeps a rolling deploy from cutting responses that were mid-flight: the destroy hooks never run while the socket is still accepting work.

Bun reports the close code as 1000 to its own client

The server sends 1001 — that is what a close callback on the server side reports, and what the reason accompanies. Bun's WebSocket client currently surfaces the code as 1000 regardless; a bare Bun.serve with no framework involved shows the same. If your client branches on the code, branch on the reason instead until that changes.

Either way the client gets a close frame at step 2, while the service is still up: its readyState leaves 1 and it can reconnect elsewhere, rather than learning the service is gone from an abnormal 1006 when the process exits. @OnDisconnect runs while the client storage is still intact — it is released only at step 6.

Bounded, always. shutdownTimeout (default 15000 ms) caps the whole sequence. The first half of that budget bounds the drain; the rest bounds the destroy hooks. stop() resolves when the deadline expires whatever is still running, logging what that was; on the signal path the process then exits with code 1 instead of 0.

Idempotent. stop() runs once per application. A second call — sequential, overlapping, or a second signal — awaits the first shutdown and re-runs nothing. A signal arriving during a shutdown is logged (Already shutting down, ignoring SIGINT) and does not restart the drain. enableGracefulShutdown() registers its listeners at most once per instance.

Called during start(), it waits for the boot. A stop() that arrives while start() is still booting — a service still in onModuleInit, the queue not yet connected — does not run beside the boot. It logs stop() called while start() is still booting: stopping once the boot settles, waits for start() to settle, and then does what a stop() called right after it would do: after a boot that resolved, the whole sequence above, over everything the boot acquired — including the queue connection and the listener it opened after stop() was called; after a boot that failed, which rolled itself back (When start() fails), only the close that follows a rollback. start() resolves, or rejects with the boot's own error, exactly as it would have without the stop(). Destroy hooks run once, after every onModuleInit has finished. In multi-service mode the parent's stop() waits for its start() the same way, and after a start() that resolved it stops every service, including the ones that finished booting after the stop() was called.

The wait is what makes the stop complete. A boot in progress still has the queue to connect, the listener to open and its signal handlers to register; a teardown run beside it would release only what existed when it began, leave everything acquired afterwards open, and keep the process alive.

The wait counts toward shutdownTimeout: the sequence gets what is left of the budget, so stop() still resolves within it. A boot still running when the budget is spent is not waited for any further — stop() resolves, logging Shutdown timed out after <n>ms while waiting for start() to finish booting, and the signal path exits with code 1 — and the application is stopped as soon as that boot settles. Only the system-metrics sampler is stopped at the deadline: it is started before onModuleInit, nothing in the boot depends on it, and left running it would keep the process alive by itself. Everything else the boot holds stays open until it settles — a queue connection too, if the boot hangs in onApplicationInit, after connecting it. So a stop() against a boot that never settles resolves only after the whole shutdownTimeout (15 s by default, longer than a test runner's hook timeout: pass a shorter one to an application a test tears down), and if that boot holds a connection, end the process yourself once stop() has given up on it.

To abort a boot from inside it, throw from the hook — the rollback releases what the boot acquired. Do not await app.stop() there: an onModuleInit or onApplicationInit awaiting it waits for its own boot, and sits out the whole shutdownTimeout before the boot can go on.

typescript
// Default: graceful shutdown is enabled, with a 15s budget
const app = new OneBunApplication(AppModule);
await app.start();
// SIGTERM/SIGINT handlers are automatically registered

// Give slow requests more room to finish (drain gets the first half: 15s here)
const app = new OneBunApplication(AppModule, {
  shutdownTimeout: 30_000,
});

// To disable automatic shutdown handling:
const app = new OneBunApplication(AppModule, {
  gracefulShutdown: false,
});
await app.start();
app.enableGracefulShutdown(); // Register the handlers yourself instead

// Programmatic shutdown — drains, then closes the server and WebSocket connections.
// The shared Redis client is NOT released here: whoever acquired a hold gives it back, and
// the connection closes when the last holder does.
await app.stop();

// Deprecated and ignored: the option has no effect and logs a warning
await app.stop({ closeSharedRedis: false });

// Pass signal for lifecycle hooks
await app.stop({ signal: 'SIGTERM' });

Multi-service mode: the parent application registers the one SIGTERM/SIGINT handler for the process; child services never register their own. The handler runs stopAll(), which stops every service concurrently (each service still drains its own requests), and the process exits only after the last service has finished its hooks. Pass gracefulShutdown: false in MultiServiceApplicationOptions to install no handler at all.

The signal name is not forwarded to the children: stopAll() calls each child's stop() with no arguments, so beforeApplicationDestroy(signal) and onApplicationDestroy(signal) both receive undefined in multi-service mode — even when the parent was given an explicit stop({ signal: 'SIGTERM' }). Do not branch on signal there.

When start() fails ​

A start() that rejects has already stopped the application. Before the error reaches your catch, it runs the shutdown sequence over whatever the boot got as far as acquiring — the HTTP listener, the WebSocket storage, the queue service and the adapter's connection, the system-metrics sampler, the trace and log flushes — and the destroy hooks. Then it rethrows the error it caught; nothing in the rollback replaces it.

So a process that catches a failed boot ends on its own. The queue adapter's connection and the metrics sampler, each of which would keep the event loop open, are released before the rejection reaches your catch, so a test or a supervisor that catches it does not have to call stop() for the process to exit.

  • Nothing to clean up in the catch. await app.stop() after a rejected start() resolves and runs none of the sequence again: it awaits the rollback that already ran, and the destroy hooks do not run twice. It does close what the rollback left open for a retry, as every stop() does: it shuts down the tracing.spanProcessors you passed in, releases a metrics.registry you passed in, and flushes and closes every OTLP log transport in the process — a loggerLayer you passed in included, and the log export of any other application still running in the process.
  • A stop() called during the boot waits for it (Graceful Shutdown). When that boot then fails, start() still rejects with its own error, the rollback releases what the boot acquired — a queue connection opened after the stop() was called included — and the stop() then does what a stop() after the failure does. A retry on the same instance still boots, and a stop() after the retry stops it.
  • Exit non-zero yourself. With nothing holding the process, a catch that only logs lets it end with code 0 — a failed boot reported to the supervisor as a success. End the catch with process.exit(1), as the minimal example does.
  • The error is the one that stopped the boot. The log leads with it (Failed to start application:), then brackets the teardown between Rolling back the failed start: releasing what it acquired and Failed start rolled back. A teardown step that fails is logged — Shutdown step "disconnecting the queue adapter" failed; ... and a summary, Rollback of the failed start completed with 1 failed step(s): ... — and never takes the original error's place.
  • Match a hook failure on its message, not its class. start() rethrows the value it caught. Thrown from onApplicationInit, a queue subscription or a taken port, that is the very object thrown. Thrown from a service's or controller's onModuleInit — where a backend unreachable at boot usually fails — it reaches start() already wrapped by Effect as a FiberFailure: the message is intact, the class is not, so instanceof and toBe do not hold there. The cache and drizzle startup errors are matched that way.
  • The rollback closes what the application built, and nothing you passed in. It disposes the application's own metrics registry, shuts down its trace provider and OTLP span exporter, and closes the OTLP transport of the logger it built — Failed to start application: is flushed to the collector first, and a line you log in the catch afterwards through app.getLogger() reaches the console only. A loggerLayer, the tracing.spanProcessors and a metrics.registry from your options stay open and keep working: the processors are flushed, not shut down, and nothing registered on the registry is cleared. In multi-service mode the services that did start keep exporting their logs.
  • A loggerLayer you built keeps an OTLP transport open. Its flush timer holds the process alive after the rollback, as it would after any failed attempt you intend to retry. When you give up without process.exit(), close it with await app.stop() or with shutdownLogger() from @onebun/logger.
  • Destroy hooks run for everything that was built, including a service whose onModuleInit never ran, or threw halfway: the boot may have failed before it, or in it. Release what exists (if (this.pool) { ... }), not what onModuleInit would have opened.
  • Retry with a new instance — or the same one. A new OneBunApplication per attempt is the simplest retry: the next attempt shares nothing with the failed one except what you pass to both, and a loggerLayer, tracing.spanProcessors or metrics.registry shared that way still works, because the rollback left it open. Calling start() again on the instance whose start failed boots as well: nothing of the failed attempt is left running, the retry rebuilds the metrics registry, the trace provider and the OTLP log transport the rollback closed, reuses what you passed in, and a stop() after the retry stops it.
typescript
import { OneBunApplication } from '@onebun/core';
import { AppModule } from './app.module';

const MAX_ATTEMPTS = 5;
const RETRY_DELAY_MS = 2_000;

async function startWithRetry(): Promise<OneBunApplication> {
  for (let attempt = 1; ; attempt++) {
    // A new instance per attempt: the one whose start() failed has released everything
    const app = new OneBunApplication(AppModule);
    try {
      await app.start();

      return app;
    } catch (error) {
      // Nothing to stop() here — the failed start() released what it had acquired
      if (attempt === MAX_ATTEMPTS) {
        throw error;
      }
      await Bun.sleep(RETRY_DELAY_MS);
    }
  }
}

The rollback is bounded by shutdownTimeout like any shutdown. In multi-service mode each service rolls back its own failed start; the services that did start are not stopped by it, and the parent's stop() does not reach them either — exit the process from the catch.

Lifecycle Hooks ​

Services and controllers can implement lifecycle hooks to execute code at specific points:

InterfaceMethodWhen Called
OnModuleInitonModuleInit()After instantiation and DI
OnApplicationInitonApplicationInit()After all modules, before HTTP server
OnModuleDestroyonModuleDestroy()During shutdown, after HTTP server stops
BeforeApplicationDestroybeforeApplicationDestroy(signal?)After the drain and listener close — first hook of the teardown
OnApplicationDestroyonApplicationDestroy(signal?)End of shutdown

The listener is already closed when beforeApplicationDestroy runs, so a hook cannot serve or self-call over HTTP — traffic was refused with 503 from the start of the drain, well before it. The three destroy hooks also run when start() fails, for every instance that was built — see When start() fails. In multi-service mode signal is undefined in both hooks — see Graceful Shutdown.

See Services API for detailed usage examples.

Multi-Service Mode ​

Run multiple services in a single process using the unified OneBunApplication constructor.

Constructor ​

typescript
new OneBunApplication(options: MultiServiceApplicationOptions)

MultiServiceApplicationOptions ​

typescript
interface MultiServiceApplicationOptions {
  services: ServicesMap;
  envSchema?: TypedEnvSchema;
  envOptions?: EnvLoadOptions;
  queue?: QueueApplicationOptions;
  enabledServices?: string[];
  excludedServices?: string[];
  externalServiceUrls?: Record<string, string>;
  /** One process-level SIGTERM/SIGINT handler on the parent (default: true) */
  gracefulShutdown?: boolean;
  /** Shutdown deadline in ms for stopAll() and every child (default: 15000) */
  shutdownTimeout?: number;

  // Defaults for every service — a service that sets the same key wins
  host?: string;
  basePath?: string;
  routePrefix?: boolean;
  envOverrides?: EnvOverrides;
  logger?: { minLevel?: 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'trace' };
  metrics?: MetricsOptions;
  tracing?: TracingOptions;
  middleware?: MiddlewareClass[];
  static?: StaticApplicationOptions;
  maxRequestBodySize?: number;
}

interface ServiceConfig {
  /** Root module CLASS — a bare `Function` is not assignable */
  module: new (...args: unknown[]) => object;
  port: number;
  host?: string;
  basePath?: string;
  routePrefix?: boolean;
  envOverrides?: EnvOverrides;
  /** Extra ENV variables for this service only */
  envSchemaExtend?: TypedEnvSchema;
  logger?: { minLevel?: 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'trace' };
  metrics?: MetricsOptions;
  tracing?: TracingOptions;
  middleware?: MiddlewareClass[];
  static?: StaticApplicationOptions;
  /** Body cap for this service's listener; falls back to the application-level value */
  maxRequestBodySize?: number;
}

type ServicesMap = Record<string, ServiceConfig>;

Two per-service keys are accepted but always overwritten with the services-map key: tracing.serviceName and metrics.defaultLabels.service. tracing: { serviceName: 'users-service' } on the users service reaches nothing — the tracer reports users. Rename the map key instead. metrics.prefix and the rest are merged service-over-application as documented above.

static is the one option that does not cascade: it is honoured per service only, an application-level static is not passed to the children.

envOverrides keys are variable names

Keys are environment variable names (DB_NAME), never config.get() paths — a wrong key is ignored silently.

Per-service scoping itself holds: each service gets its own configuration instance, so its envOverrides and envSchemaExtend apply to it alone — no service reads the ENV resolved for another, whichever one starts first.

Usage Example ​

typescript
import { OneBunApplication } from '@onebun/core';
import { UsersModule } from './users/users.module';
import { OrdersModule } from './orders/orders.module';
import { envSchema } from './config';

const app = new OneBunApplication({
  services: {
    users: {
      module: UsersModule,
      port: 3001,
      routePrefix: true,
    },
    orders: {
      module: OrdersModule,
      port: 3002,
      routePrefix: true,
      envOverrides: {
        DB_NAME: { value: 'orders_db' },
      },
    },
  },
  envSchema,
  metrics: { enabled: true },
  tracing: { enabled: true },
});

await app.start();
console.log('Running:', app.getRunningServices());
console.log('Users URL:', app.getServiceUrl('users'));

// Access child application
const usersApp = app.getApplication('users');

OneBunModule ​

Internal module class (usually not used directly).

typescript
class OneBunModule implements Module {
  static create(
    moduleClass: Function,
    loggerLayer?: Layer.Layer<never, never, unknown>,
    config?: unknown,
  ): Module;

  setup(): Effect.Effect<unknown, never, void>;
  getControllers(): Function[];
  getControllerInstance(controllerClass: Function): Controller | undefined;
  getServiceInstance<T>(tag: Context.Tag<T, T>): T | undefined;
  getLayer(selections?: ServiceSelection[]): Layer.Layer<never, never, unknown>;
  getExportedServices(): Map<Context.Tag<unknown, unknown>, unknown>;
}

Global Modules ​

Modules decorated with @Global() automatically make their exported services available in all other modules without explicit import. This is useful for cross-cutting concerns like database connections.

typescript
import { Module, Global, Service, BaseService } from '@onebun/core';

@Service()
export class DatabaseService extends BaseService {
  async query(sql: string) { /* ... */ }
}

// Mark module as global
@Global()
@Module({
  providers: [DatabaseService],
  exports: [DatabaseService],
})
export class DatabaseModule {}

// Root module imports DatabaseModule once
@Module({
  imports: [DatabaseModule],
})
export class AppModule {}

// All other modules can inject DatabaseService without importing DatabaseModule
@Module({
  providers: [UserService], // UserService can inject DatabaseService automatically
})
export class UserModule {}

Import order does not matter. A @Global() module's services reach every module that can see it regardless of where it sits in an imports array — and whether or not the importing module lists it at all. A sibling import that happens to initialize the global module first does not take its services away from the importer.

Visibility, not instance count. @Global() makes a module's exported services reachable from every module without an explicit import; a module without it is reachable only where it is imported. Either way the module itself is constructed exactly ONCE per application, so two modules importing the same one share its services rather than each getting a copy.

Scope: one instance per application. A @Global() module contributes exactly one instance per application — not one per process. Two applications in the same process each build their own, so a second DrizzleModule.forRoot() or CacheModule.forRoot() opens its own connection and never reuses the first application's. In multi-service mode the boundary is the sub-application: one global service instance per sub-application, and stopping one leaves its siblings untouched.

Globality itself is still per process. The instances are per application; the answer to "is this module ambient?" is not — it lives in one Set keyed by the module class. Two unnamed forRoot() calls that disagree about it, or about what they configure, therefore cannot each be honoured, and the framework refuses rather than letting the last one decide: an application importing the contested module fails at start() with OneBunConflictingRegistrationError naming both call sites. Give each configuration a token — forRoot({ as: TOKEN }) with forFeature(TOKEN) — when they must coexist.

The options a dynamic module was imported with are captured per application at import time, so a later forRoot() in the same process cannot retroactively change what an already-running application is using.

Global Module Utilities:

typescript
// Check if module is global
import { isGlobalModule } from '@onebun/core';
isGlobalModule(DatabaseModule); // true

Metrics Options ​

typescript
interface MetricsOptions {
  /** Enable/disable metrics (default: true) */
  enabled?: boolean;

  /** HTTP path for metrics endpoint (default: '/metrics') */
  path?: string;

  /** Default labels for all metrics */
  defaultLabels?: Record<string, string>;

  /** Enable automatic HTTP metrics (default: true) */
  collectHttpMetrics?: boolean;

  /** Enable automatic system metrics (default: true) */
  collectSystemMetrics?: boolean;

  /** Enable GC metrics (default: true) */
  collectGcMetrics?: boolean;

  /** System metrics collection interval in ms (default: 5000) */
  systemMetricsInterval?: number;

  /** Custom prefix for all metrics (default: 'onebun_') */
  prefix?: string;

  /** Buckets for HTTP duration histogram */
  httpDurationBuckets?: number[];
}

Tracing Options ​

typescript
interface TracingOptions {
  /** Enable/disable tracing (default: true) */
  enabled?: boolean;

  /** Service name (default: 'onebun-service') */
  serviceName?: string;

  /** Service version (default: '1.0.0') */
  serviceVersion?: string;

  /** Sampling rate 0.0-1.0 (default: 1.0) */
  samplingRate?: number;

  /** Trace HTTP requests (default: true) */
  traceHttpRequests?: boolean;

  /** Trace database queries (default: true) */
  traceDatabaseQueries?: boolean;

  /** Default span attributes */
  defaultAttributes?: Record<string, string | number | boolean>;

  /** Export options for external tracing systems */
  exportOptions?: {
    endpoint?: string;
    headers?: Record<string, string>;
    timeout?: number;
    batchSize?: number;
    batchTimeout?: number;
  };
}

redactConnectionUrl ​

Print a connection target without printing its credentials.

typescript
import { redactConnectionUrl } from '@onebun/core';

redactConnectionUrl('postgresql://app:hunter2@db:5432/orders?sslmode=require');
// 'postgresql://app:***@db:5432/orders?sslmode=require'

Scheme, user, host, port, path and query survive; only the password is replaced. A URL that carries no password is returned unchanged, and a string with no :// is replaced wholesale — there is no host in it worth preserving and no way to find its credentials, so echoing it back is the one thing that would leak.

It is public because the guarantee has to hold in more than one package: @onebun/drizzle and the shared Redis provider both name their target in a startup error, and a hand-rolled redactor per package is how one of them ends up failing open. Use it anywhere your own code prints a connection string — a health endpoint, a startup log, an error.

Why it does not parse

Two obvious implementations fail open on exactly the passwords that need this most. new URL() rejects a URL whose password contains a raw /, so the catch branch prints the credential. A single regex that classes the password as [^@/]* matches nothing when the password holds a /, and stops at the first @ when it holds an @. Cloud providers emit both characters routinely. This finds the authority by position and cuts the userinfo at its last@, so no character inside a password can end the match early.

Re-exports ​

The core package re-exports commonly used items:

typescript
// From @onebun/envs
export { Env, type EnvSchema, type InferConfigType, EnvValidationError } from '@onebun/envs';

// From @onebun/logger
export type { SyncLogger } from '@onebun/logger';

// From @onebun/requests
export {
  createHttpClient,
  type ErrorResponse,
  HttpStatusCode,
  InternalServerError,
  isErrorResponse,
  NotFoundError,
  OneBunBaseError,
  type SuccessResponse,
} from '@onebun/requests';

// From effect
export { Effect, Layer } from 'effect';

// Internal
export { OneBunApplication } from './application';
export { Controller as BaseController } from './controller';
export { BaseService, Service, getServiceTag } from './service';
export { OneBunModule } from './module';
export * from './decorators';
export * from './validation';

Released under the MPL-2.0 License.