Initial commit

This commit is contained in:
Chris Daßler
2025-08-29 04:22:52 +02:00
commit e1fb6e6acc
11 changed files with 2665 additions and 0 deletions

View File

@@ -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": []
}
}

97
.gitignore vendored Normal file
View File

@@ -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

349
README.md Normal file
View File

@@ -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).

725
bin/NodeImportLoader.ts Normal file
View File

@@ -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<string, string>;
}
interface LoadContext {
format?: string;
importAttributes?: Record<string, string>;
}
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<ResolveResult>;
type DefaultLoad = (
url: string,
context: LoadContext,
defaultLoad: DefaultLoad,
) => Promise<LoadResult>;
const componentCache = new Map<string, string>();
const remoteCache = new Map<string, RemoteCacheEntry>();
const typeCache = new Map<string, TypeCacheEntry>();
const processedTypes = new Set<string>();
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<string, string>;
optionalDependencies?: Record<string, string>;
}
/**
* Fetch type definitions for a component
*/
async function fetchTypeDefinitions(
config: any,
componentName: string,
version: string,
): Promise<string | null> {
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<void> {
// 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<void> {
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<string> {
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<string | null> {
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<string> {
// 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<string> {
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<string> {
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<ResolveResult> {
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<LoadResult> {
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<void> {
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<void> {
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();
})();

245
bin/prebuild-ior-types.js Normal file
View File

@@ -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 };

91
index.d.ts vendored Normal file
View File

@@ -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<string, string>;
/**
* 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<string, string>;
/**
* 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<string>;
/**
* 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;
};

21
index.js Normal file
View File

@@ -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,
};

View File

@@ -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);
});

440
lib/ior-core.js Normal file
View File

@@ -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,
};

580
lib/metro-ior-resolver.js Normal file
View File

@@ -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,
};

78
package.json Normal file
View File

@@ -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"
}