// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs". /** * Host single-instance guard shared by the plugin family. The family bundle * (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so * the loader accepts a standalone install of the same package side by side; * without this guard the second instance would still re-register the same * webserver routes, tools, settings namespaces, and system-prompt sections * and fail the boot. mountOnce makes a mount of an already-mounted package a * no-op for as long as the first instance lives (the browser half is already * deduped by package name in the client module host). * * A no-op is only safe while the holder is ALIVE. The holder can be disposed * long after the refused mount ran its course: the Host reloads a profile by * creating the new loader entries before the old ones are torn down (a * plugin-manager enable/disable/install write, a settings-driven row reload, * HMR), so the new aggregate shell entry mounts its family plugin while the * previous entry still owns the name. Dropping that refused mount lost the * plugin for good - the previous entry then disposed its own mount, releasing * the name with nobody left to take it, and the family row stayed listed as * active while its host routes 404ed (the task-board panel showed * "board.hostError.notMounted", no degraded record appeared, and only a Host * restart recovered it). * * The refused mount is therefore QUEUED, not dropped, and replayed the moment * the holder releases the name - if the waiting fiber is still alive then. The * single-instance guarantee is unchanged: exactly one mount is live per * package name, and the replay re-enters the guard so a later mount still * dedupes against it. * * The registry rides a global symbol so two module instances of the same * package (npm copy vs repository link) still share one verdict. That symbol * is a CROSS-REPOSITORY contract, not this file's private state: the four * satellite packages (dsh-skins / dsh-pet / dsh-presets / * dsh-community-plugins) are separate repositories carrying their own copy of * this guard, rebuilt on their own schedule, so the value under `MOUNTED` * must keep the shape every published copy reads (a `Set` of package names * with `has`/`add`/`delete`). The wait queues this guard added therefore live * under their own additive key, and the registry reads back a `Set` even when * some other build left a different value there. Changing `MOUNTED`'s shape * in place broke that contract once: a satellite's legacy copy created a * `Set`, the family's new copy read it as a `Map`, and every family row * mounted after it failed with "claims.get is not a function". * * cordis `ctx.effect` runs its callback immediately and treats the callback's * return value as the fiber disposer, so the unmarker is returned, not run. */ /** Published cross-repository contract: package names currently mounted. */ const MOUNTED = Symbol.for('dsh-web.mounted-plugins') /** Additive key this guard owns: refused mounts waiting for the name. */ const WAITERS = Symbol.for('dsh-web.mounted-plugins.waiters') /** The slice of a cordis context this guard touches. */ interface MountContext { effect?: (effect: () => unknown) => unknown } /** One mount that was refused while another fiber owned the package name. */ interface PendingMount { /** Replay the refused mount; a no-op once its own fiber has been disposed. */ run(): void } interface MountRegistry { [MOUNTED]?: unknown [WAITERS]?: Map } /** * The shared name registry, always a `Set` whatever another build stored here: * a foreign value (an interim shape, a hand-written global) must not take every * family plugin down with it. * @returns the process-wide set of mounted package names. */ function mountedSet(): Set { const registry = globalThis as MountRegistry const existing = registry[MOUNTED] if (existing instanceof Set) return existing as Set const created = new Set() registry[MOUNTED] = created return created } /** Queue per package name for mounts refused while a holder was alive. */ function mountWaiters(): Map { const registry = globalThis as MountRegistry return (registry[WAITERS] ??= new Map()) } /** * Wrap a cordis plugin apply so the package runs at most once per process. * The first mount registers normally and releases the name when its fiber * disposes; a mount refused while that name is held waits for the release and * then runs, unless its own fiber disposes first. * @param packageName - npm package identity shared by every install source. * @param fn - the original plugin apply. * @returns an apply of the same shape. */ export function mountOnce unknown>(packageName: string, fn: T): T { const mount = (...args: unknown[]): unknown => { const mounted = mountedSet() const ctx = args[0] as MountContext | undefined if (mounted.has(packageName)) { // Another fiber (the aggregate row, a standalone install, or a copy built // from an older revision of this guard) owns the name. Queue instead of // dropping: dropping lost the plugin for the rest of the process when the // holder was disposed by a reload. const waiters = mountWaiters() const queue = waiters.get(packageName) ?? [] let alive = true const pending: PendingMount = { run: () => { if (alive) mount(...args) }, } // A fiber that dies while it waits must not be replayed afterwards: its // context is disposed and registering effects on it would throw. ctx?.effect?.(() => () => { alive = false const index = queue.indexOf(pending) if (index >= 0) queue.splice(index, 1) }) queue.push(pending) waiters.set(packageName, queue) return } mounted.add(packageName) ctx?.effect?.(() => () => { mounted.delete(packageName) const waiters = mountWaiters() const queue = waiters.get(packageName) if (queue === undefined) return waiters.delete(packageName) // Defer the handover one microtask so the disposing mount's own effects // (routes, ledger, timers) are torn down before the next one registers. for (const waiter of queue.splice(0)) queueMicrotask(() => { waiter.run() }) }) return fn(...args) } return mount as T }