Skip to content

Custom RPC Methods

Teleportal’s RPC framework lets you define custom request/response methods that run over the existing sync connection. This extends the protocol without introducing new message types or transports – your custom methods share the same connection, authentication, and encryption as document sync.

Several built-in features use this system: file transfers, milestones, attribution queries, and the key registry are all implemented as RPC protocols.

  • Defining a typed RPC method contract
  • Implementing server-side handlers
  • Creating a client extension
  • Wiring everything together with the Server and Provider

Start by defining your method’s wire name, request type, and response type using defineMethod. Group related methods into a protocol with defineProtocol:

comments/methods.ts
import { defineMethod, defineProtocol } from "teleportal/rpc";
interface Comment {
id: string;
text: string;
userId: string;
createdAt: number;
}
export const commentList = defineMethod<
"commentList",
{ cursor?: string; limit?: number },
{ comments: Comment[]; nextCursor?: string }
>("commentList");
export const commentCreate = defineMethod<
"commentCreate",
{ text: string; parentId?: string },
{ comment: Comment }
>("commentCreate");
export const commentProtocol = defineProtocol("comments", {
list: commentList,
create: commentCreate,
});

The first type parameter is the wire name (the string sent over the connection), followed by the request and response types. defineProtocol groups methods under ergonomic keys (list, create) while preserving their wire names.

Use createHandlers to implement the protocol. Each handler receives a dependency bag (first argument) and returns an async function that processes the request:

comments/server.ts
import { createHandlers, ok, err } from "teleportal/rpc";
import { commentProtocol } from "./methods";
export function getCommentRpcHandlers(db: CommentDB) {
return createHandlers(
commentProtocol,
{ db },
{
list:
({ db }) =>
async (payload, ctx) => {
const result = await db.comments.find({
documentId: ctx.documentId,
cursor: payload.cursor,
limit: payload.limit ?? 50,
});
return ok({ comments: result.items, nextCursor: result.nextCursor });
},
create:
({ db }) =>
async (payload, ctx) => {
if (!ctx.userId) return err(401, "Authentication required");
const comment = await db.comments.insert({
documentId: ctx.documentId,
text: payload.text,
userId: ctx.userId,
});
return ok({ comment });
},
},
);
}

Handlers return ok(value) for success or err(statusCode, details) for errors. Unexpected exceptions are caught automatically and translated to err(500, message).

For simple protocols, auto-generate the client with createClientExtension:

comments/client.ts
import { createClientExtension } from "teleportal/rpc";
import { commentProtocol } from "./methods";
export const createCommentRpc = createClientExtension(commentProtocol);

For a more ergonomic API, provide a custom build function that transforms the raw RPC calls:

export const createCommentRpc = createClientExtension(commentProtocol, {
build(methods) {
return {
async list(cursor?: string) {
const response = await methods.list({ cursor, limit: 50 });
return response.comments;
},
async create(text: string) {
const response = await methods.create({ text });
return response.comment;
},
};
},
});

Register the handlers on the server and the client extension on the provider:

// Server
import { Server } from "teleportal/server";
import { MemoryDocumentStorage } from "teleportal/storage";
import { getCommentRpcHandlers } from "./comments/server";
const server = new Server({
storage: async (ctx) => new MemoryDocumentStorage(ctx.encrypted),
rpcHandlers: {
...getCommentRpcHandlers(db),
},
});
// Client
import { Provider } from "teleportal/providers";
import { createEncryptionKey } from "teleportal/encryption-key";
import { createCommentRpc } from "./comments/client";
const encryptionKey = createEncryptionKey();
const provider = await Provider.create({
url: "wss://example.com/sync",
document: "my-doc",
encryptionKey,
rpc: { comments: createCommentRpc },
});
// Use the RPC methods
const comments = await provider.rpc.comments.list({ limit: 10 });
const newComment = await provider.rpc.comments.create({ text: "Hello!" });

Client-side RPC failures throw RpcOperationError, which includes the protocol and operation name:

import { RpcOperationError } from "teleportal/rpc";
try {
await provider.rpc.comments.create({ text: "" });
} catch (error) {
if (error instanceof RpcOperationError) {
console.error(error.protocol); // "comments"
console.error(error.operation); // "create"
}
}

These built-in protocols demonstrate the same pattern at production scale:

  • File protocol (teleportal/protocols/file) – Chunked file upload/download with Merkle tree verification. Uses the "multipart" method kind for streaming uploads.
  • Milestone protocol (teleportal/protocols/milestone) – Create, list, get, and delete document snapshots.
  • Attribution protocol (teleportal/protocols/attribution) – Query authorship data for document content.
  • Key registry protocol (teleportal/protocols/key-registry) – Server-mediated encryption key distribution with wrapping, rotation, and revocation.