WebSocket API
This is the protocol that makes Adama's real-time stuff work — bidirectional communication between clients and documents over a single WebSocket connection. Understanding this layer matters even if you end up using the JavaScript client, because when things go wrong (and they will), you need to know what's happening underneath.
Connection Establishment
Endpoint
Connect to Adama using the regional WebSocket endpoint:
wss://{region}.adama-platform.com/~s
Common regions include:
us-east-1- US East (Primary)us-west-2- US West
Basic Connection
const ws = new WebSocket('wss://us-east-1.adama-platform.com/~s');
ws.onopen = () => {
console.log('Connected to Adama');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('Received:', message);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = (event) => {
console.log('Connection closed:', event.code, event.reason);
};
Message Format
All communication uses JSON messages. Every request includes:
| Field | Type | Description |
|---|---|---|
method |
string | The API method name (e.g., "connection/create") |
id |
integer | A unique request ID for correlating responses |
| Additional fields | varies | Method-specific parameters |
Every response includes:
| Field | Type | Description |
|---|---|---|
id |
integer | The request ID this response correlates to |
status |
string | "success" or "error" |
| Additional fields | varies | Response data or error details |
Request-Response Pattern
let requestId = 1;
function sendRequest(method, params) {
const message = {
method: method,
id: requestId++,
...params
};
ws.send(JSON.stringify(message));
}
// Example: Create a document connection
sendRequest('connection/create', {
identity: 'your-identity-token',
space: 'myspace',
key: 'doc-123',
'viewer-state': {}
});
Response Handling
const pendingRequests = new Map();
function sendRequest(method, params, callback) {
const id = requestId++;
pendingRequests.set(id, callback);
ws.send(JSON.stringify({
method: method,
id: id,
...params
}));
}
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
const callback = pendingRequests.get(message.id);
if (callback) {
if (message.status === 'success') {
callback(null, message);
} else {
callback(new Error(message.reason), null);
}
// Only delete for non-streaming responses
if (!message.stream) {
pendingRequests.delete(message.id);
}
}
};
Authentication
Before doing much of anything, you need an identity token. A few ways to get one:
Developer Account Login
// Step 1: Request email verification
sendRequest('init/setup-account', {
email: 'developer@example.com'
});
// Step 2: Complete with code from email
sendRequest('init/complete-account', {
email: 'developer@example.com',
code: '123456'
}, (err, response) => {
if (!err) {
const identity = response.identity;
// Store this identity for future use
}
});
Password-Based Login
sendRequest('account/login', {
email: 'developer@example.com',
password: 'your-password'
}, (err, response) => {
if (!err) {
const identity = response.identity;
}
});
Document Authorization
Documents can issue their own identities via the @authorize handler:
sendRequest('document/authorize', {
space: 'myspace',
key: 'doc-123',
username: 'user@example.com',
password: 'user-password'
}, (err, response) => {
if (!err) {
const identity = response.identity;
// This identity is scoped to this document
}
});
Custom Authorization
For flexible authentication flows, use document/authorization:
sendRequest('document/authorization', {
space: 'myspace',
key: 'doc-123',
message: {
type: 'oauth',
provider: 'google',
token: 'oauth-token-here'
}
}, (err, response) => {
if (!err) {
const identity = response.identity;
}
});
Document Connections
This is the core of Adama's real-time behavior. A document connection is a persistent link between a client and a document that enables:
- Real-time state synchronization via deltas
- Message sending through channels
- Per-viewer state (viewer-state)
Creating a Connection
sendRequest('connection/create', {
identity: identity,
space: 'myspace',
key: 'doc-123',
'viewer-state': {}
}, handleConnectionResponse);
The response is a streaming response — you'll receive multiple messages with the same id:
function handleConnectionResponse(err, message) {
if (err) {
console.error('Connection error:', err);
return;
}
if (message.delta) {
// This is a state update
applyDelta(message.delta);
}
if (message.complete) {
// Connection was closed by server
console.log('Connection ended');
}
}
The Connection ID
After the initial response, you get a connection ID for subsequent operations:
let connectionId = null;
function handleConnectionResponse(err, message) {
if (!err && message.connection) {
connectionId = message.connection;
}
// ... handle deltas
}
Sending Messages
Once connected, send messages to document channels:
sendRequest('connection/send', {
connection: connectionId,
channel: 'say',
message: {
text: 'Hello, world!'
}
}, (err, response) => {
if (!err) {
console.log('Message sent, seq:', response.seq);
}
});
Updating Viewer State
Viewer state lets each client customize what the document sends:
sendRequest('connection/update', {
connection: connectionId,
'viewer-state': {
page: 2,
filter: 'active'
}
});
Ending a Connection
Always close connections cleanly when you're done:
sendRequest('connection/end', {
connection: connectionId
});
The Delta Protocol
Adama uses JSON deltas based on RFC 7396 (JSON Merge Patch) with extensions for efficient array handling. This is where the bandwidth savings come from.
Delta Structure
A delta describes changes to the document state:
{
"delta": {
"data": {
"counter": 5,
"users": {
"42": {"name": "Alice", "score": 100},
"@o": [42]
}
}
}
}
Field Updates
Simple field changes are straightforward:
// State before: {"counter": 3}
// Delta:
{"data": {"counter": 5}}
// State after: {"counter": 5}
Nested Object Updates
Deltas merge recursively:
// State before: {"user": {"name": "Alice", "score": 50}}
// Delta:
{"data": {"user": {"score": 100}}}
// State after: {"user": {"name": "Alice", "score": 100}}
Deletions
Set a field to null to delete it:
// State before: {"temp": "value", "permanent": "data"}
// Delta:
{"data": {"temp": null}}
// State after: {"permanent": "data"}
Array/Table Handling
Adama tables show up as objects with numeric keys plus an @o field for ordering:
{
"data": {
"items": {
"1": {"id": 1, "name": "First"},
"2": {"id": 2, "name": "Second"},
"@o": [1, 2]
}
}
}
When items change:
// Adding item 3 between 1 and 2:
{
"data": {
"items": {
"3": {"id": 3, "name": "Middle"},
"@o": [1, 3, 2]
}
}
}
Efficient Ordering Updates
For large tables, @o uses range notation to avoid sending huge arrays:
// @o can be: [1, 2, 3, 4, 5]
// Or efficiently: [[1, 5]] (range from 1 to 5 inclusive)
// Mixed: [1, [3, 7], 10] (1, then 3-7, then 10)
Building a Delta Processor
class AdamaState {
constructor() {
this.data = {};
}
applyDelta(delta) {
if (delta.data) {
this.merge(this.data, delta.data);
}
}
merge(target, patch) {
for (const key in patch) {
const value = patch[key];
if (value === null) {
delete target[key];
} else if (typeof value === 'object' && !Array.isArray(value)) {
if (typeof target[key] !== 'object') {
target[key] = {};
}
this.merge(target[key], value);
} else {
target[key] = value;
}
}
}
// Convert table object to ordered array
tableToArray(tableObj) {
if (!tableObj || !tableObj['@o']) return [];
const order = this.expandOrder(tableObj['@o']);
return order.map(id => tableObj[id]).filter(Boolean);
}
expandOrder(orderSpec) {
const result = [];
for (const item of orderSpec) {
if (Array.isArray(item)) {
// Range: [start, end]
for (let i = item[0]; i <= item[1]; i++) {
result.push(i);
}
} else {
result.push(item);
}
}
return result;
}
}
Connection Lifecycle
Understanding the lifecycle matters for building anything that doesn't fall apart when the network hiccups.
States
[Disconnected] --> [Connecting] --> [Connected] --> [Disconnected]
| |
v v
[Reconnecting] <----+
The @connected Event
When a connection is established, the document's @connected handler fires:
@connected {
// Client just connected
// Return true to allow, false to reject
return true;
}
On the client side, you receive the initial state delta immediately after connection.
The @disconnected Event
When a connection ends — client closes or network failure — @disconnected fires:
@disconnected {
// Client disconnected
// Perform cleanup
}
Implementing Reconnection
Network failures are a fact of life. Build reconnection in from the start:
class RobustConnection {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectDelay = 1000;
this.maxReconnectDelay = 30000;
this.state = new AdamaState();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.reconnectDelay = 1000; // Reset delay on success
this.reestablishConnections();
};
this.ws.onclose = () => {
console.log('Disconnected, reconnecting...');
this.scheduleReconnect();
};
this.ws.onerror = (err) => {
console.error('WebSocket error:', err);
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
}
scheduleReconnect() {
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
// Exponential backoff
this.reconnectDelay = Math.min(
this.reconnectDelay * 2,
this.maxReconnectDelay
);
}
reestablishConnections() {
// Re-create document connections after reconnect
// The document will send fresh state
}
handleMessage(message) {
if (message.delta) {
this.state.applyDelta(message.delta);
this.notifyListeners();
}
}
}
Error Handling
Error Response Format
{
"id": 123,
"status": "error",
"reason": 405532
}
The reason is a numeric error code. Common ranges:
| Code | Meaning |
|---|---|
| 403xxx | Authorization/permission errors |
| 404xxx | Not found errors |
| 405xxx | Method/operation errors |
| 500xxx | Server errors |
Handling Errors Gracefully
function handleError(errorCode) {
const category = Math.floor(errorCode / 1000);
switch (category) {
case 403:
console.error('Authorization failed');
// Redirect to login
break;
case 404:
console.error('Document not found');
// Show not found UI
break;
case 405:
console.error('Operation not allowed');
// Show error message
break;
default:
console.error('Server error:', errorCode);
// Generic error handling
}
}
Direct Message Sending
For fire-and-forget operations where you don't need to maintain a connection:
sendRequest('message/direct-send', {
identity: identity,
space: 'myspace',
key: 'doc-123',
channel: 'process',
message: { action: 'trigger' }
}, (err, response) => {
if (!err) {
console.log('Message processed at seq:', response.seq);
}
});
Idempotent Sending
Use message/direct-send-once with a dedupe key for at-most-once delivery:
sendRequest('message/direct-send-once', {
identity: identity,
space: 'myspace',
key: 'doc-123',
dedupe: 'unique-operation-id-12345',
channel: 'process',
message: { action: 'trigger' }
});
Document Operations
Creating Documents
sendRequest('document/create', {
identity: identity,
space: 'myspace',
key: 'new-doc-456',
arg: {
title: 'My New Document',
owner: 'user@example.com'
}
}, (err, response) => {
if (err) {
console.error('Failed to create document');
} else {
console.log('Document created');
}
});
Listing Documents
sendRequest('document/list', {
identity: identity,
space: 'myspace',
limit: 100
}, (err, response) => {
// This is a streaming response
if (response.key) {
console.log('Document:', response.key, 'created:', response.created);
}
if (response.complete) {
console.log('Listing complete');
}
});
Practical Advice
Handle reconnection from the start. Network failures happen. If you wait until production to think about it, you'll regret it.
Keep request IDs unique per connection. Reset them when reconnecting.
Apply deltas immediately. Don't buffer them. Apply as they arrive to keep state consistent.
Clean up connections. Always send connection/end before closing the WebSocket so the server can run cleanup logic in @disconnected.
Respect backpressure. If you're sending many messages, check the WebSocket buffer:
function safeSend(message) {
if (ws.bufferedAmount < 65536) {
ws.send(JSON.stringify(message));
return true;
}
return false;
}
For most applications, you'll want the JavaScript Client which wraps all of this up nicely. But understanding the raw protocol helps when you're debugging or building a client in a language that doesn't have a library yet.