create-rebe 1.0.0
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 +54 -0
- package/bin/create-rebe.js +310 -0
- package/package.json +40 -0
- package/template/.env.example +141 -0
- package/template/.gitattributes +16 -0
- package/template/.nvmrc +1 -0
- package/template/.prettierignore +10 -0
- package/template/.prettierrc +12 -0
- package/template/LICENSE +21 -0
- package/template/README.md +150 -0
- package/template/SECURITY.md +133 -0
- package/template/app/hooks/register.hook.js +22 -0
- package/template/app/http/controllers/auth.controller.js +34 -0
- package/template/app/http/middlewares/auth.middleware.js +25 -0
- package/template/app/http/middlewares/register.middleware.js +18 -0
- package/template/app/http/validators/auth.validator.js +8 -0
- package/template/app/jobs/register.job.js +17 -0
- package/template/app/queue/register.queue.js +11 -0
- package/template/app/routes/api.route.js +18 -0
- package/template/app/routes/register.route.js +8 -0
- package/template/app/routes/web.route.js +7 -0
- package/template/app/socket/register.socket.js +15 -0
- package/template/config/app.config.js +12 -0
- package/template/config/bcrypt.config.js +7 -0
- package/template/config/cache.config.js +7 -0
- package/template/config/cors.config.js +37 -0
- package/template/config/cron.config.js +9 -0
- package/template/config/database.config.js +51 -0
- package/template/config/express.config.js +37 -0
- package/template/config/index.js +19 -0
- package/template/config/jwt.config.js +10 -0
- package/template/config/logger.config.js +10 -0
- package/template/config/mail.config.js +14 -0
- package/template/config/queue.config.js +11 -0
- package/template/config/redis.config.js +32 -0
- package/template/config/runtime.config.js +11 -0
- package/template/config/socket.config.js +15 -0
- package/template/config/storage.config.js +10 -0
- package/template/core/bootstrap.core.js +92 -0
- package/template/core/common/array.js +144 -0
- package/template/core/common/cache.js +100 -0
- package/template/core/common/collection.js +173 -0
- package/template/core/common/crypt.js +69 -0
- package/template/core/common/date.js +254 -0
- package/template/core/common/hash.js +61 -0
- package/template/core/common/object.js +155 -0
- package/template/core/common/path.js +80 -0
- package/template/core/common/storage.js +97 -0
- package/template/core/common/string.js +137 -0
- package/template/core/common/url.js +81 -0
- package/template/core/common.core.js +93 -0
- package/template/core/cron.core.js +141 -0
- package/template/core/database.core.js +113 -0
- package/template/core/error.core.js +83 -0
- package/template/core/express.core.js +161 -0
- package/template/core/hooks.core.js +47 -0
- package/template/core/jwt.core.js +81 -0
- package/template/core/logger.core.js +100 -0
- package/template/core/mailer.core.js +65 -0
- package/template/core/queue.core.js +226 -0
- package/template/core/redis.core.js +75 -0
- package/template/core/register.core.js +91 -0
- package/template/core/runtime.core.js +27 -0
- package/template/core/socket.core.js +93 -0
- package/template/core/validator.core.js +34 -0
- package/template/database/.gitkeep +0 -0
- package/template/database/models/post.model.js +26 -0
- package/template/database/models/user.model.js +30 -0
- package/template/docs/Bootstrap.md +50 -0
- package/template/docs/Common.md +48 -0
- package/template/docs/Cron.md +47 -0
- package/template/docs/Database.md +61 -0
- package/template/docs/Express.md +63 -0
- package/template/docs/Hooks.md +48 -0
- package/template/docs/Jwt.md +63 -0
- package/template/docs/Logger.md +60 -0
- package/template/docs/Mailer.md +50 -0
- package/template/docs/Queue.md +57 -0
- package/template/docs/README.md +32 -0
- package/template/docs/Redis.md +54 -0
- package/template/docs/Register.md +52 -0
- package/template/docs/Socket.md +55 -0
- package/template/docs/Validator.md +45 -0
- package/template/ecosystem.config.js +54 -0
- package/template/index.js +6 -0
- package/template/jest.config.js +27 -0
- package/template/jsconfig.json +37 -0
- package/template/logs/.gitkeep +0 -0
- package/template/nodemon.json +23 -0
- package/template/package-lock.json +7033 -0
- package/template/package.json +82 -0
- package/template/scripts/cli.js +258 -0
- package/template/storage/.gitkeep +0 -0
- package/template/tests/common.test.js +45 -0
- package/template/tests/crypt-hash-storage.test.js +44 -0
- package/template/tests/jwt.test.js +45 -0
- package/template/tests/register.test.js +55 -0
- package/template/tests/setup.js +11 -0
- package/template/tests/validator.test.js +65 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const http = require('http')
|
|
6
|
+
const crypto = require('crypto')
|
|
7
|
+
|
|
8
|
+
const express = require('express')
|
|
9
|
+
const helmet = require('helmet')
|
|
10
|
+
const compression = require('compression')
|
|
11
|
+
const rateLimit = require('express-rate-limit')
|
|
12
|
+
const multer = require('multer')
|
|
13
|
+
|
|
14
|
+
const config = require('@config/index')
|
|
15
|
+
const logger = require('@core/logger.core')
|
|
16
|
+
const ErrorHandler = require('@core/error.core')
|
|
17
|
+
const Register = require('@core/register.core')
|
|
18
|
+
|
|
19
|
+
const Routes = require('@refkinscallv/express-routing')
|
|
20
|
+
|
|
21
|
+
const MIDDLEWARE_REGISTER = Register.path('app', 'http', 'middlewares', 'register.middleware.js')
|
|
22
|
+
|
|
23
|
+
// HTTP backbone. Builds the Express app and an http.Server without listening, so
|
|
24
|
+
// the socket core can attach to the same server before the port is opened.
|
|
25
|
+
class Express {
|
|
26
|
+
static app = null
|
|
27
|
+
static server = null
|
|
28
|
+
|
|
29
|
+
// Build the app + server once (idempotent). Returns { app, server }.
|
|
30
|
+
static async create() {
|
|
31
|
+
if (Express.app && Express.server) return { app: Express.app, server: Express.server }
|
|
32
|
+
|
|
33
|
+
const app = express()
|
|
34
|
+
|
|
35
|
+
app.disable('x-powered-by')
|
|
36
|
+
if (config.express.trustProxy) app.set('trust proxy', config.express.trustProxy)
|
|
37
|
+
|
|
38
|
+
app.use(helmet(config.express.helmet))
|
|
39
|
+
app.use(config.cors.express)
|
|
40
|
+
app.use(compression())
|
|
41
|
+
app.use(express.json({ limit: config.express.bodyLimit }))
|
|
42
|
+
app.use(express.urlencoded({ extended: true, limit: config.express.bodyLimit }))
|
|
43
|
+
|
|
44
|
+
if (config.express.rateLimit.enabled) {
|
|
45
|
+
app.use(
|
|
46
|
+
rateLimit({
|
|
47
|
+
windowMs: config.express.rateLimit.windowMs,
|
|
48
|
+
max: config.express.rateLimit.max,
|
|
49
|
+
standardHeaders: config.express.rateLimit.standardHeaders,
|
|
50
|
+
legacyHeaders: config.express.rateLimit.legacyHeaders,
|
|
51
|
+
message: config.express.rateLimit.message,
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
Express._applyUserMiddleware(app)
|
|
57
|
+
Express._applyStatic(app)
|
|
58
|
+
await Express._applyRoutes(app)
|
|
59
|
+
Express._applyErrorHandling(app)
|
|
60
|
+
|
|
61
|
+
Express.app = app
|
|
62
|
+
Express.server = http.createServer(app)
|
|
63
|
+
return { app, server: Express.server }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Global application middleware: app/http/middlewares/register.middleware.js
|
|
67
|
+
// exports (app) => { ... }.
|
|
68
|
+
static _applyUserMiddleware(app) {
|
|
69
|
+
const register = Register.require(MIDDLEWARE_REGISTER, { label: 'app/http/middlewares/register.middleware.js' })
|
|
70
|
+
if (typeof register === 'function') register(app)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Serve ./public at the root and uploaded files under /uploads. The whole
|
|
74
|
+
// storage root is intentionally NOT exposed — it may hold private files.
|
|
75
|
+
static _applyStatic(app) {
|
|
76
|
+
const root = process.cwd()
|
|
77
|
+
|
|
78
|
+
const publicDir = path.join(root, 'public')
|
|
79
|
+
if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
|
|
80
|
+
|
|
81
|
+
const uploadDir = path.resolve(root, config.storage.uploadPath)
|
|
82
|
+
if (fs.existsSync(uploadDir)) {
|
|
83
|
+
app.use(
|
|
84
|
+
'/uploads',
|
|
85
|
+
express.static(uploadDir, {
|
|
86
|
+
// nosniff stops browsers from re-interpreting an uploaded file's
|
|
87
|
+
// content-type (e.g. treating an image as HTML).
|
|
88
|
+
setHeaders: (res) => res.setHeader('X-Content-Type-Options', 'nosniff'),
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Mount application routes. register.route loads web + api routes as a side
|
|
95
|
+
// effect, then Routes.apply binds them onto a router and app.use()s it.
|
|
96
|
+
static async _applyRoutes(app) {
|
|
97
|
+
require('@routes/register.route')
|
|
98
|
+
await Routes.apply(app, express.Router())
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static _applyErrorHandling(app) {
|
|
102
|
+
app.use(ErrorHandler.notFoundHandler)
|
|
103
|
+
app.use(ErrorHandler.expressErrorHandler)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
static listen() {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
if (!Express.server) {
|
|
109
|
+
return reject(new Error('Express.create() must be called before listen()'))
|
|
110
|
+
}
|
|
111
|
+
Express.server.once('error', reject)
|
|
112
|
+
Express.server.listen(config.app.port, () => {
|
|
113
|
+
logger.info(`HTTP server listening on ${config.app.url} (port ${config.app.port})`)
|
|
114
|
+
resolve(Express.server)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
static close() {
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
if (!Express.server) return resolve()
|
|
122
|
+
Express.server.close(() => {
|
|
123
|
+
Express.server = null
|
|
124
|
+
Express.app = null
|
|
125
|
+
logger.info('HTTP server closed')
|
|
126
|
+
resolve()
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build a multer middleware from storage config. Filenames are randomised to
|
|
132
|
+
// avoid path traversal and collisions; size and mime-type are constrained.
|
|
133
|
+
static upload(field) {
|
|
134
|
+
const uploadDir = path.resolve(process.cwd(), config.storage.uploadPath)
|
|
135
|
+
fs.mkdirSync(uploadDir, { recursive: true })
|
|
136
|
+
|
|
137
|
+
const storage = multer.diskStorage({
|
|
138
|
+
destination: (req, file, cb) => cb(null, uploadDir),
|
|
139
|
+
filename: (req, file, cb) => {
|
|
140
|
+
const ext = path
|
|
141
|
+
.extname(file.originalname)
|
|
142
|
+
.toLowerCase()
|
|
143
|
+
.replace(/[^.a-z0-9]/g, '')
|
|
144
|
+
cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`)
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const limits = { fileSize: config.storage.maxSizeMb * 1024 * 1024 }
|
|
149
|
+
|
|
150
|
+
const fileFilter = (req, file, cb) => {
|
|
151
|
+
const allowed = config.storage.allowedTypes
|
|
152
|
+
if (!allowed.length || allowed.includes(file.mimetype)) return cb(null, true)
|
|
153
|
+
cb(new Error(`Unsupported file type: ${file.mimetype}`))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const instance = multer({ storage, limits, fileFilter })
|
|
157
|
+
return field ? instance.single(field) : instance
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = Express
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const logger = require('@core/logger.core')
|
|
4
|
+
const Register = require('@core/register.core')
|
|
5
|
+
|
|
6
|
+
const REGISTER_FILE = Register.path('app', 'hooks', 'register.hook.js')
|
|
7
|
+
const STAGES = ['before', 'after', 'shutdown']
|
|
8
|
+
|
|
9
|
+
// Application lifecycle hooks. The bootstrap core invokes `before` (after config
|
|
10
|
+
// is ready, before services start), `after` (once the server is listening), and
|
|
11
|
+
// `shutdown` (on SIGINT/SIGTERM). Handlers live in app/hooks/register.hook.js.
|
|
12
|
+
class Hooks {
|
|
13
|
+
static _handlers = undefined
|
|
14
|
+
|
|
15
|
+
static _resolve() {
|
|
16
|
+
if (Hooks._handlers === undefined) {
|
|
17
|
+
Hooks._handlers = Register.object(REGISTER_FILE, STAGES, { label: 'app/hooks/register.hook.js' }) || {}
|
|
18
|
+
}
|
|
19
|
+
return Hooks._handlers
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static async run(stage, ctx = {}) {
|
|
23
|
+
const fn = Hooks._resolve()[stage]
|
|
24
|
+
if (typeof fn !== 'function') return
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await fn(ctx)
|
|
28
|
+
} catch (err) {
|
|
29
|
+
logger.error(`Hook "${stage}" failed`, err)
|
|
30
|
+
throw err
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static before(ctx) {
|
|
35
|
+
return Hooks.run('before', ctx)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static after(ctx) {
|
|
39
|
+
return Hooks.run('after', ctx)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static shutdown(ctx) {
|
|
43
|
+
return Hooks.run('shutdown', ctx)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = Hooks
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const jwt = require('jsonwebtoken')
|
|
4
|
+
|
|
5
|
+
const config = require('@config/index')
|
|
6
|
+
|
|
7
|
+
// Algorithm is pinned to a single symmetric scheme. Pinning on both sign and
|
|
8
|
+
// verify defeats algorithm-confusion attacks (e.g. a forged token using "none"
|
|
9
|
+
// or an RS/HS swap) — verification will reject anything not signed with HS256.
|
|
10
|
+
const ALGORITHM = 'HS256'
|
|
11
|
+
|
|
12
|
+
class Jwt {
|
|
13
|
+
static _assertSecret(secret, label) {
|
|
14
|
+
if (!secret) throw new Error(`${label} is not configured`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static sign(payload, options = {}) {
|
|
18
|
+
Jwt._assertSecret(config.jwt.secret, 'JWT_SECRET')
|
|
19
|
+
return jwt.sign(payload, config.jwt.secret, { expiresIn: config.jwt.expiresIn, algorithm: ALGORITHM, ...options })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static verify(token, options = {}) {
|
|
23
|
+
Jwt._assertSecret(config.jwt.secret, 'JWT_SECRET')
|
|
24
|
+
return jwt.verify(token, config.jwt.secret, { algorithms: [ALGORITHM], ...options })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static signRefresh(payload, options = {}) {
|
|
28
|
+
Jwt._assertSecret(config.jwt.refreshSecret, 'JWT_REFRESH_SECRET')
|
|
29
|
+
return jwt.sign(payload, config.jwt.refreshSecret, { expiresIn: config.jwt.refreshExpiresIn, algorithm: ALGORITHM, ...options })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static verifyRefresh(token, options = {}) {
|
|
33
|
+
Jwt._assertSecret(config.jwt.refreshSecret, 'JWT_REFRESH_SECRET')
|
|
34
|
+
return jwt.verify(token, config.jwt.refreshSecret, { algorithms: [ALGORITHM], ...options })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Issue an access + refresh pair for a payload (typically { sub, ... }).
|
|
38
|
+
static issue(payload) {
|
|
39
|
+
return {
|
|
40
|
+
accessToken: Jwt.sign(payload),
|
|
41
|
+
refreshToken: Jwt.signRefresh(payload),
|
|
42
|
+
tokenType: 'Bearer',
|
|
43
|
+
expiresIn: config.jwt.expiresIn,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Non-throwing verify. Returns { valid, payload, error } for control flow that
|
|
48
|
+
// prefers a result object over try/catch.
|
|
49
|
+
static tryVerify(token) {
|
|
50
|
+
try {
|
|
51
|
+
return { valid: true, payload: Jwt.verify(token), error: null }
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return { valid: false, payload: null, error }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static decode(token, options = {}) {
|
|
58
|
+
return jwt.decode(token, options)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Expiry as a Date, or null when the token has no `exp` claim.
|
|
62
|
+
static expiresAt(token) {
|
|
63
|
+
const decoded = jwt.decode(token)
|
|
64
|
+
if (!decoded || typeof decoded.exp !== 'number') return null
|
|
65
|
+
return new Date(decoded.exp * 1000)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static isExpired(token) {
|
|
69
|
+
const at = Jwt.expiresAt(token)
|
|
70
|
+
return at ? at.getTime() <= Date.now() : true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Extract the bearer token from an Authorization header value, or null.
|
|
74
|
+
static fromHeader(authHeader) {
|
|
75
|
+
if (!authHeader || typeof authHeader !== 'string') return null
|
|
76
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i)
|
|
77
|
+
return match ? match[1].trim() : null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = Jwt
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const winston = require('winston')
|
|
4
|
+
const config = require('@config/index')
|
|
5
|
+
require('winston-daily-rotate-file')
|
|
6
|
+
const { createLogger, format, transports } = winston
|
|
7
|
+
const { combine, timestamp, printf, errors } = format
|
|
8
|
+
|
|
9
|
+
class Logger {
|
|
10
|
+
static isProd = config.app.env === 'production'
|
|
11
|
+
static logDir = path.resolve(process.cwd(), config.logger.file)
|
|
12
|
+
static appName = `[${config.app.name.toUpperCase()}]`
|
|
13
|
+
static _consoleFormat = null
|
|
14
|
+
static _fileFormat = null
|
|
15
|
+
|
|
16
|
+
static outputColors = {
|
|
17
|
+
error: '\x1b[31m\x1b[1m',
|
|
18
|
+
warn: '\x1b[33m\x1b[1m',
|
|
19
|
+
info: '\x1b[36m\x1b[1m',
|
|
20
|
+
debug: '\x1b[35m\x1b[1m',
|
|
21
|
+
http: '\x1b[32m\x1b[1m',
|
|
22
|
+
verbose: '\x1b[34m\x1b[1m',
|
|
23
|
+
silly: '\x1b[90m\x1b[1m',
|
|
24
|
+
reset: '\x1b[0m',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static colorLevel(level) {
|
|
28
|
+
const c = Logger.outputColors[level] || '\x1b[37m\x1b[1m'
|
|
29
|
+
return `${c}[${level.toUpperCase()}]${Logger.outputColors.reset}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static buildMessage({ message, stack }) {
|
|
33
|
+
if (stack) return stack
|
|
34
|
+
if (message instanceof Error) return message.stack || message.message
|
|
35
|
+
return String(message)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static get consoleFormat() {
|
|
39
|
+
if (!Logger._consoleFormat) {
|
|
40
|
+
Logger._consoleFormat = combine(
|
|
41
|
+
errors({ stack: true }),
|
|
42
|
+
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
43
|
+
printf((info) => {
|
|
44
|
+
const msg = Logger.buildMessage(info)
|
|
45
|
+
return `${Logger.appName} ${Logger.colorLevel(info.level)} [${info.timestamp}] ${msg}`
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
return Logger._consoleFormat
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static get fileFormat() {
|
|
53
|
+
if (!Logger._fileFormat) {
|
|
54
|
+
Logger._fileFormat = combine(
|
|
55
|
+
errors({ stack: true }),
|
|
56
|
+
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
57
|
+
printf((info) => {
|
|
58
|
+
const msg = Logger.buildMessage(info)
|
|
59
|
+
return `${Logger.appName} [${info.level.toUpperCase()}] [${info.timestamp}] ${msg}`
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
return Logger._fileFormat
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static get productionFilter() {
|
|
67
|
+
return format((info) => {
|
|
68
|
+
if (['error', 'warn', 'debug'].includes(info.level)) return false
|
|
69
|
+
return info
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static makeRotateTransport(level, name) {
|
|
74
|
+
return new transports.DailyRotateFile({
|
|
75
|
+
level,
|
|
76
|
+
dirname: Logger.logDir,
|
|
77
|
+
filename: `${name}-%DATE%.log`,
|
|
78
|
+
datePattern: 'YYYY-MM-DD',
|
|
79
|
+
zippedArchive: true,
|
|
80
|
+
maxSize: config.logger.maxSize,
|
|
81
|
+
maxFiles: config.logger.maxFiles,
|
|
82
|
+
format: Logger.fileFormat,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static create() {
|
|
87
|
+
const consoleTransport = new transports.Console({
|
|
88
|
+
level: config.logger.level,
|
|
89
|
+
format: Logger.isProd ? combine(Logger.productionFilter(), Logger.consoleFormat) : Logger.consoleFormat,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
return createLogger({
|
|
93
|
+
level: config.logger.level,
|
|
94
|
+
exitOnError: false,
|
|
95
|
+
transports: [consoleTransport, Logger.makeRotateTransport('error', 'error'), Logger.makeRotateTransport(config.logger.level, 'combined')],
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = Logger.create()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const nodemailer = require('nodemailer')
|
|
4
|
+
|
|
5
|
+
const config = require('@config/index')
|
|
6
|
+
const logger = require('@core/logger.core')
|
|
7
|
+
|
|
8
|
+
class Mailer {
|
|
9
|
+
static _transporter = null
|
|
10
|
+
|
|
11
|
+
// Lazily create and cache the SMTP transport. Auth is omitted when no username
|
|
12
|
+
// is configured (e.g. local relays / Mailtrap without credentials).
|
|
13
|
+
static _transport() {
|
|
14
|
+
if (Mailer._transporter) return Mailer._transporter
|
|
15
|
+
|
|
16
|
+
const mail = config.mail
|
|
17
|
+
Mailer._transporter = nodemailer.createTransport({
|
|
18
|
+
host: mail.host,
|
|
19
|
+
port: mail.port,
|
|
20
|
+
secure: mail.secure,
|
|
21
|
+
auth: mail.username ? { user: mail.username, pass: mail.password } : undefined,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
return Mailer._transporter
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static _defaultFrom() {
|
|
28
|
+
const { fromName, fromAddress } = config.mail
|
|
29
|
+
if (!fromAddress) return undefined
|
|
30
|
+
return fromName ? `"${fromName}" <${fromAddress}>` : fromAddress
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Send a message. When mail is disabled the call is a safe no-op so application
|
|
34
|
+
// code never has to branch on MAIL_ENABLED.
|
|
35
|
+
static async send(message) {
|
|
36
|
+
if (!config.mail.enabled) {
|
|
37
|
+
logger.warn('Mail is disabled (MAIL_ENABLED=false); skipping send')
|
|
38
|
+
return { skipped: true }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const payload = { ...message, from: message.from || Mailer._defaultFrom() }
|
|
42
|
+
const info = await Mailer._transport().sendMail(payload)
|
|
43
|
+
logger.debug(`Mail sent: ${info.messageId}`)
|
|
44
|
+
return info
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Verify the SMTP connection/credentials. Returns false (and logs) on failure
|
|
48
|
+
// rather than throwing, so it is safe to call during boot diagnostics.
|
|
49
|
+
static async verify() {
|
|
50
|
+
if (!config.mail.enabled) {
|
|
51
|
+
logger.warn('Mail is disabled (MAIL_ENABLED=false)')
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await Mailer._transport().verify()
|
|
57
|
+
return true
|
|
58
|
+
} catch (err) {
|
|
59
|
+
logger.error('Mail transport verification failed', err)
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = Mailer
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
|
|
5
|
+
const config = require('@config/index')
|
|
6
|
+
const logger = require('@core/logger.core')
|
|
7
|
+
const Register = require('@core/register.core')
|
|
8
|
+
|
|
9
|
+
const REGISTER_FILE = Register.path('app', 'queue', 'register.queue.js')
|
|
10
|
+
const TABLE = 'queue_jobs'
|
|
11
|
+
|
|
12
|
+
class Queue {
|
|
13
|
+
static queues = new Map()
|
|
14
|
+
static draining = false
|
|
15
|
+
static _db = null
|
|
16
|
+
|
|
17
|
+
static define(name, handler, options = {}) {
|
|
18
|
+
Queue.queues.set(name, {
|
|
19
|
+
name,
|
|
20
|
+
handler,
|
|
21
|
+
concurrency: options.concurrency ?? config.queue.concurrency,
|
|
22
|
+
maxRetries: options.maxRetries ?? config.queue.maxRetries,
|
|
23
|
+
retryDelay: options.retryDelay ?? config.queue.retryDelay,
|
|
24
|
+
pending: [],
|
|
25
|
+
active: 0,
|
|
26
|
+
})
|
|
27
|
+
return Queue
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static dispatch(name, payload = {}, options = {}) {
|
|
31
|
+
const queue = Queue.queues.get(name)
|
|
32
|
+
if (!queue) throw new Error(`Queue "${name}" is not defined`)
|
|
33
|
+
|
|
34
|
+
const job = {
|
|
35
|
+
id: crypto.randomUUID(),
|
|
36
|
+
queue: name,
|
|
37
|
+
payload,
|
|
38
|
+
attempts: 0,
|
|
39
|
+
maxRetries: options.maxRetries ?? queue.maxRetries,
|
|
40
|
+
createdAt: Date.now(),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const delay = options.delay || 0
|
|
44
|
+
if (Queue._db) {
|
|
45
|
+
Queue._insertJob(job, delay).catch((err) => logger.warn(`Queue: failed to persist job ${job.id}: ${err.message}`))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (delay > 0) {
|
|
49
|
+
setTimeout(() => Queue._enqueue(queue, job), delay).unref?.()
|
|
50
|
+
} else {
|
|
51
|
+
Queue._enqueue(queue, job)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return job.id
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static _enqueue(queue, job) {
|
|
58
|
+
queue.pending.push(job)
|
|
59
|
+
Queue._drain(queue)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static _drain(queue) {
|
|
63
|
+
if (Queue.draining) return
|
|
64
|
+
while (queue.active < queue.concurrency && queue.pending.length > 0) {
|
|
65
|
+
const job = queue.pending.shift()
|
|
66
|
+
Queue._process(queue, job)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static async _process(queue, job) {
|
|
71
|
+
queue.active++
|
|
72
|
+
job.attempts++
|
|
73
|
+
if (Queue._db) await Queue._markProcessing(job.id, job.attempts).catch(() => {})
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await queue.handler(job.payload, job)
|
|
77
|
+
if (Queue._db) await Queue._markCompleted(job.id).catch(() => {})
|
|
78
|
+
logger.debug(`Queue "${queue.name}" processed job ${job.id} (attempt ${job.attempts})`)
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (job.attempts <= job.maxRetries) {
|
|
81
|
+
const backoff = queue.retryDelay * Math.pow(2, job.attempts - 1)
|
|
82
|
+
logger.warn(`Queue "${queue.name}" job ${job.id} failed (attempt ${job.attempts}), retrying in ${backoff}ms: ${err.message}`)
|
|
83
|
+
if (Queue._db) await Queue._markPending(job.id, job.attempts).catch(() => {})
|
|
84
|
+
setTimeout(() => Queue._enqueue(queue, job), backoff).unref?.()
|
|
85
|
+
} else {
|
|
86
|
+
if (Queue._db) await Queue._markFailed(job.id, err.message).catch(() => {})
|
|
87
|
+
logger.error(`Queue "${queue.name}" job ${job.id} permanently failed after ${job.attempts} attempt(s)`, err)
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
queue.active--
|
|
91
|
+
Queue._drain(queue)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static async start() {
|
|
96
|
+
if (!config.queue.enabled) {
|
|
97
|
+
logger.info('Queue is disabled (QUEUE_ENABLED=false)')
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await Register.invoke(REGISTER_FILE, [Queue], { label: 'app/queue/register.queue.js' })
|
|
102
|
+
|
|
103
|
+
if (config.queue.persist && config.db.enabled) {
|
|
104
|
+
try {
|
|
105
|
+
const Database = require('@core/database.core')
|
|
106
|
+
if (Database.sequelize) {
|
|
107
|
+
Queue._db = Database.sequelize
|
|
108
|
+
await Queue._ensureTable()
|
|
109
|
+
await Queue._loadPending()
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
logger.warn(`Queue: DB persistence unavailable, running in-memory only: ${err.message}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
logger.info(`Queue started ${Queue.queues.size} queue(s)${Queue.queues.size ? `: ${[...Queue.queues.keys()].join(', ')}` : ''}`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
static async stop(timeoutMs = 10000) {
|
|
120
|
+
Queue.draining = true
|
|
121
|
+
const deadline = Date.now() + timeoutMs
|
|
122
|
+
while (Date.now() < deadline) {
|
|
123
|
+
const busy = [...Queue.queues.values()].some((q) => q.active > 0)
|
|
124
|
+
if (!busy) break
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
126
|
+
}
|
|
127
|
+
logger.info('Queue stopped')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static stats() {
|
|
131
|
+
const out = {}
|
|
132
|
+
for (const [name, queue] of Queue.queues) {
|
|
133
|
+
out[name] = { pending: queue.pending.length, active: queue.active }
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── DB helpers ────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
static async _ensureTable() {
|
|
141
|
+
await Queue._db.query(`
|
|
142
|
+
CREATE TABLE IF NOT EXISTS \`${TABLE}\` (
|
|
143
|
+
\`id\` VARCHAR(36) NOT NULL,
|
|
144
|
+
\`queue\` VARCHAR(100) NOT NULL,
|
|
145
|
+
\`payload\` JSON NOT NULL,
|
|
146
|
+
\`status\` ENUM('pending','processing','completed','failed') NOT NULL DEFAULT 'pending',
|
|
147
|
+
\`attempts\` INT NOT NULL DEFAULT 0,
|
|
148
|
+
\`max_retries\` INT NOT NULL DEFAULT 3,
|
|
149
|
+
\`error\` TEXT NULL,
|
|
150
|
+
\`available_at\` DATETIME NOT NULL,
|
|
151
|
+
\`processed_at\` DATETIME NULL,
|
|
152
|
+
\`created_at\` DATETIME NOT NULL,
|
|
153
|
+
\`updated_at\` DATETIME NOT NULL,
|
|
154
|
+
PRIMARY KEY (\`id\`),
|
|
155
|
+
INDEX idx_queue_status (\`queue\`, \`status\`, \`available_at\`)
|
|
156
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
157
|
+
`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
static async _insertJob(job, delay) {
|
|
161
|
+
const now = new Date()
|
|
162
|
+
await Queue._db.query(
|
|
163
|
+
`INSERT INTO \`${TABLE}\` (id, \`queue\`, payload, status, attempts, max_retries, available_at, created_at, updated_at)
|
|
164
|
+
VALUES (:id, :queue, :payload, 'pending', 0, :maxRetries, :availableAt, :now, :now)`,
|
|
165
|
+
{
|
|
166
|
+
replacements: {
|
|
167
|
+
id: job.id,
|
|
168
|
+
queue: job.queue,
|
|
169
|
+
payload: JSON.stringify(job.payload),
|
|
170
|
+
maxRetries: job.maxRetries,
|
|
171
|
+
availableAt: new Date(Date.now() + delay),
|
|
172
|
+
now,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
static async _markProcessing(id, attempts) {
|
|
179
|
+
await Queue._db.query(`UPDATE \`${TABLE}\` SET status = 'processing', attempts = :attempts, updated_at = :now WHERE id = :id`, { replacements: { attempts, now: new Date(), id } })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
static async _markCompleted(id) {
|
|
183
|
+
const now = new Date()
|
|
184
|
+
await Queue._db.query(`UPDATE \`${TABLE}\` SET status = 'completed', processed_at = :now, updated_at = :now WHERE id = :id`, { replacements: { now, id } })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
static async _markFailed(id, errorMsg) {
|
|
188
|
+
const now = new Date()
|
|
189
|
+
await Queue._db.query(`UPDATE \`${TABLE}\` SET status = 'failed', error = :error, processed_at = :now, updated_at = :now WHERE id = :id`, { replacements: { error: errorMsg, now, id } })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
static async _markPending(id, attempts) {
|
|
193
|
+
await Queue._db.query(`UPDATE \`${TABLE}\` SET status = 'pending', attempts = :attempts, updated_at = :now WHERE id = :id`, { replacements: { attempts, now: new Date(), id } })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
static async _loadPending() {
|
|
197
|
+
const [rows] = await Queue._db.query(
|
|
198
|
+
`SELECT id, \`queue\`, payload, attempts, max_retries AS maxRetries
|
|
199
|
+
FROM \`${TABLE}\`
|
|
200
|
+
WHERE status IN ('pending', 'processing') AND available_at <= :now`,
|
|
201
|
+
{ replacements: { now: new Date() } },
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
let loaded = 0
|
|
205
|
+
for (const row of rows) {
|
|
206
|
+
const queue = Queue.queues.get(row.queue)
|
|
207
|
+
if (!queue) {
|
|
208
|
+
logger.warn(`Queue "${row.queue}" not registered; skipping orphaned job ${row.id}`)
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
Queue._enqueue(queue, {
|
|
212
|
+
id: row.id,
|
|
213
|
+
queue: row.queue,
|
|
214
|
+
payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload,
|
|
215
|
+
attempts: row.attempts,
|
|
216
|
+
maxRetries: row.maxRetries,
|
|
217
|
+
createdAt: Date.now(),
|
|
218
|
+
})
|
|
219
|
+
loaded++
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (loaded > 0) logger.info(`Queue: recovered ${loaded} pending job(s) from database`)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = Queue
|