@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
JavaScript
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);
}