import QS from "query-string"; import { html, render } from "lit-html"; import { effect, signal } from "~/common/signal.js"; import { rpc, workerLink, workerProxy, workerTunnel } from "./worker.js"; import { RpcChannel } from "./worker/rpc-channel.js"; /** * @import {BroadcastingStatus, WorkerOpts} from "./element.d.ts" * @import {ProxiedActions, Tunnel} from "./worker.d.ts"; * @import {Signal} from "./signal.d.ts" */ export { html, nothing } from "lit-html"; export const DEFAULT_GROUP = "default"; /** * Base for custom elements, provides some utility functionality * around rendering and managing signals. */ export class DiffuseElement extends HTMLElement { $connected = signal(false); #connected = Promise.withResolvers(); #disposables = /** @type {Array<() => void>} */ ([]); /** */ constructor() { super(); this.worker = this.worker.bind(this); this.workerLink = this.workerLink.bind(this); } /** * @param {string} _name * @param {string} oldValue * @param {string} newValue */ attributeChangedCallback(_name, oldValue, newValue) { if (oldValue !== newValue) this.#render(); } /** * Effect helper that automatically is disposes * when this element is removed from the DOM. * * @param {() => void} fn */ effect(fn) { const unregister = effect(fn); this.#disposables.push(unregister); return unregister; } /** */ forceRender() { return this.#render(); } /** */ get group() { return this.getAttribute("group") ?? DEFAULT_GROUP; } /** */ get identifier() { const ns = this.namespace; return `${this.constructor.prototype.constructor.NAME}/${this.group}${ ns?.length ? "/" + ns : "" }`; } /** */ get label() { return this.getAttribute("label") ?? this.id ?? this.localName; } /** */ get namespace() { return this.getAttribute("namespace") ? this.getAttribute("namespace") : undefined; } /** */ root() { return (this.shadowRoot ?? this); } /** */ get selector() { return this.id.length ? `#${this.id}` : this.localName; } /** */ whenConnected() { return this.#connected.promise; } /** */ whenDefined() { return customElements.whenDefined(this.localName); } /** * Avoid replacing the whole subtree, * morph the existing DOM into the new given tree. */ #render() { if (!("render" in this && typeof this.render === "function")) return; const tmp = this.render({ html: html, state: "state" in this ? this.state : undefined, }); render(tmp, this.root()); } // LIFECYCLE connectedCallback() { this.$connected.value = true; this.#connected.resolve(null); if (!("render" in this && typeof this.render === "function")) return; this.effect(() => { if (!("render" in this && typeof this.render === "function")) return; this.#render(); }); } disconnectedCallback() { this.$connected.value = false; this.#teardown(); } #teardown() { this.#disposables.forEach((fn) => fn()); } // WORKERS /** @type {undefined | Worker | SharedWorker} */ #worker; createWorker() { const NAME = this.constructor.prototype.constructor.NAME; const WORKER_URL = this.constructor.prototype.constructor.WORKER_URL; if (!NAME) throw new Error("Missing `NAME` static property"); if (!WORKER_URL) throw new Error("Missing `WORKER_URL` static property"); // Query const query = QS.stringify( "workerQuery" in this && typeof this.workerQuery === "function" ? this.workerQuery() : {}, ); // Setup worker const name = this.identifier; const url = import.meta.resolve("./" + WORKER_URL) + `?${query}`; let worker; if (this.hasAttribute("group") && typeof SharedWorker !== "undefined") { worker = new SharedWorker(url, { name, type: "module" }); } else { worker = new Worker(url, { name, type: "module" }); } return worker; } /** * @returns {Record | null} */ dependencies() { return null; } worker() { this.#worker ??= this.createWorker(); return this.#worker; } workerLink() { const worker = this.worker(); return workerLink(worker); } /** * @template {Record any>} Actions * @param {WorkerOpts} [opts] * @returns {ProxiedActions} */ workerProxy(opts) { return workerProxy( () => this.workerTunnel(opts).port, ); } /** * Creates a MessagePort that is connected to the worker. * All the dependencies are added automatically. * * @param {WorkerOpts} [opts] */ workerTunnel({ forceNew } = {}) { const worker = forceNew === true || (typeof forceNew === "object" && forceNew.self === true) ? () => this.createWorker() : () => this.worker(); const deps = this.dependencies(); let toWorker; if (deps) { toWorker = /** * @param {any} msg */ async (msg) => { /** @type {Array<[string, Tunnel]>} */ const ports = Object.entries(deps).map( /** @param {[string, DiffuseElement]} _ */ ([k, v]) => { const n = typeof forceNew === "object" ? forceNew.dependencies?.[k] ?? false : false; return [k, v.workerTunnel({ forceNew: n })]; }, ); const data = { data: Array.isArray(msg.args) ? msg.args[0] : msg.args, ports: Object.fromEntries(ports.map(([k, v]) => { return [k, v.port]; })), }; this.#disposables.push(() => { ports.forEach(([_k, v]) => v.disconnect()); }); return { data: { ...msg, args: Array.isArray(msg.args) ? [data, ...msg.args.slice(1)] : msg.args, }, transfer: ports.map(([_k, v]) => v.port), }; }; } const tunnel = workerTunnel(worker, { toWorker }); return tunnel; } } /** * Broadcastable version of the base class. * * Share the state of an element across multiple tabs * of the same origin and have one instance be the leader. */ export class BroadcastableDiffuseElement extends DiffuseElement { broadcasted = false; /** @type {{ assumeLeadership?: boolean }} */ #broadcastingOptions = {}; #broadcastingStatus; broadcastingStatus; /** @type {PromiseWithResolvers} */ #lock = Promise.withResolvers(); /** @type {PromiseWithResolvers} */ #status = Promise.withResolvers(); constructor() { super(); this.broadcast = this.broadcast.bind(this); /** @type {Signal>} */ this.#broadcastingStatus = signal(this.#status.promise); this.broadcastingStatus = this.#broadcastingStatus.get; } /** * @template {Record any }>} ActionsWithStrategy * @template {{ [K in keyof ActionsWithStrategy]: ActionsWithStrategy[K]["fn"] }} Actions * @param {string} channelName * @param {ActionsWithStrategy} actionsWithStrategy * @param {{ assumeLeadership?: boolean }} [options] */ broadcast(channelName, actionsWithStrategy, options) { if (this.broadcasted) return; if (options) this.#broadcastingOptions = options; const channel = new BroadcastChannel(channelName); const msg = new MessageChannel(); /** * @typedef {{ [K in keyof ActionsWithStrategy]: ActionsWithStrategy[K]["fn"] }} A */ this.broadcasted = true; this.channelName = channelName; /** @type {RpcChannel<{}, Actions>} */ const _rpc = rpc( msg.port2, /** @type {Actions} */ ( Object.fromEntries( Object.entries(actionsWithStrategy).map(([k, v]) => { return [k, v.fn.bind(this)]; }), ) ), ); channel.addEventListener( "message", async (event) => { if (event.data?.method?.startsWith("leader:")) { const status = await this.#status.promise; if (status.leader) { msg.port1.postMessage({ ...event.data, method: event.data.method.slice("leader:".length), }); } } else { msg.port1.postMessage(event.data); } }, ); msg.port1.addEventListener( "message", (event) => channel.postMessage(event.data), ); msg.port1.start(); msg.port2.start(); async function anyoneWaiting() { const state = await navigator.locks.query(); return !!state.pending?.length; } /** @type {RpcChannel<{}, Actions>} */ const proxyChannel = new RpcChannel(msg.port2); /** @type {ProxiedActions} */ const proxy = proxyChannel.getAPI(); /** @type {any} */ const actions = {}; Object.entries(actionsWithStrategy).forEach( ([action, { fn, strategy }]) => { const ogFn = fn.bind(this); let wrapFn = ogFn; switch (strategy) { case "leaderOnly": /** @param {Parameters} args */ wrapFn = async (...args) => { const status = await this.#status.promise; return status.leader ? ogFn(...args) : proxyChannel.callMethod(`leader:${action}`, args); }; break; case "replicate": /** @param {Parameters} args */ wrapFn = async (...args) => { anyoneWaiting().then((bool) => { if (bool) proxy[action](...args); }); return ogFn(...args); }; break; } actions[action] = wrapFn; }, ); return /** @type {ProxiedActions} */ (actions); } async isLeader() { if (this.broadcasted) { const status = await this.broadcastingStatus(); return status.leader; } else { return true; } } // LIFECYCLE /** * @override */ connectedCallback() { super.connectedCallback(); if (!this.broadcasted) return; // Grab a lock if it isn't acquired yet and if needed, // and hold it until `this.lock.promise` resolves. const assumeLeadership = this.#broadcastingOptions?.assumeLeadership; if (assumeLeadership === undefined || assumeLeadership === true) { navigator.locks.request( `${this.channelName}/lock`, assumeLeadership === true ? { steal: true } : { ifAvailable: true }, (lock) => { this.#status.resolve( lock ? { leader: true, initialLeader: true } : { leader: false }, ); if (lock) return this.#lock.promise; }, ); } else { this.#status.resolve( { leader: false }, ); } // When the lock status is initially determined, log its status. // Additionally, wait for lock if needed. this.#status.promise.then((status) => { if (status.leader) { console.log(`🧙 Elected leader for: ${this.channelName}`); } else { console.log(`🔮 Watching leader: ${this.channelName}`); } // Wait for leadership if (status.leader === false) { navigator.locks.request( `${this.channelName}/lock`, () => { this.#status = Promise.withResolvers(); this.#status.resolve({ leader: true, initialLeader: false }); this.#broadcastingStatus.value = this.#status.promise; return this.#lock.promise; }, ); } }); } /** * @override */ disconnectedCallback() { super.disconnectedCallback(); this.#lock.resolve(); } } /** * Component DOM selector. * * Basically `document.querySelector` but returns the element * with the correct type based on the element module given. * * ``` * import * as QueryEngine from "~/components/engine/query/element.js" * * const instance = component(QueryEngine) * ``` * * @template {abstract new (...args: any[]) => any} C * @param {{ CLASS: C; NAME: string }} elementModule * @param {string} [id] Optional id to select */ export function component(elementModule, id) { const el = document.querySelector( id ? `${elementModule.NAME}#${id}` : elementModule.NAME, ); if (!el) { throw new Error(`Element for selector '${elementModule.NAME}' not found.`); } return /** @type {InstanceType} */ (el); } /** * Defines a custom element, guarding against double-registration. * * @param {string} name * @param {CustomElementConstructor} constructor */ export function defineElement(name, constructor) { if (!customElements.get(name)) customElements.define(name, constructor); else console.warn(`The '${name}' element was asked to be defined again, this should be avoided. The code may have been executed multiple times.`) } /** * @template {HTMLElement} T * @param {DiffuseElement} parent * @param {string} attribute * @returns {T} */ export function query(parent, attribute) { const selector = parent.getAttribute(attribute); if (!selector) { throw new Error(`Missing required '${attribute}' attribute`); } /** @type {T | null} */ const element = document.querySelector(selector); if (!element) throw new Error(`Missing required '${selector}' element`); return element; } /** * @template {HTMLElement} T * @param {DiffuseElement} parent * @param {string} attribute */ export function queryOptional(parent, attribute) { const selector = parent.getAttribute(attribute); if (!selector) { return null; } /** @type {T | null} */ const elementOrNull = document.querySelector(selector); return elementOrNull; } /** * @param {Record} workers */ export function terminateWorkers(workers) { Object.values(workers).forEach((worker) => { if (worker instanceof Worker) worker.terminate(); }); } /** * @template {Record} T * @param {T} elements */ export async function whenElementsDefined(elements) { await Promise.all( Object.values(elements).map((element) => customElements.whenDefined(element.localName) ), ); }