From 9062257d10b45310db3beddeba842753c6402c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C5=A0ime=C4=8Dek?= Date: Wed, 19 Feb 2025 14:18:34 +0100 Subject: [PATCH 1/3] Added start command --- .changeset/dull-readers-admire.md | 6 ++ docs/cli/cli.md | 33 ++++++++- packages/cli/src/commands/start.ts | 73 +++++++++++++++++++ packages/cli/src/types.ts | 3 +- .../template/common/package.json | 4 +- utils/scripts/utils/utils.js | 10 +-- 6 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 .changeset/dull-readers-admire.md create mode 100644 packages/cli/src/commands/start.ts diff --git a/.changeset/dull-readers-admire.md b/.changeset/dull-readers-admire.md new file mode 100644 index 0000000000..a2201f55c9 --- /dev/null +++ b/.changeset/dull-readers-admire.md @@ -0,0 +1,6 @@ +--- +"create-ima-app": minor +"@ima/cli": minor +--- + +Added new `start` command to the CLI that is used to start the application server. diff --git a/docs/cli/cli.md b/docs/cli/cli.md index 0560326c08..0b12fe2255 100644 --- a/docs/cli/cli.md +++ b/docs/cli/cli.md @@ -3,7 +3,7 @@ title: 'Introduction to @ima/cli' description: 'CLI > Introduction to @ima/cli' --- -The **IMA.js CLI** allows you to build and watch your application for changes during development. These features are handle by the only two currently supported commands `build` and `dev`. +The **IMA.js CLI** allows you to build, run and watch your application for changes during development. These features are handled by the following commands: `build`, `dev`, and `start`. You can always list available commands by running: @@ -25,6 +25,7 @@ Usage: ima Commands: ima build Build an application for production ima dev Run application in development watch mode + ima start Start the application in production mode Options: --version Show version number [boolean] @@ -90,6 +91,36 @@ Options: --profile Turn on profiling support in production [boolean] [default: false] ``` +## Start + +The `npx ima start` command starts the application server. This command is designed to run your application after it has been built using the `build` command in production. + +``` +ima start + +Start the application in production mode + +Options: + --version Show version number [boolean] + --help Show help [boolean] + --server Custom path to the server file (relative to project root) [string] [default: "server/server.js"] +``` + +The start command will: +1. Run your application in production mode (by default) +2. Handle process signals (SIGTERM, SIGINT) for graceful shutdown +3. Provide proper error handling and logging + +By default, the command looks for the server file at `server/server.js` in your project root. You can customize this path using the `--server` option: + +```bash +# Using default server path +npx ima start + +# Using custom server path +npx ima start --server custom/path/to/server.js +``` + ## CLI options Most of the following options are available for both `dev` and `build` commands, however some may be exclusive to only one of them. You can always use the `--help` argument to show all available options for each command. diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts new file mode 100644 index 0000000000..d14683bbff --- /dev/null +++ b/packages/cli/src/commands/start.ts @@ -0,0 +1,73 @@ +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { logger } from '@ima/dev-utils/logger'; +import { CommandBuilder } from 'yargs'; + +import { handlerFactory } from '../lib/cli'; +import { HandlerFn } from '../types'; + +/** + * Starts ima application in production mode. + * + * @param {CliArgs} args + * @returns {Promise} + */ +const start: HandlerFn = async args => { + try { + const serverPath = args.server + ? path.resolve(args.rootDir, args.server) + : path.resolve(args.rootDir, 'server/server.js'); + + // Validate server file exists + if (!fs.existsSync(serverPath)) { + logger.error(`Server file not found at: ${serverPath}`); + process.exit(1); + } + + logger.info('Starting production server...'); + + // Spawn node process with the server + const serverProcess = spawn('node', [serverPath], { + stdio: 'inherit', + env: { ...process.env }, + }); + + // Handle server process events + serverProcess.on('error', error => { + logger.error(`Failed to start server process: ${error.message}`); + process.exit(1); + }); + + // Forward SIGTERM and SIGINT to the child process + const signals = ['SIGTERM', 'SIGINT'] as const; + signals.forEach(signal => { + process.on(signal, () => { + if (serverProcess.pid) { + process.kill(serverProcess.pid, signal); + } + process.exit(); + }); + }); + } catch (error) { + if (error instanceof Error) { + logger.error(`Failed to start server: ${error.message}`); + } else { + logger.error('Failed to start server: Unknown error'); + } + process.exit(1); + } +}; + +const CMD = 'start'; +export const command = CMD; +export const describe = 'Start the application in production mode'; +export const handler = handlerFactory(start); +export const builder: CommandBuilder = { + server: { + type: 'string', + description: 'Custom path to the server file (relative to project root)', + default: 'server/server.js', + }, +}; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 71068e677e..78911d605e 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -22,7 +22,7 @@ declare global { /** * Ima CLI commands. */ -export type ImaCliCommand = 'build' | 'dev'; +export type ImaCliCommand = 'build' | 'dev' | 'start'; /** * Arguments generated from ima CLI commands. @@ -48,6 +48,7 @@ export interface ImaCliArgs { reactRefresh?: boolean; forceLegacy?: boolean; lazyServer?: boolean; + server?: string; } /** diff --git a/packages/create-ima-app/template/common/package.json b/packages/create-ima-app/template/common/package.json index be8be8181e..8c2786971a 100644 --- a/packages/create-ima-app/template/common/package.json +++ b/packages/create-ima-app/template/common/package.json @@ -5,8 +5,8 @@ "test": "jest", "lint": "eslint './**/*.{js,jsx,ts,tsx}'", "dev": "ima dev", - "build": "NODE_ENV=production ima build", - "start": "NODE_ENV=production node server/server.js" + "build": "ima build", + "start": "ima start" }, "keywords": [ "IMA.js", diff --git a/utils/scripts/utils/utils.js b/utils/scripts/utils/utils.js index 42b01a51e5..27a0ed3357 100644 --- a/utils/scripts/utils/utils.js +++ b/utils/scripts/utils/utils.js @@ -47,9 +47,9 @@ function timeNow() { return chalk.gray(`[${h}:${m}:${s}]`); } -function createWatcher(name, baseDir, paths, destFolder, options = {}) { +function createWatcher(name, baseDir, destFolder, options = {}) { try { - const watcher = chokidar.watch(path.join(baseDir, paths), { + const watcher = chokidar.watch([baseDir], { persistent: true, cwd: baseDir, ignored: [...IGNORED], @@ -115,10 +115,6 @@ function createWatcher(name, baseDir, paths, destFolder, options = {}) { }); break; - - case 'unlink': - fs.unlink(dest, callback); - break; } // Restart ima server in host application @@ -176,7 +172,7 @@ function watchChanges(destFolder, pkgDirs) { } // Create file watcher - createWatcher(name, pkgDir, '/**/*', destPkgDir); + createWatcher(name, pkgDir, destPkgDir); }); } From 398d5506752376882c879682c1ecc58621063768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C5=A0ime=C4=8Dek?= Date: Mon, 3 Mar 2025 13:02:43 +0100 Subject: [PATCH 2/3] Adde new prerender command to IMA CLI --- .changeset/wise-cheetahs-beg.md | 6 + docs/cli/cli.md | 45 ++++++++ packages/cli/package.json | 3 +- packages/cli/src/commands/prerender.ts | 152 +++++++++++++++++++++++++ packages/cli/src/lib/cli.ts | 24 +++- packages/cli/src/types.ts | 4 +- 6 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 .changeset/wise-cheetahs-beg.md create mode 100644 packages/cli/src/commands/prerender.ts diff --git a/.changeset/wise-cheetahs-beg.md b/.changeset/wise-cheetahs-beg.md new file mode 100644 index 0000000000..a2244f219d --- /dev/null +++ b/.changeset/wise-cheetahs-beg.md @@ -0,0 +1,6 @@ +--- +"create-ima-app": minor +"@ima/cli": minor +--- + +Added new prerender command to IMA CLI diff --git a/docs/cli/cli.md b/docs/cli/cli.md index 0b12fe2255..5417dae4b7 100644 --- a/docs/cli/cli.md +++ b/docs/cli/cli.md @@ -121,6 +121,51 @@ npx ima start npx ima start --server custom/path/to/server.js ``` +## Prerender + +The `npx ima prerender` command allows you to generate static HTML files for your application routes. It can be used to generate static spa templates or completely prerender SSR content. + +``` +ima prerender + +Prerender application as static HTML + +Options: + --version Show version number [boolean] + --help Show help [boolean] + --preRenderMode Prerender mode (spa or ssr) [string] [choices: "spa", "ssr"] [default: "spa"] + --paths Path(s) to prerender (defaults to /) [array] +``` + +The prerender command will: +1. Build your application in production mode +2. Start the server in the specified mode (SPA or SSR) +3. Generate static HTML files for specified paths +4. Save the generated files in the `build` directory + +You can use the command in several ways: + +```bash +# Prerender just the root path (default) +npx ima prerender + +# Prerender a single path +npx ima prerender --paths /about + +# Prerender multiple paths +npx ima prerender --paths / --paths /about --paths /contact + +# Prerender in SSR mode +npx ima prerender --preRenderMode ssr --paths / --paths /about +``` + +The generated files will be named according to the path and mode: +- `/` -> `{mode}.html` +- `/about` -> `{mode}-about.html` +- `/blog/post-1` -> `{mode}-blog_post-1.html` + +where `{mode}` is either `spa` or `ssr` based on the `--preRenderMode` option. + ## CLI options Most of the following options are available for both `dev` and `build` commands, however some may be exclusive to only one of them. You can always use the `--help` argument to show all available options for each command. diff --git a/packages/cli/package.json b/packages/cli/package.json index 0c0390ecaa..a3f4690f63 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -75,7 +75,8 @@ "webpack": "^5.75.0", "webpack-dev-middleware": "^6.0.1", "webpack-hot-middleware": "^2.25.3", - "yargs": "^17.5.1" + "yargs": "^17.5.1", + "node-fetch": "^2.6.9" }, "devDependencies": { "@types/cli-progress": "^3.11.0", diff --git a/packages/cli/src/commands/prerender.ts b/packages/cli/src/commands/prerender.ts new file mode 100644 index 0000000000..d9522d64ef --- /dev/null +++ b/packages/cli/src/commands/prerender.ts @@ -0,0 +1,152 @@ +import { spawn } from 'child_process'; +import fs from 'fs/promises'; +import path from 'path'; + +import { logger } from '@ima/dev-utils/logger'; +import { CommandBuilder } from 'yargs'; + +import { + handlerFactory, + resolveCliPluginArgs, + runCommand, + sharedArgsFactory, +} from '../lib/cli'; +import { HandlerFn } from '../types'; +import { resolveEnvironment } from '../webpack/utils'; + +/** + * Wait for the server to start. + */ +async function waitForServer(port: number, maxAttempts = 30): Promise { + for (let i = 0; i < maxAttempts; i++) { + try { + await fetch(`http://localhost:${port}`); + + return; + } catch { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + throw new Error('Server failed to start'); +} + +/** + * Prerender a single URL: + * - Fetch the HTML content + * - Return the URL and HTML content + */ +async function preRenderPath( + path: string, + baseUrl: string +): Promise<{ url: string; html: string }> { + const url = new URL(path, baseUrl); + const response = await fetch(url.toString()); + const html = await response.text(); + + return { url: url.toString(), html }; +} + +/** + * Convert URL to filename: + * - Remove leading and trailing slashes + * - Replace remaining slashes with underscores + * - Add mode and .html extension + */ +function getOutputFilename(url: string, mode: string): string { + const pathname = new URL(url).pathname; + const urlPath = + pathname === '/' + ? '' + : pathname.replace(/^\/|\/$/g, '').replace(/\//g, '_'); + + return `${mode}${urlPath ? `-${urlPath}` : ''}.html`; +} + +const prerender: HandlerFn = async args => { + try { + // Parse paths to prerender + const paths = args.paths + ? Array.isArray(args.paths) + ? args.paths + : [args.paths] + : ['/']; + + // Build the application first + logger.info('Building application...'); + await runCommand('ima', ['build'], { + ...args, + }); + + // Load environment to get the application port + const environment = resolveEnvironment(args.rootDir); + + // Start the server with appropriate mode + const { preRenderMode } = args; + logger.info(`Starting server in ${preRenderMode.toUpperCase()} mode...`); + + const port = environment.$Server.port ?? 3001; + const hostname = environment.$Server.host ?? 'localhost'; + const serverProcess = spawn('ima', ['start'], { + stdio: 'inherit', + env: { + ...process.env, + ...(preRenderMode === 'spa' ? { IMA_CLI_FORCE_SPA: 'true' } : {}), + }, + }); + + // Wait for server to start + await waitForServer(port); + const baseUrl = `http://${hostname}:${port}`; + + // Create output directory if it doesn't exist + const outputDir = path.resolve(args.rootDir, 'build'); + await fs.mkdir(outputDir, { recursive: true }); + + // Prerender all URLs + logger.info(`Prerendering ${paths.length} Path(s)...`); + const results = await Promise.all( + paths.map(path => preRenderPath(path, baseUrl)) + ); + + // Write results to disk + for (const result of results) { + const outputPath = path.join( + outputDir, + getOutputFilename(result.url, preRenderMode) + ); + + await fs.writeFile(outputPath, result.html); + logger.info(`Prerendered ${result.url} -> ${outputPath}`); + } + + // Clean up + serverProcess.kill(); + process.exit(0); + } catch (error) { + logger.error( + error instanceof Error ? error : new Error('Unknown prerender error') + ); + process.exit(1); + } +}; + +const CMD = 'prerender'; +export const command = CMD; +export const describe = 'Prerender application as static HTML'; +export const handler = handlerFactory(prerender); +export const builder: CommandBuilder = { + ...sharedArgsFactory(CMD), + preRenderMode: { + desc: 'Prerender mode (spa or ssr)', + type: 'string', + choices: ['spa', 'ssr'], + default: 'spa', + }, + paths: { + desc: 'Path(s) to prerender (defaults to /)', + type: 'array', + string: true, + }, + ...resolveCliPluginArgs(CMD), +}; diff --git a/packages/cli/src/lib/cli.ts b/packages/cli/src/lib/cli.ts index b7ccde51ac..a2b6998f2e 100644 --- a/packages/cli/src/lib/cli.ts +++ b/packages/cli/src/lib/cli.ts @@ -1,3 +1,5 @@ +import { spawn } from 'child_process'; + import { Arguments, CommandBuilder } from 'yargs'; import { ImaCliArgs, HandlerFn, ImaCliCommand } from '../types'; @@ -90,4 +92,24 @@ function sharedArgsFactory(command: ImaCliCommand): CommandBuilder { }; } -export { handlerFactory, resolveCliPluginArgs, sharedArgsFactory }; +/** + * Runs a command and waits for it to finish. + */ +function runCommand(command: string, args: string[], env = {}): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + env: { ...process.env, ...env }, + }); + + child.on('close', code => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Command failed with code ${code}`)); + } + }); + }); +} + +export { handlerFactory, resolveCliPluginArgs, sharedArgsFactory, runCommand }; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 78911d605e..1ecadc3886 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -22,7 +22,7 @@ declare global { /** * Ima CLI commands. */ -export type ImaCliCommand = 'build' | 'dev' | 'start'; +export type ImaCliCommand = 'build' | 'dev' | 'start' | 'prerender'; /** * Arguments generated from ima CLI commands. @@ -42,6 +42,7 @@ export interface ImaCliArgs { profile?: boolean; port?: number; hostname?: string; + paths?: string | string[]; publicUrl?: string; environment: 'development' | 'production' | string; writeToDisk?: boolean; @@ -49,6 +50,7 @@ export interface ImaCliArgs { forceLegacy?: boolean; lazyServer?: boolean; server?: string; + preRenderMode: 'spa' | 'ssr'; } /** From bf3d4e3d2a3460b5c0bf7ec466d342e2c88d1d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C5=A0ime=C4=8Dek?= Date: Tue, 15 Apr 2025 11:30:23 +0200 Subject: [PATCH 3/3] Fixed CI --- packages/cli/src/commands/prerender.ts | 5 +++++ packages/cli/src/types.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/prerender.ts b/packages/cli/src/commands/prerender.ts index d9522d64ef..d8a87f99e7 100644 --- a/packages/cli/src/commands/prerender.ts +++ b/packages/cli/src/commands/prerender.ts @@ -83,6 +83,11 @@ const prerender: HandlerFn = async args => { // Start the server with appropriate mode const { preRenderMode } = args; + + if (!preRenderMode) { + throw new Error('Prerender mode is required'); + } + logger.info(`Starting server in ${preRenderMode.toUpperCase()} mode...`); const port = environment.$Server.port ?? 3001; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 1ecadc3886..d863f59a77 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -50,7 +50,7 @@ export interface ImaCliArgs { forceLegacy?: boolean; lazyServer?: boolean; server?: string; - preRenderMode: 'spa' | 'ssr'; + preRenderMode?: 'spa' | 'ssr'; } /**