From 7fce52b90539203a8f9e9f5f1423397660d5a8f5 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 23 Mar 2020 15:45:51 +0100 Subject: [PATCH] Add support for paths in tsconfig.json and jsconfig.json (#11293) * Add support for tsconfig/json `paths` option * Add tests for paths in tsconfig.json * Don't apply aliases when paths is empty * Clean up unused methods and link to TypeScript license * Add tests for jsconfig * Put feature under an experimental flag * Enable to see if tests pass * Update types * Add feature under an experimental flag --- packages/next/build/webpack-config.ts | 11 + .../webpack/plugins/jsconfig-paths-plugin.ts | 209 ++++++++++++++++++ packages/next/next-server/server/config.ts | 1 + .../jsconfig-paths/components/world.js | 5 + test/integration/jsconfig-paths/jsconfig.json | 9 + test/integration/jsconfig-paths/lib/a/api.js | 1 + test/integration/jsconfig-paths/lib/b/api.js | 1 + .../jsconfig-paths/lib/b/b-only.js | 1 + .../integration/jsconfig-paths/next.config.js | 9 + .../jsconfig-paths/pages/basic-alias.js | 9 + .../jsconfig-paths/pages/resolve-fallback.js | 5 + .../jsconfig-paths/pages/resolve-order.js | 5 + .../jsconfig-paths/test/index.test.js | 41 ++++ .../typescript-baseurl/components/hi.tsx | 5 + .../typescript-paths/components/world.tsx | 5 + .../integration/typescript-paths/lib/a/api.ts | 1 + .../integration/typescript-paths/lib/b/api.ts | 1 + .../typescript-paths/lib/b/b-only.ts | 1 + .../typescript-paths/next.config.js | 9 + .../typescript-paths/pages/basic-alias.tsx | 9 + .../pages/resolve-fallback.tsx | 5 + .../typescript-paths/pages/resolve-order.tsx | 5 + .../typescript-paths/test/index.test.js | 41 ++++ .../typescript-paths/tsconfig.json | 24 ++ 24 files changed, 413 insertions(+) create mode 100644 packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts create mode 100644 test/integration/jsconfig-paths/components/world.js create mode 100644 test/integration/jsconfig-paths/jsconfig.json create mode 100644 test/integration/jsconfig-paths/lib/a/api.js create mode 100644 test/integration/jsconfig-paths/lib/b/api.js create mode 100644 test/integration/jsconfig-paths/lib/b/b-only.js create mode 100644 test/integration/jsconfig-paths/next.config.js create mode 100644 test/integration/jsconfig-paths/pages/basic-alias.js create mode 100644 test/integration/jsconfig-paths/pages/resolve-fallback.js create mode 100644 test/integration/jsconfig-paths/pages/resolve-order.js create mode 100644 test/integration/jsconfig-paths/test/index.test.js create mode 100644 test/integration/typescript-baseurl/components/hi.tsx create mode 100644 test/integration/typescript-paths/components/world.tsx create mode 100644 test/integration/typescript-paths/lib/a/api.ts create mode 100644 test/integration/typescript-paths/lib/b/api.ts create mode 100644 test/integration/typescript-paths/lib/b/b-only.ts create mode 100644 test/integration/typescript-paths/next.config.js create mode 100644 test/integration/typescript-paths/pages/basic-alias.tsx create mode 100644 test/integration/typescript-paths/pages/resolve-fallback.tsx create mode 100644 test/integration/typescript-paths/pages/resolve-order.tsx create mode 100644 test/integration/typescript-paths/test/index.test.js create mode 100644 test/integration/typescript-paths/tsconfig.json diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index d3b2d2551b896ab..1eb1f4548efcdcf 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -43,6 +43,7 @@ import { ProfilingPlugin } from './webpack/plugins/profiling-plugin' import { ReactLoadablePlugin } from './webpack/plugins/react-loadable-plugin' import { ServerlessPlugin } from './webpack/plugins/serverless-plugin' import { TerserPlugin } from './webpack/plugins/terser-webpack-plugin/src/index' +import { JsConfigPathsPlugin } from './webpack/plugins/jsconfig-paths-plugin' import WebpackConformancePlugin, { MinificationConformanceCheck, ReactSyncScriptsConformanceCheck, @@ -910,6 +911,16 @@ export default async function getBaseWebpackConfig( webpackConfig.resolve?.modules?.push(resolvedBaseUrl) } + if ( + config.experimental.jsconfigPaths && + jsConfig?.compilerOptions?.paths && + resolvedBaseUrl + ) { + webpackConfig.resolve?.plugins?.push( + new JsConfigPathsPlugin(jsConfig.compilerOptions.paths, resolvedBaseUrl) + ) + } + webpackConfig = await buildConfiguration(webpackConfig, { rootDirectory: dir, customAppFile, diff --git a/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts new file mode 100644 index 000000000000000..5e1380be0ac5fdd --- /dev/null +++ b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts @@ -0,0 +1,209 @@ +/** + * This webpack resolver is largely based on TypeScript's "paths" handling + * The TypeScript license can be found here: + * https://github.com/microsoft/TypeScript/blob/214df64e287804577afa1fea0184c18c40f7d1ca/LICENSE.txt + */ +import { ResolvePlugin } from 'webpack' +import { join } from 'path' + +export interface Pattern { + prefix: string + suffix: string +} + +const asterisk = 0x2a + +export function hasZeroOrOneAsteriskCharacter(str: string): boolean { + let seenAsterisk = false + for (let i = 0; i < str.length; i++) { + if (str.charCodeAt(i) === asterisk) { + if (!seenAsterisk) { + seenAsterisk = true + } else { + // have already seen asterisk + return false + } + } + } + return true +} + +export function tryParsePattern(pattern: string): Pattern | undefined { + // This should be verified outside of here and a proper error thrown. + const indexOfStar = pattern.indexOf('*') + return indexOfStar === -1 + ? undefined + : { + prefix: pattern.substr(0, indexOfStar), + suffix: pattern.substr(indexOfStar + 1), + } +} + +function isPatternMatch({ prefix, suffix }: Pattern, candidate: string) { + return ( + candidate.length >= prefix.length + suffix.length && + candidate.startsWith(prefix) && + candidate.endsWith(suffix) + ) +} + +/** Return the object corresponding to the best pattern to match `candidate`. */ +export function findBestPatternMatch( + values: readonly T[], + getPattern: (value: T) => Pattern, + candidate: string +): T | undefined { + let matchedValue: T | undefined + // use length of prefix as betterness criteria + let longestMatchPrefixLength = -1 + + for (const v of values) { + const pattern = getPattern(v) + if ( + isPatternMatch(pattern, candidate) && + pattern.prefix.length > longestMatchPrefixLength + ) { + longestMatchPrefixLength = pattern.prefix.length + matchedValue = v + } + } + + return matchedValue +} + +/** + * patternStrings contains both pattern strings (containing "*") and regular strings. + * Return an exact match if possible, or a pattern match, or undefined. + * (These are verified by verifyCompilerOptions to have 0 or 1 "*" characters.) + */ +export function matchPatternOrExact( + patternStrings: readonly string[], + candidate: string +): string | Pattern | undefined { + const patterns: Pattern[] = [] + for (const patternString of patternStrings) { + if (!hasZeroOrOneAsteriskCharacter(patternString)) continue + const pattern = tryParsePattern(patternString) + if (pattern) { + patterns.push(pattern) + } else if (patternString === candidate) { + // pattern was matched as is - no need to search further + return patternString + } + } + + return findBestPatternMatch(patterns, _ => _, candidate) +} + +/** + * Tests whether a value is string + */ +export function isString(text: unknown): text is string { + return typeof text === 'string' +} + +/** + * Given that candidate matches pattern, returns the text matching the '*'. + * E.g.: matchedText(tryParsePattern("foo*baz"), "foobarbaz") === "bar" + */ +export function matchedText(pattern: Pattern, candidate: string): string { + return candidate.substring( + pattern.prefix.length, + candidate.length - pattern.suffix.length + ) +} + +export function patternText({ prefix, suffix }: Pattern): string { + return `${prefix}*${suffix}` +} + +const NODE_MODULES_REGEX = /node_modules/ + +type Paths = { [match: string]: string[] } + +/** + * Handles tsconfig.json or jsconfig.js "paths" option for webpack + * Largely based on how the TypeScript compiler handles it: + * https://github.com/microsoft/TypeScript/blob/1a9c8197fffe3dace5f8dca6633d450a88cba66d/src/compiler/moduleNameResolver.ts#L1362 + */ +export class JsConfigPathsPlugin implements ResolvePlugin { + paths: Paths + resolvedBaseUrl: string + constructor(paths: Paths, resolvedBaseUrl: string) { + this.paths = paths + + this.resolvedBaseUrl = resolvedBaseUrl + } + apply(resolver: any) { + const paths = this.paths + const pathsKeys = Object.keys(paths) + + // If no aliases are added bail out + if (pathsKeys.length === 0) { + return + } + + const baseDirectory = this.resolvedBaseUrl + const target = resolver.ensureHook('resolve') + resolver + .getHook('described-resolve') + .tapPromise( + 'JsConfigPathsPlugin', + async (request: any, resolveContext: any) => { + // Exclude node_modules from paths support (speeds up resolving) + if (request.path.match(NODE_MODULES_REGEX)) { + return + } + + const moduleName = request.request + + // If the module name does not match any of the patterns in `paths` we hand off resolving to webpack + const matchedPattern = matchPatternOrExact(pathsKeys, moduleName) + if (!matchedPattern) { + return + } + + const matchedStar = isString(matchedPattern) + ? undefined + : matchedText(matchedPattern, moduleName) + const matchedPatternText = isString(matchedPattern) + ? matchedPattern + : patternText(matchedPattern) + + let triedPaths = [] + + for (const subst of paths[matchedPatternText]) { + const path = matchedStar ? subst.replace('*', matchedStar) : subst + const candidate = join(baseDirectory, path) + const [err, result] = await new Promise((resolve, reject) => { + const obj = Object.assign({}, request, { + request: candidate, + }) + resolver.doResolve( + target, + obj, + `Aliased with tsconfig.json or jsconfig.json ${matchedPatternText} to ${candidate}`, + resolveContext, + (err: any, result: any | undefined) => { + resolve([err, result]) + } + ) + }) + + // There's multiple paths values possible, so we first have to iterate them all first before throwing an error + if (err || result === undefined) { + triedPaths.push(candidate) + continue + } + + return result + } + + throw new Error(` + Request "${moduleName}" matched tsconfig.json or jsconfig.json "paths" pattern ${matchedPatternText} but could not be resolved. + Tried paths: ${triedPaths.join(' ')} + `) + } + ) + } +} diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index 2535ec0ac8e970e..69e73235c93ba28 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -41,6 +41,7 @@ const defaultConfig: { [key: string]: any } = { (Number(process.env.CIRCLE_NODE_TOTAL) || (os.cpus() || { length: 1 }).length) - 1 ), + jsconfigPaths: false, css: true, scss: true, documentMiddleware: false, diff --git a/test/integration/jsconfig-paths/components/world.js b/test/integration/jsconfig-paths/components/world.js new file mode 100644 index 000000000000000..5a93b9838685c12 --- /dev/null +++ b/test/integration/jsconfig-paths/components/world.js @@ -0,0 +1,5 @@ +import React from 'react' + +export function World() { + return <>World +} diff --git a/test/integration/jsconfig-paths/jsconfig.json b/test/integration/jsconfig-paths/jsconfig.json new file mode 100644 index 000000000000000..3b6323e2e50f013 --- /dev/null +++ b/test/integration/jsconfig-paths/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@c/*": ["components/*"], + "@lib/*": ["lib/a/*", "lib/b/*"] + } + } +} diff --git a/test/integration/jsconfig-paths/lib/a/api.js b/test/integration/jsconfig-paths/lib/a/api.js new file mode 100644 index 000000000000000..a27fc1b9498a401 --- /dev/null +++ b/test/integration/jsconfig-paths/lib/a/api.js @@ -0,0 +1 @@ +export default () => 'Hello from a' diff --git a/test/integration/jsconfig-paths/lib/b/api.js b/test/integration/jsconfig-paths/lib/b/api.js new file mode 100644 index 000000000000000..24ee786bd1a95b2 --- /dev/null +++ b/test/integration/jsconfig-paths/lib/b/api.js @@ -0,0 +1 @@ +export default () => 'Hello from b' diff --git a/test/integration/jsconfig-paths/lib/b/b-only.js b/test/integration/jsconfig-paths/lib/b/b-only.js new file mode 100644 index 000000000000000..0f1c1ce795f53df --- /dev/null +++ b/test/integration/jsconfig-paths/lib/b/b-only.js @@ -0,0 +1 @@ +export default () => 'Hello from only b' diff --git a/test/integration/jsconfig-paths/next.config.js b/test/integration/jsconfig-paths/next.config.js new file mode 100644 index 000000000000000..4c08f1e93f2488c --- /dev/null +++ b/test/integration/jsconfig-paths/next.config.js @@ -0,0 +1,9 @@ +module.exports = { + experimental: { + jsconfigPaths: true, + }, + onDemandEntries: { + // Make sure entries are not getting disposed. + maxInactiveAge: 1000 * 60 * 60, + }, +} diff --git a/test/integration/jsconfig-paths/pages/basic-alias.js b/test/integration/jsconfig-paths/pages/basic-alias.js new file mode 100644 index 000000000000000..d2a31a937ae1142 --- /dev/null +++ b/test/integration/jsconfig-paths/pages/basic-alias.js @@ -0,0 +1,9 @@ +import React from 'react' +import { World } from '@c/world' +export default function HelloPage() { + return ( +
+ +
+ ) +} diff --git a/test/integration/jsconfig-paths/pages/resolve-fallback.js b/test/integration/jsconfig-paths/pages/resolve-fallback.js new file mode 100644 index 000000000000000..148b4404d1fb592 --- /dev/null +++ b/test/integration/jsconfig-paths/pages/resolve-fallback.js @@ -0,0 +1,5 @@ +import React from 'react' +import api from '@lib/b-only' +export default function ResolveOrder() { + return
{api()}
+} diff --git a/test/integration/jsconfig-paths/pages/resolve-order.js b/test/integration/jsconfig-paths/pages/resolve-order.js new file mode 100644 index 000000000000000..f7fbe6cee827882 --- /dev/null +++ b/test/integration/jsconfig-paths/pages/resolve-order.js @@ -0,0 +1,5 @@ +import React from 'react' +import api from '@lib/api' +export default function ResolveOrder() { + return
{api()}
+} diff --git a/test/integration/jsconfig-paths/test/index.test.js b/test/integration/jsconfig-paths/test/index.test.js new file mode 100644 index 000000000000000..308df6366bcb8ea --- /dev/null +++ b/test/integration/jsconfig-paths/test/index.test.js @@ -0,0 +1,41 @@ +/* eslint-env jest */ +/* global jasmine */ +import { join } from 'path' +import cheerio from 'cheerio' +import { renderViaHTTP, findPort, launchApp, killApp } from 'next-test-utils' + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2 + +const appDir = join(__dirname, '..') +let appPort +let app + +async function get$(path, query) { + const html = await renderViaHTTP(appPort, path, query) + return cheerio.load(html) +} + +describe('TypeScript Features', () => { + describe('default behavior', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort, {}) + }) + afterAll(() => killApp(app)) + + it('should alias components', async () => { + const $ = await get$('/basic-alias') + expect($('body').text()).toMatch(/World/) + }) + + it('should resolve the first item in the array first', async () => { + const $ = await get$('/resolve-order') + expect($('body').text()).toMatch(/Hello from a/) + }) + + it('should resolve the first item in the array first', async () => { + const $ = await get$('/resolve-fallback') + expect($('body').text()).toMatch(/Hello from only b/) + }) + }) +}) diff --git a/test/integration/typescript-baseurl/components/hi.tsx b/test/integration/typescript-baseurl/components/hi.tsx new file mode 100644 index 000000000000000..3283eafde3e3637 --- /dev/null +++ b/test/integration/typescript-baseurl/components/hi.tsx @@ -0,0 +1,5 @@ +import React from 'react' + +export function Hi(): JSX.Element { + return <>Hi +} diff --git a/test/integration/typescript-paths/components/world.tsx b/test/integration/typescript-paths/components/world.tsx new file mode 100644 index 000000000000000..d7d1f66258c778e --- /dev/null +++ b/test/integration/typescript-paths/components/world.tsx @@ -0,0 +1,5 @@ +import React from 'react' + +export function World(): JSX.Element { + return <>World +} diff --git a/test/integration/typescript-paths/lib/a/api.ts b/test/integration/typescript-paths/lib/a/api.ts new file mode 100644 index 000000000000000..a27fc1b9498a401 --- /dev/null +++ b/test/integration/typescript-paths/lib/a/api.ts @@ -0,0 +1 @@ +export default () => 'Hello from a' diff --git a/test/integration/typescript-paths/lib/b/api.ts b/test/integration/typescript-paths/lib/b/api.ts new file mode 100644 index 000000000000000..24ee786bd1a95b2 --- /dev/null +++ b/test/integration/typescript-paths/lib/b/api.ts @@ -0,0 +1 @@ +export default () => 'Hello from b' diff --git a/test/integration/typescript-paths/lib/b/b-only.ts b/test/integration/typescript-paths/lib/b/b-only.ts new file mode 100644 index 000000000000000..0f1c1ce795f53df --- /dev/null +++ b/test/integration/typescript-paths/lib/b/b-only.ts @@ -0,0 +1 @@ +export default () => 'Hello from only b' diff --git a/test/integration/typescript-paths/next.config.js b/test/integration/typescript-paths/next.config.js new file mode 100644 index 000000000000000..4c08f1e93f2488c --- /dev/null +++ b/test/integration/typescript-paths/next.config.js @@ -0,0 +1,9 @@ +module.exports = { + experimental: { + jsconfigPaths: true, + }, + onDemandEntries: { + // Make sure entries are not getting disposed. + maxInactiveAge: 1000 * 60 * 60, + }, +} diff --git a/test/integration/typescript-paths/pages/basic-alias.tsx b/test/integration/typescript-paths/pages/basic-alias.tsx new file mode 100644 index 000000000000000..f9b23a18297029c --- /dev/null +++ b/test/integration/typescript-paths/pages/basic-alias.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { World } from '@c/world' +export default function HelloPage(): JSX.Element { + return ( +
+ +
+ ) +} diff --git a/test/integration/typescript-paths/pages/resolve-fallback.tsx b/test/integration/typescript-paths/pages/resolve-fallback.tsx new file mode 100644 index 000000000000000..8b1dc93cd6bf4db --- /dev/null +++ b/test/integration/typescript-paths/pages/resolve-fallback.tsx @@ -0,0 +1,5 @@ +import React from 'react' +import api from '@lib/b-only' +export default function ResolveOrder(): JSX.Element { + return
{api()}
+} diff --git a/test/integration/typescript-paths/pages/resolve-order.tsx b/test/integration/typescript-paths/pages/resolve-order.tsx new file mode 100644 index 000000000000000..dd83cd62e8a8a3a --- /dev/null +++ b/test/integration/typescript-paths/pages/resolve-order.tsx @@ -0,0 +1,5 @@ +import React from 'react' +import api from '@lib/api' +export default function ResolveOrder(): JSX.Element { + return
{api()}
+} diff --git a/test/integration/typescript-paths/test/index.test.js b/test/integration/typescript-paths/test/index.test.js new file mode 100644 index 000000000000000..308df6366bcb8ea --- /dev/null +++ b/test/integration/typescript-paths/test/index.test.js @@ -0,0 +1,41 @@ +/* eslint-env jest */ +/* global jasmine */ +import { join } from 'path' +import cheerio from 'cheerio' +import { renderViaHTTP, findPort, launchApp, killApp } from 'next-test-utils' + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2 + +const appDir = join(__dirname, '..') +let appPort +let app + +async function get$(path, query) { + const html = await renderViaHTTP(appPort, path, query) + return cheerio.load(html) +} + +describe('TypeScript Features', () => { + describe('default behavior', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort, {}) + }) + afterAll(() => killApp(app)) + + it('should alias components', async () => { + const $ = await get$('/basic-alias') + expect($('body').text()).toMatch(/World/) + }) + + it('should resolve the first item in the array first', async () => { + const $ = await get$('/resolve-order') + expect($('body').text()).toMatch(/Hello from a/) + }) + + it('should resolve the first item in the array first', async () => { + const $ = await get$('/resolve-fallback') + expect($('body').text()).toMatch(/Hello from only b/) + }) + }) +}) diff --git a/test/integration/typescript-paths/tsconfig.json b/test/integration/typescript-paths/tsconfig.json new file mode 100644 index 000000000000000..29c96623ecbaafb --- /dev/null +++ b/test/integration/typescript-paths/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@c/*": ["components/*"], + "@lib/*": ["lib/a/*", "lib/b/*"] + }, + "esModuleInterop": true, + "module": "esnext", + "jsx": "preserve", + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true + }, + "exclude": ["node_modules"], + "include": ["next-env.d.ts", "components", "pages"] +}