createHandler creates a handler object for processing RPC requests.
import {createHandler, rpc} from'@atom-forge/rpc';
const api = {
posts: {
// expects $query from the clientlist: rpc.query(async ({page}, ctx) => {
ctx.cache.set(60);
return {posts: []};
}),
// expects $get from the clientgetById: rpc.get(async ({id}, ctx) => {
return {id, title: 'Example Post'};
}),
// expects $command from the clientcreate: rpc.command(async ({title}) => {
// create a new post
}),
},
};
const rpcHandler = createHandler(api, '/rpc');
rpcHandler.match(request); // boolean — does this request belong to this handler?
rpcHandler.handle(request); // Promise<Response>
Use the handler as the boundary between HTTP and your endpoint implementations. In a dedicated RPC route, pass the request directly to handle(). When sharing a hook or middleware with other routes, use match() first so that unrelated requests continue through the application's normal routing.
Custom Server Context
You can provide a custom server context factory to inject your own properties (e.g. authenticated user) into every handler:
The rpc object provides methods for defining your API endpoints. The method you use on the server determines how the client must call the endpoint.
rpc.query: Defines a query endpoint that expects arguments encoded with MessagePack. The client must use $query.
rpc.get: Defines a query endpoint that expects arguments as plain text in the URL. The client must use $get.
rpc.command: Defines a command endpoint. The client must use $command.
Choose query for reads with structured arguments, and get when a plain-text URL is useful for simpler clients or integrations. Both use GET requests, so arguments travel in the URL rather than a request body. Choose command for operations that change state or need a body, including file uploads. These choices are part of the endpoint contract, not interchangeable client conveniences.
rpcFactory
If you use a custom server context (see above), use rpcFactory to create a typed rpc instance so that ctx is properly typed in your handlers:
import {rpcFactory} from'@atom-forge/rpc';
const rpc = rpcFactory<AppContext>();
const api = {
posts: {
list: rpc.query(async ({page}, ctx) => {
// ctx is typed as AppContextconst user = ctx.user;
return {posts: []};
}),
},
};
Server Context (ctx)
Every handler and server-side middleware receives a ctx object with the following members:
A Map<string|symbol, any> for passing data between middlewares.
ctx.elapsedTime
Server-side elapsed time in milliseconds.
Status shortcuts:ok, created, accepted, noContent, badRequest, unauthorized, forbidden, notFound, methodNotAllowed, conflict, tooManyRequests, serverError, serviceUnavailable, and more.
Context carries the data and response controls for the current call; middleware performs work around the endpoint. For example, authentication middleware can verify a session and place the user in ctx.env, while an endpoint or custom context accessor reads that value. Adding a user accessor alone does not authenticate anyone: the middleware that populates it must also be applied to the relevant endpoints.
Caching
Rpc supports server-side caching for GET requests (both rpc.query and rpc.get). Set the cache duration in seconds using ctx.cache.set() within your endpoint implementation.
const api = {
posts: {
list: rpc.query(async ({page}, ctx) => {
ctx.cache.set(60); // Cache the response for 60 secondsreturn {posts: []};
}),
},
};
Here, caching means setting HTTP cache policy, not storing endpoint results in an application-side cache. Only enable it when reusing the response is appropriate for the data and callers involved; personalized or sensitive reads deserve particular care.
Error Handling
Use the built-in error helpers to return application-level errors from handlers. These always produce a 200 OK response with the atomforge.rpc.error key, so the client receives a typed RpcResponse.
import {rpc} from'@atom-forge/rpc';
const api = {
posts: {
create: rpc.command(async ({title}, ctx) => {
if (!ctx.env.get('user')) return rpc.error.permissionDenied();
if (title.length < 3) return rpc.error.invalidArgument({message: 'Title too short'});
// ...return {id: 1, title};
}),
},
};
Use application errors for expected failures that callers should handle, such as a rejected title or insufficient permissions. Give custom codes stable meanings so that clients can branch on the code rather than parse a human-readable message. HTTP status handling is a separate layer: the middleware examples below demonstrate rejecting a request with an explicit HTTP status instead.
Rpc has built-in support for zod for input validation. Install zod as a dependency of your project and import it directly.
If validation fails, Rpc automatically returns an application-level error (200 OK) with code INVALID_ARGUMENT and the ZodIssue array in the issues field. The handler does not run.
// Server-sideimport {rpc} from'@atom-forge/rpc';
import {z} from'zod';
const api = {
posts: {
create: rpc.zod({
title: z.string().min(3, "Title must be at least 3 characters long."),
content: z.string().min(10),
}).command(async ({title, content}) => {
// This code only runs if validation passes
}),
},
};
Handle validation errors on the client via RpcResponse:
// Client-sideconst res = await client.posts.create.$command({title: 'Hi'});
if (res.isError('INVALID_ARGUMENT')) {
console.log(res.result.issues); // ZodIssue[]
}
makeServerMiddleware
The makeServerMiddleware function is used to create server-side middleware. An optional second argument lets you attach accessor functions to the middleware, which is useful for creating reusable, self-contained middleware with helpers.
import {makeServerMiddleware} from'@atom-forge/rpc';
const authMiddleware = makeServerMiddleware(
async (ctx, next) => {
const token = ctx.cookies.get('session') ?? ctx.headers.request.get('Authorization');
const user = awaitverifyToken(token);
if (!user) {
ctx.status.unauthorized();
return {error: 'Unauthorized'}; // ✅ early return, no next() call needed
}
ctx.env.set('user', user);
returnawaitnext(); // ✅ always return the result of next()
},
// Optional accessors attached to the middleware function itself
{
isAdmin: (ctx) => ctx.env.get('user')?.role === 'admin',
}
);
The accessor functions are attached directly to the middleware function object, keeping the middleware and its associated helpers co-located. Call them from within endpoint implementations by passing ctx:
Server-side Usage
createHandlercreateHandlercreates a handler object for processing RPC requests.import {createHandler, rpc} from '@atom-forge/rpc'; const api = { posts: { // expects $query from the client list: rpc.query(async ({page}, ctx) => { ctx.cache.set(60); return {posts: []}; }), // expects $get from the client getById: rpc.get(async ({id}, ctx) => { return {id, title: 'Example Post'}; }), // expects $command from the client create: rpc.command(async ({title}) => { // create a new post }), }, }; const rpcHandler = createHandler(api, '/rpc'); rpcHandler.match(request); // boolean — does this request belong to this handler? rpcHandler.handle(request); // Promise<Response>Use the handler as the boundary between HTTP and your endpoint implementations. In a dedicated RPC route, pass the request directly to
handle(). When sharing a hook or middleware with other routes, usematch()first so that unrelated requests continue through the application's normal routing.Custom Server Context
You can provide a custom server context factory to inject your own properties (e.g. authenticated user) into every handler:
import {createHandler, ServerContext} from '@atom-forge/rpc'; class AppContext extends ServerContext { get user() { return this.env.get('user'); // populated by an auth middleware } } const rpcHandler = createHandler(api, '/rpc', { createServerContext: (args, request) => new AppContext(args, request), });The
rpcobjectThe
rpcobject provides methods for defining your API endpoints. The method you use on the server determines how the client must call the endpoint.rpc.query: Defines a query endpoint that expects arguments encoded with MessagePack. The client must use$query.rpc.get: Defines a query endpoint that expects arguments as plain text in the URL. The client must use$get.rpc.command: Defines a command endpoint. The client must use$command.Choose
queryfor reads with structured arguments, andgetwhen a plain-text URL is useful for simpler clients or integrations. Both use GET requests, so arguments travel in the URL rather than a request body. Choosecommandfor operations that change state or need a body, including file uploads. These choices are part of the endpoint contract, not interchangeable client conveniences.rpcFactoryIf you use a custom server context (see above), use
rpcFactoryto create a typedrpcinstance so thatctxis properly typed in your handlers:import {rpcFactory} from '@atom-forge/rpc'; const rpc = rpcFactory<AppContext>(); const api = { posts: { list: rpc.query(async ({page}, ctx) => { // ctx is typed as AppContext const user = ctx.user; return {posts: []}; }), }, };Server Context (
ctx)Every handler and server-side middleware receives a
ctxobject with the following members:ctx.requestRequestobject.ctx.getArgs()ctx.argsMap<string, any>.ctx.cookiesget(name),set(name, value, opts?),delete(name, opts?),getAll().ctx.headers.requestctx.headers.responsectx.cache.set(seconds)Cache-Controlmax-age for GET responses.ctx.cache.get()ctx.status.set(code)ctx.status.notFound()ctx.envMap<string|symbol, any>for passing data between middlewares.ctx.elapsedTimeStatus shortcuts:
ok,created,accepted,noContent,badRequest,unauthorized,forbidden,notFound,methodNotAllowed,conflict,tooManyRequests,serverError,serviceUnavailable, and more.Context carries the data and response controls for the current call; middleware performs work around the endpoint. For example, authentication middleware can verify a session and place the user in
ctx.env, while an endpoint or custom context accessor reads that value. Adding auseraccessor alone does not authenticate anyone: the middleware that populates it must also be applied to the relevant endpoints.Caching
Rpc supports server-side caching for
GETrequests (bothrpc.queryandrpc.get). Set the cache duration in seconds usingctx.cache.set()within your endpoint implementation.const api = { posts: { list: rpc.query(async ({page}, ctx) => { ctx.cache.set(60); // Cache the response for 60 seconds return {posts: []}; }), }, };Here, caching means setting HTTP cache policy, not storing endpoint results in an application-side cache. Only enable it when reusing the response is appropriate for the data and callers involved; personalized or sensitive reads deserve particular care.
Error Handling
Use the built-in error helpers to return application-level errors from handlers. These always produce a
200 OKresponse with theatomforge.rpc.errorkey, so the client receives a typedRpcResponse.import {rpc} from '@atom-forge/rpc'; const api = { posts: { create: rpc.command(async ({title}, ctx) => { if (!ctx.env.get('user')) return rpc.error.permissionDenied(); if (title.length < 3) return rpc.error.invalidArgument({message: 'Title too short'}); // ... return {id: 1, title}; }), }, };Use application errors for expected failures that callers should handle, such as a rejected title or insufficient permissions. Give custom codes stable meanings so that clients can branch on the code rather than parse a human-readable message. HTTP status handling is a separate layer: the middleware examples below demonstrate rejecting a request with an explicit HTTP status instead.
Use
rpc.error.makefor custom error codes:return rpc.error.make('POST_ALREADY_EXISTS', 'This slug already exists', {slug: post.slug});rpc.error.invalidArgument(details?)INVALID_ARGUMENTrpc.error.permissionDenied(details?)PERMISSION_DENIEDrpc.error.internalError(details?)INTERNAL_ERRORcorrelationId)rpc.error.make(code, message?, result?)zodintegrationRpc has built-in support for
zodfor input validation. Installzodas a dependency of your project and import it directly.If validation fails, Rpc automatically returns an application-level error (
200 OK) with codeINVALID_ARGUMENTand theZodIssuearray in theissuesfield. The handler does not run.// Server-side import {rpc} from '@atom-forge/rpc'; import {z} from 'zod'; const api = { posts: { create: rpc.zod({ title: z.string().min(3, "Title must be at least 3 characters long."), content: z.string().min(10), }).command(async ({title, content}) => { // This code only runs if validation passes }), }, };rpc.zodalso works withqueryandget:rpc.zod({id: z.number()}).query(async ({id}, ctx) => { ... }) rpc.zod({id: z.number()}).get(async ({id}, ctx) => { ... })Handle validation errors on the client via
RpcResponse:// Client-side const res = await client.posts.create.$command({title: 'Hi'}); if (res.isError('INVALID_ARGUMENT')) { console.log(res.result.issues); // ZodIssue[] }makeServerMiddlewareThe
makeServerMiddlewarefunction is used to create server-side middleware. An optional second argument lets you attach accessor functions to the middleware, which is useful for creating reusable, self-contained middleware with helpers.import {makeServerMiddleware} from '@atom-forge/rpc'; const authMiddleware = makeServerMiddleware( async (ctx, next) => { const token = ctx.cookies.get('session') ?? ctx.headers.request.get('Authorization'); const user = await verifyToken(token); if (!user) { ctx.status.unauthorized(); return {error: 'Unauthorized'}; // ✅ early return, no next() call needed } ctx.env.set('user', user); return await next(); // ✅ always return the result of next() }, // Optional accessors attached to the middleware function itself { isAdmin: (ctx) => ctx.env.get('user')?.role === 'admin', } );The accessor functions are attached directly to the middleware function object, keeping the middleware and its associated helpers co-located. Call them from within endpoint implementations by passing
ctx:const api = { admin: { deletePost: rpc.middleware(authMiddleware).command(async ({id}, ctx) => { if (!authMiddleware.isAdmin(ctx)) { ctx.status.forbidden(); return {error: 'Admin only'}; } // proceed... }), }, };This pattern keeps the middleware's knowledge — what constitutes an
isAdmincheck — in one place rather than repeating the logic in every endpoint.Applying Middleware with
rpc.middlewareUse
rpc.middleware()to attach one or more server middlewares to an endpoint:import {rpc} from '@atom-forge/rpc'; import {z} from 'zod'; // Apply middleware to a specific endpoint const api = { posts: { create: rpc.middleware(authMiddleware).command(async ({title}) => { // ... }), // Combine middleware with zod validation update: rpc.middleware(authMiddleware).zod({ id: z.number(), title: z.string(), }).command(async ({id, title}) => { // ... }), }, };You can also attach middleware to any existing object with
.on():const postsApi = { list: rpc.query(async () => { ... }), create: rpc.command(async () => { ... }), }; // Attach authMiddleware to the whole postsApi group rpc.middleware(authMiddleware).on(postsApi); const api = {posts: postsApi};