diff --git a/examples/caching-demo.mjs b/examples/caching-demo.mjs new file mode 100644 index 0000000..0b81aa0 --- /dev/null +++ b/examples/caching-demo.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +// Demonstration of the new caching functionality for non-specific versions + +import { use } from '../use.mjs'; +import { promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; + +const getCacheFile = async (packageName, version) => { + const cacheDir = path.join(tmpdir(), 'use-m-cache'); + const cacheKey = `${packageName.replace('@', '').replace('/', '-')}-${version}`; + const cachePath = path.join(cacheDir, `${cacheKey}.json`); + try { + const data = await fs.readFile(cachePath, 'utf8'); + return JSON.parse(data); + } catch { + return null; + } +}; + +console.log('=== Use-m Caching Demo ===\n'); + +console.log('1. First call to lodash@latest (this will fetch from npm and cache the result):'); +const start1 = Date.now(); +const _ = await use('lodash@latest'); +const end1 = Date.now(); +console.log(` Time taken: ${end1 - start1}ms`); +console.log(` Lodash version works: ${_.VERSION}`); + +const cacheData = await getCacheFile('lodash', 'latest'); +if (cacheData) { + console.log(` ✓ Cache created: resolved version ${cacheData.resolvedVersion}`); + console.log(` ✓ Cache timestamp: ${new Date(cacheData.timestamp).toISOString()}`); +} else { + console.log(' ✗ Cache was not created'); +} + +console.log('\n2. Second call to lodash@latest (this should use cached version and be faster):'); +const start2 = Date.now(); +const _2 = await use('lodash@latest'); +const end2 = Date.now(); +console.log(` Time taken: ${end2 - start2}ms`); +console.log(` Same lodash version: ${_2.VERSION}`); + +const cacheData2 = await getCacheFile('lodash', 'latest'); +if (cacheData2 && cacheData && cacheData2.timestamp === cacheData.timestamp) { + console.log(` ✓ Cache was reused (timestamp unchanged)`); +} else { + console.log(' ✗ Cache was not reused'); +} + +console.log('\n3. Specific version like lodash@4.17.21 (this should NOT be cached):'); +const start3 = Date.now(); +const _3 = await use('lodash@4.17.21'); +const end3 = Date.now(); +console.log(` Time taken: ${end3 - start3}ms`); +console.log(` Lodash version: ${_3.VERSION}`); + +const cacheData3 = await getCacheFile('lodash', '4.17.21'); +if (!cacheData3) { + console.log(` ✓ Specific version was NOT cached (as expected)`); +} else { + console.log(' ✗ Specific version was cached (unexpected)'); +} + +console.log('\n=== Summary ==='); +console.log('- Latest versions (like @latest) are now cached for 5 minutes by default'); +console.log('- Specific versions (like @4.17.21) are not cached'); +console.log('- This solves the issue where @latest would be fetched every time'); +console.log('- Future versions can extend caching to major version ranges like @4'); + +console.log('\nDemonstration complete! 🎉'); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f5b1536..3728513 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "use-m", - "version": "8.13.2", + "version": "8.13.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "use-m", - "version": "8.13.2", + "version": "8.13.6", "license": "UNLICENSED", "bin": { "use": "cli.mjs" diff --git a/tests/caching.test.mjs b/tests/caching.test.mjs new file mode 100644 index 0000000..ec482b6 --- /dev/null +++ b/tests/caching.test.mjs @@ -0,0 +1,129 @@ +import { describe, test, expect } from '../test-adapter.mjs'; +import { use } from 'use-m'; +import { promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; + +const moduleName = `[${import.meta.url.split('.').pop()} module]`; + +// Helper to clean cache directory +const cleanCache = async () => { + try { + const cacheDir = path.join(tmpdir(), 'use-m-cache'); + await fs.rmdir(cacheDir, { recursive: true }); + } catch (error) { + // Ignore if cache dir doesn't exist + } +}; + +// Helper to check if cache file exists +const getCacheFile = async (packageName, version) => { + const cacheDir = path.join(tmpdir(), 'use-m-cache'); + const cacheKey = `${packageName.replace('@', '').replace('/', '-')}-${version}`; + const cachePath = path.join(cacheDir, `${cacheKey}.json`); + try { + const data = await fs.readFile(cachePath, 'utf8'); + return JSON.parse(data); + } catch { + return null; + } +}; + +describe(`${moduleName} Version caching functionality`, () => { + test(`${moduleName} Should cache latest version resolution`, async () => { + // Clean cache before test + await cleanCache(); + + // First call should create cache + const _ = await use('lodash@latest'); + + // Check that cache file was created + const cacheData = await getCacheFile('lodash', 'latest'); + expect(cacheData).toBeTruthy(); + expect(cacheData.packageName).toBe('lodash'); + expect(cacheData.requestedVersion).toBe('latest'); + expect(cacheData.resolvedVersion).toMatch(/^\d+\.\d+\.\d+$/); + expect(typeof cacheData.timestamp).toBe('number'); + + // Verify lodash works + expect(_.add(1, 2)).toBe(3); + }); + + test(`${moduleName} Should use cached latest version on subsequent calls`, async () => { + // Clean cache before test + await cleanCache(); + + // First call to cache the version + await use('lodash@latest'); + const firstCacheData = await getCacheFile('lodash', 'latest'); + + // Wait a small amount to ensure timestamp difference + await new Promise(resolve => setTimeout(resolve, 10)); + + // Second call should use cached version + await use('lodash@latest'); + const secondCacheData = await getCacheFile('lodash', 'latest'); + + // Cache timestamp should not have changed + expect(secondCacheData.timestamp).toBe(firstCacheData.timestamp); + expect(secondCacheData.resolvedVersion).toBe(firstCacheData.resolvedVersion); + }, 10000); + + test(`${moduleName} Should not cache specific versions`, async () => { + // Clean cache before test + await cleanCache(); + + // Use a specific version + await use('lodash@4.17.21'); + + // Check that no cache file was created + const cacheData = await getCacheFile('lodash', '4.17.21'); + expect(cacheData).toBeNull(); + }); + + test(`${moduleName} Should cache latest version`, async () => { + // Clean cache before test + await cleanCache(); + + // Use latest version + await use('lodash@latest'); + + // Check that cache file was created + const cacheData = await getCacheFile('lodash', 'latest'); + expect(cacheData).toBeTruthy(); + expect(cacheData.packageName).toBe('lodash'); + expect(cacheData.requestedVersion).toBe('latest'); + expect(cacheData.resolvedVersion).toMatch(/^\d+\.\d+\.\d+$/); + }); + + test(`${moduleName} Should expire cache after timeout`, async () => { + // This is a conceptual test - in practice, we'd need to mock Date.now() + // or set a very short timeout to test expiration without waiting 5 minutes + + // Clean cache before test + await cleanCache(); + + // Use a package with latest version (which gets cached) + await use('lodash@latest'); + const cacheData = await getCacheFile('lodash', 'latest'); + expect(cacheData).toBeTruthy(); + + // Manually modify cache timestamp to simulate expiration + const expiredCacheData = { + ...cacheData, + timestamp: Date.now() - (6 * 60 * 1000) // 6 minutes ago + }; + + const cacheDir = path.join(tmpdir(), 'use-m-cache'); + const cacheKey = 'lodash-latest'; + const cachePath = path.join(cacheDir, `${cacheKey}.json`); + await fs.writeFile(cachePath, JSON.stringify(expiredCacheData), 'utf8'); + + // Now use the package again - should refresh the cache + await use('lodash@latest'); + const refreshedCacheData = await getCacheFile('lodash', 'latest'); + + // Timestamp should be updated + expect(refreshedCacheData.timestamp).toBeGreaterThan(expiredCacheData.timestamp); + }); +}); \ No newline at end of file diff --git a/use.cjs b/use.cjs index 3917be3..02739ea 100644 --- a/use.cjs +++ b/use.cjs @@ -409,24 +409,111 @@ const resolvers = { } }; + const isNonSpecificVersion = (version) => { + // For now, only cache 'latest' versions to be safe + // TODO: extend this to handle major version ranges once we have proper version resolution + return version === 'latest'; + }; + + const getVersionCachePath = async (packageName, version) => { + const { tmpdir } = await import('node:os'); + const { existsSync, mkdirSync } = await import('node:fs'); + const cacheDir = path.join(tmpdir(), 'use-m-cache'); + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } + const cacheKey = `${packageName.replace('@', '').replace('/', '-')}-${version}`; + return path.join(cacheDir, `${cacheKey}.json`); + }; + + const getCachedVersion = async (packageName, version, cacheTimeoutMinutes = 5) => { + if (!isNonSpecificVersion(version)) { + return null; // Don't cache specific versions + } + + try { + const cachePath = await getVersionCachePath(packageName, version); + if (await fileExists(cachePath)) { + const cacheData = JSON.parse(await readFile(cachePath, 'utf8')); + const cacheAge = Date.now() - cacheData.timestamp; + const cacheTimeoutMs = cacheTimeoutMinutes * 60 * 1000; + + if (cacheAge < cacheTimeoutMs) { + return cacheData.resolvedVersion; + } + } + } catch { + // Ignore cache read errors, will fetch fresh version + } + return null; + }; + + const setCachedVersion = async (packageName, version, resolvedVersion) => { + if (!isNonSpecificVersion(version)) { + return; // Don't cache specific versions + } + + try { + const cachePath = await getVersionCachePath(packageName, version); + const cacheData = { + packageName, + requestedVersion: version, + resolvedVersion, + timestamp: Date.now() + }; + const { writeFile } = await import('node:fs/promises'); + await writeFile(cachePath, JSON.stringify(cacheData), 'utf8'); + } catch { + // Ignore cache write errors, not critical + } + }; + const ensurePackageInstalled = async ({ packageName, version }) => { - const alias = `${packageName.replace('@', '').replace('/', '-')}-v-${version}`; + let actualVersion = version; + + // For non-specific versions, check if we have a cached resolved version + if (isNonSpecificVersion(version)) { + const cachedVersion = await getCachedVersion(packageName, version); + if (cachedVersion) { + // Use the cached resolved version to build the alias and path + actualVersion = cachedVersion; + } else { + // Resolve the actual version and cache it + if (version === 'latest') { + actualVersion = await getLatestVersion(packageName); + } else { + // For versions like '4', resolve to actual version like '4.17.21' + try { + const { stdout: resolvedVersion } = await execAsync(`npm view ${packageName}@${version} version --json`); + const versions = JSON.parse(resolvedVersion); + // Get the highest version from the list + actualVersion = Array.isArray(versions) ? versions[versions.length - 1] : versions; + } catch (error) { + // Fallback to getLatestVersion if specific version resolution fails + actualVersion = await getLatestVersion(packageName); + } + } + // Cache the resolved version + await setCachedVersion(packageName, version, actualVersion); + } + } + + const alias = `${packageName.replace('@', '').replace('/', '-')}-v-${actualVersion}`; const { stdout: globalModulesPath } = await execAsync('npm root -g'); const packagePath = path.join(globalModulesPath.trim(), alias); - if (version !== 'latest' && await directoryExists(packagePath)) { - return packagePath; - } - if (version === 'latest') { - const latestVersion = await getLatestVersion(packageName); + + // Check if package is already installed + if (await directoryExists(packagePath)) { const installedVersion = await getInstalledPackageVersion(packagePath); - if (installedVersion === latestVersion) { + if (installedVersion === actualVersion) { return packagePath; } } + try { - await execAsync(`npm install -g ${alias}@npm:${packageName}@${version}`, { stdio: 'ignore' }); + await execAsync(`npm install -g ${alias}@npm:${packageName}@${actualVersion}`, { stdio: 'ignore' }); } catch (error) { - throw new Error(`Failed to install ${packageName}@${version} globally.`, { cause: error }); + throw new Error(`Failed to install ${packageName}@${actualVersion} globally.`, { cause: error }); } return packagePath; }; @@ -521,8 +608,112 @@ const resolvers = { } }; + const isNonSpecificVersion = (version) => { + // For now, only cache 'latest' versions to be safe + // TODO: extend this to handle major version ranges once we have proper version resolution + return version === 'latest'; + }; + + const getVersionCachePath = async (packageName, version) => { + const { tmpdir } = await import('node:os'); + const { existsSync, mkdirSync } = await import('node:fs'); + const cacheDir = path.join(tmpdir(), 'use-m-cache'); + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } + const cacheKey = `${packageName.replace('@', '').replace('/', '-')}-${version}`; + return path.join(cacheDir, `${cacheKey}.json`); + }; + + const getCachedVersion = async (packageName, version, cacheTimeoutMinutes = 5) => { + if (!isNonSpecificVersion(version)) { + return null; // Don't cache specific versions + } + + try { + const cachePath = await getVersionCachePath(packageName, version); + if (await fileExists(cachePath)) { + const cacheData = JSON.parse(await readFile(cachePath, 'utf8')); + const cacheAge = Date.now() - cacheData.timestamp; + const cacheTimeoutMs = cacheTimeoutMinutes * 60 * 1000; + + if (cacheAge < cacheTimeoutMs) { + return cacheData.resolvedVersion; + } + } + } catch { + // Ignore cache read errors, will fetch fresh version + } + return null; + }; + + const setCachedVersion = async (packageName, version, resolvedVersion) => { + if (!isNonSpecificVersion(version)) { + return; // Don't cache specific versions + } + + try { + const cachePath = await getVersionCachePath(packageName, version); + const cacheData = { + packageName, + requestedVersion: version, + resolvedVersion, + timestamp: Date.now() + }; + const { writeFile } = await import('node:fs/promises'); + await writeFile(cachePath, JSON.stringify(cacheData), 'utf8'); + } catch { + // Ignore cache write errors, not critical + } + }; + + const getLatestVersion = async (packageName) => { + const { stdout: version } = await execAsync(`npm show ${packageName} version`); + return version.trim(); + }; + + const getInstalledPackageVersion = async (packagePath) => { + try { + const packageJsonPath = path.join(packagePath, 'package.json'); + const data = await readFile(packageJsonPath, 'utf8'); + const { version } = JSON.parse(data); + return version; + } catch { + return null; + } + }; + const ensurePackageInstalled = async ({ packageName, version }) => { - const alias = `${packageName.replace('@', '').replace('/', '-')}-v-${version}`; + let actualVersion = version; + + // For non-specific versions, check if we have a cached resolved version + if (isNonSpecificVersion(version)) { + const cachedVersion = await getCachedVersion(packageName, version); + if (cachedVersion) { + // Use the cached resolved version to build the alias and path + actualVersion = cachedVersion; + } else { + // Resolve the actual version and cache it + if (version === 'latest') { + actualVersion = await getLatestVersion(packageName); + } else { + // For versions like '4', resolve to actual version like '4.17.21' + try { + const { stdout: resolvedVersion } = await execAsync(`npm view ${packageName}@${version} version --json`); + const versions = JSON.parse(resolvedVersion); + // Get the highest version from the list + actualVersion = Array.isArray(versions) ? versions[versions.length - 1] : versions; + } catch (error) { + // Fallback to getLatestVersion if specific version resolution fails + actualVersion = await getLatestVersion(packageName); + } + } + // Cache the resolved version + await setCachedVersion(packageName, version, actualVersion); + } + } + + const alias = `${packageName.replace('@', '').replace('/', '-')}-v-${actualVersion}`; let binDir = ''; try { @@ -543,14 +734,18 @@ const resolvers = { const globalModulesPath = path.join(bunInstallRoot, 'install', 'global', 'node_modules'); const packagePath = path.join(globalModulesPath, alias); - if (version !== 'latest' && await directoryExists(packagePath)) { - return packagePath; + // Check if package is already installed + if (await directoryExists(packagePath)) { + const installedVersion = await getInstalledPackageVersion(packagePath); + if (installedVersion === actualVersion) { + return packagePath; + } } try { - await execAsync(`bun add -g ${alias}@npm:${packageName}@${version} --silent`, { stdio: 'ignore' }); + await execAsync(`bun add -g ${alias}@npm:${packageName}@${actualVersion} --silent`, { stdio: 'ignore' }); } catch (error) { - throw new Error(`Failed to install ${packageName}@${version} globally with Bun.`, { cause: error }); + throw new Error(`Failed to install ${packageName}@${actualVersion} globally with Bun.`, { cause: error }); } return packagePath; @@ -647,6 +842,18 @@ const baseUse = async (modulePath) => { } } +const getScriptUrl = async () => { + const error = new Error(); + const stack = error.stack || ''; + const regex = /at[^:\\/]+(file:\/\/)?(?(\/|(?<=\W)\w:)[^):]+):\d+:\d+/; + const match = stack.match(regex); + if (!match?.groups?.path) { + return null; + } + const { pathToFileURL } = await import('node:url'); + return pathToFileURL(match.groups.path).href; +} + const makeUse = async (options) => { let scriptPath = options?.scriptPath; if (!scriptPath && typeof global !== 'undefined' && typeof global['__filename'] !== 'undefined') { @@ -656,6 +863,9 @@ const makeUse = async (options) => { if (!scriptPath && metaUrl) { scriptPath = metaUrl; } + if (!scriptPath && typeof window === 'undefined' && typeof require === 'undefined') { + scriptPath = await getScriptUrl(); + } let protocol; if (scriptPath) { try { diff --git a/use.js b/use.js index c2dcb3b..aea4cca 100644 --- a/use.js +++ b/use.js @@ -409,24 +409,111 @@ const resolvers = { } }; + const isNonSpecificVersion = (version) => { + // For now, only cache 'latest' versions to be safe + // TODO: extend this to handle major version ranges once we have proper version resolution + return version === 'latest'; + }; + + const getVersionCachePath = async (packageName, version) => { + const { tmpdir } = await import('node:os'); + const { existsSync, mkdirSync } = await import('node:fs'); + const cacheDir = path.join(tmpdir(), 'use-m-cache'); + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } + const cacheKey = `${packageName.replace('@', '').replace('/', '-')}-${version}`; + return path.join(cacheDir, `${cacheKey}.json`); + }; + + const getCachedVersion = async (packageName, version, cacheTimeoutMinutes = 5) => { + if (!isNonSpecificVersion(version)) { + return null; // Don't cache specific versions + } + + try { + const cachePath = await getVersionCachePath(packageName, version); + if (await fileExists(cachePath)) { + const cacheData = JSON.parse(await readFile(cachePath, 'utf8')); + const cacheAge = Date.now() - cacheData.timestamp; + const cacheTimeoutMs = cacheTimeoutMinutes * 60 * 1000; + + if (cacheAge < cacheTimeoutMs) { + return cacheData.resolvedVersion; + } + } + } catch { + // Ignore cache read errors, will fetch fresh version + } + return null; + }; + + const setCachedVersion = async (packageName, version, resolvedVersion) => { + if (!isNonSpecificVersion(version)) { + return; // Don't cache specific versions + } + + try { + const cachePath = await getVersionCachePath(packageName, version); + const cacheData = { + packageName, + requestedVersion: version, + resolvedVersion, + timestamp: Date.now() + }; + const { writeFile } = await import('node:fs/promises'); + await writeFile(cachePath, JSON.stringify(cacheData), 'utf8'); + } catch { + // Ignore cache write errors, not critical + } + }; + const ensurePackageInstalled = async ({ packageName, version }) => { - const alias = `${packageName.replace('@', '').replace('/', '-')}-v-${version}`; + let actualVersion = version; + + // For non-specific versions, check if we have a cached resolved version + if (isNonSpecificVersion(version)) { + const cachedVersion = await getCachedVersion(packageName, version); + if (cachedVersion) { + // Use the cached resolved version to build the alias and path + actualVersion = cachedVersion; + } else { + // Resolve the actual version and cache it + if (version === 'latest') { + actualVersion = await getLatestVersion(packageName); + } else { + // For versions like '4', resolve to actual version like '4.17.21' + try { + const { stdout: resolvedVersion } = await execAsync(`npm view ${packageName}@${version} version --json`); + const versions = JSON.parse(resolvedVersion); + // Get the highest version from the list + actualVersion = Array.isArray(versions) ? versions[versions.length - 1] : versions; + } catch (error) { + // Fallback to getLatestVersion if specific version resolution fails + actualVersion = await getLatestVersion(packageName); + } + } + // Cache the resolved version + await setCachedVersion(packageName, version, actualVersion); + } + } + + const alias = `${packageName.replace('@', '').replace('/', '-')}-v-${actualVersion}`; const { stdout: globalModulesPath } = await execAsync('npm root -g'); const packagePath = path.join(globalModulesPath.trim(), alias); - if (version !== 'latest' && await directoryExists(packagePath)) { - return packagePath; - } - if (version === 'latest') { - const latestVersion = await getLatestVersion(packageName); + + // Check if package is already installed + if (await directoryExists(packagePath)) { const installedVersion = await getInstalledPackageVersion(packagePath); - if (installedVersion === latestVersion) { + if (installedVersion === actualVersion) { return packagePath; } } + try { - await execAsync(`npm install -g ${alias}@npm:${packageName}@${version}`, { stdio: 'ignore' }); + await execAsync(`npm install -g ${alias}@npm:${packageName}@${actualVersion}`, { stdio: 'ignore' }); } catch (error) { - throw new Error(`Failed to install ${packageName}@${version} globally.`, { cause: error }); + throw new Error(`Failed to install ${packageName}@${actualVersion} globally.`, { cause: error }); } return packagePath; }; @@ -521,8 +608,112 @@ const resolvers = { } }; + const isNonSpecificVersion = (version) => { + // For now, only cache 'latest' versions to be safe + // TODO: extend this to handle major version ranges once we have proper version resolution + return version === 'latest'; + }; + + const getVersionCachePath = async (packageName, version) => { + const { tmpdir } = await import('node:os'); + const { existsSync, mkdirSync } = await import('node:fs'); + const cacheDir = path.join(tmpdir(), 'use-m-cache'); + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } + const cacheKey = `${packageName.replace('@', '').replace('/', '-')}-${version}`; + return path.join(cacheDir, `${cacheKey}.json`); + }; + + const getCachedVersion = async (packageName, version, cacheTimeoutMinutes = 5) => { + if (!isNonSpecificVersion(version)) { + return null; // Don't cache specific versions + } + + try { + const cachePath = await getVersionCachePath(packageName, version); + if (await fileExists(cachePath)) { + const cacheData = JSON.parse(await readFile(cachePath, 'utf8')); + const cacheAge = Date.now() - cacheData.timestamp; + const cacheTimeoutMs = cacheTimeoutMinutes * 60 * 1000; + + if (cacheAge < cacheTimeoutMs) { + return cacheData.resolvedVersion; + } + } + } catch { + // Ignore cache read errors, will fetch fresh version + } + return null; + }; + + const setCachedVersion = async (packageName, version, resolvedVersion) => { + if (!isNonSpecificVersion(version)) { + return; // Don't cache specific versions + } + + try { + const cachePath = await getVersionCachePath(packageName, version); + const cacheData = { + packageName, + requestedVersion: version, + resolvedVersion, + timestamp: Date.now() + }; + const { writeFile } = await import('node:fs/promises'); + await writeFile(cachePath, JSON.stringify(cacheData), 'utf8'); + } catch { + // Ignore cache write errors, not critical + } + }; + + const getLatestVersion = async (packageName) => { + const { stdout: version } = await execAsync(`npm show ${packageName} version`); + return version.trim(); + }; + + const getInstalledPackageVersion = async (packagePath) => { + try { + const packageJsonPath = path.join(packagePath, 'package.json'); + const data = await readFile(packageJsonPath, 'utf8'); + const { version } = JSON.parse(data); + return version; + } catch { + return null; + } + }; + const ensurePackageInstalled = async ({ packageName, version }) => { - const alias = `${packageName.replace('@', '').replace('/', '-')}-v-${version}`; + let actualVersion = version; + + // For non-specific versions, check if we have a cached resolved version + if (isNonSpecificVersion(version)) { + const cachedVersion = await getCachedVersion(packageName, version); + if (cachedVersion) { + // Use the cached resolved version to build the alias and path + actualVersion = cachedVersion; + } else { + // Resolve the actual version and cache it + if (version === 'latest') { + actualVersion = await getLatestVersion(packageName); + } else { + // For versions like '4', resolve to actual version like '4.17.21' + try { + const { stdout: resolvedVersion } = await execAsync(`npm view ${packageName}@${version} version --json`); + const versions = JSON.parse(resolvedVersion); + // Get the highest version from the list + actualVersion = Array.isArray(versions) ? versions[versions.length - 1] : versions; + } catch (error) { + // Fallback to getLatestVersion if specific version resolution fails + actualVersion = await getLatestVersion(packageName); + } + } + // Cache the resolved version + await setCachedVersion(packageName, version, actualVersion); + } + } + + const alias = `${packageName.replace('@', '').replace('/', '-')}-v-${actualVersion}`; let binDir = ''; try { @@ -543,14 +734,18 @@ const resolvers = { const globalModulesPath = path.join(bunInstallRoot, 'install', 'global', 'node_modules'); const packagePath = path.join(globalModulesPath, alias); - if (version !== 'latest' && await directoryExists(packagePath)) { - return packagePath; + // Check if package is already installed + if (await directoryExists(packagePath)) { + const installedVersion = await getInstalledPackageVersion(packagePath); + if (installedVersion === actualVersion) { + return packagePath; + } } try { - await execAsync(`bun add -g ${alias}@npm:${packageName}@${version} --silent`, { stdio: 'ignore' }); + await execAsync(`bun add -g ${alias}@npm:${packageName}@${actualVersion} --silent`, { stdio: 'ignore' }); } catch (error) { - throw new Error(`Failed to install ${packageName}@${version} globally with Bun.`, { cause: error }); + throw new Error(`Failed to install ${packageName}@${actualVersion} globally with Bun.`, { cause: error }); } return packagePath; diff --git a/use.mjs b/use.mjs index 250fe81..3ec1106 100644 --- a/use.mjs +++ b/use.mjs @@ -72,7 +72,7 @@ const extractCallerContext = (stack) => { return null; }; -export const parseModuleSpecifier = (moduleSpecifier) => { +const parseModuleSpecifier = (moduleSpecifier) => { if (!moduleSpecifier || typeof moduleSpecifier !== 'string' || moduleSpecifier.length <= 0) { throw new Error( `Name for a package to be imported is not provided. @@ -214,7 +214,7 @@ const supportedBuiltins = { } }; -export const resolvers = { +const resolvers = { builtin: async (moduleSpecifier, pathResolver) => { const { packageName } = parseModuleSpecifier(moduleSpecifier); @@ -409,24 +409,111 @@ export const resolvers = { } }; + const isNonSpecificVersion = (version) => { + // For now, only cache 'latest' versions to be safe + // TODO: extend this to handle major version ranges once we have proper version resolution + return version === 'latest'; + }; + + const getVersionCachePath = async (packageName, version) => { + const { tmpdir } = await import('node:os'); + const { existsSync, mkdirSync } = await import('node:fs'); + const cacheDir = path.join(tmpdir(), 'use-m-cache'); + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } + const cacheKey = `${packageName.replace('@', '').replace('/', '-')}-${version}`; + return path.join(cacheDir, `${cacheKey}.json`); + }; + + const getCachedVersion = async (packageName, version, cacheTimeoutMinutes = 5) => { + if (!isNonSpecificVersion(version)) { + return null; // Don't cache specific versions + } + + try { + const cachePath = await getVersionCachePath(packageName, version); + if (await fileExists(cachePath)) { + const cacheData = JSON.parse(await readFile(cachePath, 'utf8')); + const cacheAge = Date.now() - cacheData.timestamp; + const cacheTimeoutMs = cacheTimeoutMinutes * 60 * 1000; + + if (cacheAge < cacheTimeoutMs) { + return cacheData.resolvedVersion; + } + } + } catch { + // Ignore cache read errors, will fetch fresh version + } + return null; + }; + + const setCachedVersion = async (packageName, version, resolvedVersion) => { + if (!isNonSpecificVersion(version)) { + return; // Don't cache specific versions + } + + try { + const cachePath = await getVersionCachePath(packageName, version); + const cacheData = { + packageName, + requestedVersion: version, + resolvedVersion, + timestamp: Date.now() + }; + const { writeFile } = await import('node:fs/promises'); + await writeFile(cachePath, JSON.stringify(cacheData), 'utf8'); + } catch { + // Ignore cache write errors, not critical + } + }; + const ensurePackageInstalled = async ({ packageName, version }) => { - const alias = `${packageName.replace('@', '').replace('/', '-')}-v-${version}`; + let actualVersion = version; + + // For non-specific versions, check if we have a cached resolved version + if (isNonSpecificVersion(version)) { + const cachedVersion = await getCachedVersion(packageName, version); + if (cachedVersion) { + // Use the cached resolved version to build the alias and path + actualVersion = cachedVersion; + } else { + // Resolve the actual version and cache it + if (version === 'latest') { + actualVersion = await getLatestVersion(packageName); + } else { + // For versions like '4', resolve to actual version like '4.17.21' + try { + const { stdout: resolvedVersion } = await execAsync(`npm view ${packageName}@${version} version --json`); + const versions = JSON.parse(resolvedVersion); + // Get the highest version from the list + actualVersion = Array.isArray(versions) ? versions[versions.length - 1] : versions; + } catch (error) { + // Fallback to getLatestVersion if specific version resolution fails + actualVersion = await getLatestVersion(packageName); + } + } + // Cache the resolved version + await setCachedVersion(packageName, version, actualVersion); + } + } + + const alias = `${packageName.replace('@', '').replace('/', '-')}-v-${actualVersion}`; const { stdout: globalModulesPath } = await execAsync('npm root -g'); const packagePath = path.join(globalModulesPath.trim(), alias); - if (version !== 'latest' && await directoryExists(packagePath)) { - return packagePath; - } - if (version === 'latest') { - const latestVersion = await getLatestVersion(packageName); + + // Check if package is already installed + if (await directoryExists(packagePath)) { const installedVersion = await getInstalledPackageVersion(packagePath); - if (installedVersion === latestVersion) { + if (installedVersion === actualVersion) { return packagePath; } } + try { - await execAsync(`npm install -g ${alias}@npm:${packageName}@${version}`, { stdio: 'ignore' }); + await execAsync(`npm install -g ${alias}@npm:${packageName}@${actualVersion}`, { stdio: 'ignore' }); } catch (error) { - throw new Error(`Failed to install ${packageName}@${version} globally.`, { cause: error }); + throw new Error(`Failed to install ${packageName}@${actualVersion} globally.`, { cause: error }); } return packagePath; }; @@ -521,8 +608,112 @@ export const resolvers = { } }; + const isNonSpecificVersion = (version) => { + // For now, only cache 'latest' versions to be safe + // TODO: extend this to handle major version ranges once we have proper version resolution + return version === 'latest'; + }; + + const getVersionCachePath = async (packageName, version) => { + const { tmpdir } = await import('node:os'); + const { existsSync, mkdirSync } = await import('node:fs'); + const cacheDir = path.join(tmpdir(), 'use-m-cache'); + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } + const cacheKey = `${packageName.replace('@', '').replace('/', '-')}-${version}`; + return path.join(cacheDir, `${cacheKey}.json`); + }; + + const getCachedVersion = async (packageName, version, cacheTimeoutMinutes = 5) => { + if (!isNonSpecificVersion(version)) { + return null; // Don't cache specific versions + } + + try { + const cachePath = await getVersionCachePath(packageName, version); + if (await fileExists(cachePath)) { + const cacheData = JSON.parse(await readFile(cachePath, 'utf8')); + const cacheAge = Date.now() - cacheData.timestamp; + const cacheTimeoutMs = cacheTimeoutMinutes * 60 * 1000; + + if (cacheAge < cacheTimeoutMs) { + return cacheData.resolvedVersion; + } + } + } catch { + // Ignore cache read errors, will fetch fresh version + } + return null; + }; + + const setCachedVersion = async (packageName, version, resolvedVersion) => { + if (!isNonSpecificVersion(version)) { + return; // Don't cache specific versions + } + + try { + const cachePath = await getVersionCachePath(packageName, version); + const cacheData = { + packageName, + requestedVersion: version, + resolvedVersion, + timestamp: Date.now() + }; + const { writeFile } = await import('node:fs/promises'); + await writeFile(cachePath, JSON.stringify(cacheData), 'utf8'); + } catch { + // Ignore cache write errors, not critical + } + }; + + const getLatestVersion = async (packageName) => { + const { stdout: version } = await execAsync(`npm show ${packageName} version`); + return version.trim(); + }; + + const getInstalledPackageVersion = async (packagePath) => { + try { + const packageJsonPath = path.join(packagePath, 'package.json'); + const data = await readFile(packageJsonPath, 'utf8'); + const { version } = JSON.parse(data); + return version; + } catch { + return null; + } + }; + const ensurePackageInstalled = async ({ packageName, version }) => { - const alias = `${packageName.replace('@', '').replace('/', '-')}-v-${version}`; + let actualVersion = version; + + // For non-specific versions, check if we have a cached resolved version + if (isNonSpecificVersion(version)) { + const cachedVersion = await getCachedVersion(packageName, version); + if (cachedVersion) { + // Use the cached resolved version to build the alias and path + actualVersion = cachedVersion; + } else { + // Resolve the actual version and cache it + if (version === 'latest') { + actualVersion = await getLatestVersion(packageName); + } else { + // For versions like '4', resolve to actual version like '4.17.21' + try { + const { stdout: resolvedVersion } = await execAsync(`npm view ${packageName}@${version} version --json`); + const versions = JSON.parse(resolvedVersion); + // Get the highest version from the list + actualVersion = Array.isArray(versions) ? versions[versions.length - 1] : versions; + } catch (error) { + // Fallback to getLatestVersion if specific version resolution fails + actualVersion = await getLatestVersion(packageName); + } + } + // Cache the resolved version + await setCachedVersion(packageName, version, actualVersion); + } + } + + const alias = `${packageName.replace('@', '').replace('/', '-')}-v-${actualVersion}`; let binDir = ''; try { @@ -543,14 +734,18 @@ export const resolvers = { const globalModulesPath = path.join(bunInstallRoot, 'install', 'global', 'node_modules'); const packagePath = path.join(globalModulesPath, alias); - if (version !== 'latest' && await directoryExists(packagePath)) { - return packagePath; + // Check if package is already installed + if (await directoryExists(packagePath)) { + const installedVersion = await getInstalledPackageVersion(packagePath); + if (installedVersion === actualVersion) { + return packagePath; + } } try { - await execAsync(`bun add -g ${alias}@npm:${packageName}@${version} --silent`, { stdio: 'ignore' }); + await execAsync(`bun add -g ${alias}@npm:${packageName}@${actualVersion} --silent`, { stdio: 'ignore' }); } catch (error) { - throw new Error(`Failed to install ${packageName}@${version} globally with Bun.`, { cause: error }); + throw new Error(`Failed to install ${packageName}@${actualVersion} globally with Bun.`, { cause: error }); } return packagePath; @@ -610,7 +805,7 @@ export const resolvers = { }, } -export const baseUse = async (modulePath) => { +const baseUse = async (modulePath) => { // Dynamically import the module try { const module = await import(modulePath); @@ -647,7 +842,19 @@ export const baseUse = async (modulePath) => { } } -export const makeUse = async (options) => { +const getScriptUrl = async () => { + const error = new Error(); + const stack = error.stack || ''; + const regex = /at[^:\\/]+(file:\/\/)?(?(\/|(?<=\W)\w:)[^):]+):\d+:\d+/; + const match = stack.match(regex); + if (!match?.groups?.path) { + return null; + } + const { pathToFileURL } = await import('node:url'); + return pathToFileURL(match.groups.path).href; +} + +const makeUse = async (options) => { let scriptPath = options?.scriptPath; if (!scriptPath && typeof global !== 'undefined' && typeof global['__filename'] !== 'undefined') { scriptPath = global['__filename']; @@ -656,8 +863,8 @@ export const makeUse = async (options) => { if (!scriptPath && metaUrl) { scriptPath = metaUrl; } - if (!scriptPath) { - scriptPath = import.meta.url; + if (!scriptPath && typeof window === 'undefined' && typeof require === 'undefined') { + scriptPath = await getScriptUrl(); } let protocol; if (scriptPath) { @@ -730,7 +937,7 @@ export const makeUse = async (options) => { } let __use = null; -const _use = async (moduleSpecifier) => { +const use = async (moduleSpecifier) => { const stack = new Error().stack; // For Bun, we need to capture the stack trace before any other calls @@ -757,10 +964,11 @@ const _use = async (moduleSpecifier) => { } return __use(moduleSpecifier, callerContext); } -_use.all = async (...moduleSpecifiers) => { +use.all = async (...moduleSpecifiers) => { if (!__use) { __use = await makeUse(); } return Promise.all(moduleSpecifiers.map(__use)); } -export const use = _use; + +export { parseModuleSpecifier, resolvers, makeUse, baseUse, use }; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 7c44d21..f286c44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1915,11 +1915,6 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2: - version "2.3.3" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"