reflected-ffi
Version:
A remotely reflected Foreign Function Interface
408 lines (364 loc) • 11.7 kB
JavaScript
import {
UNREF,
ASSIGN,
EVALUATE,
GATHER,
QUERY,
APPLY,
CONSTRUCT,
DEFINE_PROPERTY,
DELETE_PROPERTY,
GET,
GET_OWN_PROPERTY_DESCRIPTOR,
GET_PROTOTYPE_OF,
HAS,
IS_EXTENSIBLE,
OWN_KEYS,
SET,
SET_PROTOTYPE_OF,
} from './utils/traps.js';
import {
DIRECT,
REMOTE,
OBJECT,
ARRAY,
FUNCTION,
SYMBOL,
BIGINT,
VIEW,
BUFFER,
REMOTE_OBJECT,
REMOTE_FUNCTION
} from './types.js';
import {
fromSymbol,
toSymbol,
} from './utils/symbol.js';
import {
fromBuffer,
fromView,
} from './utils/typed.js';
import {
toName,
} from './utils/global.js';
import {
assign,
isArray,
isView,
fromKey,
toKey,
identity,
loopValues,
array,
object,
callback,
tv,
} from './utils/index.js';
import toJSONCallback from './utils/to-json-callback.js';
import query from './utils/query.js';
import heap from './utils/heap.js';
import memo from './utils/memo.js';
/**
* @typedef {Object} RemoteOptions Optional utilities used to orchestrate local <-> remote communication.
* @property {Function} [reflect=identity] The function used to reflect operations via the remote receiver. All `Reflect` methods + `unref` are supported.
* @property {Function} [transform=identity] The function used to transform local values into simpler references that the remote side can understand.
* @property {Function} [released=identity] The function invoked when a reference is released.
* @property {boolean} [buffer=false] Optionally allows direct buffer deserialization breaking JSON compatibility.
* @property {number} [timeout=-1] Optionally allows remote values to be cached when possible for a `timeout` milliseconds value. `-1` means no timeout.
*/
/**
* @param {RemoteOptions} options
* @returns
*/
export default ({
reflect = identity,
transform = identity,
released = identity,
buffer = false,
timeout = -1,
} = object) => {
const fromKeys = loopValues(fromKey);
const toKeys = loopValues(toKey);
// OBJECT, DIRECT, VIEW, REMOTE_ARRAY, REMOTE_OBJECT, REMOTE_FUNCTION, SYMBOL, BIGINT
const fromValue = value => {
if (!isArray(value)) return value;
const [t, v] = value;
if (t & REMOTE) return asProxy(t, v);
switch (t) {
case OBJECT: return global;
case DIRECT: return v;
case SYMBOL: return fromSymbol(v);
case BIGINT: return BigInt(v);
case VIEW: return fromView(v, buffer);
case BUFFER: return fromBuffer(v, buffer);
// there is no other case
}
};
const toValue = (value, cache = new Map) => {
switch (typeof value) {
case 'object': {
if (value === null) break;
if (value === globalThis) return globalTarget;
if (reflected in value) return reference;
let cached = cache.get(value);
if (!cached) {
const $ = transform(value);
if (indirect || !direct.has($)) {
if (isArray($)) {
const a = [];
cached = tv(ARRAY, a);
cache.set(value, cached);
for (let i = 0, length = $.length; i < length; i++)
a[i] = toValue($[i], cache);
return cached;
}
if (!isView($) && !($ instanceof ArrayBuffer) && toName($) === 'Object') {
const o = {};
cached = tv(OBJECT, o);
cache.set(value, cached);
for (const k in $)
o[k] = toValue($[k], cache);
return cached;
}
}
cached = tv(DIRECT, $);
cache.set(value, cached);
}
return cached;
}
case 'function': {
if (reflected in value) return reference;
let cached = cache.get(value);
if (!cached) {
const $ = transform(value);
cached = tv(FUNCTION, id($));
cache.set(value, cached);
}
return cached;
}
case 'symbol': return tv(SYMBOL, toSymbol(value));
}
return value;
};
const toValues = loopValues(toValue);
const asProxy = (t, v) => {
let wr = weakRefs.get(v), proxy = wr?.deref();
if (!proxy) {
/* c8 ignore start */
if (wr) fr.unregister(wr);
/* c8 ignore stop */
if (t === REMOTE_FUNCTION)
proxy = new Proxy(callback, new FunctionHandler(t, v));
else
proxy = new Proxy(t === REMOTE_OBJECT ? object : array, new Handler(t, v));
wr = new WeakRef(proxy);
weakRefs.set(v, wr);
fr.register(proxy, v, wr);
}
return proxy;
};
/**
* Checks if the given value is a proxy created in the remote side.
* @param {any} value
* @returns {boolean}
*/
const isProxy = value => {
switch (typeof value) {
case 'object': if (value === null) break;
case 'function': return reflected in value;
}
return false;
};
const memoize = -1 < timeout;
const Memo = /** @type {typeof import('./ts/memo.js').Memo} */(
memoize ? memo(timeout) : Map
);
class Handler {
constructor(t, v) {
this.t = t;
this.v = v;
if (memoize) this.$ = new Memo;
}
get(_, key) {
if (memoize && this.$.has(key)) return this.$.get(key);
const value = reflect(GET, this.v, toKey(key));
return memoize ?
(value[0] ?
this.$.set(key, fromValue(value[1])) :
fromValue(value[1])) :
fromValue(value)
;
}
set(_, key, value) {
const result = reflect(SET, this.v, toKey(key), toValue(value));
return memoize ? this.$.drop(key, result) : result;
}
// TODO: should `in` operations be cached too?
has(_, prop) {
if (prop === reflected) {
reference = [this.t, this.v];
return true;
}
return reflect(HAS, this.v, toKey(prop));
}
_oK() { return fromKeys(reflect(OWN_KEYS, this.v), weakRefs) }
ownKeys(_) {
return memoize ?
(this.$.has(Memo.keys) ?
this.$.get(Memo.keys) :
this.$.set(Memo.keys, this._oK())) :
this._oK()
;
}
// this would require a cache a part per each key or make
// the Cache code more complex for probably little gain
getOwnPropertyDescriptor(_, key) {
const descriptor = fromValue(reflect(GET_OWN_PROPERTY_DESCRIPTOR, this.v, toKey(key)));
if (descriptor) {
for (const k in descriptor)
descriptor[k] = fromValue(descriptor[k]);
}
return descriptor;
}
defineProperty(_, key, descriptor) {
const result = reflect(DEFINE_PROPERTY, this.v, toKey(key), toValue(descriptor));
return memoize ? this.$.drop(key, result) : result;
}
deleteProperty(_, key) {
const result = reflect(DELETE_PROPERTY, this.v, toKey(key));
return memoize ? this.$.drop(key, result) : result;
}
_gPO() { return fromValue(reflect(GET_PROTOTYPE_OF, this.v)) }
getPrototypeOf(_) {
/* c8 ignore start */
return memoize ?
(this.$.has(Memo.proto) ?
this.$.get(Memo.proto) :
this.$.set(Memo.proto, this._gPO())) :
this._gPO()
;
/* c8 ignore stop */
}
setPrototypeOf(_, value) {
const result = reflect(SET_PROTOTYPE_OF, this.v, toValue(value));
return memoize ? this.$.drop(Memo.proto, result) : result;
}
// way less common than others to be cached
isExtensible(_) { return reflect(IS_EXTENSIBLE, this.v) }
// ⚠️ due shared proxies' targets this cannot be reflected
preventExtensions(_) { return false }
}
class FunctionHandler extends Handler {
construct(_, args) { return fromValue(reflect(CONSTRUCT, this.v, toValues(args))) }
apply(_, self, args) {
const map = new Map;
return fromValue(reflect(APPLY, this.v, toValue(self, map), toValues(args, map)));
}
get(_, key) {
switch (key) {
// skip obvious roundtrip cases
case 'apply': return (self, args) => this.apply(_, self, args);
case 'call': return (self, ...args) => this.apply(_, self, args);
default: return super.get(_, key);
}
}
}
let indirect = true, direct, reference;
const { apply } = Reflect;
const { id, ref, unref } = heap();
const weakRefs = new Map;
const reflected = Symbol('reflected-ffi');
const globalTarget = tv(OBJECT, null);
const global = new Proxy(object, new Handler(OBJECT, null));
const fr = new FinalizationRegistry(v => {
weakRefs.delete(v);
reflect(UNREF, v);
});
return {
/**
* The local global proxy reference.
* @type {unknown}
*/
global,
isProxy,
/** @type {typeof assign} */
assign(target, ...sources) {
const asProxy = isProxy(target);
const assignment = assign(asProxy ? {} : target, ...sources);
if (asProxy) reflect(ASSIGN, reference[1], toValue(assignment));
return target;
},
/**
* Alows local references to be passed directly to the remote receiver,
* either as copy or serliazied values (it depends on the implementation).
* @template {WeakKey} T
* @param {T} value
* @returns {T}
*/
direct(value) {
if (indirect) {
indirect = false;
direct = new WeakSet;
}
direct.add(value);
return value;
},
/**
* Evaluates elsewhere the given callback with the given arguments.
* This utility is similar to puppeteer's `page.evaluate` where the function
* content is evaluated in the local side and its result is returned.
* @param {Function} callback
* @param {...any} args
* @returns {any}
*/
evaluate: (callback, ...args) => fromValue(
reflect(EVALUATE, null, toJSONCallback(callback), toValues(args))
),
/**
* @param {object} target
* @param {...(string|symbol)} keys
* @returns {any[]}
*/
gather(target, ...keys) {
const asProxy = isProxy(target);
const asValue = asProxy ? fromValue : (key => target[key]);
if (asProxy) keys = reflect(GATHER, reference[1], toKeys(keys, weakRefs));
for (let i = 0; i < keys.length; i++) keys[i] = asValue(keys[i]);
return keys;
},
/**
* Queries the given target for the given path.
* @param {any} target
* @param {string} path
* @returns {any}
*/
query: (target, path) => (
isProxy(target) ?
fromValue(reflect(QUERY, reference[1], path)) :
query(target, path)
),
/**
* The callback needed to resolve any local call. Currently only `apply` and `unref` are supported.
* Its returned value will be understood by the remote implementation
* and it is compatible with the structured clone algorithm.
* @param {number} method
* @param {number?} uid
* @param {...any} args
* @returns
*/
reflect: async (method, uid, ...args) => {
switch (method) {
case APPLY: {
const [context, params] = args;
for (let i = 0, length = params.length; i < length; i++)
params[i] = fromValue(params[i]);
return toValue(await apply(ref(uid), fromValue(context), params));
}
case UNREF: {
released(ref(uid));
return unref(uid);
}
}
},
};
};