Overview
@palbase/web is the Palbase client SDK for the browser. Version 8.0.0 ships one runtime dependency, one bin, six subpath entry points and exactly one entry object: the global pb. You never construct a client and there is no createClient(url, key) — codegen writes palbe.gen.ts from your deployed backend's contract into that environment's own directory under palbase/environments/, and that file calls __configure({ url, apiKey, appId }) from the @palbase/web/internal subpath at import time. Your app imports palbase/client — the barrel that re-exports whichever environment PALBASE_ENV selected — so one side-effect import of the barrel therefore both configures pb and types it with a method for every endpoint you wrote.
Quick example
cd my-web-app
palbase link k3xq81w4m # bind this checkout to a project
palbase link # fetch the contract, install the SDK, generate the client
// src/main.tsx — one import, side-effect only
import '../palbase/client';
import { pb } from '@palbase/web';
await pb.auth.signIn({ email: 'dev@example.com', password: 'correct-horse-battery' });
const todos = await pb.todos.list({ limit: 20 }); // GET /todos?limit=20
const todo = await pb.todos.create({ title: 'Ship it' }); // POST /todos
await pb.todos.update(todo.id, { completed: true }); // PATCH /todos/{id}
There is no URL and no API key in your application code, because both are baked into palbe.gen.ts by the generator. See Codegen for what that file contains and why it is committed.
Install
npm install @palbase/web
| Fact | Value |
|---|---|
| Package | @palbase/web, MIT, "type": "module", dual ESM + CJS |
| Version | 8.0.0 — also exported as VERSION |
| Runtime dependencies | one: livekit-client ^2.19.2 |
| Peer dependencies | next >= 16 and react >= 18, both optional |
| Bin | palbe-gen |
Two corrections to what this page used to say. The package does not have zero runtime dependencies: livekit-client is deliberately kept external because it spawns Web Workers from its own dist paths, so installing @palbase/web pulls that tree even if you never open a call. And the Next.js peer floor is next >= 16, not 15 — the session-refresh adapter ships as a proxy file, which is the Next 16 file convention.
What is bundled: @palbase/core, @palbase/auth and @palbase/flags are development dependencies of this package and are inlined into the published output. You never install them, and you cannot import them through @palbase/web.
Subpaths
@palbase/web pb, BackendError, isBackendError, initMls, VERSION, storage adapters
@palbase/web/internal __configure, __registerNamespaces — what palbe.gen.ts imports
@palbase/web/next pbServer, handleAuthCallback, the session-cookie codec
@palbase/web/next/client setupPalbeNext, cookieSessionStorage
@palbase/web/next/proxy palbeProxy
@palbase/web/react useUser, useSession, useFlag, useFlags, useChannel, the chat hooks
Those six are the whole public surface. In particular there is no @palbase/web/next/middleware subpath and no palbeMiddleware export — the symbol is palbeProxy, from @palbase/web/next/proxy. A few source comments inside the SDK still say otherwise; they are stale. See Next.js.
There is no createClient
Configuration happens once, from the generated file, through the internal subpath:
// palbe.gen.ts — AUTO-GENERATED, do not edit
import { __configure, __registerNamespaces } from '@palbase/web/internal';
__configure({
url: 'https://k3xq81w4m.palbase.studio',
apiKey: 'pb_project_cA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6',
appId: 'app_web',
});
PalbeConfig has exactly six fields, three of them required:
export interface PalbeConfig {
url: string; // required — the Environment's address
apiKey: string; // required — the publishable key
appId: string; // required — the app registration palbase link persisted
oauth?: PalbeOAuthConfig; // google/apple enablement, written by the CLI
storage?: SessionStorageAdapter; // override where the session is persisted
headers?: Record<string, string>; // extra headers on every request
}
__configure replaces the runtime: it tears the old realtime facade down, re-runs session hydration from storage, and notifies anything waiting on configuration. Calling it repeatedly is safe, which is what makes watch-mode regeneration work. The namespace registry survives a re-configure.
Two things it validates, in this order, and nothing else:
- The key must parse as a publishable key, or it throws
PalbeConfig.apiKey does not contain a valid publishable project identity. urlmust parse as a URL, or it throwsPalbeConfig.url must be a valid URL.
Note: Nothing resolves a URL from a key. A client is configured with an explicit
urlandapiKey, and the SDK deliberately does not check that the host matches the key — that check existed once and was removed, because on this platform every project's key carries the same ref while its address carries its own.
The key in the generated file
The publishable key is shaped pb_<stack-ref>_c<20+ base62 characters>, and on the Palbase cloud the stack ref is the compile-time constant project. So every Environment's publishable key reads pb_project_c…, and the client-side parser enforces a floor of 20 base62 characters with no ceiling — platform-minted keys carry 32. Do not write a length check of your own; a client stricter than the server refuses working credentials. Publishable keys are safe to ship in a browser bundle: on their own they carry only the anonymous role. The service-role key (pb_project_s…) must never reach a browser. See the two API keys.
Warning: Because the identity the SDK reads out of a publishable key is the constant
projectfor every Environment, the SDK's browser storage keys arepalbe.session.project,palbe.flags.project,palbe.analytics.id.projectandpalbe.analytics.optout.projectfor every project alike. Two Palbase Environments served from the same origin do collide — signing in against one overwrites the other's session. Put them on different origins.
The pb singleton
pb is one process-global client. It is a Proxy: unknown string keys resolve through the namespace registry codegen filled in, and then is hard-coded to undefined so await pb can never hang. The public interface, verbatim:
export interface PB {
call<O = unknown>(name: string, input?: unknown, options?: CallOptions): Promise<O>;
upload<O = unknown>(name: string, options: UploadOptions): Promise<O>;
readonly auth: PalbeAuth;
readonly flags: PalbeFlags;
readonly realtime: PalbeRealtime;
readonly analytics: PalbeAnalytics;
readonly calls: PalbeCalls;
readonly messaging: PalbeMessaging;
readonly perf: PalbePerf;
setTestDevice(on: boolean): void;
}
| Surface | What it is | Docs |
|---|---|---|
pb.auth | Sign-up, sign-in, sessions, OAuth, OTP, magic links, passkeys | Auth |
pb.flags | Feature flags with live polling and typed getters | Flags |
pb.realtime | WebSocket channels — subscribe and send | Realtime |
pb.analytics | Event capture, identify, screen tracking, opt-out | Analytics |
pb.messaging | End-to-end encrypted DMs and group chats over MLS | Messaging |
pb.calls | Voice and video over WebRTC, scoped to a messaging group | Calls |
pb.perf | Performance traces, web vitals, cold start | below |
pb.<namespace>.<op> | Your generated, typed endpoint calls | Calling Your Backend |
pb.call(name, input?, options?) | Untyped escape hatch — always POST | Calling Your Backend |
pb.upload(name, options) | Multipart upload with progress | Uploads |
pb.setTestDevice(on) | Marks this client's perf traffic as test traffic | below |
pb.flags, pb.realtime, pb.calls and pb.messaging are lazy getters — constructed on first property access, not at __configure time. pb.perf and the analytics identity are eager, because both stamp headers on requests you make before you ever touch them. pb.auth is eager too, though indirectly: building the analytics identity subscribes to auth events, which constructs the auth facade along with it, and in a browser __configure additionally kicks off one background user hydration through it.
initMls() is also exported from @palbase/web. It is an idempotent initializer for the MLS WASM engine, useful to pre-warm messaging; callers do not have to invoke it, because the engine awaits it internally.
There is deliberately no object-storage surface. pb.storage, pb.buckets, pb.bucket, pb.objects and pb.files are all undefined, and a standing test in the SDK keeps them that way. Bytes reach a bucket through an @Upload endpoint you wrote — see Uploads.
Namespaces come from the controller class name
A generated namespace is the controller's class name minus a trailing Controller, first letter lowercased. The base path never enters the namespace:
// modules/inbox/inbox.controller.ts — in your backend
import { Controller, Get } from "@palbase/backend";
import { z } from "zod";
export const Message = z.object({ id: z.string(), body: z.string() });
export type Message = z.infer<typeof Message>;
@Controller("/todos")
export class InboxController {
@Get("")
async list(): Promise<Message[]> {
return [];
}
}
That controller generates pb.inbox.list(), not pb.todos.list(), even though it is mounted at /todos. Name the class after the namespace you want on the client.
Two different reserved sets guard pb, and they do not match. Getting this wrong is the difference between a missing method and an app that will not boot.
The emitter skips a controller whose namespace is auth, analytics, flags or realtime (matched case-insensitively), or call, upload, then or any Object.prototype name (toString, constructor, valueOf, …). A skip is not an error: the namespace never appears on pb, the build succeeds, and the generated file records the reason.
// codegen: skipped reserved namespace "auth"
The runtime reserves nine names on pb — call, upload, auth, flags, realtime, analytics, calls, messaging, perf — and __registerNamespaces throws on every one of them. Three of those nine, calls, messaging and perf, are not in the emitter's skip set. A CallsController, MessagingController or PerfController therefore generates normally, and the registration call at the top level of palbe.gen.ts throws BackendError('validation') with code reserved_namespace the moment the file is imported — the app crashes at startup, on every page.
Rename the class either way.
Methods return values and throw
There are no { data, error } envelopes anywhere on this SDK's public surface. A call resolves with the decoded response body, or throws — a BackendError with one of seven kinds, or a generated per-endpoint error class. See Error Handling.
Every generated method, plus pb.call and pb.upload, accepts a trailing CallOptions:
export interface CallOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}
The request pipeline
Every request pb makes passes through palbeRequest and then the shared HTTP client. You never build one by hand.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
apikey | your publishable key — the key never rides on Authorization |
Authorization | Bearer <accessToken> when a session exists |
X-Platform | web in a browser; the host's own name elsewhere |
X-Client-Info | palbe-web/8.0.0 |
X-Distinct-Id | the analytics distinct id, stamped from the very first request |
X-Palbase-Bundle | window.location.origin — diagnostic only, never an authorization proof |
Idempotency-Key | a random UUID on POST, PUT, PATCH and DELETE |
X-PoW-Challenge-ID, X-PoW-Nonce | a solved proof-of-work challenge, when one was demanded |
A header you set yourself wins: options.headers is merged over the defaults, and supplying your own Idempotency-Key in any casing suppresses the generated one. The interceptors that add X-Distinct-Id and X-Palbase-Bundle check case-insensitively and yield to an explicit value.
Retries
Three attempts, 200 ms exponential backoff, capped at 10 s per sleep.
- Network failure — retried with
200 · 2^attemptms of backoff, up to 3 attempts, then thrown as kindnetwork. A network retry deliberately drops any earned proof-of-work headers: a lost response may mean the server already spent the single-use challenge, and replaying a spent nonce answerspow_invalid. 429— retried honouring theRetry-Afterheader in seconds, clamped to 10 s per sleep; a non-numeric value falls back to exponential backoff. This retry keeps the earned proof-of-work headers, because a 429 is issued instead of the work, so the challenge was never consumed. After three attempts the 429 surfaces as kindrateLimited.401— one refresh and one retry, reusing the sameIdempotency-Keyso a retried mutation can never double-apply. Skipped entirely when the 401's code issession_revoked: that is not a stale token, it is a dead session, and the SDK tears the local session down instead.
Proof of work
The platform can answer a request with 403 and a body of {"error":"pow_required","challenge":{"id","prefix","difficulty"}}. The SDK solves it and repeats the request once with the two X-PoW-* headers — transparently, in both transport layers, so sign-in and sign-up are covered as well as your own endpoints. The solver:
- refuses a
difficultyabove 24 by name; - uses
node:cryptosynchronously off-browser and WebCrypto'scrypto.subtle.digestin a browser, which is roughly 12× slower; - calibrates on this machine before committing, and refuses in milliseconds — naming the numbers — when the estimate exceeds the 120 s time budget, rather than grinding for two minutes;
- allows
8 × 2^difficultyiterations, because finding a nonce is geometric and a flat2^difficultywould fail about 37 % of legitimate hardest-risk challenges; - checks the
AbortSignalinside the loop, every 1024 nonces.
A bare pow_required with no challenge in the body is not retried; looping on a broken gate would turn it into a hang.
Note: There is no DPoP in this SDK. DPoP request signing lives in the client the CLI uses, not in the browser client. Nothing in
@palbase/websigns a request with a bound key.
Note: You do not configure CORS for the web SDK. The API accepts browser requests from any origin — authentication travels in headers rather than cookies to the API host, and the embedded key is publishable, so this is safe by construction.
pb.perf and pb.setTestDevice
Both are members of the public PB interface and both ship today. pb.perf is constructed eagerly on every runtime, because the request path records one network item per call whether or not you ever touch the facade.
class PalbePerf {
startTrace(name: string): PerfTrace;
record(item: PerfItem): void;
recordNetwork(method: string, url: string, status: number, durationMs: number, requestId?: string): void;
flush(): Promise<void>;
setTestDevice(on: boolean): void;
setSamplePct(pct: number): void;
enableFetchCapture(): () => void;
dispose(): void;
}
class PerfTrace {
putAttribute(key: string, value: string): void;
incrementMetric(name: string, by?: number): void;
stop(): void; // idempotent
}
import { pb } from '@palbase/web';
const t = pb.perf.startTrace('checkout');
t.putAttribute('plan', 'pro');
t.incrementMetric('items', 3);
t.stop(); // records one `custom` item, value = elapsed ms
const uninstall = pb.perf.enableFetchCapture(); // opt-in: wrap global fetch
await pb.perf.flush();
uninstall();
What it records, and how:
| Family | Emitted by | Name |
|---|---|---|
network | every pb request, automatically | <METHOD> <redacted url> |
app_start | one cold-start trace at boot, in the browser | cold_start |
web_vital | LCP, CLS, INP, FCP and TTFB observers, in the browser | the vital's name |
custom | startTrace(name).stop() | your name |
- Ingest is
POST /v1/analytics/perfwith a{ items: [...] }body, at most 100 items per request. - Buffering mirrors analytics: in the browser, flush at 20 items or every 10 s; on a server runtime, flush immediately with no timers.
- Fire-and-forget.
recordandrecordNetworknever throw into the caller, andflush()is the only awaited surface — it never rejects. A failed slice is not dropped; it goes to a durable offline queue that drains oldest-first on the next flush and on the browser'sonlineevent, keeping its originalrow_idso a redelivery de-duplicates server-side. - Sampling is server-controlled. The SDK fetches a sample percentage once at init and obeys it as a ceiling;
setSamplePctapplies that value and never raises the rate locally. - URLs are redacted before a network item is recorded, and
/v1/analytics/is self-excluded so a flush cannot trace itself. Aborted requests are not traced — a cancellation is not a measurement. enableFetchCapture()is off by default because swizzling the globalfetchis a page-wide side effect. It returns an uninstall function.
pb.setTestDevice(true) — and the identical pb.perf.setTestDevice(true) — makes perf flushes carry X-Palbase-Test-Device: 1, which the server reads to tag those rows as test traffic. It affects the perf flush only; it does not change how ordinary requests are made, and it is the same header the iOS SDK sends.
Browser vs server
@palbase/web runs in both places and detects which itself — you never pass a flag. It does not use one check for this. Flags, analytics, perf and the cold-start hooks test typeof document !== 'undefined'. Session storage tests typeof localStorage !== 'undefined'. pb.realtime requires both document and window to be absent before it calls an environment a server — Node 22 ships a global WebSocket, so testing for that alone would let server code open real sockets — and then guards separately on typeof WebSocket === 'undefined'.
| Surface | Browser | Server |
|---|---|---|
pb singleton | one runtime per tab | usable for one-off calls, but never sign in from shared server code — use pbServer() |
| Session storage | localStorage, under palbe.session.project | in-memory, or request cookies via the Next.js adapter |
pb.flags | snapshot plus 30 s delta polling, paused while the tab is hidden | never polls; ready() and refresh() are one-shot fetches |
pb.realtime | one shared WebSocket for every channel | channel() throws validation / realtime_unavailable |
pb.analytics | buffered — flushes at 20 events or every 10 s | one immediate request per call, zero timers |
Two more asymmetries worth knowing: pb.messaging's MLS store falls back to in-memory off-browser, so nothing persists there; and pb.upload bypasses the shared transport entirely in order to stream multipart bodies, which is why it has no proof-of-work retry, no reactive-401 retry and no perf trace.
For per-request isolation in a Next.js app, pbServer() builds a fresh runtime per call over the request's cookie store and hands back a runtime-bound PB, so two concurrent requests cannot leak each other's sessions. See Next.js.
The wrapper-module pattern
pb only works once the palbase/client barrel — which re-exports the environment's generated palbe.gen.ts — has been imported somewhere in the running module graph. Touching it before that throws a BackendError of kind notConfigured, whose message reads, verbatim:
Palbe is not configured. Run 'palbase link' in your project and make sure palbe.gen.ts is imported once at app startup.
The reliable fix is a small wrapper module that every consumer imports instead of importing @palbase/web directly, so configuration is guaranteed to have run:
// lib/palbe.ts
import '../palbase/client'; // side effect: configures pb + registers typed namespaces
export { pb, BackendError, isBackendError } from '@palbase/web';
export type { TodosListResponse, TodosCreateRequest } from '../palbase/client';
// anywhere in your app
import { pb } from '@/lib/palbe';
const todos = await pb.todos.list({ limit: 20 });
palbase link also splices an import of the palbase/client barrel into your detected app entry, which covers framework entry points. The wrapper covers everything the entry does not: tests, scripts, and isolated module graphs — most importantly the Next.js proxy.ts, which Next compiles into its own bundle. The proxy does not use the wrapper or the client barrel at all: it imports palbase/config, an import-free leaf the generator rewrites for every PALBASE_ENV, so it follows the same environment switch without dragging the runtime into a request-path bundle.
Related
- Codegen —
palbase link,palbe-gen, and the anatomy ofpalbe.gen.ts - Calling Your Backend — typed namespaces, argument order,
pb.call - Error Handling — the seven
BackendErrorkinds and the generated error classes - Auth — sessions, OAuth, OTP, magic links, passkeys
- Uploads — multipart uploads with progress and constraints
- Next.js —
pbServer,palbeProxy, and the OAuth callback route - React Hooks —
useUser,useSession,useFlag,useChannel - the two API keys — the two keys an Environment is minted with
- Linking a Checkout —
palbase link, and what it writes