"Vanilla JS"
Use Tether with plain JavaScript or integrate with any framework.
Installation
npm install @tthr/client
Creating a client
Create a Tether client instance:
import { TetherClient } from '@tthr/client';
const client = new TetherClient({
url: 'https://tether-api.yourdomain.com',
projectId: 'your-project-id',
authToken: 'your-auth-token', // or a function: () => getToken()
verbose: false, // Enable debug logging (optional)
});
Configuration options
interface TetherClientOptions {
url: string; // Tether API URL
projectId: string; // Your project ID
environment?: string; // Environment name (default: 'production')
publishableKey?: string; // Publishable key for SPA auth (tthr_pk_xxx)
authToken?: string | (() => string | Promise<string>); // JWT auth token
verbose?: boolean; // Enable debug logging (default: false)
}
SPA authentication
To call Tether functions directly from a browser without a backend proxy, use a publishable key. Publishable keys are safe to include in client-side code and are restricted by domain allowlist.
import { TetherClient } from '@tthr/client';
const client = new TetherClient({
url: 'https://tether-api.yourdomain.com',
projectId: 'your-project-id',
publishableKey: 'tthr_pk_xxxxxxxxxxxxx',
// JWT from your auth provider (Clerk, Auth0, etc.)
authToken: () => getAuthToken(),
});
// Queries and mutations work the same way
const posts = await client.query('posts.list');
await client.mutation('posts.create', { title: 'Hello' });
The publishable key is sent as the X-Tether-Key header. The JWT auth token is sent separately as Authorization: Bearer. Function access levels control which functions are available -- see the Functions docs.
Queries
Execute queries to read data:
// Simple query
const todos = await client.query('todos.list');
// Query with arguments
const activeTodos = await client.query('todos.list', { completed: false });
// Get a single item
const todo = await client.query('todos.get', { id: '123' });
Mutations
Execute mutations to modify data:
// Create
const newTodo = await client.mutation('todos.create', {
title: 'Buy milk',
});
// Update
await client.mutation('todos.update', {
id: '123',
completed: true,
});
// Delete
await client.mutation('todos.delete', { id: '123' });
Invalidating queries
After a mutation, you can invalidate cached queries to trigger a refetch:
// Invalidate specific queries after mutation
await client.mutation('todos.create', { title: 'New task' }, {
invalidates: ['todos.list', 'todos.count'],
});
// Or manually invalidate queries
client.invalidateQueries(['todos.list']);
Subscriptions
Subscribe to queries for realtime updates:
// Subscribe to a query
const unsubscribe = client.subscribe(
'todos.list',
{ completed: false },
(data) => {
console.log('Todos updated:', data);
renderTodos(data);
}
);
// Later, unsubscribe when done
unsubscribe();
Connection management
Manage the WebSocket connection for subscriptions:
// Connect explicitly (or let subscribe() connect automatically)
await client.connect();
// Disconnect when done
client.disconnect();
Connection resilience
The client automatically handles disconnections with heartbeat monitoring (ping every 20s) and exponential backoff reconnection. On reconnect, all active subscriptions are automatically re-established and missed changes are synced.
File storage
Upload and manage files with Cloudflare R2:
// Simple upload - handles everything
const file = document.querySelector('input[type="file"]').files[0];
const assetId = await client.storage.upload(file, {
metadata: { alt: 'Profile photo' }
});
// Save the assetId in your database
await client.mutation('profile.update', { photoId: assetId });
// Get download URL (expires after 1 hour)
const url = await client.storage.getUrl(assetId);
// Get asset metadata
const metadata = await client.storage.getMetadata(assetId);
console.log(metadata.filename, metadata.size, metadata.contentType);
// Delete an asset
await client.storage.delete(assetId);
Upload with progress
Track upload progress for large files:
const assetId = await client.storage.upload(file, {
onProgress: ({ loaded, total }) => {
const percent = Math.round((loaded / total) * 100);
console.log(`Upload progress: ${percent}%`);
}
});
Listing assets
List all assets for a project:
// List all assets
const { assets, totalCount } = await client.storage.list();
// With pagination
const page2 = await client.storage.list({ limit: 20, offset: 20 });
// Filter by content type
const images = await client.storage.list({ contentType: 'image/' });
Manual upload flow
For advanced use cases, you can control each step:
// 1. Generate a signed upload URL
const { uploadUrl, assetId, expiresAt } = await client.storage.generateUploadUrl({
filename: 'document.pdf',
contentType: 'application/pdf',
metadata: { category: 'reports' },
});
// 2. Upload directly to R2
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': 'application/pdf' },
});
// 3. Confirm the upload
const asset = await client.storage.confirmUpload(assetId);
Asset metadata
The AssetMetadata type returned from storage operations:
interface AssetMetadata {
id: string; // Unique asset ID
filename: string; // Original filename
contentType: string; // MIME type
size: number; // Size in bytes
sha256?: string; // Hash if provided
createdAt: string; // ISO timestamp
metadata?: object; // Custom metadata
}
Server-side client
For server-side usage (Node.js, Deno, Bun, serverless functions), use TetherServerClient. This is a lightweight HTTP-only client without WebSocket connections.
import { TetherServerClient } from '@tthr/client';
const tether = new TetherServerClient({
url: process.env.TETHER_URL,
projectId: process.env.TETHER_PROJECT_ID,
apiKey: process.env.TETHER_API_KEY,
verbose: process.env.TETHER_VERBOSE === 'true', // optional
});
// Execute queries
const users = await tether.query('users.list', { limit: 10 });
// Execute mutations
const user = await tether.mutation('users.create', { name: 'Alice' });
// Execute actions (external side effects)
await tether.action('emails.send', { to: '[email protected]' });
// Storage operations work the same way
const url = await tether.storage.getUrl(assetId);
When to use TetherServerClient
Use TetherServerClient when you don't need realtime subscriptions:
- API routes and webhooks
- Background jobs and cron tasks
- Server-side rendering (SSR)
- Serverless functions (Vercel, Cloudflare Workers, AWS Lambda)
- CLI tools and scripts
Server client vs browser client
// TetherClient (browser)
// - WebSocket connection for realtime subscriptions
// - Heartbeat monitoring and auto-reconnection
// - Query invalidation across subscriptions
// - Use for: frontend apps with realtime updates
// TetherServerClient (server)
// - HTTP-only, no WebSocket overhead
// - Stateless, perfect for serverless
// - Supports queries, mutations, actions, and storage
// - Use for: API routes, webhooks, background jobs