A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 71250e3e9ffd1e14fcc2db839b625bb50cffcd6c 570 lines 14 kB view raw
1import QS from "query-string"; 2import { html, render } from "lit-html"; 3 4import { effect, signal } from "~/common/signal.js"; 5import { rpc, workerLink, workerProxy, workerTunnel } from "./worker.js"; 6import { RpcChannel } from "./worker/rpc-channel.js"; 7 8/** 9 * @import {BroadcastingStatus, WorkerOpts} from "./element.d.ts" 10 * @import {ProxiedActions, Tunnel} from "./worker.d.ts"; 11 * @import {Signal} from "./signal.d.ts" 12 */ 13 14export { html, nothing } from "lit-html"; 15export const DEFAULT_GROUP = "default"; 16 17/** 18 * Base for custom elements, provides some utility functionality 19 * around rendering and managing signals. 20 */ 21export class DiffuseElement extends HTMLElement { 22 $connected = signal(false); 23 24 #connected = Promise.withResolvers(); 25 #disposables = /** @type {Array<() => void>} */ ([]); 26 27 /** */ 28 constructor() { 29 super(); 30 31 this.worker = this.worker.bind(this); 32 this.workerLink = this.workerLink.bind(this); 33 } 34 35 /** 36 * @param {string} _name 37 * @param {string} oldValue 38 * @param {string} newValue 39 */ 40 attributeChangedCallback(_name, oldValue, newValue) { 41 if (oldValue !== newValue) this.#render(); 42 } 43 44 /** 45 * Effect helper that automatically is disposes 46 * when this element is removed from the DOM. 47 * 48 * @param {() => void} fn 49 */ 50 effect(fn) { 51 const unregister = effect(fn); 52 this.#disposables.push(unregister); 53 return unregister; 54 } 55 56 /** */ 57 forceRender() { 58 return this.#render(); 59 } 60 61 /** */ 62 get group() { 63 return this.getAttribute("group") ?? DEFAULT_GROUP; 64 } 65 66 /** */ 67 get identifier() { 68 const ns = this.namespace; 69 return `${this.constructor.prototype.constructor.NAME}/${this.group}${ 70 ns?.length ? "/" + ns : "" 71 }`; 72 } 73 74 /** */ 75 get label() { 76 return this.getAttribute("label") ?? this.id ?? this.localName; 77 } 78 79 /** */ 80 get namespace() { 81 return this.getAttribute("namespace") 82 ? this.getAttribute("namespace") 83 : undefined; 84 } 85 86 /** */ 87 root() { 88 return (this.shadowRoot ?? this); 89 } 90 91 /** */ 92 get selector() { 93 return this.id.length ? `#${this.id}` : this.localName; 94 } 95 96 /** */ 97 whenConnected() { 98 return this.#connected.promise; 99 } 100 101 /** */ 102 whenDefined() { 103 return customElements.whenDefined(this.localName); 104 } 105 106 /** 107 * Avoid replacing the whole subtree, 108 * morph the existing DOM into the new given tree. 109 */ 110 #render() { 111 if (!("render" in this && typeof this.render === "function")) return; 112 113 const tmp = this.render({ 114 html: html, 115 state: "state" in this ? this.state : undefined, 116 }); 117 118 render(tmp, this.root()); 119 } 120 121 // LIFECYCLE 122 123 connectedCallback() { 124 this.$connected.value = true; 125 this.#connected.resolve(null); 126 127 if (!("render" in this && typeof this.render === "function")) return; 128 129 this.effect(() => { 130 if (!("render" in this && typeof this.render === "function")) return; 131 this.#render(); 132 }); 133 } 134 135 disconnectedCallback() { 136 this.$connected.value = false; 137 this.#teardown(); 138 } 139 140 #teardown() { 141 this.#disposables.forEach((fn) => fn()); 142 } 143 144 // WORKERS 145 146 /** @type {undefined | Worker | SharedWorker} */ 147 #worker; 148 149 createWorker() { 150 const NAME = this.constructor.prototype.constructor.NAME; 151 const WORKER_URL = this.constructor.prototype.constructor.WORKER_URL; 152 153 if (!NAME) throw new Error("Missing `NAME` static property"); 154 if (!WORKER_URL) throw new Error("Missing `WORKER_URL` static property"); 155 156 // Query 157 const query = QS.stringify( 158 "workerQuery" in this && typeof this.workerQuery === "function" 159 ? this.workerQuery() 160 : {}, 161 ); 162 163 // Setup worker 164 const name = this.identifier; 165 const url = import.meta.resolve("./" + WORKER_URL) + `?${query}`; 166 167 let worker; 168 169 if (this.hasAttribute("group") && typeof SharedWorker !== "undefined") { 170 worker = new SharedWorker(url, { name, type: "module" }); 171 } else { 172 worker = new Worker(url, { name, type: "module" }); 173 } 174 175 return worker; 176 } 177 178 /** 179 * @returns {Record<string, DiffuseElement> | null} 180 */ 181 dependencies() { 182 return null; 183 } 184 185 worker() { 186 this.#worker ??= this.createWorker(); 187 return this.#worker; 188 } 189 190 workerLink() { 191 const worker = this.worker(); 192 return workerLink(worker); 193 } 194 195 /** 196 * @template {Record<string, (...args: any[]) => any>} Actions 197 * @param {WorkerOpts} [opts] 198 * @returns {ProxiedActions<Actions>} 199 */ 200 workerProxy(opts) { 201 return workerProxy( 202 () => this.workerTunnel(opts).port, 203 ); 204 } 205 206 /** 207 * Creates a MessagePort that is connected to the worker. 208 * All the dependencies are added automatically. 209 * 210 * @param {WorkerOpts} [opts] 211 */ 212 workerTunnel({ forceNew } = {}) { 213 const worker = forceNew === true || 214 (typeof forceNew === "object" && forceNew.self === true) 215 ? () => this.createWorker() 216 : () => this.worker(); 217 const deps = this.dependencies(); 218 219 let toWorker; 220 221 if (deps) { 222 toWorker = 223 /** 224 * @param {any} msg 225 */ 226 async (msg) => { 227 /** @type {Array<[string, Tunnel]>} */ 228 const ports = Object.entries(deps).map( 229 /** @param {[string, DiffuseElement]} _ */ 230 ([k, v]) => { 231 const n = typeof forceNew === "object" 232 ? forceNew.dependencies?.[k] ?? false 233 : false; 234 return [k, v.workerTunnel({ forceNew: n })]; 235 }, 236 ); 237 238 const data = { 239 data: Array.isArray(msg.args) ? msg.args[0] : msg.args, 240 ports: Object.fromEntries(ports.map(([k, v]) => { 241 return [k, v.port]; 242 })), 243 }; 244 245 this.#disposables.push(() => { 246 ports.forEach(([_k, v]) => v.disconnect()); 247 }); 248 249 return { 250 data: { 251 ...msg, 252 args: Array.isArray(msg.args) 253 ? [data, ...msg.args.slice(1)] 254 : msg.args, 255 }, 256 transfer: ports.map(([_k, v]) => v.port), 257 }; 258 }; 259 } 260 261 const tunnel = workerTunnel(worker, { toWorker }); 262 return tunnel; 263 } 264} 265 266/** 267 * Broadcastable version of the base class. 268 * 269 * Share the state of an element across multiple tabs 270 * of the same origin and have one instance be the leader. 271 */ 272export class BroadcastableDiffuseElement extends DiffuseElement { 273 broadcasted = false; 274 275 /** @type {{ assumeLeadership?: boolean }} */ 276 #broadcastingOptions = {}; 277 278 #broadcastingStatus; 279 broadcastingStatus; 280 281 /** @type {PromiseWithResolvers<void>} */ 282 #lock = Promise.withResolvers(); 283 284 /** @type {PromiseWithResolvers<BroadcastingStatus>} */ 285 #status = Promise.withResolvers(); 286 287 constructor() { 288 super(); 289 290 this.broadcast = this.broadcast.bind(this); 291 292 /** @type {Signal<Promise<BroadcastingStatus>>} */ 293 this.#broadcastingStatus = signal(this.#status.promise); 294 this.broadcastingStatus = this.#broadcastingStatus.get; 295 } 296 297 /** 298 * @template {Record<string, { strategy: "leaderOnly" | "replicate", fn: (...args: any[]) => any }>} ActionsWithStrategy 299 * @template {{ [K in keyof ActionsWithStrategy]: ActionsWithStrategy[K]["fn"] }} Actions 300 * @param {string} channelName 301 * @param {ActionsWithStrategy} actionsWithStrategy 302 * @param {{ assumeLeadership?: boolean }} [options] 303 */ 304 broadcast(channelName, actionsWithStrategy, options) { 305 if (this.broadcasted) return; 306 if (options) this.#broadcastingOptions = options; 307 308 const channel = new BroadcastChannel(channelName); 309 const msg = new MessageChannel(); 310 311 /** 312 * @typedef {{ [K in keyof ActionsWithStrategy]: ActionsWithStrategy[K]["fn"] }} A 313 */ 314 315 this.broadcasted = true; 316 this.channelName = channelName; 317 318 /** @type {RpcChannel<{}, Actions>} */ 319 const _rpc = rpc( 320 msg.port2, 321 /** @type {Actions} */ ( 322 Object.fromEntries( 323 Object.entries(actionsWithStrategy).map(([k, v]) => { 324 return [k, v.fn.bind(this)]; 325 }), 326 ) 327 ), 328 ); 329 330 channel.addEventListener( 331 "message", 332 async (event) => { 333 if (event.data?.method?.startsWith("leader:")) { 334 const status = await this.#status.promise; 335 if (status.leader) { 336 msg.port1.postMessage({ 337 ...event.data, 338 method: event.data.method.slice("leader:".length), 339 }); 340 } 341 } else { 342 msg.port1.postMessage(event.data); 343 } 344 }, 345 ); 346 347 msg.port1.addEventListener( 348 "message", 349 (event) => channel.postMessage(event.data), 350 ); 351 352 msg.port1.start(); 353 msg.port2.start(); 354 355 async function anyoneWaiting() { 356 const state = await navigator.locks.query(); 357 return !!state.pending?.length; 358 } 359 360 /** @type {RpcChannel<{}, Actions>} */ 361 const proxyChannel = new RpcChannel(msg.port2); 362 363 /** @type {ProxiedActions<Actions>} */ 364 const proxy = proxyChannel.getAPI(); 365 366 /** @type {any} */ 367 const actions = {}; 368 369 Object.entries(actionsWithStrategy).forEach( 370 ([action, { fn, strategy }]) => { 371 const ogFn = fn.bind(this); 372 let wrapFn = ogFn; 373 374 switch (strategy) { 375 case "leaderOnly": 376 /** @param {Parameters<Actions[action]>} args */ 377 wrapFn = async (...args) => { 378 const status = await this.#status.promise; 379 return status.leader 380 ? ogFn(...args) 381 : proxyChannel.callMethod(`leader:${action}`, args); 382 }; 383 break; 384 385 case "replicate": 386 /** @param {Parameters<Actions[action]>} args */ 387 wrapFn = async (...args) => { 388 anyoneWaiting().then((bool) => { 389 if (bool) proxy[action](...args); 390 }); 391 return ogFn(...args); 392 }; 393 break; 394 } 395 396 actions[action] = wrapFn; 397 }, 398 ); 399 400 return /** @type {ProxiedActions<Actions>} */ (actions); 401 } 402 403 async isLeader() { 404 if (this.broadcasted) { 405 const status = await this.broadcastingStatus(); 406 return status.leader; 407 } else { 408 return true; 409 } 410 } 411 412 // LIFECYCLE 413 414 /** 415 * @override 416 */ 417 connectedCallback() { 418 super.connectedCallback(); 419 420 if (!this.broadcasted) return; 421 422 // Grab a lock if it isn't acquired yet and if needed, 423 // and hold it until `this.lock.promise` resolves. 424 const assumeLeadership = this.#broadcastingOptions?.assumeLeadership; 425 426 if (assumeLeadership === undefined || assumeLeadership === true) { 427 navigator.locks.request( 428 `${this.channelName}/lock`, 429 assumeLeadership === true ? { steal: true } : { ifAvailable: true }, 430 (lock) => { 431 this.#status.resolve( 432 lock ? { leader: true, initialLeader: true } : { leader: false }, 433 ); 434 if (lock) return this.#lock.promise; 435 }, 436 ); 437 } else { 438 this.#status.resolve( 439 { leader: false }, 440 ); 441 } 442 443 // When the lock status is initially determined, log its status. 444 // Additionally, wait for lock if needed. 445 this.#status.promise.then((status) => { 446 if (status.leader) { 447 console.log(`🧙 Elected leader for: ${this.channelName}`); 448 } else { 449 console.log(`🔮 Watching leader: ${this.channelName}`); 450 } 451 452 // Wait for leadership 453 if (status.leader === false) { 454 navigator.locks.request( 455 `${this.channelName}/lock`, 456 () => { 457 this.#status = Promise.withResolvers(); 458 this.#status.resolve({ leader: true, initialLeader: false }); 459 460 this.#broadcastingStatus.value = this.#status.promise; 461 462 return this.#lock.promise; 463 }, 464 ); 465 } 466 }); 467 } 468 469 /** 470 * @override 471 */ 472 disconnectedCallback() { 473 super.disconnectedCallback(); 474 this.#lock.resolve(); 475 } 476} 477 478/** 479 * Component DOM selector. 480 * 481 * Basically `document.querySelector` but returns the element 482 * with the correct type based on the element module given. 483 * 484 * ``` 485 * import * as QueryEngine from "~/components/engine/query/element.js" 486 * 487 * const instance = component(QueryEngine) 488 * ``` 489 * 490 * @template {abstract new (...args: any[]) => any} C 491 * @param {{ CLASS: C; NAME: string }} elementModule 492 * @param {string} [id] Optional id to select 493 */ 494export function component(elementModule, id) { 495 const el = document.querySelector( 496 id ? `${elementModule.NAME}#${id}` : elementModule.NAME, 497 ); 498 if (!el) { 499 throw new Error(`Element for selector '${elementModule.NAME}' not found.`); 500 } 501 return /** @type {InstanceType<C>} */ (el); 502} 503 504/** 505 * Defines a custom element, guarding against double-registration. 506 * 507 * @param {string} name 508 * @param {CustomElementConstructor} constructor 509 */ 510export function defineElement(name, constructor) { 511 if (!customElements.get(name)) customElements.define(name, constructor); 512 else console.warn(`The '${name}' element was asked to be defined again, this should be avoided. The code may have been executed multiple times.`) 513} 514 515/** 516 * @template {HTMLElement} T 517 * @param {DiffuseElement} parent 518 * @param {string} attribute 519 * @returns {T} 520 */ 521export function query(parent, attribute) { 522 const selector = parent.getAttribute(attribute); 523 524 if (!selector) { 525 throw new Error(`Missing required '${attribute}' attribute`); 526 } 527 528 /** @type {T | null} */ 529 const element = document.querySelector(selector); 530 if (!element) throw new Error(`Missing required '${selector}' element`); 531 return element; 532} 533 534/** 535 * @template {HTMLElement} T 536 * @param {DiffuseElement} parent 537 * @param {string} attribute 538 */ 539export function queryOptional(parent, attribute) { 540 const selector = parent.getAttribute(attribute); 541 542 if (!selector) { 543 return null; 544 } 545 546 /** @type {T | null} */ 547 const elementOrNull = document.querySelector(selector); 548 return elementOrNull; 549} 550 551/** 552 * @param {Record<string, Worker | SharedWorker>} workers 553 */ 554export function terminateWorkers(workers) { 555 Object.values(workers).forEach((worker) => { 556 if (worker instanceof Worker) worker.terminate(); 557 }); 558} 559 560/** 561 * @template {Record<string, DiffuseElement>} T 562 * @param {T} elements 563 */ 564export async function whenElementsDefined(elements) { 565 await Promise.all( 566 Object.values(elements).map((element) => 567 customElements.whenDefined(element.localName) 568 ), 569 ); 570}