From cb4eec1ca47e394e9d87eac2a1d4a46a59a99227 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Sat, 15 Aug 2026 07:43:01 -0700 Subject: [PATCH] Resolver feature: schemeResolvers (#1804) 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. - If a registered scheme resolver throws, `resolve()` catches and re-throws it as `FailedToResolveUnsupportedError`. This lets scheme resolvers signal an unsupported specifier with a plain error, without depending on metro-resolver for its error types. - 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 | 27 ++- packages/metro-config/API.md | 1 + .../src/__tests__/mergeConfig-test.js | 107 ++++++++++- packages/metro-config/src/defaults/index.js | 1 + packages/metro-config/src/loadConfig.js | 5 + packages/metro-config/src/types.js | 1 + packages/metro-resolver/API.md | 3 +- .../src/__tests__/scheme-resolvers-test.js | 168 ++++++++++++++++++ .../metro-resolver/src/__tests__/utils.js | 1 + .../errors/FailedToResolveUnsupportedError.js | 4 +- packages/metro-resolver/src/resolve.js | 60 +++++++ packages/metro-resolver/src/types.js | 12 ++ .../metro/src/node-haste/DependencyGraph.js | 1 + .../DependencyGraph/ModuleResolution.js | 3 + 15 files changed, 418 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 c65d3bc7f2..3964f20d41 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -310,6 +310,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. + +:::note Backwards compatibility + +`schemeResolvers` itself is not deprecated. However, when a specifier's scheme has *no* registered resolver, Metro currently falls back to its other resolution methods (Haste, `node_modules`, [`extraNodeModules`](#extranodemodules)) before failing, in case a project already uses scheme-like specifiers with those. This fallback is deprecated and will be removed in a later release, after which an unregistered scheme will fail immediately. + +::: + +Defaults to `{}`. + #### `useWatchman` Type: `boolean` diff --git a/docs/Resolution.md b/docs/Resolution.md index a64ef796f5..ea42dfe167 100644 --- a/docs/Resolution.md +++ b/docs/Resolution.md @@ -68,28 +68,31 @@ 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. + 2. Otherwise, continue to the following steps, but if none of them resolve *moduleName*, throw a scheme-specific error at step 11 rather than a generic resolution failure. (This fallback exists for backwards compatibility with projects using scheme-like specifiers via Haste or [`extraNodeModules`](#extranodemodules), and is deprecated.) +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 — a scheme-specific error if step 4.2 applied, otherwise a generic one. #### RESOLVE_MODULE @@ -323,6 +326,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 92affa0a65..6f2d251d24 100644 --- a/packages/metro-config/API.md +++ b/packages/metro-config/API.md @@ -150,6 +150,7 @@ 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..acc5572a6f 100644 --- a/packages/metro-config/src/__tests__/mergeConfig-test.js +++ b/packages/metro-config/src/__tests__/mergeConfig-test.js @@ -10,13 +10,15 @@ */ import type {InputConfigT} from '../types'; +import type {CustomResolver} from 'metro-resolver'; import {mergeConfig} from '../loadConfig'; +import path from 'node:path'; describe('mergeConfig', () => { test('can merge empty configs', () => { expect(mergeConfig({}, {})).toStrictEqual({ - resolver: {}, + resolver: {schemeResolvers: {}}, serializer: {}, server: {}, symbolicator: {}, @@ -206,4 +208,107 @@ describe('mergeConfig', () => { }); }); }); + + describe('resolver path resolution', () => { + // `resolve()` maps a module specifier to an absolute path, relative to + // metro-config. Without it, these paths are later `require`d from + // unrelated modules (e.g. metro-file-map's Haste worker) and fail. + test('resolves hasteImplModulePath and dependencyExtractor to absolute paths', () => { + const base: InputConfigT = {}; + const override: InputConfigT = { + resolver: { + hasteImplModulePath: 'metro-core', + dependencyExtractor: 'metro-cache', + }, + }; + const result = mergeConfig(base, override); + + expect(path.isAbsolute(result.resolver?.hasteImplModulePath ?? '')).toBe( + true, + ); + expect(path.isAbsolute(result.resolver?.dependencyExtractor ?? '')).toBe( + true, + ); + }); + + test('leaves resolver paths unset when the override does not specify them', () => { + const base: InputConfigT = {}; + const override: InputConfigT = {resolver: {}}; + const result = mergeConfig(base, override); + + expect(result.resolver?.hasteImplModulePath).toBeUndefined(); + expect(result.resolver?.dependencyExtractor).toBeUndefined(); + }); + }); + + 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..e20fa988f9 100644 --- a/packages/metro-config/src/loadConfig.js +++ b/packages/metro-config/src/loadConfig.js @@ -126,6 +126,11 @@ function mergeConfigObjects( ...(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 230e0d97c1..a6564a9586 100644 --- a/packages/metro-resolver/API.md +++ b/packages/metro-resolver/API.md @@ -28,7 +28,7 @@ export class FailedToResolvePathError extends Error { } export class FailedToResolveUnsupportedError extends Error { - constructor(message: string); + constructor(message: string, options?: {cause?: unknown | undefined}); } export type FileAndDirCandidates = { @@ -82,6 +82,7 @@ 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}> | undefined; 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..411c4701b0 --- /dev/null +++ b/packages/metro-resolver/src/__tests__/scheme-resolvers-test.js @@ -0,0 +1,168 @@ +/** + * 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('preserves the structured error when delegated resolution fails', () => { + const schemeResolver: CustomResolver = (context, specifier, platform) => + context.resolveRequest(context, './does-not-exist', platform); + const context = createContext({test: schemeResolver}); + + // The failure originates in default resolution, not in the scheme resolver, + // so it must not be flattened into FailedToResolveUnsupportedError — + // downstream consumers rely on `candidates` for diagnostics. + expect(() => Resolver.resolve(context, 'test:anything', null)).toThrow( + Resolver.FailedToResolvePathError, + ); +}); + +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('an unregistered scheme still resolves if another strategy succeeds', () => { + const {resolver, calls} = makeCapturingResolver({type: 'empty'}); + const context = { + ...createContext({test: resolver}), + resolveHasteModule: (name: string) => + name === 'other:module' ? '/root/project/bar.js' : null, + }; + + // The deprecated backwards-compatibility path: a scheme-like specifier with + // no registered resolver must still resolve via Haste/extraNodeModules + // rather than failing on the scheme. + expect(Resolver.resolve(context, 'other:module', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/project/bar.js', + }); + 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/errors/FailedToResolveUnsupportedError.js b/packages/metro-resolver/src/errors/FailedToResolveUnsupportedError.js index c12f6b096e..ecdcd329a7 100644 --- a/packages/metro-resolver/src/errors/FailedToResolveUnsupportedError.js +++ b/packages/metro-resolver/src/errors/FailedToResolveUnsupportedError.js @@ -10,7 +10,7 @@ */ export default class FailedToResolveUnsupportedError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: {cause?: unknown}) { + super(message, options); } } diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 0fd5a8aaaf..689958f743 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: ?FailedToResolveUnsupportedError; + if (isRelativeImport(specifier) || path.isAbsolute(specifier)) { const result = resolveModulePath(context, specifier, platform); if (result.type === 'failed') { @@ -112,6 +115,41 @@ export default function resolve( } } } + } else if (specifier.indexOf(':') > 0 && URL.canParse(specifier)) { + const schemeEnd = specifier.indexOf(':'); + const scheme = specifier.slice(0, schemeEnd).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) { + // A scheme resolver that delegates back into default resolution (via + // `context.resolveRequest`) may surface any resolution error. Preserve + // those, so callers keep the structured failure (candidate paths, etc). + if (isResolutionError(error)) { + throw error; + } + // Otherwise a scheme resolver 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), + {cause: 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 +357,12 @@ export default function resolve( } } + if (schemeError != null) { + // 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] : [], @@ -807,6 +851,22 @@ function isSubpathImport(filePath: string) { return filePath.startsWith('#'); } +/** + * Whether an error is one of metro-resolver's own resolution failures, which + * carry structured detail that callers rely on for diagnostics. + */ +function isResolutionError(error: unknown): boolean { + return ( + error instanceof FailedToResolveNameError || + error instanceof FailedToResolvePathError || + error instanceof FailedToResolveUnsupportedError || + error instanceof InvalidPackageConfigurationError || + error instanceof InvalidPackageError || + error instanceof PackageImportNotResolvedError || + error instanceof PackagePathNotExportedError + ); +} + function resolvedAs( resolution: TResolution, ): Result { 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,