import { VersionRange } from '@start9labs/start-core/exver' import { checkDependencies } from '@start9labs/start-core/dependencies/dependencies' import type { Effects, DependencyRequirement, Manifest, LocaleString, } from '@start9labs/start-core/types' import { runReactiveInit, setupOnInit, } from '@start9labs/start-core/inits/setupInit' import type { InitKind, InitScript, InitScriptOrFn, } from '@start9labs/start-core/inits/setupInit' import { deepEqual } from '@start9labs/start-core/util/deepEqual' import { FullProgressTracker } from '@start9labs/start-core/util/FullProgressTracker' type Base = { description: LocaleString | null metadata: { title: LocaleString; icon: string } versionRange: string } & ( | { kind: 'running'; healthChecks: string[] } | { kind: 'exists'; healthChecks?: never } ) type Narrowing = { versionRange?: string kind?: 'running' healthChecks?: string[] } /** A published base requirement with optional reactive runtime restrictions. */ export class Dependency { private narrowing?: (options: { effects: Effects }) => Promise private readonly inits: InitScript[] = [] private enabledFn?: (options: { effects: Effects }) => Promise private constructor( readonly id: Id, readonly optional: boolean, readonly base: Base, ) { VersionRange.parse(base.versionRange) } /** Publishes a requirement that is always active. */ static required(id: Id, base: Base) { return new Dependency(id, false, base) } /** Publishes a requirement activated by the service's configuration. */ static optional( id: Id, base: Base & { enabled: (options: { effects: Effects }) => Promise }, ) { const { enabled, ...fields } = base const dependency = new Dependency(id, true, fields) dependency.enabledFn = enabled return dependency } /** Restricts the published requirement for the active configuration. */ withDynamicNarrowing( fn: (options: { effects: Effects }) => Promise, ) { this.narrowing = fn return this } /** Appends an independent reactive init, run while the dependency is enabled. */ withInit(fn: InitScriptOrFn) { this.inits.push(setupOnInit(fn)) return this } async enabled(effects: Effects): Promise { return !this.enabledFn || (await this.enabledFn({ effects })) } initHandlers(): readonly InitScript[] { return this.inits } manifestInfo(): Manifest['dependencies'][string] { return { description: this.base.description, optional: this.optional, versionRange: this.base.versionRange, kind: this.base.kind, ...(this.base.kind === 'running' ? { healthChecks: this.base.healthChecks } : {}), metadata: this.base.metadata, } } /** The runtime requirement while enabled. */ async requirement(effects: Effects): Promise { const narrowed = await this.narrowing?.({ effects }) const baseRange = VersionRange.parse(this.base.versionRange) const runtimeRange = narrowed?.versionRange ? VersionRange.parse(narrowed.versionRange) : baseRange if (!baseRange.intersects(runtimeRange)) { throw new Error(`Dependency ${this.id} has an incompatible version range`) } if ( narrowed?.healthChecks?.length && this.base.kind === 'exists' && narrowed.kind !== 'running' ) { throw new Error( `Dependency ${this.id} has health checks without a running requirement`, ) } const kind = this.base.kind === 'running' || narrowed?.kind === 'running' ? 'running' : 'exists' const versionRange = narrowed?.versionRange ? baseRange.and(runtimeRange).toString() : this.base.versionRange const requirement: DependencyRequirement = kind === 'running' ? { id: this.id, kind, versionRange, healthChecks: [ ...(this.base.kind === 'running' ? this.base.healthChecks : []), ...(narrowed?.healthChecks || []), ], } : { id: this.id, kind, versionRange } return requirement } } /** A single dependency definition for the manifest and runtime. */ export class Dependencies implements InitScript { private constructor(private readonly entries: Dependency[]) {} static of() { return new Dependencies([]) } addDependency( dependency: Dependency, ): Dependencies { if (this.entries.some(entry => entry.id === dependency.id)) { throw new Error(`Duplicate dependency ${dependency.id}`) } return new Dependencies([...this.entries, dependency]) } /** The dependency metadata embedded in the package manifest. */ manifestDependencies(): Manifest['dependencies'] { return Object.fromEntries( this.entries.map(entry => [entry.id, entry.manifestInfo()]), ) } async init( effects: Effects, kind: InitKind = null, progress?: FullProgressTracker, ): Promise { const active = new Map() let ready = false let published: DependencyRequirement[] | null = null let lastPublish = Promise.resolve() const publish = () => { const next = lastPublish.then(async () => { const dependencies = this.entries.flatMap(entry => { const requirement = active.get(entry.id) return requirement ? [requirement] : [] }) if (deepEqual(published, dependencies)) return await effects.setDependencies({ dependencies }) published = dependencies }) lastPublish = next.then( () => {}, () => {}, ) return next } const setRequirement = async ( id: string, requirement: DependencyRequirement | null, ) => { if (requirement) active.set(id, requirement) else active.delete(id) if (ready) await publish() } const starters: (() => Promise | false)[] = [] for (const entry of this.entries) { let enabled = false let generation = 0 let initsStarted = -1 const startInits = async ( gen: number, initKind: InitKind, initProgress?: FullProgressTracker, ) => { if (gen !== generation || initsStarted === gen) return initsStarted = gen for (const [index, handler] of entry.initHandlers().entries()) { await runReactiveInit( effects, `dependency_${entry.id}_init_${index}`, handler, initKind, initProgress, () => gen === generation, ) } } await runReactiveInit( effects, `dependency_${entry.id}_enabled`, async child => { const next = await entry.enabled(child) if (next === enabled) return enabled = next const gen = ++generation if (!next) return setRequirement(entry.id, null) await runReactiveInit( effects, `dependency_${entry.id}`, async child => { const requirement = await entry.requirement(child) if (gen === generation) await setRequirement(entry.id, requirement) }, null, undefined, () => gen === generation, ) if (ready) await startInits(gen, null, new FullProgressTracker()) }, null, ) starters.push(() => enabled && startInits(generation, kind, progress)) } ready = true await publish() for (const start of starters) await start() } /** Checks the active runtime requirements. */ check(effects: Effects, packageIds?: Ids[]) { return checkDependencies(effects, packageIds) } }