Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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 <body> tag, but before you use any Appwrite services:

```html
<script src="https://cdn.jsdelivr.net/npm/appwrite@24.2.0"></script>
<script src="https://cdn.jsdelivr.net/npm/appwrite@25.0.0"></script>
```


Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
189 changes: 86 additions & 103 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -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 });
Expand Down Expand Up @@ -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
};
Expand Down Expand Up @@ -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[];
}

/**
Expand Down Expand Up @@ -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<string, string>;
}

/**
Expand Down Expand Up @@ -218,33 +220,18 @@ type Realtime = {
channels: Set<string>;

/**
* Set of query strings the client is subscribed to.
* Map of subscriptions keyed by client-generated subscriptionId.
*/
queries: Set<string>;

/**
* Map of subscriptions containing channel names and corresponding callback functions.
*/
subscriptions: Map<number, {
subscriptions: Map<string, {
channels: string[];
queries: string[];
callback: (payload: RealtimeResponseEvent<any>) => void
}>;

/**
* Map slot index -> subscription ID (from backend, set on 'connected').
*/
slotToSubscriptionId: Map<number, string>;

/**
* Map subscription ID -> slot index (for O(1) event dispatch).
*/
subscriptionIdToSlot: Map<string, number>;

/**
* Counter for managing subscriptions.
* Pending subscribe rows keyed by subscriptionId. Flushed and cleared on each send.
*/
subscriptionsCounter: number;
pendingSubscribes: Map<string, RealtimeRequestSubscribe>;

/**
* Boolean indicating whether automatic reconnection is enabled.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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',
};

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -679,26 +644,34 @@ 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(<RealtimeRequest>{
type: 'subscribe',
data: rows
}));
},
onMessage: (event) => {
try {
const message: RealtimeResponse = JSONbig.parse(event.data);
this.realtime.lastMessage = message;
switch (message.type) {
case 'connected': {
const messageData = <RealtimeResponseConnected>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) {
Expand All @@ -713,21 +686,32 @@ 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 = <RealtimeResponseEvent<unknown>>message.data;
if (!data?.channels) break;

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 {
Expand All @@ -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);
}
}
})
}
}

Expand Down Expand Up @@ -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<string>();
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();
}
Comment on lines 823 to 836

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unsubscribe doesn't notify the server via the new protocol

The returned unsubscribe function deletes the subscription from local maps and calls connect(), but connect() only calls sendPendingSubscribes() when the socket is already OPEN — and pendingSubscribes was just cleared for this subscriptionId, so nothing is sent.

Under the old URL-parameter model, reconnecting with an updated channel list was enough to inform the server. Under the new message-based model the server must receive an explicit unsubscribe frame. Without it, the server continues routing events for the removed subscription for the lifetime of the connection; they are silently dropped client-side only after a reconnect removes the subscription from the re-subscription list.

The Realtime service class's unsubscribe() closure correctly calls sendUnsubscribeMessage([subscriptionId]) — the same call is missing here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its fine as the deprecated client.ts shouldn't do this

}
Expand Down
4 changes: 4 additions & 0 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading