From f4458eff5d7fc3951591e8c6b57ea994ddeb13da Mon Sep 17 00:00:00 2001 From: webreflection Date: Wed, 25 Jun 2025 12:08:17 +0200 Subject: [PATCH] Added the possibility to memoize proxies in an optimistic way --- .gitignore | 1 + src/local.js | 23 +++++++++- src/remote.js | 109 +++++++++++++++++++++++++++++++++++++++++----- test/index.js | 27 +++++++++--- types/local.d.ts | 6 ++- types/remote.d.ts | 6 ++- 6 files changed, 152 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index db60700..076850d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ coverage/ dist/ node_modules/ coincident-* +test/index-timeout.js diff --git a/src/local.js b/src/local.js index 037a49b..115a3aa 100644 --- a/src/local.js +++ b/src/local.js @@ -87,6 +87,7 @@ const { * @property {Function} [remote=identity] The function used to intercept remote invokes *before* these happen. Usable to sync `events` or do other tasks. * @property {Function} [module] The function used to import modules when remote asks to `import(...)` something. * @property {boolean} [buffer=false] Optionally allows direct buffer serialization 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. */ /** @@ -99,6 +100,7 @@ export default ({ remote = identity, module = name => import(name), buffer = false, + timeout = -1, } = object) => { // received values arrive via postMessage so are compatible // with the structured clone algorithm @@ -191,6 +193,7 @@ export default ({ const { clear, id, ref, unref } = heap(); + const memoize = -1 < timeout; const weakRefs = new Map; const globalTarget = tv(OBJECT, null); const fr = new FinalizationRegistry(v => { @@ -235,7 +238,25 @@ export default ({ switch (method) { case GET: { const key = fromKey(args[0]); - return toValue(isGlobal && key === 'import' ? module : get(target, key)); + const asModule = isGlobal && key === 'import'; + const value = toValue(asModule ? module : get(target, key)); + if (memoize && isArray(value)) { + if (!asModule && (value[0] & REMOTE)) { + let cache = key in target, t = target, d; + if (cache) { + while (!(d = getOwnPropertyDescriptor(t, key))) { + t = getPrototypeOf(t); + /* c8 ignore start */ + if (!t) break; + /* c8 ignore stop */ + } + cache = !!d && 'value' in d; + value[1] = [cache, value[1]]; + } + } + else value[1] = [false, value[1]]; + } + return value; } case APPLY: { const map = new Map; diff --git a/src/remote.js b/src/remote.js index 13f76c0..3f1c871 100644 --- a/src/remote.js +++ b/src/remote.js @@ -75,6 +75,7 @@ const { preventExtensions } = Object; * @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. */ /** @@ -86,13 +87,13 @@ export default ({ 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 fromArray = value => { const [t, v] = value; if (t & REMOTE) return asProxy(value, t, v); switch (t) { @@ -106,6 +107,8 @@ export default ({ } }; + const fromValue = value => isArray(value) ? fromArray(value) : value; + const toValue = (value, cache = new Map) => { switch (typeof value) { case 'object': { @@ -185,12 +188,75 @@ export default ({ } }; + const memoize = -1 < timeout; + + class Memo extends Map { + static entries = []; + + static keys = Symbol(); + static proto = Symbol(); + + static drop(entries) { + const cached = entries.splice(0); + let i = 0; + while (i < cached.length) + cached[i++].delete(cached[i++]); + } + + static set(self, key) { + const { entries } = this; + if (entries.push(self, key) < 3) + setTimeout(this.drop, timeout, entries); + } + + drop(key, value) { + this.delete(key); + if (key !== Memo.proto) this.delete(Memo.keys); + return value; + } + + set(key, value) { + super.set(key, value); + Memo.set(this, key); + return value; + } + } + class Handler { - constructor(_) { this._ = _ } + constructor(_) { + this._ = _; + if (memoize) this.$ = new Memo; + } - get(_, key) { return fromValue(reflect(GET, this._, toKey(key))) } - set(_, key, value) { return reflect(SET, this._, toKey(key), toValue(value)) } - ownKeys(_) { return fromKeys(reflect(OWN_KEYS, this._), weakRefs) } + get(_, key) { + if (memoize && this.$.has(key)) return this.$.get(key); + const value = reflect(GET, this._, toKey(key)); + if (!memoize) return fromValue(value); + if (isArray(value)) { + let [cache, ref] = value[1]; + value[1] = ref; + ref = fromArray(value); + return cache ? this.$.set(key, ref) : ref; + } + return this.$.set(key, value); + } + set(_, key, value) { + const result = reflect(SET, this._, toKey(key), toValue(value)); + return memoize ? this.$.drop(key, result) : result; + } + + _oK() { return fromKeys(reflect(OWN_KEYS, this._), 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._, toKey(key))); if (descriptor) { @@ -199,14 +265,37 @@ export default ({ } return descriptor; } - defineProperty(_, key, descriptor) { return reflect(DEFINE_PROPERTY, this._, toKey(key), toValue(descriptor)) } - deleteProperty(_, key) { return reflect(DELETE_PROPERTY, this._, toKey(key)) } - getPrototypeOf(_) { return fromValue(reflect(GET_PROTOTYPE_OF, this._)) } - setPrototypeOf(_, value) { return reflect(SET_PROTOTYPE_OF, this._, toValue(value)) } + + defineProperty(_, key, descriptor) { + const result = reflect(DEFINE_PROPERTY, this._, toKey(key), toValue(descriptor)); + return memoize ? this.$.drop(key, result) : result; + } + + deleteProperty(_, key) { + const result = reflect(DELETE_PROPERTY, this._, toKey(key)); + return memoize ? this.$.drop(key, result) : result; + } + + _gPO() { return fromValue(reflect(GET_PROTOTYPE_OF, this._)) } + getPrototypeOf(_) { + return memoize ? + (this.$.has(Memo.proto) ? + this.$.get(Memo.proto) : + this.$.set(Memo.proto, this._gPO())) : + this._gPO() + ; + } + + setPrototypeOf(_, value) { + const result = reflect(SET_PROTOTYPE_OF, this._, toValue(value)); + return memoize ? this.$.drop(Memo.proto, result) : result; + } + // way less common than others to be cached isExtensible(_) { return reflect(IS_EXTENSIBLE, this._) } preventExtensions(target) { return preventExtensions(target) && reflect(PREVENT_EXTENSIONS, this._) } } + // TODO: should `in` operations be cached too? const has = (_, $, prop) => prop === reflected ? !!(reference = _) : reflect(HAS, $, toKey(prop)) diff --git a/test/index.js b/test/index.js index b7e1488..b6cf4fb 100644 --- a/test/index.js +++ b/test/index.js @@ -10,15 +10,20 @@ import './array-buffer.js'; const array = [1, 2, 3]; -const there = remote({ - reflect: (...args) => here.reflect(...args), - transform: value => value === array ? there.direct(array) : value, +const bootstrap = timeout => ({ + there: remote({ + timeout, + reflect: (...args) => here.reflect(...args), + transform: value => value === array ? there.direct(array) : value, + }), + here: local({ + timeout, + reflect: (...args) => there.reflect(...args), + transform: value => value === array ? here.direct(array) : value, + }) }); -const here = local({ - reflect: (...args) => there.reflect(...args), - transform: value => value === array ? here.direct(array) : value, -}); +const { there, here } = bootstrap(globalThis.timeout || -1); const { global } = there; @@ -131,6 +136,14 @@ finally { 'use strict'; global.console.log.apply(global.console, ['test', 'completed']); global.console.log.apply(global.console, arguments); + if (!globalThis.timeout) { + import('node:fs').then(async ({ copyFile }) => { + globalThis.timeout = 1; + copyFile('./test/index.js', './test/index-timeout.js', () => { + import('./index-timeout.js'); + }); + }); + } }, 250, { ok: true }); } diff --git a/types/local.d.ts b/types/local.d.ts index 751c9fd..a55bc53 100644 --- a/types/local.d.ts +++ b/types/local.d.ts @@ -1,4 +1,4 @@ -declare function _default({ reflect, transform, remote, module, buffer, }?: LocalOptions): { +declare function _default({ reflect, transform, remote, module, buffer, timeout, }?: LocalOptions): { /** * Alows local references to be passed directly to the remote receiver, * either as copy or serliazied values (it depends on the implementation). @@ -48,4 +48,8 @@ export type LocalOptions = { * Optionally allows direct buffer serialization breaking JSON compatibility. */ buffer?: boolean; + /** + * Optionally allows remote values to be cached when possible for a `timeout` milliseconds value. `-1` means no timeout. + */ + timeout?: number; }; diff --git a/types/remote.d.ts b/types/remote.d.ts index a0604f0..f5d09b3 100644 --- a/types/remote.d.ts +++ b/types/remote.d.ts @@ -1,4 +1,4 @@ -declare function _default({ reflect, transform, released, buffer, }?: RemoteOptions): { +declare function _default({ reflect, transform, released, buffer, timeout, }?: RemoteOptions): { /** * The local global proxy reference. * @type {unknown} @@ -71,4 +71,8 @@ export type RemoteOptions = { * Optionally allows direct buffer deserialization breaking JSON compatibility. */ buffer?: boolean; + /** + * Optionally allows remote values to be cached when possible for a `timeout` milliseconds value. `-1` means no timeout. + */ + timeout?: number; };