# @palbase/web

The Palbase client SDK for web. One import, one entry point: **`pb`**.

`@palbase/web` is typed against **your own backend**. You run `palbase link` once, it
generates a typed client from your deployed endpoints, and from then on
`pb.todos.create(...)`, `pb.rooms.list(...)` and friends are fully typed — request
shapes, response shapes and per-endpoint errors, all inferred from the backend you
shipped. Auth, feature flags, realtime and analytics come built in.

```ts
import { pb } from '@palbase/web';

await pb.auth.signIn({ email, password });
const todo = await pb.todos.create({ title: 'Ship it' }); // typed by your backend
```

---

## Install

```bash
npm i @palbase/web
```

Then install the Palbase CLI and link your project — the CLI generates the typed client:

```bash
brew install palgroup/tap/palbase
palbase link <project>
```

`palbase link` creates ONE visible directory in your checkout — `palbase/` —
runs `palbe-gen` when `@palbase/web` is installed, wires the generated client
into your app entry, and adds a `predev`/`prebuild` hook that keeps it in sync.

Everything that belongs to one environment lives together in that environment's
own directory, and `palbase/client.ts` is the single line your application
imports:

```text
palbase/
  client.ts                 the one import your app makes (a re-export)
  environments/
    local/                  the stack `palbase start` runs on this machine
      openapi.json          the contract
      web-config.json       this environment's url + publishable key
      palbe.gen.ts          the generated typed client
    main/
      …                     same four files, for the deployed environment
```

Commit all of it. Nothing under `palbase/` is a build artifact: the generated
client is source your app imports, so it shows up in autocomplete before any
build, changes arrive as a reviewable `git diff`, and a fresh clone builds with
no CLI and no network.

The generated client calls `__configure(...)` with the linked Environment ref,
URL, app ID, and publishable API key at module load, so importing
`palbase/client.ts` once at startup is all the setup there is.
If you forget to import it, every `pb` call throws a guided `BackendError`
(`kind: 'notConfigured'`).

> Regenerate any time your backend changes: `npx palbe-gen` (or just run
> `dev`/`build` — the hook does it for you).

### Which environment a build talks to

**One key, and its default is `local`.** `PALBASE_ENV` selects the environment;
unset means `local`, the stack every checkout gets for free. Set it wherever you
already set environment variables — an npm script, a `.env` file, your CI:

```bash
PALBASE_ENV=main npm run build
```

`palbe-gen` reads it, generates into that environment's own directory, and
rewrites `palbase/client.ts` to re-export that environment's client. Your
import never carries an environment name, so switching environments never edits
your application's source — and because a bundler follows the re-export, only
the selected environment's code reaches your bundle.

---

## Calling your backend

Codegen turns each backend controller into a typed namespace on `pb`. The
operation id is `<controller>.<method>` — `TodosController.create` becomes
`pb.todos.create(input)`, `HelloController.greet` becomes `pb.hello.greet()`.
Path params come first, the request body/query second:

```ts
import { pb } from '@palbase/web';

// POST /todos/create  — body typed, response typed
const todo = await pb.todos.create({ title: 'Buy milk', done: false });

// GET /todos/{id}     — path param, then options
const one = await pb.todos.get('todo_123');

// GET /todos          — typed query object
const list = await pb.todos.list({ limit: 20, done: false });
```

### Escape hatches

When you need to call an endpoint that isn't in the generated types yet (or do a
raw multipart upload), use `pb.call` and `pb.upload`:

```ts
// Untyped POST — you supply the response type
const result = await pb.call<{ ok: boolean }>('todos/archive', { id: 'todo_123' });

// Multipart upload with progress
const uploaded = await pb.upload<{ url: string }>('files/avatar', {
  file: blob,
  filename: 'avatar.png',
  onProgress: ({ sent, total }) => console.log(sent / total),
});
```

---

## Auth

`pb.auth` is the full authentication surface — email/password, OTP, magic links,
OAuth, password reset, email verification:

```ts
await pb.auth.signUp({ email: 'a@b.com', password: 'secret123' });
const { user, session } = await pb.auth.signIn({ email: 'a@b.com', password: 'secret123' });
await pb.auth.signOut();

// React to session changes
const unsub = pb.auth.onAuthStateChange((state) => {
  console.log(state.status); // 'signedIn' | 'signedOut'
});

pb.auth.isSignedIn;     // boolean (token presence)
pb.auth.currentUser;    // AuthUser | null
await pb.auth.refreshUser();   // re-fetch the profile (e.g. emailVerified flip)
```

Phone OTP, magic links and OAuth:

```ts
await pb.auth.signInWithOTP({ phone: '+1555…' });
await pb.auth.verifyOTP({ phone: '+1555…', token: '123456' });

await pb.auth.signInWithMagicLink('a@b.com');

// Browser flow: complete on a browser callback page with the same tab storage.
await pb.auth.signInWithOAuth({ provider: 'google', redirectTo: 'https://app.example.com/auth/callback' });
```

OAuth uses the `oauth` snapshot emitted by `palbase link`, selecting a browser
client for this app and variant. Google, Apple, Microsoft and GitHub share the
same API. The callback receives `transaction_id` and `result_code`:

```ts
await pb.auth.exchangeCodeForSession({ transactionId, resultCode, callbackURL: location.href });
```

The result is `signedIn`, `mfaRequired`, or `linked`; only `signedIn` installs a
session. Proofs are scoped to this browser tab, backend and app. A transient
failure can be retried with `completeOAuth({ transactionId })`, which reuses the
stored result. Start explicit account linking with `linkIdentity`; accounts are
never merged solely because their email addresses match.

For a Next.js `handleAuthCallback` route, begin the transaction with
`(await pbServer()).auth.beginOAuth(...)` in a Server Action or Route Handler.
That stores its proof in a transaction-specific HttpOnly cookie. The default MFA
response is uncached HTTP 202 JSON; `onMFARequired` can render your MFA handoff.
Browser session-storage proofs cannot be read by a server callback.


---

## Feature flags

`pb.flags` polls and caches your project's feature flags (auth-aware — flags
re-evaluate when the user signs in/out):

```ts
if (pb.flags.isEnabled('new-checkout')) { /* … */ }

pb.flags.getString('theme', 'light');
pb.flags.getInt('max-items', 10);

await pb.flags.getVariant('pricing-experiment'); // multivariate → variant name | null

const off = pb.flags.onChange(() => console.log('flags changed', pb.flags.all()));
```

---

## Realtime

`pb.realtime` multiplexes every subscription over one auto-reconnecting
WebSocket. Channels are client-only (browser):

```ts
const room = pb.realtime.channel('room:42');

const sub = room.on('message', (payload) => {
  console.log('got', payload);
});

room.send('message', { text: 'hello' }); // broadcast to other subscribers

sub.cancel(); // last cancel on a channel leaves it
```

Connection status is observable (`pb.realtime.status.state` /
`pb.realtime.status.onChange(...)`).

---

## Analytics

`pb.analytics` buffers events client-side and flushes in batches. It manages an
anonymous distinct id and stitches it to the user on sign-in automatically:

```ts
pb.analytics.capture('checkout_started', { plan: 'pro' });
pb.analytics.screen('Dashboard');
pb.analytics.identify('user_123', { email: 'a@b.com' });
await pb.analytics.flush();      // force-send buffered events
pb.analytics.setOptOut(true);    // GDPR opt-out (drops pending + future)
```

---

## Errors

Every failing call throws a `BackendError`. Inspect `kind` (a coarse category)
and `code` (the server's machine code):

```ts
import { pb, BackendError, isBackendError } from '@palbase/web';

try {
  await pb.todos.create({ title: '' });
} catch (e) {
  if (isBackendError(e)) {
    e.kind;       // 'validation' | 'unauthorized' | 'rateLimited' | 'notConfigured'
                  //   | 'network' | 'server' | 'decode'
    e.code;       // server machine code, e.g. 'invalid_input'
    e.status;     // HTTP status
    e.fields;     // field-level validation errors (when kind === 'validation')
    e.retryAfter; // seconds (when kind === 'rateLimited')
  }
}
```

Generated code also gives you **typed per-endpoint error classes** (e.g.
`RoomsCreateRoomLockedError`) with typed `.data`, so you can `instanceof`-match the
specific failures a given endpoint documents.

---

## Next.js — `@palbase/web/next`

The Next.js App Router adapter shares one session cookie between the browser and
the server so Server Components, Route Handlers and the proxy all see the same
auth state. Requires `next >= 16` (the `proxy` file convention).

**1. Configure the browser client** (a client provider/component):

```tsx
'use client';
import { setupPalbeNext } from '@palbase/web/next/client';
import { useEffect } from 'react';

export function PalbeProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => { setupPalbeNext(); }, []);
  return <>{children}</>;
}
```

`setupPalbeNext()` swaps in `cookieSessionStorage` so the session is written to a
cookie the server can read.

**2. Refresh the session in the proxy** (required for session-bearing RSC apps):

```ts
// proxy.ts
import { palbeProxy } from '@palbase/web/next/proxy';
import { environmentConfig } from './palbase/config';
import type { NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
  return palbeProxy(request, environmentConfig);
}

export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] };
```

`palbase link` writes this file for you, and it carries **no address and no
key** — only an import. `palbe-gen` writes `palbase/config.ts` on every run
from `PALBASE_ENV`, the same switch that selects the generated client, so the
proxy and the data client can no longer point at different stacks. Nothing in
your app holds an environment value; you choose the environment, the SDK does
the wiring.

Two rules the shape above encodes:

- **Import `@palbase/web/next/proxy`, not `@palbase/web/next`.** Next compiles
  the proxy into its own bundle, and the full adapter graph reaches
  `livekit-client` and the MLS WASM loader — megabytes on every request path.
  This entry reaches none of it.
- **Import `palbase/config`, never `palbase/client`.** The config barrel sits
  over an import-free leaf, so it brings nothing with it; the client barrel
  re-exports `palbe.gen.ts`, which calls `__configure` at import time and pulls
  that whole graph back in. It also means `config`/`matcher` must be declared in
  this file — Next reads `export const config` off this file's own AST, so a
  re-exported or imported one is silently ignored and the proxy degrades to
  matching every request.

**3. Read data in Server Components** with `pbServer()` — a per-request,
session-isolated client:

```tsx
import { pbServer } from '@palbase/web/next';

export default async function Page() {
  const pb = await pbServer();
  const todos = await pb.todos.list({ limit: 20 });
  return <TodoList items={todos} />;
}
```

**4. Complete OAuth** with a callback route handler:

```ts
// app/auth/callback/route.ts
import '@/palbase/client';
import { handleAuthCallback } from '@palbase/web/next';
export const GET = handleAuthCallback({ defaultNext: '/' });
```

---

## React — `@palbase/web/react`

Thin, concurrent-safe hooks over the observable `pb.*` facades. `react` is an
**optional** peer dependency — importing `@palbase/web` never pulls React; only
`@palbase/web/react` does.

```tsx
'use client';
import { useUser, useSession, useFlag, useFlags, useChannel } from '@palbase/web/react';

function Profile() {
  const user = useUser();                 // AuthUser | null, re-renders on auth change
  const { signedIn } = useSession();      // { signedIn, user }
  const dark = useFlag('dark-mode', false);
  const flags = useFlags();               // whole flag set

  // Subscribe to a realtime event for the component's lifetime
  const { status } = useChannel('room:42', 'message', (payload) => {
    console.log(payload);
  });

  return signedIn ? <span>{user?.email}</span> : <SignInButton />;
}
```

---

## License

MIT
