appwrite
Version:
Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API
538 lines (470 loc) • 19.8 kB
text/typescript
import { AppwriteException, Client } from '../client';
import { Channel, ActionableChannel, ResolvedChannel } from '../channel';
import { Query } from '../query';
export type RealtimeSubscription = {
close: () => Promise<void>;
}
export type RealtimeCallback<T = any> = {
channels: Set<string>;
queries: string[]; // Array of query strings
callback: (event: RealtimeResponseEvent<T>) => void;
}
export type RealtimeResponse = {
type: string;
data?: any;
}
export type RealtimeResponseEvent<T = any> = {
events: string[];
channels: string[];
timestamp: string;
payload: T;
subscriptions: string[]; // Backend-provided subscription IDs
}
export type RealtimeResponseConnected = {
channels: string[];
user?: object;
subscriptions?: { [slot: string]: string }; // Map slot index -> subscriptionId
}
export type RealtimeRequest = {
type: 'authentication';
data: {
session: string;
};
}
export enum RealtimeCode {
NORMAL_CLOSURE = 1000,
POLICY_VIOLATION = 1008,
UNKNOWN_ERROR = -1
}
export class Realtime {
private readonly TYPE_ERROR = 'error';
private readonly TYPE_EVENT = 'event';
private readonly TYPE_PONG = 'pong';
private readonly TYPE_CONNECTED = 'connected';
private readonly DEBOUNCE_MS = 1;
private readonly HEARTBEAT_INTERVAL = 20000; // 20 seconds in milliseconds
private client: Client;
private socket?: WebSocket;
// Slot-centric state: Map<slot, { channels: Set<string>, queries: string[], callback: Function }>
private activeSubscriptions = new Map<number, RealtimeCallback<any>>();
// Map slot index -> subscriptionId (from backend)
private slotToSubscriptionId = new Map<number, string>();
// Inverse map: subscriptionId -> slot index (for O(1) lookup)
private subscriptionIdToSlot = new Map<string, number>();
private heartbeatTimer?: number;
private subCallDepth = 0;
private reconnectAttempts = 0;
private subscriptionsCounter = 0;
private connectionId = 0;
private reconnect = true;
private onErrorCallbacks: Array<(error?: Error, statusCode?: number) => void> = [];
private onCloseCallbacks: Array<() => void> = [];
private onOpenCallbacks: Array<() => void> = [];
constructor(client: Client) {
this.client = client;
}
/**
* Register a callback function to be called when an error occurs
*
* @param {Function} callback - Callback function to handle errors
* @returns {void}
*/
public onError(callback: (error?: Error, statusCode?: number) => void): void {
this.onErrorCallbacks.push(callback);
}
/**
* Register a callback function to be called when the connection closes
*
* @param {Function} callback - Callback function to handle connection close
* @returns {void}
*/
public onClose(callback: () => void): void {
this.onCloseCallbacks.push(callback);
}
/**
* Register a callback function to be called when the connection opens
*
* @param {Function} callback - Callback function to handle connection open
* @returns {void}
*/
public onOpen(callback: () => void): void {
this.onOpenCallbacks.push(callback);
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = window.setInterval(() => {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: 'ping' }));
}
}, this.HEARTBEAT_INTERVAL);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
window.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
}
private async createSocket(): Promise<void> {
if (this.activeSubscriptions.size === 0) {
this.reconnect = false;
await this.closeSocket();
return;
}
const projectId = this.client.config.project;
if (!projectId) {
throw new AppwriteException('Missing project ID');
}
// Collect all unique channels from all slots
const allChannels = new Set<string>();
for (const subscription of this.activeSubscriptions.values()) {
for (const channel of subscription.channels) {
allChannels.add(channel);
}
}
let queryParams = `project=${projectId}`;
for (const channel of allChannels) {
queryParams += `&channels[]=${encodeURIComponent(channel)}`;
}
// Build query string from slots → channels → queries
// Format: channel[slot][]=query
// For each slot, repeat its queries under each channel it subscribes to
// Example: slot 1 → channels [tests, prod], queries [q1, q2]
// Produces: tests[1][]=q1&tests[1][]=q2&prod[1][]=q1&prod[1][]=q2
const selectAllQuery = Query.select(['*']).toString();
for (const [slot, subscription] of this.activeSubscriptions) {
// queries is string[] - iterate over each query string
const queries = subscription.queries.length === 0
? [selectAllQuery]
: subscription.queries;
// Repeat this slot's queries under each channel it subscribes to
// Each query is sent as a separate parameter: channel[slot][]=q1&channel[slot][]=q2
for (const channel of subscription.channels) {
for (const query of queries) {
queryParams += `&${encodeURIComponent(channel)}[${slot}][]=${encodeURIComponent(query)}`;
}
}
}
const endpoint =
this.client.config.endpointRealtime !== ''
? this.client.config.endpointRealtime
: this.client.config.endpoint || '';
const realtimeEndpoint = endpoint
.replace('https://', 'wss://')
.replace('http://', 'ws://');
const url = `${realtimeEndpoint}/realtime?${queryParams}`;
if (this.socket) {
this.reconnect = false;
if (this.socket.readyState < WebSocket.CLOSING) {
await this.closeSocket();
}
// Ensure reconnect isn't stuck false if close event was missed.
this.reconnect = true;
}
return new Promise((resolve, reject) => {
try {
const connectionId = ++this.connectionId;
const socket = (this.socket = new WebSocket(url));
socket.addEventListener('open', () => {
if (connectionId !== this.connectionId) {
return;
}
this.reconnectAttempts = 0;
this.onOpenCallbacks.forEach(callback => callback());
this.startHeartbeat();
resolve();
});
socket.addEventListener('message', (event: MessageEvent) => {
if (connectionId !== this.connectionId) {
return;
}
try {
const message = JSON.parse(event.data) as RealtimeResponse;
this.handleMessage(message);
} catch (error) {
console.error('Failed to parse message:', error);
}
});
socket.addEventListener('close', async (event: CloseEvent) => {
if (connectionId !== this.connectionId || socket !== this.socket) {
return;
}
this.stopHeartbeat();
this.onCloseCallbacks.forEach(callback => callback());
if (!this.reconnect || event.code === RealtimeCode.POLICY_VIOLATION) {
this.reconnect = true;
return;
}
const timeout = this.getTimeout();
console.log(`Realtime disconnected. Re-connecting in ${timeout / 1000} seconds.`);
await this.sleep(timeout);
this.reconnectAttempts++;
try {
await this.createSocket();
} catch (error) {
console.error('Failed to reconnect:', error);
}
});
socket.addEventListener('error', (event: Event) => {
if (connectionId !== this.connectionId || socket !== this.socket) {
return;
}
this.stopHeartbeat();
const error = new Error('WebSocket error');
console.error('WebSocket error:', error.message);
this.onErrorCallbacks.forEach(callback => callback(error));
reject(error);
});
} catch (error) {
reject(error);
}
});
}
private async closeSocket(): Promise<void> {
this.stopHeartbeat();
if (this.socket) {
return new Promise((resolve) => {
if (!this.socket) {
resolve();
return;
}
if (this.socket.readyState === WebSocket.OPEN ||
this.socket.readyState === WebSocket.CONNECTING) {
this.socket.addEventListener('close', () => {
resolve();
}, { once: true });
this.socket.close(RealtimeCode.NORMAL_CLOSURE);
} else {
resolve();
}
});
}
}
private getTimeout(): number {
if (this.reconnectAttempts < 5) {
return 1000;
} else if (this.reconnectAttempts < 15) {
return 5000;
} else if (this.reconnectAttempts < 100) {
return 10000;
} else {
return 60000;
}
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Convert a channel value to a string
*
* @private
* @param {string | Channel<any> | ActionableChannel | ResolvedChannel} channel - Channel value (string or Channel builder instance)
* @returns {string} Channel string representation
*/
private channelToString(channel: string | Channel<any> | ActionableChannel | ResolvedChannel): string {
if (typeof channel === 'string') {
return channel;
}
// All Channel instances have toString() method
if (channel && typeof (channel as Channel<any>).toString === 'function') {
return (channel as Channel<any>).toString();
}
return String(channel);
}
/**
* Subscribe to a single channel
*
* @param {string | Channel<any> | ActionableChannel | ResolvedChannel} channel - Channel name to subscribe to (string or Channel builder instance)
* @param {Function} callback - Callback function to handle events
* @returns {Promise<RealtimeSubscription>} Subscription object with close method
*/
public async subscribe(
channel: string | Channel<any> | ActionableChannel | ResolvedChannel,
callback: (event: RealtimeResponseEvent<any>) => void,
queries?: (string | Query)[]
): Promise<RealtimeSubscription>;
/**
* Subscribe to multiple channels
*
* @param {(string | Channel<any> | ActionableChannel | ResolvedChannel)[]} channels - Array of channel names to subscribe to (strings or Channel builder instances)
* @param {Function} callback - Callback function to handle events
* @returns {Promise<RealtimeSubscription>} Subscription object with close method
*/
public async subscribe(
channels: (string | Channel<any> | ActionableChannel | ResolvedChannel)[],
callback: (event: RealtimeResponseEvent<any>) => void,
queries?: (string | Query)[]
): Promise<RealtimeSubscription>;
/**
* Subscribe to a single channel with typed payload
*
* @param {string | Channel<any> | ActionableChannel | ResolvedChannel} channel - Channel name to subscribe to (string or Channel builder instance)
* @param {Function} callback - Callback function to handle events with typed payload
* @returns {Promise<RealtimeSubscription>} Subscription object with close method
*/
public async subscribe<T>(
channel: string | Channel<any> | ActionableChannel | ResolvedChannel,
callback: (event: RealtimeResponseEvent<T>) => void,
queries?: (string | Query)[]
): Promise<RealtimeSubscription>;
/**
* Subscribe to multiple channels with typed payload
*
* @param {(string | Channel<any> | ActionableChannel | ResolvedChannel)[]} channels - Array of channel names to subscribe to (strings or Channel builder instances)
* @param {Function} callback - Callback function to handle events with typed payload
* @returns {Promise<RealtimeSubscription>} Subscription object with close method
*/
public async subscribe<T>(
channels: (string | Channel<any> | ActionableChannel | ResolvedChannel)[],
callback: (event: RealtimeResponseEvent<T>) => void,
queries?: (string | Query)[]
): Promise<RealtimeSubscription>;
public async subscribe<T = any>(
channelsOrChannel: string | Channel<any> | ActionableChannel | ResolvedChannel | (string | Channel<any> | ActionableChannel | ResolvedChannel)[],
callback: (event: RealtimeResponseEvent<T>) => void,
queries: (string | Query)[] = []
): Promise<RealtimeSubscription> {
const channelArray = Array.isArray(channelsOrChannel)
? channelsOrChannel
: [channelsOrChannel];
// Convert all channels to strings
const channelStrings = channelArray.map(ch => this.channelToString(ch));
const channels = new Set(channelStrings);
// Convert queries to array of strings
// Ensure each query is a separate string in the array
const queryStrings: string[] = [];
for (const q of (queries ?? [])) {
if (Array.isArray(q)) {
// Handle nested arrays: [[q1, q2]] -> [q1, q2]
for (const inner of q) {
queryStrings.push(typeof inner === 'string' ? inner : inner.toString());
}
} else {
queryStrings.push(typeof q === 'string' ? q : q.toString());
}
}
// Allocate a new slot index
this.subscriptionsCounter++;
const slot = this.subscriptionsCounter;
// Store slot-centric data: channels, queries, and callback belong to the slot
// queries is stored as string[] (array of query strings)
// No channel mutation occurs here - channels are derived from slots in createSocket()
this.activeSubscriptions.set(slot, {
channels,
queries: queryStrings,
callback
});
this.subCallDepth++;
await this.sleep(this.DEBOUNCE_MS);
if (this.subCallDepth === 1) {
await this.createSocket();
}
this.subCallDepth--;
return {
close: async () => {
const subscriptionId = this.slotToSubscriptionId.get(slot);
this.activeSubscriptions.delete(slot);
this.slotToSubscriptionId.delete(slot);
if (subscriptionId) {
this.subscriptionIdToSlot.delete(subscriptionId);
}
await this.createSocket();
}
};
}
// cleanUp is no longer needed - slots are removed directly in subscribe().close()
// Channels are automatically rebuilt from remaining slots in createSocket()
private handleMessage(message: RealtimeResponse): void {
if (!message.type) {
return;
}
switch (message.type) {
case this.TYPE_CONNECTED:
this.handleResponseConnected(message);
break;
case this.TYPE_ERROR:
this.handleResponseError(message);
break;
case this.TYPE_EVENT:
this.handleResponseEvent(message);
break;
case this.TYPE_PONG:
// Handle pong response if needed
break;
}
}
private handleResponseConnected(message: RealtimeResponse): void {
if (!message.data) {
return;
}
const messageData = message.data as RealtimeResponseConnected;
// Store subscription ID mappings from backend
// Format: { "0": "sub_a1f9", "1": "sub_b83c", ... }
if (messageData.subscriptions) {
this.slotToSubscriptionId.clear();
this.subscriptionIdToSlot.clear();
for (const [slotStr, subscriptionId] of Object.entries(messageData.subscriptions)) {
const slot = Number(slotStr);
if (!isNaN(slot)) {
this.slotToSubscriptionId.set(slot, subscriptionId);
this.subscriptionIdToSlot.set(subscriptionId, slot);
}
}
}
let session = this.client.config.session;
if (!session) {
try {
const cookie = JSON.parse(window.localStorage.getItem('cookieFallback') ?? '{}');
session = cookie?.[`a_session_${this.client.config.project}`];
} catch (error) {
console.error('Failed to parse cookie fallback:', error);
}
}
if (session && !messageData.user) {
this.socket?.send(JSON.stringify(<RealtimeRequest>{
type: 'authentication',
data: {
session
}
}));
}
}
private handleResponseError(message: RealtimeResponse): void {
const error = new AppwriteException(
message.data?.message || 'Unknown error'
);
const statusCode = message.data?.code;
this.onErrorCallbacks.forEach(callback => callback(error, statusCode));
}
private handleResponseEvent(message: RealtimeResponse): void {
const data = message.data;
if (!data) {
return;
}
const channels = data.channels as string[];
const events = data.events as string[];
const payload = data.payload;
const timestamp = data.timestamp as string;
const subscriptions = data.subscriptions as string[] | undefined;
if (!channels || !events || !payload || !subscriptions || subscriptions.length === 0) {
return;
}
// Iterate over all matching subscriptionIds and call callback for each
for (const subscriptionId of subscriptions) {
// O(1) lookup using subscriptionId
const slot = this.subscriptionIdToSlot.get(subscriptionId);
if (slot !== undefined) {
const subscription = this.activeSubscriptions.get(slot);
if (subscription) {
const response: RealtimeResponseEvent<any> = {
events,
channels,
timestamp,
payload,
subscriptions
};
subscription.callback(response);
}
}
}
}
}