import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import { register } from 'node:module'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; // Import shared core functions const { parseRemoteIOR, parsePackageJsonAsync, fetchFromGitHub, fetchFromGitea, fetchFromIPFS, generateCacheHash, } = require('../lib/ior-core.js'); register('./src/loaders/NodeImportLoader.ts', pathToFileURL('./')); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = process.cwd(); // Use current working directory since this is a bin script const DEBUG = process.env.DEBUG === 'true'; // Create a simple console logger to avoid circular dependency issues const logger = { debug: (...args: unknown[]) => DEBUG && console.log('[NodeImportLoader:DEBUG]', ...args), info: (...args: unknown[]) => console.log('[NodeImportLoader:INFO]', ...args), warn: (...args: unknown[]) => console.warn('[NodeImportLoader:WARN]', ...args), error: (...args: unknown[]) => console.error('[NodeImportLoader:ERROR]', ...args), }; // Type definitions for Node.js loader API interface ResolveContext { parentURL?: string; conditions?: string[]; importAttributes?: Record; } interface LoadContext { format?: string; importAttributes?: Record; } type ResolveResult = { url: string; format?: string; shortCircuit?: boolean; type?: string; }; type LoadResult = { format: string; source: string | ArrayBuffer; shortCircuit?: boolean; }; type DefaultResolve = ( specifier: string, context: ResolveContext, defaultResolve: DefaultResolve, ) => Promise; type DefaultLoad = ( url: string, context: LoadContext, defaultLoad: DefaultLoad, ) => Promise; const componentCache = new Map(); const remoteCache = new Map(); const typeCache = new Map(); const processedTypes = new Set(); interface RemoteCacheEntry { path: string; timestamp: number; ttl: number; // Time to live in milliseconds } interface TypeCacheEntry { content: string; timestamp: number; } interface PackageMetadata { name: string; version: string; description?: string; main?: string; metatrom?: { ior: string; capabilities?: { p2p?: boolean; contracts?: boolean; viewer?: boolean; sync?: boolean; }; }; dependencies?: Record; optionalDependencies?: Record; } /** * Fetch type definitions for a component */ async function fetchTypeDefinitions( config: any, componentName: string, version: string, ): Promise { const baseUrl = config.type === 'github' ? `https://raw.githubusercontent.com/${config.owner}/${config.repository}/${config.branch || 'main'}` : config.type === 'gitea' ? `https://${config.instance}/${config.owner}/${config.repository}/raw/branch/${config.branch || 'main'}` : null; if (!baseUrl) return null; const componentPath = `src/components/${componentName}/${version}`; const possiblePaths = [ `${componentPath}/${componentName}.d.ts`, `${componentPath}/index.d.ts`, `${componentPath}/types.d.ts`, ]; for (const typePath of possiblePaths) { try { const response = await fetch(`${baseUrl}/${typePath}`); if (response.ok) { return await response.text(); } } catch { continue; } } return null; } /** * Generate and save type declarations */ async function generateTypeDeclaration( ior: string, isRemote: boolean = false, ): Promise { // Avoid duplicate processing const cacheKey = `${isRemote ? 'remote:' : 'local:'}${ior}`; if (processedTypes.has(cacheKey)) { return; } try { let typeContent: string | null = null; if (isRemote) { // For remote components, fetch type definitions const config = parseRemoteIOR(`ior:${ior}`); if (config) { const parts = ior.split(':'); const componentAndVersion = parts[parts.length - 1]; const [componentName, version] = componentAndVersion.split('@'); typeContent = await fetchTypeDefinitions( config, componentName, version, ); } } else { // For local components, read the .d.ts file const atIndex = ior.lastIndexOf('@'); const componentFullName = ior.substring(0, atIndex); const version = ior.substring(atIndex + 1); const componentName = componentFullName.split('.').pop(); if (!componentName) { throw new Error(`Invalid IOR format: ${ior}`); } const componentsDir = path.join(projectRoot, 'src', 'components'); const possiblePaths = [ path.join( componentsDir, componentName, version, `${componentName}.d.ts`, ), path.join(componentsDir, componentName, version, 'index.d.ts'), path.join(componentsDir, componentName, version, 'types.d.ts'), ]; for (const dtsPath of possiblePaths) { try { typeContent = await fs.readFile(dtsPath, 'utf-8'); break; } catch { continue; } } } if (typeContent) { // Check if the type content already contains the correct module declaration const moduleDeclaration = `ior:${isRemote ? '' : 'esm:'}${ior}`; // Only process if the content doesn't already have multiple module declarations // or if it doesn't contain the target module declaration if (typeContent.includes(`declare module "${moduleDeclaration}"`) || typeContent.includes(`declare module '${moduleDeclaration}'`)) { // Content already has the correct module declaration, use as-is // This preserves any additional module declarations (like 'hypercore') } else if (!typeContent.includes('declare module')) { // No module declaration at all, wrap the content typeContent = `declare module "${moduleDeclaration}" {\n${typeContent}\n}\n`; } // If it has other module declarations but not our target, leave it as-is // This preserves files with multiple module declarations // Save to type cache typeCache.set(cacheKey, { content: typeContent, timestamp: Date.now(), }); // Write to global types file await updateGlobalTypes(); } processedTypes.add(cacheKey); } catch (err) { DEBUG && logger.debug(`Failed to generate types for ${ior}:`, err); } } /** * Update global types file with all cached types */ async function updateGlobalTypes(): Promise { const typesDir = path.join(projectRoot, 'src', 'types'); const globalTypesPath = path.join(typesDir, 'auto-generated.d.ts'); let content = '// Auto-generated type declarations by NodeImportLoader\n'; content += `// Generated at: ${new Date().toISOString()}\n`; content += '// DO NOT EDIT - This file is automatically maintained\n\n'; // Add all cached type definitions for (const [key, entry] of typeCache.entries()) { content += `// ${key}\n`; content += entry.content; content += '\n\n'; } // Ensure directory exists await fs.mkdir(typesDir, { recursive: true }); // Write the file await fs.writeFile(globalTypesPath, content, 'utf-8'); DEBUG && logger.debug(`Updated global types: ${globalTypesPath}`); } /** * Cache remote component locally */ async function cacheRemoteComponent( ior: string, content: string, ttl = 3600000, // 1 hour default TTL ): Promise { const hash = generateCacheHash(ior); const cacheDir = path.join(tmpdir(), 'metatrom-components', 'remote'); const cachePath = path.join(cacheDir, `${hash}.ts`); // Create cache directory if it doesn't exist await fs.mkdir(cacheDir, { recursive: true }); // Write content to cache await fs.writeFile(cachePath, content, 'utf-8'); // Update cache map remoteCache.set(ior, { path: cachePath, timestamp: Date.now(), ttl, }); DEBUG && logger.debug(`Cached remote component: ${ior} -> ${cachePath}`); return cachePath; } /** * Get cached remote component if valid */ async function getCachedRemoteComponent(ior: string): Promise { const entry = remoteCache.get(ior); if (!entry) { return null; } // Check if cache entry is still valid if (Date.now() - entry.timestamp > entry.ttl) { remoteCache.delete(ior); // Try to delete the file (non-blocking) fs.unlink(entry.path).catch(() => { logger.error(`Error deleting cached file ${entry.path}`); }); return null; } // Verify file still exists try { await fs.access(entry.path); DEBUG && logger.debug(`Using cached component: ${ior}`); return entry.path; } catch { remoteCache.delete(ior); return null; } } /** * Fetch remote component based on IOR */ async function fetchRemoteComponent(ior: string): Promise { // Check cache first const cached = await getCachedRemoteComponent(ior); if (cached) { return cached; } const config = parseRemoteIOR(ior); if (!config) { throw new Error(`Invalid remote IOR format: ${ior}`); } // Extract component name and version from IOR const parts = ior.split(':'); const componentAndVersion = parts[parts.length - 1]; const [componentName, version] = componentAndVersion.split('@'); if (!componentName || !version) { throw new Error(`Missing component name or version in IOR: ${ior}`); } let content: string; switch (config.type) { case 'github': const githubResult = await fetchFromGitHub(config); // For Node.js, we need to extract the content differently since we're dealing with archives // For now, fetch just the main file directly (simplified approach) content = await fetchComponentFile(config, componentName, version); break; case 'gitea': const giteaResult = await fetchFromGitea(config); // Similarly for Gitea content = await fetchComponentFile(config, componentName, version); break; case 'ipfs': const ipfsResult = await fetchFromIPFS(config); content = ipfsResult.content; break; case 'p2p': throw new Error(`P2P fetching not yet implemented. PeerID: ${config.peerId}`); default: throw new Error(`Unsupported remote type: ${config.type}`); } // Cache the fetched component const cachedPath = await cacheRemoteComponent(ior, content); return cachedPath; } /** * Fetch component file directly (simplified approach for Node.js) */ async function fetchComponentFile( config: any, componentName: string, version: string, ): Promise { let baseUrl: string; if (config.type === 'github') { baseUrl = `https://raw.githubusercontent.com/${config.owner}/${config.repository}/main`; } else if (config.type === 'gitea') { baseUrl = `https://${config.instance}/${config.owner}/${config.repository}/raw/branch/main`; } else { throw new Error(`Unsupported type for direct fetch: ${config.type}`); } const componentPath = `src/components/${componentName}/${version}`; // Try to fetch the main component file const possibleFiles = [ `${componentPath}/index.ts`, `${componentPath}/${componentName}.ts`, `${componentPath}/index.tsx`, `${componentPath}/${componentName}.tsx`, ]; for (const file of possibleFiles) { try { const response = await fetch(`${baseUrl}/${file}`); if (response.ok) { return await response.text(); } } catch { // Try next file } } throw new Error(`Could not fetch component file for ${componentName}@${version}`); } /** * Get component path for local components using package.json */ async function getLocalComponentPath(ior: string): Promise { if (componentCache.has(ior)) return componentCache.get(ior)!; // IOR format: com.metatrom.examples.componentname@version const atIndex = ior.lastIndexOf('@'); if (atIndex === -1) { throw new Error(`Invalid IOR format (missing @): ${ior}`); } const componentFullName = ior.substring(0, atIndex); const version = ior.substring(atIndex + 1); if (!componentFullName || !version) { throw new Error(`Invalid IOR format: ${ior}`); } // Search for components with matching package.json const componentsDir = path.join(projectRoot, 'src', 'components'); try { const componentDirs = await fs.readdir(componentsDir, { withFileTypes: true, }); for (const dir of componentDirs) { if (dir.isDirectory()) { const versionDir = path.join(componentsDir, dir.name, version); const packagePath = path.join(versionDir, 'package.json'); try { const pkg = await parsePackageJsonAsync(packagePath); if (pkg && pkg.metatrom?.ior === ior) { // Found matching component - prefer index.ts if it exists const indexPath = path.join(versionDir, 'index.ts'); try { await fs.access(indexPath); DEBUG && logger.debug(`Mapped IOR ${ior} to ${indexPath}`); componentCache.set(ior, indexPath); return indexPath; } catch { // Fall back to main file specified in package.json const mainFile = pkg.main || `${dir.name}.ts`; const tsPath = path.join(versionDir, mainFile); await fs.access(tsPath); DEBUG && logger.debug(`Mapped IOR ${ior} to ${tsPath}`); componentCache.set(ior, tsPath); return tsPath; } } } catch (error: unknown) { DEBUG && logger.debug( `Skipping ${packagePath}:`, error instanceof Error ? error.message : String(error), ); } } } } catch (err) { throw new Error(`Failed to read components directory: ${err}`); } throw new Error(`No component found for IOR ${ior}`); } /** * Main resolve function for Node.js module resolution */ export async function resolve( specifier: string, context: ResolveContext, defaultResolve: DefaultResolve, ): Promise { DEBUG && logger.debug( `Resolving specifier: ${specifier}, parentURL: ${context.parentURL}`, ); if (specifier.startsWith('ior:')) { // Check if it's a remote IOR const remoteConfig = parseRemoteIOR(specifier); if (remoteConfig) { // Handle remote component try { const tsPath = await fetchRemoteComponent(specifier); // Generate type definitions for remote component const iorPart = specifier.substring(4); // Remove "ior:" await generateTypeDeclaration(iorPart, true); DEBUG && logger.debug(`Resolved remote IOR ${specifier} to ${tsPath}`); return { url: pathToFileURL(tsPath).href, format: 'module', shortCircuit: true, type: 'typescript', }; } catch (error) { logger.error(`Failed to fetch remote component ${specifier}:`, error); throw error; } } else { // Handle local component with format: ior:esm:com.metatrom.examples.component@version // Remove the "ior:" prefix and extract the IOR const iorWithPrefix = specifier.substring(4); // Remove "ior:" // Check if it has "esm:" prefix let actualIor = iorWithPrefix; if (iorWithPrefix.startsWith('esm:')) { actualIor = iorWithPrefix.substring(4); // Remove "esm:" } const tsPath = await getLocalComponentPath(actualIor); // Generate type definitions for local component await generateTypeDeclaration(actualIor, false); DEBUG && logger.debug(`Resolving local IOR ${actualIor} to ${tsPath}`); return { url: pathToFileURL(tsPath).href, format: 'module', shortCircuit: true, type: 'typescript', }; } } // Handle relative .ts imports if (specifier.startsWith('./') || specifier.startsWith('../')) { const parentURL = context.parentURL ? fileURLToPath(context.parentURL) : __dirname; const baseDir = path.dirname(parentURL); // Try with .ts extension first const tsPath = path.resolve(baseDir, `${specifier}.ts`); try { await fs.access(tsPath); DEBUG && logger.debug(`Resolving relative .ts file: ${tsPath}`); return { url: pathToFileURL(tsPath).href, format: 'module', shortCircuit: true, type: 'typescript', }; } catch { // Try without adding extension (file might already have .ts) const directPath = path.resolve(baseDir, specifier); try { await fs.access(directPath); if (directPath.endsWith('.ts')) { DEBUG && logger.debug(`Resolving relative .ts file: ${directPath}`); return { url: pathToFileURL(directPath).href, format: 'module', shortCircuit: true, type: 'typescript', }; } } catch { DEBUG && logger.debug( `No .ts file found for ${specifier}, falling back to default resolver`, ); } } } return defaultResolve(specifier, context, defaultResolve); } /** * Load and transpile TypeScript files */ export async function load( url: string, context: LoadContext, defaultLoad: DefaultLoad, ): Promise { if (url.endsWith('.ts')) { const filePath = fileURLToPath(url); DEBUG && logger.debug(`Loading TypeScript file: ${filePath}`); try { const code = await fs.readFile(filePath, 'utf-8'); const transpiled = transpileModule(code, { compilerOptions: { module: ModuleKind.ESNext, target: ScriptTarget.ESNext, noEmitHelpers: true, importHelpers: false, strict: true, sourceMap: true, }, }); return { format: 'module', source: transpiled.outputText, shortCircuit: true, }; } catch (error) { logger.error(`Failed to transpile ${filePath}:`, error); throw error; } } return defaultLoad(url, context, defaultLoad); } /** * Clear cache for a specific IOR or all cached components */ export function clearCache(ior?: string) { if (ior) { componentCache.delete(ior); remoteCache.delete(ior); } else { componentCache.clear(); remoteCache.clear(); } } /** * Set cache TTL for remote components */ export function setCacheTTL(ttl: number) { // This could be made more sophisticated with per-source TTL for (const entry of remoteCache.values()) { entry.ttl = ttl; } } /** * Preload remote components for faster access */ export async function preloadComponent(ior: string): Promise { try { await fetchRemoteComponent(ior); DEBUG && logger.debug(`Preloaded component: ${ior}`); } catch (error) { logger.error(`Failed to preload component ${ior}:`, error); } } /** * Initialize type generation for all local components */ async function initializeLocalTypes(): Promise { const componentsDir = path.join(projectRoot, 'src', 'components'); try { const dirs = await fs.readdir(componentsDir, { withFileTypes: true }); for (const dir of dirs) { if (dir.isDirectory()) { const componentDir = path.join(componentsDir, dir.name); const versions = await fs.readdir(componentDir, { withFileTypes: true, }); for (const version of versions) { if (version.isDirectory()) { const packagePath = path.join( componentDir, version.name, 'package.json', ); try { const packageContent = await fs.readFile(packagePath, 'utf-8'); const pkg = JSON.parse(packageContent) as PackageMetadata; if (pkg.metatrom?.ior) { await generateTypeDeclaration(pkg.metatrom.ior, false); } } catch { // Skip if no package.json or invalid } } } } } DEBUG && logger.debug('Initialized type definitions for local components'); } catch (error) { DEBUG && logger.debug('Failed to initialize local types:', error); } } // Auto-initialize types on first import (async () => { await initializeLocalTypes(); })();