oRPC
A middleware that adds the caller's repositories to the oRPC context.
import { os } from "@orpc/server";
import { createOrpc, type OrpcRequestContext } from "better-supabase/orpc";
import * as v from "valibot";
import { betterSupabase } from "./lib/supabase";
export const bs = createOrpc(betterSupabase);
const base = os.$context<OrpcRequestContext>();
const authed = base.use(bs.middleware());
const open = base.use(bs.middleware({ allow: ["user", "anon"] }));
export const router = {
customers: {
list: authed.handler(({ context }) =>
bs.unwrap(context.db.customers.findMany({ select: ["id", "name"] })),
),
rename: authed
.input(
v.object({ id: v.string(), name: v.pipe(v.string(), v.minLength(2)) }),
)
.handler(({ input, context }) =>
bs.unwrap(context.db.customers.update(input.id, { name: input.name })),
),
},
health: open.handler(() => ({ ok: true })),
};Serve the router with bs.fetchHandler. It passes the request as the
initial context, answers 404 when no procedure matches, and adds the
request's cookies to the response: refreshed session cookies with
middleware({ refresh: true }), and bs-primary-until after a write when
read replicas are configured.
import { RPCHandler } from "@orpc/server/fetch";
export default {
fetch: bs.fetchHandler(new RPCHandler(router), { prefix: "/rpc" }),
};Calling handler.handle(request, { context: { request } }) yourself also
works, but then those cookies never reach the client.
On runtimes that stop the invocation after the response, pass waitUntil to
createOrpc; fetchHandler hands it the
event sink sends a
procedure started.
Entry arrays with toOrpc
toOrpc(entries, handler, { prefix }) runs an @supabase/middleware entry
array around an oRPC fetch handler. The procedures' initial context is
{ request } plus every contributed key, and the response passes back
through the entries, so refreshed cookies reach the client without
bs.middleware():
import { RPCHandler } from "@orpc/server/fetch";
import { toOrpc } from "better-supabase/orpc";
import { createServer, withBetterSupabase } from "better-supabase/server";
const bs = createServer(betterSupabase);
// The router's procedures read context.db.
const base = os.$context<{
request: Request;
db: ReturnType<typeof bs.admin>;
}>();
export default {
fetch: toOrpc([withBetterSupabase(bs)], new RPCHandler(router), {
prefix: "/rpc",
}),
};A refused caller gets the guard's 401 Problem Details before oRPC runs. Use
bs.unwrap from createOrpc to turn a Result into an ORPCError.
Contract-first routers
With a contract from @orpc/contract, apply the same middleware to the
implementer. The client package imports only the contract, and the server
serves it over HTTP with OpenAPIHandler. This example mounts it on Hono:
import { oc } from "@orpc/contract";
import { openapi } from "@orpc/openapi";
import * as v from "valibot";
const customer = v.object({ id: v.string(), name: v.string() });
export const contract = {
customers: {
get: oc
.meta(openapi({ method: "GET", path: "/customers/{id}" }))
.input(v.object({ id: v.pipe(v.string(), v.uuid()) }))
.output(customer),
},
};import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { implement } from "@orpc/server";
import type { OrpcRequestContext } from "better-supabase/orpc";
import { Hono } from "hono";
import { contract } from "./contract";
const os = implement(contract)
.$context<OrpcRequestContext>()
.use(bs.middleware());
const router = os.router({
customers: {
get: os.customers.get.handler(({ context, input }) =>
bs.unwrap(
context.db.customers.findById(input.id, { select: ["id", "name"] }),
),
),
},
});
const handle = bs.fetchHandler(new OpenAPIHandler(router), { prefix: "/api" });
export const app = new Hono().all("/api/*", (c) => handle(c.req.raw));OpenAPIHandler sends the error's status code, so a missing row answers
404 and a write that RLS rejects answers 403, each with the Problem Details
in the body's data. The .meta(openapi(...)) form needs no import for its
side effects; .route(...) works too after import "@orpc/openapi/extensions/route".
The oRPC example
has both styles.
Context
bs.middleware() resolves the caller and rejects anyone not in allow
(default ['user']). It adds:
context.db: repositories bound to the caller, so RLS applies.context.auth: the resolvedAuthState.context.bs: the full server context.
With .claims(schema) on the definition,
context.auth and context.bs are typed by the schema's output:
export const role = authed.handler(({ context }) =>
context.auth.kind === "user" ? context.auth.claims.user_role : null,
);Permissions
createOrpc takes the server's authorizer option. bs.middleware() and
bs.authed() take the guard options allow, aal and scopes, and the
checks requireTenant, roles, permission and authorize. A
permission is decided by that authorizer, with a resource of
{ type: "route" }:
import { createOrpc } from "better-supabase/orpc";
import * as v from "valibot";
import { authorizer } from "./lib/authorizer";
import { betterSupabase } from "./lib/supabase";
export const bs = createOrpc(betterSupabase, { authorizer });
export const remove = bs
.authed({ requireTenant: true, permission: "customers.delete" })
.input(v.object({ id: v.string() }))
.handler(({ input, context }) =>
bs.unwrap(context.db.customers.delete(input.id)),
);A refused caller gets an ORPCError with code FORBIDDEN and the Problem
Details in data: NO_TENANT, MISSING_ROLE, PERMISSION_DENIED,
APPROVAL_REQUIRED or NOT_AUTHORIZED. Without an authorizer, a procedure
with a permission refuses every caller. See
Authorizers for the contract and
defineAuthorizer.
Errors
bs.unwrap(result) returns the data, or throws an ORPCError when the
Result failed. It accepts a Result, an AsyncResult or a promise of
either, and the procedure's output type is the data type. The middleware also
converts thrown DbExceptions.
The error code comes from the error's HTTP status. For example not_found
becomes NOT_FOUND, conflict becomes CONFLICT, validation becomes
UNPROCESSABLE_CONTENT and stale becomes PRECONDITION_FAILED. The
error's data is the RFC 9457 Problem Details, so clients get the same
kind, code, constraint and issues fields as the HTTP adapters send.
import { safe } from "@orpc/client";
const [error, data] = await safe(client.customers.rename({ id, name }));
if (error?.data?.kind === "conflict") showTaken(error.data.constraint);Last updated on