UNPKG

elm-review

Version:
507 lines (456 loc) 13.5 kB
/** * @import {Options} from './types/options'; * @import {Path} from './types/path'; * @import {VersionString} from './types/version'; */ const path = require('node:path'); const wasm = require('elm-solve-deps-wasm'); const fs = require('graceful-fs'); const FS = require('./fs-wrapper'); const ProjectJsonFiles = require('./project-json-files'); const SyncGet = require('./sync-get'); const {intoError} = require('./utils'); /** @type {boolean} */ let wasmWasInitialized = false; /** @type {{ get: (remoteUrl: string) => string, shutDown: () => void } | null} */ let syncGetWorker = null; class DependencyProvider { /** @type {OnlineVersionsCache} */ cache; constructor() { this.cache = new OnlineVersionsCache(); if (!wasmWasInitialized) { wasm.init(); wasmWasInitialized = true; } syncGetWorker = SyncGet.startWorker(); } /** * Solve dependencies completely offline, without any http request. * * @param {Options} options * @param {VersionString} elmVersion * @param {string} elmJson * @param {Record<string, string>} extra * @returns {string} */ solveOffline(options, elmVersion, elmJson, extra) { const lister = new OfflineAvailableVersionLister(); try { return wasm.solve_deps( elmJson, false, extra, (pkg, version) => fetchElmJsonOffline(options, elmVersion, pkg, version), (pkg) => lister.list(elmVersion, pkg) ); } catch (error) { throw intoError(error); } } /** * Solve dependencies with http requests when required. * * @param {Options} options * @param {VersionString} elmVersion * @param {string} elmJson * @param {Record<string, string>} extra * @returns {string} */ solveOnline(options, elmVersion, elmJson, extra) { const lister = new OnlineAvailableVersionLister( options, this.cache, elmVersion ); try { return wasm.solve_deps( elmJson, false, extra, (pkg, version) => fetchElmJsonOnline(options, elmVersion, pkg, version), (pkg) => lister.list(elmVersion, pkg) ); } catch (error) { throw intoError(error); } } /** * @returns {void} */ tearDown() { if (!syncGetWorker) { return; } syncGetWorker.shutDown(); syncGetWorker = null; } } class OfflineAvailableVersionLister { /** * Memoization cache to avoid doing the same work twice in list. * * @type {Map<string, string[]>} */ cache = new Map(); /** * @param {VersionString} elmVersion * @param {string} pkg * @returns {string[]} */ list(elmVersion, pkg) { const memoVersions = this.cache.get(pkg); if (memoVersions !== undefined) { return memoVersions; } const offlineVersions = readVersionsInElmHomeAndSort(elmVersion, pkg); this.cache.set(pkg, offlineVersions); return offlineVersions; } } /** * Cache of existing versions according to the package website. */ class OnlineVersionsCache { /** @type {Map<string, string[]>} */ map = new Map(); /** * @param {Options} options * @param {VersionString} elmVersion * @returns {void} */ update(options, elmVersion) { const cachePath = dependenciesCachePath( options.packageJsonVersion, elmVersion ); const remotePackagesUrl = 'https://package.elm-lang.org/all-packages'; if (this.map.size === 0) { let cacheFile; try { // Read from disk existing versions which are already cached. cacheFile = fs.readFileSync(cachePath, 'utf8'); } catch { // The cache file does not exist so let's reset it. this.map = onlineVersionsFromScratch(cachePath, remotePackagesUrl); return; } try { this.map = parseOnlineVersions(JSON.parse(cacheFile)); } catch (error) { throw new Error( `Failed to parse the cache file ${cachePath}.\n${error.message}` ); } } this.updateWithRequestSince(cachePath, remotePackagesUrl); } /** * Update the cache with a request to the package server. * * @param {string} cachePath * @param {string} remotePackagesUrl * @returns {void} */ updateWithRequestSince(cachePath, remotePackagesUrl) { // Count existing versions. let versionsCount = 0; for (const versions of this.map.values()) { versionsCount += versions.length; } // Complete cache with a remote call to the package server. const remoteUrl = remotePackagesUrl + '/since/' + (versionsCount - 1); // -1 to check if no package was deleted. if (!syncGetWorker) { return; } /** @type {string[]} */ const newVersions = JSON.parse(syncGetWorker.get(remoteUrl)); if (newVersions.length === 0) { // Reload from scratch since it means at least one package was deleted from the registry. this.map = onlineVersionsFromScratch(cachePath, remotePackagesUrl); return; } // Check that the last package in the list was already in cache // since the list returned by the package server is sorted newest first. const {pkg, version} = splitPkgVersion( /** @type {string} */ (newVersions.pop()) ); const cachePkgVersions = this.map.get(pkg); if ( cachePkgVersions !== undefined && cachePkgVersions[cachePkgVersions.length - 1] === version ) { // Insert (in reverse) newVersions into onlineVersionsCache map. for (const pkgVersion of newVersions.reverse()) { const {pkg, version} = splitPkgVersion(pkgVersion); const versionsOfPkg = this.map.get(pkg); if (versionsOfPkg === undefined) { this.map.set(pkg, [version]); } else { versionsOfPkg.push(version); } } // Save the updated onlineVersionsCache to disk. const onlineVersions = Object.fromEntries(this.map.entries()); fs.writeFileSync(cachePath, JSON.stringify(onlineVersions)); } else { // There was a problem and a package got deleted from the server. this.map = onlineVersionsFromScratch(cachePath, remotePackagesUrl); } } /** * List the versions for a package. * * @param {string} pkg * @returns {string[]} */ getVersions(pkg) { const versions = this.map.get(pkg); return versions ?? []; } } class OnlineAvailableVersionLister { /** * Memoization cache to avoid doing the same work twice in list. * * @type {Map<string, string[]>} */ memoCache = new Map(); /** @type {OnlineVersionsCache} */ onlineCache; /** * @param {Options} options * @param {OnlineVersionsCache} onlineCache * @param {VersionString} elmVersion */ constructor(options, onlineCache, elmVersion) { onlineCache.update(options, elmVersion); this.onlineCache = onlineCache; } /** * @param {VersionString} elmVersion * @param {string} pkg * @returns {string[]} */ list(elmVersion, pkg) { const memoVersions = this.memoCache.get(pkg); if (memoVersions !== undefined) { return memoVersions; } const offlineVersions = readVersionsInElmHomeAndSort(elmVersion, pkg); const allVersionsSet = new Set(this.onlineCache.getVersions(pkg)); // Combine local and online versions. for (const version of offlineVersions) { allVersionsSet.add(version); } const allVersions = [...allVersionsSet].sort(flippedSemverCompare); this.memoCache.set(pkg, allVersions); return allVersions; } } /** * @param {VersionString} elmVersion * @param {string} pkg * @returns {string[]} */ function readVersionsInElmHomeAndSort(elmVersion, pkg) { const pkgPath = ProjectJsonFiles.getPackagePathInElmHome(elmVersion, pkg); /** @type {string[]} */ let offlineVersions; try { offlineVersions = fs.readdirSync(pkgPath); } catch { // The directory doesn't exist or we don't have permissions. // It's fine to catch all cases and return an empty list. offlineVersions = []; } return offlineVersions.sort(flippedSemverCompare); } /** * Solve dependencies completely offline, without any http request. * * @param {Options} options * @param {VersionString} elmVersion * @param {string} pkg * @param {string} version * @returns {string} */ function fetchElmJsonOnline(options, elmVersion, pkg, version) { try { return fetchElmJsonOffline(options, elmVersion, pkg, version); } catch { // `fetchElmJsonOffline` can only fail in ways that are either expected // (such as file does not exist or no permissions) // or because there was an error parsing `pkg` and `version`. // In such case, this will throw again with `cacheElmJsonPath()` so it's fine. const remoteUrl = remoteElmJsonUrl(pkg, version); if (!syncGetWorker) { return ''; } const elmJson = syncGetWorker.get(remoteUrl); const cachePath = ProjectJsonFiles.elmReviewDependencyCache( options, elmVersion, pkg, version, 'elm.json' ); const parentDir = path.dirname(cachePath); fs.mkdirSync(parentDir, {recursive: true}); fs.writeFileSync(cachePath, elmJson); return elmJson; } } /** * @param {Options} options * @param {VersionString} elmVersion * @param {string} pkg * @param {string} version * @returns {string} */ function fetchElmJsonOffline(options, elmVersion, pkg, version) { try { return fs.readFileSync( ProjectJsonFiles.getElmJsonFromElmHomePath(elmVersion, pkg, version), 'utf8' ); } catch { // The read can only fail if the elm.json file does not exist // or if we don't have the permissions to read it so it's fine to catch all. // Otherwise, it means that `homeElmJsonPath()` failed while processing `pkg` and `version`. // In such case, again, it's fine to catch all since the next call to `cacheElmJsonPath()` // will fail the same anyway. const cachePath = ProjectJsonFiles.elmReviewDependencyCache( options, elmVersion, pkg, version, 'elm.json' ); return fs.readFileSync(cachePath, 'utf8'); } } /** * Reset the cache of existing versions from scratch with a request to the package server. * * @param {string} cachePath * @param {string} remotePackagesUrl * @returns {Map<string, string[]>} */ function onlineVersionsFromScratch(cachePath, remotePackagesUrl) { if (!syncGetWorker) { return new Map(); } const onlineVersionsJson = syncGetWorker.get(remotePackagesUrl); FS.mkdirpSync(path.dirname(cachePath)); fs.writeFileSync(cachePath, onlineVersionsJson); const onlineVersions = JSON.parse(onlineVersionsJson); try { return parseOnlineVersions(onlineVersions); } catch (error) { throw new Error( `Failed to parse the response from the request to ${remotePackagesUrl}.\n${error.message}` ); } } // Helper functions ################################################## /** * Compares two versions so that newer versions appear first when sorting with this function. * * @param {string} a * @param {string} b * @returns {number} */ function flippedSemverCompare(a, b) { const vA = a.split('.'); const vB = b.split('.'); return ( compareNumber(vA[0], vB[0]) || compareNumber(vA[1], vB[1]) || compareNumber(vA[2], vB[2]) ); } /** * Compare 2 numbers as string * * @param {string} a * @param {string} b * @returns {number} */ function compareNumber(a, b) { return Number.parseInt(b, 10) - Number.parseInt(a, 10); } /** * @param {unknown} json * @returns {Map<string, string[]>} */ function parseOnlineVersions(json) { if (typeof json !== 'object' || json === null || Array.isArray(json)) { throw new Error( `Expected an object, but got: ${ json === null ? 'null' : Array.isArray(json) ? 'Array' : typeof json }` ); } /** @type {Map<string, string[]>} */ const result = new Map(); for (const [key, value] of Object.entries(json)) { result.set(key, parseVersions(key, value)); } return result; } /** * @param {string} key * @param {unknown} json * @returns {string[]} */ function parseVersions(key, json) { if (!Array.isArray(json)) { throw new TypeError( `Expected ${JSON.stringify(key)} to be an array, but got: ${typeof json}` ); } for (const [index, item] of json.entries()) { if (typeof item !== 'string') { throw new TypeError( `Expected${JSON.stringify( key )}->${index} to be a string, but got: ${typeof item}` ); } } const castedJson = /** @type {string[]} */ (json); return castedJson; } /** * Cache in which we'll store information related Elm dependencies, computed by elm-solve-deps-wasm. * * @param {string} packageJsonVersion * @param {VersionString} elmVersion * @returns {Path} */ function dependenciesCachePath(packageJsonVersion, elmVersion) { return path.join( ProjectJsonFiles.elmHomeCache(packageJsonVersion), elmVersion, 'versions-cache.json' ); } /** * @param {string} pkg * @param {string} version * @returns {string} */ function remoteElmJsonUrl(pkg, version) { return `https://package.elm-lang.org/packages/${pkg}/${version}/elm.json`; } /** * @param {string} str * @returns {{pkg: string, version: string}} */ function splitPkgVersion(str) { const [pkg, version] = str.split('@'); return {pkg, version}; } module.exports = DependencyProvider;