Client library for js-bao-wss Yjs WebSocket service
npm install js-bao-wss-clientA 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.
- Document Management: Create, list, update, delete via HTTP
- Permissions: Get/update/remove document permissions
- 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
- LLM: Chat API and model listing
- Workflows: Server-side multi-step processes with LLM, delays, and transformations
- 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
``bash
npm install js-bao-wss-client
Quick Start
> Migrating from legacy decorators? Follow
src/client/docs/js-bao-v2-migration.md before continuing—models must now be defined with defineModelSchema/createModelClass.$3
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:`typescript
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;
interface Contact extends ContactAttrs, BaseModel {}
const Contact: TypedModelConstructor = 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,
connectivityProbeTimeoutMs: 2000,
onConnectivityCheck: undefined,
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 that await initializeClient(...) is valid.#### Default behaviors
-
offline mode is enabled unless you pass offline: false.
- databaseConfig defaults to { type: "sqljs" }. Supply a different engine only if you need it.$3
`typescript
// 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 }) => {});
`$3
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_json accepts 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.
- client.getLlmAnalyticsContext(): returns { logEvent, isEnabled } when any LLM auto phases are enabled so higher-level features can coordinate their own analytics.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", respects analyticsAutoEvents.minResumeMs): fired when the tab becomes visible after being hidden long enough. context_json.trigger indicates "visibility" or "manual".
- client_boot ("session", toggle: analyticsAutoEvents.boot): exactly once per client instance.
- first_doc_open / first_doc_edit ("documents", toggles: analyticsAutoEvents.firstDocOpen, analyticsAutoEvents.firstDocEdit): include the triggering documentId.
- offline_recovery ("network", toggle: analyticsAutoEvents.offlineRecovery.enabled, throttled by minIntervalMs): logged when moving from offline back to online.
- sync_error ("sync", toggle: analyticsAutoEvents.syncErrors.enabled): records the documentId and reason when 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).
- service_worker_control / service_worker_token_update ("service_worker", toggles: analyticsAutoEvents.serviceWorker.{control|tokenUpdate}): cover the bridge taking control and forwarding refreshed tokens (context_json.cause when available).
- session_end ("session", toggle: analyticsAutoEvents.sessionEnd): emitted on beforeunload and client.destroy(), including duration_ms and exit reason.
- gemini_request_started / gemini_request_succeeded / gemini_request_failed ("gemini", toggles: analyticsAutoEvents.gemini.{start|success|failure} or boolean): emitted for client.gemini.generate() and client.gemini.countTokens() lifecycles with model/context metadata.When
analyticsAutoEvents.llm enables any of start, success, or failure, client.getLlmAnalyticsContext() becomes non-null so LLM helpers can emit structured events without guessing configuration. analyticsAutoEvents.gemini controls client.getGeminiAnalyticsContext(), which powers automatic logging inside the Gemini namespace.Example configuration:
`ts
const client = await initializeClient({
...options,
analyticsAutoEvents: {
firstDocEdit: false, // suppress milestone if your app logs a custom event
blobUploads: { start: false, success: true, failure: true },
llm: { start: true, success: true, failure: 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.$3
- auth-failed: Access token invalid/expired and refresh failed. Use this to trigger reauthentication.
- Payload:
{ reason?: string; message?: string }
- 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 }
- 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" }Minimal example to react when reauthentication is needed:
`typescript
const promptLogin = () => navigateToLogin();client.on("auth-failed", promptLogin);
client.on("auth:onlineAuthRequired", promptLogin);
client.on("auth:state", ({ authenticated }) => {
if (!authenticated) promptLogin();
});
`$3
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:
`ts
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.
-
storageKeyPrefix lets 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 (persistJwtInStorage omitted) continue to run fully in-memory.
- Offline grants are unaffected; long-lived offline access still hinges on the encrypted grant workflow.$3
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:
`ts
const client = await initializeClient({
...options,
auth: {
refreshProxy: {
baseUrl: ${window.location.origin}/proxy,
cookieMaxAgeSeconds: 7 24 60 * 60, // optional override (defaults to 7 days)
},
},
});
`-
baseUrl should be an absolute URL pointing to the first-party worker prefix that forwards to /app/:appId/api/auth/*.
- cookieMaxAgeSeconds lets you shorten/extend the refresh cookie TTL; omit it to use the worker default.
- Set enabled: false when you share config across environments but only want the proxy in production.
- Leave auth.refreshProxy undefined to preserve the existing direct-to-API behaviour.
- In local Vite development the sample app leaves the proxy disabled; set VITE_USE_REFRESH_PROXY=true to test the worker path locally.$3
`typescript
// 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.
`$3
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 null for evicted/deleted- Metadata fields (may be partially present):
-
documentId: string
- title?: string
- lastKnownPermission?: "owner" | "read-write" | "reader" | "admin" | null
- permissionCachedAt?: 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?: boolean
- pendingCreate?: 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. changedFields often includes createdAt, pendingCreate, localOnly, and optionally title.
- updated/local: after local changes such as documents.update(...) (optimistic title), sync status updates (lastSyncedAt, hasUnsyncedLocalChanges), or localBytes refresh.
- updated/server: after documents.list({ refreshFromServer: true }) or network-first list merges server metadata (e.g., title, permission), changedFields reflects updated properties.
- evicted/local: after documents.evict(id) or documents.evictAll(...); metadata is null.
- deleted/server or local: the first delete seen (server push, list refresh, or local documents.delete) emits a single deleted event; subsequent delete/evict/list refreshes for the same doc are suppressed to avoid duplicates (including 404/offline fallbacks after a successful delete).Example listener:
`ts
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.
`typescript
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); the indexeddb leg waits for replay plus SQLite/SQL.js indexing, the server leg 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 be null for evicted/deleted
- pendingCreateCommitted / pendingCreateFailed
- Existing: sync, status, awareness, connection-error, connection-close$3
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
`typescript
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 mode
`Metadata 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
documents.list(...); the former documents.listLocal() is removed.`typescript
// List documents (cache-first with background refresh by default)
const docs = await client.documents.list({
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
documents.list() 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 pass includeRoot: true.
- Cache updates emit documentMetadataChanged events (typically with action: "updated", source: "server").
- Local eviction emits documentMetadataChanged with action: "evicted", metadata: null.
- Delete emits a single documentMetadataChanged with action: "deleted", then evicts locally without a second emission.$3
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
refreshFromServer is 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 code LIST_UNAVAILABLE_OFFLINE.
- "localIfAvailableElseNetwork" (default):
- If any cached metadata exists, return it immediately and, when refreshFromServer is true, refresh in the background.
- If no cached metadata exists, block on the server (like "network").Additional flags:
-
includeRoot?: boolean — include per-user root document(s) in the results. This strictly follows waitForLoad; it does not force a blocking network call.
- refreshFromServer?: boolean (default true unless localOnly is 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).$3
- Paging params:
limit, cursor, forward, returnPage. Default sort is by grantedAt (document permission grant time) descending; pass forward: true for ascending.
- returnPage: true returns { items, cursor } (backward-compatible array when omitted). With refreshFromServer: true, a background page walker fetches the remaining pages and updates the cache/documentMetadataChanged events; set refreshFromServer: false if you only want the first page. Note: cursors come from server responses; local-only/local-first paths do not fabricate cursors or enforce limit sizing—use returnPage: true when you need a cursor even if data is cached. To synchronously walk all pages yourself, loop on { returnPage: true, limit, cursor } until cursor is null. To hydrate the full cache in one call, use client.syncMetadata({ scope: "all", pageSize, includeRoot }) and then list with refreshFromServer: false.
- Server page size defaults to 100 when limit is omitted; you can request smaller/larger pages within server limits.
- Tag filtering: tag: string performs an exact-match filter server-side. Responses include tags: string[] and grantedAt; both are cached locally and usable offline (e.g., list + tag will 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. When refreshFromServer is 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 or client.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.`typescript
// 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
documents.list() filters it out; pass includeRoot: true to surface it (works offline after it’s cached).`typescript
// Exclude root (default)
const docs = await client.documents.list();// Include root document(s)
const all = await client.documents.list({ includeRoot: true });
`Behavior changes
- Root documents listing:
documents.list() excludes root docs by default. Opt-in with { includeRoot: true }.
- Offline mode requests: When networkMode is "offline", HTTP calls fail fast with code OFFLINE.
- Open options: documents.open() uses { waitForLoad, enableNetworkSync, retainLocal, availabilityWaitMs }. Older options like waitForPermission, offlineWritePolicy, per-doc offline, provisionalPermission, and startNetwork are removed.
- Create return shape: documents.create() returns { metadata } (no Y.Doc).
- Pending create events: pendingCreateCommitted/pendingCreateFailed only (no pendingCreate at creation time).
- Evict-all flag: documents.evictAll({ onlySynced }) (replaces onlyUnsynced).OAuth Authentication
`typescript
// Check if OAuth is available
const hasOAuth = await client.checkOAuthAvailable();
if (hasOAuth) {
// 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");
`Magic Link Authentication
The client supports passwordless email authentication via magic links. Magic links must be enabled in the admin console for your app.
$3
`typescript
// Send a magic link email to the user
await client.magicLinkRequest("user@example.com");
`$3
`typescript
// 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);
// isNewUser is true if this is the user's first sign-in (account was just created)
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
}
}
`OTP (Email Code) Authentication
The client supports passwordless authentication via one-time 6-digit codes sent by email. OTP authentication must be enabled in the admin console for your app.
$3
`typescript
// Send a 6-digit code to the user's email
await client.otpRequest("user@example.com");
`The code is valid for 10 minutes. Rate limits apply (5 codes per email per hour, 20 per IP per hour).
$3
`typescript
// Verify the code and complete authentication
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
}
`$3
`typescript
try {
await client.otpVerify("user@example.com", "123456");
} catch (error) {
if (error.code === "OTP_NOT_ENABLED") {
// OTP auth not enabled for this app
} else 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 code
} else if (error.code === "INVALID_TOKEN") {
// Invalid or expired code
}
}
`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 Magic Link). To use passkey authentication:
1. User creates account via OAuth or Magic Link
2. User adds a passkey to their account
3. User can then sign in with the passkey on future visits
$3
`typescript
// 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.magicLinkEnabled) {
console.log("Magic link sign-in is available");
}
if (config.otpEnabled) {
console.log("OTP (email code) sign-in is available");
}
if (config.hasOAuth) {
console.log("Google OAuth is available");
}
`$3
`typescript
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
}
`$3
`typescript
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");
`$3
`typescript
// 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
$3
`typescript
// Create a new document (returns metadata)
const { metadata } = await client.documents.create({
title: "My New Document",
});
console.log("Created document:", metadata.documentId);// List all documents user has access to
const documents = await client.documents.list();
// 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 open
`$3
`typescript
// Create or update an app-scoped alias
await client.documents.aliases.set({
scope: "app",
aliasKey: "home",
documentId,
});// 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: "app",
aliasKey: "home",
});
// Open a document via alias (same return shape as documents.open)
const { doc } = await client.documents.openAlias({
scope: "app",
aliasKey: "home",
});
// List aliases for a document (admin-only on the server)
const aliases = await client.documents.aliases.listForDocument(documentId);
// Delete an alias (no error if already missing)
await client.documents.aliases.delete({ scope: "app", aliasKey: "home" });
`#### 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:
`typescript
// Create document with app-scoped alias (requires admin/owner role)
const result = await client.documents.createWithAlias({
title: "Home Page",
alias: {
scope: "app",
aliasKey: "home",
},
});console.log(result.documentId); // The created document ID
console.log(result.alias.aliasKey); // "home"
console.log(result.alias.documentId); // Same as documentId
// Create document with user-scoped alias
const userResult = await client.documents.createWithAlias({
title: "My Draft",
alias: {
scope: "user",
aliasKey: "current-draft",
},
});
// Attempting to create with existing alias throws HTTP 409
try {
await client.documents.createWithAlias({
title: "Another Home",
alias: { scope: "app", aliasKey: "home" },
});
} 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.$3
`typescript
// 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", // 'owner' | '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);
// Validate access to a document
const accessResult = await client.documents.validateAccess(documentId);
if (accessResult.hasAccess) {
console.log("User has access:", accessResult.permission);
if (accessResult.viaInvitation) {
console.log("Access via invitation");
}
}
`Blob Storage
Blobs are stored per document and inherit document permissions. The client exposes a
BlobsAPI namespace under documents.Access patterns:
`typescript
const blobs = client.document(documentId).blobs();
`$3
`typescript
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
`typescript
// 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",
});
`$3
`typescript
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 });
}
`$3
`typescript
const meta = await client.document(documentId).blobs().get(blobId);
console.log(meta.filename, meta.contentType, meta.size);
`$3
`typescript
// 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 = url
`$3
`typescript
await client.document(documentId).blobs().delete(blobId); // { deleted: true }
`$3
`typescript
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: true to refresh from the server even if cached.
- disposition mirrors the URL helper if you need server-side content handling hints.$3
`typescript
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.$3
`typescript
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());
`$3
- 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
forceRedownload to read/prefetch for 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:
1. Uploads while offline
`typescript
await 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.
2. Reads when offline
`typescript
const text = await client.document(documentId).blobs().read(blobId, {
as: "text",
});
// Works offline thanks to the cached bytes
`3. Coming back online
`typescript
await client.setNetworkMode("online");
// Queue processes automatically; listen to blobs:queue-drained for completion
`4. Prefetch before going offline
`typescript
await client.document(documentId).blobs().prefetch(importantBlobIds);
` Prefetched blobs remain available for subsequent offline
read() calls.5. Retention
- Set
retainLocal: false on upload options to drop cached bytes after success while leaving metadata intact.
- delete() removes queue entries, cached bytes, and server objects by default.$3
BlobManager caches blob responses in the shared Cache API (js-bao-blobs:) and now exposes helpers so UI code can coordinate with the service worker:`ts
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
/