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,75 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const IORedis = require('ioredis')
|
|
4
|
+
|
|
5
|
+
const config = require('@config/index')
|
|
6
|
+
const logger = require('@core/logger.core')
|
|
7
|
+
|
|
8
|
+
// Standalone Redis connection (ioredis). A client is only created when
|
|
9
|
+
// REDIS_ENABLED=true; every consumer (cache/queue/socket/session) must gate its
|
|
10
|
+
// usage with isEnabledFor(feature) and provide an in-memory/default fallback, so
|
|
11
|
+
// the application runs unchanged whether or not Redis is present.
|
|
12
|
+
class Redis {
|
|
13
|
+
static _client = null
|
|
14
|
+
|
|
15
|
+
static async connect() {
|
|
16
|
+
if (!config.redis.enabled) {
|
|
17
|
+
logger.info('Redis is disabled (REDIS_ENABLED=false)')
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
if (Redis._client) return Redis._client
|
|
21
|
+
|
|
22
|
+
const r = config.redis
|
|
23
|
+
|
|
24
|
+
Redis._client = new IORedis({
|
|
25
|
+
host: r.host,
|
|
26
|
+
port: r.port,
|
|
27
|
+
username: r.username || undefined,
|
|
28
|
+
password: r.password || undefined,
|
|
29
|
+
db: r.db,
|
|
30
|
+
keyPrefix: r.keyPrefix || undefined,
|
|
31
|
+
maxRetriesPerRequest: r.maxRetriesPerRequest,
|
|
32
|
+
connectTimeout: r.connectTimeout,
|
|
33
|
+
tls: r.tls ? {} : undefined,
|
|
34
|
+
lazyConnect: true,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Without a listener, a runtime connection drop would emit an unhandled
|
|
38
|
+
// 'error' and crash the process.
|
|
39
|
+
Redis._client.on('error', (err) => logger.error('Redis client error', err))
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await Redis._client.connect()
|
|
43
|
+
await Redis._client.ping()
|
|
44
|
+
logger.info(`Redis connected (${r.host}:${r.port}/${r.db})`)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
logger.error('Redis connection failed', err)
|
|
47
|
+
throw err
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return Redis._client
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Return the live client. Throws when Redis is disabled or not yet connected —
|
|
54
|
+
// callers should guard with isEnabledFor() first.
|
|
55
|
+
static client() {
|
|
56
|
+
if (!config.redis.enabled) throw new Error('Redis is disabled (REDIS_ENABLED=false)')
|
|
57
|
+
if (!Redis._client) throw new Error('Redis is not connected; call Redis.connect() first')
|
|
58
|
+
return Redis._client
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// True when Redis is enabled AND the given feature opts in (use.<feature>).
|
|
62
|
+
static isEnabledFor(feature) {
|
|
63
|
+
return Boolean(config.redis.enabled && config.redis.use[feature])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static async disconnect() {
|
|
67
|
+
if (Redis._client) {
|
|
68
|
+
await Redis._client.quit().catch(() => {})
|
|
69
|
+
Redis._client = null
|
|
70
|
+
logger.info('Redis connection closed')
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = Redis
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const logger = require('@core/logger.core')
|
|
7
|
+
|
|
8
|
+
// Safe bridge loader for application `register.*.js` entry points.
|
|
9
|
+
//
|
|
10
|
+
// The core layer never imports application code statically. Each core instead
|
|
11
|
+
// loads a single, well-known register file at runtime (Inversion of Control), so
|
|
12
|
+
// the dependency arrow stays one-directional: app -> core -> config -> .env.
|
|
13
|
+
//
|
|
14
|
+
// cron.core -> app/jobs/register.job.js module.exports = (Cron) => {}
|
|
15
|
+
// queue.core -> app/queue/register.queue.js module.exports = (Queue) => {}
|
|
16
|
+
// express.core -> app/http/middlewares/register.middleware.js (app) => {}
|
|
17
|
+
// express.core -> app/routes/register.route.js side-effect, exports Routes
|
|
18
|
+
// socket.core -> app/socket/register.socket.js (io, Socket) => {}
|
|
19
|
+
// hooks.core -> app/hooks/register.hook.js { before, after, shutdown }
|
|
20
|
+
//
|
|
21
|
+
// This loader centralises the contract: resolve under the project root, tolerate
|
|
22
|
+
// a missing optional file, isolate require()/execution failures, and validate the
|
|
23
|
+
// exported shape so a malformed register fails loudly with a clear message rather
|
|
24
|
+
// than a cryptic stack deep inside a core.
|
|
25
|
+
class Register {
|
|
26
|
+
// Resolve a path fragment against the project root (process.cwd()).
|
|
27
|
+
static path(...segments) {
|
|
28
|
+
return path.resolve(process.cwd(), ...segments)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Require a register module and unwrap an ESM-style default export when present.
|
|
32
|
+
// Returns null when the file is absent and `optional` is true (the default).
|
|
33
|
+
static require(file, { optional = true, label } = {}) {
|
|
34
|
+
const name = label || file
|
|
35
|
+
|
|
36
|
+
if (!fs.existsSync(file)) {
|
|
37
|
+
if (optional) {
|
|
38
|
+
logger.warn(`Register not found, skipping: ${name}`)
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Required register file not found: ${name}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const mod = require(file)
|
|
46
|
+
return mod && mod.default !== undefined ? mod.default : mod
|
|
47
|
+
} catch (err) {
|
|
48
|
+
logger.error(`Failed to load register: ${name}`, err)
|
|
49
|
+
throw err
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Load and invoke a callable register (a function, or an object exposing
|
|
54
|
+
// register()). `args` are forwarded to the register (the core facade). Returns
|
|
55
|
+
// the register's return value, or null when the file is absent.
|
|
56
|
+
static async invoke(file, args = [], { optional = true, label } = {}) {
|
|
57
|
+
const name = label || file
|
|
58
|
+
const mod = Register.require(file, { optional, label: name })
|
|
59
|
+
if (mod == null) return null
|
|
60
|
+
|
|
61
|
+
const fn = typeof mod === 'function' ? mod : typeof mod.register === 'function' ? mod.register.bind(mod) : null
|
|
62
|
+
if (!fn) {
|
|
63
|
+
throw new Error(`Register "${name}" must export a function or an object with a register() method`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return fn(...args)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Load a register that must export a plain object (e.g. lifecycle hooks).
|
|
70
|
+
// Validates that every provided key in `allowed` is a function. Returns the
|
|
71
|
+
// object, or null when the file is absent.
|
|
72
|
+
static object(file, allowed = [], { optional = true, label } = {}) {
|
|
73
|
+
const name = label || file
|
|
74
|
+
const mod = Register.require(file, { optional, label: name })
|
|
75
|
+
if (mod == null) return null
|
|
76
|
+
|
|
77
|
+
if (typeof mod !== 'object') {
|
|
78
|
+
throw new Error(`Register "${name}" must export an object`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const key of allowed) {
|
|
82
|
+
if (mod[key] !== undefined && typeof mod[key] !== 'function') {
|
|
83
|
+
throw new Error(`Register "${name}" key "${key}" must be a function`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return mod
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = Register
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const config = require('@config/index')
|
|
4
|
+
|
|
5
|
+
function bootRuntime() {
|
|
6
|
+
const { app, runtime } = config
|
|
7
|
+
|
|
8
|
+
process.env.TZ = app.timezone
|
|
9
|
+
process.env.NODE_ENV = app.env
|
|
10
|
+
process.env.UV_THREADPOOL_SIZE = String(runtime.uvThreadpoolSize)
|
|
11
|
+
|
|
12
|
+
process.setMaxListeners(runtime.maxListeners)
|
|
13
|
+
Error.stackTraceLimit = runtime.stackTraceLimit
|
|
14
|
+
|
|
15
|
+
if (!runtime.deprecationWarnings) {
|
|
16
|
+
process.removeAllListeners('warning')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (runtime.bigintJson) {
|
|
20
|
+
// eslint-disable-next-line no-extend-native
|
|
21
|
+
BigInt.prototype.toJSON = function () {
|
|
22
|
+
return this.toString()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
bootRuntime()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Server } = require('socket.io')
|
|
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', 'socket', 'register.socket.js')
|
|
10
|
+
|
|
11
|
+
// Socket.IO attached to the Express http.Server (single port). Connection handlers
|
|
12
|
+
// live in app/socket/register.socket.js as (io, Socket) => {}.
|
|
13
|
+
class Socket {
|
|
14
|
+
static io = null
|
|
15
|
+
|
|
16
|
+
static async attach(httpServer) {
|
|
17
|
+
if (!config.socket.enabled) {
|
|
18
|
+
logger.info('Socket is disabled (SOCKET_ENABLED=false)')
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
if (Socket.io) return Socket.io
|
|
22
|
+
|
|
23
|
+
Socket.io = new Server(httpServer, {
|
|
24
|
+
path: config.socket.path,
|
|
25
|
+
serveClient: config.socket.serveClient,
|
|
26
|
+
connectTimeout: config.socket.connectTimeout,
|
|
27
|
+
pingInterval: config.socket.pingInterval,
|
|
28
|
+
pingTimeout: config.socket.pingTimeout,
|
|
29
|
+
transports: config.socket.transports,
|
|
30
|
+
allowUpgrades: config.socket.allowUpgrades,
|
|
31
|
+
maxHttpBufferSize: config.socket.maxHttpBufferSize,
|
|
32
|
+
cors: config.cors.socket.cors,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
await Socket._applyRedisAdapter()
|
|
36
|
+
|
|
37
|
+
Socket.io.on('connection', (socket) => {
|
|
38
|
+
logger.debug(`Socket connected: ${socket.id}`)
|
|
39
|
+
socket.on('disconnect', (reason) => logger.debug(`Socket disconnected: ${socket.id} (${reason})`))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
await Socket._loadHandlers()
|
|
43
|
+
|
|
44
|
+
logger.info(`Socket.IO attached (path ${config.socket.path})`)
|
|
45
|
+
return Socket.io
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Multi-instance fan-out via Redis. Opt-in (REDIS_USE_SOCKET=true) and only when
|
|
49
|
+
// the optional adapter package is installed; otherwise the default in-memory
|
|
50
|
+
// adapter is used.
|
|
51
|
+
static async _applyRedisAdapter() {
|
|
52
|
+
const Redis = require('@core/redis.core')
|
|
53
|
+
if (!Redis.isEnabledFor('socket')) return
|
|
54
|
+
|
|
55
|
+
let createAdapter
|
|
56
|
+
try {
|
|
57
|
+
;({ createAdapter } = require('@socket.io/redis-adapter'))
|
|
58
|
+
} catch {
|
|
59
|
+
logger.warn('REDIS_USE_SOCKET=true but @socket.io/redis-adapter is not installed; using default adapter')
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const pub = Redis.client()
|
|
64
|
+
const sub = pub.duplicate()
|
|
65
|
+
Socket.io.adapter(createAdapter(pub, sub))
|
|
66
|
+
logger.info('Socket.IO using Redis adapter')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static async _loadHandlers() {
|
|
70
|
+
await Register.invoke(REGISTER_FILE, [Socket.io, Socket], { label: 'app/socket/register.socket.js' })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static broadcast(event, payload) {
|
|
74
|
+
if (Socket.io) Socket.io.emit(event, payload)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static toRoom(room, event, payload) {
|
|
78
|
+
if (Socket.io) Socket.io.to(room).emit(event, payload)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static close() {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
if (!Socket.io) return resolve()
|
|
84
|
+
Socket.io.close(() => {
|
|
85
|
+
Socket.io = null
|
|
86
|
+
logger.info('Socket.IO closed')
|
|
87
|
+
resolve()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = Socket
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Adapt a Zod schema into express-routing middleware. Used per-route as:
|
|
4
|
+
// Routes.post('auth/login', AuthController.login, [Validator.make(loginSchema)])
|
|
5
|
+
//
|
|
6
|
+
// On failure it short-circuits with a 422 using the manual response envelope plus
|
|
7
|
+
// an `errors` array. On success the parsed value is stored on req.validated[source];
|
|
8
|
+
// for the body source it also replaces req.body (req.query/req.params are read-only
|
|
9
|
+
// in Express 5 and must not be reassigned).
|
|
10
|
+
class Validator {
|
|
11
|
+
static make(schema, source = 'body') {
|
|
12
|
+
return {
|
|
13
|
+
handle({ req, res, next }) {
|
|
14
|
+
const result = schema.safeParse(req[source])
|
|
15
|
+
|
|
16
|
+
if (!result.success) {
|
|
17
|
+
const errors = result.error.issues.map((issue) => ({
|
|
18
|
+
field: issue.path.join('.'),
|
|
19
|
+
message: issue.message,
|
|
20
|
+
}))
|
|
21
|
+
return res.status(422).json({ status: false, code: 422, message: 'Validation failed', data: null, meta: null, errors })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
req.validated = req.validated || {}
|
|
25
|
+
req.validated[source] = result.data
|
|
26
|
+
if (source === 'body') req.body = result.data
|
|
27
|
+
|
|
28
|
+
next()
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = Validator
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Sample model demonstrating an association (belongsTo User). Remove these sample
|
|
4
|
+
// files when you start modelling your own domain.
|
|
5
|
+
module.exports = (sequelize, DataTypes) => {
|
|
6
|
+
const Post = sequelize.define(
|
|
7
|
+
'Post',
|
|
8
|
+
{
|
|
9
|
+
id: { type: DataTypes.BIGINT.UNSIGNED, primaryKey: true, autoIncrement: true },
|
|
10
|
+
user_id: { type: DataTypes.BIGINT.UNSIGNED, allowNull: false },
|
|
11
|
+
title: { type: DataTypes.STRING(200), allowNull: false },
|
|
12
|
+
body: { type: DataTypes.TEXT, allowNull: true },
|
|
13
|
+
published_at: { type: DataTypes.DATE, allowNull: true },
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
tableName: 'posts',
|
|
17
|
+
underscored: true,
|
|
18
|
+
},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
Post.associate = (models) => {
|
|
22
|
+
if (models.User) Post.belongsTo(models.User, { foreignKey: 'user_id', as: 'author' })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return Post
|
|
26
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Sample Sequelize model. database.core loads every file in database/models/*.js
|
|
4
|
+
// when DB_ENABLED=true (files prefixed with "_" are ignored). The factory receives
|
|
5
|
+
// (sequelize, DataTypes) and returns the model; associations are wired in a second
|
|
6
|
+
// pass via the static associate(models).
|
|
7
|
+
module.exports = (sequelize, DataTypes) => {
|
|
8
|
+
const User = sequelize.define(
|
|
9
|
+
'User',
|
|
10
|
+
{
|
|
11
|
+
id: { type: DataTypes.BIGINT.UNSIGNED, primaryKey: true, autoIncrement: true },
|
|
12
|
+
name: { type: DataTypes.STRING(100), allowNull: false },
|
|
13
|
+
email: { type: DataTypes.STRING(190), allowNull: false, unique: true, validate: { isEmail: true } },
|
|
14
|
+
password: { type: DataTypes.STRING, allowNull: false },
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
tableName: 'users',
|
|
18
|
+
underscored: true,
|
|
19
|
+
// The password is excluded by default; use the withPassword scope for auth.
|
|
20
|
+
defaultScope: { attributes: { exclude: ['password'] } },
|
|
21
|
+
scopes: { withPassword: { attributes: {} } },
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
User.associate = (models) => {
|
|
26
|
+
if (models.Post) User.hasMany(models.Post, { foreignKey: 'user_id', as: 'posts' })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return User
|
|
30
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Bootstrap API
|
|
2
|
+
|
|
3
|
+
`@core/bootstrap.core` is the boot orchestrator. It wires every core in a
|
|
4
|
+
deterministic order and registers graceful shutdown. It is the only core called
|
|
5
|
+
directly from `index.js`.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
// index.js
|
|
9
|
+
require('dotenv').config()
|
|
10
|
+
require('module-alias/register')
|
|
11
|
+
require('@core/runtime.core')
|
|
12
|
+
require('@core/bootstrap.core').run()
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Method
|
|
16
|
+
|
|
17
|
+
`run()` — async. Performs:
|
|
18
|
+
|
|
19
|
+
1. `ErrorHandler.setExceptionHandler()` / `setRejectionHandler()`
|
|
20
|
+
2. `Hooks.before({ config })`
|
|
21
|
+
3. `Redis.connect()` — only if `REDIS_ENABLED=true`
|
|
22
|
+
4. `Database.connect()` — only if `DB_ENABLED=true`
|
|
23
|
+
5. `Express.create()` → `{ app, server }`
|
|
24
|
+
6. `Socket.attach(server)`
|
|
25
|
+
7. `Cron.start()` + `Queue.start()`
|
|
26
|
+
8. `Express.listen()`
|
|
27
|
+
9. `Hooks.after({ config })`
|
|
28
|
+
10. registers SIGINT/SIGTERM handlers
|
|
29
|
+
|
|
30
|
+
If any startup step throws, the error is logged and the process exits with code 1.
|
|
31
|
+
|
|
32
|
+
## Graceful shutdown
|
|
33
|
+
|
|
34
|
+
On SIGINT/SIGTERM (once), in order, each step isolated so one failure does not block
|
|
35
|
+
the rest:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
Hooks.shutdown -> Cron.stop -> Queue.stop -> Socket.close -> Express.close
|
|
39
|
+
-> Database.disconnect (if enabled) -> Redis.disconnect (if enabled) -> exit(0)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Optional subsystems (Redis, Database) are required lazily, so a project that never
|
|
43
|
+
enables them does not load them.
|
|
44
|
+
|
|
45
|
+
## Notes
|
|
46
|
+
|
|
47
|
+
- The server is created before `listen()` so the socket can attach to the same
|
|
48
|
+
`http.Server` (shared port).
|
|
49
|
+
- `Queue.stop()` drains in-process jobs best-effort (default 10s). PM2's
|
|
50
|
+
`kill_timeout` (15s) gives it headroom — see `ecosystem.config.js`.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Common API
|
|
2
|
+
|
|
3
|
+
`@core/common.core` is the facade over shared utilities and the environment readers.
|
|
4
|
+
It is the only place besides `config/*` that reads `process.env`.
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
const Common = require('@core/common.core')
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Environment readers
|
|
11
|
+
|
|
12
|
+
| Method | Returns |
|
|
13
|
+
| ----------------------------------- | -------------------------------------------- |
|
|
14
|
+
| `getEnv(key, def=null)` | string |
|
|
15
|
+
| `getEnvInt(key, def=null)` | integer |
|
|
16
|
+
| `getEnvFloat(key, def=null)` | float |
|
|
17
|
+
| `getEnvBool(key, def=null)` | boolean (`true/1/yes/on/y/t/enable/enabled`) |
|
|
18
|
+
| `getEnvArray(key, def=[], sep=',')` | trimmed, non-empty array |
|
|
19
|
+
| `getEnvJson(key, def=null)` | parsed JSON or the default |
|
|
20
|
+
|
|
21
|
+
These are used inside `config/*.config.js`. Application and core code should read
|
|
22
|
+
values from `config`, not call these directly.
|
|
23
|
+
|
|
24
|
+
## Generic helpers
|
|
25
|
+
|
|
26
|
+
| Method | Notes |
|
|
27
|
+
| ---------------- | -------------------------------------------------- |
|
|
28
|
+
| `sleep(ms)` | promise that resolves after `ms` |
|
|
29
|
+
| `isEmpty(value)` | true for null/undefined, empty string/array/object |
|
|
30
|
+
| `attempt(fn)` | `Promise<[err, result]>` — wraps try/catch |
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
const [err, result] = await Common.attempt(() => risky())
|
|
34
|
+
if (err) logger.warn(err)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Utility namespaces
|
|
38
|
+
|
|
39
|
+
Pure utilities (no dependency on other cores), exposed as `Common.<Namespace>`:
|
|
40
|
+
|
|
41
|
+
`Arr`, `Str`, `Obj`, `Url`, `Path`, `Hash`, `Crypt`, `Collection`, `Date`, `Cache`,
|
|
42
|
+
`Storage` — implemented under `core/common/*`.
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
Common.Str.slug('Hello World')
|
|
46
|
+
Common.Arr.chunk([1, 2, 3, 4], 2)
|
|
47
|
+
Common.Cache.remember('key', 60, () => expensive())
|
|
48
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Cron API
|
|
2
|
+
|
|
3
|
+
`@core/cron.core` schedules recurring jobs with `node-cron`. Jobs are declared in
|
|
4
|
+
`app/jobs/register.job.js`, which the core loads during boot.
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
const Cron = require('@core/cron.core')
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Methods
|
|
11
|
+
|
|
12
|
+
| Method | Notes |
|
|
13
|
+
| --------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
14
|
+
| `define(name, expression, handler, options?)` | validates the cron expression; `options.timezone`, `options.runOnInit` |
|
|
15
|
+
| `start()` | loads the register file, optionally prepares the history table, schedules all jobs |
|
|
16
|
+
| `runNow(name)` | run a job immediately |
|
|
17
|
+
| `remove(name)` | stop and unregister a job |
|
|
18
|
+
| `stop()` | stop all jobs |
|
|
19
|
+
| `list()` | `[{ name, expression, timezone }]` |
|
|
20
|
+
|
|
21
|
+
Expression format (node-cron): `second? minute hour day-of-month month day-of-week`.
|
|
22
|
+
|
|
23
|
+
## Declaring jobs
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
// app/jobs/register.job.js
|
|
27
|
+
module.exports = (Cron) => {
|
|
28
|
+
Cron.define('cleanup-temp', '0 3 * * *', async () => {
|
|
29
|
+
// runs daily at 03:00
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Each run is wrapped in a guard, so one failure is logged and does not stop the
|
|
35
|
+
schedule.
|
|
36
|
+
|
|
37
|
+
## Execution history (optional)
|
|
38
|
+
|
|
39
|
+
When `CRON_HISTORY=true` and the database is enabled, the core auto-creates a
|
|
40
|
+
`cron_runs` table and records each run (`running`/`completed`/`failed`) with duration.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
`CRON_ENABLED` (`true`), `CRON_TIMEZONE` (`Asia/Jakarta`), `CRON_HISTORY` (`true`).
|
|
45
|
+
|
|
46
|
+
> Single-instance only: with PM2 cluster mode every instance would fire the same
|
|
47
|
+
> schedule. See `ecosystem.config.js`.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Database API
|
|
2
|
+
|
|
3
|
+
`@core/database.core` manages the Sequelize connection and model loading. It is
|
|
4
|
+
skipped entirely when `DB_ENABLED=false`. Model/migration generators are
|
|
5
|
+
intentionally out of scope for this framework.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
const Database = require('@core/database.core')
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Methods
|
|
12
|
+
|
|
13
|
+
| Method | Returns | Notes |
|
|
14
|
+
| -------------- | --------- | ----------------------------------------------------- |
|
|
15
|
+
| `connect()` | Sequelize | idempotent; `authenticate()` then `loadModels()` |
|
|
16
|
+
| `disconnect()` | Promise | closes the pool, clears models |
|
|
17
|
+
| `loadModels()` | `Map` | loads `database/models/*.js`, then wires associations |
|
|
18
|
+
| `model(name)` | Model | throws if the model is not registered |
|
|
19
|
+
|
|
20
|
+
Static re-exports: `Database.Sequelize`, `Database.DataTypes`, `Database.Model`,
|
|
21
|
+
`Database.Op`.
|
|
22
|
+
|
|
23
|
+
## Defining a model
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
// database/models/user.model.js
|
|
27
|
+
'use strict'
|
|
28
|
+
module.exports = (sequelize, DataTypes) => {
|
|
29
|
+
const User = sequelize.define(
|
|
30
|
+
'User',
|
|
31
|
+
{
|
|
32
|
+
email: { type: DataTypes.STRING, unique: true, allowNull: false },
|
|
33
|
+
password: { type: DataTypes.STRING, allowNull: false },
|
|
34
|
+
},
|
|
35
|
+
{ tableName: 'users', underscored: true },
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
User.associate = (models) => {
|
|
39
|
+
// User.hasMany(models.Post)
|
|
40
|
+
}
|
|
41
|
+
return User
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Files starting with `_` are skipped. Associations run in a second pass after all
|
|
46
|
+
models load, so order does not matter.
|
|
47
|
+
|
|
48
|
+
## Using a model
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
const Database = require('@core/database.core')
|
|
52
|
+
const User = Database.model('User')
|
|
53
|
+
const user = await User.findOne({ where: { email } })
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
`DB_ENABLED` is the master gate. Dialect, host/port, credentials, pool, retry, and
|
|
59
|
+
`define.*` options come from `config.db` — see `.env.example` for every variable.
|
|
60
|
+
|
|
61
|
+
> Never run `sync({ force })` or `fresh()` with `APP_ENV=production`.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Express API
|
|
2
|
+
|
|
3
|
+
`@core/express.core` is the HTTP backbone. It builds the Express app and an
|
|
4
|
+
`http.Server` separately from listening, so `socket.core` can attach to the same
|
|
5
|
+
server before the port opens.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
const Express = require('@core/express.core')
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Methods
|
|
12
|
+
|
|
13
|
+
| Method | Returns | Notes |
|
|
14
|
+
| ---------------- | -------------------------- | ----------------------------------------------------------- |
|
|
15
|
+
| `create()` | `Promise<{ app, server }>` | idempotent; builds the app + server, not listening yet |
|
|
16
|
+
| `listen()` | `Promise<server>` | listens on `APP_PORT`; rejects if `create()` was not called |
|
|
17
|
+
| `close()` | Promise | stops the server |
|
|
18
|
+
| `upload(field?)` | multer middleware/instance | `single(field)` when a field name is given |
|
|
19
|
+
|
|
20
|
+
These are wired by `bootstrap.core`; `upload()` is the one you call from routes.
|
|
21
|
+
|
|
22
|
+
## Middleware order (built by `create()`)
|
|
23
|
+
|
|
24
|
+
1. `helmet(config.express.helmet)`
|
|
25
|
+
2. `config.cors.express`
|
|
26
|
+
3. `compression()`
|
|
27
|
+
4. `express.json` / `express.urlencoded` (limit = `EXPRESS_BODY_LIMIT`)
|
|
28
|
+
5. rate limit (when `EXPRESS_RATE_LIMIT_ENABLED`)
|
|
29
|
+
6. user middleware — `app/http/middlewares/register.middleware.js` `(app) => {}`
|
|
30
|
+
7. static — `./public` at `/`, uploads at `/uploads`
|
|
31
|
+
8. routes — `@routes/register.route` then `Routes.apply`
|
|
32
|
+
9. `error.core` 404 + error handlers
|
|
33
|
+
|
|
34
|
+
`x-powered-by` is disabled. The full `storage/` root is **not** served — only
|
|
35
|
+
`/uploads` and `./public`.
|
|
36
|
+
|
|
37
|
+
## File uploads
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
const Express = require('@core/express.core')
|
|
41
|
+
|
|
42
|
+
// single file under field "avatar"
|
|
43
|
+
Routes.post('upload', UploadController.store, [Express.upload('avatar')])
|
|
44
|
+
|
|
45
|
+
// in the controller, req.file holds the stored file
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Uploads are constrained by `STORAGE_UPLOAD_MAX_SIZE_MB` and
|
|
49
|
+
`STORAGE_UPLOAD_ALLOWED_TYPES`. Stored filenames are randomized to avoid path
|
|
50
|
+
traversal and collisions.
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
`EXPRESS_BODY_LIMIT`, `EXPRESS_TRUST_PROXY`, `EXPRESS_RATE_LIMIT_*`, plus the helmet
|
|
55
|
+
block in `express.config.js`, CORS from `cors.config.js`, and storage from
|
|
56
|
+
`storage.config.js`. See `.env.example` for every variable and [SECURITY.md](../SECURITY.md).
|
|
57
|
+
|
|
58
|
+
## Notes
|
|
59
|
+
|
|
60
|
+
- Behind a proxy/load balancer, set `EXPRESS_TRUST_PROXY=1` so client IPs and the
|
|
61
|
+
rate limiter work correctly.
|
|
62
|
+
- Uploaded files are served with `X-Content-Type-Options: nosniff`. SVG and other
|
|
63
|
+
active content can still carry script — see SECURITY.md before allowing them.
|