├── .gitignore ├── test ├── monorepo │ ├── package-a │ │ ├── src │ │ │ ├── index.ts │ │ │ ├── non-index.ts │ │ │ └── one-more.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── package-b │ │ ├── package.json │ │ ├── src │ │ │ └── index.ts │ │ └── tsconfig.json │ └── package-c │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── src │ │ └── index.ts ├── expected │ ├── no-resolution.js │ ├── simple-resolution.js │ └── transitive-resolution.js └── index.js ├── index.d.ts ├── .eslintrc.js ├── package.json ├── LICENSE ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test/output.js 3 | test/output 4 | -------------------------------------------------------------------------------- /test/monorepo/package-a/src/index.ts: -------------------------------------------------------------------------------- 1 | export const a = 'a'; 2 | -------------------------------------------------------------------------------- /test/monorepo/package-a/src/non-index.ts: -------------------------------------------------------------------------------- 1 | export const anotherA = 'b'; 2 | -------------------------------------------------------------------------------- /test/monorepo/package-a/src/one-more.ts: -------------------------------------------------------------------------------- 1 | export const oneMoreA = 'c'; 2 | -------------------------------------------------------------------------------- /test/monorepo/package-a/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@monorepo/package-a" 3 | } 4 | -------------------------------------------------------------------------------- /test/monorepo/package-b/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@monorepo/package-b" 3 | } 4 | -------------------------------------------------------------------------------- /test/monorepo/package-c/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@monorepo/package-c" 3 | } 4 | -------------------------------------------------------------------------------- /test/monorepo/package-c/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [{ "path": "../package-b" }] 3 | } 4 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "esbuild" 2 | declare const plugin: Plugin 3 | export = plugin 4 | -------------------------------------------------------------------------------- /test/monorepo/package-c/src/index.ts: -------------------------------------------------------------------------------- 1 | import { b } from '@monorepo/package-b'; 2 | 3 | console.log(b); 4 | -------------------------------------------------------------------------------- /test/expected/no-resolution.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | // test/monorepo/package-a/src/index.ts 3 | var a = "a"; 4 | })(); 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | commonjs: true, 5 | es2021: true, 6 | }, 7 | extends: 'eslint:recommended', 8 | parserOptions: { 9 | ecmaVersion: 'latest', 10 | }, 11 | rules: { 12 | 'no-constant-condition': ['off'], 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /test/monorepo/package-a/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // typescript allows comments 3 | "compilerOptions": { // in tsconfig.json 4 | "outDir": "./dist", 5 | "rootDir": "./src", 6 | "declaration": true, 7 | "sourceMap": true, 8 | "composite": true 9 | }, 10 | "include": ["src/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /test/monorepo/package-b/src/index.ts: -------------------------------------------------------------------------------- 1 | import { a } from '@monorepo/package-a'; 2 | import { anotherA } from '@monorepo/package-a/non-index'; 3 | import { oneMoreA } from '@monorepo/package-a/src/one-more'; 4 | 5 | console.log(a); 6 | console.log(anotherA); 7 | console.log(oneMoreA); 8 | 9 | export const b = 'b'; 10 | -------------------------------------------------------------------------------- /test/monorepo/package-b/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* 3 | multiline comments also work inside 4 | tsconfig 5 | */ 6 | "references": [{ "path": "../package-a" }], 7 | "compilerOptions": { 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "declaration": true, 11 | "sourceMap": true, 12 | "composite": true 13 | }, 14 | "include": ["src/**/*"] 15 | } 16 | -------------------------------------------------------------------------------- /test/expected/simple-resolution.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | // test/monorepo/package-a/src/index.ts 3 | var a = "a"; 4 | 5 | // test/monorepo/package-a/src/non-index.ts 6 | var anotherA = "b"; 7 | 8 | // test/monorepo/package-a/src/one-more.ts 9 | var oneMoreA = "c"; 10 | 11 | // test/monorepo/package-b/src/index.ts 12 | console.log(a); 13 | console.log(anotherA); 14 | console.log(oneMoreA); 15 | var b = "b"; 16 | })(); 17 | -------------------------------------------------------------------------------- /test/expected/transitive-resolution.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | // test/monorepo/package-a/src/index.ts 3 | var a = "a"; 4 | 5 | // test/monorepo/package-a/src/non-index.ts 6 | var anotherA = "b"; 7 | 8 | // test/monorepo/package-a/src/one-more.ts 9 | var oneMoreA = "c"; 10 | 11 | // test/monorepo/package-b/src/index.ts 12 | console.log(a); 13 | console.log(anotherA); 14 | console.log(oneMoreA); 15 | var b = "b"; 16 | 17 | // test/monorepo/package-c/src/index.ts 18 | console.log(b); 19 | })(); 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esbuild-plugin-ts-references", 3 | "version": "0.2.2", 4 | "description": "esbuild plugin for typescript references", 5 | "main": "index.js", 6 | "typings": "index.d.ts", 7 | "scripts": { 8 | "lint": "eslint index.js test/index.js", 9 | "test": "node test" 10 | }, 11 | "peerDependencies": { 12 | "esbuild": ">= 0.14.21" 13 | }, 14 | "devDependencies": { 15 | "esbuild": "^0.14.21", 16 | "eslint": "^8.9.0", 17 | "uvu": "^0.5.3" 18 | }, 19 | "files": [ 20 | "index.js" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/smacker/esbuild-plugin-ts-references.git" 25 | }, 26 | "keywords": [ 27 | "esbuild", 28 | "plugin" 29 | ], 30 | "author": "Maxim Sukharev ", 31 | "license": "MIT" 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Maxim Sukharev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # esbuild-plugin-ts-references 2 | 3 | [esbuild](https://github.com/evanw/esbuild) plugin for [Typescript references](https://www.typescriptlang.org/docs/handbook/project-references.html). 4 | 5 | ## Rationale 6 | 7 | A common approach for monorepos is yarn/npm/pnpm workspaces + typescript references. 8 | 9 | While it works in VSCode and using `tsc --build`, esbuild doesn't resolve such references automatically. The [feature request](https://github.com/evanw/esbuild/issues/1250) to add support for it was closed as it is not in the scope of the bundler and creation of a plugin was suggested. 10 | 11 | This is *the* plugin. 12 | 13 | ## Installation 14 | 15 | ```sh 16 | npm install --save-dev esbuild-plugin-ts-references 17 | ``` 18 | 19 | ## Usage 20 | 21 | Define plugin in the `plugins` section of esbuild config like this: 22 | 23 | ```js 24 | const esbuild = require('esbuild'); 25 | const tsReferences = require('esbuild-plugin-ts-references'); 26 | 27 | esbuild.build({ 28 | // ... 29 | plugins: [tsReferences] 30 | }); 31 | ``` 32 | 33 | ## Implementation details 34 | 35 | Currently the algorithm to resolve the references is very simple (but it works for me): 36 | 37 | - Find the closest `tsconfig.json` to the build target 38 | - Resolve `package.json` and `tsconfig.json` of references 39 | - Map package name from `package.json` to `rootDir` from `tsconfig.json` 40 | - Profit! 41 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const esbuild = require('esbuild'); 3 | const { test } = require('uvu'); 4 | const asset = require('uvu/assert'); 5 | const typescriptReferences = require('../'); 6 | 7 | const build = (entryPoints) => 8 | esbuild.build({ 9 | entryPoints: entryPoints, 10 | bundle: true, 11 | outfile: './test/output.js', 12 | plugins: [typescriptReferences], 13 | }); 14 | 15 | test('no resolution', async () => { 16 | await build(['./test/monorepo/package-a/src/index.ts']); 17 | 18 | const output = fs.readFileSync('./test/output.js', 'utf8'); 19 | asset.fixture( 20 | output, 21 | fs.readFileSync('./test/expected/no-resolution.js', 'utf8') 22 | ); 23 | }); 24 | 25 | test('simple resolution', async () => { 26 | await build(['./test/monorepo/package-b/src/index.ts']); 27 | 28 | const output = fs.readFileSync('./test/output.js', 'utf8'); 29 | asset.fixture( 30 | output, 31 | fs.readFileSync('./test/expected/simple-resolution.js', 'utf8') 32 | ); 33 | }); 34 | 35 | test('transitive dependency resolution', async () => { 36 | await build(['./test/monorepo/package-c/src/index.ts']); 37 | 38 | const output = fs.readFileSync('./test/output.js', 'utf8'); 39 | asset.fixture( 40 | output, 41 | fs.readFileSync('./test/expected/transitive-resolution.js', 'utf8') 42 | ); 43 | }); 44 | 45 | test('mutiple entries', async () => { 46 | // just make sure there is no exception 47 | esbuild.build({ 48 | entryPoints: [ 49 | './test/monorepo/package-a/src/index.ts', 50 | './test/monorepo/package-b/src/index.ts', 51 | ], 52 | bundle: true, 53 | outdir: './test/output', 54 | plugins: [typescriptReferences], 55 | }); 56 | }); 57 | 58 | test('mutiple entries with entry points object', async () => { 59 | // just make sure there is no exception 60 | esbuild.build({ 61 | entryPoints: { 62 | packageA: './test/monorepo/package-a/src/index.ts', 63 | packageB: './test/monorepo/package-b/src/index.ts', 64 | }, 65 | bundle: true, 66 | outdir: './test/output', 67 | plugins: [typescriptReferences], 68 | }); 69 | }); 70 | 71 | test.run(); 72 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | // regexp is taken from https://github.com/tarkh/json-easy-strip 5 | const commentsRegexp = /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g; 6 | 7 | const parseJSONFile = (filePath, stripComments) => { 8 | let content = fs.readFileSync(filePath, 'utf8'); 9 | if (stripComments) { 10 | content = content.replace(commentsRegexp, (m, g) => (g ? '' : m)); 11 | } 12 | return JSON.parse(content); 13 | }; 14 | 15 | const replacePrefix = (input, searchValue, replaceValue) => { 16 | if (input.startsWith(searchValue)) { 17 | return replaceValue + input.slice(searchValue.length); 18 | } 19 | return input; 20 | }; 21 | 22 | const resolveRefPackages = (baseDir, tsconfig, processedDirs) => { 23 | if (processedDirs.includes(baseDir)) { 24 | return {}; 25 | } 26 | 27 | processedDirs.push(baseDir); 28 | 29 | return (tsconfig.references || []).reduce((acc, r) => { 30 | const refPath = path.resolve(baseDir, r.path); 31 | const refPackage = parseJSONFile(path.join(refPath, 'package.json')); 32 | const refTsconfig = parseJSONFile( 33 | path.join(refPath, 'tsconfig.json'), 34 | true 35 | ); 36 | if (!refTsconfig.compilerOptions?.rootDir) { 37 | throw new Error( 38 | `rootDir is not defined in tsconfig.json of package '${refPackage.name}'` 39 | ); 40 | } 41 | 42 | acc[refPackage.name] = { 43 | rootDir: refTsconfig.compilerOptions.rootDir, 44 | resolveDir: path.resolve(refPath, refTsconfig.compilerOptions.rootDir), 45 | }; 46 | 47 | Object.assign(acc, resolveRefPackages(refPath, refTsconfig, processedDirs)); 48 | 49 | return acc; 50 | }, {}); 51 | }; 52 | 53 | const resolveEntrypoint = (entrypoint) => { 54 | // look for the closest tsconfig 55 | const rootDir = path.parse(entrypoint).root; 56 | let baseDir = path.dirname(entrypoint); 57 | let tsconfigPath; 58 | while (true) { 59 | tsconfigPath = path.join(baseDir, 'tsconfig.json'); 60 | if (fs.existsSync(tsconfigPath)) { 61 | break; 62 | } 63 | if (baseDir === rootDir) { 64 | throw new Error('tsconfig.json not found'); 65 | } 66 | 67 | baseDir = path.resolve(baseDir, '..'); 68 | } 69 | 70 | // build map of {'package-name': '/absolute/path'} 71 | const tsconfig = parseJSONFile(tsconfigPath, true); 72 | // avoid infinite loop for circular dependencies 73 | const processedDirs = []; 74 | return resolveRefPackages(baseDir, tsconfig, processedDirs); 75 | }; 76 | 77 | const tsReferences = { 78 | name: 'typescript-references', 79 | setup(build) { 80 | // Pull out entry points, which can either be specified as an array or an object with custom output paths 81 | // https://esbuild.github.io/api/#entry-points 82 | const entryPointOptions = build.initialOptions.entryPoints; 83 | const entryPoints = Array.isArray(entryPointOptions) 84 | ? entryPointOptions 85 | : Object.values(entryPointOptions); 86 | const refPackages = entryPoints.reduce((acc, entrypoint) => { 87 | return Object.assign(acc, resolveEntrypoint(entrypoint)); 88 | }, {}); 89 | 90 | // resolve packages 91 | const packageNames = Object.keys(refPackages); 92 | if (!packageNames.length) { 93 | return; 94 | } 95 | 96 | const filter = new RegExp(`^(${packageNames.join('|')})`); 97 | build.onResolve({ filter }, async (args) => { 98 | let package = args.path; 99 | let file = './index.ts'; 100 | if (!refPackages[package]) { 101 | for (const name of packageNames) { 102 | if (package.startsWith(name)) { 103 | file = './' + package.slice(name.length + 1); 104 | package = name; 105 | break; 106 | } 107 | } 108 | } 109 | file = replacePrefix(file, refPackages[package].rootDir, './'); 110 | 111 | const result = await build.resolve(file, { 112 | resolveDir: refPackages[package].resolveDir, 113 | kind: 'entry-point' 114 | }); 115 | if (result.errors.length > 0) { 116 | return { errors: result.errors }; 117 | } 118 | 119 | return { path: result.path }; 120 | }); 121 | }, 122 | }; 123 | 124 | module.exports = tsReferences; 125 | --------------------------------------------------------------------------------