"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