diff --git a/README.md b/README.md index 942474aba62..1fcfb338a58 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/webpack/set-webpack-public-path-plugin](./webpack/set-webpack-public-path-plugin/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fset-webpack-public-path-plugin.svg)](https://badge.fury.io/js/%40rushstack%2Fset-webpack-public-path-plugin) | [changelog](./webpack/set-webpack-public-path-plugin/CHANGELOG.md) | [@rushstack/set-webpack-public-path-plugin](https://www.npmjs.com/package/@rushstack/set-webpack-public-path-plugin) | | [/webpack/webpack-embedded-dependencies-plugin](./webpack/webpack-embedded-dependencies-plugin/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fwebpack-embedded-dependencies-plugin.svg)](https://badge.fury.io/js/%40rushstack%2Fwebpack-embedded-dependencies-plugin) | [changelog](./webpack/webpack-embedded-dependencies-plugin/CHANGELOG.md) | [@rushstack/webpack-embedded-dependencies-plugin](https://www.npmjs.com/package/@rushstack/webpack-embedded-dependencies-plugin) | | [/webpack/webpack-plugin-utilities](./webpack/webpack-plugin-utilities/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fwebpack-plugin-utilities.svg)](https://badge.fury.io/js/%40rushstack%2Fwebpack-plugin-utilities) | [changelog](./webpack/webpack-plugin-utilities/CHANGELOG.md) | [@rushstack/webpack-plugin-utilities](https://www.npmjs.com/package/@rushstack/webpack-plugin-utilities) | +| [/webpack/webpack-workspace-resolve-plugin](./webpack/webpack-workspace-resolve-plugin/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fwebpack-workspace-resolve-plugin.svg)](https://badge.fury.io/js/%40rushstack%2Fwebpack-workspace-resolve-plugin) | [changelog](./webpack/webpack-workspace-resolve-plugin/CHANGELOG.md) | [@rushstack/webpack-workspace-resolve-plugin](https://www.npmjs.com/package/@rushstack/webpack-workspace-resolve-plugin) | | [/webpack/webpack4-localization-plugin](./webpack/webpack4-localization-plugin/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fwebpack4-localization-plugin.svg)](https://badge.fury.io/js/%40rushstack%2Fwebpack4-localization-plugin) | [changelog](./webpack/webpack4-localization-plugin/CHANGELOG.md) | [@rushstack/webpack4-localization-plugin](https://www.npmjs.com/package/@rushstack/webpack4-localization-plugin) | | [/webpack/webpack4-module-minifier-plugin](./webpack/webpack4-module-minifier-plugin/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fwebpack4-module-minifier-plugin.svg)](https://badge.fury.io/js/%40rushstack%2Fwebpack4-module-minifier-plugin) | [changelog](./webpack/webpack4-module-minifier-plugin/CHANGELOG.md) | [@rushstack/webpack4-module-minifier-plugin](https://www.npmjs.com/package/@rushstack/webpack4-module-minifier-plugin) | | [/webpack/webpack5-load-themed-styles-loader](./webpack/webpack5-load-themed-styles-loader/) | [![npm version](https://badge.fury.io/js/%40microsoft%2Fwebpack5-load-themed-styles-loader.svg)](https://badge.fury.io/js/%40microsoft%2Fwebpack5-load-themed-styles-loader) | [changelog](./webpack/webpack5-load-themed-styles-loader/CHANGELOG.md) | [@microsoft/webpack5-load-themed-styles-loader](https://www.npmjs.com/package/@microsoft/webpack5-load-themed-styles-loader) | diff --git a/common/changes/@rushstack/webpack-workspace-resolve-plugin/workspace-resolve-plugin_2024-08-12-19-12.json b/common/changes/@rushstack/webpack-workspace-resolve-plugin/workspace-resolve-plugin_2024-08-12-19-12.json new file mode 100644 index 00000000000..3fe1f92ec33 --- /dev/null +++ b/common/changes/@rushstack/webpack-workspace-resolve-plugin/workspace-resolve-plugin_2024-08-12-19-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/webpack-workspace-resolve-plugin", + "comment": "Add plugin for more efficient import resolution in a monorepo with known structure. Optimizes lookup of the relevant `package.json` for a given path, and lookup of npm dependencies of the containing package.", + "type": "minor" + } + ], + "packageName": "@rushstack/webpack-workspace-resolve-plugin" +} \ No newline at end of file diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 5423702e50a..55afe1fd331 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4401,6 +4401,25 @@ importers: specifier: ~5.82.1 version: 5.82.1 + ../../../webpack/webpack-workspace-resolve-plugin: + dependencies: + '@rushstack/lookup-by-path': + specifier: workspace:* + version: link:../../libraries/lookup-by-path + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + memfs: + specifier: 3.4.3 + version: 3.4.3 + webpack: + specifier: ~5.82.1 + version: 5.82.1 + ../../../webpack/webpack4-localization-plugin: dependencies: '@rushstack/localization-utilities': diff --git a/common/reviews/api/webpack-workspace-resolve-plugin.api.md b/common/reviews/api/webpack-workspace-resolve-plugin.api.md new file mode 100644 index 00000000000..909e04e02bc --- /dev/null +++ b/common/reviews/api/webpack-workspace-resolve-plugin.api.md @@ -0,0 +1,68 @@ +## API Report File for "@rushstack/webpack-workspace-resolve-plugin" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { Compiler } from 'webpack'; +import { IPrefixMatch } from '@rushstack/lookup-by-path'; +import { LookupByPath } from '@rushstack/lookup-by-path'; +import type { WebpackPluginInstance } from 'webpack'; + +// @beta +export type IPathNormalizationFunction = ((input: string) => string) | undefined; + +// @beta +export interface IResolveContext { + descriptionFileRoot: string; + findDependency(request: string): IPrefixMatch | undefined; +} + +// @beta +export interface IResolverCacheFile { + contexts: ISerializedResolveContext[]; +} + +// @beta +export interface ISerializedResolveContext { + deps: Record; + dirInfoFiles?: string[]; + name: string; + root: string; +} + +// @beta +export interface IWorkspaceLayoutCacheOptions { + cacheData: IResolverCacheFile; + resolverPathSeparator?: '/' | '\\'; + workspaceRoot: string; +} + +// @beta +export interface IWorkspaceResolvePluginOptions { + cache: WorkspaceLayoutCache; +} + +// @beta +export class WorkspaceLayoutCache { + constructor(options: IWorkspaceLayoutCacheOptions); + readonly contextForPackage: WeakMap; + readonly contextLookup: LookupByPath; + // (undocumented) + readonly normalizeToPlatform: IPathNormalizationFunction; + // (undocumented) + readonly normalizeToSlash: IPathNormalizationFunction; + // (undocumented) + readonly resolverPathSeparator: string; +} + +// @beta +export class WorkspaceResolvePlugin implements WebpackPluginInstance { + constructor(cache: WorkspaceLayoutCache); + // (undocumented) + apply(compiler: Compiler): void; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/rush.json b/rush.json index 5728d6e9eb8..d6a547cecf3 100644 --- a/rush.json +++ b/rush.json @@ -1275,6 +1275,12 @@ "reviewCategory": "libraries", "shouldPublish": false // for now }, + { + "packageName": "@rushstack/webpack-workspace-resolve-plugin", + "projectFolder": "webpack/webpack-workspace-resolve-plugin", + "reviewCategory": "libraries", + "shouldPublish": true + }, { "packageName": "@rushstack/set-webpack-public-path-plugin", "projectFolder": "webpack/set-webpack-public-path-plugin", diff --git a/webpack/webpack-workspace-resolve-plugin/.eslintrc.js b/webpack/webpack-workspace-resolve-plugin/.eslintrc.js new file mode 100644 index 00000000000..27dc0bdff95 --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/.eslintrc.js @@ -0,0 +1,12 @@ +// This is a workaround for https://github.com/eslint/eslint/issues/3458 +require('local-node-rig/profiles/default/includes/eslint/patch/modern-module-resolution'); +// This is a workaround for https://github.com/microsoft/rushstack/issues/3021 +require('local-node-rig/profiles/default/includes/eslint/patch/custom-config-package-names'); + +module.exports = { + extends: [ + 'local-node-rig/profiles/default/includes/eslint/profile/node-trusted-tool', + 'local-node-rig/profiles/default/includes/eslint/mixins/friendly-locals' + ], + parserOptions: { tsconfigRootDir: __dirname } +}; diff --git a/webpack/webpack-workspace-resolve-plugin/.npmignore b/webpack/webpack-workspace-resolve-plugin/.npmignore new file mode 100644 index 00000000000..bc349f9a4be --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/.npmignore @@ -0,0 +1,32 @@ +# THIS IS A STANDARD TEMPLATE FOR .npmignore FILES IN THIS REPO. + +# Ignore all files by default, to avoid accidentally publishing unintended files. +* + +# Use negative patterns to bring back the specific things we want to publish. +!/bin/** +!/lib/** +!/lib-*/** +!/dist/** + +!CHANGELOG.md +!CHANGELOG.json +!heft-plugin.json +!rush-plugin-manifest.json +!ThirdPartyNotice.txt + +# Ignore certain patterns that should not get published. +/dist/*.stats.* +/lib/**/test/ +/lib-*/**/test/ +*.test.js + +# NOTE: These don't need to be specified, because NPM includes them automatically. +# +# package.json +# README.md +# LICENSE + +# --------------------------------------------------------------------------- +# DO NOT MODIFY ABOVE THIS LINE! Add any project-specific overrides below. +# --------------------------------------------------------------------------- diff --git a/webpack/webpack-workspace-resolve-plugin/LICENSE b/webpack/webpack-workspace-resolve-plugin/LICENSE new file mode 100644 index 00000000000..42b9592b121 --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/LICENSE @@ -0,0 +1,24 @@ +@rushstack/webpack-workspace-resolve-plugin + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/webpack/webpack-workspace-resolve-plugin/README.md b/webpack/webpack-workspace-resolve-plugin/README.md new file mode 100644 index 00000000000..eff328065f1 --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/README.md @@ -0,0 +1,35 @@ +# @rushstack/webpack-workspace-resolve-plugin + +This package contains a plugin for webpack 5 that leverages a cache file generated from package manager metadata to greatly accelerate module resolution. +Local benchmarks have shown a savings of around 10% of build time for some fairly large closed source projects. + +## Installation + +`npm install @rushstack/webpack-workspace-resolve-plugin --save-dev` + +## Overview + +This plugin is intended primarily for use in pnpm monorepos, but any tool that produces a strict package layout can be made compatible by generating the necessary cache file. + +The cache file contains information about the locations of every `package.json` file known to the package manager (including those in subdirectories of packages), as well as the list of declared dependencies of each and where they can be found. + +When using this plugin, the following options should be configured for your resolver: +- `symlinks: false` - Since the cache knows the symlinks for package dependencies, you can avoid the cost of testing for other symlinks unless you are using additional symlinks. +- `modules: []` - The cache should contain all information necessary to locate available dependencies for any arbitrary folder. If you need to allow resolution in other roots, you can add those, but omit `'node_modules'`. + +## Limitations + +This plugin depends on the presence of a cache file in the workspace to function. Data in this cache file is assumed not to change while the webpack process is running. + +**Note:** Generating the cache file is not in the scope of this plugin. + +This plugin does not currently support having subdirectory `package.json` files within workspace projects (e.g. for declaring `{ "type": "module" }` in mixed CommonJS/ESM packages). +This plugin does not work (well) with a hoisted node_modules installation layout. + +## Links + +- [CHANGELOG.md]( + https://github.com/microsoft/rushstack/blob/main/webpack/webpack-workspace-resolve-plugin/CHANGELOG.md) - Find + out what's new in the latest version + +`@rushstack/webpack5-workspace-resolve-plugin` is part of the [Rush Stack](https://rushstack.io/) family of projects. diff --git a/webpack/webpack-workspace-resolve-plugin/config/api-extractor.json b/webpack/webpack-workspace-resolve-plugin/config/api-extractor.json new file mode 100644 index 00000000000..fba8a2992f6 --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/config/api-extractor.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + "mainEntryPointFilePath": "/lib/index.d.ts", + + "apiReport": { + "enabled": true, + "reportFolder": "../../../common/reviews/api" + }, + + "docModel": { + "enabled": false, + "apiJsonFilePath": "../../../common/temp/api/.api.json" + }, + + "dtsRollup": { + "enabled": true + } +} diff --git a/webpack/webpack-workspace-resolve-plugin/config/jest.config.json b/webpack/webpack-workspace-resolve-plugin/config/jest.config.json new file mode 100644 index 00000000000..d1749681d90 --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/webpack/webpack-workspace-resolve-plugin/config/rig.json b/webpack/webpack-workspace-resolve-plugin/config/rig.json new file mode 100644 index 00000000000..165ffb001f5 --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/config/rig.json @@ -0,0 +1,7 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "local-node-rig" +} diff --git a/webpack/webpack-workspace-resolve-plugin/package.json b/webpack/webpack-workspace-resolve-plugin/package.json new file mode 100644 index 00000000000..02296e1343f --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/package.json @@ -0,0 +1,40 @@ +{ + "name": "@rushstack/webpack-workspace-resolve-plugin", + "version": "0.0.0", + "description": "This plugin leverages workspace-level metadata to greatly accelerate module resolution.", + "main": "lib/index.js", + "typings": "dist/webpack-workspace-resolve-plugin.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/rushstack.git", + "directory": "webpack/webpack-workspace-resolve-plugin" + }, + "engines": { + "node": ">=18.19.0" + }, + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "peerDependencies": { + "webpack": "^5.68.0", + "@types/node": "*" + }, + "dependencies": { + "@rushstack/lookup-by-path": "workspace:*" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "local-node-rig": "workspace:*", + "memfs": "3.4.3", + "webpack": "~5.82.1" + }, + "sideEffects": false, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } +} diff --git a/webpack/webpack-workspace-resolve-plugin/src/KnownDescriptionFilePlugin.ts b/webpack/webpack-workspace-resolve-plugin/src/KnownDescriptionFilePlugin.ts new file mode 100644 index 00000000000..769721c181f --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/src/KnownDescriptionFilePlugin.ts @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { Resolver } from 'webpack'; +import type { IPrefixMatch } from '@rushstack/lookup-by-path'; +import type { IResolveContext, WorkspaceLayoutCache } from './WorkspaceLayoutCache'; + +type ResolveRequest = Parameters[1]; + +/** + * A resolver plugin that optimizes locating the package.json file for a module. + * + * @internal + */ +export class KnownDescriptionFilePlugin { + public readonly source: string; + public readonly target: string; + + private readonly _skipForContext: boolean; + private readonly _cache: WorkspaceLayoutCache; + + /** + * Constructs a new instance of `KnownDescriptionFilePlugin`. + * @param cache - The workspace layout cache + * @param source - The resolve step to hook into + * @param target - The resolve step to delegate to + * @param skipForContext - If true, don't apply this plugin if the resolver is configured to resolve to a context + */ + public constructor(cache: WorkspaceLayoutCache, source: string, target: string, skipForContext?: boolean) { + this.source = source; + this.target = target; + this._cache = cache; + this._skipForContext = !!skipForContext; + } + + public apply(resolver: Resolver): void { + if (this._skipForContext && resolver.options.resolveToContext) { + return; + } + + const target: ReturnType = resolver.ensureHook(this.target); + const { fileSystem } = resolver; + + function readDescriptionFileWithParse( + descriptionFilePath: string, + callback: (err: Error | null | undefined, data?: object) => void + ): void { + fileSystem.readFile(descriptionFilePath, (err: Error | null | undefined, data?: string | Buffer) => { + if (!data?.length) { + return callback(err); + } + // eslint-disable-next-line @rushstack/no-new-null + callback(null, JSON.parse(data.toString())); + }); + } + + const readDescriptionFile: ( + descriptionFilePath: string, + cb: (err: Error | null | undefined, data?: object) => void + ) => void = fileSystem.readJson?.bind(fileSystem) ?? readDescriptionFileWithParse; + + resolver + .getHook(this.source) + .tapAsync(KnownDescriptionFilePlugin.name, (request, resolveContext, callback) => { + const { path } = request; + if (!path) { + // No request, nothing to do. + return callback(); + } + + const cache: WorkspaceLayoutCache = this._cache; + + const match: IPrefixMatch | undefined = + cache.contextLookup.findLongestPrefixMatch(path); + if (!match) { + // No description file available, proceed without. + return callback(); + } + + const remainingPath: string = path.slice(match.index); + const relativePath: string = `.${cache.normalizeToSlash?.(remainingPath) ?? remainingPath}`; + const descriptionFileRoot: string = `${path.slice(0, match.index)}`; + const descriptionFilePath: string = `${descriptionFileRoot}${cache.resolverPathSeparator}package.json`; + + const { contextForPackage } = cache; + + readDescriptionFile(descriptionFilePath, (err, descriptionFileData) => { + if (!descriptionFileData) { + resolveContext.missingDependencies?.add(descriptionFilePath); + return callback(err); + } + + resolveContext.fileDependencies?.add(descriptionFilePath); + // Store the resolver context since a WeakMap lookup is cheaper than walking the tree again + contextForPackage.set(descriptionFileData, match.value); + + // Since we don't allow any alternative processing of request, we can mutate it + // instead of cloning it. + request.descriptionFileRoot = descriptionFileRoot; + request.descriptionFilePath = descriptionFilePath; + request.descriptionFileData = descriptionFileData; + request.relativePath = relativePath; + + // Delegate to the resolver step at `target`. + resolver.doResolve( + target, + request, + 'using description file: ' + descriptionFilePath + ' (relative path: ' + relativePath + ')', + resolveContext, + (e: Error | undefined, result: ResolveRequest | undefined) => { + if (e) { + return callback(e); + } + + // Don't allow other processing + if (result === undefined) { + // eslint-disable-next-line @rushstack/no-new-null + return callback(null, null); + } + // eslint-disable-next-line @rushstack/no-new-null + callback(null, result); + } + ); + }); + }); + } +} diff --git a/webpack/webpack-workspace-resolve-plugin/src/KnownPackageDependenciesPlugin.ts b/webpack/webpack-workspace-resolve-plugin/src/KnownPackageDependenciesPlugin.ts new file mode 100644 index 00000000000..8e03064bfcf --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/src/KnownPackageDependenciesPlugin.ts @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { Resolver } from 'webpack'; +import type { IPrefixMatch } from '@rushstack/lookup-by-path'; +import type { IResolveContext, WorkspaceLayoutCache } from './WorkspaceLayoutCache'; + +type ResolveRequest = Parameters[1]; + +/** + * A resolver plugin that optimizes resolving installed dependencies for the current package. + * Enforces strict resolution. + * + * @internal + */ +export class KnownPackageDependenciesPlugin { + public readonly source: string; + public readonly target: string; + + private readonly _cache: WorkspaceLayoutCache; + + /** + * Constructs a new instance of `KnownPackageDependenciesPlugin`. + * @param cache - The workspace layout cache + * @param source - The resolve step to hook into + * @param target - The resolve step to delegate to + */ + public constructor(cache: WorkspaceLayoutCache, source: string, target: string) { + this.source = source; + this.target = target; + this._cache = cache; + } + + public apply(resolver: Resolver): void { + const target: ReturnType = resolver.ensureHook(this.target); + + resolver + .getHook(this.source) + .tapAsync(KnownPackageDependenciesPlugin.name, (request, resolveContext, callback) => { + const { path, request: rawRequest } = request; + if (!path) { + return callback(); + } + + if (!rawRequest) { + return callback(); + } + + const { descriptionFileData } = request; + if (!descriptionFileData) { + return callback(new Error(`Expected descriptionFileData for ${path}`)); + } + + const cache: WorkspaceLayoutCache = this._cache; + + const context: IResolveContext | undefined = cache.contextForPackage.get(descriptionFileData); + if (!context) { + return callback(new Error(`Expected context for ${request.descriptionFileRoot}`)); + } + + const match: IPrefixMatch | undefined = context.findDependency(rawRequest); + if (!match) { + return callback(); + } + + const isPackageRoot: boolean = match.index === rawRequest.length; + const fullySpecified: boolean | undefined = isPackageRoot ? false : request.fullySpecified; + const remainingPath: string = isPackageRoot ? '.' : `.${rawRequest.slice(match.index)}`; + const relativePath: string = + (remainingPath.length > 1 && cache.normalizeToSlash?.(remainingPath)) || remainingPath; + const { descriptionFileRoot } = match.value; + const obj: ResolveRequest = { + ...request, + path: descriptionFileRoot, + descriptionFileRoot, + descriptionFileData: undefined, + descriptionFilePath: `${descriptionFileRoot}${cache.resolverPathSeparator}package.json`, + + relativePath: relativePath, + request: relativePath, + fullySpecified, + module: false + }; + // eslint-disable-next-line @rushstack/no-new-null + resolver.doResolve(target, obj, null, resolveContext, callback); + }); + } +} diff --git a/webpack/webpack-workspace-resolve-plugin/src/WorkspaceLayoutCache.ts b/webpack/webpack-workspace-resolve-plugin/src/WorkspaceLayoutCache.ts new file mode 100644 index 00000000000..e3296428d9a --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/src/WorkspaceLayoutCache.ts @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { sep as directorySeparator } from 'node:path'; + +import { LookupByPath, type IPrefixMatch } from '@rushstack/lookup-by-path'; + +/** + * Information about a local or installed npm package. + * @beta + */ +export interface ISerializedResolveContext { + /** + * The path to the root folder of this context. + * This path is normalized to use `/` as the separator and should not end with a trailing `/`. + */ + root: string; + /** + * The name of this package. Used to inject a self-reference into the dependency map. + */ + name: string; + /** + * Map of declared dependencies to the ordinal of the corresponding context. + */ + deps: Record; + /** + * Set of relative paths to nested `package.json` files within this context. + * These paths are normalized to use `/` as the separator and should not begin with a leading `./`. + */ + dirInfoFiles?: string[]; +} + +/** + * The serialized form of the cache file. This file is expected to be generated by a separate tool from + * information known to the package manager. Namely, the dependency relationships between packages, and + * all the `package.json` files in the workspace (installed or local). + * @beta + */ +export interface IResolverCacheFile { + /** + * The ordered list of all contexts in the cache + */ + contexts: ISerializedResolveContext[]; +} + +/** + * A context for resolving dependencies in a workspace. + * @beta + */ +export interface IResolveContext { + /** + * The absolute path to the root folder of this context + */ + descriptionFileRoot: string; + /** + * Find the context that corresponds to a module specifier, when requested in the current context. + * @param request - The module specifier to resolve + */ + findDependency(request: string): IPrefixMatch | undefined; +} + +/** + * Options for creating a `WorkspaceLayoutCache`. + * @beta + */ +export interface IWorkspaceLayoutCacheOptions { + /** + * The root folder of the workspace. All paths in the cache file are assumed to be relative to this folder. + */ + workspaceRoot: string; + /** + * The parsed cache data. File reading is left as an exercise for the caller. + */ + cacheData: IResolverCacheFile; + /** + * The directory separator used in the `path` field of the resolver inputs. + * Will usually be `path.sep`. + */ + resolverPathSeparator?: '/' | '\\'; +} + +/** + * A function that normalizes a path to a platform-specific format (if needed). + * Will be undefined if the platform uses `/` as the path separator. + * + * @beta + */ +export type IPathNormalizationFunction = ((input: string) => string) | undefined; + +function backslashToSlash(path: string): string { + return path.replace(/\\/g, '/'); +} + +function slashToBackslash(path: string): string { + return path.replace(/\//g, '\\'); +} + +/** + * A cache of workspace layout information. + * @beta + */ +export class WorkspaceLayoutCache { + /** + * A lookup of context roots to their corresponding context objects + */ + public readonly contextLookup: LookupByPath; + /** + * A weak map of package JSON contents to their corresponding context objects + */ + public readonly contextForPackage: WeakMap; + + public readonly resolverPathSeparator: string; + public readonly normalizeToSlash: IPathNormalizationFunction; + public readonly normalizeToPlatform: IPathNormalizationFunction; + + public constructor(options: IWorkspaceLayoutCacheOptions) { + const { workspaceRoot, cacheData, resolverPathSeparator = directorySeparator } = options; + + if (resolverPathSeparator !== '/' && resolverPathSeparator !== '\\') { + throw new Error(`Unsupported directory separator: ${resolverPathSeparator}`); + } + + const resolveContexts: IResolveContext[] = []; + const contextLookup: LookupByPath = new LookupByPath(undefined, resolverPathSeparator); + + this.contextLookup = contextLookup; + this.contextForPackage = new WeakMap(); + + const normalizeToSlash: IPathNormalizationFunction = + resolverPathSeparator === '\\' ? backslashToSlash : undefined; + const normalizeToPlatform: IPathNormalizationFunction = + resolverPathSeparator === '\\' ? slashToBackslash : undefined; + + this.resolverPathSeparator = resolverPathSeparator; + this.normalizeToSlash = normalizeToSlash; + this.normalizeToPlatform = normalizeToPlatform; + + // Internal class due to coupling to `resolveContexts` + class ResolveContext implements IResolveContext { + private readonly _serialized: ISerializedResolveContext; + private _descriptionFileRoot: string | undefined; + private _dependencies: LookupByPath | undefined; + + public constructor(serialized: ISerializedResolveContext) { + this._serialized = serialized; + this._descriptionFileRoot = undefined; + this._dependencies = undefined; + } + + public get descriptionFileRoot(): string { + if (!this._descriptionFileRoot) { + this._descriptionFileRoot = `${workspaceRoot}${resolverPathSeparator}${ + normalizeToPlatform?.(this._serialized.root) ?? this._serialized.root + }`; + } + return this._descriptionFileRoot; + } + + public findDependency(request: string): IPrefixMatch | undefined { + if (!this._dependencies) { + // Lazy initialize this object since most packages won't be requested. + const dependencies: LookupByPath = new LookupByPath(undefined, '/'); + // Handle the self-reference scenario + dependencies.setItem(this._serialized.name, this); + for (const [key, ordinal] of Object.entries(this._serialized.deps)) { + // This calls into the array of instances that is owned by WorkpaceLayoutCache + dependencies.setItem(key, resolveContexts[ordinal]); + } + this._dependencies = dependencies; + } + + return this._dependencies.findLongestPrefixMatch(request); + } + } + + for (const serialized of cacheData.contexts) { + const resolveContext: IResolveContext = new ResolveContext(serialized); + resolveContexts.push(resolveContext); + + const descriptionFileRoot: string = resolveContext.descriptionFileRoot; + contextLookup.setItem(descriptionFileRoot, resolveContext); + + // Handle nested package.json files. These may modify some properties, but the dependency resolution + // will match the original package root. Typically these are used to set the `type` field to `module`. + if (serialized.dirInfoFiles) { + for (const file of serialized.dirInfoFiles) { + contextLookup.setItemFromSegments( + concat( + // Root is normalized to platform slashes + LookupByPath.iteratePathSegments(descriptionFileRoot, resolverPathSeparator), + // Subpaths are platform-agnostic + LookupByPath.iteratePathSegments(file, '/') + ), + resolveContext + ); + } + } + } + } +} + +function* concat(a: Iterable, b: Iterable): IterableIterator { + yield* a; + yield* b; +} diff --git a/webpack/webpack-workspace-resolve-plugin/src/WorkspaceResolvePlugin.ts b/webpack/webpack-workspace-resolve-plugin/src/WorkspaceResolvePlugin.ts new file mode 100644 index 00000000000..b6564e5824d --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/src/WorkspaceResolvePlugin.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { WebpackPluginInstance, Compiler } from 'webpack'; + +import type { WorkspaceLayoutCache } from './WorkspaceLayoutCache'; +import { KnownDescriptionFilePlugin } from './KnownDescriptionFilePlugin'; +import { KnownPackageDependenciesPlugin } from './KnownPackageDependenciesPlugin'; + +/** + * Options for constructing a `WorkspaceResolvePlugin`. + * + * @beta + */ +export interface IWorkspaceResolvePluginOptions { + /** + * The cache of workspace layout information. + */ + cache: WorkspaceLayoutCache; +} + +/** + * A Webpack plugin that optimizes package.json lookups and resolution of bare specifiers in a monorepo. + * + * @beta + */ +export class WorkspaceResolvePlugin implements WebpackPluginInstance { + private readonly _cache: WorkspaceLayoutCache; + + public constructor(cache: WorkspaceLayoutCache) { + this._cache = cache; + } + + public apply(compiler: Compiler): void { + compiler.resolverFactory.hooks.resolveOptions + .for('normal') + .tap(WorkspaceResolvePlugin.name, (resolveOptions) => { + // Omit default `node_modules` + if (resolveOptions.modules) { + resolveOptions.modules = resolveOptions.modules.filter((modulePath: string) => { + return modulePath !== 'node_modules'; + }); + } else { + resolveOptions.modules = []; + } + + const cache: WorkspaceLayoutCache = this._cache; + + resolveOptions.plugins ??= []; + resolveOptions.plugins.push( + // Optimize identifying the package.json file for the issuer + new KnownDescriptionFilePlugin(cache, 'parsed-resolve', 'described-resolve'), + // Optimize locating the installed dependencies of the current package + new KnownPackageDependenciesPlugin(cache, 'raw-module', 'resolve-as-module'), + // Optimize loading the package.json file for the destination package + new KnownDescriptionFilePlugin(cache, 'relative', 'described-relative'), + // Optimize locating and loading nested package.json for a directory + new KnownDescriptionFilePlugin(cache, 'undescribed-existing-directory', 'existing-directory', true), + // Optimize locating and loading nested package.json for a file + new KnownDescriptionFilePlugin(cache, 'undescribed-raw-file', 'raw-file') + ); + + return resolveOptions; + }); + } +} diff --git a/webpack/webpack-workspace-resolve-plugin/src/index.ts b/webpack/webpack-workspace-resolve-plugin/src/index.ts new file mode 100644 index 00000000000..61866c3a162 --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/src/index.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +export { WorkspaceResolvePlugin, type IWorkspaceResolvePluginOptions } from './WorkspaceResolvePlugin'; +export { + WorkspaceLayoutCache, + type IPathNormalizationFunction, + type IWorkspaceLayoutCacheOptions, + type IResolveContext, + type ISerializedResolveContext, + type IResolverCacheFile +} from './WorkspaceLayoutCache'; diff --git a/webpack/webpack-workspace-resolve-plugin/src/test/KnownDescriptionFilePlugin.test.ts b/webpack/webpack-workspace-resolve-plugin/src/test/KnownDescriptionFilePlugin.test.ts new file mode 100644 index 00000000000..78cc2153941 --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/src/test/KnownDescriptionFilePlugin.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { KnownDescriptionFilePlugin } from '../KnownDescriptionFilePlugin'; + +import { + parsedJson, + createResolveForTests, + type WrappedResolve, + type ResolveContext +} from './createResolveForTests'; + +function createResolve(separator: '/' | '\\'): WrappedResolve { + return createResolveForTests(separator, (cache, resolver) => { + const plugin: KnownDescriptionFilePlugin = new KnownDescriptionFilePlugin(cache, 'source', 'target'); + plugin.apply(resolver); + }); +} + +describe(KnownDescriptionFilePlugin.name, () => { + it('should resolve the package.json file for a module (/)', () => { + const resolver: WrappedResolve = createResolve('/'); + + const fileDependencies: Set = new Set(); + const context: ResolveContext = { fileDependencies }; + + const [err1, result1] = resolver({ path: '/workspace/a/lib/index.js' }, context); + expect(err1).toBeNull(); + expect(result1).toEqual({ + path: '/workspace/a/lib/index.js', + descriptionFileRoot: '/workspace/a', + descriptionFileData: parsedJson['/workspace/a/package.json'], + descriptionFilePath: '/workspace/a/package.json', + relativePath: './lib/index.js' + }); + expect(fileDependencies.size).toEqual(1); + expect(fileDependencies.has('/workspace/a/package.json')).toBeTruthy(); + + fileDependencies.clear(); + + const [err2, result2] = resolver({ path: '/workspace/a/foo/bar/baz.js' }, context); + expect(err2).toBeNull(); + expect(result2).toMatchObject({ + path: '/workspace/a/foo/bar/baz.js', + descriptionFileRoot: '/workspace/a', + descriptionFileData: parsedJson['/workspace/a/package.json'], + descriptionFilePath: '/workspace/a/package.json', + relativePath: './foo/bar/baz.js' + }); + expect(fileDependencies.size).toEqual(1); + expect(fileDependencies.has('/workspace/a/package.json')).toBeTruthy(); + + fileDependencies.clear(); + + const [err3, result3] = resolver({ path: '/workspace/a/lib-esm/index.js' }, context); + expect(err3).toBeNull(); + expect(result3).toMatchObject({ + path: '/workspace/a/lib-esm/index.js', + descriptionFileRoot: '/workspace/a/lib-esm', + descriptionFileData: parsedJson['/workspace/a/lib-esm/package.json'], + descriptionFilePath: '/workspace/a/lib-esm/package.json', + relativePath: './index.js' + }); + expect(fileDependencies.size).toEqual(1); + expect(fileDependencies.has('/workspace/a/lib-esm/package.json')).toBeTruthy(); + + fileDependencies.clear(); + }); + + it('should resolve the package.json file for a module (\\)', () => { + const resolver: WrappedResolve = createResolve('\\'); + + const fileDependencies: Set = new Set(); + const context: ResolveContext = { fileDependencies }; + + const [err1, result1] = resolver({ path: '\\workspace\\a\\lib\\index.js' }, context); + expect(err1).toBeNull(); + expect(result1).toEqual({ + path: '\\workspace\\a\\lib\\index.js', + descriptionFileRoot: '\\workspace\\a', + descriptionFileData: parsedJson['/workspace/a/package.json'], + descriptionFilePath: '\\workspace\\a\\package.json', + relativePath: './lib/index.js' + }); + expect(fileDependencies.size).toEqual(1); + expect(fileDependencies.has('\\workspace\\a\\package.json')).toBeTruthy(); + + fileDependencies.clear(); + + const [err2, result2] = resolver({ path: '\\workspace\\a\\foo\\bar\\baz.js' }, context); + expect(err2).toBeNull(); + expect(result2).toMatchObject({ + path: '\\workspace\\a\\foo\\bar\\baz.js', + descriptionFileRoot: '\\workspace\\a', + descriptionFileData: parsedJson['/workspace/a/package.json'], + descriptionFilePath: '\\workspace\\a\\package.json', + relativePath: './foo/bar/baz.js' + }); + expect(fileDependencies.size).toEqual(1); + expect(fileDependencies.has('\\workspace\\a\\package.json')).toBeTruthy(); + + fileDependencies.clear(); + + const [err3, result3] = resolver({ path: '\\workspace\\a\\lib-esm\\index.js' }, context); + expect(err3).toBeNull(); + expect(result3).toMatchObject({ + path: '\\workspace\\a\\lib-esm\\index.js', + descriptionFileRoot: '\\workspace\\a\\lib-esm', + descriptionFileData: parsedJson['/workspace/a/lib-esm/package.json'], + descriptionFilePath: '\\workspace\\a\\lib-esm\\package.json', + relativePath: './index.js' + }); + expect(fileDependencies.size).toEqual(1); + expect(fileDependencies.has('\\workspace\\a\\lib-esm\\package.json')).toBeTruthy(); + + fileDependencies.clear(); + }); + + it('should defer to other plugins if not in a context', () => { + const resolver: WrappedResolve = createResolve('/'); + + const [err1, result1] = resolver({ path: '/workspace/c/lib/index.js' }, {}); + expect(err1).toBeUndefined(); + expect(result1).toBeUndefined(); + }); +}); diff --git a/webpack/webpack-workspace-resolve-plugin/src/test/KnownPackageDependenciesPlugin.test.ts b/webpack/webpack-workspace-resolve-plugin/src/test/KnownPackageDependenciesPlugin.test.ts new file mode 100644 index 00000000000..16bf41647e3 --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/src/test/KnownPackageDependenciesPlugin.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { KnownPackageDependenciesPlugin } from '../KnownPackageDependenciesPlugin'; +import { createResolveForTests, parsedJson, type WrappedResolve } from './createResolveForTests'; + +function createResolve(separator: '/' | '\\'): WrappedResolve { + return createResolveForTests(separator, (cache, resolver) => { + const plugin: KnownPackageDependenciesPlugin = new KnownPackageDependenciesPlugin( + cache, + 'source', + 'target' + ); + plugin.apply(resolver); + }); +} + +describe(KnownPackageDependenciesPlugin.name, () => { + it('should find a relevant dependency (/)', () => { + const resolver: WrappedResolve = createResolve('/'); + + const descriptionFilePath: string = '/workspace/b/package.json'; + const descriptionFileData: object = parsedJson[descriptionFilePath]; + const descriptionFileRoot: string = '/workspace/b'; + + const [err1, result1] = resolver( + { + path: '/workspace/b/lib/foo.js', + request: 'a/lib/index.js', + descriptionFileRoot, + descriptionFileData, + descriptionFilePath, + relativePath: './lib/foo.js' + }, + {} + ); + + expect(err1).toBeFalsy(); + expect(result1).toEqual({ + path: '/workspace/a', + request: './lib/index.js', + descriptionFileRoot: '/workspace/a', + descriptionFilePath: '/workspace/a/package.json', + relativePath: './lib/index.js', + fullySpecified: undefined, + module: false + }); + }); + it('should find a relevant dependency (\\)', () => { + const resolver: WrappedResolve = createResolve('\\'); + + const descriptionFilePath: string = '\\workspace\\b\\package.json'; + const descriptionFileData: object = parsedJson['/workspace/b/package.json']; + const descriptionFileRoot: string = '\\workspace\\b'; + + const [err1, result1] = resolver( + { + path: '\\workspace\\b\\lib\\foo.js', + request: 'a/lib/index.js', + descriptionFileRoot, + descriptionFileData, + descriptionFilePath, + relativePath: './lib/foo.js' + }, + {} + ); + + expect(err1).toBeFalsy(); + expect(result1).toEqual({ + path: '\\workspace\\a', + request: './lib/index.js', + descriptionFileRoot: '\\workspace\\a', + descriptionFilePath: '\\workspace\\a\\package.json', + relativePath: './lib/index.js', + fullySpecified: undefined, + module: false + }); + }); + + it('should handle self-reference', () => { + const resolver: WrappedResolve = createResolve('/'); + + const descriptionFilePath: string = '/workspace/b/package.json'; + const descriptionFileData: object = parsedJson[descriptionFilePath]; + const descriptionFileRoot: string = '/workspace/b'; + + const [err1, result1] = resolver( + { + path: '/workspace/b/lib/foo.js', + request: 'b/lib/bar.js', + descriptionFileRoot, + descriptionFileData, + descriptionFilePath, + relativePath: './lib/foo.js' + }, + {} + ); + + expect(err1).toBeFalsy(); + expect(result1).toEqual({ + path: '/workspace/b', + request: './lib/bar.js', + descriptionFileRoot: '/workspace/b', + descriptionFilePath: '/workspace/b/package.json', + relativePath: './lib/bar.js', + fullySpecified: undefined, + module: false + }); + }); + + it('should defer to other plugins if not in a context', () => { + const resolver: WrappedResolve = createResolve('/'); + + const [err1, result1] = resolver({ path: '/workspace/c/lib/index.js' }, {}); + expect(err1).toBeUndefined(); + expect(result1).toBeUndefined(); + }); + + it('should defer to other plugins if the dependency is not found (for fallback)', () => { + const resolver: WrappedResolve = createResolve('/'); + + const descriptionFilePath: string = '/workspace/a/package.json'; + const descriptionFileData: object = parsedJson[descriptionFilePath]; + const descriptionFileRoot: string = '/workspace/a'; + + const [err1, result1] = resolver( + { + path: '/workspace/a/lib/foo.js', + request: 'events', + descriptionFileRoot, + descriptionFileData, + descriptionFilePath, + relativePath: './lib/foo.js' + }, + {} + ); + + expect(err1).toBeUndefined(); + expect(result1).toBeUndefined(); + }); +}); diff --git a/webpack/webpack-workspace-resolve-plugin/src/test/createResolveForTests.ts b/webpack/webpack-workspace-resolve-plugin/src/test/createResolveForTests.ts new file mode 100644 index 00000000000..0fd8670ad12 --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/src/test/createResolveForTests.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { Volume } from 'memfs/lib/volume'; +import type { Compiler, Resolver } from 'webpack'; + +import { WorkspaceLayoutCache, type IResolveContext } from '../WorkspaceLayoutCache'; + +export type ResolveCallback = Parameters[1]; +export type ResolveRequest = Parameters[0]; +export type ResolveContext = Parameters[1]; +export type WrappedResolve = ( + request: ResolveRequest, + resolveContext: ResolveContext + // eslint-disable-next-line @rushstack/no-new-null +) => [Error | false | null | undefined, ResolveRequest | undefined]; + +export const parsedJson: Record = { + '/workspace/a/package.json': { name: 'a' }, + '/workspace/a/lib-esm/package.json': { type: 'module' }, + '/workspace/b/package.json': { name: 'b', dependencies: { a: 'workspace:*' } } +}; + +export function createResolveForTests( + separator: '/' | '\\', + attachPlugins: (cache: WorkspaceLayoutCache, resolver: Resolver) => void +): WrappedResolve { + const fileSystem: Volume = new Volume(); + + const cache: WorkspaceLayoutCache = new WorkspaceLayoutCache({ + workspaceRoot: `${separator}workspace`, + cacheData: { + contexts: [ + { + root: 'a', + name: 'a', + deps: {}, + dirInfoFiles: ['lib-esm'] + }, + { + root: 'b', + name: 'b', + deps: { a: 0 } + } + ] + }, + resolverPathSeparator: separator + }); + + const platformJson: Record = Object.fromEntries( + Object.entries(parsedJson).map(([key, value]) => [cache.normalizeToPlatform?.(key) ?? key, value]) + ); + + const serializedJson: Record = Object.fromEntries( + Object.entries(platformJson).map(([key, value]) => [key, JSON.stringify(value)]) + ); + + fileSystem.fromJSON(serializedJson); + (fileSystem as Compiler['inputFileSystem']).readJson = ( + path: string, + cb: (err: Error | null | undefined, data?: object) => void + ) => { + const parsed: object | undefined = platformJson[path]; + if (parsed) { + return cb(null, parsed); + } + return cb(new Error(`No data found for ${path}`)); + }; + + let innerCallback: ResolveCallback | undefined = undefined; + + const resolver: Resolver = { + fileSystem, + doResolve: ( + step: string, + request: ResolveRequest, + message: string, + resolveContext: ResolveContext, + callback: (err: Error | undefined, result: ResolveRequest | undefined) => void + ) => { + return callback(undefined, request); + }, + ensureHook: (step: string) => { + expect(step).toEqual('target'); + }, + getHook: (step: string) => { + expect(step).toEqual('source'); + return { + tapAsync: ( + name: string, + cb: (request: ResolveRequest, resolveContext: ResolveContext, callback: () => void) => void + ) => { + innerCallback = cb; + } + }; + } + } as unknown as Resolver; + + // Backfill the contexts + for (const [path, json] of Object.entries(platformJson)) { + const context: IResolveContext | undefined = cache.contextLookup.findChildPath(path); + if (!context) throw new Error(`No context found for ${path}`); + cache.contextForPackage.set(json, context); + } + + attachPlugins(cache, resolver); + + return ( + request: ResolveRequest, + resolveContext: ResolveContext + ): [Error | false | null | undefined, ResolveRequest | undefined] => { + let result!: [Error | false | null | undefined, ResolveRequest | undefined]; + innerCallback!(request, resolveContext, (( + err: Error | null | false | undefined, + next: ResolveRequest | undefined + ) => { + result = [err, next]; + }) as unknown as Parameters[2]); + return result; + }; +} diff --git a/webpack/webpack-workspace-resolve-plugin/tsconfig.json b/webpack/webpack-workspace-resolve-plugin/tsconfig.json new file mode 100644 index 00000000000..97c868d4e4d --- /dev/null +++ b/webpack/webpack-workspace-resolve-plugin/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "target": "ES2019", + "types": ["heft-jest", "node"] + } +}