firetruss
Version:
Advanced data sync layer for Firebase and Vue.js
1,480 lines (1,298 loc) • 142 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('lodash'), require('vue')) :
typeof define === 'function' && define.amd ? define(['lodash', 'vue'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Truss = factory(global._, global.Vue));
})(this, (function (_, Vue) { 'use strict';
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
var ___default = /*#__PURE__*/_interopDefaultLegacy(_);
var Vue__default = /*#__PURE__*/_interopDefaultLegacy(Vue);
let vue;
let lastDigestRequest = 0, digestInProgress = false;
const bareDigest = function() {
if (vue.digestRequest > lastDigestRequest) return;
vue.digestRequest = lastDigestRequest + 1;
};
const angularProxy = {
active: typeof window !== 'undefined' && window.angular
};
if (angularProxy.active) {
initAngular();
} else {
___default.default.forEach(['digest', 'watch', 'defineModule', 'debounceDigest'], method => {
angularProxy[method] = ___default.default.noop;
});
}
function initAngular() {
const module = window.angular.module('firetruss', []);
angularProxy.digest = bareDigest;
angularProxy.watch = function() {throw new Error('Angular watch proxy not yet initialized');};
angularProxy.defineModule = function(Truss) {
module.constant('Truss', Truss);
};
angularProxy.debounceDigest = function(wait) {
if (wait) {
const debouncedDigest = ___default.default.debounce(bareDigest, wait);
angularProxy.digest = function() {
if (vue.digestRequest > lastDigestRequest) return;
if (digestInProgress) bareDigest(); else debouncedDigest();
};
} else {
angularProxy.digest = bareDigest;
}
};
module.config(['$provide', function($provide) {
$provide.decorator('$rootScope', ['$delegate', '$exceptionHandler',
function($delegate, $exceptionHandler) {
const rootScope = $delegate;
angularProxy.watch = rootScope.$watch.bind(rootScope);
const proto = Object.getPrototypeOf(rootScope);
const angularDigest = proto.$digest;
proto.$digest = bareDigest;
proto.$digest.original = angularDigest;
vue = new Vue__default.default({data: {digestRequest: 0}});
vue.$watch(() => vue.digestRequest, () => {
if (vue.digestRequest > lastDigestRequest) {
// Make sure we execute the digest outside the Vue task queue, because otherwise if the
// client replaced Promise with angular.$q all Truss.nextTick().then() functions will be
// executed inside the Angular digest and hence inside the Vue task queue. But
// Truss.nextTick() is used precisely to avoid that. Note that it's OK to use
// Vue.nextTick() here because even though it will schedule a flush via Promise.then()
// it only uses the native Promise, before it could've been monkey-patched by the app.
Vue__default.default.nextTick(() => {
if (vue.digestRequest <= lastDigestRequest) return;
digestInProgress = true;
rootScope.$digest.original.call(rootScope);
lastDigestRequest = vue.digestRequest = vue.digestRequest + 1;
});
} else {
digestInProgress = false;
}
});
const watcher = ___default.default.last(vue._watchers || vue._scope.effects);
watcher.id = Infinity; // make sure watcher is scheduled last
patchRenderWatcherGet(Object.getPrototypeOf(watcher));
return rootScope;
}
]);
}]);
}
// This is a kludge that catches errors that get through render watchers and end up killing the
// entire Vue event loop (e.g., errors raised in transition callbacks). The state of the DOM may
// not be consistent after such an error is caught, but the global error handler should stop the
// world anyway. May be related to https://github.com/vuejs/vue/issues/7653.
function patchRenderWatcherGet(prototype) {
const originalGet = prototype.get;
prototype.get = function get() {
try {
return originalGet.call(this);
} catch (e) {
if (this.vm._watcher === this && Vue__default.default.config.errorHandler) {
Vue__default.default.config.errorHandler(e, this.vm, 'uncaught render error');
} else {
throw e;
}
}
};
}
class LruCacheItem {
constructor(key, value) {
this.key = key;
this.value = value;
this.touch();
}
touch() {
this.timestamp = Date.now();
}
}
class LruCache {
constructor(maxSize, pruningSize) {
this._items = Object.create(null);
this._size = 0;
this._maxSize = maxSize;
this._pruningSize = pruningSize || Math.ceil(maxSize * 0.10);
}
has(key) {
return Boolean(this._items[key]);
}
get(key) {
const item = this._items[key];
if (!item) return;
item.touch();
return item.value;
}
set(key, value) {
const item = this._items[key];
if (item) {
item.value = value;
} else {
if (this._size >= this._maxSize) this._prune();
this._items[key] = new LruCacheItem(key, value);
this._size += 1;
}
}
delete(key) {
const item = this._items[key];
if (!item) return;
delete this._items[key];
this._size -= 1;
}
_prune() {
const itemsToPrune =
___default.default(this._items).toArray().sortBy('timestamp').take(this._pruningSize).value();
for (const item of itemsToPrune) this.delete(item.key);
}
}
const pathSegments = new LruCache(1000);
const pathMatchers = {};
const maxNumPathMatchers = 1000;
function escapeKey(key) {
if (!key) return key;
// eslint-disable-next-line no-control-regex
return key.toString().replace(/[\x00-\x1f\\.$#[\]\x7f/]/g, char =>
'\\' + ___default.default.padStart(char.charCodeAt(0).toString(16), 2, '0')
);
}
function unescapeKey(key) {
if (!key) return key;
return key.toString().replace(/\\[0-9a-f]{2}/gi, code =>
String.fromCharCode(parseInt(code.slice(1), 16))
);
}
function escapeKeys(object) {
// isExtensible check avoids trying to escape references to Firetruss internals.
if (!(___default.default.isObject(object) && Object.isExtensible(object))) return object;
let result = object;
for (const key in object) {
if (!Object.hasOwn(object, key)) continue;
const value = object[key];
const escapedKey = escapeKey(key);
const escapedValue = escapeKeys(value);
if (escapedKey !== key || escapedValue !== value) {
if (result === object) result = ___default.default.clone(object);
result[escapedKey] = escapedValue;
if (result[key] === value) delete result[key];
}
}
return result;
}
function joinPath() {
const segments = [];
for (let segment of arguments) {
if (!___default.default.isString(segment)) segment = '' + segment;
if (segment.charAt(0) === '/') segments.splice(0, segments.length);
segments.push(segment);
}
if (segments[0] === '/') segments[0] = '';
return segments.join('/');
}
function splitPath(path, leaveSegmentsEscaped) {
const key = (leaveSegmentsEscaped ? 'esc:' : '') + path;
let segments = pathSegments.get(key);
if (!segments) {
segments = path.split('/');
if (!leaveSegmentsEscaped) segments = ___default.default.map(segments, unescapeKey);
pathSegments.set(key, segments);
}
return segments;
}
class PathMatcher {
constructor(pattern) {
this.variables = [];
const prefixMatch = ___default.default.endsWith(pattern, '/$*');
if (prefixMatch) pattern = pattern.slice(0, -3);
const pathTemplate = pattern.replace(/\/\$[^/]*/g, match => {
if (match.length > 1) this.variables.push(match.slice(1));
return '\u0001';
});
Object.freeze(this.variables);
if (/[.$#[\]]|\\(?![0-9a-f][0-9a-f])/i.test(pathTemplate)) {
throw new Error('Path pattern has unescaped keys: ' + pattern);
}
this._regex = new RegExp(
// eslint-disable-next-line no-control-regex
'^' + pathTemplate.replace(/\u0001/g, '/([^/]+)') + (prefixMatch ? '($|/)' : '$'));
}
match(path) {
this._regex.lastIndex = 0;
const match = this._regex.exec(path);
if (!match) return;
const bindings = {};
for (let i = 0; i < this.variables.length; i++) {
bindings[this.variables[i]] = unescapeKey(match[i + 1]);
}
return bindings;
}
test(path) {
return this._regex.test(path);
}
toString() {
return this._regex.toString();
}
}
function makePathMatcher(pattern) {
let matcher = pathMatchers[pattern];
if (!matcher) {
matcher = new PathMatcher(pattern);
// Minimal pseudo-LRU behavior, since we don't expect to actually fill up the cache.
if (___default.default.size(pathMatchers) === maxNumPathMatchers) delete pathMatchers[___default.default.keys(pathMatchers)[0]];
pathMatchers[pattern] = matcher;
}
return matcher;
}
const MIN_WORKER_VERSION = '4.0.0';
class Snapshot {
constructor({path, value, exists, writeSerial}) {
this._path = path;
this._value = value;
this._exists = value === undefined ? exists || false : value !== null;
this._writeSerial = writeSerial;
}
get path() {
return this._path;
}
get exists() {
return this._exists;
}
get value() {
if (this._value === undefined) throw new Error('Value omitted from snapshot');
return this._value;
}
get key() {
if (this._key === undefined) this._key = unescapeKey(this._path.replace(/.*\//, ''));
return this._key;
}
get writeSerial() {
return this._writeSerial;
}
}
class Bridge {
constructor(webWorker) {
this._idCounter = 0;
this._deferreds = {};
this._suspended = false;
this._servers = {};
this._callbacks = {};
this._log = ___default.default.noop;
this._inboundMessages = [];
this._outboundMessages = [];
this._flushMessageQueue = this._flushMessageQueue.bind(this);
this._port = webWorker.port || webWorker;
this._shared = !!webWorker.port;
this._dead = undefined;
Object.seal(this);
this._port.onmessage = this._receive.bind(this);
}
init(lockName, config) {
const items = [];
try {
const storage = window.localStorage || window.sessionStorage;
if (!storage) throw new Error('localStorage and sessionStorage not available');
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
items.push({key, value: storage.getItem(key)});
}
} catch {
// Some browsers don't like us accessing local storage -- nothing we can do.
}
return this._send({msg: 'init', storage: items, config, lockName}).then(response => {
const workerVersion = response.version.match(/^(\d+)\.(\d+)\.(\d+)(-.*)?$/);
if (workerVersion) {
const minVersion = MIN_WORKER_VERSION.match(/^(\d+)\.(\d+)\.(\d+)(-.*)?$/);
// Major version must match precisely, minor and patch must be greater than or equal.
const sufficient = workerVersion[1] === minVersion[1] && (
workerVersion[2] > minVersion[2] ||
workerVersion[2] === minVersion[2] && workerVersion[3] >= minVersion[3]
);
if (!sufficient) {
return Promise.reject(new Error(
`Incompatible Firetruss worker version: ${response.version} ` +
`(${MIN_WORKER_VERSION} or better required)`
));
}
}
if (response.livenessLockName) {
navigator.locks.request(response.livenessLockName, () => {
this.crash({error: {
name: 'Error', message: 'worker terminated', extra: {shared: this._shared}
}});
});
}
return response;
});
}
suspend(suspended) {
if (suspended === undefined) suspended = true;
if (this._suspended === suspended) return;
this._suspended = suspended;
if (!suspended) {
this._receiveMessages(this._inboundMessages);
this._inboundMessages = [];
if (this._outboundMessages.length) Promise.resolve().then(this._flushMessageQueue);
}
}
enableLogging(fn) {
if (fn) {
if (fn === true) {
fn = console.log.bind(console);
this._send({msg: 'enableFirebaseLogging', value: true});
}
this._log = fn;
} else {
this._send({msg: 'enableFirebaseLogging', value: false});
this._log = ___default.default.noop;
}
}
_send(message) {
message.id = ++this._idCounter;
let promise;
if (this._dead) {
return Promise.reject(this._dead);
} else if (message.oneWay) {
promise = Promise.resolve();
} else {
promise = new Promise((resolve, reject) => {
this._deferreds[message.id] = {resolve, reject};
});
const deferred = this._deferreds[message.id];
deferred.promise = promise;
deferred.params = message;
}
if (!this._outboundMessages.length && !this._suspended) {
Promise.resolve().then(this._flushMessageQueue);
}
this._log('send:', message);
this._outboundMessages.push(message);
return promise;
}
_flushMessageQueue() {
this._log('flush:', this._outboundMessages.length, 'messages');
try {
this._port.postMessage(this._outboundMessages);
this._outboundMessages = [];
} catch (e) {
this._log('flush failed:', e);
e.extra = {messages: this._outboundMessages};
throw e;
}
}
_receive(event) {
if (this._dead) return;
if (this._suspended) {
this._inboundMessages = this._inboundMessages.concat(event.data);
} else {
this._receiveMessages(event.data);
}
}
_receiveMessages(messages) {
for (const message of messages) {
this._log('recv:', message);
const fn = this[message.msg];
if (!___default.default.isFunction(fn)) throw new Error('Unknown message: ' + message.msg);
fn.call(this, message);
}
}
bindExposedFunction(name) {
return (function() {
return this._send({msg: 'call', name, args: Array.prototype.slice.call(arguments)});
}).bind(this);
}
resolve(message) {
const deferred = this._deferreds[message.id];
if (!deferred) throw new Error('Received resolution to inexistent Firebase call');
delete this._deferreds[message.id];
deferred.resolve(message.result);
}
reject(message) {
const deferred = this._deferreds[message.id];
if (!deferred) throw new Error('Received rejection of inexistent Firebase call');
delete this._deferreds[message.id];
deferred.reject(errorFromJson(message.error, deferred.params));
}
crash(message) {
let details = `Internal worker error: ${message.error.name}: ${message.error.message}`;
if (message.error.cause) details += ` (caused by ${message.error.cause})`;
this._dead = new Error(details);
if (message.error.extra) this._dead.extra = message.error.extra;
___default.default.forEach(this._deferreds, ({reject}) => {reject(this._dead);});
this._deferreds = {};
throw this._dead;
}
updateLocalStorage({items}) {
try {
const storage = window.localStorage || window.sessionStorage;
for (const item of items) {
if (item.value === null) {
storage.removeItem(item.key);
} else {
storage.setItem(item.key, item.value);
}
}
} catch {
// If we're denied access, there's nothing we can do.
}
}
trackServer(rootUrl) {
if (Object.hasOwn(this._servers, rootUrl)) return Promise.resolve();
const server = this._servers[rootUrl] = {authListeners: []};
const authCallbackId = this._registerCallback(this._authCallback.bind(this, server));
this._send({msg: 'onAuth', url: rootUrl, callbackId: authCallbackId});
}
_authCallback(server, auth) {
server.auth = auth;
for (const listener of server.authListeners) listener(auth);
}
onAuth(rootUrl, callback, context) {
const listener = callback.bind(context);
listener.callback = callback;
listener.context = context;
this._servers[rootUrl].authListeners.push(listener);
listener(this.getAuth(rootUrl));
}
offAuth(rootUrl, callback, context) {
const authListeners = this._servers[rootUrl].authListeners;
for (let i = 0; i < authListeners.length; i++) {
const listener = authListeners[i];
if (listener.callback === callback && listener.context === context) {
authListeners.splice(i, 1);
break;
}
}
}
getAuth(rootUrl) {
return this._servers[rootUrl].auth;
}
authWithCustomToken(url, authToken) {
return this._send({msg: 'authWithCustomToken', url, authToken});
}
authAnonymously(url) {
return this._send({msg: 'authAnonymously', url});
}
unauth(url) {
return this._send({msg: 'unauth', url});
}
set(url, value, writeSerial) {return this._send({msg: 'set', url, value, writeSerial});}
update(url, value, writeSerial) {return this._send({msg: 'update', url, value, writeSerial});}
once(url, writeSerial) {
return this._send({msg: 'once', url, writeSerial}).then(snapshot => new Snapshot(snapshot));
}
on(listenerKey, url, spec, eventType, snapshotCallback, cancelCallback, context, options) {
const handle = {
listenerKey, eventType, snapshotCallback, cancelCallback, context,
params: {msg: 'on', listenerKey, url, spec, eventType, options}
};
const callback = this._onCallback.bind(this, handle);
this._registerCallback(callback, handle);
// Keep multiple IDs to allow the same snapshotCallback to be reused.
snapshotCallback.__callbackIds = snapshotCallback.__callbackIds || [];
snapshotCallback.__callbackIds.push(handle.id);
this._send({
msg: 'on', listenerKey, url, spec, eventType, callbackId: handle.id, options
}).catch(error => {
callback(error);
});
}
off(listenerKey, url, spec, eventType, snapshotCallback, context) {
const idsToDeregister = [];
let callbackId;
if (snapshotCallback) {
callbackId = this._findAndRemoveCallbackId(
snapshotCallback, handle => ___default.default.isMatch(handle, {listenerKey, eventType, context})
);
if (!callbackId) return Promise.resolve(); // no-op, never registered or already deregistered
idsToDeregister.push(callbackId);
} else {
for (const id of ___default.default.keys(this._callbacks)) {
const handle = this._callbacks[id];
if (handle.listenerKey === listenerKey && (!eventType || handle.eventType === eventType)) {
idsToDeregister.push(id);
}
}
}
// Nullify callbacks first, then deregister after off() is complete. We don't want any
// callbacks in flight from the worker to be invoked while the off() is processing, but we don't
// want them to throw an exception either.
for (const id of idsToDeregister) this._nullifyCallback(id);
return this._send({msg: 'off', listenerKey, url, spec, eventType, callbackId}).then(() => {
for (const id of idsToDeregister) this._deregisterCallback(id);
});
}
_onCallback(handle, error, snapshotJson) {
if (error) {
this._deregisterCallback(handle.id);
const e = errorFromJson(error, handle.params);
if (handle.cancelCallback) {
handle.cancelCallback.call(handle.context, e);
} else {
console.error(e);
}
} else {
handle.snapshotCallback.call(handle.context, new Snapshot(snapshotJson));
}
}
transaction(url, oldValue, relativeUpdates, writeSerial) {
return this._send(
{msg: 'transaction', url, oldValue, relativeUpdates, writeSerial}
).then(result => {
if (result.snapshots) {
result.snapshots = ___default.default.map(result.snapshots, jsonSnapshot => new Snapshot(jsonSnapshot));
}
return result;
});
}
onDisconnect(url, method, value) {
return this._send({msg: 'onDisconnect', url, method, value});
}
bounceConnection() {
return this._send({msg: 'bounceConnection'});
}
callback({id, args}) {
const handle = this._callbacks[id];
if (!handle) throw new Error('Unregistered callback: ' + id);
handle.callback.apply(null, args);
}
_registerCallback(callback, handle) {
handle = handle || {};
handle.callback = callback;
handle.id = `cb${++this._idCounter}`;
this._callbacks[handle.id] = handle;
return handle.id;
}
_nullifyCallback(id) {
this._callbacks[id].callback = ___default.default.noop;
}
_deregisterCallback(id) {
delete this._callbacks[id];
}
_findAndRemoveCallbackId(callback, predicate) {
if (!callback.__callbackIds) return;
let i = 0;
while (i < callback.__callbackIds.length) {
const id = callback.__callbackIds[i];
const handle = this._callbacks[id];
if (!handle) {
callback.__callbackIds.splice(i, 1);
continue;
}
if (predicate(handle)) {
callback.__callbackIds.splice(i, 1);
return id;
}
i += 1;
}
}
}
function errorFromJson(json, params) {
if (!json || ___default.default.isError(json)) return json;
const error = new Error(json.message);
try {
error.params = params;
for (const propertyName in json) {
if (propertyName === 'message' || !Object.hasOwn(json, propertyName)) continue;
try {
error[propertyName] = json[propertyName];
} catch {
error.extra = error.extra || {};
error.extra[propertyName] = json[propertyName];
}
}
} catch (e) {
if (!/object is not extensible/.test(e.message)) throw e;
}
return error;
}
/* eslint-disable no-use-before-define */
const EMPTY_ANNOTATIONS = {};
Object.freeze(EMPTY_ANNOTATIONS);
class Handle {
constructor(tree, path, annotations) {
this._tree = tree;
this._path = path.replace(/^\/*/, '/').replace(/\/$/, '') || '/';
if (annotations) {
this._annotations = annotations;
Object.freeze(annotations);
}
}
get $ref() {return this;}
get key() {
if (!this._key) this._key = unescapeKey(this._path.replace(/.*\//, ''));
return this._key;
}
get path() {return this._path;}
get _pathPrefix() {return this._path === '/' ? '' : this._path;}
get parent() {
return new Reference(this._tree, this._path.replace(/\/[^/]*$/, ''), this._annotations);
}
get annotations() {
return this._annotations || EMPTY_ANNOTATIONS;
}
child() {
if (!arguments.length) return this;
const segments = [];
for (const key of arguments) {
if (___default.default.isNil(key)) return;
segments.push(escapeKey(key));
}
return new Reference(
this._tree, `${this._pathPrefix}/${segments.join('/')}`,
this._annotations
);
}
children() {
if (!arguments.length) return this;
const escapedKeys = [];
for (let i = 0; i < arguments.length; i++) {
const arg = arguments[i];
if (___default.default.isArray(arg)) {
const mapping = {};
const subPath = this._pathPrefix + (escapedKeys.length ? `/${escapedKeys.join('/')}` : '');
const rest = ___default.default.slice(arguments, i + 1);
for (const key of arg) {
const subRef =
new Reference(this._tree, `${subPath}/${escapeKey(key)}`, this._annotations);
const subMapping = subRef.children.apply(subRef, rest);
if (subMapping) mapping[key] = subMapping;
}
return mapping;
}
if (___default.default.isNil(arg)) return;
escapedKeys.push(escapeKey(arg));
}
return new Reference(
this._tree, `${this._pathPrefix}/${escapedKeys.join('/')}`, this._annotations);
}
peek(callback) {
return this._tree.truss.peek(this, callback);
}
match(pattern) {
return makePathMatcher(pattern).match(this.path);
}
test(pattern) {
return makePathMatcher(pattern).test(this.path);
}
isEqual(that) {
if (!(that instanceof Handle)) return false;
return this._tree === that._tree && this.toString() === that.toString() &&
___default.default.isEqual(this._annotations, that._annotations);
}
belongsTo(truss) {
return this._tree.truss === truss;
}
}
class Query extends Handle {
constructor(tree, path, spec, annotations) {
super(tree, path, annotations);
this._spec = this._copyAndValidateSpec(spec);
const queryTerms = ___default.default(this._spec)
.map((value, key) => `${key}=${encodeURIComponent(JSON.stringify(value))}`)
.sortBy()
.join('&');
this._string = `${this._path}?${queryTerms}`;
Object.freeze(this);
}
// Vue-bound
get ready() {
return this._tree.isQueryReady(this);
}
get constraints() {
return this._spec;
}
annotate(annotations) {
return new Query(
this._tree, this._path, this._spec, ___default.default.assign({}, this._annotations, annotations));
}
_copyAndValidateSpec(spec) {
if (!spec.by) throw new Error('Query needs "by" clause: ' + JSON.stringify(spec));
if (('at' in spec) + ('from' in spec) + ('to' in spec) > 1) {
throw new Error(
'Query must contain at most one of "at", "from", or "to" clauses: ' + JSON.stringify(spec));
}
if (('first' in spec) + ('last' in spec) > 1) {
throw new Error(
'Query must contain at most one of "first" or "last" clauses: ' + JSON.stringify(spec));
}
if (!___default.default.some(['at', 'from', 'to', 'first', 'last'], clause => clause in spec)) {
throw new Error(
'Query must contain at least one of "at", "from", "to", "first", or "last" clauses: ' +
JSON.stringify(spec));
}
spec = ___default.default.clone(spec);
if (spec.by !== '$key' && spec.by !== '$value') {
if (!(spec.by instanceof Reference)) {
throw new Error('Query "by" value must be a reference: ' + spec.by);
}
let childPath = spec.by.toString();
if (!___default.default.startsWith(childPath, this._path)) {
throw new Error(
'Query "by" value must be a descendant of target reference: ' + spec.by);
}
childPath = childPath.slice(this._path.length).replace(/^\/?/, '');
if (!___default.default.includes(childPath, '/')) {
throw new Error(
'Query "by" value must not be a direct child of target reference: ' + spec.by);
}
spec.by = childPath.replace(/.*?\//, '');
}
Object.freeze(spec);
return spec;
}
toString() {return this._string;}
toJSON() {return `query → ${this.toString()}`;}
}
class Reference extends Handle {
constructor(tree, path, annotations) {
super(tree, path, annotations);
Object.freeze(this);
}
get ready() {return this._tree.isReferenceReady(this);} // Vue-bound
get value() {return this._tree.getObject(this.path);} // Vue-bound
toString() {return this._path;}
toJSON() {return `reference → ${this.toString()}`;}
annotate(annotations) {
return new Reference(this._tree, this._path, ___default.default.assign({}, this._annotations, annotations));
}
query(spec) {
return new Query(this._tree, this._path, spec, this._annotations);
}
set(value) {
this._checkForUndefinedPath();
return this._tree.update(this, 'set', {[this.path]: value});
}
update(values) {
this._checkForUndefinedPath();
return this._tree.update(this, 'update', values);
}
override(value) {
this._checkForUndefinedPath();
return this._tree.update(this, 'override', {[this.path]: value});
}
commit(updateFunction) {
this._checkForUndefinedPath();
return this._tree.commit(this, updateFunction);
}
_checkForUndefinedPath() {
if (this.path === '/undefined') throw new Error('Invalid path for operation: ' + this.path);
}
}
const SERVER_TIMESTAMP = Object.freeze({'.sv': 'timestamp'});
function isTrussEqual(a, b) {
return ___default.default.isEqualWith(a, b, isTrussValueEqual);
}
function isTrussValueEqual(a, b) {
if (a === b || a === undefined || a === null || b === undefined || b === null ||
a.$truss || b.$truss) return a === b;
if (a.isEqual) return a.isEqual(b);
}
function copyPrototype(a, b) {
for (const prop of Object.getOwnPropertyNames(a.prototype)) {
if (prop === 'constructor') continue;
Object.defineProperty(b.prototype, prop, Object.getOwnPropertyDescriptor(a.prototype, prop));
}
}
class StatItem {
constructor(name) {
___default.default.assign(this, {name, numRecomputes: 0, numUpdates: 0, computeTime: 0, updateTime: 0});
}
add(item) {
this.computeTime += item.computeTime;
this.updateTime += item.updateTime;
this.numUpdates += item.numUpdates;
this.numRecomputes += item.numRecomputes;
}
get runtime() {
return this.computeTime + this.updateTime;
}
get runtimePerRecompute() {
return this.numRecomputes ? this.computeTime / this.numRecomputes : 0;
}
get runtimePerUpdate() {
return this.numUpdates ? this.updateTime / this.numUpdates : 0;
}
toLogParts(totals) {
return [
`${this.name}:`, ` ${(this.runtime / 1000).toFixed(2)}s`,
`(${(this.runtime / totals.runtime * 100).toFixed(1)}%)`,
` ${this.numUpdates} upd /`, `${this.numRecomputes} runs`,
`(${(this.numUpdates / this.numRecomputes * 100).toFixed(1)}%)`,
` ${this.runtimePerRecompute.toFixed(2)}ms / run`,
` ${this.runtimePerUpdate.toFixed(2)}ms / upd`
];
}
}
class Stats {
constructor() {
this._items = {};
}
for(name) {
if (!this._items[name]) this._items[name] = new StatItem(name);
return this._items[name];
}
get list() {
return ___default.default(this._items).values().sortBy(item => -item.runtime).value();
}
log(n = 10) {
let stats = this.list;
if (!stats.length) return;
const totals = new StatItem('=== Total');
___default.default.forEach(stats, stat => {totals.add(stat);});
stats = ___default.default.take(stats, n);
const above = new StatItem('--- Above');
___default.default.forEach(stats, stat => {above.add(stat);});
const lines = ___default.default.map(stats, item => item.toLogParts(totals));
lines.push(above.toLogParts(totals));
lines.push(totals.toLogParts(totals));
const widths = ___default.default.map(___default.default.range(lines[0].length), i => ___default.default(lines).map(line => line[i].length).max());
___default.default.forEach(lines, line => {
console.log(___default.default.map(line, (column, i) => ___default.default.padStart(column, widths[i])).join(' '));
});
}
wrap(getter, className, propName) {
const item = this.for(`${className}.${propName}`);
return function() {
/* eslint-disable no-invalid-this */
const startTime = performance.now();
const oldValue = this._computedWatchers && this._computedWatchers[propName].value;
try {
const newValue = getter.call(this);
if (!isTrussEqual(oldValue, newValue)) item.numUpdates += 1;
return newValue;
} finally {
item.computeTime += performance.now() - startTime;
item.numRecomputes += 1;
}
};
}
}
var stats = new Stats();
class Connector {
constructor(scope, connections, tree, method, refs) {
Object.freeze(connections);
this._scope = scope;
this._connections = connections;
this._tree = tree;
this._method = method;
this._subConnectors = {};
this._disconnects = {};
this._angularUnwatches = undefined;
this._data = {};
this._vue = new Vue__default.default({data: {
descriptors: {},
refs: refs || {},
values: ___default.default.mapValues(connections, ___default.default.constant(undefined))
}});
// allow instance-level overrides of destroy() method
this.destroy = this.destroy; // eslint-disable-line no-self-assign
Object.seal(this);
this._linkScopeProperties();
___default.default.forEach(connections, (descriptor, key) => {
if (___default.default.isFunction(descriptor)) {
this._bindComputedConnection(key, descriptor);
} else {
this._connect(key, descriptor);
}
});
if (angularProxy.active && scope && scope.$on && scope.$id) {
scope.$on('$destroy', () => {this.destroy();});
}
}
get ready() {
return ___default.default.every(this._connections, (ignored, key) => {
const descriptor = this._vue.descriptors[key];
if (!descriptor) return false;
if (descriptor instanceof Handle) return descriptor.ready;
return this._subConnectors[key].ready;
});
}
get at() {
return this._vue.refs;
}
get data() {
return this._data;
}
destroy() {
this._unlinkScopeProperties();
___default.default.forEach(this._angularUnwatches, unwatch => {unwatch();});
___default.default.forEach(this._connections, (descriptor, key) => {this._disconnect(key);});
this._vue.$destroy();
}
_linkScopeProperties() {
const dataProperties = ___default.default.mapValues(this._connections, (unused, key) => ({
configurable: true, enumerable: false, get: () => {
const descriptor = this._vue.descriptors[key];
if (descriptor instanceof Reference) return descriptor.value;
return this._vue.values[key];
}
}));
Object.defineProperties(this._data, dataProperties);
if (this._scope) {
for (const key in this._connections) {
if (key in this._scope) {
throw new Error(`Property already defined on connection target: ${key}`);
}
}
Object.defineProperties(this._scope, dataProperties);
if (this._scope.__ob__) this._scope.__ob__.dep.notify();
}
}
_unlinkScopeProperties() {
if (!this._scope) return;
___default.default.forEach(this._connections, (descriptor, key) => {
delete this._scope[key];
});
}
_bindComputedConnection(key, fn) {
const connectionStats = stats.for(`connection.at.${key}`);
const getter = this._computeConnection.bind(this, fn, connectionStats);
const update = this._updateComputedConnection.bind(this, key, fn, connectionStats);
const angularWatch = angularProxy.active && !fn.angularWatchSuppressed;
// Use this._vue.$watch instead of truss.observe here so that we can disable the immediate
// callback if we'll get one from Angular anyway.
this._vue.$watch(getter, update, {immediate: !angularWatch});
if (angularWatch) {
if (!this._angularUnwatches) this._angularUnwatches = [];
this._angularUnwatches.push(angularProxy.watch(getter, update, true));
}
}
_computeConnection(fn, connectionStats) {
const startTime = performance.now();
try {
return flattenRefs(fn.call(this._scope));
} finally {
connectionStats.computeTime += performance.now() - startTime;
connectionStats.numRecomputes += 1;
}
}
_updateComputedConnection(key, value, connectionStats) {
const newDescriptor = ___default.default.isFunction(value) ? value(this._scope) : value;
const oldDescriptor = this._vue.descriptors[key];
const descriptorChanged = !isTrussEqual(oldDescriptor, newDescriptor);
if (!descriptorChanged) return;
if (connectionStats && descriptorChanged) connectionStats.numUpdates += 1;
if (!newDescriptor) {
this._disconnect(key);
return;
}
if (newDescriptor instanceof Handle || !___default.default.has(this._subConnectors, key)) {
this._disconnect(key);
this._connect(key, newDescriptor);
} else {
this._subConnectors[key]._updateConnections(newDescriptor);
}
Vue__default.default.set(this._vue.descriptors, key, newDescriptor);
angularProxy.digest();
}
_updateConnections(connections) {
___default.default.forEach(connections, (descriptor, key) => {
this._updateComputedConnection(key, descriptor);
});
___default.default.forEach(this._connections, (descriptor, key) => {
if (!___default.default.has(connections, key)) this._updateComputedConnection(key);
});
this._connections = connections;
}
_connect(key, descriptor) {
Vue__default.default.set(this._vue.descriptors, key, descriptor);
angularProxy.digest();
if (!descriptor) return;
Vue__default.default.set(this._vue.values, key, undefined);
if (descriptor instanceof Reference) {
Vue__default.default.set(this._vue.refs, key, descriptor);
this._disconnects[key] = this._tree.connectReference(descriptor, this._method);
} else if (descriptor instanceof Query) {
Vue__default.default.set(this._vue.refs, key, descriptor);
const updateFn = this._updateQueryValue.bind(this, key);
this._disconnects[key] = this._tree.connectQuery(descriptor, updateFn, this._method);
} else {
const subScope = {}, subRefs = {};
Vue__default.default.set(this._vue.refs, key, subRefs);
const subConnector = this._subConnectors[key] =
new Connector(subScope, descriptor, this._tree, this._method, subRefs);
// Use a truss.observe here instead of this._vue.$watch so that the "immediate" execution
// actually takes place after we've captured the unwatch function, in case the subConnector
// is ready immediately.
const unobserve = this._disconnects[key] = this._tree.truss.observe(
() => subConnector.ready,
subReady => {
if (!subReady) return;
unobserve();
delete this._disconnects[key];
Vue__default.default.set(this._vue.values, key, subScope);
angularProxy.digest();
}
);
}
}
_disconnect(key) {
Vue__default.default.delete(this._vue.refs, key);
this._updateRefValue(key, undefined);
if (___default.default.has(this._subConnectors, key)) {
this._subConnectors[key].destroy();
delete this._subConnectors[key];
}
if (this._disconnects[key]) this._disconnects[key]();
delete this._disconnects[key];
Vue__default.default.delete(this._vue.descriptors, key);
angularProxy.digest();
}
_updateRefValue(key, value) {
if (this._vue.values[key] !== value) {
Vue__default.default.set(this._vue.values, key, value);
angularProxy.digest();
}
}
_updateQueryValue(key, childKeys) {
if (!this._vue.values[key]) {
Vue__default.default.set(this._vue.values, key, {});
angularProxy.digest();
}
const subScope = this._vue.values[key];
for (const childKey in subScope) {
if (!Object.hasOwn(subScope, childKey)) continue;
if (!___default.default.includes(childKeys, childKey)) {
Vue__default.default.delete(subScope, childKey);
angularProxy.digest();
}
}
const object = this._tree.getObject(this._vue.descriptors[key].path);
for (const childKey of childKeys) {
if (Object.hasOwn(subScope, childKey)) continue;
Vue__default.default.set(subScope, childKey, object[childKey]);
angularProxy.digest();
}
}
}
function flattenRefs(refs) {
if (!refs) return;
if (refs instanceof Handle) return refs.toString();
return ___default.default.mapValues(refs, flattenRefs);
}
function wrapPromiseCallback(callback) {
return function() {
try {
// eslint-disable-next-line no-invalid-this
return Promise.resolve(callback.apply(this, arguments));
} catch (e) {
return Promise.reject(e);
}
};
}
function promiseCancel(promise, cancel) {
promise = promiseFinally(promise, () => {cancel = null;});
promise.cancel = () => {
if (!cancel) return;
cancel();
cancel = null;
};
propagatePromiseProperty(promise, 'cancel');
return promise;
}
function propagatePromiseProperty(promise, propertyName) {
const originalThen = promise.then, originalCatch = promise.catch;
promise.then = (onResolved, onRejected) => {
const derivedPromise = originalThen.call(promise, onResolved, onRejected);
derivedPromise[propertyName] = promise[propertyName];
propagatePromiseProperty(derivedPromise, propertyName);
return derivedPromise;
};
promise.catch = onRejected => {
const derivedPromise = originalCatch.call(promise, onRejected);
derivedPromise[propertyName] = promise[propertyName];
propagatePromiseProperty(derivedPromise, propertyName);
return derivedPromise;
};
return promise;
}
function promiseFinally(promise, onFinally) {
if (!onFinally) return promise;
onFinally = wrapPromiseCallback(onFinally);
return promise.then(result => {
return onFinally().then(() => result);
}, error => {
return onFinally().then(() => Promise.reject(error));
});
}
const INTERCEPT_KEYS = [
'read', 'write', 'auth', 'set', 'update', 'commit', 'connect', 'peek', 'authenticate',
'unathenticate', 'certify', 'all'
];
const EMPTY_ARRAY = [];
class SlowHandle {
constructor(operation, delay, callback) {
this._operation = operation;
this._delay = delay;
this._callback = callback;
this._fired = false;
}
initiate() {
this.cancel();
this._fired = false;
const elapsed = Date.now() - this._operation._startTimestamp;
this._timeoutId = setTimeout(() => {
this._fired = true;
this._callback(this._operation);
}, this._delay - elapsed);
}
cancel() {
if (this._fired) this._callback(this._operation);
if (this._timeoutId) clearTimeout(this._timeoutId);
}
}
class Operation {
constructor(type, method, target, operand) {
this._type = type;
this._method = method;
this._target = target;
this._operand = operand;
this._ready = false;
this._running = false;
this._ended = false;
this._tries = 0;
this._startTimestamp = Date.now();
this._slowHandles = [];
}
get type() {return this._type;}
get method() {return this._method;}
get target() {return this._target;}
get targets() {
if (this._method !== 'update') return [this._target];
return ___default.default.map(this._operand, (value, escapedPathFragment) => {
return new Reference(
this._target._tree, joinPath(this._target.path, escapedPathFragment),
this._target._annotations);
});
}
get operand() {return this._operand;}
get ready() {return this._ready;}
get running() {return this._running;}
get ended() {return this._ended;}
get tries() {return this._tries;}
get error() {return this._error;}
onSlow(delay, callback) {
const handle = new SlowHandle(this, delay, callback);
this._slowHandles.push(handle);
handle.initiate();
}
_setRunning(value) {
this._running = value;
}
_setEnded(value) {
this._ended = value;
}
_markReady(ending) {
this._ready = true;
if (!ending) this._tries = 0;
___default.default.forEach(this._slowHandles, handle => handle.cancel());
}
_clearReady() {
// Temporarily set ready to correctly reset previously triggered slow handles.
this._ready = true;
this._startTimestamp = Date.now();
___default.default.forEach(this._slowHandles, handle => handle.initiate());
this._ready = false;
}
_incrementTries() {
this._tries++;
}
}
class Dispatcher {
constructor(bridge) {
this._bridge = bridge;
this._callbacks = {};
Object.freeze(this);
}
intercept(interceptKey, callbacks) {
if (!___default.default.includes(INTERCEPT_KEYS, interceptKey)) {
throw new Error('Unknown intercept operation type: ' + interceptKey);
}
const badCallbackKeys =
___default.default.difference(___default.default.keys(callbacks), ['onBefore', 'onAfter', 'onError', 'onFailure']);
if (badCallbackKeys.length) {
throw new Error('Unknown intercept callback types: ' + badCallbackKeys.join(', '));
}
const wrappedCallbacks = {
onBefore: this._addCallback('onBefore', interceptKey, callbacks.onBefore),
onAfter: this._addCallback('onAfter', interceptKey, callbacks.onAfter),
onError: this._addCallback('onError', interceptKey, callbacks.onError),
onFailure: this._addCallback('onFailure', interceptKey, callbacks.onFailure)
};
return this._removeCallbacks.bind(this, interceptKey, wrappedCallbacks);
}
_addCallback(stage, interceptKey, callback) {
if (!callback) return;
const key = this._getCallbacksKey(stage, interceptKey);
const wrappedCallback = wrapPromiseCallback(callback);
(this._callbacks[key] || (this._callbacks[key] = [])).push(wrappedCallback);
return wrappedCallback;
}
_removeCallback(stage, interceptKey, wrappedCallback) {
if (!wrappedCallback) return;
const key = this._getCallbacksKey(stage, interceptKey);
if (this._callbacks[key]) ___default.default.pull(this._callbacks[key], wrappedCallback);
}
_removeCallbacks(interceptKey, wrappedCallbacks) {
___default.default.forEach(wrappedCallbacks, (wrappedCallback, stage) => {
this._removeCallback(stage, interceptKey, wrappedCallback);
});
}
_getCallbacks(stage, operationType, method) {
return [].concat(
this._callbacks[this._getCallbacksKey(stage, method)] || EMPTY_ARRAY,
this._callbacks[this._getCallbacksKey(stage, operationType)] || EMPTY_ARRAY,
this._callbacks[this._getCallbacksKey(stage, 'all')] || EMPTY_ARRAY
);
}
_getCallbacksKey(stage, interceptKey) {
return `${stage}_${interceptKey}`;
}
execute(operationType, method, target, operand, executor) {
executor = wrapPromiseCallback(executor);
const operation = this.createOperation(operationType, method, target, operand);
return this.begin(operation).then(() => {
const executeWithRetries = () => {
return executor().catch(e => this._retryOrEnd(operation, e).then(executeWithRetries));
};
return executeWithRetries();
}).then(result => this.end(operation).then(() => result));
}
createOperation(operationType, method, target, operand) {
return new Operation(operationType, method, target, operand);
}
begin(operation) {
return Promise.all(___default.default.map(
this._getCallbacks('onBefore', operation.type, operation.method),
onBefore => onBefore(operation)
)).then(() => {
if (!operation.ended) operation._setRunning(true);
}, e => this.end(operation, e));
}
markReady(operation) {
operation._markReady();
}
clearReady(operation) {
operation._clearReady();
}
retry(operation, error) {
operation._incrementTries();
operation._error = error;
return Promise.all(___default.default.map(
this._getCallbacks('onError', operation.type, operation.method),
onError => onError(operation, error)
)).then(results => {
// If the operation ended in the meantime, bail. This will cause the caller to attempt to
// fail the operation, but since it's already ended the call to end() with an error will be a
// no-op.
if (operation.ended) return;
const retrying = ___default.default.some(results);
if (retrying) delete operation._error;
return retrying;
});
}
_retryOrEnd(operation, error) {
return this.retry(operation, error).then(result => {
if (!result) return this.end(operation, error);
}, e => this.end(operation, e));
}
end(operation, error) {
if (operation.ended) return Promise.resolve();
operation._setRunning(false);
operation._setEnded(true);
if (error) {