| /* |
| * 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 type { Context, FiberState, Plugin } from './plugin-kernel.js'; |
| |
| const ID_PATTERN = /^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$/u; |
| |
| export type MakaPluginRootId = 'profile' | 'desktop-ui' | `session:${string}`; |
| |
| export interface MakaPluginPackage { |
| readonly packageId: string; |
| readonly host?: Plugin; |
| readonly client?: Plugin; |
| readonly contributions?: readonly MakaPluginContribution[]; |
| } |
| |
| export interface MakaPluginContribution { |
| readonly id: string; |
| readonly kind: 'tool' | 'ui' | 'hook' | 'service' | 'timer' | string; |
| } |
| |
| export interface MakaCompositionEntry { |
| readonly id: string; |
| readonly packageId?: string; |
| readonly config?: unknown; |
| readonly disabled?: boolean; |
| readonly inject?: readonly string[] | Readonly<Record<string, unknown>>; |
| readonly isolate?: Readonly<Record<string, true | string>>; |
| readonly intercept?: Readonly<Record<string, unknown>>; |
| readonly children?: readonly MakaCompositionEntry[]; |
| } |
| |
| export interface MakaCompositionState { |
| readonly schemaVersion: 1; |
| readonly generation: number; |
| readonly roots: { |
| readonly profile: readonly MakaCompositionEntry[]; |
| readonly desktopUi: readonly MakaCompositionEntry[]; |
| readonly sessions: Readonly<Record<string, readonly MakaCompositionEntry[]>>; |
| }; |
| } |
| |
| export type MakaCompositionOperation = |
| | { |
| readonly type: 'insert'; |
| readonly rootId?: MakaPluginRootId; |
| readonly parentId?: string; |
| readonly entry: MakaCompositionEntry; |
| readonly position?: number; |
| } |
| | { |
| readonly type: 'update'; |
| readonly entryId: string; |
| readonly patch: Partial<Omit<MakaCompositionEntry, 'id' | 'children'>>; |
| } |
| | { |
| readonly type: 'move'; |
| readonly entryId: string; |
| readonly parentId?: string; |
| readonly position?: number; |
| } |
| | { readonly type: 'remove'; readonly entryId: string }; |
| |
| export interface MakaCompositionApplyInput { |
| readonly baseGeneration?: number; |
| readonly operations: readonly MakaCompositionOperation[]; |
| } |
| |
| /** |
| * Applies Entry Tree operations to the desired-state value without activating |
| * Plugin code. Runtime Host uses this reducer to durably commit desired state |
| * before asking the live Composition Loader to converge. |
| */ |
| export function applyCompositionState( |
| state: MakaCompositionState, |
| input: MakaCompositionApplyInput, |
| ): MakaCompositionState { |
| if (state.schemaVersion !== 1) { |
| throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition state'); |
| } |
| if (input.baseGeneration !== undefined && input.baseGeneration !== state.generation) { |
| throw new MakaPluginRuntimeError( |
| 'invalid_entry', |
| `Composition generation changed from ${input.baseGeneration} to ${state.generation}`, |
| ); |
| } |
| if (input.operations.length === 0) return state; |
| if (state.generation >= Number.MAX_SAFE_INTEGER) { |
| throw new MakaPluginRuntimeError('invalid_entry', 'Composition generation is exhausted'); |
| } |
| |
| interface MutableLocation { |
| entry: MakaCompositionEntry; |
| parent?: MutableLocation; |
| readonly rootId: MakaPluginRootId; |
| siblings: MakaCompositionEntry[]; |
| } |
| |
| const profile = state.roots.profile.map(cloneCompositionEntry); |
| const desktopUi = state.roots.desktopUi.map(cloneCompositionEntry); |
| const sessions = Object.fromEntries( |
| Object.entries(state.roots.sessions).map(([scopeId, entries]) => [ |
| scopeId, |
| entries.map(cloneCompositionEntry), |
| ]), |
| ) as Record<string, MakaCompositionEntry[]>; |
| const locations = new Map<string, MutableLocation>(); |
| |
| const index = ( |
| entries: MakaCompositionEntry[], |
| rootId: MakaPluginRootId, |
| parent?: MutableLocation, |
| ): void => { |
| validatePluginRootId(rootId); |
| for (const entry of entries) { |
| validateCompositionEntry(entry); |
| if (locations.has(entry.id)) { |
| throw new MakaPluginRuntimeError( |
| 'entry_exists', |
| `Composition entry already exists: ${entry.id}`, |
| ); |
| } |
| const location: MutableLocation = { entry, parent, rootId, siblings: entries }; |
| locations.set(entry.id, location); |
| index(entry.children as MakaCompositionEntry[], rootId, location); |
| } |
| }; |
| index(profile, 'profile'); |
| index(desktopUi, 'desktop-ui'); |
| for (const [scopeId, entries] of Object.entries(sessions)) { |
| index(entries, `session:${scopeId}`); |
| } |
| |
| const requireLocation = (entryId: string): MutableLocation => { |
| const location = locations.get(entryId); |
| if (!location) { |
| throw new MakaPluginRuntimeError( |
| 'entry_not_found', |
| `Composition entry not found: ${entryId}`, |
| ); |
| } |
| return location; |
| }; |
| const rootEntries = (rootId: MakaPluginRootId): MakaCompositionEntry[] => { |
| validatePluginRootId(rootId); |
| if (rootId === 'profile') return profile; |
| if (rootId === 'desktop-ui') return desktopUi; |
| const scopeId = rootId.slice('session:'.length); |
| if (!Object.hasOwn(sessions, scopeId)) { |
| Object.defineProperty(sessions, scopeId, { |
| value: [], |
| writable: true, |
| enumerable: true, |
| configurable: true, |
| }); |
| } |
| return sessions[scopeId]!; |
| }; |
| const unindex = (entry: MakaCompositionEntry): void => { |
| locations.delete(entry.id); |
| for (const child of entry.children ?? []) unindex(child); |
| }; |
| const indexInserted = ( |
| entry: MakaCompositionEntry, |
| rootId: MakaPluginRootId, |
| siblings: MakaCompositionEntry[], |
| parent?: MutableLocation, |
| ): void => { |
| if (locations.has(entry.id)) { |
| throw new MakaPluginRuntimeError( |
| 'entry_exists', |
| `Composition entry already exists: ${entry.id}`, |
| ); |
| } |
| const location: MutableLocation = { entry, parent, rootId, siblings }; |
| locations.set(entry.id, location); |
| for (const child of entry.children ?? []) { |
| indexInserted(child, rootId, entry.children as MakaCompositionEntry[], location); |
| } |
| }; |
| |
| for (const operation of input.operations) { |
| switch (operation.type) { |
| case 'insert': { |
| const parent = operation.parentId ? requireLocation(operation.parentId) : undefined; |
| const rootId = operation.rootId ?? parent?.rootId ?? 'profile'; |
| validatePluginRootId(rootId); |
| if (parent && parent.rootId !== rootId) { |
| throw new MakaPluginRuntimeError( |
| 'invalid_entry', |
| 'Composition entries cannot move between roots', |
| ); |
| } |
| const entry = cloneCompositionEntry(operation.entry); |
| validateCompositionEntry(entry); |
| const subtreeIds = new Set<string>(); |
| for (const item of walkCompositionEntry(entry)) { |
| if (subtreeIds.has(item.id) || locations.has(item.id)) { |
| throw new MakaPluginRuntimeError( |
| 'entry_exists', |
| `Composition entry already exists: ${item.id}`, |
| ); |
| } |
| subtreeIds.add(item.id); |
| } |
| const siblings = parent |
| ? (parent.entry.children as MakaCompositionEntry[]) |
| : rootEntries(rootId); |
| siblings.splice(Math.min(operation.position ?? Infinity, siblings.length), 0, entry); |
| indexInserted(entry, rootId, siblings, parent); |
| break; |
| } |
| case 'update': { |
| const location = requireLocation(operation.entryId); |
| const next: MakaCompositionEntry = { |
| ...location.entry, |
| ...operation.patch, |
| id: location.entry.id, |
| children: location.entry.children, |
| }; |
| validateCompositionEntry(next); |
| const position = location.siblings.indexOf(location.entry); |
| location.siblings[position] = next; |
| location.entry = next; |
| break; |
| } |
| case 'move': { |
| const location = requireLocation(operation.entryId); |
| const parent = operation.parentId ? requireLocation(operation.parentId) : undefined; |
| if (parent && parent.rootId !== location.rootId) { |
| throw new MakaPluginRuntimeError( |
| 'invalid_entry', |
| 'Composition entries cannot move between roots', |
| ); |
| } |
| for (let ancestor = parent; ancestor; ancestor = ancestor.parent) { |
| if (ancestor === location) { |
| throw new MakaPluginRuntimeError( |
| 'dependency_cycle', |
| `Entry ${operation.entryId} cannot contain itself`, |
| ); |
| } |
| } |
| location.siblings.splice(location.siblings.indexOf(location.entry), 1); |
| const siblings = parent |
| ? (parent.entry.children as MakaCompositionEntry[]) |
| : rootEntries(location.rootId); |
| siblings.splice( |
| Math.min(operation.position ?? Infinity, siblings.length), |
| 0, |
| location.entry, |
| ); |
| location.parent = parent; |
| location.siblings = siblings; |
| break; |
| } |
| case 'remove': { |
| const location = requireLocation(operation.entryId); |
| location.siblings.splice(location.siblings.indexOf(location.entry), 1); |
| unindex(location.entry); |
| break; |
| } |
| } |
| } |
| |
| return freezeCompositionState({ |
| schemaVersion: 1, |
| generation: state.generation + 1, |
| roots: { profile, desktopUi, sessions }, |
| }); |
| } |
| |
| export type MakaCompositionEntryStatus = |
| | 'disabled' |
| | 'pending' |
| | 'loading' |
| | 'active' |
| | 'failed' |
| | 'unloading' |
| | 'disposed'; |
| |
| export interface MakaCompositionEntryInspection { |
| readonly id: string; |
| readonly rootId: MakaPluginRootId; |
| readonly parentId?: string; |
| readonly packageId?: string; |
| readonly config?: unknown; |
| readonly disabled: boolean; |
| readonly status: MakaCompositionEntryStatus; |
| readonly generation?: number; |
| readonly waitingFor: readonly string[]; |
| readonly effects: readonly string[]; |
| readonly children: readonly MakaCompositionEntryInspection[]; |
| readonly diagnostic?: string; |
| } |
| |
| export interface MakaPluginMountInput { |
| readonly entryId: string; |
| readonly rootId: string; |
| readonly packageId: string; |
| readonly config?: unknown; |
| } |
| |
| export interface MakaPluginMountInspection { |
| readonly entryId: string; |
| readonly rootId: string; |
| readonly packageId: string; |
| readonly enabled: boolean; |
| readonly status: MakaCompositionEntryStatus; |
| readonly current?: { readonly generation: number }; |
| readonly waitingFor: readonly string[]; |
| readonly pendingCleanupEffects: number; |
| readonly diagnostic?: { readonly message: string }; |
| } |
| |
| export interface MakaPluginMetadata { |
| readonly rootId: MakaPluginRootId; |
| readonly entryId: string; |
| readonly packageId: string; |
| readonly generation: number; |
| } |
| |
| export interface MakaContributionIdentity { |
| readonly entryId: string; |
| readonly scopeId: string; |
| readonly extensionId: string; |
| readonly generation: number; |
| } |
| |
| export interface MakaContributionContext extends MakaContributionIdentity { |
| readonly signal: AbortSignal; |
| readonly runtimeContext: Context; |
| ownEffect(label: string, dispose: () => void | Promise<void>): void; |
| dependency<T = unknown>(packageId: string): T; |
| } |
| |
| export interface MakaPluginTransaction { |
| stage( |
| label: string, |
| register: () => () => void | Promise<void>, |
| owner?: Context, |
| ): () => Promise<void>; |
| commit(): void | Promise<void>; |
| rollback(): void | Promise<void>; |
| } |
| |
| declare module './plugin-kernel.js' { |
| interface Context { |
| maka?: MakaPluginMetadata; |
| makaTransaction?: MakaPluginTransaction; |
| } |
| } |
| |
| export class MakaPluginRuntimeError extends Error { |
| readonly name = 'MakaPluginRuntimeError'; |
| |
| constructor( |
| readonly code: |
| | 'invalid_package' |
| | 'package_exists' |
| | 'package_not_found' |
| | 'package_in_use' |
| | 'invalid_entry' |
| | 'entry_exists' |
| | 'entry_not_found' |
| | 'dependency_cycle' |
| | 'activation_failed', |
| message: string, |
| options?: ErrorOptions, |
| ) { |
| super(message, options); |
| } |
| } |
| |
| export function validatePluginPackage(pkg: MakaPluginPackage): void { |
| validatePluginId(pkg.packageId, 'packageId'); |
| if (!pkg.host && !pkg.client) { |
| throw new MakaPluginRuntimeError( |
| 'invalid_package', |
| `Plugin package ${pkg.packageId} has no host or client plugin`, |
| ); |
| } |
| if (!Array.isArray(pkg.contributions ?? []) || (pkg.contributions?.length ?? 0) > 1024) { |
| throw new MakaPluginRuntimeError( |
| 'invalid_package', |
| `Plugin package ${pkg.packageId} has invalid contributions`, |
| ); |
| } |
| const contributions = new Set<string>(); |
| for (const contribution of pkg.contributions ?? []) { |
| if ( |
| !contribution || |
| typeof contribution !== 'object' || |
| typeof contribution.id !== 'string' || |
| contribution.id.length === 0 || |
| contribution.id.length > 128 || |
| /[\u0000-\u001f\u007f]/u.test(contribution.id) || |
| typeof contribution.kind !== 'string' || |
| contribution.kind.length === 0 || |
| contribution.kind.length > 128 || |
| /[\u0000-\u001f\u007f]/u.test(contribution.kind) |
| ) { |
| throw new MakaPluginRuntimeError( |
| 'invalid_package', |
| `Plugin package ${pkg.packageId} has an invalid contribution`, |
| ); |
| } |
| const identity = `${contribution.kind}\0${contribution.id}`; |
| if (contributions.has(identity)) { |
| throw new MakaPluginRuntimeError( |
| 'invalid_package', |
| `Plugin package ${pkg.packageId} repeats contribution ${contribution.kind}:${contribution.id}`, |
| ); |
| } |
| contributions.add(identity); |
| } |
| } |
| |
| export function validateCompositionEntry(entry: MakaCompositionEntry): void { |
| validatePluginId(entry.id, 'entry id'); |
| if (entry.packageId !== undefined) { |
| validatePluginId(entry.packageId!, 'packageId'); |
| } |
| for (const key of Object.keys(entry.isolate ?? {})) validateServiceName(key); |
| for (const key of Object.keys(entry.intercept ?? {})) validateServiceName(key); |
| for (const dependency of Array.isArray(entry.inject) |
| ? entry.inject |
| : Object.keys(entry.inject ?? {})) { |
| validateServiceName(dependency); |
| } |
| const childIds = new Set<string>(); |
| for (const child of entry.children ?? []) { |
| validateCompositionEntry(child); |
| if (childIds.has(child.id)) { |
| throw new MakaPluginRuntimeError( |
| 'entry_exists', |
| `Entry ${entry.id} repeats child ${child.id}`, |
| ); |
| } |
| childIds.add(child.id); |
| } |
| } |
| |
| export function validatePluginRootId(rootId: string): asserts rootId is MakaPluginRootId { |
| if ( |
| rootId !== 'profile' && |
| rootId !== 'desktop-ui' && |
| !(rootId.startsWith('session:') && rootId.length > 'session:'.length) |
| ) { |
| throw new MakaPluginRuntimeError('invalid_entry', `Invalid composition root: ${rootId}`); |
| } |
| } |
| |
| export function pluginIdentity(ctx: Context): MakaContributionIdentity { |
| const metadata = ctx.maka; |
| if (!metadata) { |
| throw new MakaPluginRuntimeError( |
| 'activation_failed', |
| 'Contribution registration requires a composition entry Context', |
| ); |
| } |
| return Object.freeze({ |
| entryId: metadata.entryId, |
| scopeId: metadata.rootId, |
| extensionId: metadata.packageId, |
| generation: metadata.generation, |
| }); |
| } |
| |
| export function ownPluginEffect( |
| ctx: Context, |
| label: string, |
| dispose: () => void | Promise<void>, |
| ): void { |
| attachPluginEffect(ctx, label, dispose); |
| } |
| |
| function attachPluginEffect( |
| ctx: Context, |
| label: string, |
| dispose: () => void | Promise<void>, |
| ): () => Promise<void> { |
| return ctx.effect(() => dispose, label); |
| } |
| |
| function registerPluginEffect( |
| ctx: Context, |
| label: string, |
| register: () => () => void | Promise<void>, |
| ): () => Promise<void> { |
| let contributionDispose: (() => void | Promise<void>) | undefined; |
| const release = ctx.effect(() => () => contributionDispose?.(), label); |
| try { |
| contributionDispose = register(); |
| return release; |
| } catch (error) { |
| void release().catch(() => undefined); |
| throw error; |
| } |
| } |
| |
| export function registerPluginContribution( |
| ctx: Context, |
| label: string, |
| register: () => () => void | Promise<void>, |
| ): () => Promise<void> { |
| if (ctx.makaTransaction) { |
| return ctx.makaTransaction.stage(label, register, ctx); |
| } |
| return registerPluginEffect(ctx, label, register); |
| } |
| |
| export class MakaPluginTransactionBuffer implements MakaPluginTransaction { |
| readonly #registrations: Array<{ |
| readonly label: string; |
| readonly register: () => () => void | Promise<void>; |
| readonly owner: Context; |
| cancelled: boolean; |
| release?: () => Promise<void>; |
| }> = []; |
| #state: 'staging' | 'committed' | 'rolled_back' = 'staging'; |
| |
| constructor(private readonly context: Context) {} |
| |
| stage( |
| label: string, |
| register: () => () => void | Promise<void>, |
| owner = this.context, |
| ): () => Promise<void> { |
| if (this.#state === 'committed') { |
| return registerPluginEffect(owner, label, register); |
| } |
| if (this.#state === 'rolled_back') { |
| throw new MakaPluginRuntimeError( |
| 'activation_failed', |
| `Cannot stage contribution after transaction is ${this.#state}`, |
| ); |
| } |
| const item: { |
| readonly label: string; |
| readonly register: () => () => void | Promise<void>; |
| readonly owner: Context; |
| cancelled: boolean; |
| release?: () => Promise<void>; |
| } = { |
| label, |
| register, |
| owner, |
| cancelled: false, |
| }; |
| this.#registrations.push(item); |
| return async () => { |
| item.cancelled = true; |
| await item.release?.(); |
| }; |
| } |
| |
| async commit(): Promise<void> { |
| if (this.#state === 'committed') return; |
| if (this.#state === 'rolled_back') { |
| throw new MakaPluginRuntimeError( |
| 'activation_failed', |
| 'Cannot commit a rolled back transaction', |
| ); |
| } |
| const registered: Array<() => Promise<void>> = []; |
| try { |
| for (const item of this.#registrations) { |
| if (item.cancelled) continue; |
| item.release = registerPluginEffect(item.owner, item.label, item.register); |
| registered.push(item.release); |
| } |
| this.#state = 'committed'; |
| this.#registrations.length = 0; |
| } catch (error) { |
| this.#state = 'rolled_back'; |
| this.#registrations.length = 0; |
| const cleanupErrors: unknown[] = []; |
| for (const dispose of registered.reverse()) { |
| try { |
| await dispose(); |
| } catch (cleanupError) { |
| cleanupErrors.push(cleanupError); |
| } |
| } |
| if (cleanupErrors.length) { |
| throw new AggregateError( |
| [error, ...cleanupErrors], |
| 'Plugin transaction commit and rollback failed', |
| ); |
| } |
| throw error; |
| } |
| } |
| |
| rollback(): void { |
| if (this.#state !== 'staging') return; |
| this.#state = 'rolled_back'; |
| this.#registrations.length = 0; |
| } |
| } |
| |
| export function fiberStateName(state: FiberState): MakaCompositionEntryStatus { |
| return ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'][ |
| state |
| ] as MakaCompositionEntryStatus; |
| } |
| |
| export function isCanonicalExtensionId(value: unknown): value is string { |
| return typeof value === 'string' && value.length <= 128 && ID_PATTERN.test(value); |
| } |
| |
| function cloneCompositionEntry(entry: MakaCompositionEntry): MakaCompositionEntry { |
| return { |
| ...entry, |
| ...(entry.inject && !Array.isArray(entry.inject) |
| ? { inject: { ...entry.inject } } |
| : entry.inject |
| ? { inject: [...entry.inject] } |
| : {}), |
| ...(entry.isolate ? { isolate: { ...entry.isolate } } : {}), |
| ...(entry.intercept ? { intercept: { ...entry.intercept } } : {}), |
| children: (entry.children ?? []).map(cloneCompositionEntry), |
| }; |
| } |
| |
| function* walkCompositionEntry(entry: MakaCompositionEntry): Generator<MakaCompositionEntry> { |
| yield entry; |
| for (const child of entry.children ?? []) yield* walkCompositionEntry(child); |
| } |
| |
| function freezeCompositionState(state: MakaCompositionState): MakaCompositionState { |
| const freezeEntry = (entry: MakaCompositionEntry): MakaCompositionEntry => |
| 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)), |
| }); |
| return Object.freeze({ |
| schemaVersion: 1, |
| generation: state.generation, |
| roots: Object.freeze({ |
| profile: Object.freeze(state.roots.profile.map(freezeEntry)), |
| desktopUi: Object.freeze(state.roots.desktopUi.map(freezeEntry)), |
| sessions: Object.freeze( |
| Object.fromEntries( |
| Object.entries(state.roots.sessions).map(([scopeId, entries]) => [ |
| scopeId, |
| Object.freeze(entries.map(freezeEntry)), |
| ]), |
| ), |
| ), |
| }), |
| }); |
| } |
| |
| export function isCanonicalExtensionScopeId(value: unknown): value is string { |
| return ( |
| typeof value === 'string' && value.length <= 128 && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(value) |
| ); |
| } |
| |
| function validatePluginId(value: unknown, label: string): asserts value is string { |
| if (!isCanonicalExtensionId(value)) { |
| throw new MakaPluginRuntimeError('invalid_entry', `Invalid ${label}`); |
| } |
| } |
| |
| function validateServiceName(value: string): void { |
| if (!/^[A-Za-z][A-Za-z0-9._:-]{0,255}$/u.test(value)) { |
| throw new MakaPluginRuntimeError('invalid_entry', `Invalid service name: ${value}`); |
| } |
| } |