UNPKG

snowdev

Version:

Zero configuration, unbundled, opinionated, development and prototyping server for simple ES modules development: types generation, format and linting, dev server and TypeScript support.

478 lines (410 loc) 14 kB
import { promises as fs } from "node:fs"; import { createRequire } from "node:module"; import { dirname, extname, isAbsolute, join, parse, resolve } from "node:path"; import console from "console-ansi"; import deepmerge from "deepmerge"; import slash from "slash"; import { createFilter } from "@rollup/pluginutils"; import { RF_OPTIONS, resolveExports, resolveBrowserIgnores, pathExists, VERSION, listFormatter, arrayDifference, dotRelativeToBarePath, bareToDotRelativePath, pick, readJson, writeJson, } from "./utils.js"; import npm from "./npm.js"; import bundle from "./bundle.js"; const require = createRequire(import.meta.url); const DEPENDENCY_TYPES = Object.freeze({ ALL: "all", DEV: "dev", PROD: "prod", CUSTOM: "custom", }); const DEPENDENCY_SAVE_TYPE_MAP = { [DEPENDENCY_TYPES.ALL]: ["prod", "dev"], [DEPENDENCY_TYPES.DEV]: ["dev"], [DEPENDENCY_TYPES.PROD]: ["prod"], [DEPENDENCY_TYPES.CUSTOM]: [], }; const getDependencies = async (options, type, names = []) => { const depsSelector = type === DEPENDENCY_TYPES.CUSTOM ? "*" : `*:is(${DEPENDENCY_SAVE_TYPE_MAP[type] .map((selector) => `.${selector}`) .join(",")})`; return JSON.parse( await npm.run(options.cwd, "query", [`':scope > ${depsSelector}'`]), ) .map((dependency) => pick(dependency, ["name", "version", "dev", "realpath"]), ) .filter(({ name }) => (names.length ? names.includes(name) : true)); }; const compareDependencies = ({ name, version }, { version: v, name: n }) => version === v && name === n; const install = async (options) => { // Check package.json exists try { await readJson(join(options.cwd, "package.json")); } catch (error) { console.error(`install - error reading package.json\n`, error); return { error }; } // Check dependencies try { const selectors = ["missing", "invalid", "extraneous"]; for (let selector of selectors) { const results = JSON.parse( await npm.run(options.cwd, "query", [`':${selector}'`]), ); if (results.length) { console.warn( `install - ${selector} dependencies: ${listFormatter.format(results.map(({ pkgid }) => pkgid))}`, ); } } } catch (error) { // This is only a warning. Don't throw if anything unexpected happen. } // Get install type: an array of custom dependencies or one of DEPENDENCY_TYPES values const type = Array.isArray(options.dependencies) ? DEPENDENCY_TYPES.CUSTOM : options.dependencies; // Resolve cache and output paths await fs.mkdir(options.cacheFolder, { recursive: true }); const dependenciesCacheFile = join(options.cacheFolder, "dependencies.json"); const outputDir = isAbsolute(options.rollup.output.dir) ? options.rollup.output.dir : join(options.cwd, options.rollup.output.dir); const importMapFile = join(outputDir, "import-map.json"); // Get current dependencies const dependencies = type === DEPENDENCY_TYPES.CUSTOM && !options.dependencies.length ? [] : await getDependencies( options, type, type === DEPENDENCY_TYPES.CUSTOM ? options.dependencies : [], ); const dependenciesNames = dependencies.map(({ name }) => name); const dependenciesHardcoded = type === DEPENDENCY_TYPES.CUSTOM ? options.dependencies.filter((name) => !dependenciesNames.includes(name)) : []; if (options.force) { console.info("install - force install."); } else if (!(await pathExists(outputDir))) { // Check if dist folder exists console.info("install - initial installation."); } else { try { // Get cached values // TODO: handle options.importMap change let cachedVersion = ""; let cachedType = DEPENDENCY_TYPES.CUSTOM; let cachedDependencies = {}; let cachedDependenciesHardcoded = []; ({ version: cachedVersion, type: cachedType, dependencies: cachedDependencies, dependenciesHardcoded: cachedDependenciesHardcoded, } = await readJson(dependenciesCacheFile)); // Check type or list of dependencies change // Calling install from CLI will always force install if (type !== cachedType) { console.info("install - dependency type changed."); } else if (VERSION !== cachedVersion) { console.info("install - snowdev version changed."); } else if (options.caller === "cli" && options.command === "install") { console.info("install - from cli."); } else { const changedDependencies = arrayDifference( dependencies, cachedDependencies, compareDependencies, ); const changedDependenciesHardcoded = arrayDifference( dependenciesHardcoded, cachedDependenciesHardcoded, ); if ( changedDependencies.length + changedDependenciesHardcoded.length === 0 ) { console.log("install - all dependencies installed."); return { importMap: deepmerge( await readJson(importMapFile), options.importMap, ), }; } else { console.log( `install - dependencies changed: ${listFormatter.format([ ...new Set( changedDependencies .map((dependency) => dependency.name) .concat(changedDependenciesHardcoded), ), ])}.`, ); } } } catch (error) { console.info(`install - no dependencies cached.`); } } // Remove output to empty it or bundle in it try { await fs.rm(outputDir, RF_OPTIONS); await fs.mkdir(outputDir, { recursive: true }); } catch (error) { console.error(`install - error removing output directory\n`, error); return { error }; } const installTargets = dependenciesNames.concat(dependenciesHardcoded); if (installTargets.length === 0) { await writeJson(dependenciesCacheFile, { version: VERSION, type, dependencies: {}, dependenciesHardcoded: {}, }); console.warn(`No ESM dependencies to install. Set "options.dependencies".`); return { importMap: options.importMap }; } const label = `install`; console.time(label); console.info( `install - ESM dependencies: ${listFormatter.format(installTargets)}`, ); let result; let input = {}; let importMap = { imports: {} }; let copies = {}; const filter = createFilter( options.resolve.include, options.resolve.exclude, { resolve: options.cwd }, ); const copyFilter = createFilter( options.resolve.copy, options.resolve.exclude, { resolve: options.cwd }, ); const packageTargets = dependenciesNames.filter( (target) => target !== "snowdev", ); // Harcoded dependency can be: // - a package listed in package.json // - a relative file path // - a package inside a package to be added as target // - a file inside a package to be added as target // - no relative folder support (use "local-dep-name": "file:./path-to-local-dep" in pacakge.json instead) // - no absolute path support (what would the import map be?) await Promise.allSettled( dependenciesHardcoded.map(async (dependency) => { try { if (parse(dependency).ext) { const isRelative = dependency.startsWith("."); const resolvedExport = isRelative ? resolve(options.cwd, dependency) : require.resolve(dependency, { paths: [options.cwd] }); if (!filter(resolvedExport)) { console.info(`Filtered out export: ${resolvedExport}`); } else { const id = isRelative ? dotRelativeToBarePath(dependency) : dependency; const isCopiedExport = copyFilter(resolvedExport); if (isCopiedExport) { copies[resolvedExport] = join(outputDir, id); } else { // TODO: why not resolvedExport? input[id] = dependency; } importMap.imports[id] = isRelative ? dependency : bareToDotRelativePath(dependency); } } else { packageTargets.push(dependency); } } catch (error) { console.error(error); } }), ); try { console.log(`install - installing (${options.transpiler})...`); const dependenciesPath = Object.fromEntries( packageTargets.map((target) => { let dependencyPath = dependencies.find( ({ name }) => name === target, )?.realpath; if (!dependencyPath) { const parent = target .split("/") .slice(0, target.startsWith("@") ? 2 : 1) .join("/"); const parentRealPath = dependencies.find( ({ name }) => name === parent, )?.realpath; if (parentRealPath) { dependencyPath = join( parentRealPath.slice(0, parentRealPath.lastIndexOf(parent)), target, ); } } return [target, dependencyPath]; }), ); const resolvedExportsMap = deepmerge( Object.fromEntries( await Promise.all( packageTargets.map(async (dependency) => [ dependency, await resolveExports(options, dependenciesPath[dependency]), ]), ), ), options.resolve.overrides, ); // TODO: parallelize for (let [dependency, entryPoints] of Object.entries(resolvedExportsMap)) { const dependencyPath = dependenciesPath[dependency]; if (!(await pathExists(dependencyPath))) { console.error(`Unresolved dependency: is "${dependency}" installed?`); continue; } for (let [specifier, entryPoint] of Object.entries(entryPoints)) { const isMain = specifier === "."; const id = isMain ? dependency : slash(join(dependency, specifier)); if (!entryPoint) { console.error( `Unresolved export: "${dependency}" "${specifier}": is "${dependency}" installed or not exporting anything?`, ); continue; } try { const resolvedExport = join(dependencyPath, entryPoint); if (!filter(resolvedExport)) { console.info(`Filtered out export: ${resolvedExport}`); continue; } if (!(await pathExists(resolvedExport))) { console.error(`Unknown export: ${resolvedExport}`); continue; } const isCopiedExport = copyFilter(resolvedExport); if (isCopiedExport) { copies[resolvedExport] = join(outputDir, id); } else { input[id] = resolvedExport; } importMap.imports[id] = bareToDotRelativePath( isCopiedExport ? id : packageTargets.includes(id) || extname(id) !== ".js" ? `${id}.js` : id, ); } catch (error) { console.error(error); } } } let noOpIds = []; if (options.resolve.browserField) { const resolvedBrowserIgnores = Object.fromEntries( await Promise.all( packageTargets.map(async (dependency) => [ dependency, await resolveBrowserIgnores(options, dependenciesPath[dependency]), ]), ), ); for (let [dependency, entryPoints] of Object.entries( resolvedBrowserIgnores, )) { const dependencyPath = dependenciesPath[dependency]; for (let specifier of Object.keys(entryPoints)) { try { const resolvedIgnore = slash(join(dependencyPath, specifier)); if (!(await pathExists(resolvedIgnore))) { console.warn(`Unresolved browser ignore: "${resolvedIgnore}"`); continue; } noOpIds.push(resolvedIgnore); } catch (error) { console.error(error); } } } } // Caveats: this will throw if all dependencies are filtered out/have unknown exports if (!Object.values(input).length) { throw new Error(`No input dependency to install.`); } // Bundle const bundleOptions = { ...options }; bundleOptions.rollup.input = { ...bundleOptions.rollup.input, input }; bundleOptions.rollup.output = { ...bundleOptions.rollup.output, entryFileNames: ({ name }) => packageTargets.includes(name) || extname(name) !== ".js" ? `${name}.js` : name, }; if (noOpIds.length) { bundleOptions.rollup = deepmerge(bundleOptions.rollup, { pluginsOptions: { noOp: { ids: noOpIds } }, }); } result = await bundle(bundleOptions); await Promise.allSettled( Object.entries(copies).map(async ([resolvedExport, copyDestination]) => { try { await fs.mkdir(dirname(copyDestination), { recursive: true }); await fs.copyFile(resolvedExport, copyDestination); } catch (error) { console.error(error); } }), ); if (!result.error) { // Write import map importMap = deepmerge(importMap, options.importMap); await writeJson(importMapFile, importMap); if (options.caller === "cli") { await fs.writeFile(join(options.cwd, ".nojekyll"), "", "utf-8"); } // Write cache await writeJson(dependenciesCacheFile, { version: VERSION, type, dependencies, dependenciesHardcoded, }); console.log("install - complete."); } } catch (error) { console.error(error); result = { error }; } console.timeEnd(label); return { ...result, input, importMap }; }; install.description = `Install ESM dependencies.`; export default install;