@yoms/create-monorepo 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +204 -0
- package/dist/index.js +649 -0
- package/package.json +74 -0
- package/templates/backend-hono/base/.env.example +7 -0
- package/templates/backend-hono/base/Dockerfile +55 -0
- package/templates/backend-hono/base/package.json +30 -0
- package/templates/backend-hono/base/src/config/env.ts +12 -0
- package/templates/backend-hono/base/src/config/logger.ts +52 -0
- package/templates/backend-hono/base/src/index.ts +49 -0
- package/templates/backend-hono/base/src/lib/__tests__/response.test.ts +66 -0
- package/templates/backend-hono/base/src/lib/errors.ts +87 -0
- package/templates/backend-hono/base/src/lib/response.ts +72 -0
- package/templates/backend-hono/base/src/middleware/__tests__/error.test.ts +86 -0
- package/templates/backend-hono/base/src/middleware/cors.middleware.ts +9 -0
- package/templates/backend-hono/base/src/middleware/error.middleware.ts +43 -0
- package/templates/backend-hono/base/src/middleware/logger.middleware.ts +14 -0
- package/templates/backend-hono/base/src/middleware/rate-limit.middleware.ts +135 -0
- package/templates/backend-hono/base/src/routes/__tests__/health.test.ts +36 -0
- package/templates/backend-hono/base/src/routes/health.route.ts +14 -0
- package/templates/backend-hono/base/src/types/app.types.ts +26 -0
- package/templates/backend-hono/base/tsconfig.json +10 -0
- package/templates/backend-hono/base/vitest.config.ts +19 -0
- package/templates/backend-hono/features/mongodb-prisma/env-additions.txt +2 -0
- package/templates/backend-hono/features/mongodb-prisma/package-additions.json +14 -0
- package/templates/backend-hono/features/mongodb-prisma/prisma/schema.prisma +18 -0
- package/templates/backend-hono/features/mongodb-prisma/src/config/database.ts +43 -0
- package/templates/backend-hono/features/mongodb-prisma/src/routes/users.route.ts +82 -0
- package/templates/backend-hono/features/mongodb-prisma/src/services/user.service.ts +121 -0
- package/templates/backend-hono/features/postgres-prisma/env-additions.txt +2 -0
- package/templates/backend-hono/features/postgres-prisma/package-additions.json +15 -0
- package/templates/backend-hono/features/postgres-prisma/prisma/schema.prisma +18 -0
- package/templates/backend-hono/features/postgres-prisma/src/config/database.ts +43 -0
- package/templates/backend-hono/features/postgres-prisma/src/routes/users.route.ts +82 -0
- package/templates/backend-hono/features/postgres-prisma/src/services/user.service.ts +121 -0
- package/templates/backend-hono/features/redis/env-additions.txt +2 -0
- package/templates/backend-hono/features/redis/package-additions.json +8 -0
- package/templates/backend-hono/features/redis/src/config/redis.ts +32 -0
- package/templates/backend-hono/features/redis/src/services/cache.service.ts +107 -0
- package/templates/backend-hono/features/smtp/env-additions.txt +7 -0
- package/templates/backend-hono/features/smtp/package-additions.json +8 -0
- package/templates/backend-hono/features/smtp/src/config/mail.ts +38 -0
- package/templates/backend-hono/features/smtp/src/services/email.service.ts +78 -0
- package/templates/backend-hono/features/swagger/package-additions.json +6 -0
- package/templates/backend-hono/features/swagger/src/lib/openapi.ts +19 -0
- package/templates/backend-hono/features/swagger/src/routes/docs.route.ts +18 -0
- package/templates/frontend-nextjs/base/.env.example +2 -0
- package/templates/frontend-nextjs/base/app/globals.css +59 -0
- package/templates/frontend-nextjs/base/app/layout.tsx +22 -0
- package/templates/frontend-nextjs/base/app/page.tsx +49 -0
- package/templates/frontend-nextjs/base/components.json +18 -0
- package/templates/frontend-nextjs/base/lib/api-client.ts +67 -0
- package/templates/frontend-nextjs/base/lib/utils.ts +6 -0
- package/templates/frontend-nextjs/base/next.config.ts +8 -0
- package/templates/frontend-nextjs/base/package.json +33 -0
- package/templates/frontend-nextjs/base/postcss.config.mjs +9 -0
- package/templates/frontend-nextjs/base/public/.gitkeep +1 -0
- package/templates/frontend-nextjs/base/tailwind.config.ts +58 -0
- package/templates/frontend-nextjs/base/tsconfig.json +19 -0
- package/templates/shared/base/package.json +19 -0
- package/templates/shared/base/src/index.ts +5 -0
- package/templates/shared/base/src/schemas/user.schema.ts +34 -0
- package/templates/shared/base/src/types.ts +46 -0
- package/templates/shared/base/tsconfig.json +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yoms/create-monorepo",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "CLI tool to scaffold monorepo projects from templates",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-monorepo": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "tsx src/index.ts",
|
|
11
|
+
"build": "tsup src/index.ts --format esm --clean",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"lint": "eslint src --ext .ts",
|
|
14
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
15
|
+
"test:generate": "tsx scripts/test-generate.ts",
|
|
16
|
+
"test:validate": "tsx scripts/validate-templates.ts",
|
|
17
|
+
"changeset": "changeset",
|
|
18
|
+
"version": "changeset version",
|
|
19
|
+
"release": "pnpm build && changeset publish",
|
|
20
|
+
"prepublishOnly": "pnpm build && pnpm typecheck"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"monorepo",
|
|
24
|
+
"template",
|
|
25
|
+
"scaffold",
|
|
26
|
+
"cli",
|
|
27
|
+
"pnpm",
|
|
28
|
+
"workspace",
|
|
29
|
+
"typescript",
|
|
30
|
+
"hono",
|
|
31
|
+
"nextjs",
|
|
32
|
+
"prisma",
|
|
33
|
+
"generator",
|
|
34
|
+
"boilerplate",
|
|
35
|
+
"starter"
|
|
36
|
+
],
|
|
37
|
+
"author": "",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/yoms07/monorepo-template.git"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/yoms07/monorepo-template/issues"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/yoms07/monorepo-template#readme",
|
|
47
|
+
"files": [
|
|
48
|
+
"dist",
|
|
49
|
+
"templates",
|
|
50
|
+
"README.md",
|
|
51
|
+
"LICENSE"
|
|
52
|
+
],
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"cac": "^6.7.14",
|
|
55
|
+
"chalk": "^5.3.0",
|
|
56
|
+
"enquirer": "^2.4.1",
|
|
57
|
+
"execa": "^9.5.2",
|
|
58
|
+
"fs-extra": "^11.2.0",
|
|
59
|
+
"ora": "^8.1.1",
|
|
60
|
+
"picocolors": "^1.1.1"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@changesets/cli": "^2.27.10",
|
|
64
|
+
"@types/fs-extra": "^11.0.4",
|
|
65
|
+
"@types/node": "^22.10.5",
|
|
66
|
+
"prettier": "^3.4.2",
|
|
67
|
+
"tsup": "^8.3.5",
|
|
68
|
+
"tsx": "^4.19.2",
|
|
69
|
+
"typescript": "^5.7.2"
|
|
70
|
+
},
|
|
71
|
+
"engines": {
|
|
72
|
+
"node": ">=18.0.0"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
FROM node:20-alpine AS base
|
|
2
|
+
|
|
3
|
+
# Install pnpm
|
|
4
|
+
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
5
|
+
|
|
6
|
+
# Build stage
|
|
7
|
+
FROM base AS build
|
|
8
|
+
|
|
9
|
+
WORKDIR /app
|
|
10
|
+
|
|
11
|
+
# Copy workspace configuration
|
|
12
|
+
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
|
13
|
+
|
|
14
|
+
# Copy package.json files for all workspaces
|
|
15
|
+
COPY packages/api/package.json ./packages/api/
|
|
16
|
+
COPY packages/shared/package.json ./packages/shared/
|
|
17
|
+
|
|
18
|
+
# Install dependencies
|
|
19
|
+
RUN pnpm install --frozen-lockfile
|
|
20
|
+
|
|
21
|
+
# Copy source code
|
|
22
|
+
COPY packages/api ./packages/api
|
|
23
|
+
COPY packages/shared ./packages/shared
|
|
24
|
+
|
|
25
|
+
# Build shared package first, then api
|
|
26
|
+
RUN pnpm --filter __PACKAGE_SCOPE__/shared build && \
|
|
27
|
+
pnpm --filter __PACKAGE_SCOPE__/api build
|
|
28
|
+
|
|
29
|
+
# Production stage
|
|
30
|
+
FROM base AS production
|
|
31
|
+
|
|
32
|
+
WORKDIR /app
|
|
33
|
+
|
|
34
|
+
ENV NODE_ENV=production
|
|
35
|
+
|
|
36
|
+
# Copy workspace configuration
|
|
37
|
+
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
|
38
|
+
COPY packages/api/package.json ./packages/api/
|
|
39
|
+
COPY packages/shared/package.json ./packages/shared/
|
|
40
|
+
|
|
41
|
+
# Install production dependencies only
|
|
42
|
+
RUN pnpm install --prod --frozen-lockfile
|
|
43
|
+
|
|
44
|
+
# Copy built files from build stage
|
|
45
|
+
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
|
|
46
|
+
COPY --from=build /app/packages/api/dist ./packages/api/dist
|
|
47
|
+
|
|
48
|
+
# Set working directory to api package
|
|
49
|
+
WORKDIR /app/packages/api
|
|
50
|
+
|
|
51
|
+
# Expose port
|
|
52
|
+
EXPOSE __API_PORT__
|
|
53
|
+
|
|
54
|
+
# Start
|
|
55
|
+
CMD ["node", "dist/index.js"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PACKAGE_SCOPE__/api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx watch src/index.ts",
|
|
8
|
+
"build": "tsup src/index.ts --format esm --clean",
|
|
9
|
+
"start": "node dist/index.js",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:watch": "vitest",
|
|
12
|
+
"test:coverage": "vitest run --coverage",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"lint": "eslint src --ext .ts",
|
|
15
|
+
"format": "prettier --write \"src/**/*.ts\""
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"hono": "^4.6.14",
|
|
19
|
+
"zod": "^3.24.1",
|
|
20
|
+
"winston": "^3.17.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.10.5",
|
|
24
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
25
|
+
"tsx": "^4.19.2",
|
|
26
|
+
"tsup": "^8.3.5",
|
|
27
|
+
"typescript": "^5.7.2",
|
|
28
|
+
"vitest": "^2.1.8"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const envSchema = z.object({
|
|
4
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
5
|
+
PORT: z.coerce.number().default(__API_PORT__),
|
|
6
|
+
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
|
7
|
+
CORS_ORIGIN: z.string().default('http://localhost:__WEB_PORT__'),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const env = envSchema.parse(process.env);
|
|
11
|
+
|
|
12
|
+
export type Env = z.infer<typeof envSchema>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import winston from 'winston';
|
|
2
|
+
import { env } from './env.js';
|
|
3
|
+
|
|
4
|
+
const { combine, timestamp, printf, colorize, errors } = winston.format;
|
|
5
|
+
|
|
6
|
+
// Custom format for development
|
|
7
|
+
const devFormat = printf(({ level, message, timestamp, stack, ...metadata }) => {
|
|
8
|
+
let msg = `${timestamp} [${level}]: ${message}`;
|
|
9
|
+
|
|
10
|
+
// Add metadata if present
|
|
11
|
+
const metaStr = Object.keys(metadata).length > 0
|
|
12
|
+
? `\n${JSON.stringify(metadata, null, 2)}`
|
|
13
|
+
: '';
|
|
14
|
+
|
|
15
|
+
// Add stack trace if error
|
|
16
|
+
const stackStr = stack ? `\n${stack}` : '';
|
|
17
|
+
|
|
18
|
+
return msg + metaStr + stackStr;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Custom format for production (JSON)
|
|
22
|
+
const prodFormat = combine(
|
|
23
|
+
errors({ stack: true }),
|
|
24
|
+
timestamp(),
|
|
25
|
+
winston.format.json()
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const logger = winston.createLogger({
|
|
29
|
+
level: env.LOG_LEVEL,
|
|
30
|
+
format: env.NODE_ENV === 'production' ? prodFormat : combine(
|
|
31
|
+
colorize(),
|
|
32
|
+
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
33
|
+
errors({ stack: true }),
|
|
34
|
+
devFormat
|
|
35
|
+
),
|
|
36
|
+
transports: [
|
|
37
|
+
new winston.transports.Console(),
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Add file transport in production
|
|
42
|
+
if (env.NODE_ENV === 'production') {
|
|
43
|
+
logger.add(new winston.transports.File({
|
|
44
|
+
filename: 'logs/error.log',
|
|
45
|
+
level: 'error'
|
|
46
|
+
}));
|
|
47
|
+
logger.add(new winston.transports.File({
|
|
48
|
+
filename: 'logs/combined.log'
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { logger };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { env } from './config/env.js';
|
|
3
|
+
import { logger } from './config/logger.js';
|
|
4
|
+
import { corsMiddleware } from './middleware/cors.middleware.js';
|
|
5
|
+
import { loggerMiddleware } from './middleware/logger.middleware.js';
|
|
6
|
+
import { errorHandler } from './middleware/error.middleware.js';
|
|
7
|
+
import { health } from './routes/health.route.js';
|
|
8
|
+
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
|
|
11
|
+
// Global middleware
|
|
12
|
+
app.use('*', corsMiddleware);
|
|
13
|
+
app.use('*', loggerMiddleware);
|
|
14
|
+
|
|
15
|
+
// Routes
|
|
16
|
+
app.route('/health', health);
|
|
17
|
+
|
|
18
|
+
// Root endpoint
|
|
19
|
+
app.get('/', (c) => {
|
|
20
|
+
return c.json({
|
|
21
|
+
success: true,
|
|
22
|
+
message: 'Welcome to __PROJECT_NAME__ API',
|
|
23
|
+
version: '0.1.0',
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Error handling
|
|
28
|
+
app.onError(errorHandler);
|
|
29
|
+
|
|
30
|
+
// 404 handler
|
|
31
|
+
app.notFound((c) => {
|
|
32
|
+
return c.json({
|
|
33
|
+
success: false,
|
|
34
|
+
error: 'Not found',
|
|
35
|
+
message: `Route ${c.req.method} ${c.req.url} not found`,
|
|
36
|
+
}, 404);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Start server
|
|
40
|
+
const port = env.PORT;
|
|
41
|
+
|
|
42
|
+
logger.info(`Starting server in ${env.NODE_ENV} mode...`);
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
port,
|
|
46
|
+
fetch: app.fetch,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
console.log(`Server running on http://localhost:${port}`);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { success, created, noContent, paginated, error } from '../response.js';
|
|
4
|
+
|
|
5
|
+
describe('Response Helpers', () => {
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
|
|
8
|
+
app.get('/success', (c) => success(c, { message: 'OK' }));
|
|
9
|
+
app.post('/created', (c) => created(c, { id: '123' }));
|
|
10
|
+
app.delete('/no-content', (c) => noContent(c));
|
|
11
|
+
app.get('/paginated', (c) =>
|
|
12
|
+
paginated(c, [{ id: 1 }, { id: 2 }], 1, 10, 25)
|
|
13
|
+
);
|
|
14
|
+
app.get('/error', (c) => error(c, 'Something failed', 400, 'BAD_REQUEST'));
|
|
15
|
+
|
|
16
|
+
it('should return success response with 200', async () => {
|
|
17
|
+
const res = await app.request('/success');
|
|
18
|
+
expect(res.status).toBe(200);
|
|
19
|
+
|
|
20
|
+
const json = await res.json();
|
|
21
|
+
expect(json.success).toBe(true);
|
|
22
|
+
expect(json.data).toEqual({ message: 'OK' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should return created response with 201', async () => {
|
|
26
|
+
const res = await app.request('/created', { method: 'POST' });
|
|
27
|
+
expect(res.status).toBe(201);
|
|
28
|
+
|
|
29
|
+
const json = await res.json();
|
|
30
|
+
expect(json.success).toBe(true);
|
|
31
|
+
expect(json.data).toEqual({ id: '123' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should return no content with 204', async () => {
|
|
35
|
+
const res = await app.request('/no-content', { method: 'DELETE' });
|
|
36
|
+
expect(res.status).toBe(204);
|
|
37
|
+
expect(res.body).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should return paginated response', async () => {
|
|
41
|
+
const res = await app.request('/paginated');
|
|
42
|
+
expect(res.status).toBe(200);
|
|
43
|
+
|
|
44
|
+
const json = await res.json();
|
|
45
|
+
expect(json.success).toBe(true);
|
|
46
|
+
expect(json.data).toHaveLength(2);
|
|
47
|
+
expect(json.pagination).toEqual({
|
|
48
|
+
page: 1,
|
|
49
|
+
limit: 10,
|
|
50
|
+
total: 25,
|
|
51
|
+
totalPages: 3,
|
|
52
|
+
hasNext: true,
|
|
53
|
+
hasPrev: false,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should return error response', async () => {
|
|
58
|
+
const res = await app.request('/error');
|
|
59
|
+
expect(res.status).toBe(400);
|
|
60
|
+
|
|
61
|
+
const json = await res.json();
|
|
62
|
+
expect(json.success).toBe(false);
|
|
63
|
+
expect(json.error).toBe('Something failed');
|
|
64
|
+
expect(json.code).toBe('BAD_REQUEST');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base application error class
|
|
3
|
+
*/
|
|
4
|
+
export class AppError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
message: string,
|
|
7
|
+
public statusCode: number = 500,
|
|
8
|
+
public code?: string,
|
|
9
|
+
public details?: unknown
|
|
10
|
+
) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = this.constructor.name;
|
|
13
|
+
Error.captureStackTrace(this, this.constructor);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 400 Bad Request
|
|
19
|
+
*/
|
|
20
|
+
export class BadRequestError extends AppError {
|
|
21
|
+
constructor(message: string = 'Bad request', details?: unknown) {
|
|
22
|
+
super(message, 400, 'BAD_REQUEST', details);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 401 Unauthorized
|
|
28
|
+
*/
|
|
29
|
+
export class UnauthorizedError extends AppError {
|
|
30
|
+
constructor(message: string = 'Unauthorized', details?: unknown) {
|
|
31
|
+
super(message, 401, 'UNAUTHORIZED', details);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 403 Forbidden
|
|
37
|
+
*/
|
|
38
|
+
export class ForbiddenError extends AppError {
|
|
39
|
+
constructor(message: string = 'Forbidden', details?: unknown) {
|
|
40
|
+
super(message, 403, 'FORBIDDEN', details);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 404 Not Found
|
|
46
|
+
*/
|
|
47
|
+
export class NotFoundError extends AppError {
|
|
48
|
+
constructor(message: string = 'Resource not found', details?: unknown) {
|
|
49
|
+
super(message, 404, 'NOT_FOUND', details);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 409 Conflict
|
|
55
|
+
*/
|
|
56
|
+
export class ConflictError extends AppError {
|
|
57
|
+
constructor(message: string = 'Resource already exists', details?: unknown) {
|
|
58
|
+
super(message, 409, 'CONFLICT', details);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 422 Unprocessable Entity
|
|
64
|
+
*/
|
|
65
|
+
export class ValidationError extends AppError {
|
|
66
|
+
constructor(message: string = 'Validation failed', details?: unknown) {
|
|
67
|
+
super(message, 422, 'VALIDATION_ERROR', details);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 429 Too Many Requests
|
|
73
|
+
*/
|
|
74
|
+
export class TooManyRequestsError extends AppError {
|
|
75
|
+
constructor(message: string = 'Too many requests', details?: unknown) {
|
|
76
|
+
super(message, 429, 'TOO_MANY_REQUESTS', details);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 500 Internal Server Error
|
|
82
|
+
*/
|
|
83
|
+
export class InternalServerError extends AppError {
|
|
84
|
+
constructor(message: string = 'Internal server error', details?: unknown) {
|
|
85
|
+
super(message, 500, 'INTERNAL_SERVER_ERROR', details);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { StatusCode } from 'hono/utils/http-status';
|
|
3
|
+
import type { ApiResponse } from '../types/app.types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Success response helper
|
|
7
|
+
*/
|
|
8
|
+
export function success<T>(c: Context, data: T, statusCode: StatusCode = 200) {
|
|
9
|
+
const response: ApiResponse<T> = {
|
|
10
|
+
success: true,
|
|
11
|
+
data,
|
|
12
|
+
};
|
|
13
|
+
return c.json(response, statusCode);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Error response helper
|
|
18
|
+
*/
|
|
19
|
+
export function error(
|
|
20
|
+
c: Context,
|
|
21
|
+
message: string,
|
|
22
|
+
statusCode: StatusCode = 500,
|
|
23
|
+
code?: string,
|
|
24
|
+
details?: unknown
|
|
25
|
+
) {
|
|
26
|
+
const response: ApiResponse = {
|
|
27
|
+
success: false,
|
|
28
|
+
error: message,
|
|
29
|
+
code,
|
|
30
|
+
details: process.env.NODE_ENV === 'development' ? details : undefined,
|
|
31
|
+
};
|
|
32
|
+
return c.json(response, statusCode);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Created response (201)
|
|
37
|
+
*/
|
|
38
|
+
export function created<T>(c: Context, data: T) {
|
|
39
|
+
return success(c, data, 201);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* No content response (204)
|
|
44
|
+
*/
|
|
45
|
+
export function noContent(c: Context) {
|
|
46
|
+
return c.body(null, 204);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Paginated response helper
|
|
51
|
+
*/
|
|
52
|
+
export function paginated<T>(
|
|
53
|
+
c: Context,
|
|
54
|
+
data: T[],
|
|
55
|
+
page: number,
|
|
56
|
+
limit: number,
|
|
57
|
+
total: number
|
|
58
|
+
) {
|
|
59
|
+
const response = {
|
|
60
|
+
success: true,
|
|
61
|
+
data,
|
|
62
|
+
pagination: {
|
|
63
|
+
page,
|
|
64
|
+
limit,
|
|
65
|
+
total,
|
|
66
|
+
totalPages: Math.ceil(total / limit),
|
|
67
|
+
hasNext: page * limit < total,
|
|
68
|
+
hasPrev: page > 1,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
return c.json(response);
|
|
72
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { errorHandler } from '../error.middleware.js';
|
|
4
|
+
import {
|
|
5
|
+
BadRequestError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
UnauthorizedError,
|
|
8
|
+
InternalServerError,
|
|
9
|
+
} from '../../lib/errors.js';
|
|
10
|
+
|
|
11
|
+
describe('Error Middleware', () => {
|
|
12
|
+
const app = new Hono();
|
|
13
|
+
|
|
14
|
+
// Test route that throws errors
|
|
15
|
+
app.get('/bad-request', () => {
|
|
16
|
+
throw new BadRequestError('Invalid input');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
app.get('/not-found', () => {
|
|
20
|
+
throw new NotFoundError('Resource not found');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
app.get('/unauthorized', () => {
|
|
24
|
+
throw new UnauthorizedError('Not authenticated');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
app.get('/server-error', () => {
|
|
28
|
+
throw new InternalServerError('Something went wrong');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
app.get('/unknown-error', () => {
|
|
32
|
+
throw new Error('Unknown error');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
app.onError(errorHandler);
|
|
36
|
+
|
|
37
|
+
it('should handle BadRequestError with 400 status', async () => {
|
|
38
|
+
const res = await app.request('/bad-request');
|
|
39
|
+
expect(res.status).toBe(400);
|
|
40
|
+
|
|
41
|
+
const json = await res.json();
|
|
42
|
+
expect(json.success).toBe(false);
|
|
43
|
+
expect(json.error).toBe('Invalid input');
|
|
44
|
+
expect(json.code).toBe('BAD_REQUEST');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should handle NotFoundError with 404 status', async () => {
|
|
48
|
+
const res = await app.request('/not-found');
|
|
49
|
+
expect(res.status).toBe(404);
|
|
50
|
+
|
|
51
|
+
const json = await res.json();
|
|
52
|
+
expect(json.success).toBe(false);
|
|
53
|
+
expect(json.error).toBe('Resource not found');
|
|
54
|
+
expect(json.code).toBe('NOT_FOUND');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should handle UnauthorizedError with 401 status', async () => {
|
|
58
|
+
const res = await app.request('/unauthorized');
|
|
59
|
+
expect(res.status).toBe(401);
|
|
60
|
+
|
|
61
|
+
const json = await res.json();
|
|
62
|
+
expect(json.success).toBe(false);
|
|
63
|
+
expect(json.error).toBe('Not authenticated');
|
|
64
|
+
expect(json.code).toBe('UNAUTHORIZED');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should handle InternalServerError with 500 status', async () => {
|
|
68
|
+
const res = await app.request('/server-error');
|
|
69
|
+
expect(res.status).toBe(500);
|
|
70
|
+
|
|
71
|
+
const json = await res.json();
|
|
72
|
+
expect(json.success).toBe(false);
|
|
73
|
+
expect(json.error).toBe('Something went wrong');
|
|
74
|
+
expect(json.code).toBe('INTERNAL_SERVER_ERROR');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should handle unknown errors with 500 status', async () => {
|
|
78
|
+
const res = await app.request('/unknown-error');
|
|
79
|
+
expect(res.status).toBe(500);
|
|
80
|
+
|
|
81
|
+
const json = await res.json();
|
|
82
|
+
expect(json.success).toBe(false);
|
|
83
|
+
expect(json.error).toBe('Internal server error');
|
|
84
|
+
expect(json.code).toBe('INTERNAL_SERVER_ERROR');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { cors } from 'hono/cors';
|
|
2
|
+
import { env } from '../config/env.js';
|
|
3
|
+
|
|
4
|
+
export const corsMiddleware = cors({
|
|
5
|
+
origin: env.CORS_ORIGIN.split(',').map(o => o.trim()),
|
|
6
|
+
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
7
|
+
allowHeaders: ['Content-Type', 'Authorization'],
|
|
8
|
+
credentials: true,
|
|
9
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import { logger } from '../config/logger.js';
|
|
3
|
+
import { AppError } from '../lib/errors.js';
|
|
4
|
+
import type { ApiResponse } from '../types/app.types.js';
|
|
5
|
+
|
|
6
|
+
export function errorHandler(error: Error, c: Context) {
|
|
7
|
+
// Handle known application errors
|
|
8
|
+
if (error instanceof AppError) {
|
|
9
|
+
logger.warn('Application error:', {
|
|
10
|
+
code: error.code,
|
|
11
|
+
message: error.message,
|
|
12
|
+
statusCode: error.statusCode,
|
|
13
|
+
path: c.req.path,
|
|
14
|
+
method: c.req.method,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const response: ApiResponse = {
|
|
18
|
+
success: false,
|
|
19
|
+
error: error.message,
|
|
20
|
+
code: error.code,
|
|
21
|
+
details: process.env.NODE_ENV === 'development' ? error.details : undefined,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return c.json(response, error.statusCode);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Handle unknown errors
|
|
28
|
+
logger.error('Unhandled error:', {
|
|
29
|
+
error: error.message,
|
|
30
|
+
stack: error.stack,
|
|
31
|
+
path: c.req.path,
|
|
32
|
+
method: c.req.method,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const response: ApiResponse = {
|
|
36
|
+
success: false,
|
|
37
|
+
error: 'Internal server error',
|
|
38
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
39
|
+
message: process.env.NODE_ENV === 'development' ? error.message : undefined,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return c.json(response, 500);
|
|
43
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Context, Next } from 'hono';
|
|
2
|
+
import { logger } from '../config/logger.js';
|
|
3
|
+
|
|
4
|
+
export async function loggerMiddleware(c: Context, next: Next) {
|
|
5
|
+
const start = Date.now();
|
|
6
|
+
const { method, url } = c.req;
|
|
7
|
+
|
|
8
|
+
await next();
|
|
9
|
+
|
|
10
|
+
const duration = Date.now() - start;
|
|
11
|
+
const status = c.res.status;
|
|
12
|
+
|
|
13
|
+
logger.info(`${method} ${url} ${status} - ${duration}ms`);
|
|
14
|
+
}
|