Skip to content

Repository files navigation

Capix

Write what your server can do. The framework handles how it's exposed.

npm CI Pre-publish audit license node

Capix replaces routes and middleware with one primitive: a capability — a typed, guarded, pure function. Define it once, and every transport serves it: the same function is a REST endpoint, a GraphQL field, a WebSocket call, a background job, and an MCP tool your AI agents can invoke — with one validation pipeline, one error model, and guards that cannot be forgotten on any of them.

import { z } from 'zod';
import { capability, defineGuard, createServer } from '@capixjs/core';
import { restTransport } from '@capixjs/transport-rest';
import { graphqlTransport } from '@capixjs/transport-graphql';
import { mcpTransport } from '@capixjs/transport-mcp';

const mustBeAdmin = defineGuard((ctx) => {
  if (!ctx.user?.admin) throw new Error('Forbidden');
});

const createPost = capability(
  z.object({ title: z.string(), body: z.string() }),
  async ({ title, body }, ctx) => db.posts.create({ title, body, authorId: ctx.user.id }),
).guard(mustBeAdmin);

createServer({
  capabilities: { posts: { createPost } },
  transports: [
    restTransport({ port: 3000 }),     // POST /posts            ← route inferred from the name
    graphqlTransport({ port: 4000 }),  // mutation posts_createPost
    mcpTransport({ port: 5000 }),      // MCP tool posts_createPost, guarded the same way
  ],
}).start();

No req/res. No next(). No middleware stack. Transports are wiring, not architecture — add or remove one without touching a single capability.

5-minute quick start → · Documentation · Examples · Benchmarks · API stability

Status: stable (1.0). The public API and wire contracts follow semantic versioning — breaking changes only ship in a new major version. npm install @capixjs/core resolves to the current stable release.

Why capabilities?

Traditional Capix
Routes scatter business logic Capabilities are pure functions — testable without a server
Middleware is implicit and ordered Guards are explicit, typed, and chainable
Cross-cutting concerns require middleware Enhancers wrap the resolver directly
Type safety stops at the controller Input/output schemas validate at runtime and infer TypeScript types
Every route file imports Request/Response Zero framework imports in your domain code

Packages

Package Description
@capixjs/core Core framework — capability, createServer, guards, enhancers, event bus
@capixjs/transport-rest HTTP/1.1 REST transport with automatic route inference
@capixjs/transport-ws WebSocket transport for real-time capabilities and server push
@capixjs/transport-graphql GraphQL transport with auto-generated schema and GraphiQL playground
@capixjs/transport-queue Queue transport for background jobs — BullMQ and SQS adapters included, or bring any queue via QueueAdapter
@capixjs/transport-mcp MCP transport — expose capabilities as Model Context Protocol tools
@capixjs/store-redis Redis-backed cache, rate-limit stores, and cross-instance event bus
@capixjs/plugin-auth JWT authentication — jwtContextBuilder, createJWTHelpers, mustBeAuthenticated
@capixjs/plugin-cors CORS plugin
@capixjs/plugin-helmet Security headers plugin
@capixjs/plugin-logging Structured request logging via pino
@capixjs/testing Test utilities — mockContext, testServer

Quick start

npm install @capixjs/core @capixjs/transport-rest zod@^4
import { z } from 'zod';
import {
  capability,
  defineContext,
  defineGuard,
  defineError,
  createServer,
} from '@capixjs/core';
import { restTransport } from '@capixjs/transport-rest';

// --- Errors ---
const Errors = {
  NotFound: defineError(404, 'Not found'),
  Forbidden: defineError(403, 'Forbidden'),
};

// --- Context ---
const buildContext = defineContext(async (req) => ({
  requestId: crypto.randomUUID(),
  user: await verifyToken(req.headers.authorization),
}));

// --- Guards ---
const mustBeLoggedIn = defineGuard((ctx) => {
  if (!ctx.user) throw Errors.Forbidden();
});

// --- Capabilities ---
const getUser = capability(
  z.object({ id: z.string() }),
  async ({ id }) => {
    const user = await db.users.find(id);
    if (!user) throw Errors.NotFound();
    return user;
  },
).guard(mustBeLoggedIn);

const createUser = capability(
  z.object({ name: z.string(), email: z.string().email() }),
  async (input) => db.users.create(input),
);

// --- Server ---
createServer({
  context: buildContext,
  capabilities: { users: { getUser, createUser } },
  transports: [restTransport({ port: 3000 })],
}).start();
// GET  /users/:id   ← getUser
// POST /users       ← createUser

Typing your context

Every real Capix app has app-specific context (database connection, current user, logger). Use capability.withContext<YourContext>() to create a factory pre-typed for your context — define it once, import it everywhere:

// src/capabilities.ts — define once
import { capability } from '@capixjs/core';
import type { AppContext } from './context.js';

export const cap = capability.withContext<AppContext>();
// src/capabilities/users/get.ts — import and use
import { cap } from '../../capabilities.js';

export const getUser = cap(
  z.object({ id: z.string() }),
  async ({ id }, ctx) => {
    // ctx.user, ctx.db — all typed correctly, no annotation needed
    const user = await ctx.db.users.findById(id);
    if (!user) throw errors.NotFound();
    return user;
  },
  'query',
).guard(mustBeUser);

Without withContext, ctx is typed as BaseContext (only requestId).

Authenticated capabilities — create a second factory for capabilities that require a logged-in user:

// After mustBeUser runs, ctx.user is non-null.
// Use authCap to express this in the resolver's type.
type AuthContext = AppContext & { user: NonNullable<AppContext['user']> };

export const cap     = capability.withContext<AppContext>();   // public endpoints
export const authCap = capability.withContext<AuthContext>();  // authenticated endpoints

// ctx.user is User (not User | null) — no null check needed
export const getProfile = authCap(z.object({}), async (_, ctx) => ctx.user, 'query')
  .guard(mustBeUser);

Note: TypeScript cannot retroactively narrow the resolver's ctx from guards added via .guard(). The authCap pattern pre-types the resolver instead. See docs/ts-workarounds.md for details.

Core concepts

Capabilities

A capability wraps a resolver function with optional input/output schemas:

// No schema — accepts any input
const ping = capability(() => 'pong');

// With input schema — validates and infers types automatically
const greet = capability(
  z.object({ name: z.string() }),
  ({ name }) => `Hello, ${name}`,
);

// With output schema — validates resolver return value
const getMetrics = capability(
  z.object({ window: z.enum(['1h', '24h']) }),
  fetchMetrics,
).output(MetricsSchema);

Capabilities are immutable. .guard() and .enhance() return new instances:

const adminOnly = greet.guard(mustBeAdmin);       // new capability
const cached = getMetrics.enhance(withCache(60)); // new capability

Guards

Guards run before the resolver and throw to reject the request:

const mustBeUser = defineGuard((ctx) => {
  if (!ctx.user) throw Errors.Unauthorized();
  // TypeScript narrows ctx.user as non-null after this point
});

// Multiple guards run in order; first failure stops execution
const cap = capability(schema, handler)
  .guard(mustBeLoggedIn)
  .guard(mustBeAdmin);

Enhancers

Enhancers wrap the resolver for cross-cutting concerns:

import { withCache, withRateLimit, withCircuitBreaker, withTimeout, withMetrics } from '@capixjs/core';

const robustCap = capability(schema, handler)
  .enhance(withCache(30))              // cache for 30 seconds
  .enhance(withRateLimit({ max: 100, windowMs: 60_000 }))
  .enhance(withCircuitBreaker({ threshold: 5, resetMs: 30_000 }))
  .enhance(withTimeout(5000));

HTTP route inference

The REST transport infers routes from capability names. No annotations required:

Capability name Route
getUser GET /users/:id
listUsers GET /users
createUser POST /users
updateUser PATCH /users/:id
replaceUser PUT /users/:id
deleteUser DELETE /users/:id
uploadAvatar POST /users/uploadAvatar

To override a route, pass overrides to restTransport — routing is a transport concern and does not belong in capability definitions:

restTransport({
  port: 3000,
  overrides: {
    'tasks.listTasks': { method: 'GET', path: '/projects/:projectId/tasks' },
  },
})

Plugins

Group related capabilities and context extensions into reusable plugins:

import { definePlugin } from '@capixjs/core';

const authPlugin = definePlugin({
  capabilities: { users: { getUser, createUser } },
  context: (base) => ({ ...base, isPlugin: true }),
});

createServer({
  plugins: [authPlugin],
  transports: [restTransport({ port: 3000 })],
}).start();

Nested resource routes

For URLs like /projects/:projectId/tasks, pass overrides to restTransport — the inference engine handles flat groups but not hierarchies:

// src/capabilities/tasks/list.ts — no routing info here
const listTasks = capability(
  z.object({
    projectId: z.string(),
    page:      z.coerce.number().default(1),
    status:    z.enum(['todo', 'done']).optional(),
  }),
  async ({ projectId, page, status }, ctx) => {
    return ctx.db.tasks.list({ projectId, page, status });
  },
  'query',
).guard(mustBeUser);
// src/server.ts — routing lives here
restTransport({
  port: 3000,
  overrides: {
    'tasks.listTasks': { method: 'GET', path: '/projects/:projectId/tasks' },
  },
})

The REST transport merges URL params, query string, and body into a single typed input object. See examples/nested-routes for a full working example.

Transport-specific capabilities

By default, all capabilities are available on all transports. Pass capabilities directly to a transport to expose only a subset:

const publicAPI = { items: { list: listItems, get: getItem } };
const memberAPI = { items: { create: createItem, update: updateItem } };
const jobsOnly  = { jobs:  { processItem, generateReport } };

createServer({
  context: buildContext,
  transports: [
    // REST and GraphQL expose public + member capabilities
    restTransport({ port: 3000, capabilities: { ...publicAPI, ...memberAPI } }),
    graphqlTransport({ port: 4000, capabilities: { ...publicAPI, ...memberAPI } }),

    // Queue only processes background jobs — never gets an HTTP endpoint
    queueTransport({ queues: ['jobs'], adapter, capabilities: jobsOnly }),
  ],
});

Capabilities are plain objects — pass the same reference to multiple transports to share them without duplication.

Top-level default

Providing capabilities at the top level sets the default for all transports that don't specify their own:

createServer({
  context:      buildContext,
  capabilities: publicAPI,              // default for REST + GraphQL
  transports: [
    restTransport({ port: 3000 }),      // uses publicAPI
    graphqlTransport({ port: 4000 }),   // uses publicAPI
    queueTransport({
      queues:       ['jobs'],
      adapter,
      capabilities: jobsOnly,           // overrides publicAPI for queue only
    }),
  ],
});

If every transport specifies its own capabilities, the top-level field can be omitted entirely. Capix throws at startup if a transport has no capabilities and no server-level default is provided.

Real-time updates

The WebSocket transport is request/response. For server-push (broadcasting mutations to connected WS clients), use a module-level EventEmitter:

// src/events.ts — shared event bus
import { EventEmitter } from 'node:events';
export const taskEvents = new EventEmitter();

// src/capabilities/tasks/update.ts — emit after mutation
export const updateTask = authCap(Input, async ({ id, ...data }, ctx) => {
  const task = await ctx.db.tasks.update(id, data);
  taskEvents.emit('task.updated', { taskId: id, data: task });
  return task;
}, 'update').guard(mustBeUser);

See examples/realtime for the complete broadcast pattern.

Known TypeScript limitation

Guard type narrowing applies to subsequent guards in the chain, but TypeScript cannot retroactively narrow the resolver's ctx parameter based on guards added via .guard(). Use capability.withContext<AuthContext>() as the workaround.

See docs/ts-workarounds.md for a full explanation, the two-factory pattern, and what a future fix would look like.

Performance

Capix's REST transport trails Fastify by ~3% in a hello-world microbenchmark and beats Hono by ~19%. Zod validation and the capability dispatch pipeline add ~270ns/request relative to a bare handler. See docs/benchmarks.md for the full results and methodology.

Framework req/s (hello world) req/s (auth + guard)
Fastify 27,659 25,813
Capix 26,240 24,194
Hono 22,910 20,332
Express 16,176 15,366

Measured on 0.1.0-beta.1 — see docs/benchmarks.md for methodology, caveats, and how to reproduce.

627 tests passing

pnpm -r test  →  627 tests, 0 failures

CLI

npm install -g @capixjs/cli
Command Description
capix new <name> Scaffold a new project
capix dev Start dev server with file watching
capix list List all registered capabilities
capix docs Print capability docs as Markdown
capix generate capability <group> <name> Generate a capability file
capix client Generate a typed fetch client
capix openapi Generate an OpenAPI 3.1 spec
capix mcp Serve capabilities as MCP tools (stdio or HTTP)

See docs/cli.md for all commands.

Documentation

Examples

Example Description
examples/basic-rest CRUD API with REST transport
examples/with-auth JWT auth, role-based guards
examples/nested-routes Nested resource routes with http override
examples/realtime EventEmitter broadcast pattern for WS push
examples/file-upload Multipart file upload
examples/pagination Query string coercion, filters, sorting
examples/jwt-auth Full JWT auth flow
examples/with-mcp Same capabilities served over REST and MCP at once

Testing

@capixjs/testing provides helpers to test capabilities without a running server:

import { mockContext, testServer } from '@capixjs/testing';

// Unit test: invoke capability directly (no server needed)
const ctx = mockContext({ user: { id: '1', admin: true } });
const result = await getUser.resolve({ id: '1' }, ctx);
expect(result.name).toBe('Alice');

// Integration test: real execution engine, no HTTP server
const server = testServer({
  context: buildContext,
  capabilities: { users: { getUser } },
});

const response = await server.call({
  capability: 'users.getUser',
  input: { id: '1' },
  headers: { authorization: 'Bearer test-token' },
});
expect(response.ok).toBe(true);
expect(response.status).toBe(200);

Development

pnpm install
pnpm -r build          # build all packages
pnpm -r test           # run all tests
pnpm -r typecheck      # typecheck all packages
# Run a specific package
cd packages/core
pnpm test
pnpm typecheck

Stability

Capix 1.0.0 is out. The stability policy is now in its stable phase: the documented public API and wire contracts only change across a major version, and deprecations live for at least one minor release before removal. See docs/changelog.md for the full history from alpha through 1.0.

Found a bug or a gap? Open an issue.

License

MIT

About

Node.js framework where you declare capabilities, not routes

Topics

Resources

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Used by

Contributors

Languages