Volcano Realtime Skill
Role
Implement live updates with deterministic realtime lifecycle handling. This skill is self-contained: connection lifecycle, all three channel types (postgres / broadcast / presence), token refresh, and cleanup are embedded.
Workflow
- Enable realtime for the project —
realtime: { enabled: true }involcano-config.yaml, thenconfig deploy(off by default; see "Enable realtime"). - Initialize
VolcanoRealtimewithapiUrl,anonKey, andaccessToken(orgetToken). connect(), then create channels with the righttype.- Register handlers BEFORE calling
subscribe(). - Always pair
subscribe()withunsubscribe()on teardown; pairconnect()withdisconnect(). - Subscriptions respect RLS — channel scope is minimal (specific table/event).
Dependencies
centrifugeis required.wsonly for Node.js server-side realtime usage. The SDK uses the browser's nativeWebSocketautomatically andwsautomatically in Node.js — provide a custom implementation only for advanced cases (see "Custom WebSocket Implementation" below).
Enable realtime (required)
Realtime is disabled by default for every project — local and cloud alike
(enabled: false). Until you enable it, every connection is rejected with
realtime disabled for project. Declare it in volcano-config.yaml and deploy:
realtime:
enabled: true # required — off by default
# broadcast_enabled: true # optional, default true
# presence_enabled: true # optional, default true
# postgres_changes_enabled: true # optional, default true
Apply with volcano config deploy (local) / volcano cloud config deploy
(cloud). This is the only realtime setup that isn't app code — the same
config governs both environments, so a realtime app verified locally deploys to
the cloud unchanged. Everything else (apiUrl/anonKey/accessToken, channels,
handlers) is identical across environments. In local mode, apiUrl is
http://localhost:8000 with the local anon key from volcano status, and you
can sign in as the shipped default user (clearwater@volcano.dev) to get an
accessToken without building a signup flow first.
Initialization
import { VolcanoRealtime } from '@volcano.dev/sdk/realtime';
const realtime = new VolcanoRealtime({
apiUrl: 'https://api.yourproject.volcano.dev',
anonKey: 'your-anon-key',
accessToken: volcano.accessToken, // JWT from auth session
});
await realtime.connect();
Browser Origins and CORS
Browser WebSocket connections include an Origin header. When CORS is enabled for your project, that origin must be listed in the project's auth CORS allowed origins or the WebSocket upgrade is rejected before authentication completes — connect fails silently from the client's point of view.
For local development, add your app origin (e.g. http://localhost:3000) to the project's auth CORS allowed origins in the Volcano dashboard.
Server-side Node.js connections usually do not send an Origin header and are not blocked by browser CORS checks.
Symptoms of a CORS misconfiguration:
realtime.connect()resolves butonConnectnever fires.onErrorfires with an opaque WebSocket error (no message).- Browser DevTools shows the WebSocket request with status
Failedor(blocked). - Same setup works on production but not on localhost (or vice versa).
Fix: add the origin to the project's auth CORS allowed origins, then reconnect.
Custom WebSocket Implementation
Most applications do not need this. The SDK uses the browser's native WebSocket in browsers and ws in Node.js. For Node.js tests or advanced server-side clients that need to inject custom headers (e.g., a fixed Origin header to satisfy CORS in a non-browser context), pass a webSocket constructor:
import WebSocket from 'ws';
class OriginWebSocket extends WebSocket {
constructor(address, protocols, options = {}) {
super(address, protocols, {
...options,
headers: {
...options.headers,
Origin: 'https://app.example.com',
},
});
}
}
const realtime = new VolcanoRealtime({
apiUrl: 'https://api.yourproject.volcano.dev',
anonKey: 'your-anon-key',
accessToken: volcano.accessToken,
webSocket: OriginWebSocket,
});
The webSocket config takes any WebSocket-compatible constructor — useful for tests that mock the transport, or for environments that need TLS client certs / proxy support beyond what ws exposes by default.
Connection Events
realtime.onConnect((ctx) => {
// ctx.client, ctx.latency
});
realtime.onDisconnect((ctx) => {
// ctx.reason, ctx.reconnect (auto-reconnect with exponential backoff)
});
realtime.onError((ctx) => {
// ctx.message
});
Postgres Changes — live DB events
Setup
const channel = realtime.channel('my-changes', { type: 'postgres' });
The signature is channel.onPostgresChanges(eventType, schema, table, callback). The schema argument is the Postgres schema name — typically 'public' for application tables.
Listen for ALL events on a table
channel.onPostgresChanges('*', 'public', 'posts', (change) => {
// change.type: 'INSERT' | 'UPDATE' | 'DELETE'
// change.table, change.schema, change.timestamp
// INSERT: change.record
// UPDATE: change.record, change.old_record, change.columns
// DELETE: change.old_record
});
await channel.subscribe();
Filter by event type
channel.onPostgresChanges('INSERT', 'public', 'messages', (c) => addMessage(c.record));
channel.onPostgresChanges('UPDATE', 'public', 'posts', (c) => updatePost(c.record));
channel.onPostgresChanges('DELETE', 'public', 'posts', (c) => removePost(c.old_record.id));
Multiple tables on one channel
const channel = realtime.channel('app-changes', { type: 'postgres' });
channel.onPostgresChanges('*', 'public', 'posts', handlePostChange);
channel.onPostgresChanges('*', 'public', 'comments', handleCommentChange);
channel.onPostgresChanges('*', 'public', 'reactions', handleReactionChange);
await channel.subscribe();
RLS interaction
Each user only receives events for rows their RLS policy allows them to see. Same channel, different deliveries per user.
Broadcast — ephemeral pub/sub
Messages aren't persisted; only currently subscribed clients receive them.
Setup
const channel = realtime.channel('notifications', { type: 'broadcast' });
Send and receive
channel.on('notification', (data) => {
showNotification(data.title, data.message);
});
channel.on('*', (data, ctx) => {
// catch-all listener
});
await channel.subscribe();
await channel.send({
type: 'notification',
title: 'New Feature!',
message: 'Check out our latest update',
});
Use cases
- Typing indicators
- Cursor positions in collaborative editors
- System-wide notifications
- Game-state synchronization
Typing indicator pattern
const channel = realtime.channel('chat-room-123', { type: 'broadcast' });
channel.on('typing', (data) => {
if (data.user_id !== currentUser.id) showTyping(data.user_id);
});
channel.on('stopped_typing', (data) => hideTyping(data.user_id));
await channel.subscribe();
let typingTimer: any;
function onInput() {
channel.send({ type: 'typing', user_id: currentUser.id });
clearTimeout(typingTimer);
typingTimer = setTimeout(() => {
channel.send({ type: 'stopped_typing', user_id: currentUser.id });
}, 2000);
}
Presence — who's online and their state
Setup
const channel = realtime.channel('lobby', { type: 'presence' });
Track and listen
await channel.subscribe();
await channel.track({
user_id: currentUser.id,
username: currentUser.name,
status: 'online',
avatar: currentUser.avatar_url,
});
channel.onPresenceSync((state) => {
// state: { [clientId]: userData }
const data]) => ({ clientId, ...data }));
updateOnlineUsersList(onlineUsers);
});
Read state at any time
const state = channel.getPresenceState();
for (const [clientId, userData] of Object.entries(state)) {
// ...
}
Update state
await channel.track({
user_id: currentUser.id,
username: currentUser.name,
status: 'away',
last_seen: new Date().toISOString(),
});
Channel Management
channel.unsubscribe();
realtime.removeChannel('my-channel', 'postgres');
realtime.removeAllChannels();
realtime.isConnected(); // boolean
realtime.disconnect();
Dynamic Token Refresh — accessToken vs getToken
Use accessToken for short-lived UI sessions where the page lifecycle is bounded by JWT expiry (~1 hour). Use getToken for long-lived clients — background tabs, desktop/native apps, edge workers, server-side polling — so the realtime client refreshes the JWT seamlessly without dropping the connection.
const realtime = new VolcanoRealtime({
apiUrl: 'https://api.example.com',
anonKey: 'anon-key',
getToken: async () => {
const { session } = await volcano.auth.refreshSession();
return session.access_token;
},
});
Auto-Fetch Integration with VolcanoAuth
const volcano = new VolcanoAuth({ ... });
volcano.database('your_database_name');
const realtime = new VolcanoRealtime({
apiUrl: 'https://api.example.com',
anonKey: 'anon-key',
accessToken: volcano.accessToken,
volcanoClient: volcano, // enables auto-fetch lightweight mode
databaseName: 'your_database_name', // optional if database(...) was called
});
TypeScript
import {
VolcanoRealtime,
RealtimeChannel,
PostgresChange,
PresenceState,
ConnectContext,
DisconnectContext,
ErrorContext,
} from '@volcano.dev/sdk/realtime';
Initial Fetch + Subscribe Pattern
// Load initial data
const { data: posts } = await volcano
.from('posts')
.select('*')
.order('created_at', { ascending: false })
.limit(50);
setPosts(posts ?? []);
// Subscribe for updates
channel.onPostgresChanges('INSERT', 'public', 'posts', (c) => {
setPosts((cur) => [c.record, ...cur]);
});
channel.onPostgresChanges('UPDATE', 'public', 'posts', (c) => {
setPosts((cur) => cur.map((p) => (p.id === c.record.id ? c.record : p)));
});
channel.onPostgresChanges('DELETE', 'public', 'posts', (c) => {
setPosts((cur) => cur.filter((p) => p.id !== c.old_record.id));
});
await channel.subscribe();
React Cleanup Pattern
useEffect(() => {
const realtime = new VolcanoRealtime({ /* ... */ });
realtime.connect();
const channel = realtime.channel('updates', { type: 'postgres' });
channel.onPostgresChanges('*', 'public', 'posts', handleChange);
channel.subscribe();
return () => {
channel.unsubscribe();
realtime.disconnect();
};
}, []);
Best Practices
- Throttle presence updates (e.g., 1 Hz) to avoid flooding the channel.
- Refresh data on reconnect — call your initial-fetch routine inside
onConnectso missed events are reconciled. - Scope channels to specific tables/events; broad subscriptions hurt RLS clarity and bandwidth.
- Combine initial fetch with subscriptions — the user sees current state immediately and live updates apply on top.
- Use
getTokenfor long-lived sessions instead of a staticaccessToken.
Error Handling
realtime.onError((ctx) => {
showConnectionError(ctx.message);
});
try {
await channel.subscribe();
} catch (error) {
console.error('Subscription failed:', error.message);
}
Connection rejected (connect times out or never accepts):
realtime disabled for project— realtime is off for this project; addrealtime: { enabled: true }tovolcano-config.yamlandconfig deploy(see "Enable realtime"). Off by default in local and cloud.invalid apikey/anon key not found— wronganonKeyfor the target project (or the wrongapiUrl).- Missing/invalid token — realtime requires an authenticated session; pass a
real
accessToken(signInfirst). There is no anonymous/token-less realtime connection, in local or cloud.
Verification Checklist
- Realtime is enabled for the project (
realtime: { enabled: true }involcano-config.yaml,config deployd) — off by default. connect()is paired withdisconnect();subscribe()is paired withunsubscribe().- Handlers are registered before
subscribe(). - Realtime behavior matches RLS expectations (per-user delivery).
- Presence updates are throttled when bound to high-frequency events.
- Dependencies:
centrifugeis present;wsonly when Node-side realtime is used.