Imported from rebasepro/rebase (
tooling/rebase-agent-skills/skills/rebase-custom-functions/SKILL.md). Install upstream withnpx skills add rebasepro/rebase --skill rebase-custom-functions. Copyright stays with the author.
Custom API Functions
IMPORTANT FOR AGENTS: Rebase supports auto-discovered custom Hono routes via the
functionsDirconfig option. Do NOT modify the main Honoappinstance or create standalone Express/Fastify servers. Instead, drop a TypeScript file in thefunctions/directory and Rebase will auto-mount it.
Overview
Custom functions let you add arbitrary HTTP endpoints to your Rebase backend. They follow the same file-based discovery pattern as collections and cron jobs:
- Create a TypeScript file in your
backend/functions/directory - Default-export a Hono app (or a factory function that returns one)
- Rebase auto-mounts it at
/api/functions/{filename}
🚨 IMPORT PATH — ALWAYS
@rebasepro/server/functions, NEVER@rebasepro/server. Insidebackend/functions/use the/functionssubpath. It is the portable authoring surface — it pulls in nothing that requires Node, so the function can run on any JavaScript runtime — and it carries typed context accessors (getUser,getDriver) so you never castc.get("user"). The package root reaches the entire framework and is for a server entrypoint, not a route handler. Both work today; only one of them still works later.
Setup
Enable custom functions by adding functionsDir to your backend config:
const backend = await initializeRebaseBackend({
// ... other config
functionsDir: path.resolve(__dirname, "../functions"), // ← add this
});
Creating a Function
Use defineFunction — it gives you a pre-typed Hono app plus the rebase
singleton, and returns exactly the app a hand-written new Hono<HonoEnv>()
would:
// backend/functions/send-invoice.ts
import { defineFunction, requireAuth } from "@rebasepro/server/functions";
export default defineFunction((app, { rebase }) => {
app.post("/", requireAuth, async (c) => {
const { orderId, email } = await c.req.json();
await rebase.email.send({
to: email,
subject: `Invoice for order ${orderId}`,
html: "<p>Thanks for your order.</p>"
});
return c.json({ success: true, message: `Invoice sent to ${email}` });
});
app.get("/status/:id", requireAuth, (c) => {
return c.json({ invoiceId: c.req.param("id"), status: "sent" });
});
});
This auto-mounts as:
POST /api/functions/send-invoiceGET /api/functions/send-invoice/status/:id
The filename (without extension) becomes the route prefix.
Factory Pattern
You can also export a factory function:
// backend/functions/webhooks.ts
import { Hono } from "hono";
import type { HonoEnv } from "@rebasepro/server/functions";
export default function () {
const app = new Hono<HonoEnv>();
app.post("/stripe", async (c) => {
const body = await c.req.text();
// Verify Stripe signature, process event
return c.json({ received: true });
});
app.post("/github", async (c) => {
const payload = await c.req.json();
// Process GitHub webhook
return c.json({ received: true });
});
return app;
}
Guarding Routes
🚨 CRITICAL FOR AGENTS: functions are PUBLIC by default. The router parses the caller's token and puts the result in the context, but does not reject anonymous requests — webhook receivers have no token to send. Reading
getUser(c)is not a check: an anonymous caller getsundefinedand the handler runs anyway. Every route needs a deliberate decision.
// backend/functions/admin-export.ts
import {
defineFunction, requireAuth, requireAdmin, requireRole, requireDriver
} from "@rebasepro/server/functions";
export default defineFunction((app) => {
// 401 for anonymous callers.
app.get("/mine", requireAuth, async (c) => {
// The request-scoped driver runs as the CALLER: RLS applies to them.
// fetchCollection takes ONE object; the collection goes in `path`.
const rows = await requireDriver(c).fetchCollection({
path: "products",
limit: 1000
});
return c.json({ data: rows });
});
// 401 anonymous, then 403 without an administrative role. Order matters.
app.get("/", requireAuth, requireAdmin, async (c) => {
return c.json({ data: await requireDriver(c).fetchCollection({ path: "products" }) });
});
// Any one of the named roles.
app.post("/publish", requireAuth, requireRole("editor", "admin"), (c) => c.json({ ok: true }));
});
Put guards in the route's own middleware slot, as above — not
app.use("/*", requireAuth). use() covers only routes declared below it, so
a route appended later at the bottom of the file is silently unprotected.
Reading the caller
Use the typed accessors, never a cast:
import { defineFunction, getUser, getUserId, getRoles, isAdmin } from "@rebasepro/server/functions";
export default defineFunction((app) => {
app.get("/me", (c) => {
const user = getUser(c); // { uid, roles, ...claims } | undefined
if (!user) return c.json({ error: "Unauthorized" }, 401);
return c.json({ uid: getUserId(c), roles: getRoles(c), admin: isAdmin(c) });
});
});
getUser narrows whatever the middleware resolved: uid is a string, roles is
always an array. getDriver(c) / requireDriver(c) return the caller-scoped
driver; getApiKey(c) and getRequestId(c) are there too.
Configuration — never at module scope
🚨 CRITICAL FOR AGENTS: never write
process.env.Xat the top of a function file. It is evaluated when the file is imported. If the variable is unset, the import throws and the loader reports the whole file as a skipped function — the route 404s with the reason buried in a boot log. Read configuration inside the handler.
import { defineFunction, requireEnv, lazyResource } from "@rebasepro/server/functions";
// Built once, on first use — not at import time.
const apiKey = lazyResource((env) => env.PRICING_API_KEY ?? "");
export default defineFunction((app) => {
app.get("/price", async (c) => {
const endpoint = requireEnv(c, "PRICING_API_URL"); // throws naming the variable
const response = await fetch(endpoint, {
headers: { authorization: `Bearer ${apiKey(c)}` }
});
return c.json(await response.json());
});
});
getEnv(c) returns the whole bag; env(c, "NAME") one value (trimmed, blank =
unset). rebase doctor reports module-scope reads.
Work that outlives the response
🚨 CRITICAL FOR AGENTS: do not leave a floating promise. Use
waitUntil(c, promise). A floating promise is dropped when the process shuts down mid-deploy;waitUntilis what a graceful shutdown waits for, and what an isolate-based host needs to keep the isolate alive past the response.
import { defineFunction, requireAuth, waitUntil } from "@rebasepro/server/functions";
export default defineFunction((app, { rebase }) => {
app.post("/orders", requireAuth, async (c) => {
const order = await c.req.json();
waitUntil(c, rebase.email.send({
to: "warehouse@example.com",
subject: `Order ${order.id}`,
html: "<p>Pick and pack</p>"
}));
return c.json({ received: true }); // caller does not wait
});
});
Use the rebase singleton for platform services — not raw SDKs
🚨 CRITICAL FOR AGENTS: For data, storage, auth, and email inside a function/hook/job, use the configured platform services on the
rebasesingleton. Never import a cloud provider SDK directly to reimplement what the platform already provides.
import { rebase } from "@rebasepro/server/functions";
await rebase.dataAsAdmin.collection<Record<string, unknown>>("orders").find({ where: { status: ["==", "paid"] } });
// `storage` is undefined when the deployment configured none — check, or
// `requireStorage()` and let the 503 say so.
await rebase.storage?.putObject({ key, file }); // → storageUrl (gs://|s3://|local://)
await rebase.email.send({ to, subject, html: "<p>Thanks for your order.</p>" });
| Need | ✅ Use | ❌ Never import directly |
|---|---|---|
| Object storage | rebase.storage (see rebase-storage skill) |
@aws-sdk/client-s3, @google-cloud/storage |
| Database / collections | rebase.dataAsAdmin (or requireDriver(c)) |
pg, drizzle clients by hand |
rebase.email |
nodemailer, provider SDKs |
|
| Auth / users | rebase.auth / getUser(c) |
custom JWT parsing |
Importing a provider SDK hardcodes one backend, bypasses the app's config
(STORAGE_TYPE, DATABASE_URL, SMTP, …), and defeats the point of the platform.
The only code that touches provider SDKs is the adapters inside
@rebasepro/server.
Reserved Identity Values in c.get("user")
The user object set by the auth middleware uses reserved values for system identities:
| Auth Method | user.uid |
user.roles |
|---|---|---|
| JWT (end-user) | Real user ID | User's assigned roles |
| Service Key | "service" |
["admin"] |
| API Key (default) | "api-key:{id}" |
["service"] |
| API Key (admin) | "api-key:{id}" |
["admin", "service"] |
| Anonymous | "anon" |
["anon"] |
TIP: Use these to differentiate internal vs. external callers in your custom functions:
app.get("/sensitive-data", requireAuth, (c) => { const isInternal = getUserId(c) === "service" || isAdmin(c); // Return full or masked data based on identity return c.json({ full: isInternal }); });
Invoking Functions from the Frontend
CRITICAL FOR AGENTS: When calling custom backend functions from the frontend, ALWAYS use
client.functions.invoke(). NEVER use rawfetch(), manually construct URLs, or manually extract auth tokens fromlocalStorage. The SDK handles all of this automatically.
The @rebasepro/client SDK provides a functions namespace that handles:
- Automatic routing — appends
/api/functions/{name}to the client's configuredbaseUrl - Automatic authentication — injects the current session's JWT into the
Authorization: Bearerheader - Standardized errors — throws
RebaseApiErroron non-2xx responses, matching the behavior of collection methods - 401 retry — automatically attempts token refresh on unauthorized responses
Basic Usage
// Invoke a function by name — auth token is injected automatically
const result = await client.functions.invoke<{ job: JobData }>('extract-job', {
url: 'https://example.com/job-posting',
html: htmlContent,
});
console.log(result.job.title);
With Options
// Custom HTTP method
const status = await client.functions.invoke<{ status: string }>('send-invoice', undefined, {
method: 'GET',
path: `status/${invoiceId}`,
});
// DELETE request
await client.functions.invoke('cleanup', { olderThan: '30d' }, {
method: 'DELETE',
});
TypeScript Generics
Use the generic type parameter to get full type safety on the response:
interface ExtractResult {
job: {
title: string;
company_name: string;
description_md: string;
};
}
const result = await client.functions.invoke<ExtractResult>('extract-job', { url });
// result.job.title is typed as string
Error Handling
Errors follow the same pattern as collection methods:
import { RebaseApiError } from '@rebasepro/client';
try {
const result = await client.functions.invoke('process-payment', { orderId });
} catch (err) {
if (err instanceof RebaseApiError) {
console.error(`Status ${err.status}: ${err.message}`);
// err.code and err.details are also available
}
}
❌ Anti-Pattern — Do NOT Do This
// WRONG: Manual fetch with manual URL and manual token extraction
const token = JSON.parse(localStorage.getItem('rebase_auth') || '{}').accessToken;
const res = await fetch(`${apiUrl}/api/functions/extract-job`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ url }),
});
✅ Correct Pattern
// RIGHT: SDK handles URL, auth, Content-Type, and error handling
const result = await client.functions.invoke('extract-job', { url });
Common Use Cases
- Webhook handlers — Stripe, GitHub, Slack, Twilio incoming webhooks
- Payment processing — Custom checkout or subscription logic
- PDF/report generation — Server-side document rendering
- Third-party API integrations — Proxy or aggregate external APIs
- Custom auth flows — Magic links, phone verification, SSO
- Data export — CSV/Excel downloads of collection data
- Health checks — Custom readiness/liveness probes
File Discovery Rules
- Files must be
.tsor.js(not.d.ts, not.test.*) index.ts/index.jsare ignored- Top level only —
functions/admin/users.tsis compiled but never mounted. Flatten it (admin-users.ts) - Each file's default export must be a Hono app or a factory returning one
- The loader uses duck-typing (
fetch()+routesarray) — any Hono-compatible instance works
Runtime Portability
A function is a Hono app, and Hono runs everywhere. What pins a function to a Node process is only what its own file imports and touches. None of this is a restriction on what you may write — every deployment today is Node — but an agent writing a new function should default to the portable choice, because it costs nothing and cannot be retrofitted cheaply.
Portable: everything from @rebasepro/server/functions; requireDriver(c)
and rebase.dataAsAdmin; rebase.auth/storage/email; fetch, URL,
crypto.subtle, TextEncoder.
Node-only: rebase.sql() (owner TCP connection); a directly imported
pg/drizzle-orm/mongodb client; Node built-ins (fs, path, node:crypto,
child_process); packages built on them (jsonwebtoken, nodemailer, sharp,
bcrypt).
Bugs on every runtime: process.env at module scope; floating promises
instead of waitUntil; relying on a handler continuing after its request timed
out.
runtimeKey() / isNodeRuntime() let a function degrade rather than fail.
rebase build records a per-function verdict in the bundle manifest and
rebase doctor reports it without building.
References
- Documentation: rebase.pro/docs
- GitHub: github.com/rebasepro/rebase