UNPKG

state-event-target

Version:
401 lines (393 loc) 12.7 kB
function attachPromiseMeta(p) { const promise = p; promise.status = "pending"; promise.then((v)=>{ promise.status = "fulfilled"; promise.value = v; }, (e)=>{ promise.status = "rejected"; promise.reason = e; }); return promise; } function defaultGetKey(param) { return param; } class KVDataEventTarget extends EventTarget { #config; #abortController; #promises; #pendingResolvers; constructor(initialEntries, config){ super(), this.#abortController = new AbortController(), this.#promises = new Map(), this.#pendingResolvers = new Map(), this.lastUpdated = performance.now(); this.#config = { getKey: defaultGetKey, ...config }; if (initialEntries) { for (const [param, value] of initialEntries){ const key = this.#config.getKey(param); const p = attachPromiseMeta(new Promise((resolve)=>{ this.#pendingResolvers.set(key, [ ...this.#pendingResolvers.get(key) ?? [], resolve ]); })); p.status = "fulfilled"; p.value = value; this.#promises.set(key, p); } } this.addEventListener("state:prime", (event)=>{ const detail = event.detail; const [key, param] = "param" in detail ? [ this.#config.getKey(detail.param), detail.param ] : [ detail.key, detail.key ]; const value = this.peek(param); if (value !== undefined) { this.dispatchEvent(new CustomEvent("state:update", { detail: { param, key, value } })); } this.prime(key); }, { signal: this.#abortController.signal }); } /** * Read attempts to return the current value synchronously if one exists, * but otherwise returns a Promise that will resolve when a value is set. */ read(param) { const p = this.getAsync(param); return p.status === "fulfilled" ? p.value : p; } /** * Prime ensures there is a Promise ready to receive a future value. * Useful for setting up the Promise before you need the value. */ prime(param) { void this.getAsync(param); } /** * Peek will always synchronously read the value. * It will return undefined if no value has been set yet. */ peek(param) { const key = this.#config.getKey(param); return this.#promises.get(key)?.value; } /** * GetAsync will always return a *stable* Promise that resolves when a value is set. */ getAsync(param) { const key = this.#config.getKey(param); if (!this.#promises.has(key)) { const p = attachPromiseMeta(new Promise((resolve)=>{ this.#pendingResolvers.set(key, [ ...this.#pendingResolvers.get(key) ?? [], resolve ]); })); this.#promises.set(key, p); this.dispatchEvent(new CustomEvent("state:missing", { detail: { param, key } })); } return this.#promises.get(key); } /** * Sets the value, resolving any pending Promises. */ set(param, value) { const key = this.#config.getKey(param); for (const pendingResolver of this.#pendingResolvers.get(key) ?? []){ pendingResolver(value); } this.#pendingResolvers.set(key, []); const existing = this.#promises.get(key); if (!existing || existing.value !== value) { const p = attachPromiseMeta(new Promise((resolve)=>{ this.#pendingResolvers.set(key, [ ...this.#pendingResolvers.get(key) ?? [], resolve ]); })); p.status = "fulfilled"; p.value = value; this.#promises.set(key, p); } this.dispatchEvent(new CustomEvent("state:update", { detail: { param, key, value } })); this.lastUpdated = performance.now(); } onMissing(cb) { this.addEventListener("state:missing", cb, { signal: this.#abortController.signal }); } clear() { this.#promises.clear(); this.#pendingResolvers.clear(); this.dispatchEvent(new CustomEvent("state:reset")); } destroy() { this.clear(); this.#abortController.abort(); } addEventListener(type, listener, options) { const opts = typeof options === "object" && options !== null ? options : { capture: options !== false }; super.addEventListener(type, listener, opts); } removeEventListener(type, listener, options) { super.removeEventListener(type, listener, options); } } class DataEventTarget extends EventTarget { #abortController; #promise; #pendingResolvers; constructor(initialValue){ super(), this.#abortController = new AbortController(), this.#pendingResolvers = [], this.lastUpdated = performance.now(); if (initialValue !== undefined) { this.set(initialValue); } this.addEventListener("state:prime", (event)=>{ const value = this.peek(); if (value !== undefined) { this.dispatchEvent(new CustomEvent("state:update", { detail: { value } })); } this.prime(); }, { signal: this.#abortController.signal }); } /** * Read attempts to return the current value synchronously if one exists, * but otherwise returns a Promise that will resolve when a value is set. */ read() { const p = this.getAsync(); return p.status === "fulfilled" ? p.value : p; } /** * Prime ensures there is a Promise ready to receive a future value. * Useful for setting up the Promise before you need the value. */ prime() { void this.getAsync(); } /** * Peek will always synchronously read the value. * It will return undefined if no value has been set yet. */ peek() { return this.#promise?.value; } /** * GetAsync will always return a *stable* Promise that resolves when a value is set. */ getAsync() { if (!this.#promise) { this.#promise = attachPromiseMeta(new Promise((resolve)=>{ this.#pendingResolvers.push(resolve); })); this.dispatchEvent(new CustomEvent("state:missing", { detail: {} })); } return this.#promise; } /** * Sets the value, resolving any pending Promises. */ set(value) { for (const pendingResolver of this.#pendingResolvers){ pendingResolver(value); } this.#pendingResolvers = []; const existing = this.#promise; if (!existing || existing.value !== value) { this.#promise = attachPromiseMeta(new Promise((resolve)=>{ this.#pendingResolvers.push(resolve); })); this.#promise.status = "fulfilled"; this.#promise.value = value; } this.dispatchEvent(new CustomEvent("state:update", { detail: { value } })); this.lastUpdated = performance.now(); } onMissing(cb) { this.addEventListener("state:missing", cb, { signal: this.#abortController.signal }); } clear() { this.#promise = undefined; this.#pendingResolvers = []; this.dispatchEvent(new CustomEvent("state:reset")); } destroy() { this.clear(); this.#abortController.abort(); } } class Broadcast { detach() { this.controller.abort(); } constructor(){ this.controller = new AbortController(); this.signal = this.controller.signal; } } class WindowBroadcast extends Broadcast { constructor(receiver, sender){ super(), this.receiver = receiver, this.sender = sender; } onMessage(handler) { this.receiver.addEventListener("message", handler, { signal: this.signal }); } postMessage(message, { targetOrigin = "*", transfer } = {}) { const senders = typeof this.sender === "function" ? this.sender(message) : Array.isArray(this.sender) ? this.sender : [ this.sender ]; for (const sender of senders){ sender.postMessage(message, targetOrigin, transfer); } } } const DEFAULT_EVENTS_TO_BROADCAST = [ "state:update", "state:reset" ]; function source(toBroadcastTarget, fromSourceTarget, namespace, config = {}) { const eventsToBroadcast = config.eventsToBroadcast ?? DEFAULT_EVENTS_TO_BROADCAST; function handleEvent(event) { const customEvent = event; if (!eventsToBroadcast.includes(customEvent.type)) { return; } const detail = customEvent.detail; if (detail) { if (config.id) { detail.id = config.id; } if (fromSourceTarget.lastUpdated) { detail.timestamp = fromSourceTarget.lastUpdated; } } toBroadcastTarget.postMessage({ type: customEvent.type, namespace, detail }); } toBroadcastTarget.onMessage((event)=>{ const { type, namespace: msgNamespace, detail } = event.data; if (msgNamespace !== namespace) { return; } if (type !== "state:request") { return; } fromSourceTarget.dispatchEvent(new CustomEvent("state:prime", { detail })); }); for (const eventName of eventsToBroadcast){ fromSourceTarget.addEventListener(eventName, handleEvent); } return new Proxy(fromSourceTarget, { get (obj, prop) { const value = obj[prop]; if (prop === "detach") { return ()=>{ toBroadcastTarget.detach(); if (typeof value === "function") { value.apply(obj); } }; } if (typeof value === "function") { return value.bind(obj); } return value; } }); } function sink(fromBroadcastSource, toSinkTarget, namespace, eventHandlers = { "state:update": (target, detail)=>{ if ("param" in detail || "key" in detail) { target.set(detail.param ?? detail.key, detail.value); } else { target.set(detail.value); } }, "state:reset": (target)=>target.clear() }) { function handleMessage(event) { const { type, namespace: msgNamespace, detail } = event.data; if (msgNamespace !== namespace) { return; } if (eventHandlers[type]) { eventHandlers[type](toSinkTarget, detail); } else { toSinkTarget.dispatchEvent(new CustomEvent(type, { detail })); } } fromBroadcastSource.onMessage(handleMessage); toSinkTarget.onMissing((e)=>{ const detail = e.detail; if (detail) { fromBroadcastSource.postMessage({ type: "state:request", namespace, detail: "param" in detail ? { param: detail.param } : "key" in detail ? { key: detail.key } : {} }); } }); return new Proxy(toSinkTarget, { get (obj, prop) { const value = obj[prop]; if (prop === "destroy") { return ()=>{ fromBroadcastSource.detach(); if (typeof value === "function") { value.call(obj); } }; } if (typeof value === "function") { return value.bind(obj); } return value; } }); } export { Broadcast, DataEventTarget, KVDataEventTarget, WindowBroadcast, sink, source };