/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as esbuild from 'esbuild'; import * as ts from 'typescript'; import * as threads from 'node:worker_threads'; import * as Vinyl from 'vinyl'; import { cpus } from 'node:os'; interface TranspileReq { readonly tsSrcs: string[]; readonly options: ts.TranspileOptions; } interface TranspileRes { readonly jsSrcs: string[]; readonly diagnostics: ts.Diagnostic[][]; } function transpile(tsSrc: string, options: ts.TranspileOptions): { jsSrc: string; diag: ts.Diagnostic[] } { const isAmd = /\n(import|export)/m.test(tsSrc); if (!isAmd && options.compilerOptions?.module === ts.ModuleKind.AMD) { // enforce NONE module-system for not-amd cases options = { ...options, ...{ compilerOptions: { ...options.compilerOptions, module: ts.ModuleKind.None } } }; } const out = ts.transpileModule(tsSrc, options); return { jsSrc: out.outputText, diag: out.diagnostics ?? [] }; } if (!threads.isMainThread) { // WORKER threads.parentPort?.addListener('message', (req: TranspileReq) => { const res: TranspileRes = { jsSrcs: [], diagnostics: [] }; for (const tsSrc of req.tsSrcs) { const out = transpile(tsSrc, req.options); res.jsSrcs.push(out.jsSrc); res.diagnostics.push(out.diag); } threads.parentPort!.postMessage(res); }); } class OutputFileNameOracle { readonly getOutputFileName: (name: string) => string; constructor(cmdLine: ts.ParsedCommandLine, configFilePath: string) { // very complicated logic to re-use TS internal functions to know the output path // given a TS input path and its config type InternalTsApi = typeof ts & { normalizePath(path: string): string; getOutputFileNames(commandLine: ts.ParsedCommandLine, inputFileName: string, ignoreCase: boolean): readonly string[]; }; this.getOutputFileName = (file) => { try { // windows: path-sep normalizing file = (ts).normalizePath(file); if (!cmdLine.options.configFilePath) { // this is needed for the INTERNAL getOutputFileNames-call below... cmdLine.options.configFilePath = configFilePath; } const isDts = file.endsWith('.d.ts'); if (isDts) { file = file.slice(0, -5) + '.ts'; cmdLine.fileNames.push(file); } const outfile = (ts).getOutputFileNames(cmdLine, file, true)[0]; if (isDts) { cmdLine.fileNames.pop(); } return outfile; } catch (err) { console.error(file, cmdLine.fileNames); console.error(err); throw err; } }; } } class TranspileWorker { private static pool = 1; readonly id = TranspileWorker.pool++; private _worker = new threads.Worker(__filename); private _pending?: [resolve: Function, reject: Function, file: Vinyl[], options: ts.TranspileOptions, t1: number]; private _durations: number[] = []; constructor(outFileFn: (fileName: string) => string) { this._worker.addListener('message', (res: TranspileRes) => { if (!this._pending) { console.error('RECEIVING data WITHOUT request'); return; } const [resolve, reject, files, options, t1] = this._pending; const outFiles: Vinyl[] = []; const diag: ts.Diagnostic[] = []; for (let i = 0; i < res.jsSrcs.length; i++) { // inputs and outputs are aligned across the arrays const file = files[i]; const jsSrc = res.jsSrcs[i]; const diag = res.diagnostics[i]; if (diag.length > 0) { diag.push(...diag); continue; } const enum SuffixTypes { Dts = 5, Ts = 3, Unknown = 0 } const suffixLen = file.path.endsWith('.d.ts') ? SuffixTypes.Dts : file.path.endsWith('.ts') ? SuffixTypes.Ts : SuffixTypes.Unknown; // check if output of a DTS-files isn't just "empty" and iff so // skip this file if (suffixLen === SuffixTypes.Dts && _isDefaultEmpty(jsSrc)) { continue; } const outBase = options.compilerOptions?.outDir ?? file.base; const outPath = outFileFn(file.path); outFiles.push(new Vinyl({ path: outPath, base: outBase, contents: Buffer.from(jsSrc), })); } this._pending = undefined; this._durations.push(Date.now() - t1); if (diag.length > 0) { reject(diag); } else { resolve(outFiles); } }); } terminate() { // console.log(`Worker#${this.id} ENDS after ${this._durations.length} jobs (total: ${this._durations.reduce((p, c) => p + c, 0)}, avg: ${this._durations.reduce((p, c) => p + c, 0) / this._durations.length})`); this._worker.terminate(); } get isBusy() { return this._pending !== undefined; } next(files: Vinyl[], options: ts.TranspileOptions) { if (this._pending !== undefined) { throw new Error('BUSY'); } return new Promise((resolve, reject) => { this._pending = [resolve, reject, files, options, Date.now()]; const req: TranspileReq = { options, tsSrcs: files.map(file => String(file.contents)) }; this._worker.postMessage(req); }); } } export interface ITranspiler { onOutfile?: (file: Vinyl) => void; join(): Promise; transpile(file: Vinyl): void; } export class TscTranspiler implements ITranspiler { static P = Math.floor(cpus().length * .5); private readonly _outputFileNames: OutputFileNameOracle; public onOutfile?: (file: Vinyl) => void; private _workerPool: TranspileWorker[] = []; private _queue: Vinyl[] = []; private _allJobs: Promise[] = []; constructor( logFn: (topic: string, message: string) => void, private readonly _onError: (err: any) => void, configFilePath: string, private readonly _cmdLine: ts.ParsedCommandLine ) { logFn('Transpile', `will use ${TscTranspiler.P} transpile worker`); this._outputFileNames = new OutputFileNameOracle(_cmdLine, configFilePath); } async join() { // wait for all penindg jobs this._consumeQueue(); await Promise.allSettled(this._allJobs); this._allJobs.length = 0; // terminate all worker this._workerPool.forEach(w => w.terminate()); this._workerPool.length = 0; } transpile(file: Vinyl) { if (this._cmdLine.options.noEmit) { // not doing ANYTHING here return; } const newLen = this._queue.push(file); if (newLen > TscTranspiler.P ** 2) { this._consumeQueue(); } } private _consumeQueue(): void { if (this._queue.length === 0) { // no work... return; } // kinda LAZYily create workers if (this._workerPool.length === 0) { for (let i = 0; i < TscTranspiler.P; i++) { this._workerPool.push(new TranspileWorker(file => this._outputFileNames.getOutputFileName(file))); } } const freeWorker = this._workerPool.filter(w => !w.isBusy); if (freeWorker.length === 0) { // OK, they will pick up work themselves return; } for (const worker of freeWorker) { if (this._queue.length === 0) { break; } const job = new Promise(resolve => { const consume = () => { const files = this._queue.splice(0, TscTranspiler.P); if (files.length === 0) { // DONE resolve(undefined); return; } // work on the NEXT file // const [inFile, outFn] = req; worker.next(files, { compilerOptions: this._cmdLine.options }).then(outFiles => { if (this.onOutfile) { outFiles.map(this.onOutfile, this); } consume(); }).catch(err => { this._onError(err); }); }; consume(); }); this._allJobs.push(job); } } } export class ESBuildTranspiler implements ITranspiler { private readonly _outputFileNames: OutputFileNameOracle; private _jobs: Promise[] = []; onOutfile?: ((file: Vinyl) => void) | undefined; private readonly _transformOpts: esbuild.TransformOptions; constructor( private readonly _logFn: (topic: string, message: string) => void, private readonly _onError: (err: any) => void, configFilePath: string, private readonly _cmdLine: ts.ParsedCommandLine ) { _logFn('Transpile', `will use ESBuild to transpile source files`); this._outputFileNames = new OutputFileNameOracle(_cmdLine, configFilePath); const isExtension = configFilePath.includes('extensions'); this._transformOpts = { target: ['es2022'], format: isExtension ? 'cjs' : 'esm', platform: isExtension ? 'node' : undefined, loader: 'ts', sourcemap: 'inline', tsconfigRaw: JSON.stringify({ compilerOptions: { ...this._cmdLine.options, ...{ module: isExtension ? ts.ModuleKind.CommonJS : undefined } satisfies ts.CompilerOptions } }), supported: { 'class-static-blocks': false, // SEE https://github.com/evanw/esbuild/issues/3823, 'dynamic-import': !isExtension, // see https://github.com/evanw/esbuild/issues/1281 'class-field': !isExtension } }; } async join(): Promise { const jobs = this._jobs.slice(); this._jobs.length = 0; await Promise.allSettled(jobs); } transpile(file: Vinyl): void { if (!(file.contents instanceof Buffer)) { throw Error('file.contents must be a Buffer'); } const t1 = Date.now(); this._jobs.push(esbuild.transform(file.contents, { ...this._transformOpts, sourcefile: file.path, }).then(result => { // check if output of a DTS-files isn't just "empty" and iff so // skip this file if (file.path.endsWith('.d.ts') && _isDefaultEmpty(result.code)) { return; } const outBase = this._cmdLine.options.outDir ?? file.base; const outPath = this._outputFileNames.getOutputFileName(file.path); this.onOutfile!(new Vinyl({ path: outPath, base: outBase, contents: Buffer.from(result.code), })); this._logFn('Transpile', `esbuild took ${Date.now() - t1}ms for ${file.path}`); }).catch(err => { this._onError(err); })); } } function _isDefaultEmpty(src: string): boolean { return src .replace('"use strict";', '') .replace(/\/\/# sourceMappingURL.*^/, '') .replace(/\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, '$1') .trim().length === 0; }