@memberjunction/react-runtime
Version:
Platform-agnostic React component runtime for MemberJunction. Provides core compilation, registry, and execution capabilities for React components in any JavaScript environment.
642 lines (558 loc) • 19.2 kB
text/typescript
/**
* @fileoverview Library dependency resolution utilities
* Provides dependency graph construction, cycle detection, topological sorting, and version resolution
* @module @memberjunction/react-runtime/utilities
*/
import { ComponentLibraryEntity } from '@memberjunction/core-entities';
import {
DependencyGraph,
DependencyNode,
LoadOrderResult,
ParsedDependency,
ParsedVersion,
ResolvedVersion,
VersionRange,
VersionRangeType,
VersionRequirement,
DependencyResolutionOptions
} from '../types/dependency-types';
/**
* Resolves library dependencies and determines load order
*/
export class LibraryDependencyResolver {
private debug: boolean = false;
constructor(options?: DependencyResolutionOptions) {
this.debug = options?.debug || false;
}
/**
* Parse dependencies from JSON string
* @param json - JSON string containing dependencies
* @returns Map of library name to version specification
*/
parseDependencies(json: string | null): Map<string, string> {
const dependencies = new Map<string, string>();
if (!json || json.trim() === '') {
return dependencies;
}
try {
const parsed = JSON.parse(json);
if (typeof parsed === 'object' && parsed !== null) {
for (const [name, version] of Object.entries(parsed)) {
if (typeof version === 'string') {
dependencies.set(name, version);
}
}
}
} catch (error) {
console.error('Failed to parse dependencies JSON:', error);
}
return dependencies;
}
/**
* Build a dependency graph from a collection of libraries
* @param libraries - All available libraries
* @returns Dependency graph structure
*/
buildDependencyGraph(libraries: ComponentLibraryEntity[]): DependencyGraph {
const nodes = new Map<string, DependencyNode>();
const roots = new Set<string>();
// First pass: create nodes for all libraries
for (const library of libraries) {
const dependencies = this.parseDependencies(library.Dependencies);
nodes.set(library.Name, {
library,
dependencies,
dependents: new Set()
});
// Initially assume all are roots
roots.add(library.Name);
}
// Second pass: establish dependency relationships
for (const [name, node] of nodes) {
for (const [depName, depVersion] of node.dependencies) {
const depNode = nodes.get(depName);
if (depNode) {
// This library depends on depName, so depName is not a root
roots.delete(depName);
// Add this library as a dependent of depName
depNode.dependents.add(name);
} else if (this.debug) {
console.warn(`Dependency '${depName}' not found for library '${name}'`);
}
}
}
return { nodes, roots };
}
/**
* Detect circular dependencies in the graph
* @param graph - Dependency graph
* @returns Array of cycles found
*/
detectCycles(graph: DependencyGraph): string[][] {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
const path: string[] = [];
const detectCyclesUtil = (nodeName: string): void => {
visited.add(nodeName);
recursionStack.add(nodeName);
path.push(nodeName);
const node = graph.nodes.get(nodeName);
if (node) {
for (const [depName] of node.dependencies) {
if (!visited.has(depName)) {
detectCyclesUtil(depName);
} else if (recursionStack.has(depName)) {
// Found a cycle
const cycleStartIndex = path.indexOf(depName);
const cycle = [...path.slice(cycleStartIndex), depName];
cycles.push(cycle);
}
}
}
path.pop();
recursionStack.delete(nodeName);
};
// Check all nodes
for (const nodeName of graph.nodes.keys()) {
if (!visited.has(nodeName)) {
detectCyclesUtil(nodeName);
}
}
return cycles;
}
/**
* Perform topological sort to determine load order
* @param graph - Dependency graph
* @returns Sorted array of libraries to load in order
*/
topologicalSort(graph: DependencyGraph): ComponentLibraryEntity[] {
const result: ComponentLibraryEntity[] = [];
const visited = new Set<string>();
const tempMarked = new Set<string>();
const visit = (nodeName: string): boolean => {
if (tempMarked.has(nodeName)) {
// Cycle detected
return false;
}
if (visited.has(nodeName)) {
return true;
}
tempMarked.add(nodeName);
const node = graph.nodes.get(nodeName);
if (node) {
// Visit dependencies first
for (const [depName] of node.dependencies) {
if (graph.nodes.has(depName)) {
if (!visit(depName)) {
return false;
}
}
}
result.push(node.library);
}
tempMarked.delete(nodeName);
visited.add(nodeName);
return true;
};
// Start with all nodes
for (const nodeName of graph.nodes.keys()) {
if (!visited.has(nodeName)) {
if (!visit(nodeName)) {
console.error(`Cycle detected involving library: ${nodeName}`);
}
}
}
return result;
}
/**
* Parse a semver version string
* @param version - Version string (e.g., "1.2.3")
* @returns Parsed version object
*/
private parseVersion(version: string): ParsedVersion | null {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(.+))?$/);
if (!match) {
return null;
}
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
prerelease: match[4],
build: match[5]
};
}
/**
* Parse a version specification into a range
* @param spec - Version specification (e.g., "^1.2.3", "~1.2.0", "1.2.3")
* @returns Parsed version range
*/
private parseVersionRange(spec: string): VersionRange {
if (spec === '*' || spec === '' || spec === 'latest') {
return { type: 'any', raw: spec };
}
// Tilde range (~1.2.3)
if (spec.startsWith('~')) {
const version = this.parseVersion(spec.substring(1));
return {
type: 'tilde',
version: version || undefined,
raw: spec
};
}
// Caret range (^1.2.3)
if (spec.startsWith('^')) {
const version = this.parseVersion(spec.substring(1));
return {
type: 'caret',
version: version || undefined,
raw: spec
};
}
// Exact version
const version = this.parseVersion(spec);
if (version) {
return {
type: 'exact',
version,
raw: spec
};
}
// Default to any if we can't parse
return { type: 'any', raw: spec };
}
/**
* Check if a version satisfies a version range
* @param version - Version to check
* @param range - Version range specification
* @returns True if version satisfies range
*/
private versionSatisfiesRange(version: ParsedVersion, range: VersionRange): boolean {
if (range.type === 'any') {
return true;
}
if (!range.version) {
return false;
}
const rangeVer = range.version;
switch (range.type) {
case 'exact':
return version.major === rangeVer.major &&
version.minor === rangeVer.minor &&
version.patch === rangeVer.patch;
case 'tilde': // ~1.2.3 means >=1.2.3 <1.3.0
if (version.major !== rangeVer.major) return false;
if (version.minor !== rangeVer.minor) return false;
return version.patch >= rangeVer.patch;
case 'caret': // ^1.2.3 means >=1.2.3 <2.0.0 (for 1.x.x)
if (rangeVer.major === 0) {
// ^0.x.y behaves like ~0.x.y
if (version.major !== 0) return false;
if (rangeVer.minor === 0) {
// ^0.0.x means exactly 0.0.x
return version.minor === 0 && version.patch === rangeVer.patch;
}
// ^0.x.y means >=0.x.y <0.(x+1).0
if (version.minor !== rangeVer.minor) return false;
return version.patch >= rangeVer.patch;
}
// Normal caret for major > 0
if (version.major !== rangeVer.major) return false;
if (version.minor < rangeVer.minor) return false;
if (version.minor === rangeVer.minor) {
return version.patch >= rangeVer.patch;
}
return true;
default:
return false;
}
}
/**
* Compare two versions for sorting
* @returns -1 if a < b, 0 if a === b, 1 if a > b
*/
private compareVersions(a: ParsedVersion, b: ParsedVersion): number {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
if (a.patch !== b.patch) return a.patch - b.patch;
// Handle prerelease versions
if (!a.prerelease && b.prerelease) return 1; // a is newer
if (a.prerelease && !b.prerelease) return -1; // b is newer
if (a.prerelease && b.prerelease) {
return a.prerelease.localeCompare(b.prerelease);
}
return 0;
}
/**
* Resolve version conflicts using NPM-style resolution
* @param requirements - Array of version requirements
* @param availableLibraries - All available libraries to choose from
* @returns Resolved version information
*/
resolveVersionConflicts(
requirements: VersionRequirement[],
availableLibraries: ComponentLibraryEntity[]
): ResolvedVersion {
if (requirements.length === 0) {
throw new Error('No version requirements provided');
}
const libraryName = requirements[0].library;
const warnings: string[] = [];
// Find all available versions for this library
const availableVersions = availableLibraries
.filter(lib => lib.Name === libraryName && lib.Version)
.map(lib => ({
library: lib,
version: this.parseVersion(lib.Version!)
}))
.filter(item => item.version !== null)
.sort((a, b) => this.compareVersions(b.version!, a.version!)); // Sort descending
if (availableVersions.length === 0) {
throw new Error(`No versions available for library '${libraryName}'`);
}
// Parse all requirements
const ranges = requirements.map(req => ({
...req,
range: this.parseVersionRange(req.versionSpec)
}));
// Find the highest version that satisfies all requirements
for (const { library, version } of availableVersions) {
let satisfiesAll = true;
const satisfiedRequirements: VersionRequirement[] = [];
for (const req of ranges) {
if (this.versionSatisfiesRange(version!, req.range)) {
satisfiedRequirements.push(req);
} else {
satisfiesAll = false;
warnings.push(
`Version ${library.Version} does not satisfy '${req.versionSpec}' required by ${req.requestedBy}`
);
break;
}
}
if (satisfiesAll) {
return {
library: libraryName,
version: library.Version!,
satisfies: requirements,
warnings: warnings.length > 0 ? warnings : undefined
};
}
}
// No version satisfies all requirements
// Return the latest version with warnings
const latest = availableVersions[0];
warnings.push(
`Could not find a version that satisfies all requirements. Using ${latest.library.Version}`
);
return {
library: libraryName,
version: latest.library.Version!,
satisfies: [],
warnings
};
}
/**
* Get the load order for a set of requested libraries
* @param requestedLibs - Library names requested
* @param allLibs - All available libraries
* @param options - Resolution options
* @returns Load order result
*/
getLoadOrder(
requestedLibs: string[],
allLibs: ComponentLibraryEntity[],
options?: DependencyResolutionOptions
): LoadOrderResult {
const errors: string[] = [];
const warnings: string[] = [];
// Filter out null, undefined, and non-string values from requestedLibs
const validRequestedLibs = requestedLibs.filter(lib => {
if (!lib || typeof lib !== 'string') {
const warning = `Invalid library name: ${lib} (type: ${typeof lib})`;
warnings.push(warning);
if (this.debug || options?.debug) {
console.warn(`⚠️ ${warning}`);
}
return false;
}
return true;
});
if (this.debug || options?.debug) {
console.log('🔍 Getting load order for requested libraries:');
console.log(' 📝 Requested (raw):', requestedLibs);
console.log(' 📝 Requested (valid):', validRequestedLibs);
console.log(' 📚 Total available libraries:', allLibs.length);
}
// Build a map for quick lookup (case-insensitive)
const libMap = new Map<string, ComponentLibraryEntity[]>();
for (const lib of allLibs) {
if (!lib?.Name) {
warnings.push(`Library with missing name found in available libraries`);
continue;
}
const key = lib.Name.toLowerCase();
if (!libMap.has(key)) {
libMap.set(key, []);
}
libMap.get(key)!.push(lib);
}
// Collect all libraries needed (requested + their dependencies)
const needed = new Set<string>();
const toProcess = [...validRequestedLibs];
const processed = new Set<string>();
const versionRequirements = new Map<string, VersionRequirement[]>();
let depth = 0;
const maxDepth = options?.maxDepth || 10;
while (toProcess.length > 0 && depth < maxDepth) {
const current = toProcess.shift()!;
// Extra safety check for null/undefined
if (!current || typeof current !== 'string') {
const warning = `Unexpected invalid library name during processing: ${current}`;
warnings.push(warning);
if (this.debug || options?.debug) {
console.warn(`⚠️ ${warning}`);
}
continue;
}
if (processed.has(current)) continue;
processed.add(current);
needed.add(current);
// Find the library (case-insensitive lookup)
const libVersions = libMap.get(current.toLowerCase());
if (!libVersions || libVersions.length === 0) {
if (this.debug || options?.debug) {
console.log(` ❌ Library '${current}' not found in available libraries`);
}
errors.push(`Library '${current}' not found`);
continue;
}
// For now, use the first version found (should be resolved properly)
const lib = libVersions[0];
const deps = this.parseDependencies(lib.Dependencies);
if ((this.debug || options?.debug) && deps.size > 0) {
console.log(` 📌 ${current} requires:`, Array.from(deps.entries()));
}
// Process dependencies
for (const [depName, depVersion] of deps) {
needed.add(depName);
// Track version requirements
if (!versionRequirements.has(depName)) {
versionRequirements.set(depName, []);
}
versionRequirements.get(depName)!.push({
library: depName,
versionSpec: depVersion,
requestedBy: current
});
if (!processed.has(depName)) {
toProcess.push(depName);
}
}
depth++;
}
if (depth >= maxDepth) {
warnings.push(`Maximum dependency depth (${maxDepth}) reached`);
}
// Resolve version conflicts for each library
const resolvedLibraries: ComponentLibraryEntity[] = [];
for (const libName of needed) {
const requirements = versionRequirements.get(libName) || [];
const versions = libMap.get(libName.toLowerCase()) || [];
if (versions.length === 0) {
errors.push(`Library '${libName}' not found`);
continue;
}
if (requirements.length > 0) {
try {
const resolved = this.resolveVersionConflicts(requirements, versions);
const selectedLib = versions.find(lib => lib.Version === resolved.version);
if (selectedLib) {
resolvedLibraries.push(selectedLib);
if (resolved.warnings) {
warnings.push(...resolved.warnings);
}
}
} catch (error: any) {
errors.push(error.message);
// Use first available version as fallback
resolvedLibraries.push(versions[0]);
}
} else {
// No specific requirements, use the first (default) version
resolvedLibraries.push(versions[0]);
}
}
// Build dependency graph with resolved libraries
const graph = this.buildDependencyGraph(resolvedLibraries);
// Check for cycles
const cycles = this.detectCycles(graph);
if (cycles.length > 0) {
errors.push(`Circular dependencies detected: ${cycles.map(c => c.join(' -> ')).join(', ')}`);
return {
success: false,
cycles,
errors,
warnings: warnings.length > 0 ? warnings : undefined
};
}
// Perform topological sort
const sorted = this.topologicalSort(graph);
if (this.debug || options?.debug) {
console.log('✅ Load order determined:', sorted.map(lib => `${lib.Name}@${lib.Version}`));
}
return {
success: errors.length === 0,
order: sorted,
errors: errors.length > 0 ? errors : undefined,
warnings: warnings.length > 0 ? warnings : undefined
};
}
/**
* Get direct dependencies of a library
* @param library - Library to get dependencies for
* @returns Map of dependency names to version specs
*/
getDirectDependencies(library: ComponentLibraryEntity): Map<string, string> {
return this.parseDependencies(library.Dependencies);
}
/**
* Get all transitive dependencies of a library
* @param libraryName - Library name to analyze
* @param allLibs - All available libraries
* @param maxDepth - Maximum depth to traverse
* @returns Set of all dependency names (including transitive)
*/
getTransitiveDependencies(
libraryName: string,
allLibs: ComponentLibraryEntity[],
maxDepth: number = 10
): Set<string> {
const dependencies = new Set<string>();
const toProcess = [libraryName];
const processed = new Set<string>();
let depth = 0;
// Build lookup map (case-insensitive)
const libMap = new Map<string, ComponentLibraryEntity>();
for (const lib of allLibs) {
libMap.set(lib.Name.toLowerCase(), lib);
}
while (toProcess.length > 0 && depth < maxDepth) {
const current = toProcess.shift()!;
if (processed.has(current)) continue;
processed.add(current);
const lib = libMap.get(current.toLowerCase());
if (!lib) continue;
const deps = this.parseDependencies(lib.Dependencies);
for (const [depName] of deps) {
dependencies.add(depName);
if (!processed.has(depName)) {
toProcess.push(depName);
}
}
depth++;
}
return dependencies;
}
}