packages/remote-context/src/RemoteValue.js
/**
* Copyright 2017 Moshe Simantov
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Many methods in this file inspire from the ES5 shim for ES6 Reflect and Proxy objects:
// {@link https://github.com/tvcutsem/harmony-reflect} (Apache-2.0 License)
import EventEmitter from 'events';
import RemoteSession from './RemoteSession';
import RemoteContext from './RemoteContext';
import {
LocalDefinePropertyAction,
LocalDeletePropertyAction,
LocalSetPrototypeOfAction,
LocalPreventExtensionsAction,
} from './actions';
import {
getCachedGetter,
getPropertyDescriptor,
isDataDescriptor,
isAccessorDescriptor,
} from './helpers/descriptors';
const kProxyData = Symbol('proxyData');
const kError = Symbol('error');
const proxyMap = new WeakMap();
/**
* A remote value proxy returned from the {@link RemoteValue} constructor.
* This proxy make possible to make changes to remote values and get the changes send to
* the remote peer.
*
* @typedef {object|function} RemoteValueProxy
*/
/**
* Remote value representation locally.
* @example
* import { RemoteValue } from 'remote-context';
*
* const proxy = new RemoteValue(remoteSession, reference, value);
*/
export default class RemoteValue extends EventEmitter {
/**
* Check if the give value is a proxy of remote value.
*
* @param {RemoteValueProxy|*} proxy The given value
* @return {boolean}
*/
static isRemoteValue(proxy) {
return proxyMap.has(proxy);
}
/**
* Get the remote value instance of the given proxy.
*
* @param {RemoteValueProxy} proxy The given proxy
* @throws {ReferenceError} If the given value is not a {@link RemoteValueProxy}.
* @return {RemoteValue}
*/
static reveal(proxy) {
const remoteValue = proxyMap.get(proxy);
if (!remoteValue) {
throw new ReferenceError(
`The given value is not a RemoteValue proxy: ${proxy}`
);
}
return remoteValue;
}
/**
* Resolve the given value.
* Reject the value if it's a {@link RemoteValueProxy} and an uncaught error accord while
* using the value (ie. assign property to the value failed). Otherwise, resolve it.
*
* @param {RemoteValueProxy|*} value The given value
* @return {Promise}
*/
static resolve(value) {
return new Promise((resolve, reject) => {
const remoteValue = proxyMap.get(value);
if (!remoteValue || !remoteValue[kError]) {
resolve(value);
return;
}
const error = remoteValue[kError];
remoteValue[kError] = null;
reject(error);
});
}
/**
* If the given value is a {@link RemoteValueProxy}, revoke the value proxy.
* Otherwise, do nothing.
*
* @param {*} proxy The given value
* @return {void}
*/
static revoke(proxy) {
const remoteValue = proxyMap.get(proxy);
if (!remoteValue) return;
remoteValue.revoke();
}
/**
* Observe for any change on the given remote value proxy.
*
* @see {@link https://github.com/tc39/proposal-observable}
* @param {RemoteValueProxy} proxy The given remove value proxy
* @param {function|null} onNext Called on every change to the given value with the
* {@link RemoteValueProxy} as the first argument
* @param {function|null} onError Called on every error occurred on the given value with an
* error as first argument
* @param {function|null} onComplete Called when the given value revoked with no arguments
* @return {{unsubscribe: (function()), closed: boolean}} An subscription object with
* `unsubscribe` method to stop observer events from calling and a `closed` property.
*/
static observe(proxy, onNext = null, onError = null, onComplete = null) {
const remoteValue = RemoteValue.reveal(proxy);
let subscription;
const events = {
change: onNext && (value => onNext(value)),
error: onError,
revoke: () => {
subscription.closed = true;
if (onComplete !== null) onComplete();
},
};
subscription = {
unsubscribe() {
Object.keys(events).forEach(event => {
const listener = events[event];
if (!listener) return;
remoteValue.removeListener(event, listener);
});
},
closed: !remoteValue.target,
};
if (!subscription.closed) {
Object.keys(events).forEach(event => {
const listener = events[event];
if (listener === null) return;
remoteValue.on(event, listener);
});
}
return subscription;
}
/**
* Create a {@link RemoteValue} class and a proxy to the given value.
*
* @param {RemoteSession} session The current value remote session
* @param {Reference} reference he value reference for further updates to the remote peer
* @param {*} value The given value
* @return {RemoteValueProxy} A proxy to the given value
*/
constructor(session, reference, value) {
if (!(session instanceof RemoteSession)) {
throw new TypeError(
`Expect session to be instance of RemoteSession: ${session}`
);
}
if (!RemoteContext.isValidReference(reference)) {
throw new TypeError(`The given reference is not valid: ${reference}`);
}
if (!(value instanceof Object)) {
return value;
}
super();
const self = this;
this[kError] = null;
this[kProxyData] = Proxy.revocable(value, {
get(target, property) {
const desc = getPropertyDescriptor(target, property);
if (desc === undefined) return undefined;
let val;
if (isDataDescriptor(desc)) {
val = desc.value;
} else {
const getter = desc.get;
if (getter === undefined) {
return undefined;
}
return getCachedGetter(target, property);
}
if (typeof val !== 'function' || !session.exists(val)) {
return val;
}
const remoteContext = session.remote;
if (!remoteContext.exists(val)) {
return val;
}
return remoteContext.fetch(remoteContext.lookup(val));
},
set(target, property, val, receiver) {
let desc = getPropertyDescriptor(target, property);
if (desc === undefined) {
desc = {
value: undefined,
writable: true,
enumerable: true,
configurable: true,
};
}
if (isAccessorDescriptor(desc)) {
const setter = desc.set;
if (setter === undefined) return false;
const ret = setter.call(receiver, val); // assumes Function.prototype.call
if (RemoteValue.isRemoteValue(setter)) {
ret.then(() => {});
}
return true;
}
if (desc.writable === false) return false;
const existingDesc = Object.getOwnPropertyDescriptor(
receiver,
property
);
if (existingDesc) {
Object.defineProperty(receiver, property, {
value: val,
writable: existingDesc.writable,
enumerable: existingDesc.enumerable,
configurable: existingDesc.configurable,
});
return true;
}
if (!Object.isExtensible(receiver)) return false;
Object.defineProperty(receiver, property, {
value: val,
writable: true,
enumerable: true,
configurable: true,
});
return true;
},
setPrototypeOf(target, prototype) {
session.request(
LocalSetPrototypeOfAction.fromRemote(session, reference, prototype),
() => {},
err => self.reject(err)
);
return true;
},
preventExtensions() {
session.request(
LocalPreventExtensionsAction.fromRemote(session, reference),
() => {},
err => self.reject(err)
);
return true;
},
defineProperty(target, property, descriptor) {
session.request(
LocalDefinePropertyAction.fromRemote(
session,
reference,
property,
descriptor
),
() => {},
err => self.reject(err)
);
return true;
},
deleteProperty(target, property) {
session.request(
LocalDeletePropertyAction.fromRemote(session, reference, property),
() => {},
err => self.reject(err)
);
return true;
},
});
this[kProxyData].target = value;
const { proxy } = this[kProxyData];
proxyMap.set(proxy, this);
return proxy;
}
get proxy() {
return this[kProxyData].proxy;
}
get target() {
return this[kProxyData].target;
}
reject(error) {
if (!this[kError]) {
this[kError] = error;
}
try {
this.emit('error', error);
} catch (err) {
// Ignore throwing error if no listeners found
}
}
revoke() {
delete this[kProxyData].target;
this[kProxyData].revoke();
this.emit('revoke');
}
// Update methods
setPrototypeOf(prototype) {
const { target, proxy } = this[kProxyData];
if (!target) return false;
Object.setPrototypeOf(target, prototype);
this.emit('change', proxy, 'setPrototypeOf');
return true;
}
preventExtensions() {
const { target, proxy } = this[kProxyData];
if (!target) return false;
Object.preventExtensions(target);
this.emit('change', proxy, 'preventExtensions');
return true;
}
defineProperty(property, descriptor) {
const { target, proxy } = this[kProxyData];
if (!target) return false;
Object.defineProperty(target, property, descriptor);
this.emit('change', proxy, 'defineProperty', property);
return true;
}
deleteProperty(property) {
const { target, proxy } = this[kProxyData];
if (!target) return false;
Object.deleteProperty(target, property);
this.emit('change', proxy, 'deleteProperty', property);
return true;
}
}
/**
* @type {RemoteValue.isRemoteValue}
*/
export const isRemoteValue = RemoteValue.isRemoteValue;
/**
* @type {RemoteValue.reveal}
*/
export const reveal = RemoteValue.reveal;
/**
* @type {RemoteValue.revoke}
*/
export const revoke = RemoteValue.revoke;
/**
* @type {RemoteValue.observe}
*/
export const observe = RemoteValue.observe;