@orria/dispatchkit
v1.1.0
Published
Lightweight CQRS-lite application framework and runtime toolkit for Bun.
Downloads
120
Readme
Features
- CQRS operation definitions:
defineQuery,defineMutation,defineAction - Runtime modules:
defineConfig,defineLogger,defineInfra,defineTransport - Runtime builder:
buildRuntime() - Strict CQRS call guards at runtime
- Standard Schema validation for config and operation
inputandreturn - Resource cleanup through
onDispose()andruntime.dispose() - Context helpers:
getModuleCtx(),getTransportCtx() - Runtime artifact generation into
src/generated/runtime - CLI:
dispatchkit generateanddispatchkit generate --watch
Installation
bun add @orria/dispatchkitInstall a validator separately. The Zod examples below use bun add zod; Valibot uses bun add valibot. Dispatchkit has no mandatory Zod dependency. dotenv is included. A custom logger such as pino is optional through defineLogger.
Minimal app structure
src/
├── index.ts
├── config.ts # optional (defineConfig)
├── logger.ts # optional (defineLogger)
├── modules/
│ └── widget/
│ ├── get.query.ts
│ ├── upsert.mutation.ts
│ └── upsert.action.ts
├── infra/
│ ├── repo.ts
│ └── db/index.ts
└── transport/
├── http.ts
└── cli/index.tsQuick start
1) Define operations
// src/modules/widget/get.query.ts
import { defineQuery } from "@orria/dispatchkit";
import { z } from "zod";
export default defineQuery({
input: z.object({ id: z.string() }),
return: z.object({ id: z.string() }).nullable(),
handler: async (ctx) => {
return ctx.infra.repo.get(ctx.input.id);
},
});Each operation becomes available on runtime.bus:
runtime.bus.query.widget.get(input)runtime.bus.query.widget.get.$unsafe(input)runtime.bus.query.widget.get.$inputruntime.bus.query.widget.get.$return
Nested module paths also generate grouped keys:
modules/widget/get.query.ts->runtime.bus.query.widget.get(...)- Flat alias is also present:
runtime.bus.query.widgetGet(...)
2) Optional runtime config
import { defineConfig } from "@orria/dispatchkit";
import { z } from "zod";
export default defineConfig(
z.object({
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
}),
);Built-in runtime config keys:
SERVICE_NAMESERVICE_DESCRIPTIONSERVICE_VERSIONLOG_LEVEL(fatal|error|warn|info|debug|trace|silent)NODE_ENV(development|production)
Config merge priority, with later sources overriding earlier ones:
- Defaults from
package.json process.env.envfilebuildRuntime(options).config- Top-level config keys in
buildRuntime(options)
The schema receives this merged input and must produce an object. Its parsed output is merged with the built-in keys, so a schema that strips unknown properties keeps SERVICE_NAME and the other defaults. Built-in fields are validated again after custom transforms. Without defineConfig, runtime config contains only the built-in keys. NODE_ENV=test is normalized to development.
3) Optional logger
import { defineLogger } from "@orria/dispatchkit";
import pino from "pino";
export default defineLogger((config) => {
const logger = pino({
name: String(config.SERVICE_NAME),
level: String(config.LOG_LEVEL),
});
return {
logger,
console,
};
});If src/logger.ts is missing, Dispatchkit uses a fallback console-based logger filtered by LOG_LEVEL.
4) Infra modules
// src/infra/repo.ts
import { defineInfra } from "@orria/dispatchkit";
export default defineInfra(({ config, logger, onDispose }) => {
logger.info("infra init", { service: config.SERVICE_NAME });
const records = new Map<string, { id: string }>();
onDispose(() => records.clear());
return {
get: (id: string) => records.get(id) ?? null,
};
});defineInfra() receives { config, logger, onDispose }.
Return behavior:
src/infra/database.ts->runtime.infra.databasesrc/infra/database/index.ts->runtime.infra.database- The module return value is assigned to that key, including class instances.
5) Transport modules
import { defineTransport } from "@orria/dispatchkit";
export default defineTransport(
() => ({
ping: () => "pong",
}),
{
allowGetTransportCtxFrom: ["http", "transport/http/**/*.ts"],
},
);allowGetTransportCtxFrom extends default allowed locations for getTransportCtx().
Shorthand values like "http" are supported.
Transport wrappers support methods on class prototypes. JavaScript requires non-configurable, non-writable own properties to return their exact values. Functions and objects held in those properties do not get additional context wrappers.
6) Build runtime
import { buildRuntime } from "@orria/dispatchkit";
const runtime = await buildRuntime({
rootDir: process.cwd(),
srcDir: "./src",
generatedDir: "./src/generated/runtime",
envFile: "./.env",
SERVICE_NAME: "my-service",
});Runtime shape:
runtime.configruntime.loggerruntime.infraruntime.busruntime.transportruntime.dispose()
After a successful build, Dispatchkit mounts globalThis.runtime and the configured console. Set mountGlobal: false to keep runtime instances separate from those globals. Context helpers still work inside their handlers and transport calls.
Relative srcDir, generatedDir, and envFile paths resolve from rootDir. By default, rootDir is the current working directory. Set generateArtifacts: false when generated types are already part of the build or the runtime filesystem is read-only:
const runtime = await buildRuntime({
rootDir: import.meta.dir,
srcDir: "src",
mountGlobal: false,
generateArtifacts: false,
});Standard Schema validation
defineConfig and operation input and return accept Standard Schema v1 validators. Zod 4 and Valibot 1 schemas work directly. Validation can be synchronous or asynchronous, including refinements and transforms. Different validators can be used in the same operation:
// src/modules/increment.query.ts
import { defineQuery } from "@orria/dispatchkit";
import { z } from "zod";
import * as v from "valibot";
export default defineQuery({
input: z.string().regex(/^\d+$/).transform(Number),
return: v.pipe(v.number(), v.transform(String)),
handler: ({ input }) => input + 1,
});The bus accepts the input schema's input type. The handler receives its output type, after parsing. The handler returns the return schema's input type, and the bus resolves to its output type. Without a return schema, the bus infers the handler's result.
If a handler calls its own generated bus method, give the handler an explicit return type so TypeScript can resolve the recursion.
await runtime.bus.query.increment("41"); // "42"
await runtime.bus.query.increment.$unsafe(41); // 42$unsafe skips both validators and all their transforms. It takes the value the handler normally receives and returns the handler's raw result. CQRS guards still apply. $input and $return expose the original schema objects, or undefined when omitted.
Elysia and TypeBox
Elysia's t schemas need an adapter. Choose the subpath that matches the TypeBox version:
| Schema source | Install alongside Dispatchkit | Adapter import |
| --- | --- | --- |
| Elysia 1, @sinclair/typebox 0.34 | bun add @sinclair/typebox | @orria/dispatchkit/typebox |
| Elysia 2, typebox 1 | bun add typebox | @orria/dispatchkit/typebox-v1 |
The adapters are optional imports; the core does not load TypeBox. Elysia 2 integration is tested with [email protected] and [email protected].
// Elysia 1
import { t } from "elysia";
import { standardSchema } from "@orria/dispatchkit/typebox";
const input = standardSchema(t.Object({ id: t.String() }));For Elysia 2, use standardSchema(schema, context?) from @orria/dispatchkit/typebox-v1. The optional context maps names to referenced schemas. Elysia 2's t.Numeric() keeps separate input and output types:
// Elysia 2
import { defineQuery } from "@orria/dispatchkit";
import { t } from "elysia";
import { standardSchema } from "@orria/dispatchkit/typebox-v1";
export default defineQuery({
input: standardSchema(t.Object({ count: t.Numeric() })),
handler: ({ input }) => input.count + 1,
});
// Bus input: { count: string | number }. Result: number.The TypeBox 0.34 adapter accepts standardSchema(schema, references?) and preserves validation and Decode transforms. For explicit encoded and decoded types, use TypeBox's own Type.Transform:
import { defineQuery } from "@orria/dispatchkit";
import { Type } from "@sinclair/typebox";
import { standardSchema } from "@orria/dispatchkit/typebox";
export default defineQuery({
input: standardSchema(
Type.Transform(Type.String()).Decode((value) => value.length).Encode(String),
),
handler: ({ input }) => input * 2,
});
// Bus input: string. Handler input and bus result: number.Elysia 1 declares t.Numeric() as TNumber, although it accepts numeric strings at runtime. Its t.Transform also exposes a different static type than TypeBox's version. The adapter preserves these runtime conversions, but it cannot recover input types that Elysia's declarations omit. Use Type.Transform when encoded input types matter. Both adapters use TypeBox's synchronous decoding APIs. They do not run Elysia's HTTP coercion, defaults, or cleaning pipeline.
Elysia 2 schemas that depend on its private asynchronous refinement queue, including file signature checks with t.File({ type: "image/png" }) or t.Files({ type: "image/png" }), fail with DISPATCHKIT_TYPEBOX_ASYNC_REFINE_UNSUPPORTED. Validate file signatures through Elysia's HTTP pipeline or a Standard Schema validator with asynchronous validation. Basic t.File() and size checks are supported, as are t.Numeric(), t.BooleanString(), and t.Date().
Resource lifecycle
Infra and transport factories receive onDispose(callback). Register cleanup immediately after acquiring a resource:
// src/transport/http.ts
import { defineTransport } from "@orria/dispatchkit";
export default defineTransport(({ onDispose }) => {
const server = Bun.serve({
port: 3000,
fetch: () => new Response("ok"),
});
onDispose(() => { server.stop(true); });
return server;
});Call await runtime.dispose() during application shutdown. Cleanup runs once, in reverse registration order, and awaits each callback. All callbacks run even if one fails; cleanup errors are reported in an AggregateError. New bus calls fail after disposal. Dispatchkit does not install process signal handlers or close unregistered resources.
A failed buildRuntime() runs the registered cleanup callbacks and leaves the previously mounted runtime and console intact. Building another runtime does not dispose the previous one; keep its reference and call dispose() when it is no longer needed.
Context helpers
getModuleCtx()returns{ config, logger, infra, bus }getTransportCtx()returns{ config, logger, bus }
Factory/handler context matrix:
defineLogger((config) => ...)->configdefineInfra((ctx) => ...)->{ config, logger, onDispose }defineTransport((ctx) => ...)->{ config, logger, bus, onDispose }defineQuery/defineMutation/defineAction.handler(ctx)->{ config, logger, infra, bus, input }
Invalid context access throws structured errors:
DISPATCHKIT_CONTEXT_UNAVAILABLEDISPATCHKIT_CONTEXT_FORBIDDEN
Bun caches application modules; their top-level code runs once per process. A transport module can read getTransportCtx() during its initial import, but a captured value keeps referring to that first runtime when another runtime reuses the module. For multiple runtime instances, read runtime-dependent values inside factories, handlers, or transport methods.
CQRS guards
Runtime enforces call chain restrictions:
query-> onlyquerymutation->query,mutationaction->query,mutation,action
Invalid calls throw DISPATCHKIT_CQRS_GUARD.
Discovery rules
Dispatchkit scans under srcDir:
modules/**/*.query.tsmodules/**/*.mutation.tsmodules/**/*.action.tsinfra/*.tsandinfra/**/index.tstransport/*.tsandtransport/**/index.ts
Notes:
*.d.tsfiles are ignored- Operation and transport naming collisions throw errors
- An infra module exporting
defineTransport(...)is treated as transport
Runtime and CLI share the same discovery rules. Static classification follows named import aliases, namespace imports, const bindings, and local imports and re-exports. The generator does not execute application modules. Dynamic wrappers and mutable definitions that cannot be classified fail with DISPATCHKIT_DISCOVERY_UNKNOWN_DEFINITION. Export defineInfra(...) or defineTransport(...) through one of the supported forms.
Generated artifacts
Default output directory: src/generated/runtime
manifest.jsonbus.d.tsruntime.d.tsindex.ts
manifest.json is rewritten only when the discovery structure changes.
Run dispatchkit generate before type checking and include the generated declarations in your TypeScript project. The default src/generated/runtime path is covered by an include pattern such as src/**/*.ts.
CLI
# one-time generation
dispatchkit generate
# watch mode
dispatchkit generate --watch
# custom paths
dispatchkit generate --srcDir ./src --generatedDir ./src/generated/runtime--watch updates generated types and the manifest as files change. It does not reload application modules in a running process.
Options:
--rootDir <path>--srcDir <path>--generatedDir <path>--watch--intervalMs <ms>
Build this package locally
bun run buildDocumentation
- Russian version:
docs/README.ru.md
