# connectbase-client

Connect Base JavaScript/TypeScript SDK for building real-time multiplayer games and applications.

## Installation

```bash
npm install connectbase-client
# or
pnpm add connectbase-client
# or
yarn add connectbase-client
```

## Key Types

Connect Base provides **two types** of Keys. Use the right key for your use case:

| Type | Prefix | Use For | Permissions | Safe to Expose? |
|------|--------|---------|-------------|-----------------|
| **Public Key** | `cb_pk_` | SDK / Web apps | Limited (RLS enforced) | ✅ Yes — safe in frontend code |
| **Secret Key** | `cb_sk_` | MCP / Admin tools | Full access (bypasses RLS) | ❌ **Never expose in frontend or public repos** |

**Which key should I use?**

| Context | Key Type | Example |
|---------|----------|---------|
| Frontend SDK (`new ConnectBase()`) | **Public Key** (`cb_pk_`) | Web/app: DB queries, auth, file uploads |
| `.env` file (`VITE_CONNECTBASE_PUBLIC_KEY`) | **Public Key** (`cb_pk_`) | React, Vue, etc. |
| CLI deploy (`.connectbaserc`) | **Public Key** (`cb_pk_`) | `npx connectbase deploy` |
| MCP server (AI tools) | **Secret Key** (`cb_sk_`) | Claude, Cursor, Windsurf |
| Server-side admin tasks | **Secret Key** (`cb_sk_`) | Backend full data access |

> ⚠️ **MCP server rejects Public Keys** — you must use a Secret Key (`cb_sk_`).
>
> ⚠️ **Never use Secret Keys in frontend code** — RLS is bypassed, exposing all data.

Create Keys in the Console under **Settings > API tab**. Choose Public or Secret type when creating. The full key is shown **only once** at creation time.

#### Server-side admin context (v3.22.0+)

When you create the SDK with **both** `publicKey` and `secretKey`, the client
attaches `X-Public-Key` (app identity) and `Authorization: Bearer cb_sk_*`
(privilege escalation) on every request. The server's `OptionalAdminSecretKey`
middleware verifies the secret key, sets an admin context, and **skips RLS**
for that request — useful for backend sync scripts, admin tools, and
`cb.auth.adminUpdateMember()`.

```typescript
// SERVER-SIDE ONLY — never ship this in a browser/mobile bundle
const cb = new ConnectBase({
  publicKey: process.env.CB_PUBLIC_KEY!,  // cb_pk_
  secretKey: process.env.CB_SECRET_KEY!,  // cb_sk_  (admin)
})

// Bypasses RLS .write/.read rules
await cb.database.createData('orders', { ... })
```

Without `secretKey`, every request is RLS-evaluated as normal — there is no
behavioral change for browser clients.

## Quick Start

```typescript
import ConnectBase from 'connectbase-client'

// Initialize the SDK — use a Public Key (cb_pk_)
const cb = new ConnectBase({
  publicKey: 'cb_pk_your-public-key'
})

// Create a game room client
const gameClient = cb.game.createClient({
  clientId: 'player-123'
})

// Set up event handlers
gameClient
  .on('onConnect', () => console.log('Connected!'))
  .on('onStateUpdate', (state) => console.log('State:', state))
  .on('onDelta', (delta) => console.log('Delta:', delta.changes))
  .on('onAction', (action) => console.log('Action:', action.type, action.clientId))
  .on('onPlayerJoined', (player) => console.log('Player joined:', player.clientId))
  .on('onPlayerLeft', (player) => console.log('Player left:', player.clientId))

// Connect and create a room
await gameClient.connect()
const state = await gameClient.createRoom({
  maxPlayers: 4,
  tickRate: 64,
  scriptName: 'my-script', // Optional: attach a lua script (must be pre-uploaded + active)
})

// 3.14+ — Attached script 의 이름/버전을 검증하고 싶으면 createRoomDetailed 사용
import { GameError } from 'connectbase-client'
try {
  const r = await gameClient.createRoomDetailed({ scriptName: 'my-script' })
  console.log('attached', r.scriptName, 'v', r.scriptVersion)
} catch (e) {
  if (e instanceof GameError && e.code === 'SCRIPT_NOT_FOUND') {
    console.error('script missing — candidates:', e.available)
  }
}
```

## Features

- **Real-time Game Server**: WebSocket-based multiplayer game state synchronization
- **Authentication**: ID/Password and OAuth social login support
- **Database**: JSON-based NoSQL database with real-time queries
- **Storage**: File storage with CDN support
- **Push Notifications**: Cross-platform push notification support
- **WebRTC**: Real-time audio/video communication
- **Payments**: Subscription and one-time payment support
- **AI Streaming**: Real-time AI text generation via WebSocket (multi-provider: Gemini, OpenAI, Claude, Ollama, LM Studio, OpenAI-compatible)
- **Knowledge Base (RAG)**: Document indexing + BM25 search with nori 한국어 형태소. PDF / DOCX / text file upload via `addDocumentFromFile`
- **Endpoint**: Call your own GPU models on your own PC through one `cb_pk_*` key — ConnectBase forwards the payload as-is (dumb pipe)
- **Support**: End-user feedback/issue reporting — users send issues to app operators, AI auto-classifies summary/urgency/category
- **CLI**: Command-line tool for deploying web storage and tunneling local services

## CLI

Deploy your web application to Connect Base Web Storage with a single command.

### Quick Start

```bash
# 1. Initialize (one-time setup)
npx connectbase init

# 2. Deploy
npm run deploy
```

The `init` command will:
- Ask for your Public Key
- List existing web storages or create a new one automatically
- Create a `.connectbaserc` config file
- Add `.connectbaserc` to `.gitignore`
- Add a `deploy` script to `package.json` (includes `build` if available)

### Commands

| Command | Description |
|---------|-------------|
| `init` | Interactive project setup (creates config, adds deploy script) |
| `deploy <dir>` | Deploy files to web storage |
| `tunnel <port>` | Expose a local service to the internet via WebSocket tunnel |

### Manual Usage

If you prefer not to use `init`, you can pass options directly:

```bash
npx connectbase deploy ./dist -s <storage-id> -k <public-key>
```

### Options

| Option | Alias | Description |
|--------|-------|-------------|
| `--storage <id>` | `-s` | Storage ID |
| `--public-key <key>` | `-k` | API Key |
| `--base-url <url>` | `-u` | Custom server URL |
| `--timeout <sec>` | `-t` | Tunnel request timeout in seconds (tunnel only) |
| `--max-body <MB>` | | Tunnel max body size in MB (tunnel only) |
| `--label <name>` | | Auto-register the issued tunnel as an endpoint binding (tunnel only). Requires Secret Key. SDK callers can then use `cb.endpoint.call(label, …)` |
| `--description <text>` | | Endpoint binding description (only valid with `--label`) |
| `--help` | `-h` | Show help |
| `--version` | `-v` | Show version |

### Tunnel

Expose a local server to the internet through a secure WebSocket tunnel. Useful for sharing local MCP servers, development servers, or any HTTP service.

```bash
# Expose local port 8084 to the internet
npx connectbase tunnel 8084 -k <public-key>

# With environment variable
export CONNECTBASE_PUBLIC_KEY=your-public-key
npx connectbase tunnel 8084

# For GPU servers or long-running tasks (e.g., image generation)
npx connectbase tunnel 7860 --timeout 300 --max-body 50
```

The tunnel creates a public URL like `https://tunnel.connectbase.world/<tunnel-id>/` that proxies all HTTP requests to your local service.

**Plan-based limits:** Timeout and body size are clamped to your plan's maximum:

| Plan | Max Timeout | Max Body |
|------|-------------|----------|
| Free | 60s | 10MB |
| Starter | 120s | 25MB |
| Pro | 300s | 50MB |
| Business | 600s | 100MB |

Features:
- Per-tunnel timeout and body size configuration
- Automatic reconnection with exponential backoff
- Request/response logging in terminal
- Graceful shutdown with Ctrl+C
- No external dependencies (uses Node.js built-in modules)

#### Auto-register an endpoint binding (`--label`)

For workflows where you want the SDK to call your local model by a stable name
(`cb.endpoint.call("comfyui-main", …)`) instead of a random tunnel URL, pass
`--label <name>`. The CLI registers the issued `tunnel_id` as an endpoint binding
on the server, so your SDK only needs the Public Key.

```bash
# Start ComfyUI on port 8188, expose it as endpoint label "comfyui-main"
npx connectbase tunnel 8188 --label comfyui-main --description "ComfyUI on my desktop"
```

Authentication uses your User Secret Key (`cb_sk_*`); the CLI calls the dual-auth
route `POST /v1/apps/:appID/endpoints/cli`. If the label already exists, the CLI
warns and keeps the tunnel running — update the binding to the new `tunnel_id`
from the console if needed.

### Configuration File

The `init` command creates `.connectbaserc` automatically. You can also create it manually:

```json
{
  "publicKey": "your-public-key",
  "storageId": "your-storage-id",
  "deployDir": "./dist"
}
```

### Environment Variables

```bash
export CONNECTBASE_PUBLIC_KEY=your-public-key
export CONNECTBASE_STORAGE_ID=your-storage-id
npx connectbase deploy ./dist
```

### Requirements

- `index.html` must exist in the root of the deploy directory
- Supported file types: `.html`, `.css`, `.js`, `.json`, `.svg`, `.png`, `.jpg`, `.gif`, `.webp`, `.woff`, `.woff2`, `.ttf`, `.mp3`, `.mp4`, `.pdf`, `.wasm`, etc.

### SPA Mode

Web Storage supports SPA (Single Page Application) mode, which is **enabled by default**. When enabled, requests to non-existent paths return `index.html` instead of 404, allowing client-side routers (React Router, Vue Router, etc.) to handle routing.

You can toggle SPA mode in the Connect Base Console under **Storage > Security Settings**, or via the API:

```bash
# Disable SPA mode (for static sites)
curl -X PUT "https://api.connectbase.world/v1/apps/{appID}/storages/webs/{storageID}" \
  -H "Authorization: Bearer <TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"spa_mode": false}'
```

> **Important:** When using SPA mode, ensure all asset paths in your HTML are absolute (`/assets/...`), not relative (`./assets/...`). For Vite projects, set `base: '/'` in `vite.config.ts`.

## API Reference

### Game Server

#### `cb.game.config` — Feature Opt-in (v3.1+, SDK 3.3.0+)

The game server's 7 features (`matchqueue` / `leaderboard` / `entity` / `scripts` /
`voice` / `replay` / `spectator`) are **explicit opt-in per app** as of v3.1
(2026-04-30). Disabled features return HTTP **403 `feature_disabled`**.
New apps default to all-OFF; existing apps without a config row fall back to
all-ON for compatibility.

```typescript
// Inspect current toggles
const cfg = await cb.game.config.get(appId)
// → { matchqueue_enabled, leaderboard_enabled, entity_enabled, scripts_enabled,
//     voice_enabled, replay_enabled, spectator_enabled }

// Partial update — only the keys you send are applied; others are preserved.
await cb.game.config.set(appId, {
  matchqueue_enabled: true,
  leaderboard_enabled: true,
})

// Single-toggle convenience wrappers
await cb.game.config.enable(appId, 'matchqueue_enabled')
await cb.game.config.disable(appId, 'voice_enabled')
```

The PATCH publishes a NATS invalidation so game-server caches drop the entry
immediately (30s TTL is the safety net).

| HTTP code | Body `error` | Meaning | Client action |
|-----------|--------------|---------|---------------|
| **403** | `feature_disabled` | Feature is not enabled for this app | Toggle via console or `cb.game.config.set(...)` |
| **429** | `cap_exceeded` | Per-app cardinality cap reached | Remove old rows or ask the operator to raise the cap env |
| **402** | `quota_exceeded` | Plan limit reached on a write route | Upgrade plan |

See [docs/game-server/OPT_IN.md](https://github.com/connectbase-world/connectbase/blob/release/docs/game-server/OPT_IN.md)
for the full policy.

#### GameRoom

The main class for real-time game communication.

```typescript
const gameClient = cb.game.createClient({
  clientId: 'unique-player-id',    // Required: Unique identifier for this player
  gameServerUrl: 'wss://...',      // Optional: Custom game server URL
  autoReconnect: true,             // Optional: Auto-reconnect on disconnect (default: true)
  maxReconnectAttempts: 5,         // Optional: Max reconnect attempts (default: 5)
  reconnectInterval: 1000,         // Optional: Base reconnect interval in ms (default: 1000)
  connectionTimeout: 10000,        // Optional: Connection timeout in ms (default: 10000)
})
```

##### Properties

| Property | Type | Description |
|----------|------|-------------|
| `roomId` | `string \| null` | Current room ID |
| `state` | `GameState \| null` | Current game state |
| `isConnected` | `boolean` | Connection status |
| `isOfflineMode` | `boolean` | Offline mode status |
| `latency` | `number` | Current latency in ms |
| `connectionState` | `ConnectionState` | Detailed connection state |

##### Methods

**connect(roomId?: string): Promise\<void>**

Connect to the game server. Optionally specify a room ID to join immediately.

```typescript
await gameClient.connect()
// or
await gameClient.connect('existing-room-id')
```

**disconnect(): void**

Disconnect from the game server.

```typescript
gameClient.disconnect()
```

**createRoom(config?: GameRoomConfig): Promise\<GameState>**

Create a new game room.

```typescript
const state = await gameClient.createRoom({
  roomId: 'my-custom-room',        // Optional: Custom room ID
  categoryId: 'battle-royale',     // Optional: Room category
  maxPlayers: 100,                 // Optional: Max players (default: 100)
  tickRate: 64,                    // Optional: Server tick rate (default: 64)
  scriptName: 'main',              // Optional (3.11.0+): Lua script attached to the room
                                   // (uploaded+activated via console or POST /v1/game/:appID/scripts).
                                   // Required for onTick / onPlayerJoin / onAction etc. to fire.
  metadata: { map: 'forest' }      // Optional: Custom metadata
})
```

**joinRoom(roomId: string, metadata?: Record\<string, string>): Promise\<GameState>**

Join an existing room.

```typescript
const state = await gameClient.joinRoom('room-id', {
  team: 'blue',
  displayName: 'Player1'
})
```

**leaveRoom(): Promise\<void>**

Leave the current room.

```typescript
await gameClient.leaveRoom()
```

**sendAction(action: GameAction): void**

Send a game action to the server.

```typescript
gameClient.sendAction({
  type: 'move',
  data: { x: 100, y: 200 }
})

gameClient.sendAction({
  type: 'attack',
  data: { targetId: 'enemy-1', damage: 50 }
})
```

**sendChat(message: string): void**

Send a chat message to the room.

```typescript
gameClient.sendChat('Hello everyone!')
```

**requestState(): Promise\<GameState>**

Request the full current state from the server.

```typescript
const state = await gameClient.requestState()
```

**listRooms(): Promise\<GameRoomInfo[]>**

List all available rooms.

```typescript
const rooms = await gameClient.listRooms()
rooms.forEach(room => {
  console.log(`${room.id}: ${room.playerCount}/${room.maxPlayers}`)
})
```

**ping(): Promise\<number>**

Measure round-trip time to the server.

```typescript
const rtt = await gameClient.ping()
console.log(`Latency: ${rtt}ms`)
```

##### Event Handlers

```typescript
gameClient
  .on('onConnect', () => {
    // Called when connected to the server
  })
  .on('onDisconnect', (event: CloseEvent) => {
    // Called when disconnected
  })
  .on('onStateUpdate', (state: GameState) => {
    // Called when full state is received
  })
  .on('onDelta', (delta: GameDelta) => {
    // Called for incremental state updates
    // Use this for efficient state synchronization
  })
  .on('onPlayerJoined', (player: GamePlayer) => {
    // Called when a player joins the room
  })
  .on('onPlayerLeft', (player: GamePlayer) => {
    // Called when a player leaves the room
  })
  .on('onChat', (message: ChatMessage) => {
    // Called when a chat message is received
  })
  .on('onMessage', (msg) => {
    // Called for custom broadcast messages from the server-side Lua script
    // (room.broadcast / room.send_to). Standard types (delta/chat/...) go to
    // their dedicated handlers; only unknown `type` messages arrive here.
    // Branch on msg.type for game-specific protocols.
  })
  .on('onError', (error: ErrorMessage) => {
    // Called on errors
  })
  .on('onPong', (pong: PongMessage) => {
    // Called when pong is received
  })
```

#### Offline Mode

Test your game logic locally without a server connection.

```typescript
// Enable offline mode
gameClient.enableOfflineMode({
  tickRate: 64,
  initialState: {
    players: {},
    objects: []
  },
  simulatedPlayers: [
    { clientId: 'bot-1', joinedAt: Date.now(), metadata: { isBot: 'true' } }
  ]
})

// Update state directly
gameClient.setOfflineState('players.player-1.position', { x: 100, y: 200 })

// Add/remove simulated players
gameClient.addSimulatedPlayer({
  clientId: 'bot-2',
  joinedAt: Date.now(),
  metadata: {}
})
gameClient.removeSimulatedPlayer('bot-2')

// Disable offline mode
gameClient.disableOfflineMode()
```

### Authentication

```typescript
// ID/Password signup
const result = await cb.auth.signUpMember({
  login_id: 'myuser123',
  password: 'password123',
  nickname: 'John'
})

// ID/Password login
const result = await cb.auth.signInMember({
  login_id: 'myuser123',
  password: 'password123'
})

// OAuth login (redirect - recommended)
await cb.oauth.signIn('google', 'https://myapp.com/oauth/callback')

// OAuth login (popup - COOP restrictions may apply)
const result = await cb.oauth.signInWithPopup('google', 'https://myapp.com/oauth/callback')

// Sign out
await cb.auth.signOut()
```

#### Admin: update another member (v3.22.0+)

Set another member's `nickname`, `role`, or `custom_data` from a server-side
admin context. Requires the SDK to be initialized with `secretKey` — calling
this without one throws synchronously. Self-update is rejected by the server.

```typescript
// SERVER-SIDE ONLY — admin context required (publicKey + secretKey)
const cb = new ConnectBase({
  publicKey: process.env.CB_PUBLIC_KEY!,
  secretKey: process.env.CB_SECRET_KEY!,
})

// Grant role used by RLS `auth.role` expressions
await cb.auth.adminUpdateMember('019abc12-...', { role: 'editor' })

// Clear the role
await cb.auth.adminUpdateMember('019abc12-...', { role: '' })

// Multi-field update
await cb.auth.adminUpdateMember('019abc12-...', {
  nickname: 'Alice',
  role: 'admin',
  custom_data: { level: 5 },
})
```

`role` is the only way to populate the RLS expression variable `auth.role` —
end-users can't set it on themselves through the public profile API.

### Database

```typescript
// Query data
const { data, total_count } = await cb.database.getData('table-id', {
  where: { status: 'active' },
  limit: 10
})

// Query with field selection (Projection) - improves response speed
const { data } = await cb.database.getData('table-id', {
  select: ['id', 'name', 'thumbnail'],  // Only return these fields
  limit: 20
})

// Exclude specific fields (e.g., large HTML/CSS content)
const { data } = await cb.database.getData('table-id', {
  exclude: ['html_content', 'css_content'],
  limit: 20
})

// Insert data — returns the created DataItem (id + data + created_at + updated_at)
const newItem = await cb.database.createData('table-id', {
  data: { name: 'John', email: 'john@example.com' }
})
console.log(newItem.id) // use immediately for navigation / cache updates

// Bulk insert — returns { created: DataItem[], total, success, failed? }
const bulk = await cb.database.createMany('table-id', [
  { data: { name: 'User1' } },
  { data: { name: 'User2' } }
])

// Update data — returns the updated DataItem with merged fields
const updated = await cb.database.updateData('table-id', 'data-id', {
  data: { name: 'Jane' }
})

// Delete data
await cb.database.deleteData('table-id', 'data-id')
```

#### Aggregation (MongoDB-style Pipeline)

```typescript
const result = await cb.database.aggregate('table-id', [
  { match: { status: 'active' } },
  { group: { _id: '$category', total: { $sum: '$price' }, count: { $sum: 1 } } },
  { sort: { total: -1 } },
  { limit: 10 }
])
console.log(result.results) // [{ _id: 'electronics', total: 5000, count: 12 }, ...]
```

#### Full-Text Search

```typescript
// Fuzzy search with highlighting
const results = await cb.database.search('table-id', 'smrt phone', ['name', 'description'], {
  fuzzy: true,
  fuzzy_distance: 2,
  highlight: true,
  limit: 10
})

results.results.forEach(item => {
  console.log(item.data.name, item.score, item.highlights)
})

// Autocomplete
const suggestions = await cb.database.autocomplete('table-id', 'sma', 'name', { limit: 5 })
```

#### Geo Queries

```typescript
// Find locations within 5km radius (query: one of near / box / polygon)
const nearby = await cb.database.geoQuery('table-id', 'location', {
  near: {
    center: { lat: 37.5665, lng: 126.9780 },
    max_distance: 5000   // meters
  }
}, { limit: 20 })

nearby.results.forEach(place => {
  console.log(place.data.name, `${place.distance}m away`)
})

// Within a rectangle
await cb.database.geoQuery('table-id', 'location', {
  box: {
    bottom_left: { lat: 37.54, lng: 126.96 },
    top_right: { lat: 37.58, lng: 127.00 }
  }
})
```

#### Batch & Transactions

`table_id` 는 항상 UUID. 콘솔/REST 로 생성한 테이블의 UUID 를 그대로 사용한다.

v3.12+ 부터 server 가 부분 실패(`success: false`)를 응답하면 SDK 가 첫 실패 op 의
error 메시지로 throw 한다 — silent success 회귀 방지 차원. 호출자는 try/catch 로 감싼다.

```typescript
// Batch: atomic multi-table operations
try {
  const result = await cb.database.batch([
    { type: 'create', table_id: ORDERS_TABLE_ID, data: { product: 'A', qty: 1 } },
    { type: 'update', table_id: INVENTORY_TABLE_ID, doc_id: 'item-a', operators: {
      qty: { type: 'increment', value: -1 }
    }},
    { type: 'update', table_id: STATS_TABLE_ID, doc_id: 'daily', operators: {
      order_count: { type: 'increment', value: 1 },
      last_order: { type: 'serverTimestamp' }
    }}
  ])
  // result.success, result.results[i].{success, doc_id, error}
} catch (e) {
  // RLS 거부 / 검증 실패 / table_id 오타 등 — 전체 batch 가 atomic 하게 롤백
  console.error('batch failed:', (e as Error).message)
}

// Transaction: read-then-write with ACID guarantees
try {
  await cb.database.transaction(
    [{ table_id: ACCOUNTS_TABLE_ID, doc_id: 'user-1', alias: 'sender' }],
    [{ type: 'update', table_id: ACCOUNTS_TABLE_ID, doc_id: 'user-1', operators: {
      balance: { type: 'increment', value: -100 }
    }}]
  )
} catch (e) {
  console.error('transaction failed:', (e as Error).message)
}
```

#### Populate (Relation Query / JOIN)

```typescript
// Query with related data populated
const posts = await cb.database.getDataWithPopulate('posts-table', {
  limit: 10,
  populate: [
    { field: 'author_id', from: 'users', as: 'author', select: ['name', 'avatar'] },
    { field: 'id', from: 'comments', as: 'comments', limit: 5, orderBy: 'created_at', order: 'desc' }
  ]
})
```

#### Security Rules (RLS)

```typescript
// Set row-level security rules
await cb.database.createSecurityRule('app-id', {
  table_name: 'posts',
  rules: {
    read: 'true',                              // Anyone can read
    create: 'auth.member_id != null',          // Only authenticated users
    update: 'auth.member_id == data.author_id', // Only author
    delete: 'auth.member_id == data.author_id'
  }
})

// List rules
const rules = await cb.database.listSecurityRules('app-id')
```

#### Indexes

```typescript
// Create unique index
await cb.database.createIndex('app-id', 'table-id', {
  name: 'idx_email_unique',
  fields: ['email'],
  unique: true
})

// Analyze and get recommendations
const analysis = await cb.database.analyzeIndexes('app-id', 'table-id')
analysis.recommendations.forEach(rec => {
  console.log(`Recommended: ${rec.fields.join(', ')} — ${rec.reason}`)
})
```

#### Triggers

```typescript
// Auto-execute function on data change
await cb.database.createTrigger('app-id', {
  name: 'on-order-created',
  table_name: 'orders',
  event: 'create',
  handler_type: 'function',
  handler_id: 'send-notification-fn-id'
})
```

#### Lifecycle (TTL / Retention)

```typescript
// Auto-delete expired sessions
await cb.database.setTTL('app-id', {
  table_name: 'sessions',
  field: 'expires_at',
  enabled: true
})

// Archive old logs after 90 days
await cb.database.setRetentionPolicy('app-id', {
  table_name: 'logs',
  retention_days: 90,
  action: 'archive',
  archive_table: 'archived_logs',
  enabled: true
})
```

### Storage

```typescript
// 파일 업로드 (UUID 기반 URL - 매번 변경됨)
const result = await cb.storage.uploadFile('storage-id', file)
console.log(result.url)

// 특정 폴더에 업로드
const result = await cb.storage.uploadFile('storage-id', file, 'folder-id')

// 업로드 진행률(%) 표시 (옵션 객체 — 문자열 parentId 와 하위 호환)
const result = await cb.storage.uploadFile('storage-id', file, {
    parentId: 'folder-id',                                 // 선택
    onProgress: (p) => console.log(`${p.percentage}%`),    // { loaded, total, percentage }
})
// 진행률은 브라우저(XMLHttpRequest) 환경에서만 실시간 보고, 그 외에는 0%→100% 만 통지

// 경로 기반 업로드 (고정 URL - Firebase Storage 스타일)
// 같은 경로에 다시 업로드하면 URL이 유지된 채로 파일만 교체
const result = await cb.storage.uploadByPath(
    'storage-id',
    '/profiles/user123/avatar.png',
    file
)
console.log(result.url) // 항상 동일한 URL

// 경로로 파일 조회
const file = await cb.storage.getByPath('storage-id', '/profiles/user123/avatar.png')

// 경로로 URL만 가져오기 (없으면 null)
const url = await cb.storage.getUrlByPath('storage-id', '/profiles/user123/avatar.png')

// 파일 목록 조회
const files = await cb.storage.getFiles('storage-id')

// 파일 삭제
await cb.storage.deleteFile('storage-id', 'file-id')

// 페이지 메타 설정 (SEO / OG 태그 - 웹 스토리지용)
await cb.storage.setPageMeta('web-storage-id', {
    path: '/products/123',
    title: '최신 스마트폰',
    description: '최고의 성능, 최저가 보장',
    image: 'https://example.com/product.jpg',
    og_type: 'product',
    json_ld: JSON.stringify({ "@context": "https://schema.org", "@type": "Product", "name": "스마트폰" }),
    robots_noindex: false        // true면 검색 결과에서 제외
})

// 여러 페이지 일괄 등록
await cb.storage.batchSetPageMeta('web-storage-id', {
    pages: [
        { path: '/products/1', title: '상품 1', description: '설명 1' },
        { path: '/products/2', title: '상품 2', description: '설명 2' },
    ]
})

// 페이지 메타 조회/삭제
const { pages } = await cb.storage.listPageMetas('web-storage-id')
await cb.storage.deletePageMeta('web-storage-id', '/products/123')
```

### Knowledge Base (RAG)

문서를 등록하고 BM25 풀텍스트 검색으로 관련 청크를 찾는 RAG 인프라. 한국어는 nori 형태소 분석기 적용. AI 채팅에 컨텍스트로 연결하려면 `cb.ai.chatStream({ knowledgeBaseId })` 를 사용한다.

```typescript
// 텍스트 문서 추가 (즉시 처리)
const doc = await cb.knowledge.addDocument('kb-id', {
    name: '환불 정책',
    source_type: 'text',
    content: '환불은 구매 후 7일 이내 신청 가능합니다...',
    metadata: { category: 'policy' }
})
// doc.status: 'pending' → 백그라운드 처리 후 'ready'

// URL 에서 가져오기
await cb.knowledge.addDocument('kb-id', {
    name: '도움말',
    source_type: 'url',
    source_url: 'https://example.com/help.html',
})

// PDF / DOCX / text 파일 업로드 (3.17.0+)
// 브라우저: <input type="file"> 결과를 그대로 전달
const file = (document.querySelector('input[type=file]') as HTMLInputElement).files![0]
await cb.knowledge.addDocumentFromFile('kb-id', file, {
    metadata: { tag: 'manual' },
})

// Node.js: fs.readFileSync 로 읽은 Buffer
import { readFileSync } from 'node:fs'
await cb.knowledge.addDocumentFromFile('kb-id', {
    data: readFileSync('./report.pdf'),
    mimeType: 'application/pdf',
    name: 'report.pdf',
})

// 문서 목록 / 삭제
const { documents } = await cb.knowledge.listDocuments('kb-id')
await cb.knowledge.deleteDocument('kb-id', 'doc-id')

// 문서 수정 — content/file_content/metadata 변경 시 전체 재색인, name 만 보내면 라벨만 변경
await cb.knowledge.updateDocument('kb-id', 'doc-id', {
    content: '환불은 구매 후 14일 이내에 가능합니다...',
})

// 키워드 검색 (BM25)
const results = await cb.knowledge.search('kb-id', {
    query: '환불 정책이 어떻게 되나요?',
    top_k: 5,
})
results.results.forEach(r => console.log(`[${r.score.toFixed(2)}] ${r.document_name}: ${r.content.slice(0, 80)}...`))

// Agentic Search — AI 가 다중 쿼리 자동 생성 (앱에 AI 프로바이더 설정 필요)
await cb.knowledge.search('kb-id', { query: '회원 등급별 혜택 비교', agentic: true })

// GET 방식 (간단한 사용)
await cb.knowledge.searchGet('kb-id', '환불', 3)
```

**파일 업로드 제약 (`addDocumentFromFile`)**

- 지원 MIME: `application/pdf` (텍스트 PDF), DOCX, `text/*` (plain/markdown/csv/html), `application/json`
- 미지원: 스캔 이미지 PDF / OCR / HWP / XLSX → `unsupported mime type for text extraction` 에러
- 크기 상한: **50MB** (원본 바이너리 기준)
- 추출 결과 빈 텍스트일 경우 `extracted text is empty` 에러 (스캔 PDF 등)

**사용자별 격리 (다중 사용자 RAG)**

`Authorization: Bearer <appmember-jwt>` 를 함께 보내면 서버가 자동으로 본인 metadata.user_id 로 결과를 제한하고, addDocument 시에도 자동 태깅. `search` 의 `where` 에 `'$auth.member_id'` 토큰 사용 시 서버가 인증된 AppMember ID 로 치환한다.

### Realtime

```typescript
// Connect to WebSocket
await cb.realtime.connect()

// Subscribe to a category
const subscription = await cb.realtime.subscribe('chat-room')

// Listen for messages
subscription.onMessage((message) => {
  console.log('New message:', message.data)
})

// Send message
await subscription.send({ text: 'Hello!' })

// Unsubscribe
await subscription.unsubscribe()

// Disconnect
await cb.realtime.disconnect()
```

#### Presence / Typing

Presence(온라인 상태) 와 typing(입력 중 표시) 은 `cb.realtime.*` 가 단일 SoT 입니다.
v2.0.0 에서 `cb.database.setPresence` / `subscribePresence` 는 제거되었습니다.
v1.x 에서 마이그레이션은 [MIGRATION-v2.md](./MIGRATION-v2.md) 참고.

```typescript
// 본인 온라인 상태 publish
await cb.realtime.setPresence('online', { device: 'web', metadata: { nickname: '홍길동' } })

// 다른 사용자 상태 구독
const unsub = await cb.realtime.subscribePresence('user-id', (info) => {
  console.log(info.userId, info.status, info.eventType) // join | leave | update
})

// 룸 단위 typing indicator
await cb.realtime.startTyping('room-id')
await cb.realtime.stopTyping('room-id')
const unsubTyping = await cb.realtime.onTypingChange('room-id', (typing) => {
  console.log(typing.users) // 현재 입력 중인 사용자 ID 목록
})
```

#### AI Streaming

Real-time AI text generation through WebSocket. The provider and model are
resolved from your app's AI config on the server; you can optionally override
them per request (`provider` / `model`). Supported providers: Gemini, OpenAI,
Claude, Ollama, LM Studio, and any OpenAI-compatible endpoint.

```typescript
// Connect first
await cb.realtime.connect()

// Start AI streaming
const session = await cb.realtime.stream(
  [
    { role: 'system', content: 'You are a helpful assistant.' },
    { role: 'user', content: 'Explain quantum computing in simple terms.' }
  ],
  {
    onToken: (token, index) => {
      // Called for each generated token
      process.stdout.write(token)
    },
    onDone: (result) => {
      // Called when generation completes
      console.log('\n\nFull text:', result.fullText)
      console.log('Total tokens:', result.totalTokens)
      console.log('Duration:', result.duration, 'ms')
    },
    onError: (error) => {
      console.error('Stream error:', error.message)
    }
  },
  {
    // All fields optional. When omitted, the server uses your app's AI config.
    provider: 'openai',  // Optional: override the app's configured provider
    model: 'gpt-4o',     // Optional: override the app's configured model
    temperature: 0.7,    // Optional: 0.0-2.0
    maxTokens: 1000      // Optional: max output tokens
  }
)

// Stop streaming early if needed
await session.stop()
```

**Stream Options:** (all optional — defaults are resolved server-side from your app's AI config, not by the SDK)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `provider` | `'gemini' \| 'openai' \| 'claude' \| 'ollama' \| 'lm_studio' \| 'openai_compatible'` | app config | Override the app's configured AI provider |
| `model` | `string` | app config | Override the app's configured model |
| `system` | `string` | - | System prompt |
| `temperature` | `number` | app config | Creativity (0.0-2.0) |
| `maxTokens` | `number` | - | Max output tokens |
| `sessionId` | `string` | auto | Session tracking ID |
| `metadata` | `object` | - | Custom metadata |
| `mcpGroup` | `string` | - | MCP group slug — enables AI Agent mode (registered MCP server tools) |

**Stream Result (onDone):**
| Field | Type | Description |
|-------|------|-------------|
| `sessionId` | `string` | Session ID |
| `fullText` | `string` | Complete generated text |
| `totalTokens` | `number` | Total tokens generated |
| `promptTokens` | `number` | Input prompt tokens |
| `duration` | `number` | Generation time in ms |

### Server Functions

Invoke a deployed function from the SDK, or expose it as a raw HTTP webhook
that external services (Discord, Stripe, GitHub, Slack Events, etc.) can call
directly.

```typescript
// Invoke a function (publicKey-authenticated; runs in your Knative pod)
const result = await cb.functions.invoke('019abc12-...', { orderId: '...' })
```

#### Raw HTTP webhook URL (v3.22.0+)

For external SaaS webhooks where you can't customize the request shape (raw
body, vendor-specific signature headers, arbitrary HTTP methods), enable
`http_trigger_enabled` on the function (Console or MCP `update_function`) and
register the URL returned by `getWebhookURL()` with the upstream service.

```typescript
const url = cb.functions.getWebhookURL('019abc12-...')
// → https://api.connectbase.world/v1/public/functions/019abc12-.../webhook
```

| `http_trigger_auth` | Required header | Use for |
|---|---|---|
| `none` | _(none)_ | External SaaS webhooks (function verifies signature itself) |
| `public_key` | `X-Public-Key: cb_pk_*` | Your own clients/services |
| `secret_key` | `Authorization: Bearer cb_sk_*` | Server-to-server admin calls |

The endpoint forwards the **raw request body** (no JSON wrap), preserves
method/path/query, and forwards all headers — so signature checks (Ed25519,
HMAC-SHA256, Stripe-Signature, X-Hub-Signature-256) work end-to-end. Body
limit is 10MB.

Return `{ statusCode, headers, body }` from the handler to emit a custom
HTTP response (for example, Discord Interactions requires a `200` with a
JSON body within 3 seconds):

```javascript
export async function handler(rawBody, ctx) {
  // The first arg is the raw request body as a UTF-8 string (parse it yourself:
  // JSON.parse(rawBody) for JSON, new URLSearchParams(rawBody) for form-encoded).
  // ctx.method / ctx.path / ctx.query / ctx.headers / ctx.rawBody (Buffer) are
  // populated for webhook invocations. The first arg is the body itself (a
  // string), not a request object with a `.body` field.
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ type: 1 }),  // Discord PING ack
  }
}
```

### Endpoint (Local Model Tunnel)

`cb.endpoint.*` is a dumb pipe to your own GPU/model server running behind a
ConnectBase tunnel. ConnectBase doesn't know your model, payload, or response
shape — it routes a `cb_pk_*` call by label to the registered tunnel and forwards
the body and headers as-is.

**Setup**: run `connectbase tunnel <port> --label <name>` once on the machine
hosting the model (see [Tunnel](#tunnel)) — that registers the binding. Then any
client with the app's Public Key can call it.

#### `cb.endpoint.call(label, init): Promise<Response>`

`fetch()`-compatible signature. Returns the raw `Response` — read the body as
JSON, text, or stream as needed.

```typescript
const cb = new ConnectBase({ publicKey: 'cb_pk_...' })

// ComfyUI prompt graph
const res = await cb.endpoint.call('comfyui-main', {
  method: 'POST',
  path: '/prompt',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ prompt: { /* ComfyUI node graph */ } }),
})
const data = await res.json()
```

```typescript
// Streaming response (SSE / chunked) — vLLM chat completions
const res = await cb.endpoint.call('vllm-local', {
  method: 'POST',
  path: '/v1/chat/completions',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ stream: true, messages: [/* { role, content } */] }),
})
if (!res.body) throw new Error('no stream')
const reader = res.body.getReader()
while (true) {
  const { done, value } = await reader.read()
  if (done) break
  // value is a Uint8Array — decode and process chunk
}
```

```typescript
// Cancel an in-flight request
const ctrl = new AbortController()
setTimeout(() => ctrl.abort(), 30_000)
await cb.endpoint.call('hunyuan-laptop', {
  method: 'POST',
  path: '/generate',
  signal: ctrl.signal,
  body: JSON.stringify({ /* model input */ }),
})
```

**`EndpointCallInit`**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | `string` | yes | Path on your model server, must start with `/` (e.g. `/prompt`, `/v1/chat/completions`) |
| `method` | `string` | no (`GET`) | HTTP method |
| `headers` | `HeadersInit` | no | Extra request headers; `X-Public-Key` is auto-injected unless you set it |
| `body` | `BodyInit \| null` | no | Request body — `string`, `Blob`, `ArrayBuffer`, `FormData`, or `ReadableStream` |
| `signal` | `AbortSignal` | no | Abort signal for cancellation |

The SDK assembles the URL as `${baseUrl}/v1/proxy/${label}${path}` and forwards
the request. Because the response is the raw `fetch` `Response`, streaming
formats (SSE, chunked, NDJSON) work out of the box.

#### `cb.endpoint.pollUntil<T>(label, init, predicate, opts?): Promise<T>`

One-line "submit job → poll for result" pattern. Repeatedly calls the same
endpoint until `predicate` returns a value. Designed for ComfyUI `/history/{id}`,
A1111 `/sdapi/v1/progress`, or any custom queue API.

Behavior:
- Calls `cb.endpoint.call(label, init)` and passes the parsed body to `predicate`
- Returns `undefined` from `predicate` → wait `intervalMs` and retry
- Returns a value from `predicate` → resolve immediately with that value
- HTTP `5xx` / network error → retry. HTTP `4xx` → reject (job-level error)
- `timeoutMs` exceeded or `signal` aborted → reject

```typescript
type Hist = Record<
  string,
  { outputs: Record<string, { images?: { filename: string }[] }> }
>

const filename = await cb.endpoint.pollUntil<string>(
  'comfyui-main',
  { path: `/history/${promptId}` },
  (data: Hist) => {
    const entry = data[promptId]
    if (!entry) return undefined // still queued
    for (const out of Object.values(entry.outputs)) {
      const img = out.images?.[0]
      if (img) return img.filename
    }
    return undefined
  },
  { intervalMs: 1000, timeoutMs: 5 * 60_000 },
)
```

**`PollUntilOptions`**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `intervalMs` | `number` | `1500` | Poll interval in ms |
| `timeoutMs` | `number` | `300000` (5 min) | Total timeout in ms — reject if exceeded |
| `parse` | `'json' \| 'text' \| 'none'` | `'json'` | Body parser. `'json'` falls back to `undefined` on parse error |
| `signal` | `AbortSignal` | — | External cancel signal — reject immediately on abort |

#### `cb.endpoint.url(label, path): string`

Returns the assembled call URL (`${baseUrl}/v1/proxy/${label}${path}`) for
URL-passing scenarios where you control the request and can attach the
`X-Public-Key` header yourself.

⚠️ **Browser-native APIs that cannot set custom headers will fail with `401`.**
ConnectBase's proxy requires `X-Public-Key` on every call (header-only — no
`?api_key=` fallback), so `<img src>`, `new Image()`, native `WebSocket`,
`<script src>`, EventSource, etc. **cannot authenticate** through this URL.
Use `cb.endpoint.call()` instead for those cases:

```typescript
// ✅ Render an image: download via call(), then convert to a blob URL
const res = await cb.endpoint.call('comfyui-main', {
  path: `/view?filename=${encodeURIComponent(name)}`,
})
img.src = URL.createObjectURL(await res.blob())
// ...later: URL.revokeObjectURL(img.src)
```

For permanent images (works across CDN, survives tunnel restarts), upload the
blob to `cb.storage` and use `saved.url` — see
[`examples/ai-image-generator/`](https://github.com/connectbase-world/connectbase/tree/release/examples/ai-image-generator).

When `cb.endpoint.url()` IS the right tool:

- Logging / debugging the resolved tunnel URL
- Passing the URL to a backend service or worker that will make the call with proper headers
- Building a `RequestInfo` for a custom `fetch()` wrapper (you control headers)

```typescript
console.log(cb.endpoint.url('comfyui-main', '/prompt'))
// → https://api.connectbase.world/v1/proxy/comfyui-main/prompt

// Hand the URL to a Service Worker that injects X-Public-Key
sw.postMessage({ url: cb.endpoint.url('comfyui-main', '/prompt'), key: PK })
```

### Push Notifications

```typescript
// Register a device (FCM for Android, APNS for iOS)
const device = await cb.push.registerDevice({
  device_token: 'fcm-token-or-apns-token',
  platform: 'android', // 'android' | 'ios' | 'web'
  device_name: 'Galaxy S24'
})

// Subscribe the device to a topic (deviceToken is required)
await cb.push.subscribeTopic(device.device_token, 'news')

// Unsubscribe the device from a topic
await cb.push.unsubscribeTopic(device.device_token, 'news')

// Web Push (browsers)
const vapidKey = await cb.push.getVAPIDPublicKey()
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: vapidKey.public_key
})
await cb.push.registerWebPush(subscription)
```

### WebRTC

```typescript
// Public Key/JWT 유효성 사전 검증
const result = await cb.webrtc.validate()
if (result.valid) {
  console.log('인증 성공:', result.app_id)
}

// 로컬 미디어 스트림 가져오기
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })

// WebRTC 연결
await cb.webrtc.connect({
  roomId: 'live:room-1',
  isBroadcaster: true,
  localStream: stream
})

// 원격 스트림 수신
cb.webrtc.onRemoteStream((peerId, remoteStream) => {
  videoElement.srcObject = remoteStream
})

// 연결 해제
cb.webrtc.disconnect()
```

### Payments & Subscriptions

```typescript
// Create a subscription (정기 결제)
const subscription = await cb.subscription.create({
  billing_key_id: 'billing-key-1',
  plan_name: 'premium-monthly',
  amount: 9900,
  billing_cycle: 'monthly'
})

// Check subscription status (단건 조회)
const detail = await cb.subscription.get(subscription.id)
console.log(detail.status)

// Cancel subscription (기본: 현재 결제 주기 종료 시 해지)
await cb.subscription.cancel(subscription.id)
```

### Support (End-user Issue Reporting)

End-user 가 앱 운영자에게 직접 버그·질문·요청을 발행하는 채널. 운영자 콘솔의 inbox 에 들어가며, AI 가 자동으로 요약·긴급도·카테고리를 분류한다 (운영자가 AI config 등록 시).

```typescript
// 로그인 사용자 (AppMember JWT 자동 첨부)
await cb.support.reportIssue({
  title: "결제 화면이 멈춰요",
  body: "결제 버튼 클릭 후 로딩이 끝나지 않습니다.",
  category: "bug",  // bug | question | feature_request | incident | other
  metadata: { pageUrl: window.location.href }
})

// 익명 발행 + reCAPTCHA v3 (운영자가 RECAPTCHA_SECRET 설정한 경우 권장)
const recaptchaToken = await grecaptcha.execute(SITE_KEY, { action: 'report_issue' })
await cb.support.reportIssue({
  title: "...",
  body: "...",
  anonymousEmail: "user@example.com",  // 회신 받을 이메일 (선택)
  recaptchaToken,
})
```

응답: `{ id, status: 'open', created_at }` (보안상 최소 정보만).

발행자가 결과를 조회하는 채널은 후속 plan 에서 추가될 예정 — 현재는 운영자가 외부 webhook(이메일/Slack 등)으로 회신하는 방식 권장.

## Types

### GameState

```typescript
interface GameState {
  roomId: string
  state: Record<string, unknown>  // Your game state
  version: number
  serverTime: number
  tickRate: number
  players: GamePlayer[]
}
```

### GameDelta

```typescript
interface GameDelta {
  fromVersion: number
  toVersion: number
  changes: Array<{
    path: string
    operation: 'set' | 'delete'
    value?: unknown
  }>
  tick: number
}
```

### GamePlayer

```typescript
interface GamePlayer {
  clientId: string
  joinedAt: number
  metadata?: Record<string, string>
}
```

### ConnectionState

```typescript
interface ConnectionState {
  status: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error' | 'offline'
  reconnectAttempt: number
  lastError?: Error
  latency: number
}
```

## Error Handling

```typescript
import ConnectBase, { ApiError, AuthError } from 'connectbase-client'

try {
  await cb.auth.signInMember({ login_id, password })
} catch (error) {
  if (error instanceof ApiError) {
    // HTTP 응답 기반 에러: status/code/details 로 분기 가능
    if (error.statusCode === 429) {
      const details = error.details as { retry_after_seconds?: number } | undefined
      const retryAfter = details?.retry_after_seconds
      // ...
    }
  } else if (error instanceof AuthError) {
    // refresh 실패/토큰 만료
  }
}

// Game API 는 별도 이벤트 핸들러도 지원
gameClient.on('onError', (error) => {
  console.error('Game error:', error.message)
})
```

### 전역 에러 관찰자 (v1.9.0+)

`ConnectBase` 초기화 시 `onError` 옵션을 주면 모든 `ApiError` / `AuthError` 가 한 곳으로 모입니다. Sentry/Datadog 등 관측성 툴과 연결하기 쉽습니다.

```typescript
const cb = new ConnectBase({
  publicKey: 'cb_pk_...',
  onError: (error) => {
    Sentry.captureException(error)
  },
})
```

### 요청 타임아웃 (v1.9.0+)

기본 30초 타임아웃이 모든 HTTP 호출에 적용됩니다. `requestTimeoutMs` 로 전역 기본값을 바꾸거나, 0 이하 값을 주면 비활성화할 수 있습니다.

```typescript
const cb = new ConnectBase({
  publicKey: 'cb_pk_...',
  requestTimeoutMs: 60000, // 60s
})
```

## Best Practices

### State Synchronization

Use delta updates for efficient state synchronization:

```typescript
gameClient.on('onDelta', (delta) => {
  // Apply only the changes instead of replacing entire state
  for (const change of delta.changes) {
    applyChange(localState, change.path, change.operation, change.value)
  }
})
```

### Reconnection Handling

```typescript
gameClient.on('onDisconnect', (event) => {
  if (event.code !== 1000) {
    // Show reconnecting UI
    showReconnectingMessage()
  }
})

gameClient.on('onConnect', () => {
  // Reconnected - request full state
  gameClient.requestState()
  hideReconnectingMessage()
})
```

### Latency Compensation

```typescript
// Measure latency periodically
setInterval(async () => {
  const rtt = await gameClient.ping()
  // Adjust client-side prediction based on latency
  updatePredictionOffset(rtt / 2)
}, 5000)
```

## Examples

### Simple Multiplayer Game

```typescript
import ConnectBase from 'connectbase-client'

const cb = new ConnectBase({ publicKey: 'your-public-key' })
const game = cb.game.createClient({ clientId: `player-${Date.now()}` })

// Local player state
let localPlayer = { x: 0, y: 0 }

game
  .on('onConnect', () => console.log('Connected'))
  .on('onStateUpdate', (state) => {
    // Render all players
    renderPlayers(state.state.players)
  })
  .on('onDelta', (delta) => {
    // Efficient incremental updates
    for (const change of delta.changes) {
      updatePlayer(change.path, change.value)
    }
  })

// Connect and create room
await game.connect()
await game.createRoom({ maxPlayers: 8 })

// Game loop
function gameLoop() {
  // Read input
  const input = getPlayerInput()

  // Send action
  if (input.moved) {
    game.sendAction({
      type: 'move',
      data: { x: input.x, y: input.y }
    })
  }

  requestAnimationFrame(gameLoop)
}

gameLoop()
```

### Chat Application

```typescript
const game = cb.game.createClient({ clientId: userId })

game.on('onChat', (message) => {
  displayMessage(message.senderId, message.message, message.timestamp)
})

await game.connect()
await game.joinRoom('general-chat')

// Send message
chatInput.addEventListener('submit', () => {
  game.sendChat(chatInput.value)
  chatInput.value = ''
})
```

## License

MIT
