import 'isomorphic-form-data'; import { fetch } from 'cross-fetch'; import { Models } from './models'; import { Service } from './service'; type Payload = { [key: string]: any; } type Headers = { [key: string]: string; } type RealtimeResponse = { type: 'error' | 'event' | 'connected' | 'response'; data: RealtimeResponseAuthenticated | RealtimeResponseConnected | RealtimeResponseError | RealtimeResponseEvent; } type RealtimeRequest = { type: 'authentication'; data: RealtimeRequestAuthenticate; } export type RealtimeResponseEvent = { events: string[]; channels: string[]; timestamp: number; payload: T; } type RealtimeResponseError = { code: number; message: string; } type RealtimeResponseConnected = { channels: string[]; user?: object; } type RealtimeResponseAuthenticated = { to: string; success: boolean; user: object; } type RealtimeRequestAuthenticate = { session: string; } type Realtime = { socket?: WebSocket; timeout?: number; url?: string; lastMessage?: RealtimeResponse; channels: Set; subscriptions: Map) => void }>; subscriptionsCounter: number; reconnect: boolean; reconnectAttempts: number; getTimeout: () => number; connect: () => void; createSocket: () => void; cleanUp: (channels: string[]) => void; onMessage: (event: MessageEvent) => void; } export type UploadProgress = { $id: string; progress: number; sizeUploaded: number; chunksTotal: number; chunksUploaded: number; } class AppwriteException extends Error { code: number; response: string; type: 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; } } class Client { config = { endpoint: 'https://HOSTNAME/v1', endpointRealtime: '', project: '', jwt: '', locale: '', }; headers: Headers = { 'x-sdk-name': 'Web', 'x-sdk-platform': 'client', 'x-sdk-language': 'web', 'x-sdk-version': '10.0.1', 'X-Appwrite-Response-Format': '1.0.0', }; /** * Set Endpoint * * Your project endpoint * * @param {string} endpoint * * @returns {this} */ setEndpoint(endpoint: string): this { this.config.endpoint = endpoint; this.config.endpointRealtime = this.config.endpointRealtime || this.config.endpoint.replace('https://', 'wss://').replace('http://', 'ws://'); return this; } /** * Set Realtime Endpoint * * @param {string} endpointRealtime * * @returns {this} */ setEndpointRealtime(endpointRealtime: string): this { 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; } private realtime: Realtime = { socket: undefined, timeout: undefined, url: '', channels: new Set(), subscriptions: 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; } }, createSocket: () => { if (this.realtime.channels.size < 1) return; const channels = new URLSearchParams(); channels.set('project', this.config.project); this.realtime.channels.forEach(channel => { channels.append('channels[]', channel); }); const url = this.config.endpointRealtime + '/realtime?' + channels.toString(); 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.socket.addEventListener('close', event => { if ( !this.realtime.reconnect || ( this.realtime?.lastMessage?.type === 'error' && // Check if last message was of type error (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 = JSON.parse(event.data); this.realtime.lastMessage = message; switch (message.type) { case 'connected': const cookie = JSON.parse(window.localStorage.getItem('cookieFallback') ?? '{}'); const session = cookie?.[`a_session_${this.config.project}`]; const messageData = message.data; if (session && !messageData.user) { this.realtime.socket?.send(JSON.stringify({ type: 'authentication', data: { session } })); } break; case 'event': let data = >message.data; if (data?.channels) { const isSubscribed = data.channels.some(channel => this.realtime.channels.has(channel)); if (!isSubscribed) return; this.realtime.subscriptions.forEach(subscription => { if (data.channels.some(channel => subscription.channels.includes(channel))) { setTimeout(() => subscription.callback(data)); } }) } break; case 'error': throw message.data; default: break; } } catch (e) { console.error(e); } }, cleanUp: channels => { 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); } } }) } } /** * Subscribes to Appwrite events and passes you the payload in realtime. * * @param {string|string[]} channels * Channel to subscribe - pass a single channel as a string or multiple with an array of strings. * * 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] * @param {(payload: RealtimeMessage) => void} callback Is called on every realtime update. * @returns {() => void} Unsubscribes from events. */ subscribe(channels: string | string[], callback: (payload: RealtimeResponseEvent) => void): () => void { let channelArray = typeof channels === 'string' ? [channels] : channels; channelArray.forEach(channel => this.realtime.channels.add(channel)); const counter = this.realtime.subscriptionsCounter++; this.realtime.subscriptions.set(counter, { channels: channelArray, callback }); this.realtime.connect(); return () => { this.realtime.subscriptions.delete(counter); this.realtime.cleanUp(channelArray); this.realtime.connect(); } } async call(method: string, url: URL, headers: Headers = {}, params: Payload = {}): Promise { method = method.toUpperCase(); headers = Object.assign({}, this.headers, headers); let options: RequestInit = { method, headers, credentials: 'include' }; if (typeof window !== 'undefined' && window.localStorage) { headers['X-Fallback-Cookies'] = window.localStorage.getItem('cookieFallback') ?? ''; } if (method === 'GET') { for (const [key, value] of Object.entries(Service.flatten(params))) { url.searchParams.append(key, value); } } else { switch (headers['content-type']) { case 'application/json': options.body = JSON.stringify(params); break; case 'multipart/form-data': let formData = new FormData(); for (const key in params) { if (Array.isArray(params[key])) { params[key].forEach((value: any) => { formData.append(key + '[]', value); }) } else { formData.append(key, params[key]); } } options.body = formData; delete headers['content-type']; break; } } try { let data = null; const response = await fetch(url.toString(), options); if (response.headers.get('content-type')?.includes('application/json')) { data = await response.json(); } else { data = { message: await response.text() }; } if (400 <= response.status) { throw new AppwriteException(data?.message, response.status, data?.type, data); } 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; } catch (e) { if (e instanceof AppwriteException) { throw e; } throw new AppwriteException((e).message); } } } export { Client, AppwriteException }; export { Query } from './query'; export type { Models, Payload }; export type { QueryTypes, QueryTypesList } from './query';