diff --git a/package.json b/package.json index 3e57f11..14e9d59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@steambrew/ttc", - "version": "2.8.6", + "version": "2.9.0", "type": "module", "main": "dist/index.js", "module": "dist/index.js", @@ -9,6 +9,7 @@ }, "scripts": { "build": "rollup -c", + "dev": "rollup -c -w", "prepare": "npm run build" }, "publishConfig": { diff --git a/src/check-health.ts b/src/check-health.ts index 06682e9..e751966 100644 --- a/src/check-health.ts +++ b/src/check-health.ts @@ -1,9 +1,10 @@ import chalk from 'chalk'; -import path from 'path'; import { existsSync, readFile } from 'fs'; +import path from 'path'; +import { PluginJson } from './plugin-json'; -export const ValidatePlugin = (bIsMillennium: boolean, target: string): Promise => { - return new Promise((resolve, reject) => { +export const ValidatePlugin = (bIsMillennium: boolean, target: string): Promise => { + return new Promise((resolve, reject) => { if (!existsSync(target)) { console.error(chalk.red.bold(`\n[-] --target [${target}] `) + chalk.red('is not a valid system path')); reject(); diff --git a/src/index.ts b/src/index.ts index 2afc78b..85c5957 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,13 +5,13 @@ * - typescript transpiler * - rollup configurator */ -import { BuildType, ValidateParameters } from './query-parser'; -import { CheckForUpdates } from './version-control'; +import { performance } from 'perf_hooks'; import { ValidatePlugin } from './check-health'; +import { Logger } from './logger'; +import { PluginJson } from './plugin-json'; +import { BuildType, ValidateParameters } from './query-parser'; import { TranspilerPluginComponent, TranspilerProps } from './transpiler'; -import { performance } from 'perf_hooks'; -import chalk from 'chalk'; -// import { Logger } from './Logger' +import { CheckForUpdates } from './version-control'; declare global { var PerfStartTime: number; @@ -25,17 +25,20 @@ const StartCompilerModule = () => { const parameters = ValidateParameters(process.argv.slice(2)); const bIsMillennium = parameters.isMillennium || false; const bTersePlugin = parameters.type == BuildType.ProdBuild; + const bWatchMode = parameters.watch || false; - console.log(chalk.greenBright.bold('config'), 'Building target:', parameters.targetPlugin, 'with type:', BuildType[parameters.type], 'minify:', bTersePlugin, '...'); + Logger.Config('Building target:', parameters.targetPlugin, 'with type:', BuildType[parameters.type], 'minify:', bTersePlugin, '...'); ValidatePlugin(bIsMillennium, parameters.targetPlugin) - .then((json: any) => { + .then((json: PluginJson) => { const props: TranspilerProps = { - bTersePlugin: bTersePlugin, - strPluginInternalName: json?.name, + bTersePlugin, + strPluginInternalName: json.name, + bWatchMode, + bIsMillennium, }; - TranspilerPluginComponent(bIsMillennium, json, props); + TranspilerPluginComponent(json, props); }) /** diff --git a/src/logger.ts b/src/logger.ts index 117d3cb..84c15d1 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -5,6 +5,10 @@ const Logger = { console.log(chalk.magenta.bold(name), ...LogMessage); }, + Config: (...LogMessage: any) => { + console.log(chalk.greenBright.bold('config'), ...LogMessage); + }, + Warn: (...LogMessage: any) => { console.log(chalk.yellow.bold('**'), ...LogMessage); }, diff --git a/src/plugin-json.d.ts b/src/plugin-json.d.ts new file mode 100644 index 0000000..5683283 --- /dev/null +++ b/src/plugin-json.d.ts @@ -0,0 +1,16 @@ +/** + * generated from https://raw.githubusercontent.com/SteamClientHomebrew/Millennium/main/src/sys/plugin-schema.json + */ +export interface PluginJson { + backend?: string; + common_name?: string; + description?: string; + frontend?: string; + include?: string[]; + name: string; + splash_image?: string; + thumbnail?: string; + useBackend?: boolean; + venv?: string; + version?: string; +} diff --git a/src/query-parser.ts b/src/query-parser.ts index f4e9db8..8aa4d88 100644 --- a/src/query-parser.ts +++ b/src/query-parser.ts @@ -5,20 +5,12 @@ import { Logger } from './logger'; * @brief print the parameter list to the stdout */ export const PrintParamHelp = () => { - console.log( - 'millennium-ttc parameter list:' + - '\n\t' + - chalk.magenta('--help') + - ': display parameter list' + - '\n\t' + - chalk.bold.red('--build') + - ': ' + - chalk.bold.red('(required)') + - ': build type [dev, prod] (prod minifies code)' + - '\n\t' + - chalk.magenta('--target') + - ': path to plugin, default to cwd', - ); + console.log(` +millennium-ttc parameter list: + ${chalk.magenta('--help')}: display parameter list + ${chalk.bold.red('--build')}: ${chalk.bold.red('(required)')}: build type [dev, prod] (prod minifies code) + ${chalk.magenta('--target')}: path to plugin, default to cwd + ${chalk.magenta('--watch')}: enable watch mode for continuous rebuilding`); }; export enum BuildType { @@ -30,12 +22,14 @@ export interface ParameterProps { type: BuildType; targetPlugin: string; // path isMillennium?: boolean; + watch?: boolean; } export const ValidateParameters = (args: Array): ParameterProps => { let typeProp: BuildType = BuildType.DevBuild, targetProp: string = process.cwd(), - isMillennium: boolean = false; + isMillennium: boolean = false, + watch: boolean = false; if (args.includes('--help')) { PrintParamHelp(); @@ -67,7 +61,7 @@ export const ValidateParameters = (args: Array): ParameterProps => { } } - if (args[i] == '--target') { + if (args[i] === '--target') { if (args[i + 1] === undefined) { Logger.Error('--target parameter must be preceded by system path'); process.exit(); @@ -76,14 +70,19 @@ export const ValidateParameters = (args: Array): ParameterProps => { targetProp = args[i + 1]; } - if (args[i] == '--millennium-internal') { + if (args[i] === '--millennium-internal') { isMillennium = true; } + + if (args[i] === '--watch') { + watch = true; + } } return { type: typeProp, targetPlugin: targetProp, isMillennium: isMillennium, + watch: watch, }; }; diff --git a/src/static-embed.ts b/src/static-embed.ts index 7579f36..8e40203 100644 --- a/src/static-embed.ts +++ b/src/static-embed.ts @@ -176,7 +176,7 @@ export default function constSysfsExpr(options: EmbedPluginOptions = {}): Plugin } try { - const currentLocString = node.loc?.start ? ` at ${id}:${node.loc.start.line}:${node.loc.start.column}` : ` in ${id}`; + const currentLocString = node.loc?.start ? ` at ${chalk.cyan.bold(id)}:${node.loc.start.line}:${node.loc.start.column}` : ` in ${id}`; const searchBasePath = callOptions.basePath ? path.isAbsolute(callOptions.basePath) @@ -197,7 +197,11 @@ export default function constSysfsExpr(options: EmbedPluginOptions = {}): Plugin fs.statSync(path.resolve(searchBasePath, pathOrPattern)).isFile() ) { const singleFilePath = path.resolve(searchBasePath, pathOrPattern); - Log(`Mode: Single file (first argument "${pathOrPattern}" resolved to "${singleFilePath}" relative to "${searchBasePath}")`); + Log( + `Mode: Single file (first argument "${chalk.cyan.bold(pathOrPattern)}" resolved to "${chalk.cyan.bold( + singleFilePath, + )}" relative to "${chalk.cyan.bold(searchBasePath)}")`, + ); try { const rawContent: string | Buffer = fs.readFileSync(singleFilePath, callOptions.encoding); @@ -209,6 +213,9 @@ export default function constSysfsExpr(options: EmbedPluginOptions = {}): Plugin }; embeddedContent = JSON.stringify(fileInfo); embeddedCount = 1; + + this.addWatchFile(singleFilePath); + Log(`Embedded 1 specific file for call${currentLocString}`); } catch (fileError: unknown) { let message = String(fileError instanceof Error ? fileError.message : fileError ?? 'Unknown file read error'); @@ -236,6 +243,8 @@ export default function constSysfsExpr(options: EmbedPluginOptions = {}): Plugin filePath: fullPath, fileName: path.relative(searchBasePath, fullPath), }); + + this.addWatchFile(fullPath); } catch (fileError: unknown) { let message = String(fileError instanceof Error ? fileError.message : fileError ?? 'Unknown file read error'); this.warn(`Error reading file ${fullPath}: ${message}`); diff --git a/src/transpiler.ts b/src/transpiler.ts index fef7956..e8f4250 100644 --- a/src/transpiler.ts +++ b/src/transpiler.ts @@ -6,7 +6,7 @@ import replace from '@rollup/plugin-replace'; import terser from '@rollup/plugin-terser'; import typescript from '@rollup/plugin-typescript'; import url from '@rollup/plugin-url'; -import { InputPluginOption, OutputBundle, OutputOptions, RollupOptions, rollup } from 'rollup'; +import { InputPluginOption, OutputBundle, OutputOptions, rollup, RollupOptions, watch } from 'rollup'; import nodePolyfills from 'rollup-plugin-polyfill-node'; import { minify_sync } from 'terser'; @@ -20,10 +20,15 @@ import { Logger } from './logger'; import dotenv from 'dotenv'; import injectProcessEnv from 'rollup-plugin-inject-process-env'; import { ExecutePluginModule, InitializePlugins } from './plugin-api'; +import { PluginJson } from './plugin-json'; import constSysfsExpr from './static-embed'; const envConfig = dotenv.config().parsed || {}; +const FRONTEND_OUTPUT_PATH = '.millennium/Dist/index.js'; +const WEBKIT_OUTPUT_PATH = '.millennium/Dist/webkit.js'; +const WEBKIT_ENTRY_PATH = './webkit/index.tsx'; + if (envConfig) { Logger.Info('envVars', 'Processing ' + Object.keys(envConfig).length + ' environment variables... ' + chalk.green.bold('okay')); } @@ -50,14 +55,16 @@ enum ComponentType { export interface TranspilerProps { bTersePlugin?: boolean; strPluginInternalName: string; + bWatchMode?: boolean; + bIsMillennium?: boolean; } const WrappedCallServerMethod = 'const __call_server_method__ = (methodName, kwargs) => Millennium.callServerMethod(pluginName, methodName, kwargs)'; const WrappedCallable = 'const __wrapped_callable__ = (route) => MILLENNIUM_API.callable(__call_server_method__, route)'; -const ConstructFunctions = (parts: string[]): string => { +function ConstructFunctions(parts: string[]): string { return parts.join('\n'); -}; +} function generate(code: string) { /** Wrap it in a proxy */ @@ -109,7 +116,7 @@ async function GetCustomUserPlugins() { return []; } -async function MergePluginList(plugins: any[]) { +async function MergePluginList(plugins: InputPluginOption[]) { const customPlugins = await GetCustomUserPlugins(); // Filter out custom plugins that have the same name as input plugins @@ -119,21 +126,24 @@ async function MergePluginList(plugins: any[]) { return [...plugins, ...filteredCustomPlugins]; } -async function GetPluginComponents(pluginJson: any, props: TranspilerProps): Promise { - let tsConfigPath = ''; - const frontendDir = GetFrontEndDirectory(pluginJson); - - if (frontendDir === '.' || frontendDir === './') { - tsConfigPath = './tsconfig.json'; - } else { - tsConfigPath = `./${frontendDir}/tsconfig.json`; +function GetTsConfigPath(directory: string): string { + const configPath = `./${directory}/tsconfig.json`; + if (fs.existsSync(configPath)) { + return configPath; } - if (!fs.existsSync(tsConfigPath)) { - tsConfigPath = './tsconfig.json'; - } + return './tsconfig.json'; +} - Logger.Info('millenniumAPI', 'Loading tsconfig from ' + chalk.cyan.bold(tsConfigPath) + '... ' + chalk.green.bold('okay')); +function GetFrontEndDirectory(pluginJson: PluginJson): string { + return pluginJson?.frontend ?? 'frontend'; +} + +function GetFrontendPluginComponents(pluginJson: PluginJson, props: TranspilerProps): InputPluginOption[] { + const frontendDir = GetFrontEndDirectory(pluginJson); + const tsConfigPath = GetTsConfigPath(frontendDir); + + Logger.Config('Loading frontend tsconfig from ' + chalk.cyan.bold(tsConfigPath) + '... ' + chalk.green.bold('okay')); let pluginList = [ typescript({ @@ -143,7 +153,7 @@ async function GetPluginComponents(pluginJson: any, props: TranspilerProps): Pro }, }), url({ - include: ['**/*.gif', '**/*.webm', '**/*.svg'], // Add all non-JS assets you use + include: ['**/*.gif', '**/*.webm', '**/*.svg', '**/*.scss', '**/*.css'], // Add all non-JS assets you use limit: 0, // Set to 0 to always copy the file instead of inlining as base64 fileName: '[hash][extname]', // Optional: custom output naming }), @@ -185,11 +195,60 @@ async function GetPluginComponents(pluginJson: any, props: TranspilerProps): Pro return pluginList; } -async function GetWebkitPluginComponents(props: TranspilerProps) { - let pluginList = [ +async function GetFrontendRollupConfig(props: TranspilerProps, pluginJson: PluginJson): Promise { + const frontendDir = GetFrontEndDirectory(pluginJson); + Logger.Config('Frontend directory set to:', chalk.cyan.bold(frontendDir)); + + const frontendPlugins = await GetFrontendPluginComponents(pluginJson, props); + + let entryFile = ''; + if (frontendDir === '.' || frontendDir === './' || frontendDir === '') { + entryFile = './index.tsx'; + } else { + entryFile = `./${frontendDir}/index.tsx`; + } + + Logger.Config('Frontend entry file set to:', chalk.cyan.bold(entryFile)); + + return { + input: entryFile, + plugins: frontendPlugins, + context: 'window', + external: (id) => { + if (id === '@steambrew/webkit') { + Logger.Error( + 'The @steambrew/webkit module should not be included in the frontend module, use @steambrew/client instead. Please remove it from the frontend module and try again.', + ); + process.exit(1); + } + + return id === '@steambrew/client' || id === 'react' || id === 'react-dom' || id === 'react-dom/client' || id === 'react/jsx-runtime'; + }, + output: { + name: 'millennium_main', + file: props.bIsMillennium ? '../../build/frontend.bin' : FRONTEND_OUTPUT_PATH, + globals: { + react: 'window.SP_REACT', + 'react-dom': 'window.SP_REACTDOM', + 'react-dom/client': 'window.SP_REACTDOM', + 'react/jsx-runtime': 'SP_JSX_FACTORY', + '@steambrew/client': 'window.MILLENNIUM_API', + }, + exports: 'named', + format: 'iife', + }, + }; +} + +async function GetWebkitPluginComponents(props: TranspilerProps): Promise { + const tsConfigPath = GetTsConfigPath('webkit'); + + Logger.Config('Loading webkit tsconfig from ' + chalk.cyan.bold(tsConfigPath) + '... ' + chalk.green.bold('okay')); + + let pluginList: InputPluginOption[] = [ InsertMillennium(ComponentType.Webkit, props), typescript({ - tsconfig: './webkit/tsconfig.json', + tsconfig: tsConfigPath, }), url({ include: ['**/*.mp4', '**/*.webm', '**/*.ogg'], @@ -224,94 +283,103 @@ async function GetWebkitPluginComponents(props: TranspilerProps) { return pluginList; } -const GetFrontEndDirectory = (pluginJson: any) => { - try { - return pluginJson?.frontend ?? 'frontend'; - } catch (error) { - return 'frontend'; - } -}; - -export const TranspilerPluginComponent = async (bIsMillennium: boolean, pluginJson: any, props: TranspilerProps) => { - const frontendDir = GetFrontEndDirectory(pluginJson); - console.log(chalk.greenBright.bold('config'), 'Frontend directory set to:', chalk.cyan.bold(frontendDir)); - - const frontendPlugins = await GetPluginComponents(pluginJson, props); - - // Fix entry file path construction - let entryFile = ''; - if (frontendDir === '.' || frontendDir === './' || frontendDir === '') { - entryFile = './index.tsx'; - } else { - entryFile = `./${frontendDir}/index.tsx`; - } - - console.log(chalk.greenBright.bold('config'), 'Entry file set to:', chalk.cyan.bold(entryFile)); - - const frontendRollupConfig: RollupOptions = { - input: entryFile, - plugins: frontendPlugins, +async function GetWebkitRollupConfig(props: TranspilerProps): Promise { + return { + input: WEBKIT_ENTRY_PATH, + plugins: await GetWebkitPluginComponents(props), context: 'window', external: (id) => { - if (id === '@steambrew/webkit') { + if (id === '@steambrew/client') { Logger.Error( - 'The @steambrew/webkit module should not be included in the frontend module, use @steambrew/client instead. Please remove it from the frontend module and try again.', + 'The @steambrew/client module should not be included in the webkit module, use @steambrew/webkit instead. Please remove it from the webkit module and try again.', ); process.exit(1); } - return id === '@steambrew/client' || id === 'react' || id === 'react-dom' || id === 'react-dom/client' || id === 'react/jsx-runtime'; + return id === '@steambrew/webkit'; }, output: { name: 'millennium_main', - file: bIsMillennium ? '../../build/frontend.bin' : '.millennium/Dist/index.js', - globals: { - react: 'window.SP_REACT', - 'react-dom': 'window.SP_REACTDOM', - 'react-dom/client': 'window.SP_REACTDOM', - 'react/jsx-runtime': 'SP_JSX_FACTORY', - '@steambrew/client': 'window.MILLENNIUM_API', - }, + file: WEBKIT_OUTPUT_PATH, exports: 'named', format: 'iife', + globals: { + '@steambrew/webkit': 'window.MILLENNIUM_API', + }, }, }; +} + +export async function RunWatchMode(frontendRollupConfig: RollupOptions, webkitRollupConfig: RollupOptions | null): Promise { + const watchConfigs = webkitRollupConfig ? [frontendRollupConfig, webkitRollupConfig] : [frontendRollupConfig]; + + const watcher = watch(watchConfigs); + + watcher.on('event', async (event) => { + let buildType: 'Frontend' | 'Webkit' | null = null; + if ('output' in event) { + buildType = event.output.some((file) => file.includes(FRONTEND_OUTPUT_PATH)) ? 'Frontend' : 'Webkit'; + } + + if (event.code === 'START') { + console.log(chalk.blueBright.bold('watch'), 'Build started...'); + } else if (event.code === 'BUNDLE_START') { + console.log(chalk.yellowBright.bold('watch'), `Bundling ${buildType}...`); + } else if (event.code === 'BUNDLE_END') { + console.log(chalk.greenBright.bold('watch'), `${buildType} build completed in ${chalk.green(`${event.duration}ms`)}`); + await event.result.close(); + } else if (event.code === 'END') { + console.log(chalk.greenBright.bold('watch'), 'All builds completed. Watching for changes...'); + } else if (event.code === 'ERROR') { + // Remove watchFiles from error object to prevent it from being logged, as it is not relevant to the user + const error = { ...event.error, watchFiles: undefined }; + Logger.Error(chalk.red.bold('watch'), chalk.red('Build error:'), error); + } + }); + + console.log(chalk.blueBright.bold('watch'), 'Watch mode enabled. Watching for file changes...'); + + function CloseWatcher() { + console.log(chalk.yellowBright.bold('watch'), 'Stopping watch mode...'); + watcher.close(); + process.exit(0); + } + + process.on('SIGINT', () => { + CloseWatcher(); + }); + + process.on('SIGUSR2', () => { + CloseWatcher(); + }); +} + +export async function TranspilerPluginComponent(pluginJson: PluginJson, props: TranspilerProps) { + const frontendRollupConfig = await GetFrontendRollupConfig(props, pluginJson); + + const hasWebkit = fs.existsSync(WEBKIT_ENTRY_PATH); + + const webkitRollupConfig: RollupOptions | null = hasWebkit ? await GetWebkitRollupConfig(props) : null; + + if (props.bWatchMode) { + RunWatchMode(frontendRollupConfig, webkitRollupConfig); + return; + } try { + const frontendTimer = performance.now(); await (await rollup(frontendRollupConfig)).write(frontendRollupConfig.output as OutputOptions); + Logger.Config('Frontend build time:', (performance.now() - frontendTimer).toFixed(3), 'ms elapsed.'); - if (fs.existsSync(`./webkit/index.tsx`)) { - const webkitRollupConfig: RollupOptions = { - input: `./webkit/index.tsx`, - plugins: await GetWebkitPluginComponents(props), - context: 'window', - external: (id) => { - if (id === '@steambrew/client') { - Logger.Error( - 'The @steambrew/client module should not be included in the webkit module, use @steambrew/webkit instead. Please remove it from the webkit module and try again.', - ); - process.exit(1); - } - - return id === '@steambrew/webkit'; - }, - output: { - name: 'millennium_main', - file: '.millennium/Dist/webkit.js', - exports: 'named', - format: 'iife', - globals: { - '@steambrew/webkit': 'window.MILLENNIUM_API', - }, - }, - }; - + if (hasWebkit && webkitRollupConfig !== null) { + const webkitTimer = performance.now(); await (await rollup(webkitRollupConfig)).write(webkitRollupConfig.output as OutputOptions); + Logger.Config('Webkit build time:', (performance.now() - webkitTimer).toFixed(3), 'ms elapsed.'); } - Logger.Info('build', 'Succeeded passing all tests in', Number((performance.now() - global.PerfStartTime).toFixed(3)), 'ms elapsed.'); + Logger.Info('build', 'Succeeded passing all tests in', (performance.now() - global.PerfStartTime).toFixed(3), 'ms elapsed.'); } catch (exception) { Logger.Error('error', 'Build failed!', exception); process.exit(1); } -}; +} diff --git a/src/version-control.ts b/src/version-control.ts index 1f3ac35..47e3b41 100644 --- a/src/version-control.ts +++ b/src/version-control.ts @@ -1,7 +1,6 @@ -import path from 'path'; -import { fileURLToPath } from 'url'; import { readFile } from 'fs/promises'; -import { dirname } from 'path'; +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; import { Logger } from './logger'; export const CheckForUpdates = async (): Promise => { @@ -12,7 +11,7 @@ export const CheckForUpdates = async (): Promise => { fetch('https://registry.npmjs.org/@steambrew/ttc') .then((response) => response.json()) .then((json) => { - if (json?.['dist-tags']?.latest != packageJson.version) { + if (json?.['dist-tags']?.latest.replace(/\./g, '') > packageJson.version.replace(/\./g, '')) { Logger.Tree('versionMon', `@steambrew/ttc@${packageJson.version} requires update to ${json?.['dist-tags']?.latest}`, { cmd: `run "npm install @steambrew/ttc@${json?.['dist-tags']?.latest}" to get latest updates!`, });