Palbase
Sign inGet started

Web SDK

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
FactValue
Package@palbase/web, MIT, "type": "module", dual ESM + CJS
Version8.0.0 — also exported as VERSION
Runtime dependenciesone: livekit-client ^2.19.2
Peer dependenciesnext >= 16 and react >= 18, both optional
Binpalbe-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:

  1. The key must parse as a publishable key, or it throws PalbeConfig.apiKey does not contain a valid publishable project identity.
  2. url must parse as a URL, or it throws PalbeConfig.url must be a valid URL.

Note: Nothing resolves a URL from a key. A client is configured with an explicit url and apiKey, 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 project for every Environment, the SDK's browser storage keys are palbe.session.project, palbe.flags.project, palbe.analytics.id.project and palbe.analytics.optout.project for 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;
}
SurfaceWhat it isDocs
pb.authSign-up, sign-in, sessions, OAuth, OTP, magic links, passkeysAuth
pb.flagsFeature flags with live polling and typed gettersFlags
pb.realtimeWebSocket channels — subscribe and sendRealtime
pb.analyticsEvent capture, identify, screen tracking, opt-outAnalytics
pb.messagingEnd-to-end encrypted DMs and group chats over MLSMessaging
pb.callsVoice and video over WebRTC, scoped to a messaging groupCalls
pb.perfPerformance traces, web vitals, cold startbelow
pb.<namespace>.<op>Your generated, typed endpoint callsCalling Your Backend
pb.call(name, input?, options?)Untyped escape hatch — always POSTCalling Your Backend
pb.upload(name, options)Multipart upload with progressUploads
pb.setTestDevice(on)Marks this client's perf traffic as test trafficbelow

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 pbcall, 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

HeaderValue
Content-Typeapplication/json
apikeyyour publishable key — the key never rides on Authorization
AuthorizationBearer <accessToken> when a session exists
X-Platformweb in a browser; the host's own name elsewhere
X-Client-Infopalbe-web/8.0.0
X-Distinct-Idthe analytics distinct id, stamped from the very first request
X-Palbase-Bundlewindow.location.origin — diagnostic only, never an authorization proof
Idempotency-Keya random UUID on POST, PUT, PATCH and DELETE
X-PoW-Challenge-ID, X-PoW-Noncea 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^attempt ms of backoff, up to 3 attempts, then thrown as kind network. 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 answers pow_invalid.
  • 429 — retried honouring the Retry-After header 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 kind rateLimited.
  • 401 — one refresh and one retry, reusing the same Idempotency-Key so a retried mutation can never double-apply. Skipped entirely when the 401's code is session_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 difficulty above 24 by name;
  • uses node:crypto synchronously off-browser and WebCrypto's crypto.subtle.digest in 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^difficulty iterations, because finding a nonce is geometric and a flat 2^difficulty would fail about 37 % of legitimate hardest-risk challenges;
  • checks the AbortSignal inside 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/web signs 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:

FamilyEmitted byName
networkevery pb request, automatically<METHOD> <redacted url>
app_startone cold-start trace at boot, in the browsercold_start
web_vitalLCP, CLS, INP, FCP and TTFB observers, in the browserthe vital's name
customstartTrace(name).stop()your name
  • Ingest is POST /v1/analytics/perf with 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. record and recordNetwork never throw into the caller, and flush() 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's online event, keeping its original row_id so 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; setSamplePct applies 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 global fetch is 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'.

SurfaceBrowserServer
pb singletonone runtime per tabusable for one-off calls, but never sign in from shared server code — use pbServer()
Session storagelocalStorage, under palbe.session.projectin-memory, or request cookies via the Next.js adapter
pb.flagssnapshot plus 30 s delta polling, paused while the tab is hiddennever polls; ready() and refresh() are one-shot fetches
pb.realtimeone shared WebSocket for every channelchannel() throws validation / realtime_unavailable
pb.analyticsbuffered — flushes at 20 events or every 10 sone 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.

  • Codegenpalbase link, palbe-gen, and the anatomy of palbe.gen.ts
  • Calling Your Backend — typed namespaces, argument order, pb.call
  • Error Handling — the seven BackendError kinds and the generated error classes
  • Auth — sessions, OAuth, OTP, magic links, passkeys
  • Uploads — multipart uploads with progress and constraints
  • Next.jspbServer, palbeProxy, and the OAuth callback route
  • React HooksuseUser, useSession, useFlag, useChannel
  • the two API keys — the two keys an Environment is minted with
  • Linking a Checkoutpalbase link, and what it writes