state-event-target
Version:
Primary state with event-driven replicas.
408 lines (399 loc) • 12.9 kB
JavaScript
Object.defineProperty(exports, '__esModule', { value: true });
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;
}
});
}
exports.Broadcast = Broadcast;
exports.DataEventTarget = DataEventTarget;
exports.KVDataEventTarget = KVDataEventTarget;
exports.WindowBroadcast = WindowBroadcast;
exports.sink = sink;
exports.source = source;