orkos
v1.1.1
Published
A lightweight modular application orchestrator for TypeScript with dependency-ordered startup, shutdown, and topological module resolution.
Maintainers
Readme
orkos 🎻
A lightweight modular application orchestrator for TypeScript.
Define modules with dependencies, and orkos sets them up in the right order and tears them down in reverse. Built on top of eldin for dependency injection.
Core Philosophy
Application startup is deceptively complex: services depend on each other, initialization order matters, and teardown must reverse that order reliably. orkos handles this with a single, explicit pattern — modules declare their dependencies, and a topological sort determines the rest. No implicit wiring, no decorator magic, no runtime surprises. The shared eldin container gives modules a clean way to exchange services without tight coupling.
Table of Contents
- Core Philosophy
- Installation
- Quick Start
- Modules
- defineModule
- External Modules
- Dependency Ordering
- Module Lifecycle
- Application API
- Error Handling
- Usage with eldin
- Contributing
- License
Installation
npm install orkos eldin --saveQuick Start
import { Application } from 'orkos';
import type { IModule } from 'orkos';
import type { IContainer } from 'eldin';
class ConfigModule implements IModule {
readonly name = 'config';
async setup(container: IContainer): Promise<void> {
container.register(ConfigToken, { useValue: { port: 3000 } });
}
}
class DatabaseModule implements IModule {
readonly name = 'database';
readonly dependencies = ['config'];
async setup(container: IContainer): Promise<void> {
const config = container.resolve(ConfigToken);
const db = new Database(config);
await db.connect();
container.register(DatabaseToken, { useValue: db });
}
async teardown(container: IContainer): Promise<void> {
const db = container.resolve(DatabaseToken);
await db.disconnect();
}
}
class HttpModule implements IModule {
readonly name = 'http';
readonly dependencies = ['config', 'database'];
async setup(container: IContainer): Promise<void> {
const config = container.resolve(ConfigToken);
const server = createServer(config.port);
await server.listen();
}
}
const app = new Application({
modules: [
new ConfigModule(),
new DatabaseModule(),
new HttpModule(),
],
});
// Sets up in dependency order: config -> database -> http
await app.setup();
// Tears down in reverse: http -> database -> config
await app.teardown();Modules
A module is any object implementing the IModule interface:
interface IModule {
readonly name: string;
readonly version?: string;
readonly dependencies?: (string | ModuleDependency)[];
setup(container: IContainer): Promise<void>;
teardown?(container: IContainer): Promise<void>;
onReady?(container: IContainer): Promise<void>;
onError?(error: Error, container: IContainer): Promise<void>;
}| Property | Description |
|----------|-------------|
| name | Unique identifier for the module |
| version | Optional semver version string |
| dependencies | Array of module names or ModuleDependency objects |
| setup() | Called during startup with the shared DI container |
| teardown() | Optional cleanup, called during shutdown |
| onReady() | Called after all modules have been set up successfully |
| onError() | Called when this module's setup() throws an error |
Modules receive the application's shared eldin container. Use it to register and resolve dependencies across modules.
ModuleDependency
Dependencies can be plain strings (module names) or objects with additional constraints:
interface ModuleDependency {
name: string; // module name to depend on
version?: string; // semver range (e.g. '>=2.0.0', '^1.3.0')
optional?: boolean; // skip silently if not registered or resolvable
package?: string; // npm package name, if different from the module name
}Examples:
const dependencies = [
'config', // simple: depend on module named 'config'
{ name: 'database', version: '>=2.0.0' }, // require database v2+
{ name: 'cache', optional: true }, // skip if cache isn't available
{ name: 'redis', package: '@myorg/orkos-redis' }, // resolve from a different package name
];The package field is used during external module resolution — it tells orkos which npm package to import() when the module isn't already registered. Without it, orkos uses the name as both the module name and the package name.
defineModule
The defineModule helper provides a convenient way to create configurable modules with typed options and defaults.
Inline Definition
import { defineModule } from 'orkos';
const CacheModule = defineModule<{ driver: 'memory' | 'redis'; ttl: number }>({
name: 'cache',
dependencies: ['config'],
defaults: { driver: 'memory', ttl: 3600 },
async setup(options, container) {
// options is { driver, ttl } with defaults merged
container.register(CacheToken, {
useFactory: () => createCache(options),
});
},
async teardown(options, container) {
const cache = container.resolve(CacheToken);
await cache.close();
},
});
const app = new Application({
modules: [
CacheModule(), // use defaults
CacheModule({ driver: 'redis' }), // override driver, keep ttl default
CacheModule(false), // disable (no-op)
],
});Factory Definition
Wrap a class-based IModule implementation with options:
import { defineModule } from 'orkos';
class CacheModule implements IModule {
readonly name = 'cache';
constructor(private options: { driver: string; ttl: number }) {}
async setup(container: IContainer) {
// use this.options
}
}
const createCacheModule = defineModule<{ driver: string; ttl: number }>({
defaults: { driver: 'memory', ttl: 3600 },
factory: (options) => new CacheModule(options),
});
const app = new Application({
modules: [
createCacheModule(),
createCacheModule({ driver: 'redis' }),
],
});External Modules
Modules can be referenced by npm package name and resolved automatically via dynamic import(). This is useful for sharing modules across projects as npm packages.
Referencing External Modules
Pass a string (package name) or a [string, options] tuple anywhere you'd pass an IModule:
const app = new Application({
modules: [
new ConfigModule(), // internal module (IModule)
'orkos-redis', // external: resolve from node_modules
['orkos-redis', { host: '10.0.0.1' }], // external with options
],
});
// Or via addModule:
app.addModule('orkos-redis');
app.addModule(['orkos-redis', { host: '10.0.0.1' }]);External modules are resolved lazily — the import() call happens during setup(), not at registration time. This keeps addModule() synchronous.
Package Convention
An orkos-compatible npm package must export a default export that is either a ModuleFactory (function) or an IModule (object).
Factory export (recommended — supports options):
// orkos-redis/src/index.ts
import { defineModule } from 'orkos';
export default defineModule<{ host: string; port: number }>({
name: 'orkos-redis',
defaults: { host: 'localhost', port: 6379 },
async setup(options, container) {
const client = createRedisClient(options);
container.register(RedisToken, { useValue: client });
},
async teardown(options, container) {
const client = container.resolve(RedisToken);
await client.disconnect();
},
});Plain IModule export (no options support):
// orkos-metrics/src/index.ts
import type { IModule } from 'orkos';
const metricsModule: IModule = {
name: 'orkos-metrics',
async setup(container) {
// initialize metrics collection
},
};
export default metricsModule;When orkos imports a package:
- Function → calls it as a
ModuleFactorywith provided options (or no args) - Object with
nameandsetup→ uses it directly as anIModule - Anything else → throws
INVALID_MODULE_EXPORT
Name matching: The module's
namemust match the package name used to reference it. If you add'orkos-redis', the exported module must havename: 'orkos-redis'. A mismatch throwsINVALID_MODULE_EXPORT. To use a different module name, declare the mapping via thepackagefield in aModuleDependency.
End-to-End Example
Package author publishes orkos-redis:
// orkos-redis/src/index.ts
import { defineModule } from 'orkos';
export default defineModule<{ url: string }>({
name: 'orkos-redis',
defaults: { url: 'redis://localhost:6379' },
async setup(options, container) {
const client = new RedisClient(options.url);
await client.connect();
container.register(RedisToken, { useValue: client });
},
async teardown(options, container) {
const client = container.resolve(RedisToken);
await client.disconnect();
},
});Consumer uses it in their application:
import { Application } from 'orkos';
const app = new Application({
modules: [
new ConfigModule(),
['orkos-redis', { url: 'redis://prod:6379' }], // resolved via import('orkos-redis')
new AuthModule(), // can depend on 'orkos-redis'
],
});
await app.setup();Dependency Resolution Flow
When setup() is called, orkos resolves modules in this order:
- Resolve explicit externals — strings and tuples passed to
addModuleor the constructor - Scan dependencies — check each resolved module's
dependenciesfor unregistered names - Auto-resolve missing deps — attempt
import(name)(orimport(package)if thepackagefield is set) - Repeat recursively — newly resolved modules may have their own unresolved dependencies
- Topological sort — once all modules are resolved, determine setup order
- Throw on failure — if a non-optional dependency can't be found after auto-resolution, throw
MODULE_NOT_FOUND
Optional dependencies (optional: true) are silently skipped if they can't be resolved.
The recursive resolution has a configurable depth limit (maxResolveDepth, default 10) to prevent runaway chains.
Auto-Install
By default, missing packages throw an error with a helpful install command:
ApplicationError: Module "orkos-redis" could not be resolved. Run: npm install orkos-redisEnable autoInstall to install them automatically via @antfu/install-pkg:
const app = new Application({
autoInstall: true, // attempts npm install before throwing
modules: ['orkos-redis'],
});Re-Resolution
External modules are cached after first resolution. Subsequent setup() calls reuse the cached modules. To force a fresh import() of all previously resolved externals (e.g. during development):
await app.setup({ resolveCache: false });This removes all previously resolved external modules and re-imports them.
Dependency Ordering
orkos resolves module setup order using topological sort (Kahn's algorithm):
const app = new Application({
modules: [
new HttpModule(), // dependencies: ['config', 'database']
new ConfigModule(), // no dependencies
new DatabaseModule(), // dependencies: ['config']
],
});
// Registration order doesn't matter — orkos resolves:
// 1. config (no deps)
// 2. database (depends on config)
// 3. http (depends on config + database)
await app.setup();Circular Dependencies
Circular dependencies are detected and throw an ApplicationError with code CIRCULAR_DEPENDENCY:
// Module A depends on B, B depends on A
// → ApplicationError: Circular module dependency detected involving: A, BMissing Dependencies
When a module declares a dependency that isn't registered, orkos first attempts auto-resolution from node_modules. If the dependency still can't be found:
- Non-optional —
setup()throws anApplicationErrorwith codeMODULE_NOT_FOUND - Optional (
optional: true) — the dependency is silently skipped
Module Lifecycle
Versioning
Modules can declare a version and dependents can enforce semver constraints via ModuleDependency:
class DatabaseModule implements IModule {
readonly name = 'database';
readonly version = '2.3.0';
// ...
}
class AuthModule implements IModule {
readonly name = 'auth';
readonly dependencies = [
{ name: 'database', version: '>=2.0.0' }, // requires database v2+
];
// ...
}Supported ranges: >=, >, <=, <, ~, ^, and exact match. If a constraint is not satisfied, setup() throws an ApplicationError with code VERSION_MISMATCH.
Hooks
onReady(container) — Called after all modules have been set up successfully, in dependency order. Use it for tasks that require the full application to be initialized (e.g. starting background jobs).
onError(error, container) — Called when this module's setup() throws. The original error is re-thrown after the hook runs. On failure, orkos automatically tears down any modules that were already set up successfully, in reverse order.
Status Tracking
Each module moves through a lifecycle: Pending → SettingUp → Ready (or Failed), and TearingDown → TornDown. Query state via getModuleStatus(name) or getStatus() for the full map.
Application API
import { Application } from 'orkos';
import type { IApplication, ApplicationContext, SetupOptions } from 'orkos';
const app: IApplication = new Application({
modules: [...],
container: myContainer, // optional: pre-configured eldin container
autoInstall: false, // optional: auto-install missing packages
maxResolveDepth: 10, // optional: recursive resolution depth limit
});| Method | Description |
|--------|-------------|
| addModule(module) | Register a module (IModule, string, or [string, options] tuple) |
| addModules(modules) | Register multiple modules at once |
| setup(options?) | Resolve externals, sort dependencies, and set up all modules |
| teardown() | Tear down all modules in reverse setup order |
| getModuleStatus(name) | Get the current status of a module |
| getStatus() | Get a map of all module statuses |
| container | The shared eldin IContainer instance |
setup() accepts an optional SetupOptions object:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| resolveCache | boolean | true | When false, re-imports all previously resolved external modules |
Dynamic Module Registration
const app = new Application();
app.addModule(new ConfigModule());
app.addModule('orkos-redis');
app.addModule(['orkos-cache', { ttl: 60 }]);
app.addModules([new HttpModule(), new CacheModule()]);
await app.setup();Error Handling
orkos uses structured error codes via ebec. All errors are instances of ApplicationError (extends BaseError) with a code property for programmatic handling:
import { ApplicationError, ApplicationErrorCode } from 'orkos';
import { isBaseError } from 'ebec';
try {
await app.setup();
} catch (error) {
if (isBaseError(error) && error.code === ApplicationErrorCode.VERSION_MISMATCH) {
// handle version mismatch
}
}| Code | When |
|------|------|
| CIRCULAR_DEPENDENCY | Circular module dependency detected |
| MODULE_NOT_FOUND | Required module/package not registered or resolvable |
| INVALID_MODULE_EXPORT | Package default export is not a valid ModuleFactory or IModule |
| MODULE_INSTALL_FAILED | Auto-install of a package failed |
| OPTIONS_NOT_SUPPORTED | Options passed to a package that exports IModule (not a factory) |
| RESOLUTION_DEPTH_EXCEEDED | Recursive resolution exceeded maxResolveDepth |
| VERSION_MISMATCH | Dependency version constraint not satisfied |
| MODULE_NOT_REGISTERED | getModuleStatus() called with unknown module name |
Usage with eldin
orkos uses eldin for dependency injection. Define typed tokens with eldin and use them across modules:
import { TypedToken } from 'eldin';
import type { IContainer } from 'eldin';
import { Application } from 'orkos';
import type { IModule } from 'orkos';
const ConfigToken = new TypedToken<Config>('Config');
const DatabaseToken = new TypedToken<Database>('Database');
class ConfigModule implements IModule {
readonly name = 'config';
async setup(container: IContainer) {
container.register(ConfigToken, {
useValue: { host: 'localhost', port: 5432 },
});
}
}
class DatabaseModule implements IModule {
readonly name = 'database';
readonly dependencies = ['config'];
async setup(container: IContainer) {
// Type-safe resolution — no manual generics needed
const config = container.resolve(ConfigToken); // Config
container.register(DatabaseToken, {
useFactory: () => new Database(config),
});
}
}
const app = new Application({
modules: [
new ConfigModule(),
new DatabaseModule(),
],
});
await app.setup();
// Access the container directly
const db = app.container.resolve(DatabaseToken); // DatabaseContributing
Before starting to work on a pull request, it is important to review the guidelines for contributing and the code of conduct. These guidelines will help to ensure that contributions are made effectively and are accepted.
License
Made with 💚
Published under MIT License.
