Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Added start & prerender commands #610

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/dull-readers-admire.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/wise-cheetahs-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"create-ima-app": minor
"@ima/cli": minor
---

Added new prerender command to IMA CLI
78 changes: 77 additions & 1 deletion docs/cli/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -25,6 +25,7 @@ Usage: ima <command>
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]
Expand Down Expand Up @@ -90,6 +91,81 @@ 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
```

## 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.
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
152 changes: 152 additions & 0 deletions packages/cli/src/commands/prerender.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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),
};
73 changes: 73 additions & 0 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
@@ -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<void>}
*/
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',
},
};
24 changes: 23 additions & 1 deletion packages/cli/src/lib/cli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { spawn } from 'child_process';

import { Arguments, CommandBuilder } from 'yargs';

import { ImaCliArgs, HandlerFn, ImaCliCommand } from '../types';
Expand Down Expand Up @@ -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<void> {
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 };
Loading
Loading