UNPKG

acebase-client

Version:

Client to connect to an AceBase realtime database server

931 lines (924 loc) 115 kB
import { Api, Transport, ID, PathInfo, ColorStyle, SchemaDefinition, SimpleEventEmitter } from 'acebase-core'; import { connect as connectSocket } from 'socket.io-client'; import * as Base64 from './base64/index.js'; import { AceBaseRequestError, NOT_CONNECTED_ERROR_MESSAGE } from './request/error.js'; import { CachedValueUnavailableError } from './errors.js'; import { promiseTimeout } from './promise-timeout.js'; import _request from './request/index.js'; const _websocketRequest = (socket, event, data, accessToken) => { if (!socket) { throw new Error(`Cannot send request because websocket connection is not open`); } const requestId = ID.generate(); // const request = data; // request.req_id = requestId; // request.access_token = accessToken; const request = { ...data, req_id: requestId, access_token: accessToken }; return new Promise((resolve, reject) => { const checkConnection = () => { if (!socket?.connected) { return reject(new AceBaseRequestError(request, null, 'websocket', 'No open websocket connection')); } }; checkConnection(); let timeout; const send = (retry = 0) => { checkConnection(); socket.emit(event, request); timeout = setTimeout(() => { if (retry < 2) { return send(retry + 1); } socket.off('result', handle); const err = new AceBaseRequestError(request, null, 'timeout', `Server did not respond to "${event}" request after ${retry + 1} tries`); reject(err); }, 1000); }; const handle = (response) => { if (response.req_id === requestId) { clearTimeout(timeout); socket.off('result', handle); if (response.success) { return resolve(response); } // Access denied? const code = typeof response.reason === 'object' ? response.reason.code : response.reason; const message = typeof response.reason === 'object' ? response.reason.message : `request failed: ${code}`; const err = new AceBaseRequestError(request, response, code, message); reject(err); } }; socket.on('result', handle); send(); }); }; /** * TODO: Find out if we can use acebase-core's EventSubscription, extended with some properties */ class EventSubscription { constructor(path, event, callback, settings) { this.path = path; this.event = event; this.callback = callback; this.settings = settings; this.state = 'requested'; this.added = Date.now(); this.activated = 0; this.lastEvent = 0; this.lastSynced = 0; this.cursor = null; this.cacheCallback = null; } activate() { this.state = 'active'; if (this.activated === 0) { this.activated = Date.now(); } } cancel(reason) { this.state = 'canceled'; this.settings.cancelCallback(reason); } } const CONNECTION_STATE_DISCONNECTED = 'disconnected'; const CONNECTION_STATE_CONNECTING = 'connecting'; const CONNECTION_STATE_CONNECTED = 'connected'; const CONNECTION_STATE_DISCONNECTING = 'disconnecting'; // eslint-disable-next-line @typescript-eslint/no-empty-function const NOOP = () => { }; /** * Api to connect to a remote AceBase server over http(s) */ export class WebApi extends Api { constructor(dbname = 'default', settings, callback) { // operations are done through http calls, // events are triggered through a websocket super(); this.dbname = dbname; this.settings = settings; this._id = ID.generate(); // For mutation contexts, not using websocket client id because that might cause security issues this.socket = null; this._serverVersion = 'unknown'; this._cursor = { /** Last cursor received by the server */ current: null, /** Last cursor received before client went offline, will be used for sync. */ sync: null, }; this._eventTimeline = { init: Date.now(), connect: 0, signIn: 0, sync: 0, disconnect: 0 }; this._subscriptions = {}; this._realtimeQueries = {}; this.accessToken = null; this.manualConnectionMonitor = new SimpleEventEmitter(); this._id = ID.generate(); // For mutation contexts, not using websocket client id because that might cause security issues this._autoConnect = typeof settings.autoConnect === 'boolean' ? settings.autoConnect : true; this._autoConnectDelay = typeof settings.autoConnectDelay === 'number' ? settings.autoConnectDelay : 0; this._connectionState = CONNECTION_STATE_DISCONNECTED; if (settings.cache.enabled !== false) { this._cache = { db: settings.cache.db, priority: settings.cache.priority, }; } if (settings.network.monitor) { // Mobile devices might go offline while the app is suspended (running in the backgound) // no events will fire and when the app resumes, it might assume it is still connected while // it is not. We'll manually poll the server to check the connection const interval = setInterval(() => this.checkConnection(), settings.network.interval * 1000); // ping every x seconds interval.unref && interval.unref(); } this.debug = settings.debug; this.eventCallback = (event, ...args) => { if (event === 'disconnect') { this._cursor.sync = this._cursor.current; } callback && callback(event, ...args); }; if (this._autoConnect) { if (this._autoConnectDelay) { setTimeout(() => this.connect().catch(NOOP), this._autoConnectDelay); } else { this.connect().catch(NOOP); } } } /** * Allow cursor used for synchronization to be changed. Should only be done while not connected. */ setSyncCursor(cursor) { this._cursor.sync = cursor; } getSyncCursor() { return this._cursor.sync; } get host() { return this.settings.url; } get url() { return `${this.settings.url}${this.settings.rootPath ? `/${this.settings.rootPath}` : ''}`; } async _updateCursor(cursor) { if (!cursor || (this._cursor.current && cursor < this._cursor.current)) { return; // Just in case this ever happens, ignore events with earlier cursors. } this._cursor.current = cursor; } get hasCache() { return !!this._cache; } get cache() { if (!this._cache) { throw new Error('DEV ERROR: no cache db is used'); } return this._cache; } async checkConnection() { // Websocket connection is used if (this.settings.network?.realtime && !this.isConnected) { // socket.io handles reconnects, we don't have to monitor return; } if (!this.settings.network?.realtime && ![CONNECTION_STATE_CONNECTING, CONNECTION_STATE_CONNECTED].includes(this._connectionState)) { // No websocket connection. Do not check if we're not connecting or connected return; } const wasConnected = this.isConnected; try { // Websocket is connected (or realtime is not used), check connectivity to server by sending http/s ping await this._request({ url: this.serverPingUrl, ignoreConnectionState: true }); if (!wasConnected) { this.manualConnectionMonitor.emit('connect'); } } catch (err) { // No need to handle error here, _request will have handled the disconnect by calling this._handleDetectedDisconnect } } _handleDetectedDisconnect(err) { if (this.settings.network?.realtime) { // Launch reconnect flow by recycling the websocket this._connectionState === CONNECTION_STATE_DISCONNECTED; this.connect().catch(NOOP); // console.assert(this._connectionState === CONNECTION_STATE_CONNECTING, 'wrong connection state'); } else { if (this._connectionState === CONNECTION_STATE_CONNECTING) { this.manualConnectionMonitor.emit('connect_error', err); } else if (this._connectionState === CONNECTION_STATE_CONNECTED) { this.manualConnectionMonitor.emit('disconnect'); } } } getCachePath(childPath) { const cacheRoot = `${this.dbname}/cache`; return childPath ? PathInfo.getChildPath(cacheRoot, childPath) : cacheRoot; } connect(retry = true) { if (this.socket !== null && typeof this.socket === 'object') { this.disconnect(); } this._connectionState = CONNECTION_STATE_CONNECTING; this.debug.log(`Connecting to AceBase server "${this.url}"`); if (!this.url.startsWith('https')) { this.debug.warn(`WARNING: The server you are connecting to does not use https, any data transferred may be intercepted!`.colorize(ColorStyle.red)); } // Change default socket.io (engine.io) transports setting of ['polling', 'websocket'] // We should only use websocket (it's almost 2022!), because if an AceBaseServer is running in a cluster, // polling should be disabled entirely because the server is not stateless: the client might reach // a different node on a next long-poll connection. // For backward compatibility the transports setting is allowed to be overriden with a setting: const transports = this.settings.network?.transports instanceof Array ? this.settings.network.transports : ['websocket']; this.debug.log(`Using ${transports.join(',')} transport${transports.length > 1 ? 's' : ''} for socket.io`); return new Promise((resolve, reject) => { if (!this.settings.network?.realtime) { // New option: not using websocket connection. Check if we can reach the server. // Make sure any previously attached events are removed this.manualConnectionMonitor.off('connect'); this.manualConnectionMonitor.off('connect_error'); this.manualConnectionMonitor.off('disconnect'); this.manualConnectionMonitor.on('connect', () => { this._connectionState = CONNECTION_STATE_CONNECTED; this._eventTimeline.connect = Date.now(); this.manualConnectionMonitor.off('connect_error'); // prevent connect_error to fire after a successful connect this.eventCallback('connect'); resolve(); }); this.manualConnectionMonitor.on('connect_error', (err) => { // New connection failed to establish. Attempts will be made to reconnect, but fail for now this.debug.error(`API connection error: ${err.message || err}`); this.eventCallback('connect_error', err); reject(err); }); this.manualConnectionMonitor.on('disconnect', () => { // Existing connection was broken, by us or network if (this._connectionState === CONNECTION_STATE_DISCONNECTING) { // Disconnect was requested by us: reason === 'client namespace disconnect' this._connectionState = CONNECTION_STATE_DISCONNECTED; // Remove event listeners this.manualConnectionMonitor.off('connect'); this.manualConnectionMonitor.off('disconnect'); this.manualConnectionMonitor.off('connect_error'); } else { // Disconnect was not requested. this._connectionState = CONNECTION_STATE_CONNECTING; this._eventTimeline.disconnect = Date.now(); } this.eventCallback('disconnect'); }); this._connectionState = CONNECTION_STATE_CONNECTING; return setTimeout(() => this.checkConnection(), 0); } const socket = this.socket = connectSocket(this.host, { // Use default socket.io connection settings: path: `/${this.settings.rootPath ? `${this.settings.rootPath}/` : ''}socket.io`, autoConnect: true, reconnection: retry, reconnectionAttempts: retry ? Infinity : 0, reconnectionDelay: 1000, reconnectionDelayMax: 5000, timeout: 20000, randomizationFactor: 0.5, transports, // Override default setting of ['polling', 'websocket'] }); socket.on('connect_error', (err) => { // New connection failed to establish. Attempts will be made to reconnect, but fail for now this.debug.error(`Websocket connection error: ${err}`); this.eventCallback('connect_error', err); reject(err); }); socket.on('connect', async () => { this._connectionState = CONNECTION_STATE_CONNECTED; this._eventTimeline.connect = Date.now(); if (this.accessToken) { // User must be signed in again (NOTE: this does not emit the "signin" event if the user was signed in before) const isFirstSignIn = this._eventTimeline.signIn === 0; try { await this.signInWithToken(this.accessToken, isFirstSignIn); } catch (err) { this.debug.error(`Could not automatically sign in user with access token upon reconnect: ${err.code || err.message}`); } } const subscribeTo = async (sub) => { // Function is called for each unique path/event combination // We must activate or cancel all subscriptions with this combination const subs = this._subscriptions[sub.path].filter(s => s.event === sub.event); try { const result = await _websocketRequest(this.socket, 'subscribe', { path: sub.path, event: sub.event }, this.accessToken); subs.forEach(s => s.activate()); } catch (err) { if (err.code === 'access_denied' && !this.accessToken) { this.debug.error(`Could not subscribe to event "${sub.event}" on path "${sub.path}" because you are not signed in. If you added this event while offline and have a user access token, you can prevent this by using client.auth.setAccessToken(token) to automatically try signing in after connecting`); } else { this.debug.error(err); } subs.forEach(s => s.cancel(err)); } }; // (re)subscribe to any active subscriptions const subscribePromises = []; Object.keys(this._subscriptions).forEach(path => { const events = []; this._subscriptions[path].forEach(sub => { if (sub.event === 'mutated') { return; } // Skip mutated events for now const serverAlreadyNotifying = events.includes(sub.event); if (!serverAlreadyNotifying) { events.push(sub.event); const promise = subscribeTo(sub); subscribePromises.push(promise); } }); }); // Now, subscribe to all top path mutated events const subscribeToMutatedEvents = async () => { let retry = false; const promises = Object.keys(this._subscriptions) .filter(path => this._subscriptions[path].some(sub => sub.event === 'mutated' && sub.state !== 'canceled')) .filter((path, i, arr) => !arr.some(otherPath => PathInfo.get(otherPath).isAncestorOf(path))) .reduce((topPaths, path) => (topPaths.includes(path) || topPaths.push(path)) && topPaths, []) .map(topEventPath => { const sub = this._subscriptions[topEventPath].find(s => s.event === 'mutated'); const promise = subscribeTo(sub).then(() => { if (sub.state === 'canceled') { // Oops, could not subscribe to 'mutated' event on topEventPath, other event(s) at child path(s) should now take over retry = true; } }); return promise; }); await Promise.all(promises); if (retry) { await subscribeToMutatedEvents(); } }; subscribePromises.push(subscribeToMutatedEvents()); await Promise.all(subscribePromises); this.eventCallback('connect'); // Safe to let client know we're connected resolve(); // Resolve the .connect() promise }); socket.on('disconnect', (reason) => { this.debug.warn(`Websocket disconnected: ${reason}`); // Existing connection was broken, by us or network if (this._connectionState === CONNECTION_STATE_DISCONNECTING) { // disconnect was requested by us: reason === 'client namespace disconnect' this._connectionState = CONNECTION_STATE_DISCONNECTED; } else { // Automatic reconnect should be done by socket.io this._connectionState = CONNECTION_STATE_CONNECTING; this._eventTimeline.disconnect = Date.now(); if (reason === 'io server disconnect') { // if the server has shut down and disconnected all clients, we have to reconnect manually this.socket = null; this.connect().catch(err => { // Immediate reconnect failed, which is ok. // socket.io will retry now }); } } this.eventCallback('disconnect'); }); socket.on('data-event', (data) => { const val = Transport.deserialize(data.val); const context = data.context || {}; context.acebase_event_source = 'server'; this._updateCursor(context.acebase_cursor); // If the server passes a cursor, it supports transaction logging. Save it for sync later on /* Using the new context, we can determine how we should handle this data event. From client v0.9.29 on, the set and update API methods add an acebase_mutation object to the context with the following info: client_id: which client initiated the mutation (web api instance, also different per browser tab) id: a unique id of the mutation op: operation used: 'set' or 'update' path: the path the operation was executed on flow: the flow used: - 'server': app was connected, cache was not used. - 'cache': app was offline while mutating, now syncs its change - 'parallel': app was connected, cache was used and updated To determine how to handle this data event, we have to know what events may have already been fired. [Mutation initiated:] - Cache database used? - No -> 'server' flow - Yes -> Client was online/connected? - No -> 'cache' flow (saved to cache db, sycing once connected) - Yes -> 'parallel' flow During 'cache' and 'parallel' flow, any change events will have fired on the cache database already. If we are receiving this data event on the same client, that means we don't have to fire those events again. If we receive this event on a different client, we only have to fire events if they change cached data. [Change event received:] - Is mutation done by us? - No -> Are we using cache? - No -> Fire events - Yes -> Update cache with events disabled*, fire events - Yes -> Are we using cache? - No -> Fire events ourself - Yes -> Skip cache update, don't fire events (both done already) * Different browser tabs use the same cache database. If we would let the cache database fire data change events, they would only fire in 1 browser tab - the first one to update the cache, the others will see no changes because the data will have been updated already. NOTE: While offline, the in-memory state of 2 separate browser tabs will go out of sync because they rely on change notifications from the server - to tackle this problem, cross-tab communication has been implemented. (TODO: let cache db's use the same client ID for server communications) */ const causedByUs = context.acebase_mutation?.client_id === this._id; const cacheEnabled = this.hasCache; //!!this._cache?.db; const fireThisEvent = !causedByUs || !cacheEnabled; const updateCache = !causedByUs && cacheEnabled; const fireCacheEvents = false; // See above flow documentation // console.log(`${this._cache ? `[${this._cache.db.api.storage.name}] ` : ''}Received data event "${data.event}" on path "${data.path}":`, val); // console.log(`Received data event "${data.event}" on path "${data.path}":`, val); const pathSubs = this._subscriptions[data.subscr_path]; if (!pathSubs && data.event !== 'mutated') { // NOTE: 'mutated' events fire on the mutated path itself. 'mutations' events fire on subscription path // We are not subscribed on this path. Happens when an event fires while a server unsubscribe // has been requested, but not processed yet: the local subscription will be gone already. // This can be confusing when using cache, an unsubscribe may have been requested after a cache // event fired - the server event will follow but we're not listening anymore! // this.debug.warn(`Received a data-event on a path we did not subscribe to: "${data.subscr_path}"`); return; } if (updateCache) { if (data.path.startsWith('__')) { // Don't cache private data. This happens when the admin user is signed in // and has an event subscription on the root, or private path. // NOTE: fireThisEvent === true, because it is impossible that this mutation was caused by us (well, it should be!) } else if (data.event === 'mutations') { // Apply all mutations const mutations = val.current; mutations.forEach(m => { const pathInfo = m.target.reduce((pathInfo, key) => pathInfo.child(key), PathInfo.get(this.getCachePath())); this.cache.db.api.set(pathInfo.path, m.val, { suppress_events: !fireCacheEvents, context }); }); } else if (data.event === 'notify_child_removed') { this.cache.db.api.set(this.getCachePath(data.path), null, { suppress_events: !fireCacheEvents, context }); // Remove cached value } else if (!data.event.startsWith('notify_')) { this.cache.db.api.set(this.getCachePath(data.path), val.current, { suppress_events: !fireCacheEvents, context }); // Update cached value } } if (!fireThisEvent) { return; } // The cache db will not have fired any events (const fireCacheEvents = false), so we can fire them here now. const targetSubs = data.event === 'mutated' ? Object.keys(this._subscriptions) .filter(path => { const pathInfo = PathInfo.get(path); return path === data.path || pathInfo.equals(data.subscr_path) || pathInfo.isAncestorOf(data.path); }) .reduce((subs, path) => { const add = this._subscriptions[path].filter(sub => sub.event === 'mutated'); subs.push(...add); return subs; }, []) : pathSubs.filter(sub => sub.event === data.event); targetSubs.forEach(subscr => { subscr.lastEvent = Date.now(); subscr.cursor = context.acebase_cursor; subscr.callback(null, data.path, val.current, val.previous, context); }); }); socket.on('query-event', (data) => { data = Transport.deserialize(data); const query = this._realtimeQueries[data.query_id]; let keepMonitoring = true; try { keepMonitoring = query.options.eventHandler(data); } catch (err) { keepMonitoring = false; } if (keepMonitoring === false) { delete this._realtimeQueries[data.query_id]; socket.emit('query-unsubscribe', { query_id: data.query_id }); } }); }); } disconnect() { if (!this.settings.network?.realtime) { // No websocket connectino is used this._connectionState = CONNECTION_STATE_DISCONNECTING; this._eventTimeline.disconnect = Date.now(); this.manualConnectionMonitor.emit('disconnect'); } else if (this.socket !== null && typeof this.socket === 'object') { if (this._connectionState === CONNECTION_STATE_CONNECTED) { this._eventTimeline.disconnect = Date.now(); } this._connectionState = CONNECTION_STATE_DISCONNECTING; this.socket.close(); this.socket = null; } } async subscribe(path, event, callback, settings) { if (!this.settings.network?.realtime) { throw new Error(`Cannot subscribe to realtime events because it has been disabled in the network settings`); } let pathSubs = this._subscriptions[path]; if (!pathSubs) { pathSubs = this._subscriptions[path] = []; } const serverAlreadyNotifying = pathSubs.some(sub => sub.event === event) || (event === 'mutated' && Object.keys(this._subscriptions).some(otherPath => PathInfo.get(otherPath).isAncestorOf(path) && this._subscriptions[otherPath].some(sub => sub.event === event && sub.state === 'active'))); const subscr = new EventSubscription(path, event, callback, settings); // { path, event, callback, settings, added: Date.now(), activate() { this.activated = Date.now() }, activated: null, lastEvent: null, cursor: null }; pathSubs.push(subscr); if (this.hasCache) { // Events are also handled by cache db const cacheRootPath = this.getCachePath(); subscr.cacheCallback = (err, path, newValue, oldValue, context) => subscr.callback(err, path.slice(cacheRootPath.length + 1), newValue, oldValue, context); this.cache.db.api.subscribe(this.getCachePath(path), event, subscr.cacheCallback); } if (serverAlreadyNotifying || !this.isConnected) { // If we're offline, the event will be subscribed once connected return; } if (event === 'mutated') { // Unsubscribe from 'mutated' events set on descendant paths of current path Object.keys(this._subscriptions) .filter(otherPath => PathInfo.get(otherPath).isDescendantOf(path) && this._subscriptions[otherPath].some(sub => sub.event === 'mutated')) .map(path => _websocketRequest(this.socket, 'unsubscribe', { path, event: 'mutated' }, this.accessToken)) .map(promise => promise.catch(err => console.error(err))); } const result = await _websocketRequest(this.socket, 'subscribe', { path, event }, this.accessToken); subscr.activate(); // return result; } async unsubscribe(path, event, callback) { if (!this.settings.network?.realtime) { throw new Error(`Cannot unsubscribe from realtime events because it has been disabled in the network settings`); } const pathSubs = this._subscriptions[path]; if (!pathSubs) { return Promise.resolve(); } const unsubscribeFrom = (subscriptions) => { subscriptions.forEach(subscr => { pathSubs.splice(pathSubs.indexOf(subscr), 1); if (this.hasCache) { // Events are also handled by cache db, also remove those if (typeof subscr.cacheCallback !== 'function') { throw new Error('DEV ERROR: When subscription was added, cacheCallback must have been set'); } this.cache.db.api.unsubscribe(this.getCachePath(path), subscr.event, subscr.cacheCallback); } }); }; const hadMutatedEvents = pathSubs.some(sub => sub.event === 'mutated'); if (!event) { // Unsubscribe from all events on path unsubscribeFrom(pathSubs); } else if (!callback) { // Unsubscribe from specific event on path const subscriptions = pathSubs.filter(subscr => subscr.event === event); unsubscribeFrom(subscriptions); } else { // Unsubscribe from a specific callback on path event const subscriptions = pathSubs.filter(subscr => subscr.event === event && subscr.callback === callback); unsubscribeFrom(subscriptions); } const hasMutatedEvents = pathSubs.some(sub => sub.event === 'mutated'); let promise = Promise.resolve(); if (pathSubs.length === 0) { // Unsubscribed from all events on path delete this._subscriptions[path]; if (this.isConnected) { promise = _websocketRequest(this.socket, 'unsubscribe', { path, access_token: this.accessToken }, this.accessToken) .catch(err => this.debug.error(`Failed to unsubscribe from event(s) on "${path}": ${err.message}`)); } } else if (this.isConnected && !pathSubs.some(subscr => subscr.event === event)) { // No callbacks left for specific event promise = _websocketRequest(this.socket, 'unsubscribe', { path: path, event, access_token: this.accessToken }, this.accessToken) .catch(err => this.debug.error(`Failed to unsubscribe from event "${event}" on "${path}": ${err.message}`)); } if (this.isConnected && hadMutatedEvents && !hasMutatedEvents) { // If any descendant paths have mutated events, resubscribe those const promises = Object.keys(this._subscriptions) .filter(otherPath => PathInfo.get(otherPath).isDescendantOf(path) && this._subscriptions[otherPath].some(sub => sub.event === 'mutated')) .map(path => _websocketRequest(this.socket, 'subscribe', { path: path, event: 'mutated' }, this.accessToken)) .map(promise => promise.catch(err => this.debug.error(`Failed to subscribe to event "${event}" on path "${path}": ${err.message}`))); promise = Promise.all([promise, ...promises]); } await promise; } transaction(path, callback, options = { context: {} }) { const id = ID.generate(); options.context = options.context || {}; // TODO: reduce this contextual overhead to 'client_id' only, or additional debugging info upon request options.context.acebase_mutation = { client_id: this._id, id, op: 'transaction', path, flow: 'server', }; const cachePath = this.getCachePath(path); return new Promise(async (resolve, reject) => { let cacheUpdateVal; const handleSuccess = async (context) => { if (this.hasCache && typeof cacheUpdateVal !== 'undefined') { // Update cache db value await this.cache.db.api.set(cachePath, cacheUpdateVal); } resolve({ cursor: context?.acebase_cursor }); }; if (this.isConnected && this.settings.network?.realtime) { // Use websocket connection const socket = this.socket; const startedCallback = async (data) => { if (data.id === id) { socket.off('tx_started', startedCallback); const currentValue = Transport.deserialize(data.value); let newValue = callback(currentValue); if (newValue instanceof Promise) { newValue = await newValue; } socket.emit('transaction', { action: 'finish', id: id, path, value: Transport.serialize(newValue), access_token: this.accessToken }); if (this.hasCache) { cacheUpdateVal = newValue; } } }; const completedCallback = (data) => { if (data.id === id) { socket.off('tx_completed', completedCallback); socket.off('tx_error', errorCallback); handleSuccess(data.context); } }; const errorCallback = (data) => { if (data.id === id) { socket.off('tx_started', startedCallback); socket.off('tx_completed', completedCallback); socket.off('tx_error', errorCallback); reject(new Error(data.reason)); } }; socket.on('tx_started', startedCallback); socket.on('tx_completed', completedCallback); socket.on('tx_error', errorCallback); // TODO: socket.on('disconnect', disconnectedCallback); socket.emit('transaction', { action: 'start', id, path, access_token: this.accessToken, context: options.context }); } else { // Websocket not connected. Try http call instead const startData = JSON.stringify({ path }); try { const tx = await this._request({ ignoreConnectionState: true, method: 'POST', url: `${this.url}/transaction/${this.dbname}/start`, data: startData, context: options.context }); const id = tx.id; const currentValue = Transport.deserialize(tx.value); let newValue = callback(currentValue); if (newValue instanceof Promise) { newValue = await newValue; } if (this.hasCache) { cacheUpdateVal = newValue; } const finishData = JSON.stringify({ id, value: Transport.serialize(newValue) }); const { context } = await this._request({ ignoreConnectionState: true, method: 'POST', url: `${this.url}/transaction/${this.dbname}/finish`, data: finishData, context: options.context, includeContext: true }); await handleSuccess(context); } catch (err) { if (['ETIMEDOUT', 'ENOTFOUND', 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'fetch_failed'].includes(err.code)) { err.message = NOT_CONNECTED_ERROR_MESSAGE; } reject(err); } } }); } /** * @returns returns a promise that resolves with the returned data, or (when options.includeContext === true) an object containing data and returned context */ async _request(options) { if (this.isConnected || options.ignoreConnectionState === true) { const result = await (async () => { try { return await _request(options.method || 'GET', options.url, { data: options.data, accessToken: this.accessToken, dataReceivedCallback: options.dataReceivedCallback, dataRequestCallback: options.dataRequestCallback, context: options.context }); } catch (err) { if (this.isConnected && err.isNetworkError) { // This is a network error, but the websocket thinks we are still connected. this.debug.warn(`A network error occurred loading ${options.url}`); // Start reconnection flow this._handleDetectedDisconnect(err); } // Rethrow the error throw err; } })(); if (result.context && result.context.acebase_cursor) { this._updateCursor(result.context.acebase_cursor); } if (options.includeContext === true) { if (!result.context) { result.context = {}; } return result; } else { return result.data; } } else { // We're not connected. We can wait for the connection to be established, // or fail the request now. Because we have now implemented caching, live requests // are only executed if they are not allowed to use cached responses. Wait for a // connection to be established (max 1s), then retry or fail if (!this.isConnecting || !this.settings.network?.realtime) { // We're currently not trying to connect, or not using websocket connection (normal connection logic is still used). // Fail now throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const connectPromise = new Promise(resolve => this.socket?.once('connect', resolve)); await promiseTimeout(connectPromise, 1000, 'Waiting for connection').catch(err => { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); }); return this._request(options); // Retry } } handleSignInResult(result, emitEvent = true) { this._eventTimeline.signIn = Date.now(); const details = { user: result.user, accessToken: result.access_token, provider: result.provider || 'acebase' }; this.accessToken = details.accessToken; this.socket?.emit('signin', details.accessToken); // Make sure the connected websocket server knows who we are as well. emitEvent && this.eventCallback('signin', details); return details; } async signIn(username, password) { if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signin`, data: { method: 'account', username, password, client_id: this.socket && this.socket.id } }); return this.handleSignInResult(result); } async signInWithEmail(email, password) { if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signin`, data: { method: 'email', email, password, client_id: this.socket && this.socket.id } }); return this.handleSignInResult(result); } async signInWithToken(token, emitEvent = true) { if (!this.isConnected) { throw new Error('Cannot sign in because client is not connected to the server. If you want to automatically sign in the user with this access token once a connection is established, use client.auth.setAccessToken(token)'); } const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signin`, data: { method: 'token', access_token: token, client_id: this.socket && this.socket.id } }); return this.handleSignInResult(result, emitEvent); } setAccessToken(token) { this.accessToken = token; } async startAuthProviderSignIn(providerName, callbackUrl, options) { if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const optionParams = typeof options === 'object' ? '&' + Object.keys(options).map(key => `option_${key}=${encodeURIComponent(options[key])}`).join('&') : ''; const result = await this._request({ url: `${this.url}/oauth2/${this.dbname}/init?provider=${providerName}&callbackUrl=${callbackUrl}${optionParams}` }); return { redirectUrl: result.redirectUrl }; } async finishAuthProviderSignIn(callbackResult) { let result; try { result = JSON.parse(Base64.decode(callbackResult)); } catch (err) { throw new Error(`Invalid result`); } if (!result.user) { // AceBaseServer 1.9.0+ does not include user details in the redirect. // We must get (and validate) auth state with received access token this.accessToken = result.access_token; const authState = await this._request({ url: `${this.url}/auth/${this.dbname}/state` }); if (!authState.signed_in) { this.accessToken = null; throw new Error(`Invalid access token received: not signed in`); } result.user = authState.user; } return this.handleSignInResult(result); } async refreshAuthProviderToken(providerName, refreshToken) { if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const result = await this._request({ url: `${this.url}/oauth2/${this.dbname}/refresh?provider=${providerName}&refresh_token=${refreshToken}` }); return result; } async signOut(options) { if (typeof options === 'boolean') { // Old signature signOut(everywhere:boolean = false) options = { everywhere: options }; } else if (typeof options !== 'object') { throw new TypeError('options must be an object'); } if (typeof options.everywhere !== 'boolean') { options.everywhere = false; } if (typeof options.clearCache !== 'boolean') { options.clearCache = false; } if (!this.accessToken) { return; } if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signout`, data: { client_id: this.socket && this.socket.id, everywhere: options.everywhere } }); this.socket && this.socket.emit('signout', this.accessToken); // Make sure the connected websocket server knows we signed out as well. this.accessToken = null; if (this.hasCache && options.clearCache) { // Clear cache, but don't wait for it to finish this.clearCache().catch(err => { console.error(`Could not clear cache:`, err); }); } this.eventCallback('signout'); } async changePassword(uid, currentPassword, newPassword) { if (!this.accessToken) { throw new Error(`not_signed_in`); } if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/change_password`, data: { uid, password: currentPassword, new_password: newPassword } }); this.accessToken = result.access_token; return { accessToken: this.accessToken }; } async forgotPassword(email) { if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/forgot_password`, data: { email } }); return result; } async verifyEmailAddress(verificationCode) { if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/verify_email`, data: { code: verificationCode } }); return result; } async resetPassword(resetCode, newPassword) { if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/reset_password`, data: { code: resetCode, password: newPassword } }); return result; } async signUp(details, signIn = true) { if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/signup`, data: details }); if (signIn) { return this.handleSignInResult(result); } return { user: result.user, accessToken: this.accessToken }; } async updateUserDetails(details) { if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/update`, data: details }); return { user: result.user }; } async deleteAccount(uid, signOut = true) { if (!this.isConnected) { throw new Error(NOT_CONNECTED_ERROR_MESSAGE); } const result = await this._request({ method: 'POST', url: `${this.url}/auth/${this.dbname}/delete`, data: { uid } }); if (signOut) { this.socket && this.socket.emit('signout', this.accessToken); this.accessToken = null; this.eventCallback('signout'); } return true; } get isConnected() { return this._connectionState === CONNECTION_STATE_CONNECTED; } get isConnecting() { return this._connectionState === CONNECTION_STATE_CONNECTING; } get connectionState() { return this._connectionState; } stats(optio