rg-express
Version:
Welcome to the **rg-express** user guide! This document will help you get started with **rg-express**, a powerful route generator for Express that simplifies route management with file-based approach. Whether you're building APIs in TypeScript or JavaScri
472 lines (462 loc) • 19.5 kB
JavaScript
import express from 'express';
import * as fs from 'fs';
import fs__default from 'fs';
import * as path from 'path';
// export class ProcessConsole {
// private loadingInterval: NodeJS.Timeout | null;
// constructor() {
// this.loadingInterval = null;
// }
// private setColor(color: string, message: string): string {
// return `\x1b[${color}m${message}\x1b[0m`;
// }
// start(message: string, frames: string[] = ['⠹', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], speed: number = 100): void {
// let i = 0;
// process.stdout.write(this.setColor('34', message + ' '));
// this.loadingInterval = setInterval(() => {
// process.stdout.write('\r' + this.setColor('34', frames[i] + ' ' + message + ' '));
// i = (i + 1) % frames.length;
// }, speed);
// }
// stop(): void {
// if (this.loadingInterval !== null) {
// clearInterval(this.loadingInterval);
// }
// }
// complete(successMessage: string): void {
// this.stop();
// process.stdout.write(`\r${this.setColor('1;92', '✔')} ${this.setColor('38;5;2', successMessage)}\n`);
// }
// error(errorMessage: string): void {
// this.stop();
// process.stdout.write(`\r${this.setColor('31',errorMessage)} \n`);
// }
// false(errorMessage: string): void {
// this.stop();
// process.stdout.write(`\r${this.setColor('31','✗')} ${this.setColor('30', errorMessage)} \n`);
// }
// }
// // // Example usage
// // const console = new ProcessConsole();
// // console.start('Processing');
// // // Perform some task...
// // console.complete('Task completed successfully.');
class ProcessConsole {
constructor() {
this.loadingInterval = null;
this.DEFAULT_FRAMES = ['⠹', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
this.DEFAULT_SPEED = 100;
// ANSI color codes as constants for better maintainability
this.COLORS = {
blue: '34',
red: '31',
green: '38;5;2',
brightGreen: '1;92',
black: '30'
};
this.SYMBOLS = {
success: '✔',
failure: '✗'
};
}
formatMessage(color, message, symbol) {
const prefix = symbol ? `${this.setColor(color, symbol)} ` : '';
return `\r${prefix}${this.setColor(color, message)}\x1b[0m`;
}
setColor(color, text) {
return `\x1b[${color}m${text}`;
}
start(message, frames = this.DEFAULT_FRAMES, speed = this.DEFAULT_SPEED) {
if (this.loadingInterval) {
this.stop();
}
let frameIndex = 0;
process.stdout.write(this.setColor(this.COLORS.blue, `${message} `));
this.loadingInterval = setInterval(() => {
process.stdout.write(this.formatMessage(this.COLORS.blue, `${frames[frameIndex]} ${message}`));
frameIndex = (frameIndex + 1) % frames.length;
}, speed);
}
stop() {
if (this.loadingInterval) {
clearInterval(this.loadingInterval);
this.loadingInterval = null;
process.stdout.write('\r'); // Clear the line
}
}
complete(successMessage) {
this.stop();
process.stdout.write(this.formatMessage(this.COLORS.green, successMessage, this.setColor(this.COLORS.brightGreen, this.SYMBOLS.success)) + '\n');
}
error(errorMessage) {
this.stop();
process.stdout.write(this.formatMessage(this.COLORS.red, errorMessage) + '\n');
}
fail(errorMessage) {
this.stop();
process.stdout.write(this.formatMessage(this.COLORS.black, errorMessage, this.setColor(this.COLORS.red, this.SYMBOLS.failure)) + '\n');
}
}
// // Example usage with async operation
// async function example() {
// const console = new ProcessConsole();
// console.start('Processing task');
// try {
// await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate work
// console.complete('Task completed successfully');
// } catch (error) {
// console.fail('Task failed unexpectedly');
// }
// }
const httpMethods = [
'GET',
'POST',
'PUT',
'DELETE',
'PATCH',
'OPTIONS',
'HEAD',
'CONNECT',
'TRACE'
];
const httpMethodsSet = new Set(httpMethods);
// export type HttpMethodUpper = typeof httpMethods[number];
function getSpreadParamsByRoute(route) {
const matches = route.match(/\[\.\.\.([a-zA-Z0-9_]+)\]/g) || [];
return matches.map(match => { var _a; return (_a = match.match(/\w+/)) === null || _a === void 0 ? void 0 : _a[0]; }).filter(Boolean);
}
function getSlugParamsByRoute(route) {
const matches = route.match(/\[(?!\.\.\.)([a-zA-Z0-9_]+)\]/g) || [];
return matches.map(match => { var _a; return (_a = match.match(/\w+/)) === null || _a === void 0 ? void 0 : _a[0]; }).filter(Boolean);
}
function checkForDuplicateParams(route) {
const matches = [...route.matchAll(/\[(\.\.\.)?([a-zA-Z0-9_]+)\]/g)];
const params = matches.map(([, , param]) => param);
// Using a Set to check for duplicates
const uniqueParams = new Set(params);
if (uniqueParams.size !== params.length) {
throw new Error(`Duplicate parameter(s) detected in route: ${params.filter((param, index, self) => self.indexOf(param) !== index).join(', ')}`);
}
}
function createRoutePath({ name, startDir }, fileExtension) {
let route = name
.replace(new RegExp(`^${startDir}/?`), '')
.replace(new RegExp(`/?route\.${fileExtension}$`), '') || '/';
route = route.replace(/\[(\w+)\]/g, ':$1');
route = route.replace(/\[\.\.\.(\w+)\]/g, "{*$1}").replace(/\\/g, '/');
// if route is not start with / then add /
if (!route.startsWith('/')) {
route = '/' + route;
}
route = route.replace(/\/+/g, '/'); // Remove duplicate slashes
return { route };
}
function filterAndLowercaseHttpMethods(exportedHandlerNames) {
return exportedHandlerNames
.filter((name) => httpMethodsSet.has(name))
.map(method => method.toLowerCase());
}
function isTypeScriptProject() {
try {
// const files = fs.readdirSync(process.cwd());
// return files.some(file => file.endsWith('.ts') || file.endsWith('.mts')) || fs.existsSync('tsconfig.json');
return fs__default.existsSync('tsconfig.json');
}
catch (_a) {
return false;
}
}
/**
* Determines the file extension of the current executing file (e.g., .ts, .js, .mjs, etc.)
* by inspecting the call stack.
*
* @returns {string} The file extension (e.g., 'ts', 'js', 'mjs', etc.)
*/
function determineFileExtension() {
var _a;
const stack = (_a = new Error().stack) === null || _a === void 0 ? void 0 : _a.split('\n')[3]; // Get the stack trace and grab the 3rd line (caller location)
const match = stack === null || stack === void 0 ? void 0 : stack.match(/\((.*?\.([a-zA-Z]+)):\d+:\d+\)/); // Match file extension pattern
return match ? match[2] : ''; // Return the file extension (e.g., 'ts', 'js', etc.)
}
// readFiles.ts
class PathParser {
static getPathPowers(filePath) {
const segments = filePath.split('/');
const pathSplitLength = segments.length;
let slugPower = 0;
let slugsPower = 0;
let slugStartAt = 0;
let slugsStartAt = 0;
segments.forEach((segment, index) => {
if (segment.match(this.SLUG_PATTERN)) {
slugPower += pathSplitLength - index;
slugStartAt = slugStartAt || index + 1;
}
else if (segment.match(this.SPREAD_PATTERN)) {
slugsPower += pathSplitLength - index;
slugsStartAt = slugsStartAt || index + 1;
}
});
return { slugPower, slugsPower, pathSplitLength, slugStartAt, slugsStartAt };
}
static getSlugsCount(filePath) {
const matches = filePath.match(this.SLUG_PATTERN);
return (matches === null || matches === void 0 ? void 0 : matches.length) || 0;
}
}
PathParser.SLUG_PATTERN = /\[\w+\]/g;
PathParser.SPREAD_PATTERN = /\[\.\.\.\w+\]/g;
class PathSorter {
static sortSpread(a, b) {
if (a === b)
return 0;
const aPowers = PathParser.getPathPowers(a);
const bPowers = PathParser.getPathPowers(b);
return (bPowers.slugsStartAt - aPowers.slugsStartAt ||
bPowers.slugsPower - aPowers.slugsPower ||
bPowers.slugStartAt - aPowers.slugStartAt ||
bPowers.slugPower - aPowers.slugPower ||
bPowers.pathSplitLength - aPowers.pathSplitLength ||
a.localeCompare(b));
}
static sortBasic(a, b) {
if (a === b)
return 0;
const hasBracketA = a.includes('[');
const hasBracketB = b.includes('[');
if (hasBracketA !== hasBracketB) {
return hasBracketA ? 1 : -1;
}
if (hasBracketA && hasBracketB) {
const slugsDiff = PathParser.getSlugsCount(b) - PathParser.getSlugsCount(a);
return slugsDiff !== 0 ? slugsDiff : b.length - a.length;
}
return a.localeCompare(b);
}
static sortFiles(fileList) {
const basicFiles = fileList.filter(f => !f.includes('[...'));
const spreadFiles = fileList.filter(f => f.includes('[...'));
return [
...basicFiles.sort(this.sortBasic),
...spreadFiles.sort(this.sortSpread)
];
}
}
class FileReader {
static normalizePath(filePath) {
return filePath.replace(/\\/g, '/');
}
static isRouteFile(fileName, extension) {
return fileName === `route.${extension}`;
}
static readFiles(directoryPath, fileExtension) {
try {
const normalizedDir = this.normalizePath(directoryPath);
const entries = fs.readdirSync(normalizedDir, { withFileTypes: true });
const fileList = [];
for (const entry of entries) {
const entryPath = this.normalizePath(path.join(normalizedDir, entry.name));
if (entry.isDirectory()) {
// skip dirs starting with "_"
if (entry.name.startsWith("_"))
continue;
fileList.push(...this.readFiles(entryPath, fileExtension));
continue;
}
if (entry.isFile()) {
if (this.isRouteFile(entry.name, fileExtension)) {
checkForDuplicateParams(entryPath);
fileList.push(entryPath);
}
}
}
return PathSorter.sortFiles(fileList);
}
catch (error) {
console.error("Error reading files:", error);
return [];
}
}
}
function getValidFunctionName(input) {
return input
.replace(/[\[\]\/]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/\.\.\.\w+/g, '');
}
/**
* Helper function to create the type definition for request parameters.
*/
function createRequestType(slugParams, spreadParams) {
const slugParamTypes = slugParams.map(p_name => `${p_name}: string;`).join(' ');
const spreadParamTypes = spreadParams.map(p_name => `${p_name}: string[];`).join(' ');
return `type Request = ExpressRequest<{ ${slugParamTypes} ${spreadParamTypes} }>;`;
}
/**
* Generates the content for the route file based on the parameters and language.
*/
function generateContent(fileExtension, allParams, slugParams, spreadParams, funcName, message) {
switch (fileExtension) {
case 'ts':
case 'mts':
return allParams.length
? `import { Request as ExpressRequest, Response } from 'express';
${createRequestType(slugParams, spreadParams)}
export const GET = async (req: Request, res: Response) => {
const { ${allParams.join(', ')} } = req.params;
res.send({ ${allParams.join(', ')} });
};`
: `import { Request, Response } from 'express';
export const GET = async (req: Request, res: Response) => {
res.send('${message}');
};`;
case 'js':
return allParams.length
? `const ${funcName} = async (req, res) => {
const { ${spreadParams.join(', ')} } = req.params;
res.send({ ${spreadParams.join(', ')} });
};
module.exports = { GET: ${funcName} };`
: `const ${funcName} = async (req, res) => {
res.send('${message}');
};
module.exports = { GET: ${funcName} };`;
case 'mjs':
return allParams.length
? `export const GET = async (req, res) => {
const { ${spreadParams.join(', ')} } = req.params;
res.send({ ${spreadParams.join(', ')} });
};`
: `export const GET = async (req, res) => {
res.send('${message}');
};`;
default:
throw new Error('Unsupported file extension');
}
}
/**
* Writes the startup code to a file if it does not exist or if it is empty.
*/
function writeToFileSyncStartupCode(startDir, filename, { codeSnippet, codeSnippetFn }) {
const spreadParams = getSpreadParamsByRoute(filename); // [...slug]
const slugParams = getSlugParamsByRoute(filename); // [userId]
const allParams = [...slugParams, ...spreadParams]; // Combined
const relativePath = filename.substring(startDir.length + 1, filename.lastIndexOf('/'));
const funcName = relativePath
? getValidFunctionName(`handleGet${relativePath.replace(/^\w/, c => c.toUpperCase()).replace(/-(\w)/g, (_, g) => g.toUpperCase())}`)
: 'handleGetRequest';
const message = relativePath || 'hello';
const ext = filename.split('.').pop();
const content = (codeSnippetFn && codeSnippetFn({ allParams, slugParams, spreadParams })) || codeSnippet || generateContent(ext, allParams, slugParams, spreadParams, funcName, message);
try {
// Check if the file exists and if its content is empty
if (!fs.existsSync(filename) || !fs.readFileSync(filename, 'utf8').trim()) {
fs.writeFileSync(filename, content);
}
}
catch (error) {
console.error('Error writing startup code:', error);
}
}
const consoleP = new ProcessConsole();
const consoleP2 = new ProcessConsole();
function routes(config) {
const normalizedConfig = typeof config === 'string'
? { baseDir: config, autoSetup: false, routeGenIfEmpty: false }
: config;
// Log a deprecation warning if 'autoSetup' is used
if (normalizedConfig.autoSetup) {
console.warn('[DEPRECATED] "autoSetup" is deprecated. Please use "routeGenIfEmpty" instead.');
}
console.time('✓ Ready in');
const codeSnippet = typeof normalizedConfig.routeGenIfEmpty === 'object' && 'codeSnippet' in normalizedConfig.routeGenIfEmpty ? normalizedConfig.routeGenIfEmpty.codeSnippet : undefined;
const codeSnippetFn = typeof normalizedConfig.routeGenIfEmpty === 'object' && 'codeSnippetFn' in normalizedConfig.routeGenIfEmpty ? normalizedConfig.routeGenIfEmpty.codeSnippetFn : undefined;
const isRouteGenWIthObject = codeSnippet !== undefined || codeSnippetFn !== undefined;
const isRouteGenWithBoolean = typeof normalizedConfig.routeGenIfEmpty === 'boolean' && normalizedConfig.routeGenIfEmpty === true;
const isRouteGenIfEmpty = Boolean(isRouteGenWIthObject || isRouteGenWithBoolean || normalizedConfig.autoSetup);
const fileExtension = determineFileExtension();
validateFileExtension(fileExtension);
consoleP2.complete(`${isTypeScriptProject() ? 'TypeScript' : fileExtension}`);
const startDir = `${normalizedConfig.baseDir}/routes`.replace(/\\/g, '/');
const isTsProject = isTypeScriptProject();
const canAutoSetup = isTsProject ? fileExtension === 'ts' : true;
const shouldAutoSetup = canAutoSetup && isRouteGenIfEmpty;
if (isRouteGenIfEmpty) {
shouldAutoSetup
? consoleP2.complete(`AutoSetup: routeGenIfEmpty is enabled`)
: consoleP2.fail(`AutoSetup is disabled. To enable, ensure that your project is a '${fileExtension}' project and set 'routeGenIfEmpty' to true or provide a code snippet.`);
}
consoleP.start('Route processing');
const router = normalizedConfig.app || express.Router();
const fileList = FileReader.readFiles(startDir, fileExtension);
if (fileList.length) {
processRoutes(fileList, startDir, router, shouldAutoSetup, fileExtension, { codeSnippet, codeSnippetFn });
}
consoleP.complete('Route processing complete');
console.timeEnd('✓ Ready in');
if (!normalizedConfig.app) {
return router;
}
}
function validateFileExtension(ext) {
if (!['ts', 'js', 'mjs', 'mts'].includes(ext)) {
throw new Error('Invalid file extension detected');
}
}
function processRoutes(fileList, startDir, router, autoSetup, lang, { codeSnippet, codeSnippetFn }) {
const routeDefinitions = fileList.map(filePath => {
const { route: expressRoutePath } = createRoutePath({ name: filePath, startDir }, lang);
const exportedHandlers = require(filePath);
const supportedMethods = filterAndLowercaseHttpMethods(Object.keys(exportedHandlers));
const methodHandlers = supportedMethods.map(httpMethod => {
return {
method: httpMethod,
handler: exportedHandlers[httpMethod.toUpperCase()]
};
});
return {
route: expressRoutePath,
supportedMethods,
filePath,
methodHandlers
};
});
routeDefinitions.forEach(({ filePath, methodHandlers, route }) => {
if (autoSetup) {
writeToFileSyncStartupCode(startDir, filePath, { codeSnippet, codeSnippetFn });
}
methodHandlers.forEach(({ method, handler }) => {
// check validity of handler
// handler can be a function or an array of functions (for middleware)
let isValidHandler = false;
let spacial_case = '';
if (typeof handler === 'function') {
isValidHandler = true;
}
else if (typeof handler === 'object' && Array.isArray(handler)) {
// if it's an array, and all elements are functions, then it's valid else store what item/index of element is invalid in spacial_case
const allFunctions = handler.every((h, index) => {
if (typeof h !== 'function') {
spacial_case = `Element at index ${index} is not a function`;
return false;
}
return true;
});
if (allFunctions) {
isValidHandler = true;
}
}
if (!isValidHandler) {
// consoleP.fail(`Handler for ${method.toUpperCase()} ${route} in ${filePath} is not a valid method handler. It should be a function or an array of functions.`);
consoleP.fail(`Invalid handler for ${method.toUpperCase()} ${route} in ${filePath}. ${spacial_case ? `Issue: ${spacial_case}` : 'Handler should be a function or an array of functions.'}`);
return;
}
router[method](route, handler);
});
});
}
const rg = Object.assign(routes, {
routes
});
export { rg as default, routes };