acebase-client
Version:
Client to connect to an AceBase realtime database server
931 lines (924 loc) • 115 kB
JavaScript
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