Calling Your Backend
Your own backend runs on a machine, at an address of its own: https://<ref>.app.palbase.studio for the machine named backend, https://<name>-<ref>.app.palbase.studio for any other. The app calls it through its client. palbase apps:sync reads the contract the backend serves (a palbase/backend app writes one of its routes, their schemas and the errors they throw; another program serves its own OpenAPI document) and writes the app a module in which each route is a typed method: pb.backend.notes.create({ title }). The call goes from the browser to the machine's address with the signed-in user's access token. A machine asks for no API key, and the client never sends it one: your routes are the gate.
Quick example
The backend: a route, and the origins its browsers come from.
// server/src/notes/notes.controller.ts
import { Body, Controller, Get, Post, User, z, type UserT } from "palbase/backend";
const CreateNote = z.object({ title: z.string().min(1).max(200) });
type CreateNote = z.infer<typeof CreateNote>;
interface Note {
id: string;
owner: string;
title: string;
}
@Controller("/notes")
export class NotesController {
private readonly notes: Note[] = [];
@Get("")
list(@User() user: UserT): Note[] {
return this.notes.filter((n) => n.owner === user.id);
}
@Post("")
create(@Body(CreateNote) body: CreateNote, @User() user: UserT): Note {
const note = { id: crypto.randomUUID(), owner: user.id, title: body.title };
this.notes.push(note);
return note;
}
}
// server/src/app.module.ts
import { Module } from "palbase/backend";
import { NotesController } from "./notes/notes.controller";
@Module({ controllers: [NotesController] })
export class AppModule {}
// server/src/main.ts
import { PalbaseFactory } from "palbase/backend";
import { AppModule } from "./app.module";
// Browsers on this origin may call the machine: preflights are answered, CORS headers are sent.
await PalbaseFactory.create(AppModule, { cors: { origins: ["https://app.example.com"] } });
Once the backend is deployed, write the app its module from the checkout's root:
palbase apps:sync shop-web
The app imports pb from it, and the backend is pb.backend:
// src/notes.ts
import { pb } from "../palbase/shop-web";
// The user signs in with the project's auth; the backend's calls carry their token.
await pb.auth.signIn("ada@example.com", "correct-horse-battery-staple");
const created = await pb.backend.notes.create({ title: "Ship the docs" });
const mine = await pb.backend.notes.list();
console.log(created.id, mine.length);
What palbase apps:sync wrote, cut to this page's routes and put in one file:
// palbase/shop-web.ts
// Code generated by palbase apps:sync — DO NOT EDIT. (Cut to this page's routes.)
import { createClient, type BackendBase, type CallOptions, type ClientOptions, type PalbaseClient } from "palbase/web";
import { __registerBackend } from "palbase/web/palbe";
export interface NotesCreateRequest {
title: string;
}
export interface NotesCreateResponse {
id: string;
owner: string;
title: string;
}
export interface NotesListResponseItem {
id: string;
owner: string;
title: string;
}
export type NotesListResponse = NotesListResponseItem[];
// The routes, for the clients of this environment's project alone.
__registerBackend("https://k3xq81w4m.palbase.studio", "backend", "backend", "https://k3xq81w4m.app.palbase.studio", {
notes: {
create: { method: "POST", path: "/notes" },
list: { method: "GET", path: "/notes", input: "none" },
},
});
export interface Backend extends BackendBase {
notes: {
create(input: NotesCreateRequest, options?: CallOptions): Promise<NotesCreateResponse>;
list(options?: CallOptions): Promise<NotesListResponse>;
};
}
export const settings = {
url: "https://k3xq81w4m.palbase.studio",
publishableKey: "pb_k3xq81w4m_cZ8yX7wV6uT5",
environment: "staging",
app: "shop-web",
appId: "0b6f2c1e-8d4a-4f7b-9c3e-5a1d2e3f4b6c",
} as const;
/** The app's client: its project's modules (pb.auth, …) and its backends, pb.backend. */
export type Client = PalbaseClient & { backend: Backend };
let client: Client | undefined;
/** The app's client, made once with these settings — and with options, when connect(options) is called before anything uses the client (pb). */
export function connect(options: Omit<ClientOptions, "url" | "publishableKey"> = {}): Client {
if (client) {
if (Object.keys(options).length > 0) throw new Error("palbase: connect(options) comes before the client is first used — call it as the app starts");
return client;
}
client = createClient({ ...options, url: settings.url, publishableKey: settings.publishableKey, appId: settings.appId }) as Client;
return client;
}
/** The app's client, made when it is first used: pb.<backend>.<controller>.<method>(), pb.auth, … */
export const pb: Client = new Proxy({} as Client, {
get(_, key) {
const c = connect();
const value: unknown = Reflect.get(c, key, c);
return typeof value === "function" ? value.bind(c) : value;
},
has: (_, key) => Reflect.has(connect(), key),
});
The generated module
palbase apps:sync writes under palbase/, from what the project serves now, and touches nothing else in the checkout. For a web app, palbase/<app>.ts is what the app imports: its settings, connect() and pb, typed with each backend of the environment, and with one backend that backend's types, typed errors and gateways too (with several, each one's are in palbase/palbe/<machine>.ts). Each environment's own files sit beside them, which a build for that environment imports instead (Building for an environment). Every file it writes, the editor's environment, CI's --check and its refusals are in Syncing an App's Settings; registering the app is Client Apps.
Run it again when the backend's routes change: a call the backend no longer takes is then a compile error in the app. To try a route no machine serves yet, --backend <machine>=<dir> makes the client from the backend's checkout on this computer (palbase apps:sync).
The backend's name. pb.<backend> is the machine's name camelCased: pb.backend for the machine named backend, pb.shopApi for shop-api. palbase apps:sync --as shop-api=shop names it otherwise, the same in every environment. A name the client already has (auth, flags, storage, …) is refused.
A route's method. A palbase/backend route is its controller's name without Controller, then its method's: NotesController.create is pb.backend.notes.create. The arguments are, in order: each path parameter (/notes/{id} takes id first), the input (the body, or for a GET the query its schema declares), then the options:
| Option | What it is |
|---|---|
headers | Headers of your own, sent with the call. |
signal | An AbortSignal that cancels it. |
A route the generated types do not have yet is pb.backend.call("notes/create", input, options?): it POSTs the input as JSON and answers the route's JSON, untyped.
Options of the client. pb makes its client the first time it is used, with the settings of its environment. Give it options of createClient (analytics, consent, language, appVersion, …) with connect(options) as the app starts, before anything reads pb:
// src/main.ts
import { connect } from "../palbase/shop-web";
connect({ analytics: true, appVersion: "2.4.1", consent: { start: { analytics: "denied", performance: "denied" } } });
A backend is its own project's: the module registers it for the clients of its environment's project alone. A client made for another project that reads pb.backend throws a TypeError before anything is sent, so one project's users never send their token to another project's machine.
Building for an environment
Each environment's backend has its own routes and its own address, so an app is built against one environment. In a Vite app, palbase/web/vite picks it by the build's mode:
// vite.config.ts
import { palbase } from "palbase/web/vite";
export default {
plugins: [
// vite (development) is built against staging; vite build (production) against main.
palbase({ app: "shop-web", environments: { development: "staging", production: "main" } }),
],
};
In each mode the app's imports of palbase/<app> and palbase/palbe/<machine> are that environment's files. A mode the plugin is given no environment for fails the build and says so. Before vite build, the plugin type-checks the app against its environment with the app's own TypeScript and tsconfig, so a route that environment's backend does not have, or a call that no longer fits its types, fails the build instead of the app.
| Option | What it is |
|---|---|
app | The app's slug, as palbase apps:list shows it. |
environments | Each mode the app is built in (vite --mode), and the environment it is built against. |
dir | Where palbase apps:sync writes. Default: palbase. |
tsconfig | The app's tsconfig, from its root. Default: tsconfig.app.json where there is one, else tsconfig.json. |
typecheck | Type-check the app before a build. Default: true. |
With another bundler, palbase/<app>.ts is the --types environment's, and palbase/<app>.<env>.ts is each environment's to import for a build of that environment.
The token
Every call carries the signed-in user's access token as Authorization: Bearer …, and nothing when no one is signed in: the route decides whom it answers. The token is the session's, the one pb.auth keeps and refreshes. A token the machine refuses before its time is refreshed once and the call is sent again.
A write (POST, PUT, PATCH, DELETE) carries an Idempotency-Key the client makes, the same one on every attempt of that call. The client sends a call again on its own as it does the project's calls (Error Handling): a read, or a write whose route keeps the key, never another.
A palbase/backend app checks the token on every call that sends one: the signature against the project's keys, and that the session still stands. A route that needs a user answers:
| Answer | When |
|---|---|
401 unauthorized | No token. |
401 invalid_token | A token that is not valid, or an Authorization header that is not a Bearer token. data.reason says which. |
403 email_not_verified | The route asks for a verified email (auth: { verifiedEmail: true }) and the user's is not. |
503 auth_unavailable | The token could not be checked just now. Retry-After: 1, and sending again is safe. |
A route marked open ({ auth: false }) answers anyone. On such a route a bad token is no one, not a refusal. See Authentication.
What comes back
A route's answer is its JSON, typed by its contract, or undefined for a route that answers nothing (204). A refusal rejects the call with a BackendError:
| Field | What it is |
|---|---|
kind | validation (a 400 with the fields that failed, or a call the client refused before sending it), unauthorized (401), rateLimited (429), network (no answer came), decode (an answer that is not JSON), server (every other refusal). |
code | The envelope's error: bad_request, not_found, your own note_locked, … |
status | The HTTP status; 0 when no answer came. |
message | The envelope's error_description. |
fields | A 400's fields: { field, message } for each. |
retryAfter | Seconds to wait, when the answer said. |
requestId | The request's id. Quote it to find the call in the machine's logs. |
data | The envelope's data, as the route sent it. |
An error the route throws that its contract names (palbase/backend reads them from the route's source as it writes the contract) comes as its own generated class instead, with its code, status, typed data and the BackendError as its cause. See Responses & Errors.
// src/create-note.ts
import { isBackendError } from "palbase/web";
import { pb } from "../palbase/shop-web";
export async function createNote(title: string): Promise<string> {
try {
const note = await pb.backend.notes.create({ title });
return `created ${note.id}`;
} catch (e) {
if (!isBackendError(e)) throw e;
switch (e.kind) {
case "validation":
return e.fields?.map((f) => `${f.field}: ${f.message}`).join(", ") ?? e.message;
case "unauthorized":
return "sign in again";
case "rateLimited":
return `too many notes; try again in ${e.retryAfter ?? 1} s`;
default:
return `not created: ${e.code} (${e.requestId ?? "no request id"})`;
}
}
}
isBackendError(e) holds where instanceof BackendError would not: two copies of the package loaded side by side make two classes.
A call the client refuses before anything is sent is a BackendError of kind validation with status 0: a path parameter that is missing, empty, . or .. (missing_path_param, invalid_path_parameter), an input for a route that takes none (unexpected_argument), a body for a GET (unsupported_get_input), a query value that is an object or an array (invalid_query_value), an upload's file that is not a Blob (invalid_file).
Every refusal of a palbase/backend app is the platform's envelope, whatever the route:
{
"error": "bad_request",
"error_description": "Request body failed validation",
"status": 400,
"request_id": "req_6f1c2b9e-…",
"data": { "fields": [{ "field": "title", "message": "Invalid input: expected string, received undefined" }] }
}
| Status | error | What it means |
|---|---|---|
| 400 | bad_request | A parameter failed its schema. data.fields names each, 20 at most, and data.more counts the rest. |
| 401, 403 | permission_denied | The database refused the caller: a row-level security policy, or a table their role has no grant on. 403 to a signed-in user, 401 to no one, for whom signing in may be what is missing. See Row-Level Security. |
| 404 | not_found | No route for the method and path, or the handler said so. |
| 413 | payload_too_large | A body over the app's limit (1 MiB unless the app says more). |
| 429 | too_many_requests | Over the route's limit for this caller. Retry-After and data.retryAfter say when to try again. |
| 503 | service_unavailable, database_unavailable, auth_unavailable | Something the route needs did not answer. Retry-After says when to come back. |
| 500 | internal_error | The handler threw something that is not a refusal. The details are in the machine's logs under the request_id. |
Encrypted fields
A field your route marks encrypted() — or answers through encrypt() — the module seals before the call leaves the browser and opens when its answer arrives: the network panel shows pbt1.…, your code the value, and the calls are written as any other. The module holds the project's id and signing key for it, which palbase apps:sync writes. What can be marked, the keys and their rotation, and the errors are in Encrypted Requests.
Cross-origin calls
A machine's address is not your app's origin, so the browser sends a preflight before each call that carries a token or a JSON body. A palbase/backend app answers it for the origins of the project's web apps by itself: those you give with palbase apps:env:set <app> --origin. Name any others in PalbaseFactory.create(…, { cors }):
cors | Effect |
|---|---|
{ origins: ["https://app.example.com"] } | Exact origins: https://host[:port], http only for localhost and 127.0.0.1. A preflight from another origin is answered 403. |
{ origins: "*" } | Any origin. The credential is a bearer token, not a cookie, so * hands nothing to another site by itself. |
maxAge | Seconds a browser keeps a preflight's answer (default 600). |
| left out | The project's web apps' origins alone. With no web app in the project, no CORS at all: a browser on another origin cannot call the machine. |
The machine is started with the web apps' origins in PALBASE_WEB_ORIGINS (comma-separated), so a backend without palbase/backend can read them too. A web app's origin added or taken away rolls the machine out again with no downtime, within about 15 seconds.
The answer exposes retry-after and x-request-id to your code. Requested headers are allowed as asked, so a header of your own needs no setting.
This is the backend's own CORS: the platform adds no header at a machine's address. The project's address is a separate matter: it answers a page on any site by itself. See Overview.
A backend without a contract
palbase apps:sync makes a client from the contract the machine serves. A palbase/backend app writes its own; a program built another way that serves an OpenAPI document names where with palbase machines:update <machine> --openapi-path <path>. A machine with no contract gets no client, and the app calls it with fetch and the user's token:
// src/lib/api.ts
import { pb } from "./palbase";
const BACKEND = "https://k3xq81w4m.app.palbase.studio";
/** One call to the machine, as the signed-in user. */
export async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
const token = (await pb.auth.currentSession())?.accessToken;
const res = await fetch(`${BACKEND}${path}`, {
method,
headers: {
...(token ? { authorization: `Bearer ${token}` } : {}),
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${method} ${path} answered ${res.status}`);
return (res.status === 204 ? undefined : await res.json()) as T;
}
// src/lib/palbase.ts
import { createClient } from "palbase/web";
export const pb = createClient({ url: "https://k3xq81w4m.palbase.studio", publishableKey: "pb_k3xq81w4m_cZ8yX7wV6uT5" });
currentSession() answers the session the client holds, which it refreshes before it lapses. A token the backend refuses with 401 before its time (revoked, refreshed in another tab) is worth pb.auth.refreshSession() and one more try.
A machine that sleeps
A machine of a shared size sleeps when it is idle, and the next request wakes it. That request waits while the machine starts, so the first call after a quiet spell is slower than the rest. A machine of a dedicated size never sleeps. See Machines.
Related
- Overview — the client, the session and calling the project from a browser
- Gateways — your backend's realtime channels, from the same module
- Uploads — sending a file to your backend's upload route
- Auth — signing the user in
- Error Handling —
PalbaseError, and what the client retries - Controllers & Routing — the routes this page calls
- Authentication — how the machine checks the token
- Machines — addresses, sizes, sleep and wake