js-bao-wss-client
JsBao Client Library
A TypeScript/JavaScript client library for js-bao-wss that provides HTTP APIs and real-time collaborative editing using Yjs. This README reflects the current implementation and replaces older docs that referenced removed options/behaviors.
Features
- Document Management: Create, list, update, delete via HTTP
- Permissions: Get/update/remove document permissions, transfer ownership
- Invitations: Create/list/update/delete; accept/decline (invitee)
- Realtime Collaboration: Y.Doc sync over multi-tenant WebSocket
- Awareness: Presence/cursor broadcast and server-triggered refresh
- Auth/OAuth: Client-orchestrated OAuth and cookie refresh
- Magic Link Authentication: Passwordless email-based sign-in
- OTP Authentication: Passwordless sign-in with 6-digit email codes
- Passkey Authentication: WebAuthn/passkey support for passwordless sign-in
- Automatic Reconnect: Backoff + re-auth on 401
- Token Management: Proactive refresh in HTTP calls
- Analytics: Buffered event logging API with optional automatic lifecycle events
- Blob Storage: Upload/list/get/downloadUrl/delete per document with offline cache
- Server Functions: Invoke your app's server functions, start and wait on long-running runs, and join the channels they authorize
- Offline-first Open: Non-blocking open with IndexedDB-backed cache
- Offline Blob Cache: Cache API + IndexedDB backed uploads/reads with eviction and retry
- Network Controls: Online/offline modes, reachability, connection control
- Root Documents: Opt-in listing via
includeRoot
Installation
npm install js-bao-wss-client
# Peer dependencies
npm install yjs lib0Node.js Environment
The client works in both browser and Node.js environments. Storage behavior differs by environment:
| Environment | Storage Provider | Persistence | Automatic |
|---|---|---|---|
| Browser | IndexedDB | ✅ Persistent | ✅ Yes |
| Node.js + better-sqlite3 | SQLite | ✅ Persistent | ✅ Yes (if installed) |
| Node.js (no native deps) | Memory | ❌ Volatile | ✅ Fallback |
Enabling Persistence in Node.js
To enable persistent storage in Node.js, install better-sqlite3:
npm install better-sqlite3The client will automatically detect and use it. Data is stored at ~/.js-bao/{appId}/storage.db.
How Auto-Detection Works
Browser → Uses IndexedDB (always)
Node.js → Checks for better-sqlite3
├─ Found → Uses SQLite file storage
└─ Not found → Falls back to in-memory with warningExplicit Configuration
You can override the auto-detection with storageConfig:
import { initializeClient } from "js-bao-wss-client";
// Force SQLite with custom path
const client = await initializeClient({
// ... other options
storageConfig: { type: "better-sqlite3", filePath: "./data/my-app.db" },
});
// Force in-memory (no persistence)
const client = await initializeClient({
// ... other options
storageConfig: { type: "memory" },
});What's Stored
The storage provider persists:
- KV Cache: API response caching for offline access
- Offline Grants: Credentials for offline authentication
- Auth Tokens: JWT persistence across restarts (if
auth.persistJwtInStorageis enabled) - Analytics Queue: Buffered analytics events
- Document Metadata: Last-opened timestamps and local document state
Browser Bundling
When bundling for browsers, better-sqlite3 is automatically excluded:
- Dynamic imports ensure the SQLite code is never loaded in browsers
- The browser build uses IndexedDB exclusively
- No configuration needed—tree-shaking handles it automatically
Yjs Document Persistence
The storage options above (storageConfig) handle general data like auth tokens and cache. Yjs document persistence (offline document editing) is configured separately:
| Environment | Default Provider | Persistence |
|---|---|---|
| Browser | y-indexeddb (built-in) | ✅ Automatic |
| Node.js | None | ❌ Server-only sync |
To enable Yjs document persistence in Node.js, use the yjsPersistence option with y-sqlite3:
npm install y-sqlite3import { initializeClient } from "js-bao-wss-client";
import { SqlitePersistence } from "y-sqlite3";
const client = await initializeClient({
apiUrl: "https://api.example.com",
wsUrl: "wss://api.example.com",
appId: "my-app",
models: [Task, Project],
// Yjs document persistence for Node.js. The first argument is the store
// name — see "What the first argument is" below.
yjsPersistence: (storeName, ydoc, { appId, userId }) => {
return new SqlitePersistence(storeName, ydoc, {
dbPath: `${process.env.HOME}/.my-app/${appId}/${userId}/yjs.sqlite`
});
},
});
// Documents now persist locally in SQLite
const { ydoc } = await client.documents.open("doc-123", {
waitForLoad: "localIfAvailableElseNetwork"
});What the first argument is
The factory's first argument is the store name to persist this Yjs state under, not necessarily the document id. For an ordinary document, and for a large document (documentFormat: 2) that has not rotated yet, the two are the same value.
A large document's Yjs state is an epoch overlay rather than the document itself, so once it rotates each epoch gets its own store name, {documentId}@{epoch}. A rotation attaches a provider for the fresh epoch and then clears the sealed one, so a factory that put both epochs under one name would lose every write made since the rotation to that clear. Keying your storage on the first argument — as the example above does — is correct for both kinds of document, and needs no change when a document grows large.
The third argument carries the rest of what the store is for:
| Field | Meaning |
|---|---|
appId | The application id |
userId | The authenticated user, or "anon" |
documentId | The plain document id, without the epoch suffix |
documentFormat | 2 for a large document, 1 for an ordinary one — absent on the very first open of a document created elsewhere, whose format nothing local knows yet |
epoch | The epoch the store name is scoped to, or null when the name is the plain document id |
Use context.documentId when you want to group every store belonging to one document; use the first argument whenever you are naming storage.
Why two storage systems?
storageConfig→ General key-value data (auth, cache, metadata)yjsPersistence→ Yjs document updates (binary CRDT data with specialized compaction)
Browser users don't need to configure either—both use IndexedDB automatically.
Complete Node.js Example
Here's a complete example for a CLI tool or server application:
import { initializeClient } from "js-bao-wss-client";
import { SqlitePersistence } from "y-sqlite3";
import * as path from "path";
import * as os from "os";
// Define your models
import { Task, Project } from "./models";
async function main() {
const appId = "my-app";
const dataDir = path.join(os.homedir(), ".my-app", appId);
const client = await initializeClient({
apiUrl: "https://api.example.com",
wsUrl: "wss://api.example.com",
appId,
token: process.env.AUTH_TOKEN, // Or use auth flows below
models: [Task, Project],
// SQLite for general storage (auth, cache, metadata)
storageConfig: {
type: "better-sqlite3",
filePath: path.join(dataDir, "storage.db"),
},
// SQLite for Yjs document persistence
yjsPersistence: (docId, ydoc, { userId }) => {
return new SqlitePersistence(docId, ydoc, {
dbPath: path.join(dataDir, userId, "documents.db"),
});
},
// Persist JWT across restarts
auth: {
persistJwtInStorage: true,
storageKeyPrefix: "cli",
},
// Node.js database config
databaseConfig: {
type: "node-sqlite",
options: {},
},
});
// Wait for connection
await new Promise<void>((resolve) => {
if (client.isConnected()) return resolve();
client.on("status", (e) => e.status === "connected" && resolve());
});
// Work with documents
const { doc: ydoc } = await client.documents.open("doc-123", {
waitForLoad: "localIfAvailableElseNetwork",
enableNetworkSync: true,
});
// Use your models
const tasks = await Task.query();
console.log(`Found ${tasks.data.length} tasks`);
// Clean up
await client.destroy();
}
main().catch(console.error);Offline-First in Node.js
The client supports offline-first patterns in Node.js:
// Check network status
const status = client.getNetworkStatus();
console.log(`Mode: ${status.mode}, Online: ${status.isOnline}`);
// Force offline mode (for testing or airplane mode)
await client.setNetworkMode("offline");
// Create documents while offline (queued for sync)
const { metadata } = await client.documents.create({
title: "Offline Draft",
localOnly: false, // Will sync when back online
});
// Work with local data
const { doc } = await client.documents.open(metadata.documentId, {
waitForLoad: "local", // Don't wait for network
enableNetworkSync: false,
});
// Go back online - pending creates auto-sync
await client.setNetworkMode("online");
// Or manually commit pending creates
await client.documents.commitOfflineCreate(metadata.documentId);JWT Persistence Across Restarts
Enable JWT persistence so CLI tools remember authentication:
const client = await initializeClient({
// ... other options
storageConfig: {
type: "better-sqlite3",
filePath: "./data/storage.db",
},
auth: {
persistJwtInStorage: true,
storageKeyPrefix: "my-cli", // Namespace for multiple tools
},
});
// First run: provide token
// Subsequent runs: token is loaded from SQLite automatically
// Check if we have a persisted session
const userId = await client.waitForUserId({ timeoutMs: 5000 }).catch(() => null);
if (userId) {
console.log(`Restored session for user: ${userId}`);
} else {
console.log("No saved session, please authenticate");
}Local-Only Documents (No Server)
Create documents that never sync to the server:
// Create local-only document
const { metadata } = await client.documents.create({
title: "Local Notes",
localOnly: true,
});
// Open without network
const { doc } = await client.documents.open(metadata.documentId, {
waitForLoad: "local",
enableNetworkSync: false,
});
// Data persists in SQLite but never syncs to server
const notes = doc.getMap("notes");
notes.set("idea", "This stays local");
// Clean up local data when done
await client.documents.evict(metadata.documentId, { force: true });Multi-Client Sync Testing
For testing sync behavior between multiple Node.js clients:
import { initializeClient } from "js-bao-wss-client";
async function testSync() {
// Create two clients (different storage paths)
const client1 = await initializeClient({
// ... shared config
storageConfig: { type: "better-sqlite3", filePath: "./data/client1.db" },
});
const client2 = await initializeClient({
// ... shared config
storageConfig: { type: "better-sqlite3", filePath: "./data/client2.db" },
});
// Both open same document
const doc1 = await client1.openDocument("shared-doc");
const doc2 = await client2.openDocument("shared-doc");
// Changes sync via WebSocket
doc1.getMap("data").set("from", "client1");
// Wait for sync
await new Promise((r) => setTimeout(r, 1000));
// Verify on client2
console.log(doc2.getMap("data").get("from")); // "client1"
await Promise.all([client1.destroy(), client2.destroy()]);
}Required Packages for Node.js
# Core (always required)
npm install js-bao-wss-client yjs lib0 js-bao
# WebSocket transport (required in Node.js — the browser uses the global WebSocket)
npm install ws
# For SQLite storage (optional, recommended for persistence)
npm install better-sqlite3
# For Yjs document persistence (optional, for offline document editing)
npm install y-sqlite3Note: ws, better-sqlite3, and y-sqlite3 are declared as optional peer dependencies. In Node.js the client connects over WebSocket via ws (it dynamically import("ws") when there is no global WebSocket), so ws must be installed for any test or script that opens a document or database — otherwise the connection attempt fails with Cannot find package 'ws'. better-sqlite3 and y-sqlite3 are native modules requiring compilation; if you encounter build issues, ensure you have the appropriate build tools installed (Python, C++ compiler). None are needed in the browser.
Testing in Node.js (dual-context tests)
The same test can run in two contexts — interactively in an app's in-browser test-harness panel, and headlessly in Node under a normal test runner (e.g. Vitest) to gate CI — because JsBaoClient runs the full document / Yjs / database / blob lifecycle in Node with no browser. Author the test once; run it in the panel while developing and under vitest run in CI.
Authenticate from Node with no browser and no email delivery. Use the +primitivetest OTP bypass: otpVerify(email, "000000") authenticates and connects a fresh test user, provided the email's base address is on the app's testAccountBaseEmails whitelist (the app owner's per-app consent gate). No admin credentials, no pre-existing user — the user is auto-provisioned on first verify.
Prerequisites:
- The app's
testAccountBaseEmailsincludes the base of your sign-in email — add it totestAccountBaseEmailsin the app'sapp.tomland runprimitive config push --only app. The sign-in email is then any<base>+primitivetest<suffix>@example.comderivative (e.g.alice+primitivetest-ci@example.com). Reuse a stable suffix per CI project so the same test user is reused across runs (the provisioner is find-or-create) rather than accumulating one user per run. wsis installed (see Required Packages above).- Storage defaults to
auto— SQLite viabetter-sqlite3when present, in-memory otherwise. No native dependency is required to run ephemeral tests.
import { initializeClient } from "js-bao-wss-client";
// Author once — this same async body is what a browser harness test runs too.
const client = await initializeClient({
apiUrl: "https://your-api.example.com",
wsUrl: "wss://your-ws.example.com",
appId: "your-app-id",
models: [/* the models your tests need, as in the browser */],
// storageConfig defaults to { type: "auto" } — memory fallback in Node,
// no native deps required.
});
// Dev-environment test sign-in: whitelisted +primitivetest email + code "000000".
// On success the client is authenticated AND connected — no browser.
await client.otpVerify("alice+primitivetest-ci@example.com", "000000");
// From here the client behaves exactly as in the browser.
const doc = await client.documents.create();
await client.documents.open(doc.id);Under vitest run, a failing assertion or a group that throws surfaces as a test failure and yields a non-zero exit natively (all passing → exit 0), so the suite gates a merge with no bespoke runner. Emit JUnit for CI with vitest run --reporter=junit.
Failure mode. If the sign-in email is not whitelisted (its base is absent from testAccountBaseEmails, or the app's mode rejects the provisioning), otpVerify rejects with a clear authentication error rather than hanging — catch it and fail the test:
await expect(
client.otpVerify("not-whitelisted@example.com", "000000"),
).rejects.toThrow(/otp|auth|verif/i);The in-browser test-harness panel and this Node path run the same registered tests. Nothing in the test authoring contract requires a DOM — every browser API the client touches is guarded with
typeof window === "undefined".
Quick Start
Migrating from legacy decorators? Follow
src/client/docs/js-bao-v2-migration.mdbefore continuing—models must now be defined withdefineModelSchema/createModelClass.
1. Initialize the Client
initializeClient(options) constructs JsBaoClient, waits for the embedded database to be ready, and blocks until the new auth bootstrap sequence finishes (persisted JWT, cookie refresh, offline unlock, or OAuth handoff). Always await it before interacting with the client:
import {
initializeClient,
defineModelSchema,
createModelClass,
InferAttrs,
TypedModelConstructor,
} from "js-bao-wss-client";
import type { BaseModel } from "js-bao";
const contactSchema = defineModelSchema({
name: "contacts",
fields: {
id: { type: "id", autoAssign: true, indexed: true },
name: { type: "string", indexed: true },
email: { type: "string", indexed: true },
status: { type: "string", default: "Active" },
},
});
type ContactAttrs = InferAttrs<typeof contactSchema>;
interface Contact extends ContactAttrs, BaseModel {}
const Contact: TypedModelConstructor<Contact> = createModelClass({
schema: contactSchema,
});
async function bootstrap() {
const client = await initializeClient({
apiUrl: "https://your-api.example.com",
wsUrl: "wss://your-ws.example.com",
appId: "your-app-id",
token: "your-jwt-token", // optional for OAuth/bootstrap
// Optional: override the local query engine (defaults to SQL.js)
databaseConfig: { type: "node-sqlite", options: { filePath: "./local.db" } },
blobUploadConcurrency: 4, // optional (default 2 concurrent uploads)
models: [Contact],
// Optional behaviors
offline: true, // enabled by default; set false to disable IndexedDB doc persistence
auth: {
persistJwtInStorage: true, // optional: reuse short-lived JWT across reloads while valid
storageKeyPrefix: "my-app", // optional namespace when running multiple clients on same origin
},
autoOAuth: false,
oauthRedirectUri: "https://your-app.com/oauth/callback",
suppressAutoLoginMs: 5000,
autoUnlockOfflineOnInit: true,
autoNetwork: true,
globalAdminAppId: "global-admin-app",
wsHeaders: undefined,
logLevel: "info",
maxReconnectDelay: 30,
});
return client;
}
const client = await bootstrap();Note: All following examples assume an async context (e.g., inside
async function main()or using top-level await) so thatawait initializeClient(...)is valid.
Default behaviors
offlinemode is enabled unless you passoffline: false.databaseConfigdefaults to{ type: "sqljs" }. Supply a different engine only if you need it. A web app that opens large documents (documentFormat: 2) passes{ type: "opfs", options: { workerURL?, brokerURL?, locateFile? } }— an OPFS-backed store in a dedicated worker that survives a reload; ordinary documents keep the SQL.js engine underneath.brokerURLpoints atjs-bao/format2-broker, a small SharedWorker that lets several tabs of the same app share one document's store: one tab hosts the engine and the others reach it through a port the broker hands over. Without it a second tab of the same document is refused. Needs js-bao 0.8.0 or newer; with an older js-bao the client throwsOPFS_ENGINE_UNAVAILABLE.
2. Listen to Connection Events
// Connection status
client.on("status", ({ status, net }) => {
console.log("Connection status:", status, net); // status plus network snapshot
});
// Authentication events
client.on("auth-failed", ({ message }) => {
console.error("Auth failed:", message);
// Redirect user to login
});
client.on("auth-success", () => {
console.log("Authentication successful");
});
client.on("auth:onlineAuthRequired", () => {
// Went online without a token; prompt user to sign in
});
// Connection errors
client.on("connection-error", (error) => {
console.error("Connection error:", error);
});
// Connection close
client.on("connection-close", (event) => {
console.log("Connection closed:", event.code, event.reason);
});
// Network mode changes
client.on("networkMode", ({ mode }) => {
console.log("Network mode:", mode);
});
// Auth lifecycle
client.on("auth:state", (s) => console.log("Auth state:", s));
client.on("auth:logout", () => {});
client.on("auth:logout:complete", () => {});
// Offline grant lifecycle
client.on("offlineAuth:enabled", () => {});
client.on("offlineAuth:unlocked", () => {});
client.on("offlineAuth:renewed", () => {});
client.on("offlineAuth:revoked", () => {});
client.on("offlineAuth:failed", () => {});
client.on("offlineAuth:expiringSoon", ({ daysLeft }) => {});Analytics
The client exposes a buffered analytics queue that batches events, retries on reconnect, and shares storage with offline persistence. Use it to emit custom instrumentation or rely on the built-in automatic events described below.
Client API
client.analytics.logEvent({ action, feature, context_json?, ... }): enqueue a single event.context_jsonaccepts an object (auto-serialized) or a JSON string.client.analytics.flush(): attempt to send the queue immediately; also runs automatically on reconnect and right before unload/destroy.client.analytics.setPlanOverride(plan)and.setAppVersionOverride(version): stamp metadata onto every subsequent event until you clear or replace it.
Queued events are persisted in IndexedDB when offline storage is active, so short offline windows or reloads do not drop data. Everything funnels through the same analytics.batch WebSocket channel used by the live event test.
Automatic events
All automatic emitters are on by default; pass analyticsAutoEvents when constructing the client to opt out per feature.
user_active_daily(feature: "session", toggle:analyticsAutoEvents.dailyAuth): first successful auth per calendar day.user_returned("session", respectsanalyticsAutoEvents.minResumeMs): fired when the tab becomes visible after being hidden long enough.context_json.triggerindicates"visibility"or"manual".sync_error("sync", toggle:analyticsAutoEvents.syncErrors.enabled): records thedocumentIdandreasonwhen flush/send attempts fail (with interval throttling).blob_upload_started/blob_upload_succeeded/blob_upload_failed("blobs", toggles:analyticsAutoEvents.blobUploads.{start|success|failure}): include blob/document identifiers, attempt counts, byte size, and retry details (with truncated error text on failure).session_end("session", toggle:analyticsAutoEvents.sessionEnd): emitted onbeforeunloadandclient.destroy(), includingduration_msand exit reason.
Removed events (no longer emitted, and their option flags have been removed from analyticsAutoEvents): client_boot (boot), first_doc_open (firstDocOpen), first_doc_edit (firstDocEdit), offline_recovery (offlineRecovery), service_worker_control / service_worker_token_update (serviceWorker), llm_request_*, gemini_request_*.
Example configuration:
const client = await initializeClient({
...options,
analyticsAutoEvents: {
blobUploads: { start: false, success: true, failure: true },
sessionEnd: false,
},
});Manual client.analytics.logEvent(...) calls share the same queue and flush behaviour as the automatic stream, so custom events keep order/metadata without extra plumbing.
Auth Events Reference
- auth-failed: Access token invalid/expired and refresh failed. Use this to trigger reauthentication.
- Payload:
{ reason?: string; message?: string }
- Payload:
- auth-success: Authentication succeeded or token refreshed.
- Payload: none
- auth-refresh-deferred: Access token refresh was deferred due to connectivity issues. Use this to show "trying to reconnect" UI.
- Payload:
{ status: "scheduled" | "offline"; nextAttemptMs?: number; cause?: string }
- Payload:
- auth:onlineAuthRequired: Client attempted to go online without a token. Prompt for sign-in.
- Payload: none
- auth:logout: Logout flow started (explicit sign-out). Clear app state/stop sensitive activities.
- Payload: none
- auth:logout:complete: Logout flow finished.
- Payload: none
- auth:state: Generic auth state changes.
- Payload:
{ authenticated: boolean; mode: "online" | "offline" | "auto" | "none" }
- Payload:
Minimal example to react when reauthentication is needed:
const promptLogin = () => navigateToLogin();
client.on("auth-failed", promptLogin);
client.on("auth:onlineAuthRequired", promptLogin);
client.on("auth:state", ({ authenticated }) => {
if (!authenticated) promptLogin();
});Persisting short-lived JWTs (optional)
By default the client keeps the access token in memory and relies on the refresh cookie whenever a reload happens. You can opt-in to caching the current short-lived JWT in IndexedDB so that a refresh can be skipped while the token is still valid:
const client = await initializeClient({
...options,
auth: {
persistJwtInStorage: true,
storageKeyPrefix: "tenant-a", // optional namespace per app/user sandbox
},
});
const info = client.getAuthPersistenceInfo();
// => { mode: "persisted", hydrated: false | true }- The persisted token is only reused when it remains outside the refresh safety window (roughly 2 minutes before expiry). If the cached token is stale, the client falls back to the existing refresh flow.
storageKeyPrefixlets you isolate multiple client instances that run on the same origin (e.g., multi-tenant dashboards or tests).- Persistence is cleared automatically on logout, auth failures, or when you disable the feature. Apps that keep the default (
persistJwtInStorageomitted) continue to run fully in-memory. - Offline grants are unaffected; long-lived offline access still hinges on the encrypted grant workflow.
First-party refresh proxy
Safari and other strict browsers block third-party cookies, so you can opt into a same-origin refresh proxy by wiring the client through your app worker:
const client = await initializeClient({
...options,
auth: {
refreshProxy: {
baseUrl: `${window.location.origin}/proxy`,
cookieMaxAgeSeconds: 7 * 24 * 60 * 60, // optional override (defaults to 7 days)
},
},
});baseUrlshould be an absolute URL pointing to the first-party worker prefix that forwards to/app/:appId/api/auth/*.cookieMaxAgeSecondslets you shorten/extend the refresh cookie TTL; omit it to use the worker default.- Set
enabled: falsewhen you share config across environments but only want the proxy in production. - Leave
auth.refreshProxyundefined to preserve the existing direct-to-API behaviour. - In local Vite development the sample app leaves the proxy disabled; set
VITE_USE_REFRESH_PROXY=trueto test the worker path locally.
Document Lifecycle Events
// Fires once per open call as soon as the Y.Doc is created and local wiring is ready
client.on("documentOpened", ({ documentId }) => {
console.log("documentOpened:", documentId);
});
// Fires up to twice per open cycle after the initial wiring is complete:
// - once when initial data is loaded from IndexedDB (browser + offline: true) *after*
// the local query engine (SQL.js/SQLite) has replayed/indexed the data
// - once when the document first becomes synced with the server
client.on(
"documentLoaded",
({ documentId, source, hadData, bytes, elapsedMs }) => {
console.log("documentLoaded:", {
documentId,
source,
hadData,
bytes,
elapsedMs,
});
}
);
// Fires after a document is fully closed and all related resources are cleaned up
client.on("documentClosed", ({ documentId }) => {
console.log("documentClosed:", documentId);
});
// Notes:
// - 'indexeddb' emits only when offline persistence is enabled and IndexedDB is available,
// and only after the js-bao local query engine finishes connecting (SQLite/SQL.js indexes ready).
// - 'server' emits on first transition to synced per open cycle; hadData/bytes reflect server updates applied.
// - elapsedMs is measured from the start of documents.open for that document.
// - Unsubscribe listeners on unmount to avoid duplicate logs.documentMetadataChanged: payload details
The client emits documentMetadataChanged whenever local metadata changes or server metadata is merged into the local cache.
Payload shape:
{ documentId, metadata, changedFields?, action, source }- action:
"created" | "updated" | "evicted" | "deleted" - source:
"local" | "server" - changedFields: array of field names that changed (when applicable)
- metadata: an object with the most recent local view of metadata, or
nullforevicted/deleted
Metadata fields (may be partially present):
documentId: stringtitle?: stringlastKnownPermission?: "owner" | "read-write" | "reader" | "admin" | nullpermissionCachedAt?: string(ISO timestamp)lastOpenedAt?: string(ISO timestamp)lastSyncedAt?: string(ISO timestamp; updated on successful sync or server-merge)localBytes?: number(approx bytes of IndexedDB update store when available)hasUnsyncedLocalChanges?: booleanpendingCreate?: boolean(true for client-created docs pending server commit)createdAt?: string(ISO timestamp; local create time)localOnly?: boolean(true for offline-only documents)
Typical emissions:
- created/local: immediately after
documents.create(...)updates local cache.changedFieldsoften includescreatedAt,pendingCreate,localOnly, and optionallytitle. - updated/local: after local changes such as
documents.update(...)(optimistictitle), sync status updates (lastSyncedAt,hasUnsyncedLocalChanges), or localBytes refresh.
- created/local: immediately after
updated/server: after
me.ownedDocuments({ refreshFromServer: true })or network-first list merges server metadata (e.g.,title,permission),changedFieldsreflects updated properties.evicted/local: after
documents.evict(id)ordocuments.evictAll(...);metadataisnull.deleted/server or local: the first delete seen (server push, list refresh, or local
documents.delete) emits a singledeletedevent; subsequent delete/evict/list refreshes for the same doc are suppressed to avoid duplicates (including 404/offline fallbacks after a successful delete).
Example listener:
client.on("documentMetadataChanged", (updates) => {
const u = Array.isArray(updates) ? updates[0] : updates;
if (!u) return;
// u: { documentId, metadata, changedFields?, action, source }
console.log(
"metadataChanged",
u.documentId,
u.action,
u.source,
u.changedFields
);
});Offline-first: Open Behavior
The client supports non-blocking open so UIs can render immediately from local cache while network work continues in the background.
const { doc, metadata } = await client.documents.open(documentId, {
// Non-blocking knobs
waitForLoad: "localIfAvailableElseNetwork", // "local" | "network" | "localIfAvailableElseNetwork" (default)
enableNetworkSync: true, // false => per-doc manual start
retainLocal: true, // keep local cache on close
availabilityWaitMs: 30000, // network availability timeout (when needed)
});
// Manual start if you opened with enableNetworkSync: false
await client.startNetworkSync(documentId);Events:
- documentOpened: Emitted once the Y.Doc exists and wiring is ready (before load events)
- documentLoaded: Per source (
indexeddb/server); theindexeddbleg waits for replay plus SQLite/SQL.js indexing, theserverleg fires on first sync. Payload{ documentId, source, hadData, bytes?, elapsedMs }. - documentClosed: Emitted after a document is closed and cleanup completes
- permission: Emitted when permission changes; upgrade to write triggers a sync (respecting start mode)
- documentMetadataChanged: Unified metadata event. Payload shape:
{ documentId, metadata, changedFields?, action, source }action:"created" | "updated" | "evicted" | "deleted"source:"local" | "server"metadata: may benullforevicted/deleted
- pendingCreateCommitted / pendingCreateFailed
- Existing:
sync,status,awareness,connection-error,connection-close
Permission changes auto-sync
When a document transitions from non-writable to writable (e.g., reader → read-write), the client automatically runs a sync so earlier local edits are pushed (subject to the document's start mode).
Network Status / Offline Mode
client.getNetworkStatus(); // { mode: "auto" | "online" | "offline", transport: "connected"|"connecting"|"disconnected", isOnline: boolean, connected?: boolean, lastOnlineAt?: string, lastError?: string }
client.isOnline(); // boolean
await client.setNetworkMode("offline");
await client.goOffline();
await client.goOnline();
// HTTP requests fail fast in offline modeMetadata Cache and Local Documents
The client maintains an IndexedDB-backed metadata index so apps can render lists and document summaries offline. Local listing is merged into me.ownedDocuments(...); the former documents.listLocal() is removed.
// List documents (cache-first with background refresh by default)
const docs = await client.me.ownedDocuments({
includeRoot: false,
// Default behavior is cache-first with background refresh when local cache exists
// You can control it explicitly with waitForLoad (see below)
waitForLoad: "localIfAvailableElseNetwork",
});
// List currently open documents (ids)
const open = await client.documents.listOpen();
// Get cached local metadata for a document
const meta = await client.documents.getLocalMetadata(documentId);
// Evict local data for a document (keeps remote doc intact)
await client.documents.evict(documentId);
// Evict all local data; onlySynced=true avoids unsynced-loss
await client.documents.evictAll({ onlySynced: true });
// Configure global retention
client.setRetentionPolicy({
// e.g., { maxDocs?: number, maxBytes?: number, ttlMs?: number, defaultRetain?: "persist" | "session" }
});Notes:
- The client updates the local metadata cache automatically when
me.ownedDocuments()returns server data (including last-known permission and root doc metadata). Root is always cached from the server but filtered out of the returned list unless you passincludeRoot: true. - Cache updates emit
documentMetadataChangedevents (typically withaction: "updated",source: "server"). - Local eviction emits
documentMetadataChangedwithaction: "evicted",metadata: null. - Delete emits a single
documentMetadataChangedwithaction: "deleted", then evicts locally without a second emission.
Listing options (waitForLoad)
documents.list supports the same high-level loading modes as documents.open via waitForLoad:
- "local": return local metadata immediately; no blocking network wait. If no local metadata exists, returns an empty list. If
refreshFromServeris true (default), a background refresh runs to update the local cache. - "network": block until the server responds (up to
serverTimeoutMs, default 10000ms). If the client is explicitly offline, the call fails fast with codeLIST_UNAVAILABLE_OFFLINE. - "localIfAvailableElseNetwork" (default):
- If any cached metadata exists, return it immediately and, when
refreshFromServeris true, refresh in the background. - If no cached metadata exists, block on the server (like "network").
- If any cached metadata exists, return it immediately and, when
Additional flags:
includeRoot?: boolean— include per-user root document(s) in the results. This strictly followswaitForLoad; it does not force a blocking network call.refreshFromServer?: boolean(default true unlesslocalOnlyis true) — controls whether a server request is made at all. Background refresh is only started when the primary flow returns immediately (i.e., it does not already block on network).localOnly?: boolean— short-circuits and returns only documents that have local data; no network access.serverTimeoutMs?: number— timeout for the blocking network path (when applicable).
Pagination and tags
- Paging params:
limit,cursor,forward,returnPage. Default sort is bygrantedAt(document permission grant time) descending; passforward: truefor ascending. returnPage: truereturns{ items, cursor }(backward-compatible array when omitted). WithrefreshFromServer: true, a background page walker fetches the remaining pages and updates the cache/documentMetadataChangedevents; setrefreshFromServer: falseif you only want the first page. Note: cursors come from server responses; local-only/local-first paths do not fabricate cursors or enforcelimitsizing—usereturnPage: truewhen you need a cursor even if data is cached. To synchronously walk all pages yourself, loop on{ returnPage: true, limit, cursor }untilcursoris null. To hydrate the full cache in one call, useclient.syncMetadata({ scope: "all", pageSize, includeRoot })and then list withrefreshFromServer: false.- Server page size defaults to 100 when
limitis omitted; you can request smaller/larger pages within server limits. - Tag filtering:
tag: stringperforms an exact-match filter server-side. Responses includetags: string[]andgrantedAt; both are cached locally and usable offline (e.g., list +tagwill be filtered from cache when offline/local-only). Root is also considered for tag queries—if you tag the root, it can appear in tag-filtered results even when the default list filters root out. WhenrefreshFromServeris true with a tag, the client still fetches the full dataset in the background to keep the cache complete, then filters locally for the tag. - Tag CRUD: server endpoints exist (
POST /documents/:id/tags { tag },DELETE /documents/:id/tags/:tag) and document create accepts optional tags; the high-level client currently exposes tagging via HTTP helpers orclient.makeRequest.
Offline behavior:
- In explicit offline mode, "network" or the network leg of "localIfAvailableElseNetwork" fails fast with
LIST_UNAVAILABLE_OFFLINE. "local" returns the local list (and skips background refresh).
Local-first Document Creation (Client-generated ULIDs)
Create documents locally-first with a client-generated ULID. The client returns metadata immediately and, when not localOnly, marks as a pending create that is auto-committed when online.
// Local-first create (returns metadata)
const { metadata } = await client.documents.create({ title: "Draft" });
const id = metadata.documentId;
// Optional: manual commit (default onExists: "link")
await client.documents.commitOfflineCreate(id, { onExists: "link" });
// Start sync (if you opened with manual start)
await client.startNetworkSync(id);
// Introspection
const pending = await client.documents.listPendingCreates();
const isPending = await client.documents.isPendingCreate(id);
await client.documents.cancelPendingCreate(id);Events: pendingCreateCommitted, pendingCreateFailed help drive UI state.
Root Documents
Some apps use a per-user root document. The server always returns the root in list responses (unless tag-filtered), and the client caches it. By default me.ownedDocuments() filters it out; pass includeRoot: true to surface it (works offline after it’s cached).
// Exclude root (default)
const docs = await client.me.ownedDocuments();
// Include root document(s)
const all = await client.me.ownedDocuments({ includeRoot: true });Behavior changes
- Root documents listing:
me.ownedDocuments()excludes root docs by default. Opt-in with{ includeRoot: true }. - Offline mode requests: When
networkModeis"offline", HTTP calls fail fast with codeOFFLINE. - Open options:
documents.open()uses{ waitForLoad, enableNetworkSync, retainLocal, availabilityWaitMs }. Older options likewaitForPermission,offlineWritePolicy, per-docoffline,provisionalPermission, andstartNetworkare removed. - Create return shape:
documents.create()returns{ metadata }(noY.Doc). - Pending create events:
pendingCreateCommitted/pendingCreateFailedonly (nopendingCreateat creation time). - Evict-all flag:
documents.evictAll({ onlySynced })(replacesonlyUnsynced).
OAuth Authentication
Google registers a separate OAuth client per platform, so the server publishes a client MAP keyed by client type (web, ios, android, desktop, chrome-extension). A browser reads the web entry; there is no single "is Google available" flag, because one could only ever be right for one platform.
// Check if Google sign-in is available for THIS (browser) client: the provider
// is enabled and the web entry is usable.
const googleAvailable = await client.checkOAuthAvailable();
if (googleAvailable) {
// Start OAuth flow (redirects to Google)
await client.startOAuthFlow();
}
// Handle OAuth callback (in your callback page)
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get("code");
const state = urlParams.get("state");
// Without constructing a client on the callback route:
import { JsBaoClient } from "js-bao-wss-client";
if (code && state) {
try {
const token = await JsBaoClient.exchangeOAuthCode({
apiUrl: API_URL,
appId: APP_ID,
code,
state,
});
// Persist token in your auth store and initialize the client later
// (example only; use your own secure storage strategy)
localStorage.setItem("jwt", token);
} catch (error) {
console.error("OAuth callback failed:", error);
}
}
// Later (e.g., after redirecting back to your app shell):
import { initializeClient } from "js-bao-wss-client";
const client = await initializeClient({
apiUrl: API_URL,
wsUrl: WS_URL,
appId: APP_ID,
token,
databaseConfig: { type: "sqljs" },
});
// Check authentication status
if (client.isAuthenticated()) {
console.log("User is authenticated");
const token = client.getToken();
}
// Manually set token
client.setToken("new-jwt-token");Email Sign-In
One request sends ONE email carrying a 6-digit code and — when the redirect target is on the app's [auth].emailRedirectUris allow-list — a sign-in link. The user finishes with whichever suits them, and consuming either one retires both: one email signs a user in once. Email sign-in must be enabled for the app ([auth].emailSignInEnabled, on by default).
Request the email
// `redirectUri` defaults to the client's oauthRedirectUri. With no target at
// all the email carries the code alone, from the same template. A target that
// IS sent must match the app's non-empty allow-list, or the request is
// rejected 400 `Invalid redirect URI` — it never degrades to code-only.
await client.emailSignInRequest("user@example.com", {
redirectUri: "https://app.example.com/auth/callback",
});Both credentials expire together, 15 minutes after the request. Rate limits apply (5 requests per email per hour, 20 per IP per hour).
magicLinkRequest and otpRequest still work and are deprecated: both are aliases of the same issuance path and send the same email.
Finish with the code
const { user, isNewUser } = await client.otpVerify("user@example.com", "123456");
console.log("Logged in as:", user.email);
// isNewUser is true if this is the user's first sign-in (account was just created)
if (isNewUser) {
// Show onboarding flow for new users
}Finish with the link
// In your callback page (e.g., /oauth/callback)
const params = new URLSearchParams(window.location.search);
const magicToken = params.get("magic_token");
if (magicToken) {
// Verify the token and complete authentication
const { user, promptAddPasskey, isNewUser } = await client.magicLinkVerify(magicToken);
console.log("Logged in as:", user.email);
if (isNewUser) {
// Show onboarding flow for new users
}
// If promptAddPasskey is true, consider prompting the user to add a passkey
if (promptAddPasskey) {
// Show UI to add passkey for future logins
}
}Error Handling
try {
await client.otpVerify("user@example.com", "123456");
} catch (error) {
if (error.code === "RATE_LIMITED") {
// Too many attempts, try again later
} else if (error.code === "OTP_MAX_ATTEMPTS") {
// Maximum verification attempts exceeded, request a new email
} else if (error.code === "INVALID_TOKEN") {
// Invalid or expired code
}
}When email sign-in is disabled the request endpoints answer a plain 400 with "Email sign-in is not enabled for this app" and no error code — gate the UI on getAuthConfig()'s emailSignInEnabled instead.
Passkey Authentication
The client supports WebAuthn/passkey authentication for passwordless sign-in. Passkeys must be enabled in the admin console for your app.
Note: Passkeys can only be added to existing accounts (created via OAuth or email sign-in). To use passkey authentication:
- User creates account via OAuth or email sign-in
- User adds a passkey to their account
- User can then sign in with the passkey on future visits
Check Auth Methods Availability
// Get auth configuration for the app
const config = await client.getAuthConfig();
// Check available authentication methods
if (config.hasPasskey) {
console.log("Passkeys are available");
}
if (config.emailSignInEnabled) {
console.log("Email sign-in is available");
}
// Per-platform Google configuration. `usable` is the server's shape verdict for
// one entry — a client id, at least one redirect URI, and a client secret
// exactly when that client type takes one. Availability is that AND the
// provider switch, which is what `checkOAuthAvailable()` computes.
if (config.googleOAuthEnabled && config.googleClients.clients.web?.usable) {
console.log("Google OAuth is available in the browser");
}Sign In with Passkey
import { startAuthentication } from "@simplewebauthn/browser";
// 1. Get authentication options
const { options, challengeToken } = await client.passkeyAuthStart();
// 2. Authenticate with browser
const credential = await startAuthentication({ optionsJSON: options });
// 3. Complete authentication (sets token internally)
const { user, isNewUser } = await client.passkeyAuthFinish(credential, challengeToken);
console.log("Logged in as:", user.email);
// isNewUser is true if this is the user's first sign-in to this app
// (Note: for passkeys, this is rare since passkeys are added to existing accounts)
if (isNewUser) {
// Show onboarding flow
}Add Passkey to Existing Account
import { startRegistration } from "@simplewebauthn/browser";
// User must be authenticated
// 1. Get registration options
const { options, challengeToken } = await client.passkeyRegisterStart();
// 2. Create passkey with browser
const credential = await startRegistration({ optionsJSON: options });
// 3. Complete registration
await client.passkeyRegisterFinish(credential, challengeToken, "MacBook Pro");Manage Passkeys
// List user's passkeys
const { passkeys } = await client.passkeyList();
console.log(passkeys); // [{ passkeyId, deviceName, createdAt, lastUsedAt }]
// Update a passkey's device name
const { passkey } = await client.passkeyUpdate(passkeyId, {
deviceName: "Work MacBook",
});
// Delete a passkey
await client.passkeyDelete(passkeyId);Document Management
Create and List Documents
// Create a new document (returns metadata)
const { metadata } = await client.documents.create({
title: "My New Document",
});
console.log("Created document:", metadata.documentId);
// Create a LARGE document (`documentFormat: 2`) — records live in a persisted
// local store and in the server's own table, so it scales far past an ordinary
// document's ~10 MB. The choice is made here and is never migrated. The
// returned metadata reports the format immediately, before the server commit
// lands, and `documents.get(id).documentFormat` reports it afterwards.
const large = await client.documents.create({
title: "Ledger",
documentFormat: 2,
});
console.log("Format:", large.metadata.documentFormat); // 2
// Omitting `documentFormat` (or passing `1`) creates an ordinary document and
// sends nothing extra. Any other value is refused before anything is created,
// with `JsBaoError` code `INVALID_ARGUMENT`; `localOnly: true` cannot be
// combined with `documentFormat: 2` (`LOCAL_ONLY_UNSUPPORTED_OPTION`), because
// a large document's records live in a store only the server's room opens.
// `documents.createWithAlias` takes the same option with the same effect.
// A large document needs a local store that outlives the session: Node has
// one, and a browser needs `databaseConfig: { type: "opfs", ... }`.
// List documents. There are two readers, and they are disjoint: the documents
// the user owns, and the documents shared with them. Reading both is what it
// takes to see everything the user has access to.
const owned = await client.me.ownedDocuments(); // DocumentInfo[]
const { items: shared } = await client.me.sharedDocuments(); // page 1 of { items, nextCursor }
// Get document details (network)
const docInfo = await client.documents.get(documentId);
console.log("Document:", docInfo.title, docInfo.permission);
// Update document (root titles cannot be changed)
const updatedDoc = await client.documents.update(documentId, {
title: "Updated Title",
});
// Delete a document (offline/pending-create/not-found handled by local eviction)
// Throws if the document is currently open unless you force-close it first
await client.documents.delete(documentId, { forceCloseIfOpen: true }); // auto-closes if openDocument Aliases
Note: Only
scope: "user"is supported. App-scoped aliases have been removed — they conferred no access (there is no app-wide document-sharing primitive) and only named a document every caller had to already be permitted to open.
// User-scoped alias (defaults userId to the current user)
await client.documents.aliases.set({
scope: "user",
aliasKey: "current-draft",
documentId,
});
// Resolve an alias
const alias = await client.documents.aliases.resolve({
scope: "user",
aliasKey: "current-draft",
});
// Open a document via alias (same return shape as documents.open)
const { doc } = await client.documents.openAlias({
scope: "user",
aliasKey: "current-draft",
});
// List aliases for a document (requires document access)
const aliases = await client.documents.aliases.listForDocument(documentId);
// Delete an alias (no error if already missing)
await client.documents.aliases.delete({ scope: "user", aliasKey: "current-draft" });Atomic Create with Alias
Create a document and alias in a single atomic operation. This is an online-only operation that only creates the document if the alias doesn't already exist:
// Create document with a user-scoped alias
const result = await client.documents.createWithAlias({
title: "My Draft",
alias: {
scope: "user",
aliasKey: "current-draft",
},
});
console.log(result.documentId); // The created document ID
console.log(result.alias.aliasKey); // "current-draft"
console.log(result.alias.documentId); // Same as documentId
// The alias create takes the whole create vocabulary — `tags`, `metadata` and
// `documentFormat` — with the same meaning they have on `create()`: applied when
// the call creates the document. The response echoes what it applied, and omits
// `documentFormat` for an ordinary document.
const ledger = await client.documents.createWithAlias({
title: "Ledger",
alias: { scope: "user", aliasKey: "ledger" },
tags: ["ledger", "q3"],
metadata: { owner: "finance" },
documentFormat: 2,
});
console.log(ledger.documentFormat); // 2
console.log(ledger.tags); // ["ledger", "q3"]
// Attempting to create with an existing alias throws HTTP 409
try {
await client.documents.createWithAlias({
title: "Another Draft",
alias: { scope: "user", aliasKey: "current-draft" },
});
} catch (err) {
console.log("Alias already exists");
}Differences from separate create() + aliases.set():
- ✅ Atomic: Document is only created if alias doesn't exist
- ✅ No race conditions: Server-side transaction ensures consistency
- ✅ Cleaner error handling: Single 409 error if alias exists (no orphaned documents)
- ❌ Online only: Requires network connection (no offline support)
Use createWithAlias() when you need guaranteed uniqueness based on an alias (e.g., "only one home page per app"). Use regular create() + aliases.set() when offline support is needed or when the document should be created regardless of alias conflicts.
Get-or-Create with Alias
When you want the document to exist rather than to be new — "this user's ledger" — getOrCreateWithAlias() is the idempotent form: the first call creates it, every later one returns the same document. It takes the same options, applied only when the call creates the document:
const ledger = await client.documents.getOrCreateWithAlias({
title: "Ledger",
alias: { scope: "user", aliasKey: "ledger" },
tags: ["ledger"],
documentFormat: 2,
});
console.log(ledger.created); // true on the call that made it, false after
console.log(ledger.documentFormat); // 2 either way
console.log(ledger.tags); // ["ledger"] on the create; absent afterwardsA stated documentFormat is also a statement about a document the alias already names. If the stored format differs, the call is refused with DOCUMENT_FORMAT_MISMATCH (409) naming the document and both formats, and nothing is created — being handed an ordinary document when you asked for a large one is the mistake this route exists to prevent. If it agrees, the format is echoed. Omit it and an existing binding is answered exactly as it always was.
All three create routes refuse a top-level body key they do not read, with 400
VALIDATION_FAILEDand adetailsentry naming it. The typed methods above send only keys the routes read; this reaches you only if you build a request body by hand.
Manage Permissions
// Get document permissions
const permissions = await client.documents.getPermissions(documentId);
permissions.forEach((perm) => {
console.log(`${perm.email}: ${perm.permission}`);
});
// Grant permission to a user
await client.documents.updatePermissions(documentId, {
userId: "user-123",
permission: "read-write", // 'read-write' | 'reader'
});
// Batch update permissions
await client.documents.updatePermissions(documentId, {
permissions: [
{ userId: "user-1", permission: "read-write" },
{ userId: "user-2", permission: "reader" },
],
});
// Remove permission
await client.documents.removePermission(documentId, userId);
// Transfer document ownership
await client.documents.transferOwnership(documentId, newOwnerId);
// or via DocumentContext:
await client.document(documentId).transferOwnership(newOwnerId);
// Validate access to a document
const accessResult = await client.documents.validateAccess(documentId);
if (accessResult.hasAccess) {
console.log("User has access:", accessResult.permission);
}Blob Storage
Blobs are stored per document and inherit document permissions. The client exposes a BlobsAPI namespace under documents.
Access patterns:
const blobs = client.document(documentId).blobs();Upload a Blob
const data = new TextEncoder().encode("hello blob");
const { blobId, numBytes, contentType } = await blobs.upload(data, {
filename: "hello.txt",
contentType: "text/plain",
disposition: "attachment", // or "inline"
// sha256Base64?: optional; computed automatically if omitted
});Alternate single-step helper
// Convenience wrapper that returns { blobId, numBytes }
const { blobId, numBytes } = await client
.document(documentId)
.blobs()
.uploadFile(new TextEncoder().encode("hello alt"), {
filename: "alt.txt",
contentType: "text/plain",
});List Blobs (with pagination)
const page1 = await client.document(documentId).blobs().list({ limit: 10 });
page1.items.forEach((b) => {
console.log(b.blobId, b.filename, b.size);
});
if (page1.cursor) {
const page2 = await blobs.list({ cursor: page1.cursor });
}Get Blob Metadata
const meta = await client.document(documentId).blobs().get(blobId);
console.log(meta.filename, meta.contentType, meta.size);Get a Download URL
// Returns a direct Worker URL (no presign). Add `disposition` to control attachment vs inline.
const url = client
.document(documentId)
.blobs()
.downloadUrl(blobId, { disposition: "attachment" });
// e.g., use in browser: window.location.href = urlDelete a Blob
await client.document(documentId).blobs().delete(blobId); // { deleted: true }Read Cached Blobs (different shapes)
const text = await client.document(documentId).blobs().read(blobId, {
as: "text",
});
const arrayBuffer = await client
.document(documentId)
.blobs()
.read(blobId, { as: "arrayBuffer" });
const blobObj = await client.document(documentId).blobs().read(blobId, {
as: "blob",
});
const bytes = await client.document(documentId).blobs().read(blobId, {
as: "uint8array",
});- All reads hit the Cache API / IndexedDB cache when available.
- Pass
forceRedownload: trueto refresh from the server even if cached. dispositionmirrors the URL helper if you need server-side content handling hints.
Prefetch Blobs for Offline Use
await client.document(documentId).blobs().prefetch([blobA, blobB], {
concurrency: 4,
forceRedownload: false,
});Prefetch downloads the bytes into the Cache API/IndexedDB store so subsequent read() calls succeed offline.
Inspect / Control the Upload Queue
const uploadsApi = client.document(documentId).blobs();
// Queue status (includes in-flight + pending items)
uploadsApi.uploads().forEach((task) => {
console.log(task.blobId, task.status);
});
// Pause/resume individual uploads
uploadsApi.pauseUpload(blobId);
uploadsApi.resumeUpload(blobId);
// Pause or resume everything for this document
uploadsApi.pauseAll();
uploadsApi.resumeAll();
// Global events (optional)
client.on("blobs:upload-progress", ([event]) => {
console.log(event.queueId, event.status, event.bytesTransferred);
});
client.on("blobs:upload-completed", ([event]) => {
console.log("done", event.queueId);
});
client.on("blobs:queue-drained", () => console.log("all uploads complete"));
// Adjust concurrency at runtime (minimum 1)
client.documents.setUploadConcurrency(5);
console.log("Current concurrency:", client.documents.getUploadConcurrency());Notes
- The client automatically computes base64 SHA-256 if not provided.
- Upload requires write-level permission (or admin/owner). Listing, metadata, and download require reader+.
- Uploads are queued when offline; the manager processes up to 2 uploads in parallel when network conditions allow. Pass
forceRedownloadtoread/prefetchfor fresh server bytes. - Events (
blobs:*) surface queue state for progress bars or toast notifications. - Client-side max upload size is not enforced in the SDK.
Offline Blob Storage
Blob storage is fully offline-aware:
Uploads while offline
typescriptawait client.setNetworkMode("offline"); const { blobId } = await client .document(documentId) .blobs() .upload(new TextEncoder().encode("draft"), { filename: "draft.txt", contentType: "text/plain", }); // Inspect pending work console.log(client.document(documentId).blobs().uploads());- Bytes are written to the Cache API (browser) or kept in a short-lived in-memory map when caching is unavailable.
- Queue entries persist in IndexedDB so refreshes or reconnects continue uploading.
Reads when offline
typescriptconst text = await client.document(documentId).blobs().read(blobId, { as: "text", }); // Works offline thanks to the cached bytesComing back online
typescriptawait client.setNetworkMode("online"); // Queue processes automatically; listen to blobs:queue-drained for completionPrefetch before going offline
typescriptawait client.document(documentId).blobs().prefetch(importantBlobIds);Prefetched blobs remain available for subsequent offline
read()calls.Retention
- Set
retainLocal: falseon upload options to drop cached bytes after success while leaving metadata intact. delete()removes queue entries, cached bytes, and server objects by default.
- Set
Service Worker Integration
BlobManager caches blob responses in the shared Cache API (js-bao-blobs:<appId>:<userId>) and now exposes helpers so UI code can coordinate with the service worker:
const blobs = client.documents.blobs(documentId);
if (!blobs.hasServiceWorkerControl()) {
console.warn("Service worker has not taken control yet");
}
const url = blobs.proxyUrl(blobId, {
disposition: "attachment",
attachmentFilename: "report.pdf",
});
imageElement.src = url;The client now posts these messages for you (including apiBaseUrl, cachePrefix, and the current token). To opt out (and send custom payloads) set serviceWorkerBridge: { enabled: false } when constructing JsBaoClient. Apps that never register a service worker simply ignore the bridge while continuing to use the shared Cache API for read() calls.
To support <img>/<video> tags and other non-authenticated fetches, add a service worker handler for requests on the same origin that match /app/{appId}/api/documents/{documentId}/blobs/{blobId}/download. The handler should swap the origin to the API host provided in the bridge payload, attach auth headers, fall back to the network when needed, and mirror the Cache API used by the main thread. A complete example:
// sw.js
const STATE = {
appId: null,
userId: null,
token: null,
cachePrefix: null,
globalAdminAppId: null,
apiBaseUrl: null,
};
// In-memory metadata example; persist to IndexedDB if you need SW restarts to keep state.
const BLOB_METADATA = new Map();
self.addEventListener("message", (event) => {
const { type, payload } = event.data || {};
if (!type || !payload) return;
if (type === "jsBao:init") {
STATE.appId = payload.appId ?? STATE.appId;
STATE.userId = payload.userId ?? STATE.userId;
STATE.cachePrefix = payload.blobs?.cachePrefix ?? STATE.cachePrefix;
STATE.globalAdminAppId = payload.globalAdminAppId ?? STATE.globalAdminAppId;
STATE.apiBaseUrl = payload.apiBaseUrl ?? STATE.apiBaseUrl;
STATE.token = payload.auth?.token ?? STATE.token;
} else if (type === "jsBao:tokenUpdated") {
STATE.token = payload.token ?? STATE.token;
}
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
const apiOrigin = STATE.apiBaseUrl ?? self.location.origin;
if (url.origin !== apiOrigin) return;
if (!STATE.appId) return;
if (!url.pathname.startsWith(`/app/${STATE.appId}/api/documents/`)) return;
if (!url.pathname.includes("/blobs/")) return;
if (!url.pathname.endsWith("/download")) return;
event.respondWith(handleProxy(event.request));
});
async function handleProxy(request) {
if (!STATE.appId) {
return fetch(request);
}
const requestUrl = new URL(request.url);
const apiBase = STATE.apiBaseUrl
? new URL(STATE.apiBaseUrl)
: new URL(requestUrl.origin);
const upstreamUrl = new URL(
`${requestUrl.pathname}${requestUrl.search}`,
apiBase.origin
);
const canonicalKey = buildCanonicalKey(apiBase.origin, requestUrl.pathname);
const metadata = extractDispositionMetadata(requestUrl);
if (metadata) {
BLOB_METADATA.set(canonicalKey, metadata);
}
const headers = new Headers(request.headers);
if (STATE.token) {
headers.set("Authorization", `Bearer ${STATE.token}`);
}
if (STATE.globalAdminAppId) {
headers.set("X-Global-Admin-App-Id", STATE.globalAdminAppId);
}
const upstreamRequest = new Request(upstreamUrl.toString(), {
method: request.method,
headers,
redirect: request.redirect,
cache: "no-store",
credentials: "omit",
mode: "cors",
});
const cacheName =
STATE.cachePrefix ?? `js-bao-blobs:${STATE.appId}:${STATE.userId}`;
const cache = await caches.open(cacheName);
const canonicalRequest = new Request(canonicalKey, { method: "GET" });
const effectiveMetadata = metadata ?? BLOB_METADATA.get(canonicalKey) ?? null;
const cached = await cache.match(canonicalRequest);
if (cached) {
return applyDisposition(cached, effectiveMetadata);
}
const upstream = await fetch(upstreamRequest);
if (!upstream.ok || request.method !== "GET") {
return upstream;
}
const sanitized = stripDisposition(upstream);
try {
await cache.put(canonicalRequest, sanitized.clone());
} catch (err) {
console.warn("[SW] Failed to write blob cache entry", err);
}
return applyDisposition(sanitized, effectiveMetadata);
}
function buildCanonicalKey(origin, pathname) {
return new URL(pathname, origin).toString();
}
function extractDispositionMetadata(url) {
const disposition = url.searchParams.get("disposition");
if (!disposition) return null;
const attachmentFilename =
url.searchParams.get("attachmentFilename") ?? undefined;
return { disposition, attachmentFilename };
}
function stripDisposition(response) {
const headers = new Headers(response.headers);
headers.delete("Content-Disposition");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
function applyDisposition(response, metadata) {
if (!metadata) return response;
const headers = new Headers(response.headers);
headers.delete("Content-Disposition");
if (metadata.disposition === "inline") {
headers.set("Content-Disposition", "inline");
} else if (metadata.disposition === "attachment") {
const filename = metadata.attachmentFilename;
if (filename) {
headers.set(
"Content-Disposition",
`attachment; filename="${sanitizeAsciiFilename(
filename
)}"; filename*=UTF-8''${encodeRFC5987(filename)}`
);
} else {
headers.set("Content-Disposition", "attachment");
}
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
function sanitizeAsciiFilename(filename) {
return filename.replace(/["\\]/g, "_");
}
function encodeRFC5987(value) {
return encodeURIComponent(value)
.replace(
/['()*]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
)
.replace(/%(7C|60|5E)/g, (_, hex) => `%${hex.toLowerCase()}`);
}The canonical request uses only origin + pathname, so all disposition variants reuse the same cache entry. Metadata can live in memory (as shown) or in IndexedDB if you need to survive worker restarts. Because cached responses are stored without Content-Disposition, each hit reapplies headers based on the active request. Extend the sample with background eviction or cache versioning as needed.
Document Sharing
Per-document invitations were removed in client 3.0.0. Sharing is immediate: updatePermissions grants access to a registered user on the spot, and for an email that has not registered yet it writes a deferred grant plus an app-level invitation whose inviteToken the recipient redeems.
// Share with an existing user (takes effect immediately) or defer the grant
// for an email that has not registered yet.
const grant = await client.documents.updatePermissions(documentId, {
email: "user@example.com",
permission: "read-write", // 'read-write' | 'reader'
});
if (grant.deferred) {
console.log("Deferred until they accept:", grant.inviteToken);
}
// Deferred grants still waiting on their recipient
const pending = await client.documents.listPendingInvitations(documentId);
// Withdraw access (a live grant or a pending deferred one)
await client.documents.removePermission(documentId, {
email: "user@example.com",
});
// The recipient redeems the app-level invitation, which resolves every
// deferred grant issued to their email.
await client.invitations.accept(inviteToken);Users
// Look up basic profile info for a user in the current app (cached like `me`)
const user = await client.users.getBasic("u01H...");
console.log(user.name, user.email, user.appRole);Server Functions
A server function is TypeScript your app's team writes in its config tree and pushes with primitive config push. The client calls it by key, and the function's access gate decides who may. Inside, the function works on the app's authority: it reads and writes databases and documents, runs saved prompts, calls integrations, and publishes to channels. The Server Functions guide covers writing one; this section covers calling one.
Invoking a Function
client.functions.invoke runs the function inside the request and resolves with its result:
const result = await client.functions.invoke<{ message: string }>("greet", {
input: { name: "Ada" }, // what the handler receives as `input`
timeoutMs: 10_000, // default 5 000, clamped to 30 000
});
if (result.status === "completed") {
console.log(result.output?.message); // "hello Ada"
} else {
// "failed" — the handler threw or its output failed outputSchema — or "timeout"
console.error(result.status, result.error, result.errorCode);
}statusiscompleted,failed, ortimeout. All three resolve: the call reached the function and the function has an answer. The promise rejects only when the platform refused before the code ran — theaccessgate or a disabled function (403), an unknown key (404), a function with no pushed code (409), or the rate ceiling (429, or503when the ceiling cannot be checked).- Options are all optional:
input,contextDocId(a document the call is about),meta(caller metadata, at most 1 KB encoded), andtimeoutMs. invocationIdnames the invocation-log record for the call. Pass it toprimitive functions logs <functionId> --invocation <id>to read what the function printed and the error it threw.
Starting a Long-Running Run
client.functions.start runs the same function as a task: it answers with a run id immediately and the code keeps running. Every pushed function accepts both invoke and start; the caller chooses.
const run = await client.functions.start("order-sync", {
input: { orderId },
runKey: `order-${orderId}`, // a repeat replays the existing run (existing: true)
});
// Poll once…
const now = await client.functions.getStatus(run.runId);
console.log(now.status);
// …or wait for a terminal state.
const settled = await client.functions.waitFor<{ confirmedAt: number }>(run.runId, {
timeoutMs: 60_000, // default 15 minutes; throws WORKFLOW_WAIT_TIMEOUT past it
});
if (settled.status === "failed") {
console.error("order-sync failed:", settled.error?.message);
} else {
console.log(settled.status, settled.output?.confirmedAt);
}
// Stop a run that is still going.
await client.functions.terminate({ functionKey: "order-sync", runKey: run.runKey });runKeymakes the start idempotent per caller andcontextDocId: repeating it answers the run that already exists, withexisting: true, instead of starting a second one.waitForsettles oncompleted,failed, orterminated. A failed run resolves withstatus: "failed"and itserror; it does not reject.getStatusis one read. A run id that is not this app's, or does not exist, rejects withNOT_FOUND.
Channels
A channel delivers what server functions publish to whoever has joined it. A function authorizes the caller for one channel with ctx.channels.authorize and returns the grant; the client presents it with subscribeToChannel, and every ctx.channels.publish to that channel arrives as a channelMessage event:
// 1. Ask the authorizing function for a grant; it names this caller and one channel.
const result = await client.functions.invoke<{ grant: string; expiresAt: number }>(
"order-room",
{ input: { orderId } }
);
if (result.status !== "completed" || !result.output) return;
// 2. Present the grant on the socket the client already has.
const subscription = await client.subscribeToChannel(
`orders:${orderId}`,
result.output.grant
);
client.on("channelMessage", (event) => {
// event.channel, event.payload, event.functionKey, event.sentAt
console.log(event.channel, event.payload);
});
// A grant that expired while the socket was down is refused on reconnect —
// invoke the function again and subscribe again.
client.on("channelSubscribeFailed", (event) => {
console.warn("renew", event.channel, event.message);
});
// Later: leave. Idempotent — the same as client.unsubscribeFromChannel(channel).
subscription.unsubscribe();A membership lasts until the grant's expiresAt (epoch milliseconds, also on the returned subscription). To keep listening, invoke the authorizing function again before then and call subscribeToChannel with the new grant; that replaces the membership rather than adding a second one.
Deprecated Client APIs
These client APIs still work but are deprecated. Each has a server-function replacement:
client.workflows.*— call a server function:client.functions.invokefor a result, orclient.functions.start/getStatus/waitForfor a long-running run.client.prompts.execute— call a server function that runs the prompt withctx.prompts.run.client.integrations.call— call a server function that calls the integration withctx.integrations.call.client.databases.*— call a server function that reads and writes records withctx.dband manages databases withctx.api.databases; for live updates, join a channel the function publishes to.client.cronTriggers.*— schedule a server function by declaring[[function.triggers.cron]]in its config.
Groups
Groups allow batch permission grants. A group has a groupType and groupId.
// Create a group
const group = await client.groups.create({
groupType: "team",
groupId: "engineering",
name: "Engineering Team",
description: "All engineers",
});
// List groups
const groups = await client.groups.list();
const teams = await client.groups.list({ type: "team" });
// Get, update, delete
const g = await client.groups.get("team", "engineering");
await client.groups.update("team", "engineering", { name: "Eng Team" });
await client.groups.delete("team", "engineering");Group Members
// Add a member by userId
await client.groups.addMember("team", "engineering", { userId: "user-123" });
// Add a member by email
await client.groups.addMember("team", "engineering", { email: "alice@example.com" });
// List members
const members = await client.groups.listMembers("team", "engineering");
// Update role
await client.groups.updateMemberRole("team", "engineering", "user-123", { role: "admin" });
// Remove member by userId
await client.groups.removeMember("team", "engineering", "user-123");
// Remove member by email
await client.groups.removeMember("team", "engineering", { email: "alice@example.com" });
// List a user's group memberships
const memberships = await client.groups.listUserMemberships("user-123");Real-time Collaboration
Open and Edit Documents
import * as Y from "yjs";
// Open a document for real-time editing
const { doc: ydoc } = await client.documents.open(documentId, {
waitForLoad: "localIfAvailableElseNetwork",
enableNetworkSync: true,
});
if (!ydoc) throw new Error("No local copy available and network required");
// Get shared types from the Y.Doc
const ytext = ydoc.getText("content");
const ymap = ydoc.getMap("metadata");
// Listen for document changes
ydoc.on("update", (update) => {
console.log("Document updated locally or remotely");
});
// Make changes to the document (only if not read-only)
if (!client.documents.isReadOnly(documentId)) {
ytext.insert(0, "Hello, world!");
ymap.set("title", "My Document");
ymap.set("lastModified", new Date().toISOString());
}
// Check document permission and read-only status
const permission = client.documents.getDocumentPermission(documentId);
console.log("User permission:", permission); // 'owner' | 'read-write' | 'reader' | 'admin'
if (client.documents.isReadOnly(documentId)) {
console.log("Document is read-only");
}
// Check sync status
if (client.documents.isSynced(documentId)) {
console.log("Document is synced with server");
}
// Listen for sync events
client.on("sync", ({ documentId, synced }) => {
console.log(`Document ${documentId} sync status: ${synced}`);
});
// Close document when done
await client.documents.close(documentId);Document-to-Model Mapping (ORM Integration)
The client provides APIs to route js-bao ORM queries to specific Y.Docs. This allows you to work with multiple documents simultaneously while maintaining clear model-to-document associations.
Basic Concepts
- Model mapping: Explicitly routes a specific model class to a specific document
- Default document: Fallback document for models without explicit mappings
- Resolution hierarchy: Model mapping → Default document → Error
Setting a Default Document
Use setDefaultDocumentId() to establish a fallback document for all unmapped models:
// Open a document first (required)
const { doc } = await client.documents.open("my-default-doc");
// Set it as the default for unmapped models
client.setDefaultDocumentId("my-default-doc");
// Now any model without an explicit mapping will use this document
// e.g., Contact.query() → runs against 'my-default-doc'Important: Documents must be open before setting as default. The method throws if the document isn't already open.
Mapping Specific Models
Use addDocumentModelMapping() to route individual models to specific documents:
// Open the documents you want to use
await client.documents.open("users-doc");
await client.documents.open("tasks-doc");
// Map specific models
client.addDocumentModelMapping("Contact", "users-doc");
client.addDocumentModelMapping("Task", "tasks-doc");
// Now queries route correctly:
// Contact.query() → uses 'users-doc'
// Task.query() → uses 'tasks-doc'Complete Example: Multi-Document Application
import { initializeClient } from "js-bao-wss-client";
import { Contact, Task, Note } from "./models";
async function setupModels() {
const client = await initializeClient({
apiUrl: "https://api.example.com",
wsUrl: "wss://ws.example.com",
appId: "my-app",
token: "jwt-token",
databaseConfig: { type: "sqljs" },
});
// 1. Open all documents you'll be using
const usersDoc = await client.documents.open("users-doc-123");
const tasksDoc = await client.documents.open("tasks-doc-456");
const generalDoc = await client.documents.open("general-doc-789");
// 2. Set default document for unmapped models
client.setDefaultDocumentId("general-doc-789");
// 3. Map specific models to their documents
client.addDocumentModelMapping("Contact", "users-doc-123");
client.addDocumentModelMapping("Task", "tasks-doc-456");
// Note has no mapping, so it will use the default document
// 4. Now ORM queries route automatically:
const contacts = await Contact.query(); // → users-doc-123
const tasks = await Task.query(); // → tasks-doc-456
const notes = await Note.query(); // → general-doc-789 (default)
// 5. Verify current mappings
const contactDocId = client.getDocumentModelMapping("Contact");
console.log("Contact uses:", contactDocId); // 'users-doc-123'
const defaultDocId = client.getDefaultDocumentId();
console.log("Default doc:", defaultDocId); // 'general-doc-789'
return { client, usersDoc, tasksDoc, generalDoc };
}
await setupModels();Updating Mappings
You can change mappings at runtime:
// Switch Contact to a different document
await client.documents.open("contacts-v2-doc");
client.addDocumentModelMapping("Contact", "contacts-v2-doc");
// Clear a specific mapping (falls back to default document)
client.clearDocumentModelMapping("Contact");
// Change the default document
await client.documents.open("new-default-doc");
client.setDefaultDocumentId("new-default-doc");
// Clear the default (models without mappings will error)
client.clearDefaultDocumentId();Querying Current Configuration
// Check what document a model is using
const taskDocId = client.getDocumentModelMapping("Task");
if (taskDocId) {
console.log("Task model uses document:", taskDocId);
} else {
console.log("Task model has no explicit mapping");
}
// Check the default document
const defaultId = client.getDefaultDocumentId();
if (defaultId) {
console.log("Default document:", defaultId);
} else {
console.log("No default document set");
}
// List all currently open documents
const openDocs = client.listOpenDocuments();
console.log("Open documents:", openDocs);Best Practices
- Open before mapping: Always open documents before setting them as default or mapping models to them
- Set default first: Establish a default document before adding specific mappings to avoid errors
- Organize by domain: Group related models in the same document (e.g., all user-related models in one doc)
- Document lifecycle: Clear mappings when closing documents to avoid stale references
- Multi-tenant apps: Use different document mappings per tenant/workspace
Error Handling
try {
// This will throw if the document isn't open
client.setDefaultDocumentId("not-open-yet");
} catch (error) {
console.error("Document must be open first");
}
try {
// This will throw if no mapping or default exists
await UnmappedModel.query();
} catch (error) {
console.error("No document mapping for this model");
}Use Case: Workspace Switching
async function switchWorkspace(workspaceId: string) {
// Open the workspace's document
const { doc } = await client.documents.open(workspaceId);
// Update all model mappings to this workspace
client.setDefaultDocumentId(workspaceId);
client.addDocumentModelMapping("Task", workspaceId);
client.addDocumentModelMapping("Note", workspaceId);
client.addDocumentModelMapping("Contact", workspaceId);
// All subsequent queries now use the new workspace's document
const tasks = await Task.query(); // → reads from workspaceId
}Use Case: Per-Model Documents
// Organize models by type across different documents
await Promise.all([
client.documents.open("people-doc"),
client.documents.open("projects-doc"),
client.documents.open("calendar-doc"),
]);
// Set up routing
client.addDocumentModelMapping("Contact", "people-doc");
client.addDocumentModelMapping("Company", "people-doc");
client.addDocumentModelMapping("Project", "projects-doc");
client.addDocumentModelMapping("Task", "projects-doc");
client.addDocumentModelMapping("Event", "calendar-doc");
client.addDocumentModelMapping("Reminder", "calendar-doc");
// No default needed - all models are explicitly mappedAwareness (User Presence)
The JsBaoClient provides two awareness APIs. Use the DocumentsAPI methods for convenience:
// Set your awareness state (cursor, selection, user info) - Convenience API
client.documents.setAwareness(documentId, {
user: {
name: "John Doe",
email: "john@example.com",
color: "#ff0000",
},
cursor: {
line: 10,
column: 5,
},
selection: {
start: { line: 10, column: 5 },
end: { line: 10, column: 12 },
},
});
// Get all awareness states - Convenience API
const awarenessStates = client.documents.getAwarenessStates(documentId);
awarenessStates.forEach((state, clientId) => {
console.log(`Client ${clientId}:`, state.user?.name, state.cursor);
});
// Advanced awareness API for more control
client.setLocalAwarenessState(documentId, {
user: { name: "Jane Doe", color: "#00ff00" },
cursor: { line: 5, column: 10 },
});
// Listen for awareness changes
client.on("awareness", ({ documentId, added, updated, removed }) => {
console.log(`Awareness changed for ${documentId}`);
console.log("Added clients:", added);
console.log("Updated clients:", updated);
console.log("Removed clients:", removed);
});User Profile and Session
// Get current user profile
const profile = await client.me.get();
console.log("User:", profile.name, profile.email);
console.log("App role:", profile.appRole);
console.log("User ID:", profile.userId);
console.log("Avatar:", profile.avatarUrl);
// Update user profile (name and/or external avatar URL)
const updated = await client.me.update({
name: "New Display Name",
});
// Set an external avatar URL
await client.me.update({
avatarUrl: "https://example.com/my-avatar.png",
});
// Clear avatar URL
await client.me.update({
avatarUrl: null,
});
// Upload avatar image (stored by the platform, proxied through the API)
const imageBlob = await fetch("/path/to/image.png").then((r) => r.blob());
const { avatarUrl } = await client.me.uploadAvatar(imageBlob, "image/png");
console.log("New avatar URL:", avatarUrl);
// Upload from ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
await client.me.uploadAvatar(arrayBuffer, "image/jpeg");
// Get session information
const session = await client.session.get();
console.log("Session ID:", session.sessionId);
console.log("Session expires:", session.expiresAt);
console.log("Last activity:", session.lastActivity);Error Handling
HTTP operations throw typed errors so you can branch on the kind of failure without parsing message strings:
JsBaoApiError— the server responded with a non-2xx status. Carries:status: number— the HTTP status code (always present).code?: string— a stable, machine-readable code from the server body (e.g."alias_exists"on a 409), orundefinedwhen the server didn't supply one.body?: unknown— the parsed JSON error body ({ error, status, timestamp, code?, details? }), the raw text for a non-JSON body, ornullfor an empty body.message— keeps the legacyHTTP <status>: <text>prefix, so older code that regex-parses.messagekeeps working.
JsBaoNetworkError— the request never reached the server (unreachable host, refused connection, DNS failure, abort). A network failure is inherently retryable; decide retries withinstanceof JsBaoNetworkError.JsBaoError— client-side logical errors with acode(e.g."OFFLINE"when the client is in offline mode). Unchanged.
Use the guards isJsBaoApiError / isJsBaoNetworkError / isJsBaoError, or instanceof. Neither JsBaoApiError nor JsBaoNetworkError exposes a retryable field — the class already tells you whether a retry makes sense.
import {
JsBaoApiError,
JsBaoNetworkError,
isJsBaoApiError,
isJsBaoNetworkError,
} from "js-bao-wss-client";
try {
const res = await client.documents.create({ title: "Test" });
} catch (error) {
if (isJsBaoNetworkError(error)) {
console.error("Server unreachable — safe to retry");
} else if (isJsBaoApiError(error)) {
switch (error.status) {
case 401:
console.error("Authentication failed");
break;
case 403:
console.error("Permission denied");
break;
case 404:
console.error("Not found");
break;
default:
console.error(`API error ${error.status} (${error.code ?? "no code"})`);
}
} else if ((error as any).code === "OFFLINE") {
console.error("Client offline");
} else {
throw error;
}
}Idempotent add/remove without string parsing
Branch on status/code to treat an expected conflict as success:
// Adding an item that already exists (409) is success for an idempotent add.
async function addOnce() {
try {
await client.makeRequest("PUT", `/document-aliases/app/${key}`, {
documentId,
mustNotExist: true,
});
return "created";
} catch (error) {
if (isJsBaoApiError(error) && error.status === 409) {
return "already-exists"; // idempotent — not a real failure
}
throw error;
}
}// WebSocket errors are handled via events client.on("connection-error", (error) => { console.error("WebSocket error:", error); });
client.on("auth-failed", ({ message }) => { console.error("Auth failed:", message); // Refresh token or redirect to login });
### Auth Error Codes
Authentication methods (OAuth, Magic Link, Passkey) throw `AuthError` with machine-readable codes for programmatic error handling:
```typescript
import { AuthError, AUTH_CODES } from "js-bao-wss-client";
// Available error codes:
// AUTH_CODES.ADDED_TO_WAITLIST - User added to waitlist (invite-only app)
// AUTH_CODES.INVITATION_REQUIRED - User needs invitation to access app
// AUTH_CODES.DOMAIN_NOT_ALLOWED - User's email domain not in allowlist
// AUTH_CODES.INVALID_TOKEN - Token is invalid or malformed
// AUTH_CODES.TOKEN_EXPIRED - Token has expired
// AUTH_CODES.PASSKEY_NOT_ENABLED - Passkey auth not enabled for app
// AUTH_CODES.MAGIC_LINK_NOT_ENABLED - Magic link auth not enabled for app
// AUTH_CODES.WAITLIST_ENTRY_UPDATED - Waitlist entry was updated (success code)
try {
await client.handleOAuthCallback(code, state);
} catch (error) {
if (error instanceof AuthError) {
switch (error.code) {
case AUTH_CODES.ADDED_TO_WAITLIST:
showWaitlistMessage("You've been added to the waitlist.");
break;
case AUTH_CODES.INVITATION_REQUIRED:
showError("You need an invitation to access this app.");
break;
case AUTH_CODES.DOMAIN_NOT_ALLOWED:
showError("Your email domain is not allowed.");
break;
default:
showError(error.message);
}
}
}
// Check error code without instanceof
if ((error as any).code === AUTH_CODES.ADDED_TO_WAITLIST) {
// Handle waitlist case
}The AuthError class extends Error with a code property containing the machine-readable error code. This enables reliable error handling without parsing error messages.
Token Management
// Refresh token when it expires
client.refreshToken("newJwtToken");
// Handle auth failures
client.on("auth-failed", async ({ message }) => {
try {
// Get new token from your auth system
const newToken = await refreshAuthToken();
client.refreshToken(newToken);
} catch (error) {
// Redirect to login page
window.location.href = "/login";
}
});
// Manual token management
client.setToken("new-jwt-token"); // Also calls refreshToken internally
const currentToken = client.getToken();
const isAuthenticated = client.isAuthenticated();Connection Management
// Check connection status
if (client.isConnected()) {
console.log("WebSocket is connected");
}
// Check document sync status
if (client.documents.isSynced(documentId)) {
console.log("Document is synced");
}
// Manually control connection (returns Promise)
await client.setShouldConnect(false); // Disconnect
await client.setShouldConnect(true); // Reconnect
// Manual disconnect (returns Promise)
await client.disconnect();
// Clean up when done (closes all documents and connections)
await client.destroy();Logout, Offline Identity, and Offline Grant Discovery
The client provides a unified logout that clears the server refresh cookie (best-effort), shuts down networking, and resets in-memory auth. Apps can control how offline capability behaves post-logout.
await client.logout({
redirectTo: "/login", // optional redirect
wipeLocal: false, // if true, evict local document caches
revokeOffline: false, // if true, delete the stored offline grant
clearOfflineIdentity: true, // default true; if false, keep in-memory offlineIdentity
});- If
clearOfflineIdentityistrue(default): the client clears the in-memoryofflineIdentityduring logout so the instance is not considered authenticated until the user explicitly unlocks offline. - If
clearOfflineIdentityisfalse: the client keeps the in-memoryofflineIdentity. This can be desirable in apps that want the offline persona to remain available immediately after sign out.
Offline grant persistence and discovery:
- Enabling or unlocking offline stores a signed grant in a user-scoped IndexedDB database named
js-bao:offline:{appId}:{userId}. - The client remembers the last offline-capable user id in
localStorageunderjs-bao:last-user:{appId}. hasOfflineGrantStored()detects grants even after logout by discovering the last user vialocalStorageor (when available) enumeratingindexedDB.databases().
Client-managed Auth Orchestration
New client options:
const client = await initializeClient({
// ...
autoOAuth: true, // auto-start OAuth if still unauthenticated
oauthRedirectUri, // required when autoOAuth is true
suppressAutoLoginMs: 5000, // suppress silent refresh/OAuth for ~5s after logout
autoUnlockOfflineOnInit: true, // when offline and a grant exists, unlock at startup
databaseConfig: { type: "sqljs" },
});Behavior at startup (when no token):
- If
autoUnlockOfflineOnInitand device is offline and a grant exists, the client unlocks offline, opens user-scoped IndexedDB (metadata + offline), and emitsofflineAuth:unlocked. - Otherwise, if not within
suppressAutoLoginMs, the client attempts a silent cookie refresh to mint a JWT. - If still unauthenticated and
autoOAuthis true, the client auto-starts OAuth.
Ongoing refresh:
- The client proactively refreshes tokens before expiry and retries once on
401before emittingauth-failed.
Apps should subscribe to events (auth-success, auth-failed, offlineAuth:unlocked) to mirror session state into their UI/store.
Complete Example: Collaborative Text Editor
import { JsBaoClient, initializeClient } from "js-bao-wss-client";
import * as Y from "yjs";
class CollaborativeEditor {
private client!: JsBaoClient;
private ydoc: Y.Doc | null = null;
private ytext: Y.Text | null = null;
static async create(
apiUrl: string,
wsUrl: string,
appId: string,
token?: string
): Promise<CollaborativeEditor> {
const client = await initializeClient({
apiUrl,
wsUrl,
appId,
token,
databaseConfig: { type: "sqljs" },
autoOAuth: !token,
oauthRedirectUri: window.location.origin + "/oauth/callback",
});
const editor = new CollaborativeEditor();
editor.client = client;
editor.setupEventListeners();
return editor;
}
private constructor() {}
private setupEventListeners() {
// Connection status
this.client.on("status", ({ status }) => {
this.updateConnectionUI(status);
});
// Document sync
this.client.on("sync", ({ documentId, synced }) => {
this.updateSyncUI(synced);
});
// User awareness
this.client.on("awareness", ({ documentId, added, updated, removed }) => {
this.updateCursors(added, updated, removed);
});
// Auth events
this.client.on("auth-failed", () => {
this.handleAuthFailure();
});
}
async createDocument(title: string) {
const { metadata } = await this.client.documents.create({ title });
return metadata.documentId;
}
async openDocument(documentId: string) {
// Open for real-time editing
const { doc } = await this.client.documents.open(documentId, {
waitForLoad: "localIfAvailableElseNetwork",
enableNetworkSync: true,
});
this.ydoc = doc as Y.Doc;
this.ytext = this.ydoc.getText("content");
// Listen for text changes
this.ytext.observe((event) => {
this.updateEditorContent();
});
// Set initial awareness
this.client.documents.setAwareness(documentId, {
user: { name: "Current User", color: "#0066cc" },
cursor: { line: 0, column: 0 },
});
// Check if read-only
if (this.client.documents.isReadOnly(documentId)) {
this.setEditorReadOnly(true);
}
return this.ytext;
}
insertText(index: number, text: string, formatting?: any) {
if (
this.ytext &&
!this.client.documents.isReadOnly(this.getCurrentDocumentId())
) {
this.ytext.insert(index, text, formatting);
}
}
deleteText(index: number, length: number) {
if (
this.ytext &&
!this.client.documents.isReadOnly(this.getCurrentDocumentId())
) {
this.ytext.delete(index, length);
}
}
updateCursor(line: number, column: number, documentId: string) {
this.client.documents.setAwareness(documentId, {
user: { name: "Current User", color: "#0066cc" },
cursor: { line, column },
});
}
async inviteUser(
documentId: string,
email: string,
permission: "read-write" | "reader"
) {
try {
const grant = await this.client.documents.updatePermissions(documentId, {
email,
permission,
});
console.log("Shared with", email, grant);
return grant;
} catch (error) {
console.error("Failed to invite user:", error);
throw error;
}
}
private updateConnectionUI(status: string) {
// Update your UI to show connection status
console.log("Connection:", status);
}
private updateSyncUI(synced: boolean) {
// Update your UI to show sync status
console.log("Synced:", synced);
}
private updateCursors(added: string[], updated: string[], removed: string[]) {
// Update cursor display in your editor
console.log("Cursors changed:", { added, updated, removed });
}
private updateEditorContent() {
// Update your editor's display
if (this.ytext) {
const content = this.ytext.toString();
console.log("Content updated:", content);
}
}
private setEditorReadOnly(readOnly: boolean) {
// Set your editor to read-only mode
console.log("Editor read-only:", readOnly);
}
private handleAuthFailure() {
// Handle authentication failure
if (this.client.checkOAuthAvailable()) {
this.client.startOAuthFlow();
} else {
// Redirect to custom login
window.location.href = "/login";
}
}
private getCurrentDocumentId(): string {
// Return current document ID
return "current-doc-id";
}
async closeDocument(documentId: string) {
this.client.documents.close(documentId);
this.ydoc = null;
this.ytext = null;
}
async destroy() {
await this.client.destroy();
}
}
// Usage
const editor = new CollaborativeEditor(
"https://api.example.com",
"wss://ws.example.com",
"my-app-id",
"optional-jwt-token" // Omit for OAuth flow
);
// Create and open a document
const documentId = await editor.createDocument("My Collaborative Doc");
const ytext = await editor.openDocument(documentId);
// Make some edits
editor.insertText(0, "Hello, collaborative world!");
// Invite a user
await editor.inviteUser(documentId, "colleague@example.com", "read-write");Best Practices
- Always handle auth failures: Set up proper token refresh logic or OAuth flow
- Listen for sync events: Don't assume documents are immediately synced
- Clean up resources: Call
documents.close()on documents anddestroy()on client - Use awareness wisely: Update cursor/selection frequently but throttle updates
- Handle network issues: The client auto-reconnects, but update your UI accordingly
- Batch operations: Use batch permission updates when possible
- Check permissions: Use
validateAccess()andisReadOnly()before making changes - Use invitations: Invite users via email instead of requiring user IDs
TypeScript Support
The library is written in TypeScript and provides full type definitions for all APIs, events, and data structures.
Local Development & Testing
For contributors and developers working on this package:
Testing Locally
See LOCAL_TESTING.md for a comprehensive guide on testing your changes locally using npm pack.
Quick test:
pnpm run build && pnpm pack
cd ../../../ && mkdir test-package && cd test-package
npm init -y && npm install ../js-bao-wss/src/client/js-bao-wss-client-1.0.0.tgz
echo 'import {JsBaoClient} from "js-bao-wss-client"; console.log("✅ Works!")' > test.js
sed -i '' 's/"type": "commonjs"/"type": "module"/' package.json && node test.jsRunning Built-in Tests
cd tests
pnpm install
pnpm run test:esm # Test ESM imports
pnpm run test:umd # Instructions for UMD testingBuild Commands
pnpm run build # Build both ESM and UMD
pnpm run build:esm # Build ESM only
pnpm run build:umd # Build UMD only
pnpm pack # Create publishable packageInternal Architecture
OfflineStore Storage Initialization
The OfflineStore class handles all persistent storage operations (metadata, grants, analytics, JWT persistence). It uses a lazy initialization pattern where storage is only created when first needed.
Key methods:
ensureInitialized(ctx: MetadataContext)- For user-scoped data (metadata, grants, analytics). Requiresctx.userId. CallsstorageProvider.init(userId)to namespace storage by user.ensureAuthInitialized(ctx: AuthTokenContext)- For auth data (JWT persistence). Does NOT require userId since we need to load persisted JWTs before knowing who the user is. Uses namespace likeauth:${appId}:${namespace}.
Both methods call ensureStorage() internally, which invokes the storage initializer provided by JsBaoClient to create the storage provider.
Rule for writing new OfflineStore methods:
Every public method that accesses storage must follow this pattern:
async newStorageMethod(ctx: MetadataContext, ...): Promise<...> {
// 1. Early exit for missing required context (before async work)
if (!ctx.userId) return ...;
// 2. Ensure storage is initialized for this context
await this.ensureInitialized(ctx); // or ensureAuthInitialized for auth methods
// 3. Check if storage is actually available (initialization might have failed)
if (!this.storageProvider || !this.storageProvider.isReady()) return ...;
// 4. Do the actual storage operation
await this.storageProvider.get/put/delete(...);
}Important: The ensure* call must come BEFORE the !this.storageProvider check, otherwise the method will return early without ever initializing storage.
This pattern is internal to OfflineStore. Code outside OfflineStore (like JsBaoClient) doesn't need to think about initialization - just call the public OfflineStore methods and they handle it internally.