- Add branch detection in parseRemoteIOR for GitHub and Gitea - Support fetching from branches in addition to tags - Implement synchronous remote resolution using sync-fetch - Add dynamic cache TTL based on version type (beta/rc/branch/stable) - Export sync functions for Metro bundler usage - Fix remote component resolution in Metro (no more sync errors)
495 lines
14 KiB
JavaScript
495 lines
14 KiB
JavaScript
/**
|
|
* 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, versionOrBranch] = componentAndVersion.split('@');
|
|
|
|
// Detect if it's a branch
|
|
const isBranch = versionOrBranch && (
|
|
versionOrBranch.includes('/') ||
|
|
versionOrBranch.startsWith('main') ||
|
|
versionOrBranch.startsWith('master') ||
|
|
versionOrBranch.startsWith('dev')
|
|
);
|
|
|
|
return {
|
|
type: 'github',
|
|
owner,
|
|
repository: repo,
|
|
componentName,
|
|
version: !isBranch ? versionOrBranch : undefined,
|
|
branch: isBranch ? versionOrBranch : undefined,
|
|
};
|
|
}
|
|
|
|
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, versionOrBranch] = pathAndVersion.split('@');
|
|
const pathParts = fullPath.split('/');
|
|
|
|
const owner = pathParts[0];
|
|
const componentName = pathParts[pathParts.length - 1];
|
|
const repository = pathParts.slice(1).join('/') || componentName;
|
|
|
|
// Detect if it's a branch
|
|
const isBranch = versionOrBranch && (
|
|
versionOrBranch.includes('/') ||
|
|
versionOrBranch.startsWith('main') ||
|
|
versionOrBranch.startsWith('master') ||
|
|
versionOrBranch.startsWith('dev')
|
|
);
|
|
|
|
return {
|
|
type: 'gitea',
|
|
instance,
|
|
owner,
|
|
repository,
|
|
componentName,
|
|
version: !isBranch ? versionOrBranch : undefined,
|
|
branch: isBranch ? versionOrBranch : undefined,
|
|
};
|
|
} 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, versionOrBranch] = componentAndVersion.split('@');
|
|
|
|
// Detect if it's a branch
|
|
const isBranch = versionOrBranch && (
|
|
versionOrBranch.includes('/') ||
|
|
versionOrBranch.startsWith('main') ||
|
|
versionOrBranch.startsWith('master') ||
|
|
versionOrBranch.startsWith('dev')
|
|
);
|
|
|
|
return {
|
|
type: 'gitea',
|
|
instance,
|
|
owner,
|
|
repository,
|
|
componentName,
|
|
version: !isBranch ? versionOrBranch : undefined,
|
|
branch: isBranch ? versionOrBranch : undefined,
|
|
};
|
|
}
|
|
|
|
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, branch } = config;
|
|
|
|
// If it's a branch, fetch from branch
|
|
if (branch) {
|
|
const archiveUrl = `https://api.github.com/repos/${owner}/${repository}/tarball/${branch}`;
|
|
|
|
try {
|
|
console.log(`[IOR Resolver] Fetching GitHub branch: ${branch}`);
|
|
const archiveBuffer = await fetchUrlBuffer(archiveUrl);
|
|
console.log(`[IOR Resolver] Downloaded archive from GitHub branch ${branch} (${archiveBuffer.length} bytes)`);
|
|
return { archive: archiveBuffer, componentName };
|
|
} catch (err) {
|
|
throw new Error(`Failed to fetch branch ${branch} from GitHub: ${owner}/${repository} - ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// Otherwise, try 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, branch } = config;
|
|
|
|
// If it's a branch, fetch from branch
|
|
if (branch) {
|
|
const archiveUrl = `https://${instance}/api/v1/repos/${owner}/${repository}/archive/${branch}.tar.gz`;
|
|
|
|
try {
|
|
console.log(`[IOR Resolver] Fetching Gitea branch: ${branch}`);
|
|
const archiveBuffer = await fetchUrlBuffer(archiveUrl);
|
|
console.log(`[IOR Resolver] Downloaded archive from Gitea branch ${branch} (${archiveBuffer.length} bytes)`);
|
|
return { archive: archiveBuffer, componentName };
|
|
} catch (err) {
|
|
throw new Error(`Failed to fetch branch ${branch} from Gitea: ${instance}/${owner}/${repository} - ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// Otherwise, try 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,
|
|
}; |