From e1fb6e6accc19131e7f26604fe9144e18cf69a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Da=C3=9Fler?= Date: Fri, 29 Aug 2025 04:22:52 +0200 Subject: [PATCH] Initial commit --- .claude/settings.local.json | 13 + .gitignore | 97 +++++ README.md | 349 ++++++++++++++++ bin/NodeImportLoader.ts | 725 ++++++++++++++++++++++++++++++++++ bin/prebuild-ior-types.js | 245 ++++++++++++ index.d.ts | 91 +++++ index.js | 21 + lib/fetch-remote-component.js | 26 ++ lib/ior-core.js | 440 +++++++++++++++++++++ lib/metro-ior-resolver.js | 580 +++++++++++++++++++++++++++ package.json | 78 ++++ 11 files changed, 2665 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bin/NodeImportLoader.ts create mode 100644 bin/prebuild-ior-types.js create mode 100644 index.d.ts create mode 100644 index.js create mode 100644 lib/fetch-remote-component.js create mode 100644 lib/ior-core.js create mode 100644 lib/metro-ior-resolver.js create mode 100644 package.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..fbbd505 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Read(/Users/chris/Workspace/dev/Metatrom/examples/Libp2pMDNSApp/**)", + "Read(/Users/chris/Workspace/dev/Metatrom/examples/Libp2pMDNSApp/**)", + "Read(/Users/chris/Workspace/dev/Metatrom/examples/Libp2pMDNSApp/**)", + "Read(/Users/chris/Workspace/dev/Metatrom/examples/Libp2pMDNSApp/scripts/**)", + "Read(/Users/chris/Workspace/dev/Metatrom/templates/components/**)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00efece --- /dev/null +++ b/.gitignore @@ -0,0 +1,97 @@ +# Created by https://www.toptal.com/developers/gitignore/api/macos,windows,linux,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,linux,visualstudiocode + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/macos,windows,linux,visualstudiocode diff --git a/README.md b/README.md new file mode 100644 index 0000000..a698542 --- /dev/null +++ b/README.md @@ -0,0 +1,349 @@ +# @metatrom/ior-resolver + +IOR (Interoperable Object Reference) resolver for Metro bundler and Node.js with support for local and remote component fetching. + +## Features + +- 🚀 **Metro Bundler Support** - Seamlessly integrate IOR resolution with React Native projects +- 🌐 **Remote Component Fetching** - Load components from GitHub, Gitea, IPFS, and P2P networks +- 📦 **Local Component Resolution** - Resolve local components using package.json metadata +- 🔄 **Hot Reload Support** - Automatic mapping updates during development +- 📝 **TypeScript Support** - Auto-generate type declarations for IOR components +- ⚡ **Caching** - Intelligent caching of remote components for better performance +- 🛠️ **Pre-build Script** - Generate types before development starts +- 🎯 **Shared Core** - Consolidated functions to avoid duplication + +## Installation + +```bash +npm install @metatrom/ior-resolver +``` + +## Project Structure + +``` +@metatrom/ior-resolver/ +├── bin/ # Executable scripts +│ ├── prebuild-ior-types.js # Pre-build script for type generation +│ └── NodeImportLoader.ts # Node.js loader implementation +├── lib/ # Core library files +│ ├── ior-core.js # Shared core functions +│ ├── metro-ior-resolver.js # Metro resolver implementation +│ └── fetch-remote-component.js # Remote fetching utility +├── index.js # Main entry point +├── index.d.ts # TypeScript definitions +└── package.json +``` + +## Usage + +### Metro Configuration (React Native) + +In your `metro.config.js`: + +```javascript +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +const { createHotReloadableResolver } = require('@metatrom/ior-resolver'); + +const defaultConfig = getDefaultConfig(__dirname); + +const config = { + resolver: { + resolveRequest: createHotReloadableResolver(__dirname), + }, +}; + +module.exports = mergeConfig(defaultConfig, config); +``` + +### Node.js Loader + +For Node.js projects using TypeScript and IOR imports: + +```javascript +// In your loader file +import { resolve, load } from '@metatrom/ior-resolver/node'; + +// Or register the loader +import { register } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +register('@metatrom/ior-resolver/node', pathToFileURL('./')); +``` + +### Pre-build Script + +Add to your `package.json` scripts to generate TypeScript declarations before development: + +```json +{ + "scripts": { + "prebuild": "prebuild-ior-types", + "dev": "npm run prebuild && npm start" + } +} +``` + +Or run directly: + +```bash +npx prebuild-ior-types +``` + +## IOR Format + +IOR (Interoperable Object Reference) provides a unified way to reference components across different sources: + +### Local Components +```javascript +import Logger from 'ior:esm:com.metatrom.examples.logger@1.0.0'; +``` + +### Remote Components + +#### GitHub +```javascript +import Component from 'ior:github:owner/repo:componentname@version'; +``` + +#### Gitea +```javascript +import Component from 'ior:gitea:instance.com:owner/repo:componentname@version'; +// Alternative format for repos with slashes in name: +import Component from 'ior:gitea:instance.com:owner/repo/componentname@version'; +``` + +#### IPFS +```javascript +import Component from 'ior:ipfs:QmHash:componentname@version'; +``` + +#### P2P (Coming Soon) +```javascript +import Component from 'ior:p2p:peer-id:componentname@version'; +``` + +## Component Structure + +Local components should follow this structure: + +``` +src/components/ +└── componentname/ + └── version/ + ├── package.json + ├── index.ts (or componentname.ts) + └── componentname.d.ts (optional) +``` + +### package.json Example + +```json +{ + "name": "logger", + "version": "1.0.0", + "main": "index.ts", + "metatrom": { + "ior": "com.metatrom.examples.logger@1.0.0" + } +} +``` + +## API Reference + +### Core Functions (ior-core.js) + +The shared core module provides common functionality used by both Metro and Node.js implementations: + +- `parseRemoteIOR(ior)` - Parse remote IOR strings +- `parsePackageJson(path)` - Synchronously parse package.json +- `parsePackageJsonAsync(path)` - Asynchronously parse package.json +- `fetchUrl(url)` - Fetch content from URL +- `fetchUrlBuffer(url)` - Fetch URL as Buffer +- `fetchFromGitHub(config)` - Fetch from GitHub +- `fetchFromGitea(config)` - Fetch from Gitea +- `fetchFromIPFS(config)` - Fetch from IPFS +- `extractArchive(buffer, dir)` - Extract tar.gz archives +- `findEntryPoint(dir, name)` - Find component entry point +- `generateCacheHash(ior)` - Generate SHA256 hash for caching +- `buildLocalIORMappings(root)` - Build local component mappings + +### Metro Resolver Functions + +#### `createIORResolver(projectRoot: string)` +Creates a basic IOR resolver for Metro. + +#### `createHotReloadableResolver(projectRoot: string)` +Creates an IOR resolver with hot reload support that watches for package.json changes. + +#### `fetchRemoteComponent(ior: string)` +Fetches and caches a remote component. + +#### `clearRemoteCache()` +Clears all cached remote components. + +### Node.js Loader Functions + +#### `resolve(specifier, context, defaultResolve)` +Node.js loader resolve hook for IOR imports. + +#### `load(url, context, defaultLoad)` +Node.js loader load hook for TypeScript transpilation. + +#### `clearCache(ior?: string)` +Clears cache for a specific IOR or all cached components. + +#### `setCacheTTL(ttl: number)` +Sets the cache time-to-live for remote components. + +#### `preloadComponent(ior: string)` +Pre-fetches a remote component for faster access. + +## Configuration + +### Environment Variables + +- `DEBUG=true` - Enable debug logging for the Node.js loader +- `NODE_ENV=development` - Enable hot reload for Metro resolver + +### Cache Configuration + +The resolver uses different cache locations for different environments: + +- **Metro**: `.ior-cache/remote/` in project root +- **Node.js**: OS temp directory under `metatrom-components/remote/` +- **Default TTL**: 1 hour (3600000ms) + +### Cache Persistence + +Metro resolver maintains a persistent cache mapping in `.ior-cache/mappings.json` for faster startup times. + +## Architecture + +The package is designed with a shared core architecture: + +1. **ior-core.js** - Contains all shared functionality for parsing, fetching, and caching +2. **metro-ior-resolver.js** - Metro-specific implementation using the shared core +3. **NodeImportLoader.ts** - Node.js-specific implementation using the shared core + +This design eliminates code duplication and ensures consistency across different environments. + +## Migration from Direct Implementation + +If you're migrating from having these files directly in your project: + +1. Install the package: + ```bash + npm install @metatrom/ior-resolver + ``` + +2. Update `metro.config.js` to use the package import: + ```javascript + const { createHotReloadableResolver } = require('@metatrom/ior-resolver'); + ``` + +3. Update `package.json` scripts: + ```json + { + "scripts": { + "prebuild": "prebuild-ior-types" + } + } + ``` + +4. Remove the old files from your project: + - `metro-ior-resolver.js` + - `scripts/prebuild-ior-types.js` + - `fetch-remote-component.js` + - `NodeImportLoader.ts` + +## TypeScript Support + +The package automatically generates TypeScript declarations for IOR components: + +- Local components: Types are read from `.d.ts` files in the component directory +- Remote components: Types are fetched from the remote repository +- Auto-generated types are saved to `src/types/auto-generated.d.ts` + +## Security Considerations + +- Remote components are fetched over HTTPS +- Components are cached locally with SHA256 hash verification +- Cache TTL ensures components are refreshed periodically +- No automatic code execution - components must be explicitly imported + +## Performance + +- **Caching**: Components are cached locally to avoid repeated network requests +- **Parallel fetching**: Multiple components can be fetched in parallel +- **Hot reload**: Development mode watches for changes without restart +- **Type generation**: Types are generated asynchronously without blocking + +## Troubleshooting + +### Common Issues + +1. **"Cannot resolve IOR module"** + - Ensure the component exists in the expected location + - Check the IOR format is correct + - Verify network connectivity for remote components + +2. **"Failed to fetch remote component"** + - Check network connectivity + - Verify the repository exists and is accessible + - Ensure the version tag exists + +3. **Types not generating** + - Run `npx prebuild-ior-types` manually + - Check for `.d.ts` files in component directories + - Verify `src/types/` directory exists + +### Debug Mode + +Enable debug logging: + +```bash +DEBUG=true npm start +``` + +### Clear Cache + +If you encounter stale cache issues: + +```javascript +// In your code +const { clearRemoteCache } = require('@metatrom/ior-resolver'); +clearRemoteCache(); +``` + +Or manually delete: +```bash +rm -rf .ior-cache +rm -rf /tmp/metatrom-components # Node.js cache +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +### Development Setup + +1. Clone the repository +2. Install dependencies: `npm install` +3. Build TypeScript: `npm run build` +4. Run tests: `npm test` + +### Code Structure + +- Keep shared functionality in `lib/ior-core.js` +- Environment-specific code in respective files +- Maintain TypeScript types for all exports +- Follow existing code style + +## License + +MIT + +## Support + +For issues and feature requests, please use the [GitHub issue tracker](https://github.com/metatrom/ior-resolver/issues). \ No newline at end of file diff --git a/bin/NodeImportLoader.ts b/bin/NodeImportLoader.ts new file mode 100644 index 0000000..11e5dca --- /dev/null +++ b/bin/NodeImportLoader.ts @@ -0,0 +1,725 @@ +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(); +})(); \ No newline at end of file diff --git a/bin/prebuild-ior-types.js b/bin/prebuild-ior-types.js new file mode 100644 index 0000000..f8db9bc --- /dev/null +++ b/bin/prebuild-ior-types.js @@ -0,0 +1,245 @@ +#!/usr/bin/env node + +/** + * Pre-build script to generate TypeScript declarations for IOR components + * This script: + * 1. Scans the codebase for IOR imports + * 2. Fetches remote components if not cached + * 3. Generates auto-generated.d.ts before development starts + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const { execSync } = require('node:child_process'); + +// Import the resolver functions +const { + fetchRemoteComponent, + parseRemoteIOR, + buildLocalIORMappings +} = require('../lib/metro-ior-resolver.js'); + +const projectRoot = process.cwd(); +const srcDir = path.join(projectRoot, 'src'); + +// Regex patterns to find IOR imports +const IOR_IMPORT_PATTERNS = [ + // ES6 imports: import { X } from 'ior:...' + /import\s+(?:{[^}]+}|\*\s+as\s+\w+|\w+)\s+from\s+['"]ior:([^'"]+)['"]/gm, + // ES6 dynamic imports: import('ior:...') + /import\s*\(\s*['"]ior:([^'"]+)['"]\s*\)/gm, + // CommonJS: require('ior:...') + /require\s*\(\s*['"]ior:([^'"]+)['"]\s*\)/gm, +]; + +/** + * Recursively scan directory for JavaScript/TypeScript files + */ +function* walkFiles(dir) { + const files = fs.readdirSync(dir, { withFileTypes: true }); + + for (const file of files) { + const filePath = path.join(dir, file.name); + + // Skip node_modules, build directories, and cache + if (file.isDirectory()) { + if (!['node_modules', 'build', 'dist', '.ior-cache', '.git'].includes(file.name)) { + yield* walkFiles(filePath); + } + } else if (file.isFile()) { + // Only process JS/TS files + if (/\.(js|jsx|ts|tsx)$/.test(file.name)) { + yield filePath; + } + } + } +} + +/** + * Extract IOR imports from a file + */ +function extractIORImports(filePath) { + const content = fs.readFileSync(filePath, 'utf-8'); + const imports = new Set(); + + for (const pattern of IOR_IMPORT_PATTERNS) { + // Reset regex lastIndex for global patterns + pattern.lastIndex = 0; + let match; + while ((match = pattern.exec(content)) !== null) { + // Add the full IOR string (including 'ior:' prefix) + imports.add(`ior:${match[1]}`); + } + } + + return Array.from(imports); +} + +/** + * Scan entire codebase for IOR imports + */ +function scanForIORImports() { + console.log('[Pre-build] Scanning codebase for IOR imports...'); + const allImports = new Set(); + + // Scan src directory + if (fs.existsSync(srcDir)) { + for (const filePath of walkFiles(srcDir)) { + const imports = extractIORImports(filePath); + if (imports.length > 0) { + console.log(`[Pre-build] Found ${imports.length} IOR imports in ${path.relative(projectRoot, filePath)}`); + imports.forEach(imp => allImports.add(imp)); + } + } + } + + // Also scan App.tsx if it exists at root + const appPath = path.join(projectRoot, 'App.tsx'); + if (fs.existsSync(appPath)) { + const imports = extractIORImports(appPath); + if (imports.length > 0) { + console.log(`[Pre-build] Found ${imports.length} IOR imports in App.tsx`); + imports.forEach(imp => allImports.add(imp)); + } + } + + return Array.from(allImports); +} + +/** + * Categorize imports into local and remote + */ +function categorizeImports(imports) { + const local = []; + const remote = []; + + for (const ior of imports) { + const remoteConfig = parseRemoteIOR(ior); + if (remoteConfig) { + remote.push(ior); + } else { + local.push(ior); + } + } + + return { local, remote }; +} + +/** + * Save IOR mappings to persistent file + */ +function saveIORMappings(mappings) { + const mappingFile = path.join(projectRoot, '.ior-cache', 'mappings.json'); + const cacheDir = path.dirname(mappingFile); + + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + const data = { + version: '1.0', + generated: new Date().toISOString(), + mappings: Object.fromEntries(mappings), + }; + + fs.writeFileSync(mappingFile, JSON.stringify(data, null, 2), 'utf-8'); + console.log(`[Pre-build] Saved IOR mappings to ${mappingFile}`); +} + +/** + * Pre-fetch all remote components + */ +async function prefetchRemoteComponents(remoteImports) { + const mappings = new Map(); + + console.log(`[Pre-build] Pre-fetching ${remoteImports.length} remote components...`); + + for (const ior of remoteImports) { + try { + console.log(`[Pre-build] Fetching: ${ior}`); + const cachedPath = await fetchRemoteComponent(ior); + + // Extract the cache directory from the entry file path + const cacheDir = path.dirname(cachedPath); + const hash = path.basename(cacheDir); + + mappings.set(ior, { + path: cachedPath, + directory: cacheDir, + hash: hash, + timestamp: Date.now(), + }); + + console.log(`[Pre-build] ✓ Cached at: ${hash}`); + } catch (err) { + console.error(`[Pre-build] ✗ Failed to fetch ${ior}:`, err.message); + } + } + + return mappings; +} + +/** + * Main execution + */ +async function main() { + console.log('[Pre-build] Starting IOR type pre-generation...'); + + try { + // Step 1: Scan for IOR imports + const imports = scanForIORImports(); + console.log(`[Pre-build] Found ${imports.length} unique IOR imports`); + + if (imports.length === 0) { + console.log('[Pre-build] No IOR imports found, skipping pre-build'); + return; + } + + // Step 2: Categorize imports + const { local, remote } = categorizeImports(imports); + console.log(`[Pre-build] Local: ${local.length}`); + console.log(`[Pre-build] Remote: ${remote.length}`); + + // Step 3: Build local mappings + if (local.length > 0) { + console.log('[Pre-build] Building local component mappings...'); + const localMappings = buildLocalIORMappings(projectRoot); + console.log(`[Pre-build] Found ${localMappings.size} local components`); + } + + // Step 4: Pre-fetch remote components + if (remote.length > 0) { + const remoteMappings = await prefetchRemoteComponents(remote); + + // Save mappings for cache reconstruction + if (remoteMappings.size > 0) { + saveIORMappings(remoteMappings); + } + } + + // Step 5: The type generation happens automatically during fetchRemoteComponent + // Check if auto-generated.d.ts was created + const typesFile = path.join(projectRoot, 'src', 'types', 'auto-generated.d.ts'); + if (fs.existsSync(typesFile)) { + const stats = fs.statSync(typesFile); + console.log(`[Pre-build] ✓ Generated types file: ${typesFile}`); + console.log(`[Pre-build] Size: ${stats.size} bytes`); + console.log(`[Pre-build] Modified: ${stats.mtime.toISOString()}`); + } + + console.log('[Pre-build] Pre-build completed successfully'); + } catch (err) { + console.error('[Pre-build] Pre-build failed:', err); + process.exit(1); + } +} + +// Run if executed directly +if (require.main === module) { + main().catch(err => { + console.error('[Pre-build] Fatal error:', err); + process.exit(1); + }); +} + +module.exports = { scanForIORImports, prefetchRemoteComponents }; \ No newline at end of file diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..5610840 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,91 @@ +/** + * Type definitions for @metatrom/ior-resolver + */ + +export interface IORResolverContext { + originModulePath: string; + [key: string]: any; +} + +export interface IORResolverResult { + type: 'sourceFile'; + filePath: string; +} + +export type IORResolver = ( + context: IORResolverContext, + moduleName: string, + platform?: string +) => IORResolverResult | undefined; + +export interface RemoteComponentConfig { + type: 'github' | 'gitea' | 'ipfs' | 'p2p'; + owner?: string; + repository?: string; + componentName?: string; + version?: string; + branch?: string; + instance?: string; + hash?: string; + peerId?: string; +} + +/** + * Create an IOR resolver for Metro bundler + * @param projectRoot - The root directory of the project + * @returns A resolver function for Metro + */ +export function createIORResolver(projectRoot: string): IORResolver; + +/** + * Create a hot-reloadable IOR resolver for Metro bundler + * @param projectRoot - The root directory of the project + * @returns A resolver function for Metro with hot reload support + */ +export function createHotReloadableResolver(projectRoot: string): IORResolver; + +/** + * Build mappings for local IOR components + * @param projectRoot - The root directory of the project + * @returns A Map of IOR to file path mappings + */ +export function buildIORMappings(projectRoot: string): Map; + +/** + * Build mappings for local IOR components + * @param projectRoot - The root directory of the project + * @returns A Map of IOR to file path mappings + */ +export function buildLocalIORMappings(projectRoot: string): Map; + +/** + * Fetch a remote component based on IOR + * @param ior - The IOR string to resolve + * @returns Promise resolving to the cached component path + */ +export function fetchRemoteComponent(ior: string): Promise; + +/** + * Parse a remote IOR string + * @param ior - The IOR string to parse + * @returns The parsed configuration or null if invalid + */ +export function parseRemoteIOR(ior: string): RemoteComponentConfig | null; + +/** + * Clear the remote component cache + */ +export function clearRemoteCache(): void; + +/** + * Metro resolver module + */ +export const metro: { + createIORResolver: typeof createIORResolver; + createHotReloadableResolver: typeof createHotReloadableResolver; + buildIORMappings: typeof buildIORMappings; + buildLocalIORMappings: typeof buildLocalIORMappings; + fetchRemoteComponent: typeof fetchRemoteComponent; + parseRemoteIOR: typeof parseRemoteIOR; + clearRemoteCache: typeof clearRemoteCache; +}; \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..d0cc56a --- /dev/null +++ b/index.js @@ -0,0 +1,21 @@ +/** + * Main entry point for @metatrom/ior-resolver + * Provides unified exports for both Metro and Node.js environments + */ + +// Export Metro resolver functions +const metroResolver = require('./lib/metro-ior-resolver.js'); + +module.exports = { + // Metro exports + createIORResolver: metroResolver.createIORResolver, + createHotReloadableResolver: metroResolver.createHotReloadableResolver, + buildIORMappings: metroResolver.buildIORMappings, + buildLocalIORMappings: metroResolver.buildLocalIORMappings, + fetchRemoteComponent: metroResolver.fetchRemoteComponent, + parseRemoteIOR: metroResolver.parseRemoteIOR, + clearRemoteCache: metroResolver.clearRemoteCache, + + // Re-export metro module for convenience + metro: metroResolver, +}; \ No newline at end of file diff --git a/lib/fetch-remote-component.js b/lib/fetch-remote-component.js new file mode 100644 index 0000000..d058211 --- /dev/null +++ b/lib/fetch-remote-component.js @@ -0,0 +1,26 @@ +// Suppress console.log from the resolver during fetching +const originalLog = console.log; +console.log = () => {}; + +const { fetchRemoteComponent } = require('./metro-ior-resolver.js'); + +// Restore console.log for our output +console.log = originalLog; + +const ior = process.argv[2]; +if (!ior) { + console.error('No IOR provided'); + process.exit(1); +} + +fetchRemoteComponent(ior) + .then(path => { + // Output only the path on stdout + console.log(path); + process.exit(0); + }) + .catch(err => { + // Output error to stderr + console.error('FETCH_ERROR:', err.message); + process.exit(1); + }); \ No newline at end of file diff --git a/lib/ior-core.js b/lib/ior-core.js new file mode 100644 index 0000000..5eea7f3 --- /dev/null +++ b/lib/ior-core.js @@ -0,0 +1,440 @@ +/** + * Core IOR resolver functionality shared between Metro and Node.js implementations + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const https = require('node:https'); +const http = require('node:http'); +const { execSync } = require('node:child_process'); + +/** + * Parse remote IOR format + * Format examples: + * - ior:github:owner/repo:componentname@version + * - ior:gitea:instance.com:owner/repo:componentname@version + * - ior:ipfs:QmHash:componentname@version + * - ior:p2p:peer-id:componentname@version + */ +function parseRemoteIOR(ior) { + const parts = ior.split(':'); + + if (parts.length < 4 || parts[0] !== 'ior') { + return null; + } + + const type = parts[1]; + + switch (type) { + case 'github': { + // ior:github:owner/repo:componentname@version + const [, , repoPath, componentAndVersion] = parts; + const [owner, repo] = repoPath.split('/'); + const [componentName, version] = componentAndVersion.split('@'); + + return { + type: 'github', + owner, + repository: repo, + componentName, + version, + }; + } + + case 'gitea': { + // Two possible formats: + // ior:gitea:instance.com:owner/repo:componentname@version (5 parts) + // ior:gitea:instance.com:owner/repo/componentname@version (4 parts - component name in path) + + if (parts.length === 4) { + // Format: ior:gitea:instance.com:owner/repo/componentname@version + const [, , instance, pathAndVersion] = parts; + const [fullPath, version] = pathAndVersion.split('@'); + const pathParts = fullPath.split('/'); + + const owner = pathParts[0]; + const componentName = pathParts[pathParts.length - 1]; + const repository = pathParts.slice(1).join('/') || componentName; + + return { + type: 'gitea', + instance, + owner, + repository, + componentName, + version, + }; + } else if (parts.length === 5) { + // Format: ior:gitea:instance.com:owner/repo:componentname@version + const [, , instance, repoPath, componentAndVersion] = parts; + const repoPathParts = repoPath.split('/'); + const owner = repoPathParts[0]; + const repository = repoPathParts.slice(1).join('/'); + const [componentName, version] = componentAndVersion.split('@'); + + return { + type: 'gitea', + instance, + owner, + repository, + componentName, + version, + }; + } + + return null; + } + + case 'ipfs': { + // ior:ipfs:QmHash:componentname@version + const [, , hash, componentAndVersion] = parts; + const [componentName, version] = componentAndVersion.split('@'); + + return { + type: 'ipfs', + hash, + componentName, + version, + }; + } + + case 'p2p': { + // ior:p2p:peer-id:componentname@version + const [, , peerId, componentAndVersion] = parts; + const [componentName, version] = componentAndVersion.split('@'); + + return { + type: 'p2p', + peerId, + componentName, + version, + }; + } + + default: + return null; + } +} + +/** + * Parse package.json to find IOR mapping + */ +function parsePackageJson(packagePath) { + try { + const content = fs.readFileSync(packagePath, 'utf-8'); + const pkg = JSON.parse(content); + return pkg; + } catch (err) { + return null; + } +} + +/** + * Parse package.json asynchronously + */ +async function parsePackageJsonAsync(packagePath) { + try { + const content = await fs.promises.readFile(packagePath, 'utf-8'); + const pkg = JSON.parse(content); + return pkg; + } catch (err) { + return null; + } +} + +/** + * Fetch content from URL + */ +function fetchUrl(url) { + return new Promise((resolve, reject) => { + const client = url.startsWith('https:') ? https : http; + + client.get(url, (res) => { + if (res.statusCode === 302 || res.statusCode === 301) { + // Handle redirects + fetchUrl(res.headers.location).then(resolve).catch(reject); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${url}`)); + return; + } + + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => resolve(data)); + }).on('error', reject); + }); +} + +/** + * Fetch URL and return as Buffer + */ +async function fetchUrlBuffer(url) { + return new Promise((resolve, reject) => { + const protocol = url.startsWith('https:') ? https : http; + + protocol.get(url, (response) => { + // Handle redirects + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + return fetchUrlBuffer(response.headers.location).then(resolve).catch(reject); + } + + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}: ${url}`)); + return; + } + + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => resolve(Buffer.concat(chunks))); + response.on('error', reject); + }).on('error', reject); + }); +} + +/** + * Fetch component archive from GitHub + */ +async function fetchFromGitHub(config) { + const { owner, repository, componentName, version } = config; + + // Try both tag formats: "1.0.0" and "v1.0.0" + const tagVariants = [version, `v${version}`]; + + for (const tag of tagVariants) { + // GitHub API endpoint for tarball + const archiveUrl = `https://api.github.com/repos/${owner}/${repository}/tarball/${tag}`; + + try { + console.log(`[IOR Resolver] Fetching GitHub archive: ${archiveUrl}`); + const archiveBuffer = await fetchUrlBuffer(archiveUrl); + console.log(`[IOR Resolver] Downloaded archive from GitHub (${archiveBuffer.length} bytes)`); + return { archive: archiveBuffer, componentName }; + } catch (err) { + console.log(`[IOR Resolver] Failed to fetch with tag ${tag}: ${err.message}`); + // Try next tag variant + } + } + + throw new Error(`Failed to fetch component from GitHub: ${owner}/${repository}@${version} (tried tags: ${tagVariants.join(', ')})`); +} + +/** + * Fetch component archive from Gitea + */ +async function fetchFromGitea(config) { + const { instance, owner, repository, componentName, version } = config; + + // Try both tag formats: "1.0.0" and "v1.0.0" + const tagVariants = [version, `v${version}`]; + + for (const tag of tagVariants) { + // Try to download the archive + const archiveUrl = `https://${instance}/api/v1/repos/${owner}/${repository}/archive/${tag}.tar.gz`; + + try { + console.log(`[IOR Resolver] Fetching Gitea archive: ${archiveUrl}`); + const archiveBuffer = await fetchUrlBuffer(archiveUrl); + console.log(`[IOR Resolver] Downloaded archive from Gitea (${archiveBuffer.length} bytes)`); + return { archive: archiveBuffer, componentName }; + } catch (err) { + console.log(`[IOR Resolver] Failed to fetch with tag ${tag}: ${err.message}`); + // Try next tag variant + } + } + + throw new Error(`Failed to fetch component from Gitea: ${instance}/${owner}/${repository}@${version} (tried tags: ${tagVariants.join(', ')})`); +} + +/** + * Fetch component from IPFS + */ +async function fetchFromIPFS(config) { + const { hash, componentName, version } = config; + + // Try multiple IPFS gateways for redundancy + const gateways = [ + `https://ipfs.io/ipfs/${hash}`, + `https://gateway.pinata.cloud/ipfs/${hash}`, + `https://cloudflare-ipfs.com/ipfs/${hash}`, + ]; + + for (const gateway of gateways) { + try { + // Try to get main file from package.json first + const packageUrl = `${gateway}/${componentName}/${version}/package.json`; + let mainFile = `${componentName}.ts`; + + try { + const pkgContent = await fetchUrl(packageUrl); + const pkgData = JSON.parse(pkgContent); + if (pkgData.main) { + mainFile = pkgData.main; + } + } catch { + // Use default + } + + const urls = [ + `${gateway}/${componentName}/${version}/${mainFile}`, + `${gateway}/${componentName}/${version}/index.ts`, + `${gateway}/${componentName}/${version}/index.tsx`, + ]; + + for (const url of urls) { + try { + const content = await fetchUrl(url); + return { content, url }; + } catch { + // Try next URL + } + } + } catch { + // Try next gateway + } + } + + throw new Error(`Failed to fetch from IPFS: ${hash}/${componentName}@${version}`); +} + +/** + * Extract archive to directory + */ +function extractArchive(archiveBuffer, targetDir, stripComponents = 1) { + const tempArchive = path.join(targetDir, '..', `temp-${Date.now()}.tar.gz`); + + // Ensure target directory exists + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + // Write archive to temp file + fs.writeFileSync(tempArchive, archiveBuffer); + + try { + // Extract archive + execSync(`tar -xzf "${tempArchive}" -C "${targetDir}" --strip-components=${stripComponents}`, { + stdio: 'pipe' + }); + + // Clean up temp archive + fs.unlinkSync(tempArchive); + } catch (err) { + // Clean up on failure + if (fs.existsSync(tempArchive)) { + fs.unlinkSync(tempArchive); + } + throw err; + } +} + +/** + * Find entry point in a directory + */ +function findEntryPoint(dir, componentName) { + let entryPath = null; + + // Check package.json for main field + const packageJsonPath = path.join(dir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + if (packageJson.main) { + const mainPath = path.join(dir, packageJson.main); + if (fs.existsSync(mainPath)) { + return mainPath; + } + } + } catch { + // Ignore package.json errors + } + } + + // Try common entry points + const possibleEntries = [ + path.join(dir, 'index.ts'), + path.join(dir, 'index.tsx'), + path.join(dir, 'index.js'), + path.join(dir, 'index.jsx'), + path.join(dir, `${componentName}.ts`), + path.join(dir, `${componentName}.tsx`), + path.join(dir, `${componentName}.js`), + path.join(dir, `${componentName}.jsx`), + ]; + + for (const entry of possibleEntries) { + if (fs.existsSync(entry)) { + return entry; + } + } + + return null; +} + +/** + * Generate cache hash for an IOR + */ +function generateCacheHash(ior) { + return crypto.createHash('sha256').update(ior).digest('hex'); +} + +/** + * Scan components directory to build local IOR mappings + */ +function buildLocalIORMappings(projectRoot) { + const componentsDir = path.join(projectRoot, 'src', 'components'); + const mappings = new Map(); + + try { + const componentDirs = fs.readdirSync(componentsDir, { withFileTypes: true }); + + for (const dir of componentDirs) { + if (dir.isDirectory()) { + const componentPath = path.join(componentsDir, dir.name); + const versions = fs.readdirSync(componentPath, { withFileTypes: true }); + + for (const version of versions) { + if (version.isDirectory()) { + const versionPath = path.join(componentPath, version.name); + const packagePath = path.join(versionPath, 'package.json'); + + const pkg = parsePackageJson(packagePath); + if (pkg?.metatrom?.ior) { + // Map both with and without esm: prefix + const ior = pkg.metatrom.ior; + const mainFile = pkg.main || 'index.ts'; + const resolvedPath = path.join(versionPath, mainFile); + + mappings.set(`ior:esm:${ior}`, resolvedPath); + mappings.set(`ior:${ior}`, resolvedPath); + + console.log(`[IOR Resolver] Mapped local: ${ior} -> ${resolvedPath}`); + } + } + } + } + } + } catch (err) { + console.error('[IOR Resolver] Error building local mappings:', err); + } + + return mappings; +} + +module.exports = { + parseRemoteIOR, + parsePackageJson, + parsePackageJsonAsync, + fetchUrl, + fetchUrlBuffer, + fetchFromGitHub, + fetchFromGitea, + fetchFromIPFS, + extractArchive, + findEntryPoint, + generateCacheHash, + buildLocalIORMappings, +}; \ No newline at end of file diff --git a/lib/metro-ior-resolver.js b/lib/metro-ior-resolver.js new file mode 100644 index 0000000..0d7bddc --- /dev/null +++ b/lib/metro-ior-resolver.js @@ -0,0 +1,580 @@ +/** + * Enhanced Metro resolver with both local and remote IOR support + * Uses shared core functionality from ior-core.js + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const { + parseRemoteIOR, + parsePackageJson, + fetchFromGitHub, + fetchFromGitea, + fetchFromIPFS, + extractArchive, + findEntryPoint, + generateCacheHash, + buildLocalIORMappings, +} = require('./ior-core.js'); + +/** + * Cache for IOR to path mappings + */ +const remoteCache = new Map(); + +/** + * Type cache for auto-generated type declarations + */ +const typeCache = new Map(); +const processedTypes = new Set(); + +/** + * Remote cache configuration + */ +const CACHE_TTL = 3600000; // 1 hour default TTL +// Use project-local cache directory instead of temp directory to avoid watchman issues +const CACHE_DIR = path.join(process.cwd(), '.ior-cache', 'remote'); + +/** + * Ensure cache directory exists + */ +function ensureCacheDir() { + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + } +} + +/** + * Cache remote component locally (full repository) + */ +async function cacheRemoteComponent(ior, archiveBuffer, componentName) { + ensureCacheDir(); + + const hash = generateCacheHash(ior); + const cacheDir = path.join(CACHE_DIR, hash); + + // Extract archive to cache directory + try { + extractArchive(archiveBuffer, cacheDir); + } catch (err) { + console.error(`[IOR Resolver] Failed to extract archive: ${err.message}`); + throw err; + } + + // Find the entry point + const entryPath = findEntryPoint(cacheDir, componentName); + + if (!entryPath) { + throw new Error(`No entry point found for component ${componentName} in ${cacheDir}`); + } + + // Update cache map with entry point + remoteCache.set(ior, { + path: entryPath, + directory: cacheDir, + timestamp: Date.now(), + ttl: CACHE_TTL, + }); + + console.log(`[IOR Resolver] Cached remote component: ${ior}`); + console.log(`[IOR Resolver] Directory: ${cacheDir}`); + console.log(`[IOR Resolver] Entry: ${entryPath}`); + + // Generate type declarations asynchronously (non-blocking) + generateTypeDeclaration(ior, cacheDir).catch(err => { + console.warn(`[IOR Resolver] Type generation failed for ${ior}:`, err.message); + }); + + // Update persistent mappings + updatePersistentMappings(ior, entryPath, cacheDir); + + return entryPath; +} + +/** + * Get cached remote component if valid + */ +function getCachedRemoteComponent(ior) { + 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 directory (non-blocking) + if (entry.directory) { + try { + fs.rmSync(entry.directory, { recursive: true, force: true }); + } catch { + // Ignore errors + } + } + return null; + } + + // Verify entry file still exists + if (fs.existsSync(entry.path)) { + console.log(`[IOR Resolver] Using cached component: ${ior}`); + console.log(`[IOR Resolver] Entry: ${entry.path}`); + return entry.path; + } + + remoteCache.delete(ior); + return null; +} + +/** + * Generate type declaration for a remote component + */ +async function generateTypeDeclaration(ior, cacheDir) { + // Avoid duplicate processing + if (processedTypes.has(ior)) { + return; + } + + try { + // Look for .d.ts files in the cached directory + const files = fs.readdirSync(cacheDir); + let typeContent = null; + let typeFile = null; + + // Priority order for type files + const possibleTypeFiles = [ + files.find(f => f.endsWith('.d.ts')), + 'index.d.ts', + 'types.d.ts' + ].filter(Boolean); + + for (const file of possibleTypeFiles) { + const filePath = path.join(cacheDir, file); + if (fs.existsSync(filePath)) { + typeContent = fs.readFileSync(filePath, 'utf-8'); + typeFile = file; + break; + } + } + + if (typeContent) { + // Transform module declaration to match the actual import path + const moduleRegex = /declare\s+module\s+['"]([^'"]+)['"]/; + const match = typeContent.match(moduleRegex); + + if (match && match[1] !== ior) { + // Replace the module declaration with the actual IOR + typeContent = typeContent.replace( + moduleRegex, + `declare module '${ior}'` + ); + } else if (!match) { + // No module declaration, wrap the content + typeContent = `declare module '${ior}' {\n${typeContent}\n}\n`; + } + + // Extract the hash from the cache directory path + const hash = path.basename(cacheDir); + + // Add to type cache + typeCache.set(ior, { + content: typeContent, + timestamp: Date.now(), + source: typeFile, + hash: hash + }); + + // Mark as processed + processedTypes.add(ior); + + // Update the global types file + await updateGlobalTypes(); + + console.log(`[IOR Resolver] Generated type declarations for ${ior} from ${typeFile}`); + } + } catch (err) { + console.warn(`[IOR Resolver] Failed to generate types for ${ior}:`, err.message); + } +} + +/** + * Update the auto-generated types file + */ +async function updateGlobalTypes() { + const projectRoot = process.cwd(); + const typesDir = path.join(projectRoot, 'src', 'types'); + const globalTypesPath = path.join(typesDir, 'auto-generated.d.ts'); + + let content = '/**\n'; + content += ' * Auto-generated type declarations for remote IOR components\n'; + content += ` * Generated at: ${new Date().toISOString()}\n`; + content += ' * DO NOT EDIT - This file is automatically maintained by metro-ior-resolver\n'; + content += ' */\n\n'; + + // Add all cached type definitions + for (const [ior, entry] of typeCache.entries()) { + content += `// Source: ${entry.source || 'unknown'}`; + if (entry.hash) { + content += `\n// Hash: ${entry.hash}\n`; + } else { + content += '\n'; + } + content += `// Cached: ${new Date(entry.timestamp).toISOString()}\n`; + content += entry.content; + content += '\n\n'; + } + + // Ensure directory exists + if (!fs.existsSync(typesDir)) { + fs.mkdirSync(typesDir, { recursive: true }); + } + + // Write the file + fs.writeFileSync(globalTypesPath, content, 'utf-8'); + + console.log(`[IOR Resolver] Updated auto-generated types: ${globalTypesPath}`); +} + +/** + * Fetch remote component based on IOR + */ +async function fetchRemoteComponent(ior) { + // Check cache first + const cached = getCachedRemoteComponent(ior); + if (cached) { + return cached; + } + + const config = parseRemoteIOR(ior); + if (!config) { + throw new Error(`Invalid remote IOR format: ${ior}`); + } + + console.log(`[IOR Resolver] Fetching remote component: ${ior}`); + + let result; + + switch (config.type) { + case 'github': + result = await fetchFromGitHub(config); + break; + + case 'gitea': + result = await fetchFromGitea(config); + break; + + case 'ipfs': + result = await fetchFromIPFS(config); + break; + + case 'p2p': + throw new Error(`P2P fetching not yet implemented for Metro. PeerID: ${config.peerId}`); + + default: + throw new Error(`Unsupported remote type: ${config.type}`); + } + + // Cache the fetched component + // For GitHub and Gitea, result contains { archive, componentName } + // For IPFS, result contains { content, url } + if (result.archive) { + const cachedPath = await cacheRemoteComponent(ior, result.archive, result.componentName); + return cachedPath; + } else { + // Legacy path for IPFS (still returns content directly) + // Create a simple archive-like structure for IPFS content + ensureCacheDir(); + const hash = generateCacheHash(ior); + const cacheDir = path.join(CACHE_DIR, hash); + + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + // Write content to index.ts + const entryPath = path.join(cacheDir, 'index.ts'); + fs.writeFileSync(entryPath, result.content, 'utf-8'); + + // Update cache + remoteCache.set(ior, { + path: entryPath, + directory: cacheDir, + timestamp: Date.now(), + ttl: CACHE_TTL, + }); + + updatePersistentMappings(ior, entryPath, cacheDir); + return entryPath; + } +} + +/** + * Enhanced resolver function for Metro with remote support + */ +function createEnhancedIORResolver(projectRoot) { + // Build local mappings on initialization + const localMappings = buildLocalIORMappings(projectRoot); + + return function enhancedIORResolver(context, moduleName, platform) { + // Only handle IOR imports + if (!moduleName.startsWith('ior:')) { + // Not an IOR import, return undefined for default resolution + return undefined; + } + + // First check local mappings + const localPath = localMappings.get(moduleName); + + if (localPath) { + console.log(`[IOR Resolver] Resolving local: ${moduleName} -> ${localPath}`); + + // Check if file exists with various extensions + const extensions = ['', '.ts', '.tsx', '.js', '.jsx']; + for (const ext of extensions) { + const fullPath = localPath.endsWith('.ts') || localPath.endsWith('.tsx') || localPath.endsWith('.js') || localPath.endsWith('.jsx') + ? localPath + : localPath + ext; + + if (fs.existsSync(fullPath)) { + return { + type: 'sourceFile', + filePath: path.resolve(fullPath), + }; + } + } + + // Try index files if main file not found + const dirPath = path.dirname(localPath); + const indexExtensions = ['/index.ts', '/index.tsx', '/index.js', '/index.jsx']; + for (const indexExt of indexExtensions) { + const indexPath = dirPath + indexExt; + if (fs.existsSync(indexPath)) { + return { + type: 'sourceFile', + filePath: path.resolve(indexPath), + }; + } + } + } + + // Check if it's a remote IOR (synchronously for now) + const remoteConfig = parseRemoteIOR(moduleName); + if (remoteConfig) { + throw new Error(`Remote IOR components not yet supported in sync mode: ${moduleName}`); + } + + // IOR not found + throw new Error(`Cannot resolve IOR module: ${moduleName}`); + }; +} + +/** + * Create a resolver with hot reload support + * Rebuilds mappings when package.json files change + */ +function createHotReloadableEnhancedResolver(projectRoot) { + let resolver = createEnhancedIORResolver(projectRoot); + + // Watch for package.json changes in components directory + if (process.env.NODE_ENV === 'development') { + const componentsDir = path.join(projectRoot, 'src', 'components'); + + try { + // Simple file watcher for package.json files + const watchDirs = (dir) => { + fs.readdir(dir, { withFileTypes: true }, (err, entries) => { + if (err) return; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + watchDirs(fullPath); + } else if (entry.name === 'package.json') { + fs.watchFile(fullPath, { interval: 1000 }, () => { + console.log(`[IOR Resolver] Detected change in ${fullPath}, rebuilding mappings...`); + resolver = createEnhancedIORResolver(projectRoot); + }); + } + } + }); + }; + + watchDirs(componentsDir); + } catch (err) { + console.warn('[IOR Resolver] Could not set up file watching:', err); + } + } + + // Return a wrapper function that always uses the latest resolver + return (context, moduleName, platform) => { + return resolver(context, moduleName, platform); + }; +} + +/** + * Clear remote cache + */ +function clearRemoteCache() { + remoteCache.clear(); + + if (fs.existsSync(CACHE_DIR)) { + const files = fs.readdirSync(CACHE_DIR); + for (const file of files) { + try { + fs.unlinkSync(path.join(CACHE_DIR, file)); + } catch { + // Ignore errors + } + } + } + + console.log('[IOR Resolver] Remote cache cleared'); +} + +/** + * Update persistent IOR mappings + */ +function updatePersistentMappings(ior, entryPath, cacheDir) { + const mappingFile = path.join(process.cwd(), '.ior-cache', 'mappings.json'); + + try { + // Load existing mappings or create new + let data = { version: '1.0', mappings: {} }; + if (fs.existsSync(mappingFile)) { + try { + data = JSON.parse(fs.readFileSync(mappingFile, 'utf-8')); + } catch { + // Use default if file is corrupted + } + } + + // Update mappings + const hash = path.basename(cacheDir); + data.mappings[ior] = { + path: entryPath, + directory: cacheDir, + hash: hash, + timestamp: Date.now(), + }; + data.generated = new Date().toISOString(); + + // Save mappings + const mappingDir = path.dirname(mappingFile); + if (!fs.existsSync(mappingDir)) { + fs.mkdirSync(mappingDir, { recursive: true }); + } + fs.writeFileSync(mappingFile, JSON.stringify(data, null, 2), 'utf-8'); + } catch (err) { + // Non-critical error, just log + console.warn('[IOR Resolver] Failed to update mappings:', err.message); + } +} + +/** + * Load persistent IOR mappings if available + */ +function loadIORMappings() { + const mappingFile = path.join(process.cwd(), '.ior-cache', 'mappings.json'); + + if (fs.existsSync(mappingFile)) { + try { + const data = JSON.parse(fs.readFileSync(mappingFile, 'utf-8')); + console.log(`[IOR Resolver] Loaded IOR mappings from ${mappingFile}`); + + // Restore mappings to remoteCache + if (data.mappings) { + for (const [ior, mapping] of Object.entries(data.mappings)) { + // Verify the cached files still exist + if (fs.existsSync(mapping.path)) { + remoteCache.set(ior, { + path: mapping.path, + directory: mapping.directory, + timestamp: mapping.timestamp, + ttl: CACHE_TTL, + }); + } + } + console.log(`[IOR Resolver] Restored ${remoteCache.size} cached components`); + } + + return true; + } catch (err) { + console.warn('[IOR Resolver] Failed to load mappings:', err.message); + } + } + + return false; +} + +/** + * Initialize type declarations for existing cached components + */ +async function initializeTypesFromCache() { + if (!fs.existsSync(CACHE_DIR)) { + return; + } + + console.log('[IOR Resolver] Scanning cache for existing components...'); + + // First try to load persistent mappings + loadIORMappings(); + + try { + const dirs = fs.readdirSync(CACHE_DIR, { withFileTypes: true }); + + for (const dir of dirs) { + if (dir.isDirectory()) { + const cacheDir = path.join(CACHE_DIR, dir.name); + + // Try to determine the IOR from the remoteCache + let foundIor = null; + for (const [ior, entry] of remoteCache.entries()) { + if (entry.directory === cacheDir) { + foundIor = ior; + break; + } + } + + if (!foundIor) { + // Try to read package.json to get hints about the component + const pkgPath = path.join(cacheDir, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + if (pkg.name && pkg.version) { + console.log(`[IOR Resolver] Found cached component without IOR mapping: ${pkg.name}@${pkg.version}`); + } + } catch { + // Skip if we can't read package.json + } + } + continue; + } + + // Generate types for this cached component + await generateTypeDeclaration(foundIor, cacheDir); + } + } + + console.log('[IOR Resolver] Finished scanning cache'); + } catch (err) { + console.warn('[IOR Resolver] Failed to initialize types from cache:', err.message); + } +} + +// Initialize types from cache on module load +initializeTypesFromCache().catch(err => { + console.warn('[IOR Resolver] Failed to initialize types:', err.message); +}); + +// Export unified functions +module.exports = { + // Main exports + createIORResolver: createEnhancedIORResolver, + createHotReloadableResolver: createHotReloadableEnhancedResolver, + buildIORMappings: buildLocalIORMappings, + buildLocalIORMappings, + fetchRemoteComponent, + parseRemoteIOR, + clearRemoteCache, +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..83eff1b --- /dev/null +++ b/package.json @@ -0,0 +1,78 @@ +{ + "name": "@metatrom/ior-resolver", + "version": "1.0.0", + "description": "Interoperable Object Reference (IOR) resolver for Metro bundler and Node.js with support for local and remote component fetching", + "main": "index.js", + "types": "index.d.ts", + "bin": { + "prebuild-ior-types": "./bin/prebuild-ior-types.js" + }, + "exports": { + ".": { + "require": "./index.js", + "import": "./index.js", + "types": "./index.d.ts" + }, + "./metro": { + "require": "./lib/metro-ior-resolver.js", + "types": "./lib/metro-ior-resolver.d.ts" + }, + "./node": { + "import": "./bin/NodeImportLoader.ts", + "types": "./bin/NodeImportLoader.d.ts" + }, + "./prebuild": { + "require": "./bin/prebuild-ior-types.js" + } + }, + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "files": [ + "index.js", + "index.d.ts", + "lib/", + "bin/", + "README.md", + "LICENSE" + ], + "keywords": [ + "ior", + "resolver", + "metro", + "react-native", + "node", + "import", + "loader", + "remote", + "components", + "metatrom", + "interoperable" + ], + "author": "Metatrom", + "license": "MIT", + "dependencies": { + "typescript": "^5.0.0" + }, + "peerDependencies": { + "@react-native/metro-config": ">=0.80.0" + }, + "peerDependenciesMeta": { + "@react-native/metro-config": { + "optional": true + } + }, + "engines": { + "node": ">=18" + }, + "repository": { + "type": "git", + "url": "https://github.com/metatrom/ior-resolver.git" + }, + "bugs": { + "url": "https://github.com/metatrom/ior-resolver/issues" + }, + "homepage": "https://github.com/metatrom/ior-resolver#readme" +} \ No newline at end of file