Parcel 24 ๐ฆ Writing a Resolver โ Custom module resolution logic
A resolver plugin is the first stage of Parcel’s pipeline, and its job is narrower than it first appears. It does not read files, does not compile code, and does not transform assets. It receives a dependency specifierโthe text inside an import statementโand returns an absolute file path, or null to pass the dependency to the next resolver in the chain . The resolver is the mechanism for a custom import syntax, a virtual module, or a module resolution algorithm that the default resolver does not express. The default resolver handles relative paths, node_modules, and package exports; a custom resolver handles everything else, and returns null for the cases the default resolver should handle.
The resolver’s return value can be more than a file path. It can return code directly, which creates a virtual module without a file on disk. It can return isExcluded: true, which removes the module from the build entirelyโthe pattern for a package that the hosting environment provides at runtime . It can return invalidateOnFileChange and invalidateOnFileCreate arrays, which tell Parcel’s cache which files the resolution depends on . The resolver is a small plugin with a small API, but the API covers the cases that the glob-based transformer configuration cannot.
Key point: A resolver receives a specifier and a dependency object, and returns a filePath, an optional code string for a virtual module, an optional isExcluded flag, and optional cache invalidation arrays. Returning null passes the dependency to the next resolver in the pipeline.
Why the resolver is the extension point for custom imports
The specifier problem. The default resolver understands relative paths (./foo), absolute paths (/foo), and package names (foo). It does not understand ^/foo, @company/foo, or env. A custom resolver can intercept those specifiers before the default resolver sees them. The ^/foo example in the Parcel documentation is the canonical case: the resolver matches the ^/ prefix, resolves it to a path relative to the project root, and returns the path. The default resolver never runs for that specifier .
The virtual module problem. A resolver can return code instead of a file path. The code property is the module content, and the filePath property tells Parcel where to resolve the code’s dependencies and how to interpret the content (e.g., by the file extension) . This is the resolver-level equivalent of the transformer-level virtual module. A resolver that returns code: 'export default "hello"' creates a module that the bundler treats as a real module, even though no file exists.
The excluded module problem. Some packages are provided by the runtime environment and should not be bundled. The aws-sdk package is included in the AWS Lambda runtime, and bundling it duplicates code that already exists. The resolver returns isExcluded: true for aws-sdk, and Parcel leaves the import in the output instead of bundling the package . This is a different outcome from external: true in esbuild; the module is excluded from the graph, not marked as an external dependency.
The trade-off. The resolver is cached by Parcel, and the cache is invalidated by the files that the resolver reads. A resolver that reads a configuration file must return the file path in invalidateOnFileChange, or the cache will return a stale resolution after the configuration changes . The invalidation is the cost of the caching. A resolver that reads nothing does not need to return any invalidation, and the cache is correct by default.
a. The resolver structure and the null pass-through
The resolver is created with the Resolver class from @parcel/plugin. The resolve method receives an object with the specifier and the dependency, and it returns a result object or null.
// resolver.js โ a resolver for a custom import prefix
import { Resolver } from '@parcel/plugin';
import path from 'path';
export default new Resolver({
async resolve({ specifier, dependency }) {
// Intercept the "^/" prefix.
if (specifier.startsWith('^/')) {
// Resolve the path relative to the project root.
const filePath = path.join(dependency.resolveFrom, '..', specifier.slice(2));
return { filePath };
}
// Let the next resolver in the pipeline handle
// every other specifier.
return null;
},
});
The dependency.resolveFrom property is the file path that contains the import. The resolver uses it as the base for the relative resolution. The null return is the pass-through mechanism: if the custom resolver does not handle the specifier, the default resolver runs next . The .parcelrc configuration must include the default resolver after the custom resolver:
// .parcelrc โ the custom resolver before the default
{
"extends": "@parcel/config-default",
"resolvers": ["parcel-resolver-custom", "..."]
}
The "..." is the default resolver. Without it, the custom resolver must handle every dependency in the project, or the build fails .
b. The virtual module and the cache invalidation
A resolver can return code to create a virtual module. The filePath is still required, because it tells Parcel where to resolve the code’s dependencies and how to interpret the content. The extension of the filePath determines the transformer pipeline for the virtual module.
// resolver.js โ a virtual module resolver
import { Resolver } from '@parcel/plugin';
import path from 'path';
export default new Resolver({
async resolve({ specifier }) {
if (specifier === 'virtual:config') {
return {
// The filePath is the base for dependency resolution.
// The .js extension tells Parcel to use the JavaScript pipeline.
filePath: path.join(__dirname, 'virtual-config.js'),
// The code is the module content.
code: `export default ${JSON.stringify({ version: '1.0.0' })};`,
};
}
return null;
},
});
The cache invalidation is the other half of the resolver. A resolver that reads a configuration file must return the file path in invalidateOnFileChange, so that a change to the file triggers a re-resolution. A resolver that expects a file to exist must return the path in invalidateOnFileCreate, so that the creation of the file triggers a re-resolution .
// resolver.js โ a resolver with cache invalidation
import { Resolver } from '@parcel/plugin';
import path from 'path';
export default new Resolver({
async resolve({ specifier, options }) {
const aliasFile = path.join(options.projectRoot, 'alias.json');
try {
// Read the alias configuration.
const content = await options.inputFS.readFile(aliasFile, 'utf8');
const aliases = JSON.parse(content);
if (aliases[specifier]) {
return {
filePath: aliases[specifier],
// Tell Parcel that the resolution depends on this file.
// The cache is invalidated when the file changes.
invalidateOnFileChange: [aliasFile],
};
}
} catch (err) {
// The file does not exist.
// Tell Parcel to re-resolve when the file is created.
return {
invalidateOnFileCreate: [{ filePath: aliasFile }],
};
}
return null;
},
});
The options.inputFS is the file system abstraction that Parcel provides. Reading through it is the correct pattern; reading directly from fs bypasses the cache invalidation and produces stale results .
Complete Example Session
// ============================================
// PART 1: THE CUSTOM PREFIX RESOLVER
// ============================================
// resolver.js
import { Resolver } from '@parcel/plugin';
import path from 'path';
export default new Resolver({
async resolve({ specifier, dependency }) {
// The "^/" prefix means "resolve from the project root".
if (specifier.startsWith('^/')) {
const filePath = path.join(dependency.resolveFrom, '..', specifier.slice(2));
return { filePath };
}
// Pass to the default resolver.
return null;
},
});
// ============================================
// PART 2: THE .parcelrc CONFIGURATION
// ============================================
// .parcelrc
{
"extends": "@parcel/config-default",
"resolvers": ["parcel-resolver-custom", "..."]
}
// ============================================
// PART 3: THE VIRTUAL MODULE RESOLVER
// ============================================
// resolver.js โ a virtual module
import { Resolver } from '@parcel/plugin';
export default new Resolver({
async resolve({ specifier }) {
if (specifier === 'virtual:env') {
return {
filePath: 'virtual-env.js',
code: `export default ${JSON.stringify(process.env)};`,
};
}
return null;
},
});
// ============================================
// PART 4: THE EXCLUDED MODULE RESOLVER
// ============================================
// resolver.js โ exclude aws-sdk from the bundle
import { Resolver } from '@parcel/plugin';
export default new Resolver({
async resolve({ specifier }) {
if (specifier === 'aws-sdk') {
// The module is provided by the runtime environment.
// Do not bundle it.
return { isExcluded: true };
}
return null;
},
});
// ============================================
// PART 5: THE ALIAS RESOLVER WITH CACHE INVALIDATION
// ============================================
// resolver.js โ a resolver that reads an alias file
import { Resolver } from '@parcel/plugin';
import path from 'path';
export default new Resolver({
async resolve({ specifier, options }) {
const aliasFile = path.join(options.projectRoot, 'alias.json');
try {
const content = await options.inputFS.readFile(aliasFile, 'utf8');
const aliases = JSON.parse(content);
if (aliases[specifier]) {
return {
filePath: aliases[specifier],
invalidateOnFileChange: [aliasFile],
};
}
} catch (err) {
return {
invalidateOnFileCreate: [{ filePath: aliasFile }],
};
}
return null;
},
});
// ============================================
// PART 6: THE SPECIFIER TYPE BRANCHING
// ============================================
// resolver.js โ different resolution for URL and ESM specifiers
import { Resolver } from '@parcel/plugin';
import { fileURLToPath, pathToFileURL } from 'url';
import path from 'path';
export default new Resolver({
async resolve({ specifier, dependency }) {
if (dependency.specifierType === 'url') {
// Resolve the URL relative to the importer.
const url = new URL(specifier, pathToFileURL(dependency.resolveFrom));
return { filePath: fileURLToPath(url) };
}
// Resolve the path relative to the importer.
return { filePath: path.resolve(dependency.resolveFrom, specifier) };
},
});
// ============================================
// PART 7: THE DIAGNOSTICS RETURN
// ============================================
// resolver.js โ return diagnostics instead of throwing
import { Resolver } from '@parcel/plugin';
export default new Resolver({
async resolve({ specifier }) {
if (specifier === 'broken') {
return {
diagnostics: [
{
message: 'The "broken" module cannot be resolved.',
hints: ['Remove the import or provide a valid module.'],
},
],
};
}
return null;
},
});
// The diagnostics are shown only if no resolver resolves the dependency.
// If the default resolver resolves it, the diagnostics are discarded.
// ============================================
// PART 8: THE RESOLVER ORDER
// ============================================
// The resolvers run in the order they appear in .parcelrc.
// The first resolver that returns a result wins.
// A resolver that returns null passes to the next resolver.
// ============================================
// PART 9: THE NAMING CONVENTION
// ============================================
// Official: @parcel/resolver-{name}
// Community: parcel-resolver-{name}
// Scoped: @scope/parcel-resolver[-{name}]
// The name must be descriptive.
// "parcel-resolver-custom" is the package name in the example.
// ============================================
// PART 10: THE VERIFICATION CHECKLIST
// ============================================
// [ ] The resolver is registered in the .parcelrc resolvers array.
// [ ] The "..." is included after the custom resolver.
// [ ] The resolver returns null for the specifiers it does not handle.
// [ ] The virtual module returns a filePath and code.
// [ ] The cache invalidation is set for any file the resolver reads.
// [ ] The isExcluded flag is used for runtime-provided modules.
The ten parts covered the custom prefix, the configuration, the virtual module, the excluded module, the alias resolver with cache invalidation, the specifier type branching, the diagnostics, the resolver order, the naming convention, and the verification. The through-line is that the resolver is a small plugin with a small API, and the null return is the pass-through.
Quick Reference
The resolver API
| Property | Purpose |
|---|---|
specifier | The import text (e.g., './foo') |
dependency.resolveFrom | The file that contains the import |
dependency.specifierType | 'esm', 'url', 'commonjs', etc. |
Return filePath | The absolute path to the module |
Return code | The virtual module content |
Return isExcluded | Exclude the module from the build |
Return invalidateOnFileChange | Files that invalidate the cache |
Return invalidateOnFileCreate | Files that invalidate the cache when created |
Return diagnostics | Warnings that are shown if no resolver resolves |
The .parcelrc configuration
| Pattern | Purpose |
|---|---|
"resolvers": ["my-resolver", "..."] | Custom resolver, then default |
"resolvers": ["my-resolver"] | Custom resolver replaces default |
The naming convention
| Type | Official | Community |
|---|---|---|
| Resolver | @parcel/resolver-{name} | parcel-resolver-{name} |
Best Practices
โ Do This:
// Return null for specifiers the resolver does not handle
return null; // โ
// Return the filePath and code for a virtual module
return { filePath: 'virtual.js', code: 'export default 1' }; // โ
// Return invalidateOnFileChange for files the resolver reads
return { filePath, invalidateOnFileChange: [configFile] }; // โ
// Use options.inputFS to read files
const content = await options.inputFS.readFile(configFile); // โ
โ Don’t Do This:
// Return undefined instead of null
return; // the pipeline may not continue correctly // โ
// Read files directly from fs
import fs from 'fs'; fs.readFileSync(configFile); // โ (cache is not invalidated)
// Omit the "..." from the resolvers array
"resolvers": ["my-resolver"] // the default resolver is gone // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The custom resolver is not called | The .parcelrc does not include it | Add it to the resolvers array |
| The default resolver is not running | The "..." is missing | Add "..." to the array |
| The cache is stale | The resolver reads a file without invalidation | Return invalidateOnFileChange |
| The virtual module has no dependencies resolved | The filePath is missing | Return a filePath with the correct extension |
| The diagnostics are not shown | Another resolver resolved the dependency | The diagnostics are shown only if no resolver resolves |
Real-World Examples
1. Custom prefix
if (specifier.startsWith('^/')) return { filePath: resolve(specifier) };
2. Pass-through
return null;
3. Virtual module
return { filePath: 'virtual.js', code: 'export default 1' };
4. Excluded module
if (specifier === 'aws-sdk') return { isExcluded: true };
5. Cache invalidation
return { filePath, invalidateOnFileChange: [configFile] };
6. File creation invalidation
return { invalidateOnFileCreate: [{ filePath: configFile }] };
7. Specifier type
if (dependency.specifierType === 'url') { /* URL resolution */ }
8. Diagnostics
return { diagnostics: [{ message: 'Cannot resolve' }] };
9. The .parcelrc
"resolvers": ["parcel-resolver-custom", "..."]
10. Reading with inputFS
await options.inputFS.readFile(configFile);
Visual
The Resolver Pipeline
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ RESOLVER PIPELINE โ
โ โ
โ import '^/foo' โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Custom resolver โ Returns { filePath } โ
โ โ handles '^/' โ or null โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โโโโถ result: the dependency is resolved โ
โ โ โ
โ โโโโถ null: pass to the next resolver โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Default resolver โ Handles relative paths, โ
โ โ ("...") โ node_modules, etc. โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Virtual Module
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ filePath AND code โ
โ โ
โ return { โ
โ filePath: 'virtual-env.js', โ the base for resolution โ
โ code: 'export default { ... }' โ the content โ
โ } โ
โ โ
โ The .js extension selects the JavaScript transformer pipelineโ
โ The code is the module content. โ
โ No file exists on disk. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Cache Invalidation
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE RESOLVER READS A FILE โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Read alias.json โ โ
โ โ using inputFS โ โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Return the path in โ โ
โ โ invalidateOnFileChangeโ โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Parcel watches the โ โ
โ โ file and invalidatesโ โ
โ โ the cache on change โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Resolver API | new Resolver({ async resolve({ specifier, dependency }) { ... } }) |
Return filePath | The absolute path to the module |
Return code | The virtual module content |
Return isExcluded | Exclude the module from the build |
Return invalidateOnFileChange | Files that invalidate the cache |
Return invalidateOnFileCreate | Files that invalidate the cache when created |
Return diagnostics | Warnings shown if no resolver resolves |
Return null | Pass to the next resolver |
Key takeaways:
- The resolver turns a specifier into a path. It receives the
specifierand thedependency, and returns afilePathornull. Thenullreturn passes the dependency to the next resolver in the chain, which is how the default resolver handles the cases the custom resolver does not . - The resolver can create virtual modules. The
codeproperty is the module content, and thefilePathis the base for dependency resolution and the extension for the transformer pipeline. The virtual module is a module that exists only in the resolver’s return value . - The
isExcludedflag removes a module from the build. Theaws-sdkexample is the canonical case: the module is provided by the runtime environment, and bundling it duplicates code that already exists. The import stays in the output, but the module is not bundled . - The cache invalidation is required for files the resolver reads. The
invalidateOnFileChangearray tells Parcel which files the resolution depends on. Without it, the cache returns a stale resolution after the configuration changes . - The
"..."in the.parcelrcis the default resolver. The custom resolver runs first, and the default resolver handles the dependencies the custom resolver does not. Without the"...", the custom resolver must handle every dependency, or the build fails .
Remember: A resolver is the first stage of the pipeline. It receives a specifier and returns a path, or null to pass to the next resolver. It can return code for a virtual module, isExcluded to remove a module from the build, and invalidateOnFileChange to tell the cache which files the resolution depends on. The resolver is small, and the API is small, but the cases it coversโcustom import syntax, virtual modules, excluded modulesโare the cases that the default resolver cannot express. The "..." in the .parcelrc is the default resolver, and the custom resolver is the extension point.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!