blob: 90e62929739535c14ae18d2ff33e485251a37580 [file]
/*
* 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}`);
}
}