@rechunk/cli
Version:
Command-line interface for managing ReChunk projects, chunks, and deployments
205 lines (183 loc) • 6 kB
text/typescript
import withRechunk from '@rechunk/rollup-preset';
import chalk from 'chalk';
import {program} from 'commander';
import {createHash} from 'crypto';
import http, {IncomingMessage, ServerResponse} from 'http';
import {KEYUTIL, KJUR, RSAKey} from 'jsrsasign';
import path from 'path';
import {rollup} from 'rollup';
import url from 'url';
import {getRechunkConfig, LOGO} from '../lib';
/**
* Generates a port number based on the string "rechunk" by mapping it to an ephemeral port.
*
* The port selection process involves the following steps:
*
* 1. **ASCII Value Conversion**: Each character in the string "rechunk" is converted to its corresponding
* ASCII value:
* - 'r' -> 114
* - 'e' -> 101
* - 'c' -> 99
* - 'h' -> 104
* - 'u' -> 117
* - 'n' -> 110
* - 'k' -> 107
*
* 2. **Summation**: The ASCII values are summed together:
* - Total = 114 + 101 + 99 + 104 + 117 + 110 + 107 = 752
*
* 3. **Modulo Operation**: The sum is then mapped to the ephemeral port range (49152 to 65535) using
* the modulo operation:
* - Size of the ephemeral port range = 65535 - 49152 = 16383
* - Remainder = 752 % 16383 = 752 (since 752 is less than 16383)
*
* 4. **Port Calculation**: The final port number is obtained by adding the remainder to the start of
* the ephemeral port range:
* - Final Port = 49152 + 752 = 49904
*
* The chosen port number (49904) falls within the ephemeral port range and is derived uniquely
* from the string "rechunk".
*
* @returns {number} The generated ephemeral port number based on the string "rechunk".
*/
const PORT = 49904;
/**
* Defines a command for the "dev-server" operation using the "commander" library.
* This command facilitates serving React Native chunks based on `.rechunkrc.json`.
*
* @example
* ```bash
* pnpm rechunk dev-server
* ```
*/
program
.command('dev-server')
.description(
'ReChunk development server to serve and sign React Native chunks.',
)
.action(() => {
const rc = getRechunkConfig();
startDevServer(rc);
});
/**
* Starts the development server to serve chunks dynamically based on incoming requests.
*
* @param rc - The ReChunk configuration object.
*/
function startDevServer(rc: ReturnType<typeof getRechunkConfig>): void {
const server = http.createServer((req, res) => handleRequest(req, res, rc));
server.listen(PORT, () => {
console.log();
console.log(LOGO);
console.log(
` ${chalk.green`→`} host: http://localhost
${chalk.green`→`} port: ${PORT}
${chalk.green`→`} path: /projects/:project/chunks/:chunkId`,
);
console.log();
});
}
/**
* Handles incoming HTTP requests, processing and serving chunks based on the URL.
*
* @param req - The incoming HTTP request.
* @param res - The outgoing HTTP response.
* @param rc - The ReChunk configuration object.
*/
async function handleRequest(
req: IncomingMessage,
res: ServerResponse,
rc: ReturnType<typeof getRechunkConfig>,
): Promise<void> {
const {projectId, chunkId} = parseUrl(req.url);
if (!chunkId) {
res.writeHead(400, {'Content-Type': 'text/plain'});
res.end('Bad Request');
return;
}
console.log(
`${chalk.green` ⑇`} ${new Date().toISOString()}: Serving /projects/${projectId}/chunks/${chunkId}`,
);
try {
const decodeChunkId = Buffer.from(chunkId, 'base64').toString('utf-8');
const code = await bundleChunk(decodeChunkId);
const token = generateToken(code, rc.privateKey);
sendJsonResponse(res, {token, data: code});
} catch (error) {
logError(res, (error as Error).message);
}
}
/**
* Parses the request URL to extract the project ID and chunk ID.
*
* @param requestUrl - The incoming request URL.
* @returns An object containing `projectId` and `chunkId`.
* @throws {Error} If the URL cannot be parsed.
*/
function parseUrl(requestUrl: string | undefined): {
projectId: string;
chunkId: string;
} {
const parsedUrl = url.parse(requestUrl || '', true);
const matches = parsedUrl.pathname?.match(/\/projects\/(.*)\/chunks\/(\w+)/);
if (!matches) {
throw new Error('[ReChunk]: Unable to parse URL');
}
return {projectId: matches[1], chunkId: matches[2]};
}
/**
* Bundles the specified chunk using Rollup.
*
* @param entryPath - The file path for the chunk entry point.
* @returns A promise resolving to the bundled code as a string.
* @throws {Error} If bundling fails.
*/
async function bundleChunk(entryPath: string): Promise<string> {
const input = path.resolve(process.cwd(), entryPath);
const rollupBuild = await rollup(await withRechunk({input}));
const {
output: [{code}],
} = await rollupBuild.generate({interop: 'auto', format: 'cjs'});
return code;
}
/**
* Generates a signed token for the chunk using the private key.
*
* @param code - The bundled code for which to generate the token.
* @param privateKey - The private key to sign the token.
* @returns The signed token.
*/
function generateToken(code: string, privateKey: string): string {
const prvKey = KEYUTIL.getKey(privateKey) as RSAKey;
const sPayload = createHash('sha256').update(code).digest('hex');
return KJUR.jws.JWS.sign(
'RS256',
JSON.stringify({alg: 'RS256'}),
sPayload,
prvKey,
);
}
/**
* Sends a JSON response to the client.
*
* @param res - The outgoing HTTP response.
* @param data - The data to send as a JSON response.
*/
function sendJsonResponse(
res: ServerResponse,
data: Record<string, unknown>,
): void {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify(data));
}
/**
* Logs an error message and sends a 500 response to the client.
*
* @param res - The outgoing HTTP response.
* @param message - The error message to log and send.
*/
function logError(res: ServerResponse, message: string): void {
console.error(`❌ Error: ${message}`);
res.writeHead(500, {'Content-Type': 'text/plain'});
res.end(`Server Error: ${message}`);
}