| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import { Context, type Fiber, type Inject, type Plugin } from './plugin-kernel.js'; |
| import { |
| fiberStateName, |
| type MakaCompositionEntry, |
| type MakaCompositionEntryInspection, |
| type MakaCompositionApplyInput, |
| type MakaCompositionSnapshot, |
| type MakaPluginMetadata, |
| type MakaPluginPackage, |
| type MakaPluginRootId, |
| MakaPluginRuntimeError, |
| MakaPluginTransactionBuffer, |
| type MakaPluginTransaction, |
| validateCompositionEntry, |
| validatePluginPackage, |
| validatePluginRootId, |
| } from './plugin-runtime.js'; |
| |
| interface LiveEntry { |
| spec: MakaCompositionEntry; |
| readonly rootId: MakaPluginRootId; |
| parent?: LiveEntry; |
| context: Context; |
| fiber?: Fiber; |
| generation?: number; |
| readonly children: LiveEntry[]; |
| diagnostic?: string; |
| } |
| |
| const FIBER_PENDING = 0; |
| const FIBER_FAILED = 3; |
| |
| interface LiveRoot { |
| readonly id: MakaPluginRootId; |
| readonly context: Context; |
| readonly entries: LiveEntry[]; |
| } |
| |
| export interface MakaCompositionLoaderOptions { |
| readonly root?: Context; |
| readonly transaction?: (context: Context) => MakaPluginTransaction | undefined; |
| } |
| |
| export class MakaCompositionLoader { |
| readonly root: Context; |
| readonly #packages = new Map<string, MakaPluginPackage>(); |
| readonly #roots = new Map<MakaPluginRootId, LiveRoot>(); |
| readonly #entries = new Map<string, LiveEntry>(); |
| readonly #isolationLabels = new Map<string, symbol>(); |
| readonly #transaction?: (context: Context) => MakaPluginTransaction | undefined; |
| #compositionGeneration = 0; |
| #fiberGeneration = 0; |
| #mutation: Promise<void> = Promise.resolve(); |
| |
| constructor(options: MakaCompositionLoaderOptions = {}) { |
| this.root = options.root ?? new Context(); |
| this.#transaction = options.transaction; |
| } |
| |
| install(pkg: MakaPluginPackage): Promise<void> { |
| return this.#mutate(async () => { |
| validatePluginPackage(pkg); |
| if (this.#packages.has(pkg.packageId)) { |
| throw new MakaPluginRuntimeError( |
| 'package_exists', |
| `Plugin package is already installed: ${pkg.packageId}`, |
| ); |
| } |
| this.#packages.set(pkg.packageId, freezePackage(pkg)); |
| }); |
| } |
| |
| uninstall(packageId: string): Promise<void> { |
| return this.#mutate(async () => { |
| if (!this.#packages.has(packageId)) { |
| throw new MakaPluginRuntimeError( |
| 'package_not_found', |
| `Plugin package is not installed: ${packageId}`, |
| ); |
| } |
| const user = [...this.#entries.values()].find((entry) => entry.spec.packageId === packageId); |
| if (user) { |
| throw new MakaPluginRuntimeError( |
| 'package_in_use', |
| `Plugin package is used by entry ${user.spec.id}`, |
| ); |
| } |
| this.#packages.delete(packageId); |
| }); |
| } |
| |
| create( |
| rootId: MakaPluginRootId, |
| entry: MakaCompositionEntry, |
| parentId?: string, |
| position = Infinity, |
| ): Promise<MakaCompositionEntryInspection> { |
| return this.apply({ |
| operations: [{ type: 'insert', rootId, entry, parentId, position }], |
| }).then(([inspection]) => inspection!); |
| } |
| |
| update( |
| entryId: string, |
| patch: Partial<Omit<MakaCompositionEntry, 'id' | 'children'>>, |
| ): Promise<MakaCompositionEntryInspection> { |
| return this.apply({ operations: [{ type: 'update', entryId, patch }] }).then( |
| ([inspection]) => inspection!, |
| ); |
| } |
| |
| move( |
| entryId: string, |
| newParentId?: string, |
| position = Infinity, |
| ): Promise<MakaCompositionEntryInspection> { |
| return this.apply({ |
| operations: [{ type: 'move', entryId, parentId: newParentId, position }], |
| }).then(([inspection]) => inspection!); |
| } |
| |
| enable(entryId: string): Promise<MakaCompositionEntryInspection> { |
| return this.update(entryId, { disabled: false }); |
| } |
| |
| disable(entryId: string): Promise<MakaCompositionEntryInspection> { |
| return this.update(entryId, { disabled: true }); |
| } |
| |
| apply(input: MakaCompositionApplyInput): Promise<readonly MakaCompositionEntryInspection[]> { |
| return this.#mutate(async () => { |
| if ( |
| input.baseGeneration !== undefined && |
| input.baseGeneration !== this.#compositionGeneration |
| ) |
| throw new MakaPluginRuntimeError( |
| 'invalid_entry', |
| `Composition generation changed from ${input.baseGeneration} to ${this.#compositionGeneration}`, |
| ); |
| const before = this.snapshot(); |
| const inspections: MakaCompositionEntryInspection[] = []; |
| let appliedOperations = 0; |
| try { |
| for (const operation of input.operations) { |
| switch (operation.type) { |
| case 'insert': { |
| const entry = await this.#insert( |
| operation.rootId ?? this.#inferRoot(operation.parentId), |
| operation.entry, |
| operation.parentId, |
| operation.position, |
| ); |
| inspections.push(this.#inspect(entry)); |
| appliedOperations += 1; |
| break; |
| } |
| case 'update': { |
| const entry = await this.#update(operation.entryId, operation.patch); |
| inspections.push(this.#inspect(entry)); |
| appliedOperations += 1; |
| break; |
| } |
| case 'move': { |
| const entry = await this.#move( |
| operation.entryId, |
| operation.parentId, |
| operation.position, |
| ); |
| inspections.push(this.#inspect(entry)); |
| appliedOperations += 1; |
| break; |
| } |
| case 'remove': |
| await this.#remove(operation.entryId); |
| appliedOperations += 1; |
| break; |
| } |
| } |
| } catch (error) { |
| // A candidate can fail before changing the live tree. Rebuilding in |
| // that case would unnecessarily dispose the current Fiber and lose |
| // its registered contributions. |
| if (appliedOperations > 0) await this.#replaceSnapshot(before, 'rollback'); |
| throw error; |
| } |
| if (input.operations.length > 0) this.#compositionGeneration += 1; |
| return Object.freeze(inspections); |
| }); |
| } |
| |
| replaceSubtree( |
| entryId: string, |
| entry: MakaCompositionEntry, |
| ): Promise<MakaCompositionEntryInspection> { |
| return this.#mutate(async () => { |
| const current = this.#requireEntry(entryId); |
| if (entry.id !== entryId) { |
| throw new MakaPluginRuntimeError( |
| 'invalid_entry', |
| 'Replacement subtree must preserve entry id', |
| ); |
| } |
| validateCompositionEntry(entry); |
| const descendantIds = new Set<string>(); |
| for (const item of walk(entry)) { |
| if (descendantIds.has(item.id)) { |
| throw new MakaPluginRuntimeError( |
| 'entry_exists', |
| `Replacement subtree repeats entry ${item.id}`, |
| ); |
| } |
| descendantIds.add(item.id); |
| const existing = this.#entries.get(item.id); |
| if (existing && !isWithin(existing, current)) { |
| throw new MakaPluginRuntimeError( |
| 'entry_exists', |
| `Composition entry already exists: ${item.id}`, |
| ); |
| } |
| } |
| const inspection = await this.#replace(current, freezeEntry(entry)); |
| this.#compositionGeneration += 1; |
| return inspection; |
| }); |
| } |
| |
| remove(entryId: string): Promise<void> { |
| return this.apply({ operations: [{ type: 'remove', entryId }] }).then(() => undefined); |
| } |
| |
| inspectTree(rootId?: MakaPluginRootId): readonly MakaCompositionEntryInspection[] { |
| if (rootId) validatePluginRootId(rootId); |
| const selected = rootId ? this.#roots.get(rootId) : undefined; |
| const roots = rootId ? (selected ? [selected] : []) : [...this.#roots.values()]; |
| return Object.freeze( |
| roots.flatMap((root) => root.entries.map((entry) => this.#inspect(entry))), |
| ); |
| } |
| |
| inspect(entryId: string): MakaCompositionEntryInspection { |
| return this.#inspect(this.#requireEntry(entryId)); |
| } |
| |
| installedPackages(): readonly { readonly packageId: string }[] { |
| return Object.freeze( |
| [...this.#packages.values()] |
| .map(({ packageId }) => Object.freeze({ packageId })) |
| .sort((left, right) => left.packageId.localeCompare(right.packageId)), |
| ); |
| } |
| |
| package(packageId: string): MakaPluginPackage { |
| const pkg = this.#packages.get(packageId); |
| if (!pkg) { |
| throw new MakaPluginRuntimeError( |
| 'package_not_found', |
| `Plugin package is not installed: ${packageId}`, |
| ); |
| } |
| return pkg; |
| } |
| |
| async awaitSettled(): Promise<void> { |
| while (true) { |
| const tasks = [...this.#entries.values()].flatMap((entry) => |
| entry.fiber?.inertia ? [entry.fiber.inertia] : [], |
| ); |
| if (!tasks.length) return; |
| await Promise.allSettled(tasks); |
| } |
| } |
| |
| snapshot(): MakaCompositionSnapshot { |
| const encode = (rootId: MakaPluginRootId): readonly MakaCompositionEntry[] => |
| Object.freeze((this.#roots.get(rootId)?.entries ?? []).map((entry) => serialize(entry))); |
| const sessions = Object.fromEntries( |
| [...this.#roots.values()].flatMap((root) => |
| root.id.startsWith('session:') |
| ? [[root.id.slice('session:'.length), encode(root.id)] as const] |
| : [], |
| ), |
| ); |
| return Object.freeze({ |
| schemaVersion: 1, |
| generation: this.#compositionGeneration, |
| roots: Object.freeze({ |
| profile: encode('profile'), |
| desktopUi: encode('desktop-ui'), |
| sessions: Object.freeze(sessions), |
| }), |
| }); |
| } |
| |
| replaceSnapshot(snapshot: MakaCompositionSnapshot): Promise<void> { |
| return this.#mutate(() => this.#replaceSnapshot(snapshot, 'publish')); |
| } |
| |
| async #replaceSnapshot( |
| snapshot: MakaCompositionSnapshot, |
| generationMode: 'publish' | 'rollback', |
| ): Promise<void> { |
| if (snapshot.schemaVersion !== 1) |
| throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition snapshot'); |
| const previousGeneration = this.#compositionGeneration; |
| const pristine = previousGeneration === 0 && this.#entries.size === 0 && this.#roots.size === 0; |
| const specs = new Map<MakaPluginRootId, readonly MakaCompositionEntry[]>([ |
| ['profile', snapshot.roots.profile], |
| ['desktop-ui', snapshot.roots.desktopUi], |
| ...Object.entries(snapshot.roots.sessions).map( |
| ([id, entries]) => [`session:${id}` as MakaPluginRootId, entries] as const, |
| ), |
| ]); |
| const stagedRoots = new Map<MakaPluginRootId, LiveRoot>(); |
| const stagedIds = new Set<string>(); |
| try { |
| for (const [rootId, entries] of specs) { |
| validatePluginRootId(rootId); |
| const context = this.root.extend({ makaRootId: rootId }); |
| const root: LiveRoot = { id: rootId, context, entries: [] }; |
| stagedRoots.set(rootId, root); |
| for (const spec of entries) { |
| validateCompositionEntry(spec); |
| for (const item of walk(spec)) { |
| if (stagedIds.has(item.id)) |
| throw new MakaPluginRuntimeError( |
| 'entry_exists', |
| `Composition entry already exists: ${item.id}`, |
| ); |
| stagedIds.add(item.id); |
| } |
| root.entries.push(await this.#stage(spec, rootId, undefined, context, false)); |
| } |
| } |
| for (const root of stagedRoots.values()) |
| for (const entry of root.entries) await this.#commitSubtree(entry); |
| } catch (error) { |
| return rethrowAfterCleanup( |
| error, |
| () => |
| settleAll( |
| [...stagedRoots.values()].flatMap((root) => |
| [...root.entries].reverse().map((entry) => this.#dispose(entry)), |
| ), |
| 'Staged composition cleanup failed', |
| ), |
| 'Composition replacement and cleanup failed', |
| ); |
| } |
| const previous = [...this.#roots.values()]; |
| this.#roots.clear(); |
| this.#entries.clear(); |
| for (const [rootId, root] of stagedRoots) { |
| this.#roots.set(rootId, root); |
| for (const entry of root.entries) this.#index(entry); |
| } |
| this.#compositionGeneration = |
| generationMode === 'rollback' |
| ? snapshot.generation |
| : pristine |
| ? snapshot.generation |
| : Math.max(previousGeneration, snapshot.generation) + 1; |
| await this.#retire( |
| settleAll( |
| previous.flatMap((root) => |
| [...root.entries].reverse().map((entry) => this.#dispose(entry)), |
| ), |
| 'Previous composition cleanup failed', |
| ), |
| 'Previous composition cleanup failed after publishing the replacement', |
| ); |
| } |
| |
| async close(): Promise<void> { |
| await this.#mutate(async () => { |
| const roots = [...this.#roots.values()]; |
| this.#roots.clear(); |
| this.#entries.clear(); |
| const errors: unknown[] = []; |
| try { |
| await settleAll( |
| roots.flatMap((root) => [...root.entries].reverse().map((entry) => this.#dispose(entry))), |
| 'Composition entry cleanup failed', |
| ); |
| } catch (error) { |
| errors.push(error); |
| } |
| try { |
| await this.root.fiber.dispose(); |
| } catch (error) { |
| errors.push(error); |
| } |
| throwIfErrors(errors, 'Composition loader close failed'); |
| }); |
| } |
| |
| async #replace( |
| current: LiveEntry, |
| spec: MakaCompositionEntry, |
| ): Promise<MakaCompositionEntryInspection> { |
| const parentContext = current.parent?.context ?? this.#root(current.rootId).context; |
| const candidate = await this.#stage( |
| spec, |
| current.rootId, |
| current.parent, |
| parentContext, |
| current.parent ? isDisabled(current.parent) : false, |
| ); |
| try { |
| await this.#commitSubtree(candidate); |
| } catch (error) { |
| current.diagnostic = diagnostic(error); |
| return rethrowAfterCleanup( |
| error, |
| () => this.#dispose(candidate), |
| `Entry ${current.spec.id} replacement and cleanup failed`, |
| ); |
| } |
| const siblings = current.parent?.children ?? this.#root(current.rootId).entries; |
| const index = siblings.indexOf(current); |
| this.#unindex(current); |
| siblings[index] = candidate; |
| this.#index(candidate); |
| await this.#retire( |
| this.#dispose(current), |
| `Entry ${current.spec.id} cleanup failed after publishing its replacement`, |
| ); |
| return this.#inspect(candidate); |
| } |
| |
| async #rebind(entry: LiveEntry, parent: LiveEntry | undefined, position: number): Promise<void> { |
| const replacement = await this.#stage( |
| serialize(entry), |
| entry.rootId, |
| parent, |
| parent?.context ?? this.#root(entry.rootId).context, |
| parent ? isDisabled(parent) : false, |
| ); |
| try { |
| await this.#commitSubtree(replacement); |
| } catch (error) { |
| return rethrowAfterCleanup( |
| error, |
| () => this.#dispose(replacement), |
| `Entry ${entry.spec.id} move activation and cleanup failed`, |
| ); |
| } |
| const source = entry.parent?.children ?? this.#root(entry.rootId).entries; |
| const target = parent?.children ?? this.#root(entry.rootId).entries; |
| this.#unindex(entry); |
| source.splice(source.indexOf(entry), 1); |
| target.splice(Math.min(position, target.length), 0, replacement); |
| this.#index(replacement); |
| await this.#retire( |
| this.#dispose(entry), |
| `Entry ${entry.spec.id} cleanup failed after publishing its rebound Fiber`, |
| ); |
| } |
| |
| async #stage( |
| spec: MakaCompositionEntry, |
| rootId: MakaPluginRootId, |
| parent: LiveEntry | undefined, |
| parentContext: Context, |
| ancestorDisabled: boolean, |
| ): Promise<LiveEntry> { |
| let context = parentContext.extend({ makaEntryId: spec.id }); |
| for (const [service, label] of Object.entries(spec.isolate ?? {})) { |
| const symbol = label === true ? Symbol(`${spec.id}:${service}`) : this.#isolationLabel(label); |
| context = context.isolate(service, symbol); |
| } |
| for (const [service, config] of Object.entries(spec.intercept ?? {})) |
| context = context.intercept(service, config); |
| const live: LiveEntry = { spec: freezeEntry(spec), rootId, parent, context, children: [] }; |
| const disabled = ancestorDisabled || spec.disabled === true; |
| if (!disabled && spec.packageId) { |
| const pkg = this.#packages.get(spec.packageId); |
| if (!pkg) |
| throw new MakaPluginRuntimeError( |
| 'package_not_found', |
| `Plugin package is not installed: ${spec.packageId}`, |
| ); |
| if (!pkg.host) |
| throw new MakaPluginRuntimeError( |
| 'invalid_package', |
| `Plugin package has no Host plugin: ${spec.packageId}`, |
| ); |
| const generation = ++this.#fiberGeneration; |
| const metadata: MakaPluginMetadata = Object.freeze({ |
| rootId, |
| entryId: spec.id, |
| packageId: spec.packageId, |
| generation, |
| }); |
| context = context.extend({ maka: metadata }); |
| const transaction = this.#transaction?.(context) ?? new MakaPluginTransactionBuffer(context); |
| if (transaction) context = context.extend({ makaTransaction: transaction }); |
| live.context = context; |
| live.generation = generation; |
| const plugin = entryPlugin(pkg.host, spec.inject); |
| live.fiber = context.plugin(plugin, spec.config); |
| try { |
| await live.fiber.await(); |
| if (live.fiber.state === FIBER_FAILED) throw new Error(`Plugin Fiber failed: ${spec.id}`); |
| } catch (error) { |
| live.diagnostic = diagnostic(error); |
| const cleanupErrors: unknown[] = []; |
| try { |
| await live.fiber.dispose(); |
| } catch (cleanupError) { |
| cleanupErrors.push(cleanupError); |
| } |
| try { |
| await transaction?.rollback(); |
| } catch (cleanupError) { |
| cleanupErrors.push(cleanupError); |
| } |
| const cause = cleanupErrors.length |
| ? new AggregateError( |
| [error, ...cleanupErrors], |
| `Entry ${spec.id} activation and cleanup failed`, |
| ) |
| : error; |
| throw new MakaPluginRuntimeError( |
| 'activation_failed', |
| `Unable to activate entry ${spec.id}: ${diagnostic(error)}`, |
| { cause }, |
| ); |
| } |
| } |
| try { |
| for (const child of spec.children ?? []) |
| live.children.push(await this.#stage(child, rootId, live, live.context, disabled)); |
| } catch (error) { |
| return rethrowAfterCleanup( |
| error, |
| () => this.#dispose(live), |
| `Entry ${spec.id} staging and cleanup failed`, |
| ); |
| } |
| return live; |
| } |
| |
| async #commitSubtree(entry: LiveEntry): Promise<void> { |
| await entry.context.makaTransaction?.commit(); |
| for (const child of entry.children) await this.#commitSubtree(child); |
| } |
| |
| async #dispose(entry: LiveEntry): Promise<void> { |
| const errors: unknown[] = []; |
| try { |
| await settleAll( |
| [...entry.children].reverse().map((child) => this.#dispose(child)), |
| `Entry ${entry.spec.id} child cleanup failed`, |
| ); |
| } catch (error) { |
| errors.push(error); |
| } |
| try { |
| await entry.context.makaTransaction?.rollback(); |
| } catch (error) { |
| errors.push(error); |
| } |
| try { |
| await entry.fiber?.dispose(); |
| } catch (error) { |
| errors.push(error); |
| } |
| throwIfErrors(errors, `Entry ${entry.spec.id} cleanup failed`); |
| } |
| |
| async #retire(task: Promise<void>, message: string): Promise<void> { |
| try { |
| await task; |
| } catch (error) { |
| this.root.logger.warn(message, error); |
| } |
| } |
| |
| #root(rootId: MakaPluginRootId): LiveRoot { |
| validatePluginRootId(rootId); |
| let root = this.#roots.get(rootId); |
| if (!root) { |
| root = { id: rootId, context: this.root.extend({ makaRootId: rootId }), entries: [] }; |
| this.#roots.set(rootId, root); |
| } |
| return root; |
| } |
| |
| #inferRoot(parentId: string | undefined): MakaPluginRootId { |
| if (!parentId) return 'profile'; |
| return this.#requireEntry(parentId).rootId; |
| } |
| |
| async #insert( |
| rootId: MakaPluginRootId, |
| entry: MakaCompositionEntry, |
| parentId?: string, |
| position = Infinity, |
| ): Promise<LiveEntry> { |
| validatePluginRootId(rootId); |
| validateCompositionEntry(entry); |
| this.#assertUniqueSubtree(entry); |
| const parent = parentId ? this.#requireEntry(parentId) : undefined; |
| if (parent && parent.rootId !== rootId) |
| throw new MakaPluginRuntimeError( |
| 'invalid_entry', |
| 'Composition entries cannot move between roots', |
| ); |
| const createdRoot = !this.#roots.has(rootId); |
| const root = this.#root(rootId); |
| try { |
| const live = await this.#stage( |
| entry, |
| rootId, |
| parent, |
| parent?.context ?? root.context, |
| parent ? isDisabled(parent) : false, |
| ); |
| try { |
| await this.#commitSubtree(live); |
| } catch (error) { |
| await rethrowAfterCleanup( |
| error, |
| () => this.#dispose(live), |
| `Entry ${entry.id} commit and cleanup failed`, |
| ); |
| } |
| const siblings = parent?.children ?? root.entries; |
| siblings.splice(Math.min(position, siblings.length), 0, live); |
| this.#index(live); |
| return live; |
| } catch (error) { |
| if (createdRoot && root.entries.length === 0) this.#roots.delete(rootId); |
| throw error; |
| } |
| } |
| |
| async #update( |
| entryId: string, |
| patch: Partial<Omit<MakaCompositionEntry, 'id' | 'children'>>, |
| ): Promise<LiveEntry> { |
| const current = this.#requireEntry(entryId); |
| const next = freezeEntry({ |
| ...current.spec, |
| ...patch, |
| id: current.spec.id, |
| children: current.children.map(serialize), |
| }); |
| validateCompositionEntry(next); |
| const structural = |
| next.packageId !== current.spec.packageId || |
| !shallowCompositionEqual(next.inject, current.spec.inject) || |
| !shallowCompositionEqual(next.isolate, current.spec.isolate) || |
| !shallowCompositionEqual(next.intercept, current.spec.intercept); |
| if (!structural && current.fiber && next.disabled !== true && current.spec.disabled !== true) { |
| await current.fiber.update(next.config); |
| current.spec = next; |
| current.diagnostic = undefined; |
| return current; |
| } |
| return this.#replace(current, next).then((inspection) => this.#requireEntry(inspection.id)); |
| } |
| |
| async #move(entryId: string, newParentId?: string, position = Infinity): Promise<LiveEntry> { |
| const entry = this.#requireEntry(entryId); |
| const parent = newParentId ? this.#requireEntry(newParentId) : undefined; |
| if (parent && parent.rootId !== entry.rootId) |
| throw new MakaPluginRuntimeError( |
| 'invalid_entry', |
| 'Composition entries cannot move between roots', |
| ); |
| for (let ancestor = parent; ancestor; ancestor = ancestor.parent) |
| if (ancestor === entry) |
| throw new MakaPluginRuntimeError( |
| 'dependency_cycle', |
| `Entry ${entryId} cannot contain itself`, |
| ); |
| await this.#rebind(entry, parent, position); |
| return this.#requireEntry(entryId); |
| } |
| |
| async #remove(entryId: string): Promise<void> { |
| const entry = this.#requireEntry(entryId); |
| const siblings = entry.parent?.children ?? this.#root(entry.rootId).entries; |
| siblings.splice(siblings.indexOf(entry), 1); |
| this.#unindex(entry); |
| await this.#retire( |
| this.#dispose(entry), |
| `Entry ${entry.spec.id} cleanup failed after removing it from the composition`, |
| ); |
| } |
| |
| #requireEntry(entryId: string): LiveEntry { |
| const entry = this.#entries.get(entryId); |
| if (!entry) |
| throw new MakaPluginRuntimeError( |
| 'entry_not_found', |
| `Composition entry not found: ${entryId}`, |
| ); |
| return entry; |
| } |
| |
| #assertUniqueSubtree(entry: MakaCompositionEntry): void { |
| const local = new Set<string>(); |
| for (const item of walk(entry)) { |
| if (local.has(item.id) || this.#entries.has(item.id)) |
| throw new MakaPluginRuntimeError( |
| 'entry_exists', |
| `Composition entry already exists: ${item.id}`, |
| ); |
| local.add(item.id); |
| } |
| } |
| |
| #index(entry: LiveEntry): void { |
| this.#entries.set(entry.spec.id, entry); |
| for (const child of entry.children) this.#index(child); |
| } |
| |
| #unindex(entry: LiveEntry): void { |
| this.#entries.delete(entry.spec.id); |
| for (const child of entry.children) this.#unindex(child); |
| } |
| |
| #inspect(entry: LiveEntry): MakaCompositionEntryInspection { |
| const sourceInject = entry.fiber?.inject ?? entry.spec.inject; |
| const inject = Array.isArray(sourceInject) |
| ? sourceInject |
| : Object.keys((sourceInject as Readonly<Record<string, unknown>> | undefined) ?? {}); |
| const waitingFor = |
| entry.fiber?.state === FIBER_PENDING |
| ? inject.filter((name) => entry.context.get(name) === undefined) |
| : []; |
| return Object.freeze({ |
| id: entry.spec.id, |
| rootId: entry.rootId, |
| ...(entry.parent ? { parentId: entry.parent.spec.id } : {}), |
| ...(entry.spec.packageId ? { packageId: entry.spec.packageId } : {}), |
| ...(entry.spec.config === undefined ? {} : { config: entry.spec.config }), |
| disabled: isDisabled(entry), |
| status: isDisabled(entry) |
| ? 'disabled' |
| : entry.fiber |
| ? fiberStateName(entry.fiber.state) |
| : 'active', |
| ...(entry.generation === undefined ? {} : { generation: entry.generation }), |
| waitingFor: Object.freeze(waitingFor), |
| effects: Object.freeze(entry.fiber?.getEffects().map(({ label }) => label) ?? []), |
| children: Object.freeze(entry.children.map((child) => this.#inspect(child))), |
| ...((entry.diagnostic ?? entry.fiber?.error) |
| ? { diagnostic: entry.diagnostic ?? diagnostic(entry.fiber?.error) } |
| : {}), |
| }); |
| } |
| |
| #isolationLabel(label: string): symbol { |
| let symbol = this.#isolationLabels.get(label); |
| if (!symbol) { |
| symbol = Symbol(label); |
| this.#isolationLabels.set(label, symbol); |
| } |
| return symbol; |
| } |
| |
| #mutate<T>(operation: () => Promise<T>): Promise<T> { |
| const result = this.#mutation.then(operation, operation); |
| this.#mutation = result.then( |
| () => undefined, |
| () => undefined, |
| ); |
| return result; |
| } |
| } |
| |
| function entryPlugin(plugin: Plugin, inject: MakaCompositionEntry['inject']): Plugin { |
| const combined = mergeInject((plugin as Plugin.Base).inject, inject); |
| return { |
| name: (plugin as Plugin.Base).name ?? 'maka-entry', |
| ...(combined ? { inject: combined } : {}), |
| ...((plugin as Plugin.Base).Config ? { Config: (plugin as Plugin.Base).Config } : {}), |
| apply(ctx: Context, config: unknown) { |
| if (typeof plugin !== 'function') return plugin.apply(ctx, config as never); |
| if (isConstructor(plugin)) return Reflect.construct(plugin, [ctx, config]); |
| return (plugin as Plugin.Function)(ctx, config as never); |
| }, |
| }; |
| } |
| |
| function isConstructor(value: Function): boolean { |
| return /^class\s/u.test(Function.prototype.toString.call(value)); |
| } |
| |
| function mergeInject( |
| left: Inject | undefined, |
| right: MakaCompositionEntry['inject'], |
| ): Inject | undefined { |
| if (!left && !right) return undefined; |
| const output: Record<string, unknown> = {}; |
| for (const source of [left, right]) { |
| if (Array.isArray(source)) for (const name of source) output[name] = null; |
| else Object.assign(output, source ?? {}); |
| } |
| return output; |
| } |
| |
| function freezePackage(pkg: MakaPluginPackage): MakaPluginPackage { |
| return Object.freeze({ ...pkg, contributions: Object.freeze([...(pkg.contributions ?? [])]) }); |
| } |
| |
| function freezeEntry(entry: MakaCompositionEntry): MakaCompositionEntry { |
| return Object.freeze({ |
| ...entry, |
| ...(entry.inject && !Array.isArray(entry.inject) |
| ? { inject: Object.freeze({ ...entry.inject }) } |
| : entry.inject |
| ? { inject: Object.freeze([...entry.inject]) } |
| : {}), |
| ...(entry.isolate ? { isolate: Object.freeze({ ...entry.isolate }) } : {}), |
| ...(entry.intercept ? { intercept: Object.freeze({ ...entry.intercept }) } : {}), |
| children: Object.freeze((entry.children ?? []).map(freezeEntry)), |
| }); |
| } |
| |
| function serialize(entry: LiveEntry): MakaCompositionEntry { |
| return freezeEntry({ ...entry.spec, children: entry.children.map(serialize) }); |
| } |
| |
| function shallowCompositionEqual(left: unknown, right: unknown): boolean { |
| if (Object.is(left, right)) return true; |
| if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false; |
| const leftEntries = Object.entries(left); |
| const rightEntries = Object.entries(right); |
| return ( |
| leftEntries.length === rightEntries.length && |
| leftEntries.every( |
| ([key, value]) => |
| Object.hasOwn(right, key) && |
| Object.is(value, (right as Readonly<Record<string, unknown>>)[key]), |
| ) |
| ); |
| } |
| |
| function* walk(entry: MakaCompositionEntry): Generator<MakaCompositionEntry> { |
| yield entry; |
| for (const child of entry.children ?? []) yield* walk(child); |
| } |
| |
| function isWithin(entry: LiveEntry, root: LiveEntry): boolean { |
| for (let current: LiveEntry | undefined = entry; current; current = current.parent) |
| if (current === root) return true; |
| return false; |
| } |
| |
| function isDisabled(entry: LiveEntry): boolean { |
| for (let current: LiveEntry | undefined = entry; current; current = current.parent) { |
| if (current.spec.disabled === true) return true; |
| } |
| return false; |
| } |
| |
| function diagnostic(error: unknown): string { |
| return error instanceof Error ? error.message : String(error); |
| } |
| |
| async function settleAll(tasks: Iterable<PromiseLike<unknown>>, message: string): Promise<void> { |
| const results = await Promise.allSettled(tasks); |
| const errors = results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : [])); |
| throwIfErrors(errors, message); |
| } |
| |
| async function rethrowAfterCleanup( |
| error: unknown, |
| cleanup: () => Promise<void>, |
| message: string, |
| ): Promise<never> { |
| try { |
| await cleanup(); |
| } catch (cleanupError) { |
| throw new AggregateError([error, cleanupError], message); |
| } |
| throw error; |
| } |
| |
| function throwIfErrors(errors: readonly unknown[], message: string): void { |
| if (errors.length) throw new AggregateError(errors, message); |
| } |