| | |

Parcel 24 ๐Ÿ“ฆ Writing a Resolver โ€” Custom module resolution logic

โฑ๏ธ Estimated read: 13 min | |

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

PropertyPurpose
specifierThe import text (e.g., './foo')
dependency.resolveFromThe file that contains the import
dependency.specifierType'esm', 'url', 'commonjs', etc.
Return filePathThe absolute path to the module
Return codeThe virtual module content
Return isExcludedExclude the module from the build
Return invalidateOnFileChangeFiles that invalidate the cache
Return invalidateOnFileCreateFiles that invalidate the cache when created
Return diagnosticsWarnings that are shown if no resolver resolves

The .parcelrc configuration

PatternPurpose
"resolvers": ["my-resolver", "..."]Custom resolver, then default
"resolvers": ["my-resolver"]Custom resolver replaces default

The naming convention

TypeOfficialCommunity
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

PitfallWhy It HappensFix
The custom resolver is not calledThe .parcelrc does not include itAdd it to the resolvers array
The default resolver is not runningThe "..." is missingAdd "..." to the array
The cache is staleThe resolver reads a file without invalidationReturn invalidateOnFileChange
The virtual module has no dependencies resolvedThe filePath is missingReturn a filePath with the correct extension
The diagnostics are not shownAnother resolver resolved the dependencyThe 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

ItemValue
Resolver APInew Resolver({ async resolve({ specifier, dependency }) { ... } })
Return filePathThe absolute path to the module
Return codeThe virtual module content
Return isExcludedExclude the module from the build
Return invalidateOnFileChangeFiles that invalidate the cache
Return invalidateOnFileCreateFiles that invalidate the cache when created
Return diagnosticsWarnings shown if no resolver resolves
Return nullPass to the next resolver

Key takeaways:

  • The resolver turns a specifier into a path. It receives the specifier and the dependency, and returns a filePath or null. The null return 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 code property is the module content, and the filePath is 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 isExcluded flag removes a module from the build. The aws-sdk example 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 invalidateOnFileChange array tells Parcel which files the resolution depends on. Without it, the cache returns a stale resolution after the configuration changes .
  • The "..." in the .parcelrc is 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.

Was this helpful?


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!