From 6e775258d1373a03d846373c2402a34f1ca543fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Da=C3=9Fler?= Date: Mon, 29 Sep 2025 12:32:41 +0200 Subject: [PATCH] feat: add branch support, sync resolution, and dynamic cache TTL - 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) --- lib/ior-core.js | 103 +++++++++++++++++------ lib/metro-ior-resolver.js | 172 ++++++++++++++++++++++++++++++++++++-- package.json | 3 +- 3 files changed, 248 insertions(+), 30 deletions(-) diff --git a/lib/ior-core.js b/lib/ior-core.js index 5eea7f3..42a99ca 100644 --- a/lib/ior-core.js +++ b/lib/ior-core.js @@ -31,14 +31,23 @@ function parseRemoteIOR(ior) { // ior:github:owner/repo:componentname@version const [, , repoPath, componentAndVersion] = parts; const [owner, repo] = repoPath.split('/'); - const [componentName, version] = componentAndVersion.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, + version: !isBranch ? versionOrBranch : undefined, + branch: isBranch ? versionOrBranch : undefined, }; } @@ -46,24 +55,33 @@ function parseRemoteIOR(ior) { // 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 [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, + version: !isBranch ? versionOrBranch : undefined, + branch: isBranch ? versionOrBranch : undefined, }; } else if (parts.length === 5) { // Format: ior:gitea:instance.com:owner/repo:componentname@version @@ -71,18 +89,27 @@ function parseRemoteIOR(ior) { const repoPathParts = repoPath.split('/'); const owner = repoPathParts[0]; const repository = repoPathParts.slice(1).join('/'); - const [componentName, version] = componentAndVersion.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: 'gitea', instance, owner, repository, componentName, - version, + version: !isBranch ? versionOrBranch : undefined, + branch: isBranch ? versionOrBranch : undefined, }; } - + return null; } @@ -199,15 +226,29 @@ async function fetchUrlBuffer(url) { * 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 { 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); @@ -218,7 +259,7 @@ async function fetchFromGitHub(config) { // Try next tag variant } } - + throw new Error(`Failed to fetch component from GitHub: ${owner}/${repository}@${version} (tried tags: ${tagVariants.join(', ')})`); } @@ -226,15 +267,29 @@ async function fetchFromGitHub(config) { * 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 { 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); @@ -245,7 +300,7 @@ async function fetchFromGitea(config) { // Try next tag variant } } - + throw new Error(`Failed to fetch component from Gitea: ${instance}/${owner}/${repository}@${version} (tried tags: ${tagVariants.join(', ')})`); } diff --git a/lib/metro-ior-resolver.js b/lib/metro-ior-resolver.js index 6cb87eb..b19f60c 100644 --- a/lib/metro-ior-resolver.js +++ b/lib/metro-ior-resolver.js @@ -5,6 +5,7 @@ const fs = require('node:fs'); const path = require('node:path'); +const syncFetch = require('sync-fetch'); const { parseRemoteIOR, parsePackageJson, @@ -28,6 +29,26 @@ const remoteCache = new Map(); const typeCache = new Map(); const processedTypes = new Set(); +/** + * Determine cache TTL based on version type + */ +function determineCacheTTL(ior) { + const parts = ior.split('@'); + const versionOrBranch = parts[parts.length - 1]; + + if (versionOrBranch.includes('-beta') || versionOrBranch.includes('-alpha')) { + return 5 * 60 * 1000; // 5 minutes for beta/alpha + } else if (versionOrBranch.includes('-rc')) { + return 60 * 60 * 1000; // 1 hour for RC + } else if (versionOrBranch.includes('/')) { + return 60 * 1000; // 1 minute for branches + } else { + return process.env.IOR_CACHE_TTL !== undefined + ? parseInt(process.env.IOR_CACHE_TTL) + : 3600000; // Default 1 hour for stable + } +} + /** * Remote cache configuration */ @@ -90,12 +111,13 @@ async function cacheRemoteComponent(ior, archiveBuffer, componentName) { throw new Error(`No entry point found for component ${componentName} in ${cacheDir}`); } - // Update cache map with entry point + // Update cache map with entry point using dynamic TTL + const ttl = determineCacheTTL(ior); remoteCache.set(ior, { path: entryPath, directory: cacheDir, timestamp: Date.now(), - ttl: CACHE_TTL, + ttl: ttl, }); console.log(`[IOR Resolver] Cached remote component: ${ior}`); @@ -335,6 +357,131 @@ async function fetchRemoteComponent(ior) { } } +/** + * Synchronously fetch and cache remote component + */ +function fetchRemoteComponentSync(ior, config) { + // Check cache first + const cached = getCachedRemoteComponent(ior); + if (cached) { + return cached; + } + + console.log(`[IOR Resolver] Sync fetching remote component: ${ior}`); + + // Determine URL based on config + let archiveUrl; + + if (config.branch) { + // Branch-based URL + if (config.type === 'github') { + archiveUrl = `https://api.github.com/repos/${config.owner}/${config.repository}/tarball/${config.branch}`; + } else if (config.type === 'gitea') { + archiveUrl = `https://${config.instance}/api/v1/repos/${config.owner}/${config.repository}/archive/${config.branch}.tar.gz`; + } else { + throw new Error(`Branch fetching not supported for type: ${config.type}`); + } + } else if (config.version) { + // Version-based URL (try with and without 'v' prefix) + const version = config.version; + if (config.type === 'github') { + archiveUrl = `https://api.github.com/repos/${config.owner}/${config.repository}/tarball/${version}`; + } else if (config.type === 'gitea') { + archiveUrl = `https://${config.instance}/api/v1/repos/${config.owner}/${config.repository}/archive/${version}.tar.gz`; + } else { + throw new Error(`Version fetching not supported for type: ${config.type}`); + } + } else { + throw new Error(`No version or branch specified in config`); + } + + try { + // Sync fetch using sync-fetch + console.log(`[IOR Resolver] Fetching from: ${archiveUrl}`); + const response = syncFetch(archiveUrl, { + headers: { + 'User-Agent': 'IOR-Resolver/1.0', + 'Accept': 'application/octet-stream', + }, + }); + + if (!response.ok) { + // Try with 'v' prefix if it's a version + if (config.version && !config.version.startsWith('v')) { + const versionWithV = `v${config.version}`; + const altUrl = archiveUrl.replace(config.version, versionWithV); + + console.log(`[IOR Resolver] Retrying with v prefix: ${altUrl}`); + const altResponse = syncFetch(altUrl, { + headers: { + 'User-Agent': 'IOR-Resolver/1.0', + 'Accept': 'application/octet-stream', + }, + }); + + if (altResponse.ok) { + const buffer = Buffer.from(altResponse.arrayBuffer()); + return cacheRemoteComponentSync(ior, buffer, config.componentName); + } + } + + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const buffer = Buffer.from(response.arrayBuffer()); + return cacheRemoteComponentSync(ior, buffer, config.componentName); + } catch (err) { + console.error(`[IOR Resolver] Sync fetch failed: ${err.message}`); + throw new Error(`Failed to fetch remote component: ${ior} - ${err.message}`); + } +} + +/** + * Synchronously cache remote component + */ +function cacheRemoteComponentSync(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 using dynamic TTL + const ttl = determineCacheTTL(ior); + remoteCache.set(ior, { + path: entryPath, + directory: cacheDir, + timestamp: Date.now(), + ttl: ttl, + }); + + console.log(`[IOR Resolver] Sync cached remote component: ${ior}`); + + // 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; +} + /** * Enhanced resolver function for Metro with remote support */ @@ -384,12 +531,20 @@ function createEnhancedIORResolver(projectRoot) { } } - // Check if it's a remote IOR (synchronously for now) + // Check if it's a remote IOR - now with sync support! const remoteConfig = parseRemoteIOR(moduleName); if (remoteConfig) { - throw new Error(`Remote IOR components not yet supported in sync mode: ${moduleName}`); + try { + const entryPath = fetchRemoteComponentSync(moduleName, remoteConfig); + return { + type: 'sourceFile', + filePath: path.resolve(entryPath), + }; + } catch (err) { + throw new Error(`Failed to resolve remote IOR: ${moduleName} - ${err.message}`); + } } - + // IOR not found throw new Error(`Cannot resolve IOR module: ${moduleName}`); }; @@ -599,9 +754,16 @@ module.exports = { // Main exports createIORResolver: createEnhancedIORResolver, createHotReloadableResolver: createHotReloadableEnhancedResolver, + + // Sync functions for Metro + fetchRemoteComponentSync, + cacheRemoteComponentSync, + + // Existing exports buildIORMappings: buildLocalIORMappings, buildLocalIORMappings, fetchRemoteComponent, parseRemoteIOR, clearRemoteCache, + determineCacheTTL, }; \ No newline at end of file diff --git a/package.json b/package.json index a86f7a0..7a3180a 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "author": "Metatrom", "license": "MIT", "dependencies": { - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "sync-fetch": "^0.5.2" }, "peerDependencies": { "@react-native/metro-config": ">=0.80.0"