diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b4d163..7492c403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## 25.0.0 + +* Breaking: Added `unsubscribe()`, `update()`, and `close()` for Realtime subscription lifecycle. +* Added: Added `userPhone` to the `Membership` model. +* Updated: Updated `X-Appwrite-Response-Format` header to `1.9.2`. + ## 24.2.0 * Added `x` OAuth provider to `OAuthProvider` enum diff --git a/README.md b/README.md index 7764e90b..6ed4881e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Appwrite Web SDK ![License](https://img.shields.io/github/license/appwrite/sdk-for-web.svg?style=flat-square) -![Version](https://img.shields.io/badge/api%20version-1.9.1-blue.svg?style=flat-square) +![Version](https://img.shields.io/badge/api%20version-1.9.2-blue.svg?style=flat-square) [![Build Status](https://img.shields.io/travis/com/appwrite/sdk-generator?style=flat-square)](https://travis-ci.com/appwrite/sdk-generator) [![Twitter Account](https://img.shields.io/twitter/follow/appwrite?color=00acee&label=twitter&style=flat-square)](https://twitter.com/appwrite) [![Discord](https://img.shields.io/discord/564160730845151244?label=discord&style=flat-square)](https://appwrite.io/discord) @@ -33,7 +33,7 @@ import { Client, Account } from "appwrite"; To install with a CDN (content delivery network) add the following scripts to the bottom of your tag, but before you use any Appwrite services: ```html - + ``` diff --git a/package-lock.json b/package-lock.json index 4bca5085..fc146255 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "appwrite", - "version": "24.2.0", + "version": "25.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "appwrite", - "version": "24.2.0", + "version": "25.0.0", "license": "BSD-3-Clause", "dependencies": { "json-bigint": "1.0.0" diff --git a/package.json b/package.json index d240cf02..feea2e8e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "appwrite", "homepage": "https://appwrite.io/support", "description": "Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API", - "version": "24.2.0", + "version": "25.0.0", "license": "BSD-3-Clause", "main": "dist/cjs/sdk.js", "exports": { diff --git a/src/client.ts b/src/client.ts index ca15ae48..6bd679d2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,7 @@ import { Models } from './models'; import { Channel, ActionableChannel, ResolvedChannel } from './channel'; import { Query } from './query'; +import { ID } from './id'; import JSONbigModule from 'json-bigint'; const JSONbigParser = JSONbigModule({ storeAsString: false }); const JSONbigSerializer = JSONbigModule({ useNativeBigInt: true }); @@ -37,7 +38,7 @@ function reviver(_key: string, value: any): any { return value; } -const JSONbig = { +export const JSONbig = { parse: (text: string) => JSONbigParser.parse(text, reviver), stringify: JSONbigSerializer.stringify }; @@ -76,14 +77,20 @@ type RealtimeResponse = { */ type RealtimeRequest = { /** - * Type of the request: 'authentication'. + * Type of the request: 'authentication' or 'subscribe'. */ - type: 'authentication'; + type: 'authentication' | 'subscribe'; /** * Data required for authentication. */ - data: RealtimeRequestAuthenticate; + data: RealtimeRequestAuthenticate | RealtimeRequestSubscribe[]; +} + +type RealtimeRequestSubscribe = { + subscriptionId: string; + channels: string[]; + queries: string[]; } /** @@ -144,11 +151,6 @@ type RealtimeResponseConnected = { * User object representing the connected user (optional). */ user?: object; - - /** - * Map slot index -> subscription ID from backend (optional). - */ - subscriptions?: Record; } /** @@ -218,33 +220,18 @@ type Realtime = { channels: Set; /** - * Set of query strings the client is subscribed to. + * Map of subscriptions keyed by client-generated subscriptionId. */ - queries: Set; - - /** - * Map of subscriptions containing channel names and corresponding callback functions. - */ - subscriptions: Map) => void }>; /** - * Map slot index -> subscription ID (from backend, set on 'connected'). - */ - slotToSubscriptionId: Map; - - /** - * Map subscription ID -> slot index (for O(1) event dispatch). - */ - subscriptionIdToSlot: Map; - - /** - * Counter for managing subscriptions. + * Pending subscribe rows keyed by subscriptionId. Flushed and cleared on each send. */ - subscriptionsCounter: number; + pendingSubscribes: Map; /** * Boolean indicating whether automatic reconnection is enabled. @@ -276,12 +263,7 @@ type Realtime = { */ createHeartbeat: () => void; - /** - * Function to clean up resources associated with specified channels. - * - * @param {string[]} channels - List of channel names to clean up. - */ - cleanUp: (channels: string[], queries: string[]) => void; + sendPendingSubscribes: () => void; /** * Function to handle incoming messages from the WebSocket connection. @@ -398,8 +380,8 @@ class Client { 'x-sdk-name': 'Web', 'x-sdk-platform': 'client', 'x-sdk-language': 'web', - 'x-sdk-version': '24.2.0', - 'X-Appwrite-Response-Format': '1.9.1', + 'x-sdk-version': '25.0.0', + 'X-Appwrite-Response-Format': '1.9.2', }; /** @@ -575,11 +557,8 @@ class Client { heartbeat: undefined, url: '', channels: new Set(), - queries: new Set(), subscriptions: new Map(), - slotToSubscriptionId: new Map(), - subscriptionIdToSlot: new Map(), - subscriptionsCounter: 0, + pendingSubscribes: new Map(), reconnect: true, reconnectAttempts: 0, lastMessage: undefined, @@ -620,22 +599,8 @@ class Client { } const encodedProject = encodeURIComponent((this.config.project as string) ?? ''); - let queryParams = 'project=' + encodedProject; - - this.realtime.channels.forEach(channel => { - queryParams += '&channels[]=' + encodeURIComponent(channel); - }); - - // Per-subscription queries: channel[slot][]=query so server can route events by subscription - const selectAllQuery = Query.select(['*']).toString(); - this.realtime.subscriptions.forEach((sub, slot) => { - const queries = sub.queries.length > 0 ? sub.queries : [selectAllQuery]; - sub.channels.forEach(channel => { - queries.forEach(query => { - queryParams += '&' + encodeURIComponent(channel) + '[' + slot + '][]=' + encodeURIComponent(query); - }); - }); - }); + // URL carries only the project; channels/queries are sent via subscribe message. + const queryParams = 'project=' + encodedProject; const url = this.config.endpointRealtime + '/realtime?' + queryParams; @@ -679,8 +644,27 @@ class Client { this.realtime.createSocket(); }, timeout); }) + } else if (this.realtime.socket?.readyState === WebSocket.OPEN) { + this.realtime.sendPendingSubscribes(); } }, + sendPendingSubscribes: () => { + if (!this.realtime.socket || this.realtime.socket.readyState !== WebSocket.OPEN) { + return; + } + + if (this.realtime.pendingSubscribes.size < 1) { + return; + } + + const rows = Array.from(this.realtime.pendingSubscribes.values()); + this.realtime.pendingSubscribes.clear(); + + this.realtime.socket.send(JSONbig.stringify({ + type: 'subscribe', + data: rows + })); + }, onMessage: (event) => { try { const message: RealtimeResponse = JSONbig.parse(event.data); @@ -688,17 +672,6 @@ class Client { switch (message.type) { case 'connected': { const messageData = message.data; - if (messageData?.subscriptions) { - this.realtime.slotToSubscriptionId.clear(); - this.realtime.subscriptionIdToSlot.clear(); - for (const [slotStr, subscriptionId] of Object.entries(messageData.subscriptions)) { - const slot = Number(slotStr); - if (!isNaN(slot) && typeof subscriptionId === 'string') { - this.realtime.slotToSubscriptionId.set(slot, subscriptionId); - this.realtime.subscriptionIdToSlot.set(subscriptionId, slot); - } - } - } let session = this.config.session; if (!session) { @@ -713,8 +686,22 @@ class Client { } })); } + + this.realtime.subscriptions.forEach((sub, subscriptionId) => { + this.realtime.pendingSubscribes.set(subscriptionId, { + subscriptionId, + channels: sub.channels, + queries: sub.queries ?? [] + }); + }); + this.realtime.sendPendingSubscribes(); break; } + case 'response': + // The SDK generates subscriptionIds client-side and sends them on every + // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state + // the SDK needs to reconcile. + break; case 'event': { const data = >message.data; if (!data?.channels) break; @@ -722,12 +709,9 @@ class Client { const eventSubIds = data.subscriptions; if (eventSubIds && eventSubIds.length > 0) { for (const subscriptionId of eventSubIds) { - const slot = this.realtime.subscriptionIdToSlot.get(subscriptionId); - if (slot !== undefined) { - const subscription = this.realtime.subscriptions.get(slot); - if (subscription) { - setTimeout(() => subscription.callback(data)); - } + const subscription = this.realtime.subscriptions.get(subscriptionId); + if (subscription) { + setTimeout(() => subscription.callback(data)); } } } else { @@ -751,31 +735,6 @@ class Client { } catch (e) { console.error(e); } - }, - cleanUp: (channels, queries) => { - this.realtime.channels.forEach(channel => { - if (channels.includes(channel)) { - let found = Array.from(this.realtime.subscriptions).some(([_key, subscription] )=> { - return subscription.channels.includes(channel); - }) - - if (!found) { - this.realtime.channels.delete(channel); - } - } - }) - - this.realtime.queries.forEach(query => { - if (queries.includes(query)) { - let found = Array.from(this.realtime.subscriptions).some(([_key, subscription]) => { - return subscription.queries?.includes(query); - }); - - if (!found) { - this.realtime.queries.delete(query); - } - } - }) } } @@ -835,20 +794,44 @@ class Client { channelStrings.forEach(channel => this.realtime.channels.add(channel)); const queryStrings = (queries ?? []).map(q => typeof q === 'string' ? q : q.toString()); - queryStrings.forEach(query => this.realtime.queries.add(query)); - const counter = this.realtime.subscriptionsCounter++; - this.realtime.subscriptions.set(counter, { + let subscriptionId = ''; + const attempts = this.realtime.subscriptions.size + 1; + for (let i = 0; i < attempts; i++) { + const candidate = ID.unique(); + if (!this.realtime.subscriptions.has(candidate)) { + subscriptionId = candidate; + break; + } + } + if (subscriptionId === '') { + throw new AppwriteException('Failed to generate unique subscription id'); + } + this.realtime.subscriptions.set(subscriptionId, { channels: channelStrings, queries: queryStrings, callback }); + this.realtime.pendingSubscribes.set(subscriptionId, { + subscriptionId, + channels: channelStrings, + queries: queryStrings + }); this.realtime.connect(); return () => { - this.realtime.subscriptions.delete(counter); - this.realtime.cleanUp(channelStrings, queryStrings); + this.realtime.subscriptions.delete(subscriptionId); + this.realtime.pendingSubscribes.delete(subscriptionId); + const stillUsed = new Set(); + this.realtime.subscriptions.forEach(sub => { + sub.channels.forEach(channel => stillUsed.add(channel)); + }); + this.realtime.channels.forEach(channel => { + if (!stillUsed.has(channel)) { + this.realtime.channels.delete(channel); + } + }); this.realtime.connect(); } } diff --git a/src/models.ts b/src/models.ts index ef424e14..a22d5b76 100644 --- a/src/models.ts +++ b/src/models.ts @@ -987,6 +987,10 @@ export namespace Models { * User email address. Hide this attribute by toggling membership privacy in the Console. */ userEmail: string; + /** + * User phone number. Hide this attribute by toggling membership privacy in the Console. + */ + userPhone: string; /** * Team ID. */ diff --git a/src/services/realtime.ts b/src/services/realtime.ts index a33b45de..a1368b9e 100644 --- a/src/services/realtime.ts +++ b/src/services/realtime.ts @@ -1,8 +1,29 @@ -import { AppwriteException, Client } from '../client'; +import { AppwriteException, Client, JSONbig } from '../client'; import { Channel, ActionableChannel, ResolvedChannel } from '../channel'; import { Query } from '../query'; +import { ID } from '../id'; + +export type RealtimeSubscriptionUpdate = { + channels?: (string | Channel | ActionableChannel | ResolvedChannel)[]; + queries?: (string | Query)[]; +} export type RealtimeSubscription = { + /** + * Remove this subscription only. Keeps the WebSocket open so other subscriptions keep receiving events. + * Use `Realtime.disconnect()` to close the connection entirely. + */ + unsubscribe: () => Promise; + + /** + * Replace the channels and/or queries for this subscription on the server without re-creating it. + */ + update: (changes: RealtimeSubscriptionUpdate) => Promise; + + /** + * Alias of `unsubscribe()` plus legacy auto-disconnect when this was the last active subscription. + * Prefer `unsubscribe()` for per-subscription teardown and `Realtime.disconnect()` for full shutdown. + */ close: () => Promise; } @@ -28,14 +49,17 @@ export type RealtimeResponseEvent = { export type RealtimeResponseConnected = { channels: string[]; user?: object; - subscriptions?: { [slot: string]: string }; // Map slot index -> subscriptionId } export type RealtimeRequest = { - type: 'authentication'; - data: { - session: string; - }; + type: 'authentication' | 'subscribe' | 'unsubscribe'; + data: any; +} + +type RealtimeRequestSubscribeRow = { + subscriptionId?: string; + channels: string[]; + queries: string[]; } export enum RealtimeCode { @@ -49,22 +73,18 @@ export class Realtime { private readonly TYPE_EVENT = 'event'; private readonly TYPE_PONG = 'pong'; private readonly TYPE_CONNECTED = 'connected'; + private readonly TYPE_RESPONSE = 'response'; private readonly DEBOUNCE_MS = 1; private readonly HEARTBEAT_INTERVAL = 20000; // 20 seconds in milliseconds private client: Client; private socket?: WebSocket; - // Slot-centric state: Map, queries: string[], callback: Function }> - private activeSubscriptions = new Map>(); - // Map slot index -> subscriptionId (from backend) - private slotToSubscriptionId = new Map(); - // Inverse map: subscriptionId -> slot index (for O(1) lookup) - private subscriptionIdToSlot = new Map(); + private activeSubscriptions = new Map>(); + private pendingSubscribes = new Map(); private heartbeatTimer?: number; private subCallDepth = 0; private reconnectAttempts = 0; - private subscriptionsCounter = 0; private connectionId = 0; private reconnect = true; @@ -108,16 +128,16 @@ export class Realtime { private startHeartbeat(): void { this.stopHeartbeat(); - this.heartbeatTimer = window.setInterval(() => { + this.heartbeatTimer = window?.setInterval(() => { if (this.socket && this.socket.readyState === WebSocket.OPEN) { - this.socket.send(JSON.stringify({ type: 'ping' })); + this.socket.send(JSONbig.stringify({ type: 'ping' })); } }, this.HEARTBEAT_INTERVAL); } private stopHeartbeat(): void { if (this.heartbeatTimer) { - window.clearInterval(this.heartbeatTimer); + window?.clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; } } @@ -134,39 +154,8 @@ export class Realtime { throw new AppwriteException('Missing project ID'); } - // Collect all unique channels from all slots - const allChannels = new Set(); - 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)}`; - } - } - } + // URL carries only the project; channels/queries are sent via the subscribe message. + const queryParams = `project=${projectId}`; const endpoint = this.client.config.endpointRealtime !== '' @@ -206,7 +195,7 @@ export class Realtime { return; } try { - const message = JSON.parse(event.data) as RealtimeResponse; + const message = JSONbig.parse(event.data) as RealtimeResponse; this.handleMessage(message); } catch (error) { console.error('Failed to parse message:', error); @@ -293,6 +282,72 @@ export class Realtime { return new Promise(resolve => setTimeout(resolve, ms)); } + private sendUnsubscribeMessage(subscriptionIds: string[]): void { + const ids = subscriptionIds.filter(id => typeof id === 'string' && id.length > 0); + if (ids.length === 0) { + return; + } + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + return; + } + this.socket.send(JSONbig.stringify({ + type: 'unsubscribe', + data: ids.map(subscriptionId => ({ subscriptionId })) + })); + } + + private generateUniqueSubscriptionId(): string { + const attempts = this.activeSubscriptions.size + 1; + for (let i = 0; i < attempts; i++) { + const id = ID.unique(); + if (!this.activeSubscriptions.has(id)) { + return id; + } + } + throw new AppwriteException('Failed to generate unique subscription id'); + } + + private enqueuePendingSubscribe(subscriptionId: string): void { + const subscription = this.activeSubscriptions.get(subscriptionId); + if (!subscription) { + return; + } + this.pendingSubscribes.set(subscriptionId, { + subscriptionId, + channels: Array.from(subscription.channels), + queries: subscription.queries ?? [] + }); + } + + /** + * Close the WebSocket connection and drop all active subscriptions client-side. + * Use this instead of calling `unsubscribe()` on every subscription when you want to tear everything down. + */ + public async disconnect(): Promise { + this.activeSubscriptions.clear(); + this.pendingSubscribes.clear(); + this.reconnect = false; + await this.closeSocket(); + } + + private sendPendingSubscribes(): void { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + return; + } + + if (this.pendingSubscribes.size < 1) { + return; + } + + const rows = Array.from(this.pendingSubscribes.values()); + this.pendingSubscribes.clear(); + + this.socket.send(JSONbig.stringify({ + type: 'subscribe', + data: rows + })); + } + /** * Convert a channel value to a string * @@ -390,44 +445,92 @@ export class Realtime { } } - // Allocate a new slot index - this.subscriptionsCounter++; - const slot = this.subscriptionsCounter; + const subscriptionId = this.generateUniqueSubscriptionId(); - // 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, { + this.activeSubscriptions.set(subscriptionId, { channels, queries: queryStrings, callback }); + this.enqueuePendingSubscribe(subscriptionId); this.subCallDepth++; + try { + await this.sleep(this.DEBOUNCE_MS); + + if (this.subCallDepth === 1) { + if (!this.socket || this.socket.readyState > WebSocket.OPEN) { + await this.createSocket(); + } else if (this.socket.readyState === WebSocket.OPEN) { + this.sendPendingSubscribes(); + } + } + } finally { + this.subCallDepth--; + } - await this.sleep(this.DEBOUNCE_MS); + const unsubscribe = async (): Promise => { + if (!this.activeSubscriptions.has(subscriptionId)) { + return; + } + this.activeSubscriptions.delete(subscriptionId); + this.pendingSubscribes.delete(subscriptionId); + this.sendUnsubscribeMessage([subscriptionId]); + }; - if (this.subCallDepth === 1) { - await this.createSocket(); - } + const update = async (changes: RealtimeSubscriptionUpdate): Promise => { + const subscription = this.activeSubscriptions.get(subscriptionId); + if (!subscription) { + return; + } - this.subCallDepth--; + if (changes.channels !== undefined) { + const nextChannelStrings = changes.channels.map(ch => this.channelToString(ch)); + subscription.channels = new Set(nextChannelStrings); + } - return { - close: async () => { - const subscriptionId = this.slotToSubscriptionId.get(slot); - this.activeSubscriptions.delete(slot); - this.slotToSubscriptionId.delete(slot); - if (subscriptionId) { - this.subscriptionIdToSlot.delete(subscriptionId); + if (changes.queries !== undefined) { + const nextQueries: string[] = []; + for (const q of changes.queries) { + if (Array.isArray(q)) { + for (const inner of q) { + nextQueries.push(typeof inner === 'string' ? inner : (inner as Query).toString()); + } + } else { + nextQueries.push(typeof q === 'string' ? q : q.toString()); + } } - await this.createSocket(); + subscription.queries = nextQueries; + } + + this.enqueuePendingSubscribe(subscriptionId); + + this.subCallDepth++; + try { + await this.sleep(this.DEBOUNCE_MS); + + if (this.subCallDepth === 1) { + if (!this.socket || this.socket.readyState > WebSocket.OPEN) { + await this.createSocket(); + } else if (this.socket.readyState === WebSocket.OPEN) { + this.sendPendingSubscribes(); + } + } + } finally { + this.subCallDepth--; + } + }; + + const close = async (): Promise => { + await unsubscribe(); + if (this.activeSubscriptions.size === 0) { + this.reconnect = false; + await this.closeSocket(); } }; - } - // cleanUp is no longer needed - slots are removed directly in subscribe().close() - // Channels are automatically rebuilt from remaining slots in createSocket() + return { unsubscribe, update, close }; + } private handleMessage(message: RealtimeResponse): void { if (!message.type) { @@ -447,6 +550,9 @@ export class Realtime { case this.TYPE_PONG: // Handle pong response if needed break; + case this.TYPE_RESPONSE: + this.handleResponseAction(message); + break; } } @@ -457,24 +563,10 @@ export class Realtime { 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') ?? '{}'); + const cookie = JSONbig.parse(window.localStorage.getItem('cookieFallback') ?? '{}'); session = cookie?.[`a_session_${this.client.config.project}`]; } catch (error) { console.error('Failed to parse cookie fallback:', error); @@ -482,13 +574,18 @@ export class Realtime { } if (session && !messageData.user) { - this.socket?.send(JSON.stringify({ + this.socket?.send(JSONbig.stringify({ type: 'authentication', data: { session } })); } + + for (const subscriptionId of this.activeSubscriptions.keys()) { + this.enqueuePendingSubscribe(subscriptionId); + } + this.sendPendingSubscribes(); } private handleResponseError(message: RealtimeResponse): void { @@ -515,23 +612,24 @@ export class Realtime { 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 = { - events, - channels, - timestamp, - payload, - subscriptions - }; - subscription.callback(response); - } + const subscription = this.activeSubscriptions.get(subscriptionId); + if (!subscription) { + continue; } + subscription.callback({ + events, + channels, + timestamp, + payload, + subscriptions + }); } } + + private handleResponseAction(_message: RealtimeResponse): void { + // The SDK generates subscriptionIds client-side and sends them on every + // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state + // the SDK needs to reconcile. + } }