@kozojs/core
v0.7.1
Published
TypeScript backend framework where routes, validation, OpenAPI, clients and tests share one contract — at native uWebSockets speed.
Maintainers
Readme
@kozojs/core
Build a TypeScript backend from one route contract.
Define a route once with a Zod schema — runtime validation, an OpenAPI 3.1 spec, a generated TypeScript client, and route-derived tests all come from that one definition. With no extra wiring you get:
- Runtime validation — RFC 7807 errors on bad input, zero boilerplate
- OpenAPI 3.1 — generated from the same schema
- A route-derived client SDK —
app.generateClient()→api.users.post({ body }) - A static testing contract —
createContractTestClient(app)derives paths, inputs, statuses, and outputs - Native-speed routing — an optional uWebSockets.js transport registers routes straight into a C++ radix trie
Kozo requires Node.js 20.19 or newer. app.listen() is the default Node.js transport; app.nativeListen() and app.listenSsr() add optional native and Vite integrations. app.fetch exposes the underlying Fetch API handler for compatible adapters.
npm install @kozojs/core zodQuick Start
import { createKozo, z } from '@kozojs/core';
const app = createKozo();
app.get('/users/:id', {
params: z.object({ id: z.string().uuid() }),
response: z.object({ id: z.string(), name: z.string() }),
}, (ctx) => ({
id: ctx.params.id,
name: 'John Doe',
}));
await app.listen(3000);Table of Contents
- Server Modes
- Route Registration
- Static Route Contracts
- Route Groups
- Schema Validation
- Services (Dependency Injection)
- Guards
- Middleware
- Error Handling (RFC 7807)
- Graceful Shutdown
- Type-Safe Client Generation
- OpenAPI Generation
- File-System Routing
- WebSocket (uWS)
- SSR Integration
- Helper Schemas & Utilities
- Fast Response Utilities
- Runtime Compatibility
- API Reference
Server Modes
Kozo offers three server transports. Same routes, same handlers — pick the transport that fits your deployment.
app.listen(port?) — Node.js HTTP
Standard node:http server via @hono/node-server. Works everywhere.
await app.listen(3000);app.nativeListen(port?) — uWebSockets.js (C++ transport)
Routes are registered directly with uWS's C++ radix trie router — zero JS routing overhead per request. Requires uWebSockets.js as a peer dependency (it is published on GitHub, not npm):
pnpm add uNetworking/uWebSockets.js#v20.66.0const { port, server } = await app.nativeListen(3000);
// or with CORS:
await app.nativeListen({ port: 3000, cors: { origin: '*' } });app.listenSsr(port, config) — Unified API + SSR
Single server for both API routes and Vite-powered SSR pages. See SSR Integration.
await app.listenSsr(3000, {
root: './web',
entryServer: 'src/entry-server.tsx',
});Route Registration
Register routes with .get(), .post(), .put(), .patch(), .delete(). Each accepts an optional schema object for validation.
// No schema — handler only
app.get('/health', () => ({ status: 'ok' }));
// With schema — body, query, params, headers, response
app.post('/users', {
body: z.object({ name: z.string(), email: z.string().email() }),
response: z.object({ id: z.string().uuid(), name: z.string() }),
}, (ctx) => {
return { id: uuid(), name: ctx.body.name };
});The handler context ctx contains:
| Property | Type | Description |
|----------|------|-------------|
| ctx.body | Inferred from schema.body | Validated request body (POST/PUT/PATCH) |
| ctx.query | Inferred from schema.query | Validated query parameters |
| ctx.params | Inferred from schema.params | Validated path parameters |
| ctx.headers | Inferred from schema.headers | Validated request headers |
| ctx.services | TServices | Injected services |
| ctx.json(data, status?) | Response | Return JSON response |
| ctx.text(data, status?) | Response | Return text response |
| ctx.html(data, status?) | Response | Return HTML response |
| ctx.req | KozoRequest | Typed request helper |
Handlers can return a plain object (auto-serialized as JSON) or a Response object for full control.
Static Route Contracts
TypeScript cannot derive compile-time types from routes discovered only at
runtime. Use createRouter() and mount() when a consumer such as
@kozojs/testing needs the complete route tree:
import { createKozo, createRouter, z } from '@kozojs/core';
const users = createRouter()
.get('/:id', {
params: z.object({ id: z.string() }),
response: {
200: z.object({ id: z.string(), name: z.string() }),
404: z.object({ message: z.string() }),
},
}, ({ params, json }) => {
return params.id !== 'missing'
? json({ id: params.id, name: 'Ada' }, 200)
: json({ message: 'User not found' }, 404);
});
const app = createKozo().mount('/users', users);Fluent chaining directly from createKozo() also preserves route types. Calling
app.get(...) and ignoring its returned value still registers the runtime
route, but it cannot change the already inferred type of app. Dynamically
loaded file-system and plugin routes remain accessible through runtime
introspection and raw test clients unless the application exports an explicit
static contract for them.
Route Groups
Group routes under a common prefix:
app.group('/api/v1', (r) => {
r.get('/users', (ctx) => listUsers());
r.get('/users/:id', { params: uuidParams }, (ctx) => getUser(ctx.params.id));
r.post('/users', { body: CreateUserSchema }, (ctx) => createUser(ctx.body));
});
// Registers: GET /api/v1/users, GET /api/v1/users/:id, POST /api/v1/usersSchema Validation
Kozo uses Zod natively — no AJV, no JSON Schema intermediate step. Schemas are compiled once at route registration.
app.post('/users', {
body: z.object({
email: z.string().email(),
name: z.string().min(2).max(50),
age: z.number().min(18),
}),
query: z.object({
dryRun: z.coerce.boolean().optional(),
}),
params: z.object({
orgId: z.string().uuid(),
}),
response: UserSchema, // bare schema -> normalized to { 200: UserSchema }
// or: response: { 200: UserSchema, 201: CreatedSchema }
}, handler);Invalid requests return RFC 7807 application/problem+json:
{
"type": "https://kozo-docs.vercel.app/docs/core/errors#validation-failed",
"title": "Validation Failed",
"status": 400,
"errors": [
{ "field": "email", "message": "Invalid email", "code": "invalid_string" }
]
}Response schema = contract (read this)
When you set response on a route, Kozo treats it as the public API contract, not just typing sugar:
- Compiled serialization — if the schema maps to JSON Schema (
z.object, primitives, arrays, …), Kozo compiles afast-json-stringifyserializer once at route registration. Schemas with.transform(),z.date(), orz.any()useJSON.stringifyinstead (safe fallback — no silent wrong compile). - Undeclared fields are dropped — extra properties on the handler return value (e.g. DB columns not in the schema) are omitted from the JSON response. This is intentional contract enforcement: declare everything you return, or remove
responsefrom the route if you want pass-through serialization. - Status maps are exact —
ctx.json(data, 201)uses the schema declared for status 201. An undeclared status uses normal JSON serialization for backward compatibility, so declare every public status when field stripping must be enforced.
app.get('/users/:id', {
params: z.object({ id: z.string() }),
response: z.object({ id: z.string(), name: z.string() }), // only these two keys in JSON
}, ({ params, services }) => services.db.findUser(params.id));
// If the DB row has `{ id, name, email, createdAt }`, the client receives `{ id, name }` only.Services (Dependency Injection)
Pass typed services at construction — every handler receives them via ctx.services:
interface AppServices {
db: Database;
cache: RedisClient;
stripe: Stripe;
}
const app = createKozo<AppServices>({
services: { db, cache, stripe },
});
app.get('/users', (ctx) => {
// ctx.services.db is fully typed as Database
return ctx.services.db.users.findMany();
});Guards (security — single source of truth)
app.guard(pattern, fn) registers a transport-agnostic check: it runs as Hono
middleware under listen() and compiled into the uWS fast path under
nativeListen() — identical semantics, native speed. Use guards for auth,
roles, and rate limits.
Register a guard before the routes it protects. This preserves middleware order on the Node/Hono transport and matches the native transport.
import { rateLimitGuard, requireSecret } from '@kozojs/core';
import { jwtGuard, roleGuard } from '@kozojs/auth';
app.guard('/api/*', jwtGuard(requireSecret('JWT_SECRET'), { publicPaths: ['/api/health'] }));
app.guard('/api/admin/*', roleGuard('admin'));
app.guard('/api/auth/*', rateLimitGuard({ max: 20, window: 60 }));
// Custom guard: allow (return nothing), attach user, or deny
app.guard('/internal/*', (req) => {
if (req.header('x-api-key') !== process.env.API_KEY) {
return { deny: { status: 401, body: { error: 'invalid api key' } } };
}
});CORS is handled at the transport level (preflight included):
await app.nativeListen({ port: 3000, cors: { origin: ['https://app.com'], credentials: true } });Middleware
Register Hono middleware globally or per-path:
import { logger, errorHandler } from '@kozojs/core/middleware';
app.middleware(logger()); // request logging
app.middleware(errorHandler()); // error handler (RFC 7807)
// Custom middleware (needs the Hono Context)
app.middleware('/admin/*', async (c, next) => {
c.set('requestId', crypto.randomUUID());
return next();
});Under
nativeListen(), routes covered by middleware patterns are served through the Hono bridge to guarantee correctness (~35% slower than the native path). Preferapp.guard()for security checks — it stays native. Versions ≤ 0.5.15 silently bypassed middleware undernativeListen(); upgrade to ≥ 0.5.16.Native transport limits (multipart, streaming/SSE on bridged routes, HTTPS): see Common Pitfalls §12.
Built-in Middleware
| Middleware | Import | Options |
|-----------|--------|---------|
| logger(options?) | @kozojs/core/middleware | prefix?: string, colorize?: boolean |
| cors(options?) | @kozojs/core/middleware | origin, allowMethods, allowHeaders, maxAge, credentials |
| rateLimit(options) | @kozojs/core/middleware | max, window (seconds), keyGenerator?, store? (Redis etc.) |
| errorHandler() | @kozojs/core/middleware | Catches KozoError -> RFC 7807 response |
Error Handling (RFC 7807)
All errors follow RFC 7807 Problem Details. Throw any KozoError subclass and it becomes a structured response.
import {
KozoError,
NotFoundError,
BadRequestError,
UnauthorizedError,
ForbiddenError,
ConflictError,
GoneError,
ValidationFailedError,
} from '@kozojs/core';
app.get('/users/:id', { params: uuidParams }, (ctx) => {
const user = db.users.find(ctx.params.id);
if (!user) throw new NotFoundError(); // -> 404
if (!canAccess(user)) throw new ForbiddenError('Insufficient permissions'); // -> 403
return user;
});Error classes:
| Class | Status | Default Message |
|-------|--------|-----------------|
| KozoError | any | (custom) |
| BadRequestError | 400 | "Bad Request" |
| UnauthorizedError | 401 | "Unauthorized" |
| ForbiddenError | 403 | "Forbidden" |
| NotFoundError | 404 | "Resource Not Found" |
| ConflictError | 409 | "Conflict" |
| GoneError | 410 | "Gone" |
| ValidationFailedError | 400 | (custom, includes .errors array) |
Pre-built response helpers (zero-allocation hot path):
import {
notFoundResponse,
unauthorizedResponse,
forbiddenResponse,
internalErrorResponse,
validationErrorResponse,
} from '@kozojs/core';Graceful Shutdown
Kozo drains in-flight requests before closing. No request is dropped mid-flight.
await app.listen(3000);
// Later (e.g. on SIGTERM):
await app.shutdown({
timeoutMs: 30000,
onShutdownStart: (inflight) => console.log('Draining ' + inflight + ' requests'),
onShutdownComplete: () => console.log('Clean exit'),
});Full lifecycle control via ShutdownManager:
const manager = app.getShutdownManager();
// Register database cleanup
manager.setDatabase(db, 'postgresql'); // also: 'mysql', 'sqlite'
// Custom cleanup hooks (run after draining, before DB close)
manager.addCleanupHook(async () => {
await cache.quit();
await queue.close();
});
// Wire to process signals
process.on('SIGTERM', () => app.shutdown());
process.on('SIGINT', () => app.shutdown());During shutdown:
- New requests ->
503 Service Unavailable - In-flight requests -> allowed to complete (up to
timeoutMs) - Cleanup hooks run
- Database connections closed
- Server closed
Type-Safe Client Generation
Generate a fully typed TypeScript client from your routes:
const code = app.generateClient({
baseUrl: 'https://api.example.com',
includeValidation: true, // embed Zod schemas for client-side validation
validateByDefault: false, // opt-in per request
});
writeFileSync('./client/api.ts', code);Generated route-tree usage:
import { createKozoClient } from './client/api';
const api = createKozoClient({ baseUrl: 'https://api.example.com' });
const users = await api.users.get({
query: { page: 1 },
});
const created = await api.users.post({
body: { name: 'Jane', email: '[email protected]' },
});
if (created.status === 201) {
created.body.id; // string
}
const detail = await api.users.$id.get({
params: { id: created.status === 201 ? created.body.id : 'missing' },
});
if (detail.status === 404) {
detail.body.message; // narrowed to the declared 404 schema
}createKozoClient() is the preferred API. It returns declared HTTP statuses as
the union { status, headers, body, ok }; an undeclared status throws
KozoUnexpectedResponseError. The generated KozoClient class retains flat
methods such as usersById() as deprecated compatibility aliases. Those
legacy methods preserve their historical behavior and throw KozoApiError for
every non-2xx response.
Options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| baseUrl | string | '' | API base URL |
| includeValidation | boolean | true | Include Zod schemas in output |
| validateByDefault | boolean | false | Enable validation in client constructor |
| defaultHeaders | Record<string, string> | {} | Default request headers |
OpenAPI Generation
Mount Swagger UI and an OpenAPI 3.1 document from registered routes:
app.mountDocs({
title: 'My API',
version: '1.0.0',
servers: [{ url: 'https://api.example.com' }],
});- Swagger UI:
/docs - OpenAPI JSON:
/docs.json
Outside production the routes are enabled by default. In production, opt in explicitly:
app.mountDocs({
title: 'My API',
version: '1.0.0',
enabled: process.env.ENABLE_API_DOCS === 'true',
});For custom spec pipelines, use createOpenAPIGenerator() directly. generateSwaggerHtml(specUrl, title?) accepts the URL of an OpenAPI document, not the document object.
Schemas are converted via Zod v4 native z.toJSONSchema(). Supported route metadata includes path params, query params, request bodies, response schemas, tags, Bearer auth, and summaries.
File-System Routing
Auto-register routes from the file system:
const app = createKozo({ routesDir: './src/routes' });
await app.loadRoutes();Convention:
| File | Route |
|------|-------|
| routes/users.ts | GET/POST /users |
| routes/users/[id].ts | GET/PUT/DELETE /users/:id |
| routes/_middleware.ts | Skipped (prefixed with _) |
| routes/users.test.ts | Skipped (test file) |
Each route file exports a default handler and optional schema/meta:
// routes/users/[id].ts
import { z } from 'zod';
export const schema = {
params: z.object({ id: z.string().uuid() }),
response: UserSchema,
};
export const meta = { auth: true, tags: ['users'] };
export default (ctx) => {
return ctx.services.db.users.find(ctx.params.id);
};Programmatic API:
import { createFileSystemRouting, applyFileSystemRouting } from '@kozojs/core/middleware';WebSocket (uWS)
WebSocket support via uWebSockets.js native pub/sub. Requires nativeListen().
app.ws('/ws/chat', {
open(ws) {
ws.subscribe('chat');
},
message(ws, data) {
ws.publish('chat', data);
},
close(ws) {
console.log('disconnected');
},
});
await app.nativeListen(3000);With typed user data and auth upgrade:
app.ws<{ userId: string }>('/ws/secure', {
upgrade(req) {
const userId = verifyToken(req.headers['authorization']);
if (!userId) return false; // reject upgrade
return { userId }; // attached as ws.data
},
open(ws) {
console.log(ws.data.userId + ' connected');
ws.subscribe('user:' + ws.data.userId);
},
message(ws, data) {
ws.publish('user:' + ws.data.userId, data);
},
});Note:
app.listen()will warn if WebSocket routes are registered — useapp.nativeListen()instead.
SSR Integration
Unified API + Vite SSR from a single server. No separate frontend server or proxy.
import path from 'node:path';
import { createKozo } from '@kozojs/core';
const app = createKozo({ routesDir: './src/routes' });
await app.loadRoutes();
await app.listenSsr(3000, {
root: path.resolve('./web'),
entryServer: 'src/entry-server.tsx',
apiPrefix: '/api',
});How it works: requests matching apiPrefix go to Hono, everything else -> Vite SSR pipeline.
- Dev mode: Vite middleware for HMR + optional SSR rendering (auto-detected)
- Prod mode: Static files from
dist/client/+ pre-built SSR fromdist/server/
Supports React 18 streaming (renderToPipeableStream) and string rendering.
SSR Config
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| root | string | — | Web app root (where index.html lives) |
| entryServer | string | — | Server entry relative to root |
| apiPrefix | string \| string[] | '/api' | Routes that bypass SSR |
| devSsr | boolean | auto-detected | Enable SSR in dev mode |
| template | string | 'index.html' | HTML template path |
| appPlaceholder | string | <!--app-html--> (default) | Placeholder for rendered HTML |
| headPlaceholder | string | <!--ssr-head--> (default) | Placeholder for head tags |
| distClient | string | 'dist/client' | Built client assets |
| distServer | string | 'dist/server' | Server bundle directory |
Helper Schemas & Utilities
Common schemas to avoid repeating boilerplate:
import {
paginationSchema, // { page: z.coerce.number().default(1), limit: ... }
uuidParams, // { id: z.string().uuid() }
idParams, // { id: z.coerce.number().int().positive() }
timestamps, // { createdAt: z.date(), updatedAt: z.date() }
sortSchema, // { sortBy?: string, sortOrder: 'asc' | 'desc' }
searchSchema, // { q?: string }
successSchema, // { success: boolean, message?: string }
deletedSchema, // { success: boolean, deletedId: string }
uuid, // () => string (crypto.randomUUID)
paginate, // (items, page, limit) -> PaginatedResult
defineEnv, // validate process.env with Zod at startup
} from '@kozojs/core';Environment validation:
const env = defineEnv({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
// Throws at startup with clear message if any variable is missing/invalidPagination:
app.get('/users', { query: paginationSchema }, (ctx) => {
return paginate(allUsers, ctx.query.page, ctx.query.limit);
// -> { data: [...], total, page, limit, totalPages, hasNext, hasPrev }
});Fast Response Utilities
Zero-allocation response helpers for custom native handlers:
import {
fastWriteJson, // 200 JSON
fastWriteText, // 200 text/plain
fastWriteHtml, // 200 text/html
fastWriteJsonStatus, // JSON with custom status
fastWrite404, // pre-built 404
fastWrite500, // pre-built 500
fastWrite400, // validation error
fastWriteError, // KozoError -> problem+json
} from '@kozojs/core';Runtime Compatibility
// Standard Node.js HTTP
await app.listen(3000);
// Node.js + optional uWebSockets.js dependency
await app.nativeListen(3000);
// Fetch API integration; confirm compatibility in your target adapter
export default { fetch: app.fetch };API Reference
createKozo<TServices>(config?)
Create a Kozo application.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| services | TServices | {} | Dependency injection container |
| routesDir | string | — | Directory for file-system routing |
| maxBodyBytes | number | 1048576 | Max request body size — larger requests get a 413 |
| logger | boolean | true | Set false to silence the startup banner (tests, benchmarks) |
Instance Methods
| Method | Description |
|--------|-------------|
| .get(path, schema?, handler) | Register a GET route |
| .post(path, schema?, handler) | Register a POST route |
| .put(path, schema?, handler) | Register a PUT route |
| .patch(path, schema?, handler) | Register a PATCH route |
| .delete(path, schema?, handler) | Register a DELETE route |
| .group(prefix, fn) | Group routes under a prefix |
| .ws(path, handler) | Register a WebSocket route (requires nativeListen) |
| .middleware(path?, handler) | Register Hono middleware |
| .use(plugin) | Install a plugin |
| .listen(port?) | Start Node.js HTTP server (default: 3000) |
| .nativeListen(port?) | Start uWebSockets.js server |
| .listenSsr(port, config) | Start unified API + SSR server |
| .loadRoutes(dir?) | Load routes from file system |
| .shutdown(options?) | Graceful shutdown |
| .generateClient(options?) | Generate typed client SDK |
| .mountDocs(options?) | Mount Swagger UI and an OpenAPI 3.1 document |
| .getRoutes() | Inspect registered routes |
| .getShutdownManager() | Access shutdown manager |
| .getApp() | Access underlying Hono instance |
| .fetch | Hono fetch handler (for Workers/Deno) |
Exports Map
import { ... } from '@kozojs/core'; // main exports
import { ... } from '@kozojs/core/middleware'; // middlewareLicense
MIT
