UNPKG

@graphql-tools/external-fragment-loader

Version:

A GraphQL loader that resolves cross-package fragment dependencies from external packages

570 lines (569 loc) • 23.8 kB
import { existsSync, promises as fsPromises, readFileSync } from 'fs'; import { basename, join, resolve } from 'path'; import { Kind, parse, print, visit } from 'graphql'; import memoizee from 'memoizee'; import unixify from 'unixify'; import { gqlPluckFromCodeStringSync, } from '@graphql-tools/graphql-tag-pluck'; import { matchFiles, matchFilesSync } from '@graphql-tools/load-files'; const { readFile } = fsPromises; const packageMapData = new WeakMap(); // --- Core logic --- const DEFAULT_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'graphql', 'gql']; const GQL_EXTENSIONS = ['graphql', 'gql']; const DEFAULT_SCAN_INTERNAL_DIRS = ['src']; const DEFAULT_EXCLUDE_PATTERNS = ['**/__generated__/**', '**/node_modules/**']; function getSourceGlob(extensions) { return extensions.length === 1 ? `**/*.${extensions[0]}` : `**/*.{${extensions.join(',')}}`; } function isGraphQLFile(filePath) { return GQL_EXTENSIONS.some(ext => filePath.endsWith(`.${ext}`)); } function extractFragmentsAndSpreads(rawSDLs) { const sources = rawSDLs.map(rawSDL => ({ rawSDL, document: parse(rawSDL, { noLocation: true }), })); const definitions = []; const spreads = new Set(); const spreadsPerFragment = new Map(); for (const source of sources) { visit(source.document, { [Kind.FRAGMENT_DEFINITION](node) { definitions.push({ name: node.name.value, typeCondition: node.typeCondition.name.value, }); const fragmentSpreads = spreadsPerFragment.get(node.name.value) ?? new Set(); visit(node, { [Kind.FRAGMENT_SPREAD](spread) { fragmentSpreads.add(spread.name.value); }, }); spreadsPerFragment.set(node.name.value, fragmentSpreads); }, [Kind.FRAGMENT_SPREAD](node) { spreads.add(node.name.value); }, }); } return { sources, definitions, spreads, spreadsPerFragment }; } function createPackageMapBuilder() { return { fragments: new Map(), spreadsPerFile: new Map(), defsPerFile: new Map(), sourcesPerFile: new Map(), spreadsPerFragment: new Map(), }; } function registerFile({ builder, packageDir, filePath, info, }) { builder.sourcesPerFile.set(filePath, info.sources); if (info.definitions.length === 0 && info.spreads.size === 0) return; builder.defsPerFile.set(filePath, info.definitions); builder.spreadsPerFile.set(filePath, info.spreads); for (const [fragmentName, fragmentSpreads] of info.spreadsPerFragment) { const spreads = builder.spreadsPerFragment.get(fragmentName) ?? new Set(); for (const spread of fragmentSpreads) { spreads.add(spread); } builder.spreadsPerFragment.set(fragmentName, spreads); } for (const def of info.definitions) { const existing = builder.fragments.get(def.name); if (existing && existing.filePath !== filePath) { throw new Error(`Duplicate fragment "${def.name}" within package at "${packageDir}": ` + `defined in "${existing.filePath}" and "${filePath}".`); } builder.fragments.set(def.name, { filePath, typeCondition: def.typeCondition }); } } function parseFile({ filePath, content, pluckConfig, fileContentFilter, }) { if (fileContentFilter && !fileContentFilter(content, filePath)) return; let rawSDLs = [content]; if (!isGraphQLFile(filePath)) { let pluckedSources; try { // Code files are scanned opportunistically, so skip files that cannot be // plucked. GraphQL parsing below must still surface invalid documents. pluckedSources = gqlPluckFromCodeStringSync(filePath, content, pluckConfig); } catch { return undefined; } rawSDLs = pluckedSources.map(source => source.body); } return extractFragmentsAndSpreads(rawSDLs); } // --- Memoized functions --- function createPackageFragmentMap(builder) { const map = { fragments: builder.fragments, spreadsPerFile: builder.spreadsPerFile, defsPerFile: builder.defsPerFile, }; packageMapData.set(map, { sourcesPerFile: builder.sourcesPerFile, spreadsPerFragment: builder.spreadsPerFragment, }); return map; } function getPackageMapData(map) { const data = packageMapData.get(map); if (!data) { throw new Error('External fragment package map metadata is missing.'); } return data; } function readPackageJsonDepsRaw(packageJsonPath, includeDevDependencies) { try { const content = readFileSync(packageJsonPath, 'utf8'); const { dependencies = {}, devDependencies = {} } = JSON.parse(content); const allDeps = includeDevDependencies ? { ...dependencies, ...devDependencies } : dependencies; return { names: Object.keys(allDeps), record: allDeps }; } catch (err) { if (err.code === 'ENOENT') { return { names: [], record: {} }; } throw err; } } function buildPackageFragmentMapRaw(packageDir, scanInternalDirs, extensions, excludePatterns, pluckConfig, fileContentFilter) { const builder = createPackageMapBuilder(); const dirs = scanInternalDirs .map(folder => resolve(packageDir, folder)) .filter(dir => existsSync(dir)); if (dirs.length === 0) { return createPackageFragmentMap(builder); } const sourceGlob = getSourceGlob(extensions); const ignorePatterns = excludePatterns.map(p => `!${p}`); const allFiles = dirs.flatMap(dir => matchFilesSync([unixify(sourceGlob), ...ignorePatterns.map(unixify)], { cwd: dir, absolute: true, })); for (const filePath of allFiles) { const content = readFileSync(filePath, 'utf8'); const info = parseFile({ filePath, content, pluckConfig, fileContentFilter }); if (info) registerFile({ builder, packageDir, filePath, info }); } return createPackageFragmentMap(builder); } async function buildPackageFragmentMapAsyncRaw(packageDir, scanInternalDirs, extensions, excludePatterns, pluckConfig, fileContentFilter) { const builder = createPackageMapBuilder(); const dirs = scanInternalDirs .map(folder => resolve(packageDir, folder)) .filter(dir => existsSync(dir)); if (dirs.length === 0) { return createPackageFragmentMap(builder); } const sourceGlob = getSourceGlob(extensions); const ignorePatterns = excludePatterns.map(p => `!${p}`); const allFiles = (await Promise.all(dirs.map(dir => matchFiles([unixify(sourceGlob), ...ignorePatterns.map(unixify)], { cwd: dir, absolute: true, })))).flat(); await Promise.all(allFiles.map(async (filePath) => { const content = await readFile(filePath, 'utf8'); const info = parseFile({ filePath, content, pluckConfig, fileContentFilter }); if (info) registerFile({ builder, packageDir, filePath, info }); })); return createPackageFragmentMap(builder); } let readPackageJsonDeps = memoizee(readPackageJsonDepsRaw, { primitive: true }); let buildPackageFragmentMap = memoizee(buildPackageFragmentMapRaw, { primitive: true, }); let buildPackageFragmentMapAsync = memoizee(buildPackageFragmentMapAsyncRaw, { primitive: true, promise: true, }); let appliedCacheTTL; function initCache(cacheTTL) { if (cacheTTL === appliedCacheTTL) return; const ttlOpt = cacheTTL != null && cacheTTL !== Infinity ? { maxAge: cacheTTL } : undefined; readPackageJsonDeps = memoizee(readPackageJsonDepsRaw, { primitive: true, ...ttlOpt, }); buildPackageFragmentMap = memoizee(buildPackageFragmentMapRaw, { primitive: true, ...ttlOpt, }); buildPackageFragmentMapAsync = memoizee(buildPackageFragmentMapAsyncRaw, { primitive: true, promise: true, ...ttlOpt, }); appliedCacheTTL = cacheTTL; } /** * Clears all internal caches. */ export function clearCache() { readPackageJsonDeps.clear(); buildPackageFragmentMap.clear(); buildPackageFragmentMapAsync.clear(); appliedCacheTTL = undefined; } function getPackageFragmentMap(args, cacheResult) { if (cacheResult) { return buildPackageFragmentMap(...args); } return buildPackageFragmentMap._get(...args) ?? buildPackageFragmentMapRaw(...args); } function getPackageFragmentMapAsync(args, cacheResult) { if (cacheResult) { return buildPackageFragmentMapAsync(...args); } return buildPackageFragmentMapAsync._get(...args) ?? buildPackageFragmentMapAsyncRaw(...args); } // --- Shared helpers --- function findPackageDir(packageName, externalPackagesDirs) { for (const searchPath of externalPackagesDirs) { const candidate = join(searchPath, packageName); if (existsSync(join(candidate, 'package.json'))) { return candidate; } } return null; } function collectTransitiveDeps({ packageName, externalPackagesDirs, filter, includeDevDependencies, packageDependencyFilter, visited = new Set(), result = new Set(), }) { if (visited.has(packageName)) return result; visited.add(packageName); const pkgDir = findPackageDir(packageName, externalPackagesDirs); if (!pkgDir) return result; const packageJsonPath = join(pkgDir, 'package.json'); const { names: deps, record: depsRecord } = readPackageJsonDeps(packageJsonPath, includeDevDependencies); if (!packageDependencyFilter || packageDependencyFilter(depsRecord)) { result.add(packageName); } for (const dep of deps.filter(filter)) { collectTransitiveDeps({ packageName: dep, externalPackagesDirs, filter, includeDevDependencies, packageDependencyFilter, visited, result, }); } return result; } function findMissingFragments(rootMap) { const missing = new Set(); for (const spreads of rootMap.spreadsPerFile.values()) { for (const spread of spreads) { if (!rootMap.fragments.has(spread)) { missing.add(spread); } } } return missing; } /** * Resolves missing fragments to provider files, following their external spreads. * Examples, 'A' is the fragment name being looked up: * - Need A; only `file_a.graphql` defines A, with no spreads -> [file_a.graphql]. * - Need A; no provider defines A -> throws a missing fragment error. * - Need A; two packages define A -> throws a duplicate fragment error. * - Need A; A in `file_a.graphql` spreads B, provided by `file_b.graphql` -> [file_a.graphql, file_b.graphql]. */ function findExternalFragments({ missingFragments, depMaps, rootMap, rootPackageName, filterToRequiredFragments, }) { const globalIndex = new Map(); for (const [depName, map] of depMaps) { for (const [fragName, info] of map.fragments) { const entry = { packageName: depName, filePath: info.filePath, typeCondition: info.typeCondition, }; const existing = globalIndex.get(fragName); if (existing) { existing.push(entry); } else { globalIndex.set(fragName, [entry]); } } } const resolvedFiles = new Map(); const requiredFragmentNamesPerFile = new Map(); const resolvedFragmentNames = new Set(); const localFragmentNames = new Set(rootMap.fragments.keys()); const unresolved = new Set(missingFragments); while (unresolved.size > 0) { const nextUnresolved = new Set(); const errors = []; for (const fragName of unresolved) { if (resolvedFragmentNames.has(fragName) || localFragmentNames.has(fragName)) continue; const entries = globalIndex.get(fragName); if (!entries) { errors.push(`Fragment "${fragName}" is spread in "${rootPackageName}" but not defined in any of its transitive dependencies.`); } else if (entries.length > 1) { const providers = entries.map(e => `"${e.packageName}" (${e.filePath})`).join(' and '); errors.push(`Duplicate fragment "${fragName}" found in ${providers}.`); } else { const entry = entries[0]; resolvedFragmentNames.add(fragName); const requiredFragmentNames = requiredFragmentNamesPerFile.get(entry.filePath) ?? new Set(); requiredFragmentNames.add(fragName); requiredFragmentNamesPerFile.set(entry.filePath, requiredFragmentNames); const depMap = depMaps.get(entry.packageName); if (!resolvedFiles.has(entry.filePath)) { const defs = depMap.defsPerFile.get(entry.filePath) || []; resolvedFiles.set(entry.filePath, { filePath: entry.filePath, definitions: defs, packageName: entry.packageName, }); } const spreads = filterToRequiredFragments ? getPackageMapData(depMap).spreadsPerFragment.get(fragName) : depMap.spreadsPerFile.get(entry.filePath); for (const spread of spreads ?? []) { if (!resolvedFragmentNames.has(spread) && !localFragmentNames.has(spread)) { nextUnresolved.add(spread); } } } } if (errors.length > 0) { throw new Error(errors.join('\n')); } if (nextUnresolved.size === 0) break; unresolved.clear(); for (const s of nextUnresolved) unresolved.add(s); } return [...resolvedFiles.values()].map(file => { const depMap = depMaps.get(file.packageName); const sources = getPackageMapData(depMap).sourcesPerFile.get(file.filePath) ?? []; const requiredFragmentNames = requiredFragmentNamesPerFile.get(file.filePath) ?? new Set(); return { ...file, // Duplicate checks must use the same fragment names as the filtered sources. definitions: filterToRequiredFragments ? file.definitions.filter(definition => requiredFragmentNames.has(definition.name)) : file.definitions, sources: filterToRequiredFragments ? filterParsedSources(sources, requiredFragmentNames) : [], }; }); } function filterParsedSources(sources, requiredFragmentNames) { const filteredSources = []; for (const source of sources) { const definitions = source.document.definitions.filter(definition => definition.kind === Kind.FRAGMENT_DEFINITION && requiredFragmentNames.has(definition.name.value)); if (definitions.length === 0) { continue; } const document = definitions.length === source.document.definitions.length ? source.document : { kind: Kind.DOCUMENT, definitions }; filteredSources.push({ rawSDL: definitions.length === source.document.definitions.length ? source.rawSDL : print(document), document, }); } return filteredSources; } function detectDuplicateFragments(rootMap, resolvedFiles, rootPackageName) { const registry = new Map(); for (const [fragName, info] of rootMap.fragments) { registry.set(fragName, { packageName: rootPackageName, filePath: info.filePath }); } const errors = []; for (const resolved of resolvedFiles) { for (const def of resolved.definitions) { const existing = registry.get(def.name); if (existing) { errors.push(`Duplicate fragment "${def.name}" found in "${resolved.packageName}" (${resolved.filePath}) ` + `and "${existing.packageName}" (${existing.filePath}).`); } registry.set(def.name, { packageName: resolved.packageName, filePath: resolved.filePath }); } } if (errors.length > 0) { throw new Error(errors.join('\n')); } } function getPackageNameFromDir(packageDir) { try { const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')); return pkg.name || basename(packageDir) || 'unknown'; } catch (err) { // A missing package.json can use the directory name, but malformed JSON, // permission errors, and unexpected failures should remain visible. if (err.code === 'ENOENT') { return basename(packageDir) || 'unknown'; } throw err; } } function normalizeOptions(options) { const externalPackagesDirs = options.externalPackagesDirs; const filter = options.packageNameFilter ?? (() => true); const packageDependencyFilter = options.packageDependencyFilter; const includeDevDependencies = options.includeDevDependencies ?? true; const scanInternalDirs = options.scanInternalDirs ?? DEFAULT_SCAN_INTERNAL_DIRS; const extensions = options.extensions ?? DEFAULT_EXTENSIONS; const excludePatterns = options.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS; const invalidateTargetPackageCache = options.invalidateTargetPackageCache ?? false; return { externalPackagesDirs, filter, packageDependencyFilter, includeDevDependencies, scanInternalDirs, extensions, excludePatterns, invalidateTargetPackageCache, }; } function getTransitiveDeps(packageDir, { externalPackagesDirs, filter, packageDependencyFilter, includeDevDependencies, }) { const packageJsonPath = join(packageDir, 'package.json'); const { names: rootDeps } = readPackageJsonDeps(packageJsonPath, includeDevDependencies); const filteredRootDeps = rootDeps.filter(filter); const transitiveDeps = new Set(); for (const dep of filteredRootDeps) { collectTransitiveDeps({ packageName: dep, externalPackagesDirs, filter, includeDevDependencies, packageDependencyFilter, visited: new Set(), result: transitiveDeps, }); } return transitiveDeps; } function resolveFromMaps(options) { const resolvedFiles = findExternalFragments(options); detectDuplicateFragments(options.rootMap, resolvedFiles, options.rootPackageName); return resolvedFiles; } function toPublicResolvedFiles(resolvedFiles) { return resolvedFiles.map(({ filePath, definitions, packageName }) => ({ filePath, definitions, packageName, })); } function getPackageMapArgs(packageDir, options, { scanInternalDirs, extensions, excludePatterns }) { return [ packageDir, scanInternalDirs, extensions, excludePatterns, options.pluckConfig, options.fileContentFilter, ]; } function invalidateTargetPackageCacheIfRequested(options, { includeDevDependencies, invalidateTargetPackageCache }, rootPackageMapArgs, deleteMap) { if (!invalidateTargetPackageCache) return; deleteMap(...rootPackageMapArgs); readPackageJsonDeps.delete(join(options.packageDir, 'package.json'), includeDevDependencies); } function createResolutionContext(packageDir, normalized, rootMap) { const missingFragments = findMissingFragments(rootMap); if (missingFragments.size === 0) return; const rootPackageName = getPackageNameFromDir(packageDir); const transitiveDeps = getTransitiveDeps(packageDir, normalized); if (transitiveDeps.size === 0) { const errors = [...missingFragments].map(frag => `Fragment "${frag}" is spread in "${rootPackageName}" but no transitive dependencies were found to search.`); throw new Error(errors.join('\n')); } return { rootMap, rootPackageName, missingFragments, transitiveDeps }; } function getDependencyPackages(options, normalized, packageNames) { const dependencies = []; for (const name of packageNames) { const packageDir = findPackageDir(name, normalized.externalPackagesDirs); if (packageDir) { dependencies.push({ name, args: getPackageMapArgs(packageDir, options, normalized) }); } } return dependencies; } // --- Public API --- /** * Resolves cross-package GraphQL fragment dependencies across external packages. * Async version — reads files in parallel for better performance on large codebases. */ export async function resolveExternalFragmentsWithSources(options, filterToRequiredFragments = true) { const normalized = normalizeOptions(options); initCache(options.cacheTTL); const rootPackageMapArgs = getPackageMapArgs(options.packageDir, options, normalized); invalidateTargetPackageCacheIfRequested(options, normalized, rootPackageMapArgs, buildPackageFragmentMapAsync.delete); // Consumer packages are intentionally not inserted into the cache. If this // package was already encountered as a provider, reuse that cached map. const rootMap = await getPackageFragmentMapAsync(rootPackageMapArgs, false); const context = createResolutionContext(options.packageDir, normalized, rootMap); if (!context) return []; const depMaps = new Map(); await Promise.all(getDependencyPackages(options, normalized, context.transitiveDeps).map(async (dependency) => { const map = await getPackageFragmentMapAsync(dependency.args, true); depMaps.set(dependency.name, map); })); return resolveFromMaps({ rootMap: context.rootMap, depMaps, rootPackageName: context.rootPackageName, missingFragments: context.missingFragments, filterToRequiredFragments, }); } export async function resolveExternalFragments(options) { const resolvedFiles = await resolveExternalFragmentsWithSources(options, false); return toPublicResolvedFiles(resolvedFiles); } /** * Resolves cross-package GraphQL fragment dependencies across external packages. * Sync version. */ export function resolveExternalFragmentsSyncWithSources(options, filterToRequiredFragments = true) { const normalized = normalizeOptions(options); initCache(options.cacheTTL); const rootPackageMapArgs = getPackageMapArgs(options.packageDir, options, normalized); invalidateTargetPackageCacheIfRequested(options, normalized, rootPackageMapArgs, buildPackageFragmentMap.delete); // Consumer packages are intentionally not inserted into the cache. If this // package was already encountered as a provider, reuse that cached map. const rootMap = getPackageFragmentMap(rootPackageMapArgs, false); const context = createResolutionContext(options.packageDir, normalized, rootMap); if (!context) return []; const depMaps = new Map(); for (const dependency of getDependencyPackages(options, normalized, context.transitiveDeps)) { depMaps.set(dependency.name, getPackageFragmentMap(dependency.args, true)); } return resolveFromMaps({ rootMap: context.rootMap, depMaps, rootPackageName: context.rootPackageName, missingFragments: context.missingFragments, filterToRequiredFragments, }); } export function resolveExternalFragmentsSync(options) { const resolvedFiles = resolveExternalFragmentsSyncWithSources(options, false); return toPublicResolvedFiles(resolvedFiles); }