diff --git a/index.js b/index.js index e04659a..38a4d23 100644 --- a/index.js +++ b/index.js @@ -33,7 +33,10 @@ async function askStackQuestions() { { name: chalk.bold.cyan("MEVN") + " → MongoDB + Express + Vue.js + Node.js", value: "mevn" }, { name: chalk.bold.yellow("MEVN") + " + Tailwind + Auth", value: "mevn+tailwind+auth" }, { name: chalk.bold.yellow("Next.js") + " + tRPC + Prisma + Tailwind + Auth", value: "t3-stack" }, - { name: chalk.bold.red("Hono") + " → Hono + Prisma + React", value: "hono" } + { name: chalk.bold.red("Hono") + " → Hono + Prisma + React", value: "hono" }, + { name: chalk.bold.blue("Express TS Pro") + " → Production-ready TypeScript Express backend", value: "express-ts-pro" } + ,{ name: chalk.bold.cyan("Next.js + Express") + " → Separate backend with Express.js", value: "next-express" } + ,{ name: chalk.bold.green("MERN Turborepo") + " → Monorepo with Turbo (apps/client, apps/server)", value: "mern-turbo" } ], pageSize: 10, diff --git a/templates/express-ts-pro/server/README.md b/templates/express-ts-pro/server/README.md new file mode 100644 index 0000000..a5d4a1f --- /dev/null +++ b/templates/express-ts-pro/server/README.md @@ -0,0 +1,35 @@ +Express TS Pro (Celtrix) + +Production-ready TypeScript + Express backend template with: + +- Logging (Winston) with JSON logs and request IDs +- Security: Helmet, CORS, rate limiting +- Role-based auth (JWT-based; roles via claims) +- Basic notification service (email via Nodemailer) +- File utilities: read XLSX and PDF +- Clean project structure and error handling + +Quick Start + +``` +cp .env.example .env +npm install +npm run dev +``` + +Health check: GET `/health` + +Scripts +- npm run dev: Run with ts-node + nodemon +- npm run build: Type-check and transpile to dist +- npm start: Run compiled app + +Env +- PORT (default 4000) +- JWT_SECRET (required for auth) +- SMTP vars for notifications (optional) + +Notes +- Upload endpoint: POST `/files/parse` (field: `file`) parses XLSX or PDF. +- Protected example: GET `/admin/stats` requires role `admin`. + diff --git a/templates/express-ts-pro/server/package.json b/templates/express-ts-pro/server/package.json new file mode 100644 index 0000000..94dbfbd --- /dev/null +++ b/templates/express-ts-pro/server/package.json @@ -0,0 +1,38 @@ +{ + "name": "express-ts-pro-server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "nodemon --watch src --ext ts --exec ts-node src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "nodemailer": "^6.9.14", + "pdf-parse": "^1.1.1", + "socket.io": "^4.8.1", + "useragent": "^2.3.0", + "uuid": "^9.0.1", + "winston": "^3.13.0", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", + "@types/multer": "^1.4.12", + "@types/node": "^22.7.4", + "nodemon": "^3.1.7", + "ts-node": "^10.9.2", + "typescript": "^5.6.3" + } +} + diff --git a/templates/express-ts-pro/server/src/app.ts b/templates/express-ts-pro/server/src/app.ts new file mode 100644 index 0000000..c982ceb --- /dev/null +++ b/templates/express-ts-pro/server/src/app.ts @@ -0,0 +1,28 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import { rateLimiter } from './setup/rateLimiter'; +import { requestLogger } from './setup/requestLogger'; +import { errorHandler, notFoundHandler } from './setup/errorHandlers'; +import { router as apiRouter } from './setup/routes'; + +export function createApp() { + const app = express(); + + app.use(helmet()); + app.use(cors()); + app.use(express.json({ limit: '2mb' })); + app.use(requestLogger); + app.use(rateLimiter); + + app.get('/health', (_req, res) => res.json({ status: 'ok' })); + + app.use('/api/v1', apiRouter); + + app.use(notFoundHandler); + app.use(errorHandler); + + return app; +} + + diff --git a/templates/express-ts-pro/server/src/server.ts b/templates/express-ts-pro/server/src/server.ts new file mode 100644 index 0000000..f70cf8f --- /dev/null +++ b/templates/express-ts-pro/server/src/server.ts @@ -0,0 +1,27 @@ +import 'dotenv/config'; +import { createApp } from './app'; +import { logger } from './setup/logger'; +import { createServer } from 'http'; +import { Server } from 'socket.io'; +import { MonitoringHub } from './telemetry/monitoringHub'; + +const app = createApp(); +const httpServer = createServer(app); +const io = new Server(httpServer, { cors: { origin: '*' } }); +MonitoringHub.attach(io); + +const port = Number(process.env.PORT) || 4000; +const server = httpServer.listen(port, () => { + logger.info('server_started', { port, env: process.env.NODE_ENV || 'development' }); +}); + +process.on('unhandledRejection', (reason: any) => { + logger.error('unhandled_rejection', { reason: reason?.message || String(reason) }); +}); + +process.on('uncaughtException', (err) => { + logger.error('uncaught_exception', { message: err.message, stack: err.stack }); + server.close(() => process.exit(1)); +}); + + diff --git a/templates/express-ts-pro/server/src/setup/errorHandlers.ts b/templates/express-ts-pro/server/src/setup/errorHandlers.ts new file mode 100644 index 0000000..5ae3996 --- /dev/null +++ b/templates/express-ts-pro/server/src/setup/errorHandlers.ts @@ -0,0 +1,14 @@ +import { Request, Response, NextFunction } from 'express'; +import { logger } from './logger'; + +export function notFoundHandler(_req: Request, res: Response) { + res.status(404).json({ error: 'Not found' }); +} + +export function errorHandler(err: any, _req: Request, res: Response, _next: NextFunction) { + logger.error('unhandled_error', { message: err?.message, stack: err?.stack }); + const status = typeof err?.status === 'number' ? err.status : 500; + res.status(status).json({ error: err?.message || 'Internal Server Error' }); +} + + diff --git a/templates/express-ts-pro/server/src/setup/index.ts b/templates/express-ts-pro/server/src/setup/index.ts new file mode 100644 index 0000000..fa592ec --- /dev/null +++ b/templates/express-ts-pro/server/src/setup/index.ts @@ -0,0 +1,7 @@ +export * from './errorHandlers'; +export * from './logger'; +export * from './rateLimiter'; +export * from './requestLogger'; +export * from './routes'; + + diff --git a/templates/express-ts-pro/server/src/setup/logger.ts b/templates/express-ts-pro/server/src/setup/logger.ts new file mode 100644 index 0000000..ba5b8fe --- /dev/null +++ b/templates/express-ts-pro/server/src/setup/logger.ts @@ -0,0 +1,37 @@ +import { createLogger, format, transports } from 'winston'; +import path from 'path'; +import fs from 'fs'; +import { v4 as uuid } from 'uuid'; +import { Request, Response, NextFunction } from 'express'; + +const logDir = path.join(process.cwd(), 'src', 'log'); +if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true }); + +export const logger = createLogger({ + level: 'info', + format: format.combine( + format.timestamp(), + format.errors({ stack: true }), + format.json(), + ), + transports: [ + new transports.Console({ + format: format.combine( + format.colorize(), + format.printf(({ level, message, timestamp, ...meta }) => { + return `${timestamp} ${level}: ${message} ${Object.keys(meta).length ? JSON.stringify(meta) : ''}`; + }) + ) + }), + new transports.File({ filename: path.join(logDir, 'error.log'), level: 'error' }), + new transports.File({ filename: path.join(logDir, 'warning.log'), level: 'warn' }), + new transports.File({ filename: path.join(logDir, 'success.log'), level: 'info' }) + ], +}); + +export function withRequestContext(req: Request, _res: Response, next: NextFunction) { + (req as any).requestId = (req.headers['x-request-id'] as string) || uuid(); + next(); +} + + diff --git a/templates/express-ts-pro/server/src/setup/rateLimiter.ts b/templates/express-ts-pro/server/src/setup/rateLimiter.ts new file mode 100644 index 0000000..1b3b9b1 --- /dev/null +++ b/templates/express-ts-pro/server/src/setup/rateLimiter.ts @@ -0,0 +1,10 @@ +import rateLimit from 'express-rate-limit'; + +export const rateLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 100, + standardHeaders: true, + legacyHeaders: false, +}); + + diff --git a/templates/express-ts-pro/server/src/setup/requestLogger.ts b/templates/express-ts-pro/server/src/setup/requestLogger.ts new file mode 100644 index 0000000..df61f21 --- /dev/null +++ b/templates/express-ts-pro/server/src/setup/requestLogger.ts @@ -0,0 +1,30 @@ +import { Request, Response, NextFunction } from 'express'; +import { logger } from './logger'; +import useragent from 'useragent'; +import { MonitoringHub } from '../telemetry/monitoringHub'; + +export function requestLogger(req: Request, res: Response, next: NextFunction) { + const start = Date.now(); + res.on('finish', () => { + const durationMs = Date.now() - start; + const agent = useragent.parse(req.headers['user-agent'] || ''); + const payload = { + method: req.method, + path: req.originalUrl, + status: res.statusCode, + durationMs, + ip: req.ip, + ua: { + family: agent.family, + os: agent.os?.toString(), + device: agent.device?.toString() + }, + timestamp: new Date().toISOString() + }; + logger.info('http_request', payload); + MonitoringHub.emitEvent('http_request', payload); + }); + next(); +} + + diff --git a/templates/express-ts-pro/server/src/setup/routes.ts b/templates/express-ts-pro/server/src/setup/routes.ts new file mode 100644 index 0000000..1b1f8ef --- /dev/null +++ b/templates/express-ts-pro/server/src/setup/routes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { authenticate, requireRoles } from '../system/auth'; +import { router as filesRouter } from '../system/files'; +import { router as notifyRouter } from '../system/notify'; +import { dashboardRouter } from '../telemetry/dashboard'; + +export const router = Router(); + +router.use('/files', authenticate, filesRouter); +router.use('/notify', authenticate, notifyRouter); +router.use('/monitor', dashboardRouter); + +router.get('/admin/stats', authenticate, requireRoles('admin'), (_req, res) => { + res.json({ users: 0, uptime: process.uptime() }); +}); + + diff --git a/templates/express-ts-pro/server/src/system/auth.ts b/templates/express-ts-pro/server/src/system/auth.ts new file mode 100644 index 0000000..68dc2e6 --- /dev/null +++ b/templates/express-ts-pro/server/src/system/auth.ts @@ -0,0 +1,40 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +export type JwtUser = { + sub: string; + roles?: string[]; +}; + +declare global { + namespace Express { + interface Request { + user?: JwtUser; + } + } +} + +export function authenticate(req: Request, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization || ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : undefined; + if (!token) return res.status(401).json({ error: 'Missing token' }); + try { + const secret = process.env.JWT_SECRET || 'dev_secret'; + const decoded = jwt.verify(token, secret) as JwtUser; + req.user = decoded; + next(); + } catch { + return res.status(401).json({ error: 'Invalid token' }); + } +} + +export function requireRoles(...roles: string[]) { + return (req: Request, res: Response, next: NextFunction) => { + const userRoles = req.user?.roles || []; + const ok = roles.every((r) => userRoles.includes(r)); + if (!ok) return res.status(403).json({ error: 'Forbidden' }); + next(); + }; +} + + diff --git a/templates/express-ts-pro/server/src/system/files.ts b/templates/express-ts-pro/server/src/system/files.ts new file mode 100644 index 0000000..e50009a --- /dev/null +++ b/templates/express-ts-pro/server/src/system/files.ts @@ -0,0 +1,52 @@ +import { Router } from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import xlsx from 'xlsx'; +import pdfParse from 'pdf-parse'; + +export const router = Router(); + +const diskStorage = multer.diskStorage({ + destination: (_req, file, cb) => { + const mime = file.mimetype; + let folder = 'documents'; + if (mime.startsWith('image/')) folder = 'images'; + else if (mime.startsWith('video/')) folder = 'videos'; + const uploadDir = path.join(process.cwd(), 'uploads', folder); + fs.mkdirSync(uploadDir, { recursive: true }); + cb(null, uploadDir); + }, + filename: (_req, file, cb) => { + const unique = Date.now() + '-' + Math.round(Math.random() * 1e9); + cb(null, unique + path.extname(file.originalname)); + } +}); + +const upload = multer({ storage: diskStorage, limits: { fileSize: 10 * 1024 * 1024 } }); + +router.post('/parse', upload.single('file'), async (req, res, next) => { + try { + if (!req.file) return res.status(400).json({ error: 'file is required' }); + const mime = req.file.mimetype; + if (mime === 'application/pdf') { + const data = await pdfParse(req.file.buffer); + return res.json({ type: 'pdf', text: data.text.slice(0, 5000) }); + } + if ( + mime === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || + mime === 'application/vnd.ms-excel' || + mime === 'application/octet-stream' + ) { + const wb = xlsx.readFile(req.file.path); + const first = wb.SheetNames[0]; + const rows = xlsx.utils.sheet_to_json(wb.Sheets[first], { header: 1 }); + return res.json({ type: 'xlsx', rows }); + } + return res.status(415).json({ error: 'Unsupported file type' }); + } catch (e) { + next(e); + } +}); + + diff --git a/templates/express-ts-pro/server/src/system/notify.ts b/templates/express-ts-pro/server/src/system/notify.ts new file mode 100644 index 0000000..d435bf9 --- /dev/null +++ b/templates/express-ts-pro/server/src/system/notify.ts @@ -0,0 +1,56 @@ +import { Router } from 'express'; +import nodemailer from 'nodemailer'; +// Stubs for future channels (Twilio, Telegram, Slack, Discord, Meta) +// Implement actual clients and secrets via env vars when available. + +export const router = Router(); + +const transporter = nodemailer.createTransport({ + host: process.env.NOTIFY_SMTP_HOST, + port: Number(process.env.NOTIFY_SMTP_PORT || 587), + secure: false, + auth: process.env.NOTIFY_SMTP_USER + ? { user: process.env.NOTIFY_SMTP_USER, pass: process.env.NOTIFY_SMTP_PASS } + : undefined, +}); + +router.post('/email', async (req, res, next) => { + try { + const { to, subject, text, html } = req.body || {}; + if (!to || !subject || (!text && !html)) { + return res.status(400).json({ error: 'to, subject, and text|html are required' }); + } + const from = process.env.NOTIFY_FROM || 'no-reply@example.com'; + const info = await transporter.sendMail({ from, to, subject, text, html }); + res.json({ messageId: info.messageId }); + } catch (e) { + next(e); + } +}); + +router.post('/sms', async (_req, res) => { + // TODO: Integrate Twilio + res.json({ status: 'queued', channel: 'sms' }); +}); + +router.post('/whatsapp', async (_req, res) => { + // TODO: Integrate Twilio WhatsApp + res.json({ status: 'queued', channel: 'whatsapp' }); +}); + +router.post('/telegram', async (_req, res) => { + // TODO: Integrate Telegram bot + res.json({ status: 'queued', channel: 'telegram' }); +}); + +router.post('/slack', async (_req, res) => { + // TODO: Integrate Slack webhook + res.json({ status: 'queued', channel: 'slack' }); +}); + +router.post('/discord', async (_req, res) => { + // TODO: Integrate Discord webhook + res.json({ status: 'queued', channel: 'discord' }); +}); + + diff --git a/templates/express-ts-pro/server/src/telemetry/dashboard.ts b/templates/express-ts-pro/server/src/telemetry/dashboard.ts new file mode 100644 index 0000000..db6b302 --- /dev/null +++ b/templates/express-ts-pro/server/src/telemetry/dashboard.ts @@ -0,0 +1,49 @@ +import { Router } from 'express'; +import { authenticate, requireRoles } from '../system/auth'; +import { MonitoringHub } from './monitoringHub'; + +export const dashboardRouter = Router(); + +dashboardRouter.get('/events', authenticate, requireRoles('admin'), (_req, res) => { + res.json({ events: MonitoringHub.getRecent() }); +}); + +dashboardRouter.get('/', authenticate, requireRoles('admin'), (_req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.send(` + +
+ +