Skip to content

Covenant RPC

Full-stack TypeScript RPC with automatic cache invalidation and realtime channels.
Typesafe procedures Type-safe queries and mutations with automatic resource tracking and cache invalidation
import { declareCovenant, query, mutation } from "@covenant-rpc/core";
import { z } from "zod";

const todoSchema = z.object({ id: z.string(), text: z.string() });

export const covenant = declareCovenant({
  procedures: {
    getTodos: query({
      input: z.null(),
      output: z.array(todoSchema),
    }),
    addTodo: mutation({
      input: z.object({ text: z.string() }),
      output: todoSchema,
    }),
    deleteTodo: mutation({
      input: z.object({ id: z.string() }),
      output: z.null(),
    }),
  },
  channels: {},
});
import { CovenantServer } from "@covenant-rpc/server";
import { emptyServerToSidekick } from "@covenant-rpc/server/interfaces/empty";
import { covenant } from "./covenant";

type Todo = { id: string; text: string };

const todos: Todo[] = [
  { id: "1", text: "Read the overview" },
  { id: "2", text: "Try the quickstart" },
];

export const server = new CovenantServer(covenant, {
  contextGenerator: () => undefined,
  derivation: () => ({}),
  sidekickConnection: emptyServerToSidekick(),
});

server.defineProcedure("getTodos", {
  resources: () => ["todos"],
  procedure: () => todos,
});

server.defineProcedure("addTodo", {
  resources: ({ outputs }) => ["todos", `todo/${outputs.id}`],
  procedure: ({ inputs }) => {
    const todo: Todo = { id: crypto.randomUUID(), text: inputs.text };
    todos.push(todo);
    return todo;
  },
});

server.defineProcedure("deleteTodo", {
  resources: ({ inputs }) => ["todos", `todo/${inputs.id}`],
  procedure: ({ inputs, error }) => {
    const i = todos.findIndex((t) => t.id === inputs.id);
    if (i === -1) error("Not found", 404);
    todos.splice(i, 1);
    return null;
  },
});

server.assertAllDefined();
import type { APIRoute } from "astro";
import { server } from "../../lib/server";

export const prerender = false;

// server.handle() accepts a Request and returns a Response —
// the same interface that Astro API routes, Next.js route handlers,
// and any other WinterTC-compatible framework expects.
export const GET: APIRoute = ({ request }) => server.handle(request);
export const POST: APIRoute = ({ request }) => server.handle(request);
Live Demo Hitting a real Covenant API route — mutations return resources that trigger listener refetches
Server response
Waiting for requests…
Client
  • Loading…
Realtime channels Bidirectional WebSocket channels with typed messages and params-based routing — powered by Sidekick
import { declareCovenant, channel } from "@covenant-rpc/core";
import { z } from "zod";

export const covenant = declareCovenant({
  procedures: {},
  channels: {
    chat: channel({
      // Messages the client sends
      clientMessage: z.object({ text: z.string() }),
      // Messages the server broadcasts
      serverMessage: z.object({ username: z.string(), text: z.string() }),
      // Data sent when connecting (e.g. auth token or display name)
      connectionRequest: z.object({ username: z.string() }),
      // Per-connection data returned from onConnect
      connectionContext: z.object({ username: z.string() }),
      // URL-style params that scope the channel
      params: ["roomId"],
    }),
  },
});
import { CovenantServer } from "@covenant-rpc/server";
import { httpServerToSidekick } from "@covenant-rpc/server/interfaces/http";
import { covenant } from "./covenant";

export const server = new CovenantServer(covenant, {
  contextGenerator: () => undefined,
  derivation: () => ({}),
  sidekickConnection: httpServerToSidekick("http://localhost:4001", "secret"),
});

server.defineChannel("chat", {
  onConnect: ({ inputs }) => {
    // Return value becomes the per-connection context
    return { username: inputs.username };
  },

  onMessage: async ({ inputs, params, context }) => {
    // Broadcast to every client connected to this room
    await server.sendMessage(
      "chat",
      { roomId: params.roomId },
      { username: context.username, text: inputs.text }
    );
  },
});

server.assertAllDefined();
import { CovenantClient } from "@covenant-rpc/client";
import { httpClientToServer, httpClientToSidekick } from "@covenant-rpc/client/interfaces/http";
import { covenant } from "./covenant";

const client = new CovenantClient(covenant, {
  serverConnection: httpClientToServer("/api/covenant", {}),
  sidekickConnection: httpClientToSidekick("ws://localhost:4001"),
});

// Connect — runs onConnect on the server
const result = await client.connect(
  "chat",
  { roomId: "general" },
  { username: "alice" }
);
if (!result.success) throw new Error(result.error.message);

// Subscribe to incoming messages (fully typed)
const unsubscribe = await client.subscribe(
  "chat",
  { roomId: "general" },
  result.token,
  (msg) => console.log(`${msg.username}: ${msg.text}`)
);

// Send a message — triggers onMessage on the server
await client.send("chat", { roomId: "general" }, result.token, {
  text: "Hello!",
});

// Disconnect
unsubscribe();
Simulated demo Messages route through onMessage and are broadcast to all subscribers in the room
Channel activity
Room: general

Type-safe by default

Full TypeScript inference from your schema validators to every call site. No any, no hand-written API types, no drift between frontend and backend.

Automatic cache invalidation

Declare which resources a procedure touches. Mutations automatically trigger refetches in every matching listener — no manual cache management.

Realtime channels

Bidirectional WebSocket channels with typed messages, params-based routing, and per-connection context. Powered by Sidekick.

AI-agent friendly

The covenant file is the only context an agent needs to work on either side of the stack. Both frontend and backend derive their types from the same source.

Terminal window
bun add @covenant-rpc/core @covenant-rpc/server @covenant-rpc/client @covenant-rpc/react zod

Works with any Standard Schema compatible validator (Zod, Valibot, ArkType, etc.).