import * as path from 'path' ; import * as fse from 'fs-extra' ; import chalk = require ( 'chalk' ) ; import portfinder = require ( 'portfinder' ) ; import * as TypeScript from 'typescript' ; import webpack = require ( 'webpack' ) ; import { CleanWebpackPlugin } from 'clean-webpack-plugin' ; import { WebpackPluginServe } from 'webpack-plugin-serve' ; import MiniCssExtractPlugin = require ( 'mini-css-extract-plugin' ) ; import ReactRefreshWebpackPlugin = require ( '@pmmmwh/react-refresh-webpack-plugin' ) ; const webpackPluginServeClientJS = require . resolve ( 'webpack-plugin-serve/client' ) ; const reactRefreshBabelPluginJS = require . resolve ( 'react-refresh/babel' ) ; const babelPluginDynamicImportJS = require . resolve ( '@babel/plugin-syntax-dynamic-import' ) ; import { resolveFrom } from "./importers/resolveFrom" ; import { Shout } from './Shout' ; import { BuildVariables } from './variables-factory/BuildVariables' ; import { PathFinder } from './variables-factory/PathFinder' ; import { LoaderPaths } from './loaders/LoaderPaths' ; import { parseTypescriptConfig } from './TypescriptConfigParser' ; import { InstapackBuildPlugin } from './plugins/InstapackBuildPlugin' ; import { mergeTypeScriptPathAlias , getWildcardModules } from './TypeScriptPathsTranslator' ; import { UserSettingsPath } from './user-settings/UserSettingsPath' ; /**
 * Contains methods for compiling a TypeScript project.
 */ export class TypeScriptBuildEngine { private readonly variables : BuildVariables ; private readonly finder : PathFinder ; private readonly typescriptCompilerOptions : TypeScript . CompilerOptions ; private useBabel = false ; private vueLoaderPath : string | undefined ; private vueLoader : VueLoader | undefined ; private port = 28080 ; private certificates : { key : Buffer ; cert : Buffer ; } | null = null ; param settings param flags constructor ( variables ) { this . variables = variables ; this . finder = new PathFinder ( variables ) ; this . typescriptCompilerOptions = parseTypescriptConfig ( variables . root , variables . typescriptConfiguration ) . options ; if ( ! this . typescriptCompilerOptions . target ) { Shout . warning ( 'instapack does not support targeting ES3! JS build target has been set to ES5. (TypeScript compiler options)' ) ; this . typescriptCompilerOptions . target = TypeScript . ScriptTarget . ES5 ; } this . typescriptCompilerOptions . noEmit = false ; this . typescriptCompilerOptions . emitDeclarationOnly = false ; this . typescriptCompilerOptions . sourceMap = variables . sourceMap ; this . typescriptCompilerOptions . inlineSources = variables . sourceMap ; } /**
     * Gets JS Babel transpile rules for webpack.
     */ get jsBabelWebpackRules ( ) : webpack . RuleSetRule { return { test : /\.jsx?$/ , exclude : /node_modules/ , use : { loader : LoaderPaths . babel , ident : 'babel-js' } } ; } /**
     * Gets a configured TypeScript rules for webpack.
     */ get typescriptWebpackRules ( ) : webpack . RuleSetRule { const loaders = [ { loader : LoaderPaths . typescript , ident : 'typescript' , options : { compilerOptions : this . typescriptCompilerOptions } } ] ; if ( this . useBabel ) { loaders . unshift ( { loader : LoaderPaths . babel , ident : 'babel-typescript' } ) ; } const tsRules = { test : /\.tsx?$/ , exclude : /node_modules/ , use : loaders } ; return tsRules ; } /**
     * Gets a Vue Single-File Component rule for webpack.
     */ get vueWebpackRules ( ) : webpack . RuleSetRule { return { test : /\.vue$/ , exclude : /node_modules/ , use : [ { loader : this . vueLoaderPath , ident : 'vue' , options : { transformAssetUrls : { } } } ] } ; } /**
     * Gets a configured HTML template rules for webpack.
     */ get htmlWebpackRules ( ) : webpack . RuleSetRule { return { test : /\.html?$/ , exclude : /node_modules/ , use : [ { loader : LoaderPaths . html , ident : 'html-txt' } ] } ; } /**
     * Gets CSS rules for webpack to prevent explosion during vue compile.
     */ get vueCssWebpackRules ( ) : webpack . RuleSetRule { const cssModulesLoader = { loader : LoaderPaths . css , ident : 'vue-css-module' , options : { esModule : false , modules : { localIdentName : '[local]_[contenthash:8]' } , url : false } } ; const cssLoader = { loader : LoaderPaths . css , ident : 'vue-css' , options : { esModule : false , url : false } } ; return { test : /\.css$/ , oneOf : [ { resourceQuery : /module=true/ , use : [ MiniCssExtractPlugin . loader , cssModulesLoader ] } , { use : [ MiniCssExtractPlugin . loader , cssLoader ] } ] } ; } /**
     * Gets JS Babel transpile rules for webpack.
     */ get reactRefreshWebpackRules ( ) : webpack . RuleSetRule { return { test : /\.[jt]sx?$/ , exclude : /node_modules/ , use : [ { loader : LoaderPaths . babel , ident : 'react-fast-refresh-babel' , options : { plugins : [ babelPluginDynamicImportJS , reactRefreshBabelPluginJS , ] } } ] } ; } /**
     * Gets transpile libraries (to ES5) rules for webpack
     */ get transpileLibrariesWebpackRules ( ) : webpack . RuleSetRule { return { test : /\.js$/ , include : /node_modules/ , use : { loader : LoaderPaths . transpileLibraries , ident : 'js-libraries-to-es5' , options : { compilerOptions : this . typescriptCompilerOptions } } } ; } /**
     * Gets webpack plugins array.
     */ get webpackPlugins ( ) : webpack . WebpackPluginInstance [ ] { const plugins = [ ] ; const typescriptTarget = this . typescriptCompilerOptions . target ?? TypeScript . ScriptTarget . ES3 ; plugins . push ( new InstapackBuildPlugin ( this . variables , typescriptTarget ) ) ; if ( this . vueLoader ) { plugins . push ( new this . vueLoader . VueLoaderPlugin ( ) ) ; } if ( Object . keys ( this . variables . env ) . length > 0 ) { plugins . push ( new webpack . EnvironmentPlugin ( this . variables . env ) ) ; } if ( this . variables . serve ) { plugins . push ( new WebpackPluginServe ( { host : 'localhost' , port : this . port , https : this . certificates , progress : 'minimal' , log : { level : 'error' } } ) ) ; } if ( this . variables . reactRefresh ) { plugins . push ( new ReactRefreshWebpackPlugin ( ) ) ; } if ( this . variables . production ) { plugins . push ( new CleanWebpackPlugin ( ) ) ; } plugins . push ( new MiniCssExtractPlugin ( { filename : 'ipack.jss.css' , chunkFilename : 'ipack.[id].js.css' } ) ) ; return plugins ; } /**
     * Gets webpack rules array using input TypeScript configuration and Babel flag.
     */ get webpackRules ( ) : webpack . RuleSetRule [ ] { const rules = [ this . typescriptWebpackRules , ] ; if ( this . vueLoaderPath ) { rules . push ( this . vueCssWebpackRules ) ; rules . push ( this . vueWebpackRules ) ; } rules . push ( this . htmlWebpackRules ) ; if ( this . useBabel ) { rules . push ( this . jsBabelWebpackRules ) ; } if ( this . typescriptCompilerOptions . target === TypeScript . ScriptTarget . ES5 ) { rules . push ( this . transpileLibrariesWebpackRules ) ; } if ( this . variables . reactRefresh ) { rules . unshift ( this . reactRefreshWebpackRules ) ; } return rules ; } /**
     * Get the suitable webpack configuration dev tool for the job, according to instapack settings.
     */ get webpackConfigurationDevTool ( ) : false | 'hidden-source-map' | 'source-map' | 'eval-source-map' { if ( this . variables . sourceMap === false ) { return false ; } if ( this . variables . production ) { return 'hidden-source-map' ; } if ( this . variables . watch === false ) { return 'source-map' ; } return 'eval-source-map' ; } /**
     * Returns webpack configuration from blended instapack settings and build flags.
     */ createWebpackConfiguration ( ) { const entry = { main : { filename : this . finder . jsOutputFileName , import : [ this . finder . jsEntry ] as [ string , ... string [ ] ] , } } ; if ( this . variables . serve ) { entry . main . import . push ( webpackPluginServeClientJS ) ; } const config = { entry : entry , output : { filename : this . finder . jsChunkFileName , path : path . normalize ( this . finder . jsOutputFolder ) , publicPath : 'js/' , library : this . variables . namespace , libraryTarget : ( this . variables . umdLibraryProject ? 'umd' : undefined ) } , externals : this . variables . externals , resolve : this . webpackResolveOptions , plugins : this . webpackPlugins , module : { rules : this . webpackRules } , mode : ( this . variables . production ? 'production' : 'development' ) , devtool : this . webpackConfigurationDevTool , optimization : { emitOnErrors : false , } , performance : { hints : false } } ; const tsTarget = this . typescriptCompilerOptions . target ?? TypeScript . ScriptTarget . ES5 ; if ( tsTarget < TypeScript . ScriptTarget . ES2015 ) { config . target = [ 'web' , 'es5' ] ; } if ( ! this . variables . umdLibraryProject && config . optimization ) { config . optimization . splitChunks = { cacheGroups : { vendors : { name : 'dll' , test : /[\\/]node_modules[\\/]/ , chunks : 'initial' , enforce : true , priority : 99 } } } ; } return config ; } get webpackResolveOptions ( ) : webpack . ResolveOptions { const alias = mergeTypeScriptPathAlias ( this . typescriptCompilerOptions , this . finder . root , this . variables . alias ) ; const wildcards = getWildcardModules ( this . typescriptCompilerOptions , this . finder . root ) ; if ( this . variables . reactRefresh ) { alias [ 'react-refresh' ] = [ path . resolve ( __dirname , '../node_modules/react-refresh' ) ] ; } const config = { extensions : [ '.ts' , '.tsx' , '.js' , '.jsx' , '.vue' , '.json' ] , alias : alias } ; if ( wildcards ) { config . modules = wildcards ; } if ( this . typescriptCompilerOptions . preserveSymlinks ) { config . symlinks = false ; } return config ; } buildOnce ( webpackConfiguration ) { return new Promise < webpack . Stats | undefined > ( ( ok , reject ) => { webpack ( webpackConfiguration , ( err , stats ) => { if ( err ) { reject ( err ) ; } ok ( stats ) ; } ) ; } ) ; } watch ( webpackConfiguration ) { const compiler = webpack ( webpackConfiguration ) ; return new Promise < void > ( ( ok , reject ) => { compiler . watch ( { ignored : [ 'node_modules' ] , aggregateTimeout : 300 } , ( err ) => { if ( err ) { reject ( err ) ; } return ok ( ) ; } ) ; } ) ; } /**
     * Runs the TypeScript build engine.
     */ async build ( ) { this . useBabel = await fse . pathExists ( this . finder . babelConfiguration ) ; if ( this . variables . vue ) { const vueLoaderPath = resolveFrom ( 'vue-loader' , this . variables . root ) ; if ( vueLoaderPath ) { this . vueLoaderPath = vueLoaderPath ; this . vueLoader = require ( vueLoaderPath ) ; } } if ( this . variables . serve ) { this . port = await portfinder . getPortPromise ( { port : this . port } ) ; const host = `${ this . variables . https ? 'https' : 'http' }://localhost:${ chalk . greenBright ( this . port ) }` ; Shout . timed ( chalk . yellowBright ( 'Hot Reload' ) , `server running on ${ host }` ) ; } if ( this . variables . https ) { const certFileAsync = fse . readFile ( UserSettingsPath . certFile ) ; const keyFileAsync = fse . readFile ( UserSettingsPath . keyFile ) ; this . certificates = { key : await keyFileAsync , cert : await certFileAsync } ; } const webpackConfiguration = this . createWebpackConfiguration ( ) ; if ( this . variables . watch ) { await this . watch ( webpackConfiguration ) ; } else { const stats = await this . buildOnce ( webpackConfiguration ) ; if ( this . variables . stats && stats ) { await fse . outputJson ( this . finder . statsJsonFilePath , stats . toJson ( ) ) ; } } } } interface VueLoader { VueLoaderPlugin : { new ( ) : webpack . WebpackPluginInstance ; } ; }