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.
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); 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(); 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.
bun add @covenant-rpc/core @covenant-rpc/server @covenant-rpc/client @covenant-rpc/react zodWorks with any Standard Schema compatible validator (Zod, Valibot, ArkType, etc.).