Skip to content

Custom Plugin Development

TypoKit plugins let you extend both the build pipeline and the application lifecycle through a single interface. This guide walks you through building a real plugin — a request-timing plugin that measures handler latency and exposes metrics.

A plugin-timing that:

  1. Build-time — taps the route table to index all registered routes
  2. Runtime — registers middleware that measures request duration
  3. Services — exposes a timing service for other plugins to query
  4. CLI commands — adds a typokit timing:report command
  5. Introspection endpoints — adds a /_inspect/timing endpoint
  6. Framework-native middleware — injects a Server-Timing header via getNativeServer()
  1. Create the package directory

    Terminal window
    mkdir -p packages/plugin-timing/src
    cd packages/plugin-timing
  2. Initialize package.json

    {
    "name": "@typokit/plugin-timing",
    "version": "0.1.0",
    "private": true,
    "type": "module",
    "exports": {
    ".": "./src/index.ts"
    },
    "dependencies": {
    "@typokit/core": "workspace:*"
    }
    }
  3. Create tsconfig.json

    {
    "extends": "../../tsconfig.json",
    "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
    },
    "include": ["src"]
    }

Every plugin is a factory function that returns a TypoKitPlugin object:

packages/plugin-timing/src/index.ts
import type {
TypoKitPlugin,
BuildPipeline,
AppInstance,
AppError,
RequestContext,
CliCommand,
InspectEndpoint,
} from "@typokit/core";
export interface TimingPluginOptions {
/** Percentiles to track (default: [50, 90, 95, 99]) */
percentiles?: number[];
/** Maximum number of data points to retain (default: 10_000) */
maxSamples?: number;
}
export function timingPlugin(options: TimingPluginOptions = {}): TypoKitPlugin {
const { percentiles = [50, 90, 95, 99], maxSamples = 10_000 } = options;
// Internal state — lives in closure, not on AppInstance
const routeLatencies = new Map<string, number[]>();
const knownRoutes = new Set<string>();
return {
name: "plugin-timing",
onBuild(pipeline) { /* Step 3 */ },
async onStart(app) { /* Step 4 */ },
async onReady(app) { /* Step 5 */ },
onError(error, ctx) { /* Step 6 */ },
async onStop(app) { /* Step 7 */ },
commands() { /* Step 8 */ return []; },
inspect() { /* Step 9 */ return []; },
};
}

Step 3: Build-Time Hooks — Tapping the Pipeline

Section titled “Step 3: Build-Time Hooks — Tapping the Pipeline”

The onBuild hook receives a BuildPipeline with six sequential hooks. For our timing plugin, we tap afterRouteTable to index all compiled routes:

onBuild(pipeline: BuildPipeline) {
// Tap into the route table after it's compiled
pipeline.hooks.afterRouteTable.tap("plugin-timing", (routeTable, ctx) => {
// Walk the compiled radix tree to collect all routes
function collectRoutes(node: typeof routeTable, prefix: string) {
if (node.handler) {
const route = `${node.handler.method} ${prefix || "/"}`;
knownRoutes.add(route);
}
for (const [segment, child] of Object.entries(node.children ?? {})) {
collectRoutes(child, `${prefix}/${segment}`);
}
if (node.paramChild) {
collectRoutes(node.paramChild, `${prefix}/:${node.paramChild.paramName}`);
}
if (node.wildcardChild) {
collectRoutes(node.wildcardChild, `${prefix}/*`);
}
}
collectRoutes(routeTable, "");
console.log(`[plugin-timing] Indexed ${knownRoutes.size} routes`);
});
},
HookArgumentsUse Case
beforeTransformBuildContextModify config before processing starts
afterTypeParseSchemaTypeMap, BuildContextInspect or transform parsed types
afterValidatorsGeneratedOutput[], BuildContextAdd custom validators
afterRouteTableCompiledRouteTable, BuildContextInspect the compiled route tree
emitGeneratedOutput[], BuildContextAdd generated files to the output
doneBuildResultPost-build reporting, cache warming

Hooks execute in order. Each uses AsyncSeriesHook (tapable pattern) — handlers run sequentially, not in parallel.

If your plugin needs to generate files, tap the emit hook:

pipeline.hooks.emit.tap("plugin-timing", (outputs, ctx) => {
outputs.push({
filePath: "timing/routes.json",
content: JSON.stringify([...knownRoutes], null, 2),
description: "Known routes for timing analysis",
});
});

Generated files are written to the configured outputDir (default .typokit/).

Step 4: Runtime — Registering Services and Middleware

Section titled “Step 4: Runtime — Registering Services and Middleware”

The onStart hook fires before the server listens. This is where you register services on app.services and set up middleware:

async onStart(app: AppInstance) {
// ─── Register the timing service (DI pattern) ───
app.services["timing"] = {
/** Record a latency sample for a route */
record(route: string, durationMs: number) {
let samples = routeLatencies.get(route);
if (!samples) {
samples = [];
routeLatencies.set(route, samples);
}
samples.push(durationMs);
// Evict oldest samples when over limit
if (samples.length > maxSamples) {
samples.splice(0, samples.length - maxSamples);
}
},
/** Get percentile stats for a route */
stats(route: string) {
const samples = routeLatencies.get(route);
if (!samples?.length) return null;
const sorted = [...samples].sort((a, b) => a - b);
return Object.fromEntries(
percentiles.map((p) => {
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return [`p${p}`, sorted[Math.max(0, idx)]];
})
);
},
/** Get stats for all routes */
allStats() {
const result: Record<string, unknown> = {};
for (const route of routeLatencies.keys()) {
result[route] = this.stats(route);
}
return result;
},
};
},

Plugins register middleware by calling defineMiddleware() during onStart. The middleware runs in the TypoKit pipeline — after request normalization, before handler execution:

import { defineMiddleware } from "@typokit/core";
async onStart(app: AppInstance) {
// ... service registration above ...
// ─── Register timing middleware ───
const timingMiddleware = defineMiddleware<{ requestStartTime: number }>({
name: "timing",
priority: -100, // Run first (lower priority = earlier execution)
async handler(ctx) {
const start = performance.now();
// Return context additions — available to all downstream middleware and handlers
return { requestStartTime: start };
},
async afterHandler(ctx) {
const duration = performance.now() - ctx.requestStartTime;
const route = `${ctx.method} ${ctx.route}`;
// Record via service
const timing = app.services["timing"] as TimingService;
timing.record(route, duration);
ctx.log.debug("Request timing", { route, durationMs: duration });
},
});
app.middleware.push(timingMiddleware);
},

onReady fires after all routes are registered and the server is listening. Use it for warmup tasks:

async onReady(app: AppInstance) {
console.log(`[plugin-timing] Tracking ${knownRoutes.size} routes`);
console.log(`[plugin-timing] Reporting percentiles: ${percentiles.join(", ")}`);
// Optional: warm up internal data structures
for (const route of knownRoutes) {
routeLatencies.set(route, []);
}
},

onError is called whenever an unhandled error is observed. Use it for error-rate tracking:

onError(error: AppError, ctx: RequestContext) {
// Track error latencies separately
const route = `${ctx.method} ${ctx.route}`;
const duration = performance.now() - (ctx as any).requestStartTime;
const timing = routeLatencies.get(`ERROR ${route}`);
if (timing) {
timing.push(duration);
} else {
routeLatencies.set(`ERROR ${route}`, [duration]);
}
},

onStop fires during graceful shutdown. Clean up resources:

async onStop(app: AppInstance) {
// Flush any pending metrics
console.log(`[plugin-timing] Final stats:`);
for (const [route, samples] of routeLatencies) {
if (samples.length > 0) {
console.log(` ${route}: ${samples.length} samples`);
}
}
routeLatencies.clear();
knownRoutes.clear();
delete app.services["timing"];
},

The commands() method returns an array of CliCommand objects. These are available via typokit <command>:

commands(): CliCommand[] {
return [
{
name: "timing:report",
description: "Display request timing statistics",
options: [
{ name: "route", description: "Filter by route pattern" },
{ name: "format", description: "Output format (table|json)", required: false },
],
async run(args) {
const filter = args.route as string | undefined;
const format = (args.format as string) ?? "table";
const allStats: Record<string, unknown> = {};
for (const [route, samples] of routeLatencies) {
if (filter && !route.includes(filter)) continue;
if (samples.length === 0) continue;
const sorted = [...samples].sort((a, b) => a - b);
allStats[route] = {
count: samples.length,
...Object.fromEntries(
percentiles.map((p) => {
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return [`p${p}`, `${sorted[Math.max(0, idx)].toFixed(2)}ms`];
})
),
};
}
if (format === "json") {
console.log(JSON.stringify(allStats, null, 2));
} else {
for (const [route, stats] of Object.entries(allStats)) {
console.log(`\n${route}`);
for (const [key, value] of Object.entries(stats as Record<string, unknown>)) {
console.log(` ${key}: ${value}`);
}
}
}
},
},
];
},

Usage:

Terminal window
# View all timing stats
typokit timing:report
# Filter to a specific route
typokit timing:report --route "GET /api/users"
# JSON output for scripting
typokit timing:report --format json

The inspect() method returns endpoints available via typokit inspect and the debug sidecar:

inspect(): InspectEndpoint[] {
return [
{
path: "/_inspect/timing",
description: "Request timing percentiles for all routes",
async handler() {
const result: Record<string, unknown> = {};
for (const [route, samples] of routeLatencies) {
if (samples.length === 0) continue;
const sorted = [...samples].sort((a, b) => a - b);
result[route] = {
sampleCount: samples.length,
percentiles: Object.fromEntries(
percentiles.map((p) => {
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return [`p${p}`, sorted[Math.max(0, idx)]];
})
),
};
}
return result;
},
},
{
path: "/_inspect/timing/slow",
description: "Slowest routes by p99 latency",
async handler() {
const stats = [...routeLatencies.entries()]
.filter(([, s]) => s.length > 0)
.map(([route, samples]) => {
const sorted = [...samples].sort((a, b) => a - b);
const p99Idx = Math.ceil(0.99 * sorted.length) - 1;
return { route, p99: sorted[Math.max(0, p99Idx)], samples: samples.length };
})
.sort((a, b) => b.p99 - a.p99)
.slice(0, 10);
return stats;
},
},
];
},

These endpoints are accessible two ways:

Terminal window
# Via CLI (connects to running server)
typokit inspect timing
typokit inspect timing/slow --json
# Via debug sidecar (if plugin-debug is installed)
curl http://localhost:9800/_inspect/timing

Step 10: Framework-Native Middleware via getNativeServer()

Section titled “Step 10: Framework-Native Middleware via getNativeServer()”

Sometimes you need to register middleware at the framework level — below TypoKit’s normalization layer. Use getNativeServer() for this:

async onReady(app: AppInstance) {
// ... warmup code from Step 5 ...
// ─── Inject Server-Timing header via native server ───
const server = app.getNativeServer?.();
if (server && typeof server.use === "function") {
// Express/Fastify-compatible
server.use((req: any, res: any, next: any) => {
const start = performance.now();
res.on("finish", () => {
const duration = performance.now() - start;
res.setHeader("Server-Timing", `total;dur=${duration.toFixed(2)}`);
});
next();
});
}
},

In your application’s app.ts:

import { createApp } from "@typokit/core";
import { timingPlugin } from "@typokit/plugin-timing";
import { debugPlugin } from "@typokit/plugin-debug";
const app = createApp({
plugins: [
timingPlugin({
percentiles: [50, 90, 95, 99],
maxSamples: 50_000,
}),
debugPlugin(), // Optional: enables sidecar access to timing endpoints
],
});

Plugins execute in registration order for all hooks. If plugin B depends on services from plugin A, register A first.

Here’s the full plugin-timing implementation:

packages/plugin-timing/src/index.ts
import type {
TypoKitPlugin,
BuildPipeline,
AppInstance,
AppError,
RequestContext,
CliCommand,
InspectEndpoint,
CompiledRouteTable,
} from "@typokit/core";
import { defineMiddleware } from "@typokit/core";
export interface TimingPluginOptions {
percentiles?: number[];
maxSamples?: number;
}
export function timingPlugin(options: TimingPluginOptions = {}): TypoKitPlugin {
const { percentiles = [50, 90, 95, 99], maxSamples = 10_000 } = options;
const routeLatencies = new Map<string, number[]>();
const knownRoutes = new Set<string>();
function computePercentiles(samples: number[]) {
const sorted = [...samples].sort((a, b) => a - b);
return Object.fromEntries(
percentiles.map((p) => {
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return [`p${p}`, sorted[Math.max(0, idx)]];
})
);
}
function collectRoutes(node: CompiledRouteTable, prefix: string) {
if (node.handler) {
knownRoutes.add(`${node.handler.method} ${prefix || "/"}`);
}
for (const [seg, child] of Object.entries(node.children ?? {})) {
collectRoutes(child, `${prefix}/${seg}`);
}
if (node.paramChild) {
collectRoutes(node.paramChild, `${prefix}/:${node.paramChild.paramName}`);
}
if (node.wildcardChild) {
collectRoutes(node.wildcardChild, `${prefix}/*`);
}
}
return {
name: "plugin-timing",
onBuild(pipeline) {
pipeline.hooks.afterRouteTable.tap("plugin-timing", (routeTable) => {
collectRoutes(routeTable, "");
});
},
async onStart(app) {
app.services["timing"] = {
record(route: string, durationMs: number) {
let samples = routeLatencies.get(route);
if (!samples) { samples = []; routeLatencies.set(route, samples); }
samples.push(durationMs);
if (samples.length > maxSamples) samples.splice(0, samples.length - maxSamples);
},
stats(route: string) {
const s = routeLatencies.get(route);
return s?.length ? computePercentiles(s) : null;
},
allStats() {
const r: Record<string, unknown> = {};
for (const [k, v] of routeLatencies) if (v.length) r[k] = computePercentiles(v);
return r;
},
};
},
async onReady(app) {
for (const route of knownRoutes) {
if (!routeLatencies.has(route)) routeLatencies.set(route, []);
}
},
onError(error, ctx) {
const route = `ERROR ${ctx.method} ${ctx.route}`;
const samples = routeLatencies.get(route) ?? [];
routeLatencies.set(route, samples);
},
async onStop(app) {
routeLatencies.clear();
knownRoutes.clear();
delete app.services["timing"];
},
commands(): CliCommand[] {
return [{
name: "timing:report",
description: "Display request timing statistics",
options: [
{ name: "route", description: "Filter by route pattern" },
{ name: "format", description: "Output format (table|json)" },
],
async run(args) {
const filter = args.route as string | undefined;
const result: Record<string, unknown> = {};
for (const [route, samples] of routeLatencies) {
if (filter && !route.includes(filter)) continue;
if (!samples.length) continue;
result[route] = { count: samples.length, ...computePercentiles(samples) };
}
console.log(JSON.stringify(result, null, 2));
},
}];
},
inspect(): InspectEndpoint[] {
return [
{
path: "/_inspect/timing",
description: "Request timing percentiles",
async handler() {
const r: Record<string, unknown> = {};
for (const [k, v] of routeLatencies) {
if (v.length) r[k] = { samples: v.length, ...computePercentiles(v) };
}
return r;
},
},
];
},
};
}
  1. Use factory functions — Always export a function that returns TypoKitPlugin, not the object directly. This enables configuration and scoped state.

  2. Keep state in closures — Store plugin data in the factory’s closure scope, not on app.services. Expose services only as a read/write API.

  3. Respect the lifecycle — Set up in onStart, use resources in onReady, clean up in onStop. Don’t access services before they’re registered.

  4. Choose the right middleware layer:

    • defineMiddleware() — Typed context, runs inside TypoKit pipeline. Use for business logic.
    • Framework-native (via getNativeServer()) — Raw HTTP, runs before normalization. Use for headers, compression, CORS.
  5. No onRequest/onResponse hooks — By design. Use defineMiddleware() for per-request logic in plugins (register it during onStart).

  6. Build-time vs. runtime — Generate code in onBuild (via emit hook), consume it at runtime. Avoid heavy processing in lifecycle hooks.

  7. Naming conventions — Official plugins prefix services with _ (e.g., _debug, _ws). Custom plugins should use unprefixed names.

The compile hook is a specialized build hook that lets plugins override or extend the default TypeScript compilation step. Most plugins won’t need this — it’s designed for plugins that target a different language or build system entirely.

The compile hook receives a CompileContext alongside the standard BuildContext:

export interface CompileContext {
/** Set to true if your plugin handled compilation */
handled: boolean;
/** Which compiler ran (e.g., "tsc", "cargo", "go") */
compiler?: string;
/** Compilation result */
result?: { success: boolean; errors: string[] };
}

Use the compile hook when your plugin:

  • Generates non-TypeScript code that needs its own compiler (e.g., Rust, Go, Java)
  • Needs to replace tsc with a faster or specialized compiler
  • Wants to run additional compilation alongside the default tsc step

If compileCtx.handled is set to true by any plugin, the default TypeScript compiler is skipped entirely. If no plugin handles compilation, tsc runs as usual.

The @typokit/plugin-axum plugin is the canonical example of using the compile hook. It generates a complete Rust/Axum web server from TypeScript types, then compiles it with cargo build:

export function axumPlugin(options = {}): TypoKitPlugin {
return {
name: "plugin-axum",
onBuild(pipeline) {
// Generate Rust source files during emit
pipeline.hooks.emit.tap("plugin-axum", async (outputs, ctx) => {
const rustOutputs = native.generateRustCodegen(typeFiles, routeFiles);
for (const output of rustOutputs) {
writeFile(output.path, output.content);
outputs.push(output);
}
});
// Compile with cargo instead of tsc
pipeline.hooks.compile.tap("plugin-axum", async (compileCtx, ctx) => {
const result = spawnSync("cargo", ["build"], { cwd: ctx.rootDir });
compileCtx.handled = true;
compileCtx.compiler = "cargo";
compileCtx.result = {
success: result.status === 0,
errors: result.status !== 0 ? [result.stderr] : [],
};
});
},
};
}

See the Plugins concept page for the full list of official plugins, including plugin-axum’s complete documentation.