Skip to content

arpan404/joor

Joor

Joor is a Fetch-native, AOT-generated, type-safe RPC backend framework for AI-native TypeScript systems.

The framework is built around a strict route contract:

  • file-routed procedures with one *.rpc.ts file per operation
  • ahead-of-time manifest, dispatcher, native adapters, typed client, OpenAPI JSON, and AI docs JSON
  • O(1) procedure dispatch through generated route-id lookup
  • unary RPC, batch RPC, typed SSE streaming, and route-specific transport surfaces
  • Joor-owned schema DSL with interpreted validation and OpenAPI conversion
  • layered typed context through plugins
  • typed headers, response headers, errors, auth, rate limits, caching, and custom request/context preservation
  • Fetch, Node, Express, Fastify, Elysia, Hono, Koa, Bun, Deno, AWS Lambda, Cloudflare Workers, Next.js, Vercel, and Netlify runtime adapters
  • Biome lint, oxfmt formatting, strict tsc, and Vitest

Quickstart

Create a procedure:

import { defineProcedure, t } from 'joor';

export default defineProcedure({
  input: t.object({
    id: t.string().uuid(),
  }),
  output: t.object({
    id: t.string(),
    name: t.string(),
  }),
  errors: {
    NOT_FOUND: t.object({
      message: t.string(),
    }),
  },
  meta: {
    summary: 'Get a user',
    tags: ['users'],
  },
  async handler(ctx, input) {
    const user = await ctx.services.users.findById(input.id);
    if (!user) {
      return ctx.error('NOT_FOUND', { message: 'User not found' });
    }
    return ctx.ok(user);
  },
});

For plugin-provided services, define app config and bind the generated context type:

import {
  createPlugin,
  defineConfig,
  defineProcedure,
  t,
  type JoorConfigContext,
} from 'joor';

const usersPlugin = createPlugin({
  name: 'users',
  setup() {
    return {
      users: {
        findById(id: string) {
          return { id, name: 'Ada' };
        },
      },
    };
  },
});

const config = defineConfig({
  entry: './rpc',
  outDir: './.joor',
  plugins: [usersPlugin] as const,
  cors: { origin: 'http://localhost:3000' },
});

type AppContext = JoorConfigContext<typeof config>;

export const procedure = defineProcedure.withContext<AppContext>()({
  input: t.object({ id: t.string() }),
  headers: t.object({
    authorization: t.optional(t.string().min(1)),
    'x-tenant-id': t.string().min(1),
  }),
  responseHeaders: t.object({
    'cache-control': t.string(),
  }),
  output: t.object({ id: t.string(), name: t.string() }),
  async handler(ctx, input) {
    ctx.headers['x-tenant-id'];
    ctx.headers.authorization;
    ctx.rawHeaders.get('authorization');
    return ctx.ok(ctx.services.users.findById(input.id), {
      'cache-control': 'private, max-age=60',
    });
  },
});

Build generated artifacts:

npm run joor -- build --entry ./rpc --out ./.joor

Or use config:

npm run joor -- build --config ./joor.config.ts

Generated output:

.joor/
  manifest.ts
  dispatcher.ts
  client.ts
  fetch.ts
  cloudflare.ts
  next.ts
  vercel.ts
  netlify.ts
  aws-lambda.ts
  node.ts
  bun.ts
  deno.ts
  openapi.json
  ai-docs.json

Use the generated Fetch dispatcher:

import { fetch } from './.joor/dispatcher.js';

export default { fetch };

Or use a generated platform entrypoint directly:

// Cloudflare Workers
export { default } from './.joor/cloudflare.js';

// Next.js App Router
export { GET, POST, OPTIONS } from './.joor/next.js';

// Vercel Functions
export { default } from './.joor/vercel.js';

// Netlify Edge Functions
export { default } from './.joor/netlify.js';

// AWS Lambda HTTP API
export { default as handler } from './.joor/aws-lambda.js';

Use the generated typed client:

import { client } from './.joor/client.js';

const result = await client.users.get({
  id: '550e8400-e29b-41d4-a716-446655440000',
});

if (result.ok) {
  result.data.name;
} else {
  result.error.code;
}

RPC Model

By default, all RPC calls go through:

POST /rpc

The dispatcher accepts a single request or a batch. Streaming procedures use the same endpoint with Accept: text/event-stream and emit data, error, and done SSE events. Set path in joor.config.ts to move the endpoint; generated clients, OpenAPI JSON, and AI docs JSON use that configured path.

Procedure ids are derived from file paths:

rpc/users/get.rpc.ts -> users.get
rpc/users/watch.rpc.ts -> users.watch
rpc/admin/users/list.rpc.ts -> admin.users.list

CLI

npm run joor -- build
npm run joor -- dev
npm run joor -- typecheck
npm run joor -- openapi
npm run joor -- doctor

build emits .joor/ artifacts, dev watches and rebuilds, openapi refreshes docs output, and doctor runs format, lint, tests, and package build.

Runtime Options

createJoorHandler accepts:

  • plugins for typed context services
  • path to move the RPC endpoint away from /rpc
  • cors for preflight and response headers
  • maxBodyBytes for request body limits
  • onError for runtime diagnostics

Fetch is the base runtime. The package also exposes small adapters for Node, Express, Fastify, Elysia, Hono, Koa, Bun, Deno, AWS Lambda HTTP API, Cloudflare Workers, Next.js, Vercel, and Netlify. Custom adapters can reuse joor/runtime/body for JSON body limits and joor/runtime/response for serialized envelope and Response conversion helpers.

Platform helpers expose typed deployment shapes when you do not use generated entrypoints: createCloudflareWorker() returns a Worker object, createCloudflareWorkerFor<Env, Context, Request>() preserves typed Worker bindings, createNextRouteHandlers() returns App Router method exports, createNextRouteHandlersFor<Context, Request>() preserves App Router context and extended request types, createVercelFunction() returns a fetch object, createNetlifyEdgeFunction() returns a Netlify Edge handler, and createNetlifyEdgeFunctionFor<Context, Request>() preserves the Netlify context object. Framework adapters also provide typed factory forms such as createJoorHandlerFor<Request>(), createBunFetchFor<Request>(), createDenoFetchFor<Request>(), createCloudflareFetchFor<Request>(), createVercelFetchFor<Request>(), createNetlifyFetchFor<Request>(), createAwsLambdaHandlerFor<Event>(), createNodeRpcRequestHandlerFor<Incoming, Outgoing>(), createExpressHandlerFor<Request, Response>(), createFastifyHandlerFor<Request, Reply>(), createKoaHandlerFor<Context>(), createHonoHandlerFor<Context>(), and createElysiaHandlerFor<Context>() for apps with extended runtime event or framework context types. Route-specific adapter factories narrow the accepted RPC body shape when a deployment surface only handles unary or streaming routes. Use createRouteUnary*/createUnaryRoute* or the concise createUnary* aliases for unary-only handlers, and createRouteStream*/createStreamRoute* or createStream* for stream-only handlers across Fetch, Bun, Deno, Node, AWS Lambda, Cloudflare, Next.js, Vercel, Netlify, Express, Fastify, Elysia, Hono, and Koa. Serve/listen helpers follow the same naming: serveRouteUnaryBun(), serveUnaryBun(), serveDenoStream(), listenUnary(), and their route-first aliases preserve route-specific hooks, middleware bodies, plugin services, and extended request or platform context types. Low-level RPC helpers expose the same pattern through createRpcHandlerFor<Request>(), createRpcBodyHandlerFor<Request>(), and createRpcBodyResultHandlerFor<Request>(). Low-level transport helpers keep the route-specific contract too: Bun, Deno, and Node expose createRouteUnary*TransportRequestHandler*/createUnaryRoute*TransportRequestHandler* for unary-only transport bodies and createRouteStream*TransportRequestHandler*/createStreamRoute*TransportRequestHandler* for streaming-only transport bodies, including typed For and path-scoped variants for custom Request, IncomingMessage, or ServerResponse subtypes. Compiled runtime helpers mirror the same request typing with createCompiledRpcHandlerFor<Request>(), createCompiledRouteUnaryRpcHandlerFor<Request>()/createCompiledUnaryRpcHandlerFor<Request>(), createCompiledRouteStreamRpcHandlerFor<Request>()/createCompiledStreamRpcHandlerFor<Request>(), createDenoCompiledTransportRequestHandlerFor<Request>(), createRouteUnaryDenoCompiledTransportRequestHandlerFor<Request>()/createUnaryDenoCompiledTransportRequestHandlerFor<Request>(), and createRouteStreamDenoCompiledTransportRequestHandlerFor<Request>()/createStreamDenoCompiledTransportRequestHandlerFor<Request>(). Generated native dispatcher, Bun, Deno, Node, AWS Lambda, Cloudflare, Next.js, Vercel, and Netlify entrypoints also export typed factory helpers such as createFetchFor<Request>(), createRouteUnaryFetchFor<Request>()/createUnaryFetchFor<Request>(), .joor/node's createHandler<Incoming, Outgoing>(), .joor/aws-lambda's createRouteUnaryAwsLambdaHandlerFor<Event, Request>()({ createRequest }), .joor/cloudflare's createRouteUnaryWorkerFor<Env, Context, Request>(), and .joor/next's createRouteStreamHandlersFor<Context, Request>() so generated handlers can preserve extended request types while keeping route-first and concise naming available in generated code. Generated dispatchers expose the same split at lower levels through nativeUnaryTransport, nativeStreamTransport, nativeUnaryBody, nativeStreamBody, and their NativeUnary*/NativeStream* result and handler aliases. Clients can also keep custom request types by pairing ClientFetch<Request>() with a matching createRequest factory, so the client transport never widens a typed fetch back to a plain Request. Route-specific client constructors, including createRouteUnaryClient(), createRouteStreamClient(), createManifestRouteUnaryClient(), and createManifestRouteStreamClient(), expose only the unary or streaming methods for that surface while preserving manifest-derived request defaults. Streaming clients keep stream() as the data-only iterator and expose streamEvents() for typed raw SSE data/error/done envelopes; generated callable stream leaves mirror that as client.users.watch.events(input). Generated .joor/client entrypoints mirror the unary/stream split with createRouteUnaryClient(), createRouteStreamClient(), routeUnaryClient, and routeStreamClient callable trees that include only the matching route leaves. Generated ai-docs.json and OpenAPI x-joor-procedures metadata also record each route's generated client path and callable/transport methods, including streaming events and streamEvents surfaces, so agents can choose the typed route API without inferring it from prose.

Type Safety Contract

Route ids are the source of truth for every route-specific type. Client calls, request builders, protocol request builders, batches, generated callable leaves, generated native dispatchers, and route-unary or route-stream adapters bind input, headers, response headers, errors, stream events, and result envelopes to the selected route id. Mismatched route ids and inputs are rejected at compile time on both handwritten manifest clients and generated .joor/* entrypoints.

Unary-only surfaces do not expose stream calls, stream-only surfaces do not expose unary calls, and generated route-specific exports keep the same split across transport handlers, body handlers, platform factories, and native dispatch helpers. Absolute RPC paths use the RpcPath template type so compiler docs, OpenAPI metadata, runtime adapters, and package subpaths reject relative paths before runtime.

Next.js API Routes

For the Next.js App Router, create app/api/rpc/route.ts and export handlers from the generated manifest:

import { createNextRouteHandlers } from 'joor/runtime/next';
import config from '../../../joor.config';
import { manifest } from '../../../.joor/manifest';

const handlers = createNextRouteHandlers(manifest, {
  ...config,
  path: '/api/rpc',
});

export const runtime = 'edge';
export const { GET, POST, OPTIONS } = handlers;

The adapter is Fetch-native, so it works with both Edge-compatible route handlers and standard App Router Request/Response APIs.

For dynamic App Router segments, use the typed factory form to preserve the route context shape. The same factory can take a second generic for extended Request subtypes:

import {
  createNextRouteHandlersFor,
  type NextRouteContext,
} from 'joor/runtime/next';

type Params = { team: string };

const createHandlers = createNextRouteHandlersFor<NextRouteContext<Params>>();
const { GET, POST, OPTIONS } = createHandlers(manifest, {
  ...config,
  path: '/teams/[team]/rpc',
});

type AppRequest = Request & { requestId: string };

const createTypedHandlers = createNextRouteHandlersFor<
  NextRouteContext<Params>,
  AppRequest
>();

Typed Headers

Procedures can declare request and response headers with the same schema DSL used for input/output. Request header names are normalized to lowercase before validation, so HTTP names like X-Tenant-Id are declared as 'x-tenant-id'. Header schemas must be objects with string-like values (t.string(), t.enum(...), string t.literal(...), or optional versions of those), matching the values HTTP transports can read and emit.

export default defineProcedure({
  input: t.object({ id: t.string() }),
  headers: t.object({
    authorization: t.optional(t.string()),
    'x-tenant-id': t.string(),
  }),
  responseHeaders: t.object({
    'cache-control': t.string(),
  }),
  output: t.object({ id: t.string(), tenantId: t.string() }),
  async handler(ctx, input) {
    return ctx.ok(
      {
        id: input.id,
        tenantId: ctx.headers['x-tenant-id'],
      },
      { 'cache-control': 'private' }
    );
  },
});

ctx.headers is the typed, validated request header object. ctx.rawHeaders is the original Fetch Headers instance for lower-level access. Declared response headers are validated before a success envelope is returned, included on the success envelope, and attached to the HTTP response for single unary calls.

Auth, Hooks, and Limits

Auth policies are typed and procedure-local. A successful policy return value becomes ctx.auth; a ctx.error(...) return short-circuits the procedure.

import { createAuthPolicy, defineProcedure, t } from 'joor';

const sessionAuth = createAuthPolicy<
  { users: { findByToken(token: string): { id: string } | null } },
  { authorization: string },
  { userId: string }
>({
  name: 'session',
  authenticate(ctx) {
    const token = ctx.headers.authorization;
    const user = ctx.services.users.findByToken(token);
    if (user === null) {
      return ctx.error('UNAUTHORIZED', { message: 'Unauthorized' });
    }
    return { userId: user.id };
  },
});

export default defineProcedure({
  input: t.object({ ok: t.boolean() }),
  headers: t.object({ authorization: t.string() }),
  output: t.object({ userId: t.string() }),
  auth: sessionAuth,
  meta: {
    rateLimit: { limit: 60, window: '1m' },
  },
  async handler(ctx) {
    return ctx.ok({ userId: ctx.auth.userId });
  },
});

createJoorHandler also accepts hooks and middleware with beforeRequest/afterResponse callbacks. meta.rateLimit is enforced by the runtime with a bounded in-memory window. Forwarded client IP headers are ignored by default; enable rateLimit.trustProxy only behind a proxy that strips and rewrites those headers.

Query procedures can also opt into in-memory response caching:

export default defineProcedure({
  input: t.object({ id: t.string() }),
  output: t.object({ id: t.string(), name: t.string() }),
  meta: {
    kind: 'query',
    cache: {
      ttl: '30s',
      key: ['input.id'],
    },
  },
  async handler(ctx, input) {
    return ctx.ok(await ctx.services.users.findById(input.id));
  },
});

Default cache keys include input, typed request headers, and auth context. If you provide cache.key, include every tenant/user dimension that can affect the response, such as auth.subject or headers.x-tenant-id.

Security Defaults

All runtime adapters enforce maxBodyBytes by default. Oversized request bodies return a PAYLOAD_TOO_LARGE RPC error with HTTP status 413.

CORS is disabled unless cors is configured. Enabling CORS without an explicit origin does not emit Access-Control-Allow-Origin; use a concrete origin or intentionally configure your own gateway policy.

joor build imports config and procedure files to inspect them, so run the compiler only for code you trust. The compiler warns when generated runtime safety checks are disabled or when wildcard CORS is configured.

Performance Knobs

The default runtime validates request headers, input, output, response headers, and rate limits. For trusted internal edges or benchmark runs, these can be disabled independently:

createJoorHandler(manifest, {
  validateHeaders: false,
  validateInput: false,
  validateOutput: false,
  validateResponseHeaders: false,
  enforceRateLimit: false,
});

The Node runtime also has a parsed-body fast path so it does not need to read the same body twice.

joor build emits an AOT dispatcher that imports each procedure directly and switches on literal procedure ids. That keeps generated apps off the generic manifest lookup path while preserving the same envelopes, validation, auth, streaming, and plugin behavior.

Development

npm run format:check
npm run lint
npm run test
npm run build

npm run lint runs Biome and strict TypeScript. npm run format formats the repository with oxfmt.

Status

Joor is pre-release, but the runtime surface is broader than a prototype slice: schema validation, procedure definition, generated dispatchers, generated native adapters, typed clients, route-specific transports, framework adapters, OpenAPI, and AI-readable docs are implemented and covered by strict TypeScript and runtime tests.

License

MIT. See LICENSE.md.

Releases

No releases published

Packages

 
 
 

Contributors