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
963 lines (843 loc) • 30.4 kB
text/typescript
import { Models } from './models';
import { Channel, ActionableChannel, ResolvedChannel } from './channel';
import { Query } from './query';
import JSONbigModule from 'json-bigint';
import BigNumber from 'bignumber.js';
const JSONbigParser = JSONbigModule({ storeAsString: false });
const JSONbigSerializer = JSONbigModule({ useNativeBigInt: true });
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER);
function reviver(_key: string, value: any): any {
if (BigNumber.isBigNumber(value)) {
if (value.isInteger()) {
const str = value.toFixed();
const bi = BigInt(str);
if (bi >= MIN_SAFE && bi <= MAX_SAFE) {
return Number(str);
}
return bi;
}
return value.toNumber();
}
return value;
}
const JSONbig = {
parse: (text: string) => JSONbigParser.parse(text, reviver),
stringify: JSONbigSerializer.stringify
};
/**
* Payload type representing a key-value pair with string keys and any values.
*/
type Payload = {
[key: string]: any;
}
/**
* Headers type representing a key-value pair with string keys and string values.
*/
type Headers = {
[key: string]: string;
}
/**
* Realtime response structure with different types.
*/
type RealtimeResponse = {
/**
* Type of the response: 'error', 'event', 'connected', 'response' or 'pong'.
*/
type: 'error' | 'event' | 'connected' | 'response' | 'pong';
/**
* Data associated with the response based on the response type.
*/
data: RealtimeResponseAuthenticated | RealtimeResponseConnected | RealtimeResponseError | RealtimeResponseEvent<unknown> | undefined;
}
/**
* Realtime request structure for authentication.
*/
type RealtimeRequest = {
/**
* Type of the request: 'authentication'.
*/
type: 'authentication';
/**
* Data required for authentication.
*/
data: RealtimeRequestAuthenticate;
}
/**
* Realtime event response structure with generic payload type.
*/
type RealtimeResponseEvent<T extends unknown> = {
/**
* List of event names associated with the response.
*/
events: string[];
/**
* List of channel names associated with the response.
*/
channels: string[];
/**
* Timestamp indicating the time of the event.
*/
timestamp: string;
/**
* Payload containing event-specific data.
*/
payload: T;
/**
* Subscription IDs this event matches (from backend, optional).
*/
subscriptions?: string[];
}
/**
* Realtime response structure for errors.
*/
type RealtimeResponseError = {
/**
* Numeric error code indicating the type of error.
*/
code: number;
/**
* Error message describing the encountered error.
*/
message: string;
}
/**
* Realtime response structure for a successful connection.
*/
type RealtimeResponseConnected = {
/**
* List of channels the user is connected to.
*/
channels: string[];
/**
* User object representing the connected user (optional).
*/
user?: object;
/**
* Map slot index -> subscription ID from backend (optional).
*/
subscriptions?: Record<string, string>;
}
/**
* Realtime response structure for authenticated connections.
*/
type RealtimeResponseAuthenticated = {
/**
* Destination channel for the response.
*/
to: string;
/**
* Boolean indicating the success of the authentication process.
*/
success: boolean;
/**
* User object representing the authenticated user.
*/
user: object;
}
/**
* Realtime request structure for authentication.
*/
type RealtimeRequestAuthenticate = {
/**
* Session identifier for authentication.
*/
session: string;
}
type TimeoutHandle = ReturnType<typeof setTimeout> | number;
/**
* Realtime interface representing the structure of a realtime communication object.
*/
type Realtime = {
/**
* WebSocket instance for realtime communication.
*/
socket?: WebSocket;
/**
* Timeout for reconnect operations.
*/
timeout?: TimeoutHandle;
/**
* Heartbeat interval for the realtime connection.
*/
heartbeat?: TimeoutHandle;
/**
* URL for establishing the WebSocket connection.
*/
url?: string;
/**
* Last received message from the realtime server.
*/
lastMessage?: RealtimeResponse;
/**
* Set of channel names the client is subscribed to.
*/
channels: Set<string>;
/**
* Set of query strings the client is subscribed to.
*/
queries: Set<string>;
/**
* Map of subscriptions containing channel names and corresponding callback functions.
*/
subscriptions: Map<number, {
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.
*/
subscriptionsCounter: number;
/**
* Boolean indicating whether automatic reconnection is enabled.
*/
reconnect: boolean;
/**
* Number of reconnection attempts made.
*/
reconnectAttempts: number;
/**
* Function to get the timeout duration for communication operations.
*/
getTimeout: () => number;
/**
* Function to establish a WebSocket connection.
*/
connect: () => void;
/**
* Function to create a new WebSocket instance.
*/
createSocket: () => void;
/**
* Function to create a new heartbeat interval.
*/
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;
/**
* Function to handle incoming messages from the WebSocket connection.
*
* @param {MessageEvent} event - Event containing the received message.
*/
onMessage: (event: MessageEvent) => void;
}
/**
* Type representing upload progress information.
*/
type UploadProgress = {
/**
* Identifier for the upload progress.
*/
$id: string;
/**
* Current progress of the upload (in percentage).
*/
progress: number;
/**
* Total size uploaded (in bytes) during the upload process.
*/
sizeUploaded: number;
/**
* Total number of chunks that need to be uploaded.
*/
chunksTotal: number;
/**
* Number of chunks that have been successfully uploaded.
*/
chunksUploaded: number;
}
/**
* Exception thrown by the package
*/
class AppwriteException extends Error {
/**
* The error code associated with the exception.
*/
code: number;
/**
* The response string associated with the exception.
*/
response: string;
/**
* Error type.
* See [Error Types](https://appwrite.io/docs/response-codes#errorTypes) for more information.
*/
type: string;
/**
* Initializes a Appwrite Exception.
*
* @param {string} message - The error message.
* @param {number} code - The error code. Default is 0.
* @param {string} type - The error type. Default is an empty string.
* @param {string} response - The response string. Default is an empty string.
*/
constructor(message: string, code: number = 0, type: string = '', response: string = '') {
super(message);
this.name = 'AppwriteException';
this.message = message;
this.code = code;
this.type = type;
this.response = response;
}
}
/**
* Client that handles requests to Appwrite
*/
class Client {
static CHUNK_SIZE = 1024 * 1024 * 5;
/**
* Holds configuration such as project.
*/
config: {
endpoint: string;
endpointRealtime: string;
project: string;
jwt: string;
locale: string;
session: string;
devkey: string;
} = {
endpoint: 'https://cloud.appwrite.io/v1',
endpointRealtime: '',
project: '',
jwt: '',
locale: '',
session: '',
devkey: '',
};
/**
* Custom headers for API requests.
*/
headers: Headers = {
'x-sdk-name': 'Web',
'x-sdk-platform': 'client',
'x-sdk-language': 'web',
'x-sdk-version': '22.3.0',
'X-Appwrite-Response-Format': '1.8.0',
};
/**
* Set Endpoint
*
* Your project endpoint
*
* @param {string} endpoint
*
* @returns {this}
*/
setEndpoint(endpoint: string): this {
if (!endpoint || typeof endpoint !== 'string') {
throw new AppwriteException('Endpoint must be a valid string');
}
if (!endpoint.startsWith('http://') && !endpoint.startsWith('https://')) {
throw new AppwriteException('Invalid endpoint URL: ' + endpoint);
}
this.config.endpoint = endpoint;
this.config.endpointRealtime = endpoint.replace('https://', 'wss://').replace('http://', 'ws://');
return this;
}
/**
* Set Realtime Endpoint
*
* @param {string} endpointRealtime
*
* @returns {this}
*/
setEndpointRealtime(endpointRealtime: string): this {
if (!endpointRealtime || typeof endpointRealtime !== 'string') {
throw new AppwriteException('Endpoint must be a valid string');
}
if (!endpointRealtime.startsWith('ws://') && !endpointRealtime.startsWith('wss://')) {
throw new AppwriteException('Invalid realtime endpoint URL: ' + endpointRealtime);
}
this.config.endpointRealtime = endpointRealtime;
return this;
}
/**
* Set Project
*
* Your project ID
*
* @param value string
*
* @return {this}
*/
setProject(value: string): this {
this.headers['X-Appwrite-Project'] = value;
this.config.project = value;
return this;
}
/**
* Set JWT
*
* Your secret JSON Web Token
*
* @param value string
*
* @return {this}
*/
setJWT(value: string): this {
this.headers['X-Appwrite-JWT'] = value;
this.config.jwt = value;
return this;
}
/**
* Set Locale
*
* @param value string
*
* @return {this}
*/
setLocale(value: string): this {
this.headers['X-Appwrite-Locale'] = value;
this.config.locale = value;
return this;
}
/**
* Set Session
*
* The user session to authenticate with
*
* @param value string
*
* @return {this}
*/
setSession(value: string): this {
this.headers['X-Appwrite-Session'] = value;
this.config.session = value;
return this;
}
/**
* Set DevKey
*
* Your secret dev API key
*
* @param value string
*
* @return {this}
*/
setDevKey(value: string): this {
this.headers['X-Appwrite-Dev-Key'] = value;
this.config.devkey = value;
return this;
}
private realtime: Realtime = {
socket: undefined,
timeout: undefined,
heartbeat: undefined,
url: '',
channels: new Set(),
queries: new Set(),
subscriptions: new Map(),
slotToSubscriptionId: new Map(),
subscriptionIdToSlot: new Map(),
subscriptionsCounter: 0,
reconnect: true,
reconnectAttempts: 0,
lastMessage: undefined,
connect: () => {
clearTimeout(this.realtime.timeout);
this.realtime.timeout = window?.setTimeout(() => {
this.realtime.createSocket();
}, 50);
},
getTimeout: () => {
switch (true) {
case this.realtime.reconnectAttempts < 5:
return 1000;
case this.realtime.reconnectAttempts < 15:
return 5000;
case this.realtime.reconnectAttempts < 100:
return 10_000;
default:
return 60_000;
}
},
createHeartbeat: () => {
if (this.realtime.heartbeat) {
clearTimeout(this.realtime.heartbeat);
}
this.realtime.heartbeat = window?.setInterval(() => {
this.realtime.socket?.send(JSONbig.stringify({
type: 'ping'
}));
}, 20_000);
},
createSocket: () => {
if (this.realtime.subscriptions.size < 1) {
this.realtime.reconnect = false;
this.realtime.socket?.close();
return;
}
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);
});
});
});
const url = this.config.endpointRealtime + '/realtime?' + queryParams;
if (
url !== this.realtime.url || // Check if URL is present
!this.realtime.socket || // Check if WebSocket has not been created
this.realtime.socket?.readyState > WebSocket.OPEN // Check if WebSocket is CLOSING (3) or CLOSED (4)
) {
if (
this.realtime.socket &&
this.realtime.socket?.readyState < WebSocket.CLOSING // Close WebSocket if it is CONNECTING (0) or OPEN (1)
) {
this.realtime.reconnect = false;
this.realtime.socket.close();
}
this.realtime.url = url;
this.realtime.socket = new WebSocket(url);
this.realtime.socket.addEventListener('message', this.realtime.onMessage);
this.realtime.socket.addEventListener('open', _event => {
this.realtime.reconnectAttempts = 0;
this.realtime.createHeartbeat();
});
this.realtime.socket.addEventListener('close', event => {
if (
!this.realtime.reconnect ||
(
this.realtime?.lastMessage?.type === 'error' && // Check if last message was of type error
(<RealtimeResponseError>this.realtime?.lastMessage.data).code === 1008 // Check for policy violation 1008
)
) {
this.realtime.reconnect = true;
return;
}
const timeout = this.realtime.getTimeout();
console.error(`Realtime got disconnected. Reconnect will be attempted in ${timeout / 1000} seconds.`, event.reason);
setTimeout(() => {
this.realtime.reconnectAttempts++;
this.realtime.createSocket();
}, timeout);
})
}
},
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) {
const cookie = JSONbig.parse(window.localStorage.getItem('cookieFallback') ?? '{}');
session = cookie?.[`a_session_${this.config.project}`];
}
if (session && !messageData?.user) {
this.realtime.socket?.send(JSONbig.stringify(<RealtimeRequest>{
type: 'authentication',
data: {
session
}
}));
}
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));
}
}
}
} else {
const isSubscribed = data.channels.some(channel => this.realtime.channels.has(channel));
if (!isSubscribed) break;
this.realtime.subscriptions.forEach(subscription => {
if (data.channels.some(channel => subscription.channels.includes(channel))) {
setTimeout(() => subscription.callback(data));
}
});
}
break;
}
case 'pong':
break; // Handle pong response if needed
case 'error':
throw message.data;
default:
break;
}
} 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);
}
}
})
}
}
/**
* Subscribes to Appwrite events and passes you the payload in realtime.
*
* @deprecated Use the Realtime service instead.
* @see Realtime
*
* @param {string|string[]|Channel<any>|ActionableChannel|ResolvedChannel|(Channel<any>|ActionableChannel|ResolvedChannel)[]} channels
* Channel to subscribe - pass a single channel as a string or Channel builder instance, or multiple with an array.
*
* Possible channels are:
* - account
* - collections
* - collections.[ID]
* - collections.[ID].documents
* - documents
* - documents.[ID]
* - files
* - files.[ID]
* - executions
* - executions.[ID]
* - functions.[ID]
* - teams
* - teams.[ID]
* - memberships
* - memberships.[ID]
*
* You can also use Channel builders:
* - Channel.database('db').collection('col').document('doc').create()
* - Channel.bucket('bucket').file('file').update()
* - Channel.function('func').execution('exec').delete()
* - Channel.team('team').create()
* - Channel.membership('membership').update()
* @param {(payload: RealtimeMessage) => void} callback Is called on every realtime update.
* @returns {() => void} Unsubscribes from events.
*/
subscribe<T extends unknown>(
channels: string | string[] | Channel<any> | ActionableChannel | ResolvedChannel | (Channel<any> | ActionableChannel | ResolvedChannel)[],
callback: (payload: RealtimeResponseEvent<T>) => void,
queries: (string | Query)[] = []
): () => void {
const channelArray = Array.isArray(channels) ? channels : [channels];
// Convert Channel instances to strings
const channelStrings = channelArray.map(ch => {
if (typeof ch === 'string') {
return ch;
}
// All Channel instances have toString() method
if (ch && typeof (ch as Channel<any>).toString === 'function') {
return (ch as Channel<any>).toString();
}
// Fallback to generic string conversion
return String(ch);
});
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, {
channels: channelStrings,
queries: queryStrings,
callback
});
this.realtime.connect();
return () => {
this.realtime.subscriptions.delete(counter);
this.realtime.cleanUp(channelStrings, queryStrings);
this.realtime.connect();
}
}
prepareRequest(method: string, url: URL, headers: Headers = {}, params: Payload = {}): { uri: string, options: RequestInit } {
method = method.toUpperCase();
headers = Object.assign({}, this.headers, headers);
if (typeof window !== 'undefined' && window.localStorage) {
const cookieFallback = window.localStorage.getItem('cookieFallback');
if (cookieFallback) {
headers['X-Fallback-Cookies'] = cookieFallback;
}
}
let options: RequestInit = {
method,
headers,
};
if (headers['X-Appwrite-Dev-Key'] === undefined) {
options.credentials = 'include';
}
if (method === 'GET') {
for (const [key, value] of Object.entries(Client.flatten(params))) {
url.searchParams.append(key, value);
}
} else {
switch (headers['content-type']) {
case 'application/json':
options.body = JSONbig.stringify(params);
break;
case 'multipart/form-data':
const formData = new FormData();
for (const [key, value] of Object.entries(params)) {
if (value instanceof File) {
formData.append(key, value, value.name);
} else if (Array.isArray(value)) {
for (const nestedValue of value) {
formData.append(`${key}[]`, nestedValue);
}
} else {
formData.append(key, value);
}
}
options.body = formData;
delete headers['content-type'];
break;
}
}
return { uri: url.toString(), options };
}
async chunkedUpload(method: string, url: URL, headers: Headers = {}, originalPayload: Payload = {}, onProgress: (progress: UploadProgress) => void) {
const [fileParam, file] = Object.entries(originalPayload).find(([_, value]) => value instanceof File) ?? [];
if (!file || !fileParam) {
throw new Error('File not found in payload');
}
if (file.size <= Client.CHUNK_SIZE) {
return await this.call(method, url, headers, originalPayload);
}
let start = 0;
let response = null;
while (start < file.size) {
let end = start + Client.CHUNK_SIZE; // Prepare end for the next chunk
if (end >= file.size) {
end = file.size; // Adjust for the last chunk to include the last byte
}
headers['content-range'] = `bytes ${start}-${end-1}/${file.size}`;
const chunk = file.slice(start, end);
let payload = { ...originalPayload };
payload[fileParam] = new File([chunk], file.name);
response = await this.call(method, url, headers, payload);
if (onProgress && typeof onProgress === 'function') {
onProgress({
$id: response.$id,
progress: Math.round((end / file.size) * 100),
sizeUploaded: end,
chunksTotal: Math.ceil(file.size / Client.CHUNK_SIZE),
chunksUploaded: Math.ceil(end / Client.CHUNK_SIZE)
});
}
if (response && response.$id) {
headers['x-appwrite-id'] = response.$id;
}
start = end;
}
return response;
}
async ping(): Promise<string> {
return this.call('GET', new URL(this.config.endpoint + '/ping'));
}
async call(method: string, url: URL, headers: Headers = {}, params: Payload = {}, responseType = 'json'): Promise<any> {
const { uri, options } = this.prepareRequest(method, url, headers, params);
let data: any = null;
const response = await fetch(uri, options);
// type opaque: No-CORS, different-origin response (CORS-issue)
if (response.type === 'opaque') {
throw new AppwriteException(
`Invalid Origin. Register your new client (${window.location.host}) as a new Web platform on your project console dashboard`,
403,
"forbidden",
""
);
}
const warnings = response.headers.get('x-appwrite-warning');
if (warnings) {
warnings.split(';').forEach((warning: string) => console.warn('Warning: ' + warning));
}
if (response.headers.get('content-type')?.includes('application/json')) {
data = JSONbig.parse(await response.text());
} else if (responseType === 'arrayBuffer') {
data = await response.arrayBuffer();
} else {
data = {
message: await response.text()
};
}
if (400 <= response.status) {
let responseText = '';
if (response.headers.get('content-type')?.includes('application/json') || responseType === 'arrayBuffer') {
responseText = JSONbig.stringify(data);
} else {
responseText = data?.message;
}
throw new AppwriteException(data?.message, response.status, data?.type, responseText);
}
const cookieFallback = response.headers.get('X-Fallback-Cookies');
if (typeof window !== 'undefined' && window.localStorage && cookieFallback) {
window.console.warn('Appwrite is using localStorage for session management. Increase your security by adding a custom domain as your API endpoint.');
window.localStorage.setItem('cookieFallback', cookieFallback);
}
return data;
}
static flatten(data: Payload, prefix = ''): Payload {
let output: Payload = {};
for (const [key, value] of Object.entries(data)) {
let finalKey = prefix ? prefix + '[' + key +']' : key;
if (Array.isArray(value)) {
output = { ...output, ...Client.flatten(value, finalKey) };
} else {
output[finalKey] = value;
}
}
return output;
}
}
export { Client, AppwriteException };
export { Query } from './query';
export type { Models, Payload, UploadProgress };
export type { RealtimeResponseEvent };
export type { QueryTypes, QueryTypesList } from './query';