From d8386f578e90617aaf86fcbc780dd1167073f542 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Sun, 2 Aug 2026 07:23:34 -0700 Subject: [PATCH] Resolver feature: schemeResolvers Summary: Adds a pluggable mechanism for resolving URI-scheme-prefixed import specifiers (e.g. `metro:foo`) in metro-resolver. - Adds optional field `schemeResolvers?: Readonly<{[scheme: string]: CustomResolver}>` to `ResolutionContext`, keyed by scheme. The scheme parsed from a specifier is lowercased before lookup, so keys must be lowercase (both `Foo:` and `foo:` match the `'foo'` key). Lookup uses an own-property check (`Object.hasOwn`), so a specifier whose scheme collides with an `Object.prototype` key (e.g. `constructor:`) is never dispatched to an inherited value. - Exposed as `config.resolver.schemeResolvers` (default `{}`). `mergeConfig` deep-merges it per scheme, so presets and user configs combine key-by-key rather than replacing the whole map. - `resolve()` dispatches a scheme-prefixed specifier to its registered resolver as part of specifier classification: after (mutually exclusive) relative/absolute and subpath-import handling, but before the remaining strategies (browser-field redirection, Haste, node_modules, extraNodeModules). A user `resolveRequest` still takes precedence, since it runs first and can delegate back into default resolution, where scheme dispatch then applies. - Because absolute-path handling runs first, Windows drive-absolute specifiers (`C:\...`, `C:/...`) are resolved as paths and never treated as schemes. A scheme with no registered resolver falls through to normal resolution and, only if that also fails, raises a scheme-specific error. ## Why? ### Example - `babel/runtime` Concretely, an example of a problem this solves is with using `babel/plugin-transform-runtime`. Currently, Metro's transform pipeline injects imports of `babel/runtime`, and resolves it as any other runtime dependency, using the source location as the resolver origin. The problem is, even though we've injected this dependency, we have no guarantee that it will resolve as we expect - because it's indistinguishable from an ordinary user-authored import, we resolve hierarchically, which may fail or resolve to an unexpected version. `babel/plugin-transform-runtime` has the [`absoluteRuntime`](https://babeljs.io/docs/babel-plugin-transform-runtime#absoluteruntime) option, which overcomes the issue above, but at the cost of making the transform cache non-portable by injecting absolute file paths into ASTs. This totally breaks remote caching, and is a non-starter in Metro's architecture. `babel/plugin-transform-runtime` *also* has a (newer) [`moduleName`](https://babeljs.io/docs/babel-plugin-transform-runtime#modulename) option, which allows us to replace `babel/runtime` with a string of our choosing. We can use that to inject, say `metro:babel-runtime`, and with `schemeResolvers`, Metro core can configure where that resolves. *And* we can collect those dependencies to determine which helpers are actually used (FB: see footnote) Note `babel/runtime` is not a core concern of resolution generically, so special handling like this belongs in `metro`, not `metro-resolver`. That's why I think a pluggable `metro:` protocol makes sense - Metro can clearly dictate the behaviour of its own namespace, without the indirection and cost of wrapping the whole resolver via custom `resolveRequest`. ### And beyond: `data:`, `virtual:`, `react-native:`, `expo:` This is an ergonomic extension point for Metro, integrators and library authors to provide custom resolution behaviour for injected or virtual imports. Currently, this requires wrapping `resolveRequest` repeatedly. ``` - **[Feature]** Add `schemeResolvers` to `ResolutionContext`, configurable via `config.resolver.schemeResolvers`, to resolve custom URI schemes ``` Reviewed By: huntie Differential Revision: D113034376 --- docs/Configuration.md | 36 +++++ docs/Resolution.md | 26 +++- packages/metro-config/API.md | 3 + .../src/__tests__/mergeConfig-test.js | 74 +++++++++- packages/metro-config/src/defaults/index.js | 1 + packages/metro-config/src/loadConfig.js | 8 +- packages/metro-config/src/types.js | 1 + packages/metro-resolver/API.md | 3 + .../src/__tests__/scheme-resolvers-test.js | 137 ++++++++++++++++++ .../metro-resolver/src/__tests__/utils.js | 1 + packages/metro-resolver/src/resolve.js | 36 +++++ packages/metro-resolver/src/types.js | 12 ++ .../metro/src/node-haste/DependencyGraph.js | 1 + .../DependencyGraph/ModuleResolution.js | 3 + 14 files changed, 330 insertions(+), 12 deletions(-) create mode 100644 packages/metro-resolver/src/__tests__/scheme-resolvers-test.js diff --git a/docs/Configuration.md b/docs/Configuration.md index b15852372d..c50d621209 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -316,6 +316,42 @@ resolveRequest: (context, moduleName, platform) => { For more information on customizing the resolver, see [Module Resolution](https://metrobundler.dev/docs/resolution). +#### `schemeResolvers` + +Type: `?{[scheme: string]: `[`CustomResolver`](./Resolution.md#resolverequest-customresolver)`}` + +An object of custom resolvers for import specifiers prefixed with a URI scheme, keyed by lowercase scheme name (the prefix before the first `:`, without the colon). When Metro's default resolution encounters a specifier whose scheme matches a registered key (for example `my-scheme:foo` matching `'my-scheme'`), the corresponding resolver is invoked with the full specifier. + +```javascript +schemeResolvers: { + 'my-scheme': (context, specifier, platform) => { + // `specifier` is the full 'my-scheme:...' string. + // Resolve it to a file, or delegate back to the default resolver via + // `context.resolveRequest(context, someOtherName, platform)`. + return { + type: 'sourceFile', + filePath: '/absolute/path/to/file.js', + }; + }, +}, +``` + +This differs from [`resolveRequest`](#resolverequest) in a few ways: + +- Scheme resolvers run *within* Metro's default resolution rather than replacing it. A user [`resolveRequest`](#resolverequest) still takes precedence, and can delegate back into default resolution (via `context.resolveRequest`), at which point scheme resolvers apply. +- Only specifiers matching a registered scheme are dispatched. Relative (`./`, `../`) and subpath (`#…`) imports are resolved first and are never treated as schemes. +- The resolver receives a `context` whose [`resolveRequest`](./Resolution.md#resolverequest-customresolver) delegates to Metro's default resolution, for easy chaining. + +The scheme parsed from a specifier is lowercased before lookup, so keys must be lowercase — both `Foo:` and `foo:` match the `'foo'` key. When multiple configs are combined with `mergeConfig`, `schemeResolvers` are merged per scheme, so a later config replaces an earlier resolver only when it reuses the same (lowercase) key. + +:::warning Deprecated + +For backwards compatibility, Metro falls back to other methods to resolve the specifier (Haste, `node_modules`, [`extraNodeModules`](#extranodemodules)). This will be removed in a later release. + +::: + +Defaults to `{}`. + #### `useWatchman` Type: `boolean` diff --git a/docs/Resolution.md b/docs/Resolution.md index a64ef796f5..db92328a7b 100644 --- a/docs/Resolution.md +++ b/docs/Resolution.md @@ -68,28 +68,30 @@ Parameters: (*context*, *moduleName*, *platform*) 2. Return the result of [**RESOLVE_MODULE**](#resolve_module)(*context*, *absoluteModuleName*, *platform*), or continue. 3. If *moduleName* begins `'#'` 1. Throw an error. This will be replaced with subpath imports support in a non-breaking future release. -4. Apply [**BROWSER_SPEC_REDIRECTION**](#browser_spec_redirection) to *moduleName*. If this is `false`: +4. If *moduleName* parses as a URL, let *scheme* be the lowercased scheme (the prefix before `':'`), then + 1. If [`context.schemeResolvers`](#schemeresolvers-readonlyscheme-string-customresolver) has a resolver registered for *scheme*, return the result of calling it with (*context*, *moduleName*, *platform*), where *context.resolveRequest* is set to the default resolver for chaining. +5. Apply [**BROWSER_SPEC_REDIRECTION**](#browser_spec_redirection) to *moduleName*. If this is `false`: 1. Return the empty module. -5. If [Haste resolutions are allowed](#allowhaste-boolean), then +6. If [Haste resolutions are allowed](#allowhaste-boolean), then 1. Get the result of [**RESOLVE_HASTE**](#resolve_haste)(*context*, *moduleName*, *platform*). 2. If resolved as a Haste package path, then 1. Perform the algorithm for resolving a path (step 2 above). Throw an error if this resolution fails. For example, if the Haste package path for `'a/b'` is `foo/package.json`, perform step 2 as if _moduleName_ was `foo/c`. -6. If [`context.enablePackageExports`](#enablepackageexports-boolean) is enabled, then +7. If [`context.enablePackageExports`](#enablepackageexports-boolean) is enabled, then 1. Get the result of [**PACKAGE_SELF_RESOLVE**](#package_self_resolve)(*context*, *moduleName*, *platform*). 2. If resolved, return result. -7. If [`context.disableHierarchicalLookup`](#disableHierarchicalLookup-boolean) is not `true`, then +8. If [`context.disableHierarchicalLookup`](#disableHierarchicalLookup-boolean) is not `true`, then 1. Try resolving _moduleName_ under `node_modules` from the current directory (i.e. parent of [`context.originModulePath`](#originmodulepath-string)) up to the root directory. 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*) for each candidate path. -8. For each element _nodeModulesPath_ of [`context.nodeModulesPaths`](#nodemodulespaths-readonlyarraystring): - 1. Try resolving _moduleName_ under _nodeModulesPath_ as if the latter was another `node_modules` directory (similar to step 5 above). +9. For each element _nodeModulesPath_ of [`context.nodeModulesPaths`](#nodemodulespaths-readonlyarraystring): + 1. Try resolving _moduleName_ under _nodeModulesPath_ as if the latter was another `node_modules` directory (similar to step 8 above). 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*) for each candidate path. -9. If [`context.extraNodeModules`](#extranodemodules-string-string) is set: +10. If [`context.extraNodeModules`](#extranodemodules-string-string) is set: 1. Split _moduleName_ into a package name (including an optional [scope](https://docs.npmjs.com/cli/v8/using-npm/scope)) and relative path. 2. Look up the package name in [`context.extraNodeModules`](#extranodemodules-string-string). If found, then 1. Construct a path _modulePath_ by replacing the package name part of _moduleName_ with the value found in [`context.extraNodeModules`](#extranodemodules-string-string) 2. Return the result of [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*). -10. If no valid resolution has been found, throw a resolution failure error. +11. If no valid resolution has been found, throw a resolution failure error. #### RESOLVE_MODULE @@ -323,6 +325,14 @@ When calling the default resolver with a non-null `resolveRequest` function, it Inside a custom resolver, `resolveRequest` is set to the default resolver function, for easy chaining and customization. +#### `schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>` + +An object of [custom resolvers](#resolverequest-customresolver) for import specifiers prefixed with a URI scheme, keyed by lowercased scheme name (the part before the first `':'`, without the colon). The scheme parsed from a specifier is lowercased before lookup, so keys must be lowercase (both `Foo:` and `foo:` match the `'foo'` key). Defaults to [`resolver.schemeResolvers`](./Configuration.md#schemeresolvers). + +When the default resolver encounters a specifier whose scheme matches a key — for example `my-scheme:foo` matching `'my-scheme'` — it invokes the corresponding resolver with the full specifier. The resolver is passed a `context` whose [`resolveRequest`](#resolverequest-customresolver) is the default resolver, so it can chain back into default resolution (e.g. to resolve a relative path). + +Relative (`./`, `../`) and subpath (`#…`) imports are handled before scheme dispatch and are never treated as schemes. See [**RESOLVE**](#resolve) step 4 for how scheme dispatch fits into the algorithm, and [`resolver.schemeResolvers`](./Configuration.md#schemeresolvers) for precedence relative to [`resolveRequest`](#resolverequest-customresolver). + #### `dependency: ?Dependency` A dependency descriptor corresponding to the current resolution request. This is provided for diagnostic purposes *only* and may not be used for semantic purposes. See the [Caching](#caching) section for more information. diff --git a/packages/metro-config/API.md b/packages/metro-config/API.md index 2781eff24b..17d2b97df1 100644 --- a/packages/metro-config/API.md +++ b/packages/metro-config/API.md @@ -147,6 +147,9 @@ export type ResolverConfigT = { platforms: ReadonlyArray; resolveRequest: null | undefined | CustomResolver; resolverMainFields: ReadonlyArray; + schemeResolvers: Readonly<{ + [scheme: string]: CustomResolver; + }>; sourceExts: ReadonlyArray; unstable_conditionNames: ReadonlyArray; unstable_conditionsByPlatform: Readonly<{ diff --git a/packages/metro-config/src/__tests__/mergeConfig-test.js b/packages/metro-config/src/__tests__/mergeConfig-test.js index 4a9c78a4e5..a62c408818 100644 --- a/packages/metro-config/src/__tests__/mergeConfig-test.js +++ b/packages/metro-config/src/__tests__/mergeConfig-test.js @@ -10,13 +10,14 @@ */ import type {InputConfigT} from '../types'; +import type {CustomResolver} from 'metro-resolver'; import {mergeConfig} from '../loadConfig'; describe('mergeConfig', () => { test('can merge empty configs', () => { expect(mergeConfig({}, {})).toStrictEqual({ - resolver: {}, + resolver: {schemeResolvers: {}}, serializer: {}, server: {}, symbolicator: {}, @@ -206,4 +207,75 @@ describe('mergeConfig', () => { }); }); }); + + describe('resolver.schemeResolvers merging', () => { + const resolverA: CustomResolver = () => ({type: 'empty'}); + const resolverB: CustomResolver = () => ({type: 'empty'}); + const resolverC: CustomResolver = () => ({type: 'empty'}); + + test('deep merges override schemes into base schemes', () => { + const base: InputConfigT = { + resolver: {schemeResolvers: {a: resolverA}}, + }; + const override: InputConfigT = { + resolver: {schemeResolvers: {b: resolverB}}, + }; + const result = mergeConfig(base, override); + expect(result.resolver?.schemeResolvers).toStrictEqual({ + a: resolverA, + b: resolverB, + }); + }); + + test('override scheme replaces base scheme with the same key', () => { + const base: InputConfigT = { + resolver: {schemeResolvers: {a: resolverA}}, + }; + const override: InputConfigT = { + resolver: {schemeResolvers: {a: resolverC}}, + }; + const result = mergeConfig(base, override); + expect(result.resolver?.schemeResolvers?.a).toBe(resolverC); + }); + + test('keeps base schemeResolvers when override.resolver sets other fields', () => { + const base: InputConfigT = { + resolver: {schemeResolvers: {a: resolverA}}, + }; + const override: InputConfigT = {resolver: {sourceExts: ['ts']}}; + const result = mergeConfig(base, override); + expect(result.resolver?.schemeResolvers).toStrictEqual({a: resolverA}); + }); + + test('applies override schemeResolvers when base has none', () => { + const base: InputConfigT = {resolver: {}}; + const override: InputConfigT = { + resolver: {schemeResolvers: {b: resolverB}}, + }; + const result = mergeConfig(base, override); + expect(result.resolver?.schemeResolvers).toStrictEqual({b: resolverB}); + }); + + test('other resolver properties are preserved when schemeResolvers is merged', () => { + const base: InputConfigT = { + resolver: {sourceExts: ['js'], schemeResolvers: {a: resolverA}}, + }; + const override: InputConfigT = { + resolver: {schemeResolvers: {b: resolverB}}, + }; + const result = mergeConfig(base, override); + expect(result.resolver?.sourceExts).toEqual(['js']); + expect(result.resolver?.schemeResolvers).toStrictEqual({ + a: resolverA, + b: resolverB, + }); + }); + + test('results in empty schemeResolvers when neither side sets it', () => { + const base: InputConfigT = {resolver: {}}; + const override: InputConfigT = {resolver: {}}; + const result = mergeConfig(base, override); + expect(result.resolver?.schemeResolvers).toStrictEqual({}); + }); + }); }); diff --git a/packages/metro-config/src/defaults/index.js b/packages/metro-config/src/defaults/index.js index 4ea6563e32..24507326b0 100644 --- a/packages/metro-config/src/defaults/index.js +++ b/packages/metro-config/src/defaults/index.js @@ -46,6 +46,7 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({ nodeModulesPaths: [], resolveRequest: null, resolverMainFields: ['browser', 'main'], + schemeResolvers: {}, unstable_conditionNames: [], unstable_conditionsByPlatform: { web: ['browser'], diff --git a/packages/metro-config/src/loadConfig.js b/packages/metro-config/src/loadConfig.js index 8fe370d064..847b4a62ea 100644 --- a/packages/metro-config/src/loadConfig.js +++ b/packages/metro-config/src/loadConfig.js @@ -123,9 +123,11 @@ function mergeConfigObjects( ...(overrides.resolver?.dependencyExtractor != null ? {dependencyExtractor: resolve(overrides.resolver.dependencyExtractor)} : null), - ...(overrides.resolver?.hasteImplModulePath != null - ? {hasteImplModulePath: resolve(overrides.resolver.hasteImplModulePath)} - : null), + schemeResolvers: { + // $FlowFixMe[exponential-spread] + ...base.resolver?.schemeResolvers, + ...overrides.resolver?.schemeResolvers, + }, }, serializer: { ...base.serializer, diff --git a/packages/metro-config/src/types.js b/packages/metro-config/src/types.js index c7b98e0d3a..8b0480500f 100644 --- a/packages/metro-config/src/types.js +++ b/packages/metro-config/src/types.js @@ -113,6 +113,7 @@ type ResolverConfigT = { platforms: ReadonlyArray, resolveRequest: ?CustomResolver, resolverMainFields: ReadonlyArray, + schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>, sourceExts: ReadonlyArray, unstable_conditionNames: ReadonlyArray, unstable_conditionsByPlatform: Readonly<{ diff --git a/packages/metro-resolver/API.md b/packages/metro-resolver/API.md index 1c92a4ceff..ca9555d475 100644 --- a/packages/metro-resolver/API.md +++ b/packages/metro-resolver/API.md @@ -82,6 +82,9 @@ export type ResolutionContext = Readonly<{ resolveHasteModule: (name: string) => null | undefined | string; resolveHastePackage: (name: string) => null | undefined | string; resolveRequest?: null | undefined | CustomResolver; + schemeResolvers?: Readonly<{ + [scheme: string]: CustomResolver; + }>; sourceExts: ReadonlyArray; unstable_conditionNames: ReadonlyArray; unstable_conditionsByPlatform: Readonly<{ diff --git a/packages/metro-resolver/src/__tests__/scheme-resolvers-test.js b/packages/metro-resolver/src/__tests__/scheme-resolvers-test.js new file mode 100644 index 0000000000..44acd7b629 --- /dev/null +++ b/packages/metro-resolver/src/__tests__/scheme-resolvers-test.js @@ -0,0 +1,137 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +'use strict'; + +import type { + CustomResolutionContext, + CustomResolver, + Resolution, + ResolutionContext, +} from '../index'; + +import {createResolutionContext} from './utils'; + +const Resolver = require('../index'); + +const fileMap = { + '/root/project/foo.js': '', + '/root/project/bar.js': '', +}; + +function createContext( + schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>, +): ResolutionContext { + return { + ...createResolutionContext(fileMap), + originModulePath: '/root/project/foo.js', + schemeResolvers, + }; +} + +type Call = { + context: CustomResolutionContext, + specifier: string, + platform: string | null, +}; + +function makeCapturingResolver(resolution: Resolution): { + resolver: CustomResolver, + calls: Array, +} { + const calls: Array = []; + const resolver: CustomResolver = (context, specifier, platform) => { + calls.push({context, specifier, platform}); + return resolution; + }; + return {resolver, calls}; +} + +test('invokes a registered scheme resolver with the full specifier', () => { + const resolution: Resolution = { + type: 'sourceFile', + filePath: '/resolved/by/scheme.js', + }; + const {resolver, calls} = makeCapturingResolver(resolution); + const context = createContext({test: resolver}); + + expect(Resolver.resolve(context, 'test:some/module', 'ios')).toEqual( + resolution, + ); + expect(calls).toHaveLength(1); + expect(calls[0].specifier).toBe('test:some/module'); + expect(calls[0].platform).toBe('ios'); + // The resolver receives a delegating context whose `resolveRequest` is the + // default `resolve`, so it can fall back to standard resolution. + expect(calls[0].context.resolveRequest).toBe(Resolver.resolve); +}); + +test('scheme resolver can delegate back to default resolution', () => { + const schemeResolver: CustomResolver = (context, specifier, platform) => + context.resolveRequest(context, './bar', platform); + const context = createContext({test: schemeResolver}); + + expect(Resolver.resolve(context, 'test:anything', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/project/bar.js', + }); +}); + +test('re-throws an error thrown by a registered scheme resolver as FailedToResolveUnsupportedError', () => { + const failing: CustomResolver = () => { + throw new Error('boom while resolving'); + }; + const context = createContext({test: failing}); + + expect(() => Resolver.resolve(context, 'test:anything', 'ios')).toThrow( + Resolver.FailedToResolveUnsupportedError, + ); +}); + +test('throws a scheme-specific error for an unregistered scheme once other resolution is exhausted', () => { + const {resolver, calls} = makeCapturingResolver({type: 'empty'}); + const context = createContext({test: resolver}); + + // `other:` parses as a scheme but has no registered resolver, so it falls + // through to Haste/node_modules/extraNodeModules resolution and, only once + // those are exhausted, throws a scheme-specific error. + expect(() => Resolver.resolve(context, 'other:module', null)).toThrow( + Resolver.FailedToResolveUnsupportedError, + ); + expect(calls).toHaveLength(0); +}); + +test('relative specifiers are resolved before scheme dispatch', () => { + const {resolver, calls} = makeCapturingResolver({type: 'empty'}); + const context = createContext({test: resolver}); + + // `./bar` is handled by relative/absolute resolution and must never be + // mistaken for a scheme, even when scheme resolvers are registered. + expect(Resolver.resolve(context, './bar', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/project/bar.js', + }); + expect(calls).toHaveLength(0); +}); + +test('does not dispatch a scheme matching an Object.prototype key to an inherited value', () => { + const {resolver, calls} = makeCapturingResolver({type: 'empty'}); + const context = createContext({test: resolver}); + + // `constructor:` is a valid URL scheme that lowercases to `constructor`, an + // `Object.prototype` key. A naive `schemeResolvers[scheme]` read would return + // `Object.prototype.constructor` (non-null) and wrongly invoke it. The + // own-property guard must treat it as unregistered and fall through. + expect(() => Resolver.resolve(context, 'constructor:module', null)).toThrow( + Resolver.FailedToResolveUnsupportedError, + ); + expect(calls).toHaveLength(0); +}); diff --git a/packages/metro-resolver/src/__tests__/utils.js b/packages/metro-resolver/src/__tests__/utils.js index 1714dfd053..fc4aa9fcd8 100644 --- a/packages/metro-resolver/src/__tests__/utils.js +++ b/packages/metro-resolver/src/__tests__/utils.js @@ -84,6 +84,7 @@ export function createResolutionContext( resolveAsset: (filePath: string) => null, resolveHasteModule: (name: string) => null, resolveHastePackage: (name: string) => null, + schemeResolvers: {}, sourceExts: ['js', 'jsx', 'json', 'ts', 'tsx'], unstable_conditionNames: ['require'], unstable_conditionsByPlatform: { diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 0fd5a8aaaf..4baa7417a0 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -19,6 +19,7 @@ import type { import FailedToResolveNameError from './errors/FailedToResolveNameError'; import FailedToResolvePathError from './errors/FailedToResolvePathError'; +import FailedToResolveUnsupportedError from './errors/FailedToResolveUnsupportedError'; import formatFileCandidates from './errors/formatFileCandidates'; import InvalidPackageConfigurationError from './errors/InvalidPackageConfigurationError'; import InvalidPackageError from './errors/InvalidPackageError'; @@ -63,6 +64,8 @@ export default function resolve( ); } + let schemeError: ?Error; + if (isRelativeImport(specifier) || path.isAbsolute(specifier)) { const result = resolveModulePath(context, specifier, platform); if (result.type === 'failed') { @@ -112,6 +115,33 @@ export default function resolve( } } } + } else if (specifier.indexOf(':') > 0 && URL.canParse(specifier)) { + const scheme = specifier.slice(0, specifier.indexOf(':')).toLowerCase(); + const schemeResolvers = context.schemeResolvers; + if (schemeResolvers != null && Object.hasOwn(schemeResolvers, scheme)) { + try { + return schemeResolvers[scheme]( + Object.freeze({...context, resolveRequest: resolve}), + specifier, + platform, + ); + } catch (error: unknown) { + // Scheme resolvers may throw a plain error to signal an unsupported + // specifier (they need not depend on metro-resolver); surface it as the + // resolver's typed error. + throw new FailedToResolveUnsupportedError( + error instanceof Error ? error.message : String(error), + ); + } + } + + // TODO: In a breaking change, we should throw this immediately. + // For now, fall through in case the user is using scheme-like specifiers + // for Haste, or in extraNodeModules, etc. Throw a scheme-specific error + // if nothing else works. + schemeError = new FailedToResolveUnsupportedError( + `No resolver is registered for the '${scheme}:' URI scheme.`, + ); } const {originModulePath} = context; @@ -319,6 +349,12 @@ export default function resolve( } } + if (schemeError) { + // The specifier is a scheme we don't recognise and every other resolution + // strategy has been exhausted, so fail with a scheme-specific error. + throw schemeError; + } + throw buildFailedToResolveNameError( context, extraNodeModulePath != null ? [extraNodeModulePath] : [], diff --git a/packages/metro-resolver/src/types.js b/packages/metro-resolver/src/types.js index 5e24445720..75710827ac 100644 --- a/packages/metro-resolver/src/types.js +++ b/packages/metro-resolver/src/types.js @@ -228,6 +228,18 @@ export type ResolutionContext = Readonly<{ resolveHastePackage: (name: string) => ?string, resolveRequest?: ?CustomResolver, + + /** + * Resolvers for specifiers prefixed with a URI scheme, keyed by the + * lowercased scheme (the part before the first ':', without the colon). The + * scheme parsed from a specifier is lowercased before lookup, so keys must be + * lowercase (both `Foo:` and `foo:` match the `'foo'` key). When a + * specifier's scheme matches a key, the corresponding resolver is invoked + * instead of the default algorithm, receiving the full specifier and a + * context whose `resolveRequest` delegates to default resolution. + */ + schemeResolvers?: Readonly<{[scheme: string]: CustomResolver}>, + sourceExts: ReadonlyArray, unstable_conditionNames: ReadonlyArray, unstable_conditionsByPlatform: Readonly<{ diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index a1d0abf6c8..d779fbf978 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -227,6 +227,7 @@ export default class DependencyGraph extends EventEmitter { return assets.length ? assets : null; }, resolveRequest: this._config.resolver.resolveRequest, + schemeResolvers: this._config.resolver.schemeResolvers, sourceExts: this._config.resolver.sourceExts, unstable_conditionNames: this._config.resolver.unstable_conditionNames, unstable_conditionsByPlatform: diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index 24fc3f93f4..79a95333ef 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -54,6 +54,7 @@ type Options = Readonly<{ reporter: Reporter, resolveAsset: ResolveAsset, resolveRequest: ?CustomResolver, + schemeResolvers: Readonly<{[scheme: string]: CustomResolver}>, sourceExts: ReadonlyArray, unstable_conditionNames: ReadonlyArray, unstable_conditionsByPlatform: Readonly<{ @@ -119,6 +120,7 @@ export class ModuleResolver { preferNativePlatform, resolveAsset, resolveRequest, + schemeResolvers, sourceExts, unstable_conditionNames, unstable_conditionsByPlatform, @@ -151,6 +153,7 @@ export class ModuleResolver { resolveHastePackage: (name: string) => this._options.getHastePackagePath(name, platform), resolveRequest, + schemeResolvers, sourceExts, unstable_conditionNames, unstable_conditionsByPlatform,