forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
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}