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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -0
  3. package/bin/create-rebe.js +310 -0
  4. package/package.json +40 -0
  5. package/template/.env.example +141 -0
  6. package/template/.gitattributes +16 -0
  7. package/template/.nvmrc +1 -0
  8. package/template/.prettierignore +10 -0
  9. package/template/.prettierrc +12 -0
  10. package/template/LICENSE +21 -0
  11. package/template/README.md +150 -0
  12. package/template/SECURITY.md +133 -0
  13. package/template/app/hooks/register.hook.js +22 -0
  14. package/template/app/http/controllers/auth.controller.js +34 -0
  15. package/template/app/http/middlewares/auth.middleware.js +25 -0
  16. package/template/app/http/middlewares/register.middleware.js +18 -0
  17. package/template/app/http/validators/auth.validator.js +8 -0
  18. package/template/app/jobs/register.job.js +17 -0
  19. package/template/app/queue/register.queue.js +11 -0
  20. package/template/app/routes/api.route.js +18 -0
  21. package/template/app/routes/register.route.js +8 -0
  22. package/template/app/routes/web.route.js +7 -0
  23. package/template/app/socket/register.socket.js +15 -0
  24. package/template/config/app.config.js +12 -0
  25. package/template/config/bcrypt.config.js +7 -0
  26. package/template/config/cache.config.js +7 -0
  27. package/template/config/cors.config.js +37 -0
  28. package/template/config/cron.config.js +9 -0
  29. package/template/config/database.config.js +51 -0
  30. package/template/config/express.config.js +37 -0
  31. package/template/config/index.js +19 -0
  32. package/template/config/jwt.config.js +10 -0
  33. package/template/config/logger.config.js +10 -0
  34. package/template/config/mail.config.js +14 -0
  35. package/template/config/queue.config.js +11 -0
  36. package/template/config/redis.config.js +32 -0
  37. package/template/config/runtime.config.js +11 -0
  38. package/template/config/socket.config.js +15 -0
  39. package/template/config/storage.config.js +10 -0
  40. package/template/core/bootstrap.core.js +92 -0
  41. package/template/core/common/array.js +144 -0
  42. package/template/core/common/cache.js +100 -0
  43. package/template/core/common/collection.js +173 -0
  44. package/template/core/common/crypt.js +69 -0
  45. package/template/core/common/date.js +254 -0
  46. package/template/core/common/hash.js +61 -0
  47. package/template/core/common/object.js +155 -0
  48. package/template/core/common/path.js +80 -0
  49. package/template/core/common/storage.js +97 -0
  50. package/template/core/common/string.js +137 -0
  51. package/template/core/common/url.js +81 -0
  52. package/template/core/common.core.js +93 -0
  53. package/template/core/cron.core.js +141 -0
  54. package/template/core/database.core.js +113 -0
  55. package/template/core/error.core.js +83 -0
  56. package/template/core/express.core.js +161 -0
  57. package/template/core/hooks.core.js +47 -0
  58. package/template/core/jwt.core.js +81 -0
  59. package/template/core/logger.core.js +100 -0
  60. package/template/core/mailer.core.js +65 -0
  61. package/template/core/queue.core.js +226 -0
  62. package/template/core/redis.core.js +75 -0
  63. package/template/core/register.core.js +91 -0
  64. package/template/core/runtime.core.js +27 -0
  65. package/template/core/socket.core.js +93 -0
  66. package/template/core/validator.core.js +34 -0
  67. package/template/database/.gitkeep +0 -0
  68. package/template/database/models/post.model.js +26 -0
  69. package/template/database/models/user.model.js +30 -0
  70. package/template/docs/Bootstrap.md +50 -0
  71. package/template/docs/Common.md +48 -0
  72. package/template/docs/Cron.md +47 -0
  73. package/template/docs/Database.md +61 -0
  74. package/template/docs/Express.md +63 -0
  75. package/template/docs/Hooks.md +48 -0
  76. package/template/docs/Jwt.md +63 -0
  77. package/template/docs/Logger.md +60 -0
  78. package/template/docs/Mailer.md +50 -0
  79. package/template/docs/Queue.md +57 -0
  80. package/template/docs/README.md +32 -0
  81. package/template/docs/Redis.md +54 -0
  82. package/template/docs/Register.md +52 -0
  83. package/template/docs/Socket.md +55 -0
  84. package/template/docs/Validator.md +45 -0
  85. package/template/ecosystem.config.js +54 -0
  86. package/template/index.js +6 -0
  87. package/template/jest.config.js +27 -0
  88. package/template/jsconfig.json +37 -0
  89. package/template/logs/.gitkeep +0 -0
  90. package/template/nodemon.json +23 -0
  91. package/template/package-lock.json +7033 -0
  92. package/template/package.json +82 -0
  93. package/template/scripts/cli.js +258 -0
  94. package/template/storage/.gitkeep +0 -0
  95. package/template/tests/common.test.js +45 -0
  96. package/template/tests/crypt-hash-storage.test.js +44 -0
  97. package/template/tests/jwt.test.js +45 -0
  98. package/template/tests/register.test.js +55 -0
  99. package/template/tests/setup.js +11 -0
  100. 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