odac 1.0.1 → 1.2.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/.agent/rules/coding.md +27 -0
- package/.agent/rules/memory.md +33 -0
- package/.agent/rules/project.md +30 -0
- package/.agent/rules/workflow.md +16 -0
- package/.github/workflows/auto-pr-description.yml +3 -1
- package/.github/workflows/release.yml +42 -1
- package/.github/workflows/test-coverage.yml +6 -5
- package/.github/workflows/test-publish.yml +36 -0
- package/.husky/pre-commit +10 -0
- package/.husky/pre-push +13 -0
- package/.releaserc.js +3 -3
- package/CHANGELOG.md +184 -0
- package/README.md +53 -34
- package/bin/odac.js +181 -49
- package/client/odac.js +878 -995
- package/docs/backend/01-overview/03-development-server.md +39 -46
- package/docs/backend/02-structure/01-typical-project-layout.md +59 -25
- package/docs/backend/03-config/00-configuration-overview.md +15 -6
- package/docs/backend/03-config/01-database-connection.md +3 -3
- package/docs/backend/03-config/02-static-route-mapping-optional.md +1 -1
- package/docs/backend/03-config/03-request-timeout.md +1 -1
- package/docs/backend/03-config/04-environment-variables.md +4 -4
- package/docs/backend/03-config/05-early-hints.md +2 -2
- package/docs/backend/04-routing/02-controller-less-view-routes.md +9 -3
- package/docs/backend/04-routing/03-api-and-data-routes.md +18 -0
- package/docs/backend/04-routing/07-cron-jobs.md +17 -1
- package/docs/backend/04-routing/09-websocket.md +29 -0
- package/docs/backend/05-controllers/01-how-to-build-a-controller.md +48 -3
- package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +2 -0
- package/docs/backend/05-controllers/03-controller-classes.md +61 -55
- package/docs/backend/05-forms/01-custom-forms.md +103 -95
- package/docs/backend/05-forms/02-automatic-database-insert.md +21 -21
- package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +17 -0
- package/docs/backend/07-views/02-rendering-a-view.md +1 -1
- package/docs/backend/07-views/03-variables.md +5 -5
- package/docs/backend/07-views/04-request-data.md +1 -1
- package/docs/backend/07-views/08-backend-javascript.md +1 -1
- package/docs/backend/07-views/10-styling-and-tailwind.md +93 -0
- package/docs/backend/08-database/01-getting-started.md +100 -0
- package/docs/backend/08-database/02-basics.md +136 -0
- package/docs/backend/08-database/03-advanced.md +84 -0
- package/docs/backend/08-database/04-migrations.md +48 -0
- package/docs/backend/09-validation/01-the-validator-service.md +1 -0
- package/docs/backend/10-authentication/03-register.md +9 -2
- package/docs/backend/10-authentication/04-odac-register-forms.md +48 -48
- package/docs/backend/10-authentication/05-session-management.md +16 -2
- package/docs/backend/10-authentication/06-odac-login-forms.md +50 -50
- package/docs/backend/10-authentication/07-magic-links.md +134 -0
- package/docs/backend/11-mail/01-the-mail-service.md +118 -28
- package/docs/backend/12-streaming/01-streaming-overview.md +2 -2
- package/docs/backend/13-utilities/01-odac-var.md +7 -7
- package/docs/backend/13-utilities/02-ipc.md +73 -0
- package/docs/frontend/01-overview/01-introduction.md +5 -1
- package/docs/frontend/02-ajax-navigation/01-quick-start.md +1 -1
- package/docs/index.json +21 -125
- package/eslint.config.mjs +5 -47
- package/jest.config.js +1 -1
- package/package.json +16 -7
- package/src/Auth.js +414 -121
- package/src/Config.js +12 -7
- package/src/Database.js +188 -0
- package/src/Env.js +3 -1
- package/src/Ipc.js +337 -0
- package/src/Lang.js +9 -2
- package/src/Mail.js +408 -37
- package/src/Odac.js +105 -40
- package/src/Request.js +71 -49
- package/src/Route/Cron.js +62 -18
- package/src/Route/Internal.js +215 -12
- package/src/Route/Middleware.js +7 -2
- package/src/Route.js +372 -109
- package/src/Server.js +118 -12
- package/src/Storage.js +169 -0
- package/src/Token.js +6 -4
- package/src/Validator.js +95 -3
- package/src/Var.js +22 -6
- package/src/View/EarlyHints.js +43 -33
- package/src/View/Form.js +210 -28
- package/src/View.js +108 -7
- package/src/WebSocket.js +18 -3
- package/template/odac.json +5 -0
- package/template/package.json +3 -1
- package/template/route/www.js +12 -10
- package/template/view/content/home.html +3 -3
- package/template/view/head/main.html +2 -2
- package/test/Client.test.js +168 -0
- package/test/Config.test.js +112 -0
- package/test/Lang.test.js +92 -0
- package/test/Odac.test.js +86 -0
- package/test/{framework/middleware.test.js → Route/Middleware.test.js} +2 -2
- package/test/{framework/Route.test.js → Route.test.js} +1 -1
- package/test/{framework/View → View}/EarlyHints.test.js +1 -1
- package/test/{framework/WebSocket.test.js → WebSocket.test.js} +2 -2
- package/test/scripts/check-coverage.js +4 -4
- package/docs/backend/08-database/01-database-connection.md +0 -99
- package/docs/backend/08-database/02-using-mysql.md +0 -322
- package/src/Mysql.js +0 -575
- package/template/config.json +0 -5
- package/test/cli/Cli.test.js +0 -36
- package/test/core/Candy.test.js +0 -234
- package/test/core/Commands.test.js +0 -538
- package/test/core/Config.test.js +0 -1432
- package/test/core/Lang.test.js +0 -250
- package/test/core/Process.test.js +0 -156
- package/test/server/Api.test.js +0 -647
- package/test/server/DNS.test.js +0 -2050
- package/test/server/DNS.test.js.bak +0 -2084
- package/test/server/Hub.test.js +0 -497
- package/test/server/Log.test.js +0 -73
- package/test/server/Mail.account.test_.js +0 -460
- package/test/server/Mail.init.test_.js +0 -411
- package/test/server/Mail.test_.js +0 -1340
- package/test/server/SSL.test_.js +0 -1491
- package/test/server/Server.test.js +0 -765
- package/test/server/Service.test_.js +0 -1127
- package/test/server/Subdomain.test.js +0 -440
- package/test/server/Web/Firewall.test.js +0 -175
- package/test/server/Web/Proxy.test.js +0 -397
- package/test/server/Web.test.js +0 -1494
- package/test/server/__mocks__/acme-client.js +0 -17
- package/test/server/__mocks__/bcrypt.js +0 -50
- package/test/server/__mocks__/child_process.js +0 -389
- package/test/server/__mocks__/crypto.js +0 -432
- package/test/server/__mocks__/fs.js +0 -450
- package/test/server/__mocks__/globalOdac.js +0 -227
- package/test/server/__mocks__/http.js +0 -575
- package/test/server/__mocks__/https.js +0 -272
- package/test/server/__mocks__/index.js +0 -249
- package/test/server/__mocks__/mail/server.js +0 -100
- package/test/server/__mocks__/mail/smtp.js +0 -31
- package/test/server/__mocks__/mailparser.js +0 -81
- package/test/server/__mocks__/net.js +0 -369
- package/test/server/__mocks__/node-forge.js +0 -328
- package/test/server/__mocks__/os.js +0 -320
- package/test/server/__mocks__/path.js +0 -291
- package/test/server/__mocks__/selfsigned.js +0 -8
- package/test/server/__mocks__/server/src/mail/server.js +0 -100
- package/test/server/__mocks__/server/src/mail/smtp.js +0 -31
- package/test/server/__mocks__/smtp-server.js +0 -106
- package/test/server/__mocks__/sqlite3.js +0 -394
- package/test/server/__mocks__/testFactories.js +0 -299
- package/test/server/__mocks__/testHelpers.js +0 -363
- package/test/server/__mocks__/tls.js +0 -229
package/src/Config.js
CHANGED
|
@@ -5,19 +5,24 @@ const os = require('os')
|
|
|
5
5
|
module.exports = {
|
|
6
6
|
auth: {
|
|
7
7
|
key: 'id',
|
|
8
|
-
token: '
|
|
8
|
+
token: 'odac_auth' // This is the TABLE NAME for tokens, not a secret token.
|
|
9
9
|
},
|
|
10
10
|
request: {
|
|
11
11
|
timeout: 10000
|
|
12
12
|
},
|
|
13
13
|
encrypt: {
|
|
14
|
-
key: 'odac'
|
|
14
|
+
key: 'odac' // Default encryption key. MUST be overridden in production.
|
|
15
15
|
},
|
|
16
16
|
earlyHints: {
|
|
17
17
|
enabled: true,
|
|
18
18
|
auto: true,
|
|
19
19
|
maxResources: 5
|
|
20
20
|
},
|
|
21
|
+
ipc: {
|
|
22
|
+
driver: 'memory',
|
|
23
|
+
redis: 'default'
|
|
24
|
+
},
|
|
25
|
+
debug: process.env.NODE_ENV !== 'production',
|
|
21
26
|
|
|
22
27
|
init: function () {
|
|
23
28
|
try {
|
|
@@ -26,17 +31,17 @@ module.exports = {
|
|
|
26
31
|
this.system = {}
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
if (fs.existsSync(__dir + '/
|
|
34
|
+
if (fs.existsSync(__dir + '/odac.json')) {
|
|
30
35
|
let config = {}
|
|
31
36
|
try {
|
|
32
|
-
config = JSON.parse(fs.readFileSync(__dir + '/
|
|
37
|
+
config = JSON.parse(fs.readFileSync(__dir + '/odac.json'))
|
|
33
38
|
config = this._interpolate(config)
|
|
34
39
|
} catch (err) {
|
|
35
|
-
console.error('Error reading config file:', __dir + '/
|
|
40
|
+
console.error('Error reading config file:', __dir + '/odac.json', err.message)
|
|
36
41
|
}
|
|
37
42
|
this._deepMerge(this, config)
|
|
38
43
|
}
|
|
39
|
-
this.encrypt.key = nodeCrypto.createHash('
|
|
44
|
+
this.encrypt.key = nodeCrypto.createHash('sha256').update(this.encrypt.key).digest()
|
|
40
45
|
},
|
|
41
46
|
|
|
42
47
|
_interpolate: function (obj) {
|
|
@@ -44,7 +49,7 @@ module.exports = {
|
|
|
44
49
|
return obj.replace(/\$\{(\w+)\}/g, (_, key) => {
|
|
45
50
|
// Special variables
|
|
46
51
|
if (key === 'odac') {
|
|
47
|
-
return __dirname.replace(/\/
|
|
52
|
+
return __dirname.replace(/\/src$/, '/client')
|
|
48
53
|
}
|
|
49
54
|
// Environment variables
|
|
50
55
|
return process.env[key] || ''
|
package/src/Database.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const knex = require('knex')
|
|
3
|
+
|
|
4
|
+
class DatabaseManager {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.connections = {}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async init() {
|
|
10
|
+
if (!Odac.Config.database) return
|
|
11
|
+
|
|
12
|
+
let multiple = typeof Odac.Config.database[Object.keys(Odac.Config.database)[0]] === 'object'
|
|
13
|
+
let dbs = multiple ? Odac.Config.database : {default: Odac.Config.database}
|
|
14
|
+
|
|
15
|
+
for (let key of Object.keys(dbs)) {
|
|
16
|
+
let db = dbs[key]
|
|
17
|
+
let client = 'mysql2'
|
|
18
|
+
if (db.type === 'postgres' || db.type === 'pg' || db.type === 'postgresql') client = 'pg'
|
|
19
|
+
if (db.type === 'sqlite' || db.type === 'sqlite3') client = 'sqlite3'
|
|
20
|
+
|
|
21
|
+
let connectionConfig = {}
|
|
22
|
+
|
|
23
|
+
if (client === 'sqlite3') {
|
|
24
|
+
connectionConfig = {
|
|
25
|
+
filename: db.filename || db.database || './dev.sqlite3'
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
connectionConfig = {
|
|
29
|
+
host: db.host || '127.0.0.1',
|
|
30
|
+
user: db.user,
|
|
31
|
+
password: db.password,
|
|
32
|
+
database: db.database,
|
|
33
|
+
port: db.port
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.connections[key] = knex({
|
|
38
|
+
client: client,
|
|
39
|
+
connection: connectionConfig,
|
|
40
|
+
pool: {
|
|
41
|
+
min: 0,
|
|
42
|
+
max: db.connectionLimit || 10
|
|
43
|
+
},
|
|
44
|
+
useNullAsDefault: true // For sqlite
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Test connection
|
|
48
|
+
try {
|
|
49
|
+
await this.connections[key].raw('SELECT 1')
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.error(`Odac Database Error: Failed to connect to '${key}' database.`)
|
|
52
|
+
console.error(e.message)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
nanoid(size = 21) {
|
|
58
|
+
const nodeCrypto = require('crypto')
|
|
59
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
60
|
+
let id = ''
|
|
61
|
+
while (id.length < size) {
|
|
62
|
+
const bytes = nodeCrypto.randomBytes(size + 5)
|
|
63
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
64
|
+
const byte = bytes[i] & 63
|
|
65
|
+
if (byte < 62) {
|
|
66
|
+
id += alphabet[byte]
|
|
67
|
+
if (id.length === size) break
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return id
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const manager = new DatabaseManager()
|
|
76
|
+
|
|
77
|
+
const tableProxyHandler = {
|
|
78
|
+
get(knexInstance, prop) {
|
|
79
|
+
// 1. Check for legacy/alias methods
|
|
80
|
+
if (prop === 'run') return knexInstance.raw.bind(knexInstance)
|
|
81
|
+
if (prop === 'table')
|
|
82
|
+
return function (tableName) {
|
|
83
|
+
return knexInstance(tableName)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2. Pass through Knex instance methods (raw, schema, fn, destroy, etc.)
|
|
87
|
+
if (typeof knexInstance[prop] === 'function') {
|
|
88
|
+
return knexInstance[prop].bind(knexInstance)
|
|
89
|
+
}
|
|
90
|
+
if (prop in knexInstance) {
|
|
91
|
+
return knexInstance[prop]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 3. Assume it's a table name and return a Query Builder
|
|
95
|
+
// But we need to be careful not to intercept Promise methods if they are accessed on the instance (though knex instance isn't a promise)
|
|
96
|
+
|
|
97
|
+
// Create the Query Builder
|
|
98
|
+
const qb = knexInstance(prop)
|
|
99
|
+
|
|
100
|
+
// Odac DX Improvement: Wrap count() to return a clean number
|
|
101
|
+
const originalCount = qb.count
|
|
102
|
+
qb.count = function (...args) {
|
|
103
|
+
this._odacIsCount = true
|
|
104
|
+
return originalCount.apply(this, args)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const originalThen = qb.then
|
|
108
|
+
qb.then = function (resolve, reject) {
|
|
109
|
+
if (this._odacIsCount) {
|
|
110
|
+
return originalThen.call(
|
|
111
|
+
this,
|
|
112
|
+
result => {
|
|
113
|
+
// If the result is a single row with a single key, treat it as a scalar count usually
|
|
114
|
+
const isScalar = Array.isArray(result) && result.length === 1 && Object.keys(result[0]).length === 1
|
|
115
|
+
|
|
116
|
+
if (isScalar) {
|
|
117
|
+
const keys = Object.keys(result[0])
|
|
118
|
+
if (keys.length === 1) {
|
|
119
|
+
const val = result[0][keys[0]]
|
|
120
|
+
// Parse string numbers (common in Postgres for count)
|
|
121
|
+
if (val != null && String(val).trim() !== '' && !isNaN(val)) {
|
|
122
|
+
resolve(Number(val))
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
resolve(result)
|
|
128
|
+
},
|
|
129
|
+
reject
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
return originalThen.call(this, resolve, reject)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 4. Extend the Query Builder with ODAC specific methods
|
|
136
|
+
|
|
137
|
+
// .schema(callback) for "Code-First" migrations
|
|
138
|
+
// Usage: await Odac.DB.users.schema(t => { t.string('name') })
|
|
139
|
+
qb.schema = async function (callback) {
|
|
140
|
+
const exists = await knexInstance.schema.hasTable(prop)
|
|
141
|
+
if (!exists) {
|
|
142
|
+
await knexInstance.schema.createTable(prop, callback)
|
|
143
|
+
}
|
|
144
|
+
return this
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return qb
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const rootProxy = new Proxy(manager, {
|
|
152
|
+
get(target, prop) {
|
|
153
|
+
// Access to internal manager methods
|
|
154
|
+
if (prop === 'init') return target.init.bind(target)
|
|
155
|
+
if (prop === 'connections') return target.connections
|
|
156
|
+
|
|
157
|
+
// Access to specific database connection: Odac.DB.analytics
|
|
158
|
+
if (target.connections[prop]) {
|
|
159
|
+
return new Proxy(target.connections[prop], tableProxyHandler)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Direct access to raw/fn/schema/table on default connection
|
|
163
|
+
if (target.connections['default'] && (prop === 'raw' || prop === 'fn' || prop === 'schema' || prop === 'table')) {
|
|
164
|
+
if (prop === 'table')
|
|
165
|
+
return function (tableName) {
|
|
166
|
+
return target.connections['default'](tableName)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const val = target.connections['default'][prop]
|
|
170
|
+
if (typeof val === 'function') {
|
|
171
|
+
return val.bind(target.connections['default'])
|
|
172
|
+
}
|
|
173
|
+
return val
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Expose nanoid helper directly on Odac.DB.nanoid()
|
|
177
|
+
if (prop === 'nanoid') return target.nanoid.bind(target)
|
|
178
|
+
|
|
179
|
+
// Default connection fallback: Odac.DB.users -> default.users
|
|
180
|
+
if (target.connections['default']) {
|
|
181
|
+
return tableProxyHandler.get(target.connections['default'], prop)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
module.exports = rootProxy
|
package/src/Env.js
CHANGED
|
@@ -20,7 +20,9 @@ module.exports = {
|
|
|
20
20
|
// Parse quoted values
|
|
21
21
|
value = this._parseValue(value)
|
|
22
22
|
|
|
23
|
-
process.env[key]
|
|
23
|
+
if (process.env[key] === undefined) {
|
|
24
|
+
process.env[key] = value
|
|
25
|
+
}
|
|
24
26
|
})
|
|
25
27
|
} catch (err) {
|
|
26
28
|
console.error('Error reading .env file:', err.message)
|
package/src/Ipc.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
const cluster = require('node:cluster')
|
|
2
|
+
const {EventEmitter} = require('node:events')
|
|
3
|
+
|
|
4
|
+
class Ipc extends EventEmitter {
|
|
5
|
+
constructor() {
|
|
6
|
+
super()
|
|
7
|
+
this.driver = null
|
|
8
|
+
this.config = {}
|
|
9
|
+
this._requests = new Map() // For memory driver response tracking
|
|
10
|
+
this._subs = new Map() // For memory driver subscriptions
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* ARCHITECTURE NOTE:
|
|
15
|
+
* This module implements a "Primary-Replica" pattern for the 'memory' driver.
|
|
16
|
+
* - The Primary process holds the 'Source of Truth' in local Maps.
|
|
17
|
+
* - Workers communicate via IPC (process.send) to read/write to this central store.
|
|
18
|
+
* This ensures state consistency across the cluster without needing Redis.
|
|
19
|
+
*/
|
|
20
|
+
async init() {
|
|
21
|
+
if (this.initialized) return
|
|
22
|
+
this.initialized = true
|
|
23
|
+
|
|
24
|
+
this.config = Odac.Config.ipc || {driver: 'memory'}
|
|
25
|
+
|
|
26
|
+
// default MaxListeners is 10. If we have thousands of different channels, it's fine.
|
|
27
|
+
// But if we attach many listeners to the "same" channel or event emitter, we might need more.
|
|
28
|
+
// For Ipc (which extends EventEmitter), let's bump it up just in case.
|
|
29
|
+
this.setMaxListeners(0) // Unlimited
|
|
30
|
+
|
|
31
|
+
if (this.config.driver === 'redis') {
|
|
32
|
+
await this._initRedis()
|
|
33
|
+
} else {
|
|
34
|
+
await this._initMemory()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- Public API ---
|
|
39
|
+
|
|
40
|
+
async set(key, value, ttl = 0) {
|
|
41
|
+
if (this.config.driver === 'redis') {
|
|
42
|
+
const args = [key, JSON.stringify(value)]
|
|
43
|
+
if (ttl > 0) args.push('EX', ttl)
|
|
44
|
+
return this.redis.set(...args)
|
|
45
|
+
} else {
|
|
46
|
+
return this._sendMemory('set', {key, value, ttl})
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async get(key) {
|
|
51
|
+
if (this.config.driver === 'redis') {
|
|
52
|
+
const val = await this.redis.get(key)
|
|
53
|
+
return val ? JSON.parse(val) : null
|
|
54
|
+
} else {
|
|
55
|
+
return this._sendMemory('get', {key})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async del(key) {
|
|
60
|
+
if (this.config.driver === 'redis') {
|
|
61
|
+
return this.redis.del(key)
|
|
62
|
+
} else {
|
|
63
|
+
return this._sendMemory('del', {key})
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async publish(channel, message) {
|
|
68
|
+
if (this.config.driver === 'redis') {
|
|
69
|
+
return this.redis.publish(channel, JSON.stringify(message))
|
|
70
|
+
} else {
|
|
71
|
+
return this._sendMemory('publish', {channel, message})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async subscribe(channel, callback) {
|
|
76
|
+
if (this.config.driver === 'redis') {
|
|
77
|
+
if (!this.subRedis) {
|
|
78
|
+
this.subRedis = this.redis.duplicate()
|
|
79
|
+
await this.subRedis.connect()
|
|
80
|
+
this.subRedis.on('message', (chan, msg) => {
|
|
81
|
+
this.emit(chan, JSON.parse(msg))
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
// Redis handles duplicate subscriptions gracefully (ignores them)
|
|
85
|
+
await this.subRedis.subscribe(channel)
|
|
86
|
+
this.on(channel, callback)
|
|
87
|
+
} else {
|
|
88
|
+
// Memory driver subscription
|
|
89
|
+
if (!this._subs.has(channel)) {
|
|
90
|
+
this._subs.set(channel, new Set())
|
|
91
|
+
// Inform main process that this worker is subscribed
|
|
92
|
+
this._sendMemory('subscribe', {channel})
|
|
93
|
+
}
|
|
94
|
+
this._subs.get(channel).add(callback)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async unsubscribe(channel, callback) {
|
|
99
|
+
if (this.config.driver === 'redis') {
|
|
100
|
+
this.removeListener(channel, callback)
|
|
101
|
+
// If no more listeners for this channel, unsubscribe from redis to save resources
|
|
102
|
+
if (this.listenerCount(channel) === 0 && this.subRedis) {
|
|
103
|
+
await this.subRedis.unsubscribe(channel)
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
if (this._subs.has(channel)) {
|
|
107
|
+
const callbacks = this._subs.get(channel)
|
|
108
|
+
callbacks.delete(callback)
|
|
109
|
+
if (callbacks.size === 0) {
|
|
110
|
+
this._subs.delete(channel)
|
|
111
|
+
this._sendMemory('unsubscribe', {channel})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Drivers ---
|
|
118
|
+
|
|
119
|
+
async _initRedis() {
|
|
120
|
+
try {
|
|
121
|
+
const Redis = require('redis')
|
|
122
|
+
this.redis = Redis.createClient(Odac.Config.database?.redis?.[this.config.redis || 'default'] || {})
|
|
123
|
+
await this.redis.connect()
|
|
124
|
+
} catch (e) {
|
|
125
|
+
console.error('IPC Redis Driver Error:', e)
|
|
126
|
+
// Re-throw to ensure application doesn't start in a broken state.
|
|
127
|
+
throw e
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async _initMemory() {
|
|
132
|
+
if (cluster.isPrimary) {
|
|
133
|
+
if (!this._memoryStore) this._memoryStore = new Map()
|
|
134
|
+
if (!this._memorySubs) this._memorySubs = new Map()
|
|
135
|
+
|
|
136
|
+
// PREVENT DUPLICATE LISTENERS via Global References
|
|
137
|
+
// If Ipc is reloaded (hot-reload), the old listener remains on 'cluster' (global).
|
|
138
|
+
// We must remove it before adding a new one.
|
|
139
|
+
|
|
140
|
+
if (global.__odac_ipc_message_handler) {
|
|
141
|
+
cluster.removeListener('message', global.__odac_ipc_message_handler)
|
|
142
|
+
}
|
|
143
|
+
if (global.__odac_ipc_exit_handler) {
|
|
144
|
+
cluster.removeListener('exit', global.__odac_ipc_exit_handler)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const messageHandler = (worker, msg) => {
|
|
148
|
+
if (msg && msg.type && msg.type.startsWith('ipc:')) {
|
|
149
|
+
this._handlePrimaryMessage(worker, msg)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const exitHandler = worker => {
|
|
154
|
+
// Cleanup worker subscriptions on exit
|
|
155
|
+
for (const [channel, workers] of this._memorySubs) {
|
|
156
|
+
workers.delete(worker.id)
|
|
157
|
+
if (workers.size === 0) {
|
|
158
|
+
this._memorySubs.delete(channel)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Save references globally
|
|
164
|
+
global.__odac_ipc_message_handler = messageHandler
|
|
165
|
+
global.__odac_ipc_exit_handler = exitHandler
|
|
166
|
+
|
|
167
|
+
cluster.on('message', messageHandler)
|
|
168
|
+
cluster.on('exit', exitHandler)
|
|
169
|
+
|
|
170
|
+
this._startGarbageCollector()
|
|
171
|
+
} else {
|
|
172
|
+
process.on('message', msg => {
|
|
173
|
+
if (msg && msg.type === 'ipc:response') {
|
|
174
|
+
const req = this._requests.get(msg.id)
|
|
175
|
+
// If request exists (hasn't timed out yet)
|
|
176
|
+
if (req) {
|
|
177
|
+
clearTimeout(req.timeout) // Stop the timeout timer
|
|
178
|
+
req.resolve(msg.data)
|
|
179
|
+
this._requests.delete(msg.id)
|
|
180
|
+
}
|
|
181
|
+
} else if (msg && msg.type === 'ipc:message') {
|
|
182
|
+
// Pub/Sub message received from Primary
|
|
183
|
+
const subs = this._subs.get(msg.channel)
|
|
184
|
+
if (subs) {
|
|
185
|
+
subs.forEach(cb => cb(msg.message))
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_sendMemory(action, payload) {
|
|
193
|
+
if (cluster.isPrimary) {
|
|
194
|
+
// If used from primary directly (rare but possible)
|
|
195
|
+
// Logic would be direct call to _handlePrimaryMessage logic essentially,
|
|
196
|
+
// but simpler. For now, assuming IPC is mostly used by workers.
|
|
197
|
+
// If primary uses it, we should implement direct store access.
|
|
198
|
+
return this._handleDirectPrimaryCall(action, payload)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return new Promise((resolve, reject) => {
|
|
202
|
+
const id = require('node:crypto').randomUUID()
|
|
203
|
+
if (action !== 'subscribe' && action !== 'publish' && action !== 'unsubscribe') {
|
|
204
|
+
// Only wait for response for data ops
|
|
205
|
+
const timeout = setTimeout(() => {
|
|
206
|
+
if (this._requests.has(id)) {
|
|
207
|
+
this._requests.delete(id)
|
|
208
|
+
reject(new Error(`IPC request timed out: ${action}`))
|
|
209
|
+
}
|
|
210
|
+
}, 5000)
|
|
211
|
+
|
|
212
|
+
this._requests.set(id, {resolve, reject, timeout})
|
|
213
|
+
} else {
|
|
214
|
+
resolve() // Pub/Sub/Unsub doesn't wait for ack
|
|
215
|
+
}
|
|
216
|
+
process.send({type: `ipc:${action}`, id, ...payload})
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_handleDirectPrimaryCall(action, payload) {
|
|
221
|
+
// Basic implementation for Primary process using itself
|
|
222
|
+
if (action === 'set') {
|
|
223
|
+
const expireAt = payload.ttl > 0 ? Date.now() + payload.ttl * 1000 : Infinity
|
|
224
|
+
this._memoryStore.set(payload.key, {value: payload.value, expireAt})
|
|
225
|
+
return true
|
|
226
|
+
}
|
|
227
|
+
if (action === 'get') {
|
|
228
|
+
const data = this._memoryStore.get(payload.key)
|
|
229
|
+
if (!data) return null
|
|
230
|
+
if (data.expireAt !== Infinity && Date.now() > data.expireAt) {
|
|
231
|
+
this._memoryStore.delete(payload.key)
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
return data.value
|
|
235
|
+
}
|
|
236
|
+
if (action === 'del') return this._memoryStore.delete(payload.key)
|
|
237
|
+
if (action === 'publish') {
|
|
238
|
+
const workers = this._memorySubs.get(payload.channel)
|
|
239
|
+
if (workers) {
|
|
240
|
+
workers.forEach(wId => {
|
|
241
|
+
const w = cluster.workers[wId]
|
|
242
|
+
if (w) w.send({type: 'ipc:message', channel: payload.channel, message: payload.message})
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// subscribe on primary not deeply implemented to avoid complexity, usually workers listen.
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_startGarbageCollector() {
|
|
250
|
+
// Run every 5 minutes.
|
|
251
|
+
// This is "lazy enough" not to impact CPU, but frequent enough to free memory.
|
|
252
|
+
const interval = setInterval(
|
|
253
|
+
() => {
|
|
254
|
+
try {
|
|
255
|
+
const now = Date.now()
|
|
256
|
+
for (const [key, data] of this._memoryStore) {
|
|
257
|
+
if (data.expireAt !== Infinity && now > data.expireAt) {
|
|
258
|
+
this._memoryStore.delete(key)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} catch (e) {
|
|
262
|
+
console.error('[Odac IPC GC Error]', e)
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
5 * 60 * 1000
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
// Allow process to exit even if this interval is running
|
|
269
|
+
interval.unref()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_handlePrimaryMessage(worker, msg) {
|
|
273
|
+
const {type, id, key, value, ttl, channel, message} = msg
|
|
274
|
+
const action = type.replace('ipc:', '')
|
|
275
|
+
|
|
276
|
+
let response = null
|
|
277
|
+
|
|
278
|
+
switch (action) {
|
|
279
|
+
case 'set': {
|
|
280
|
+
const expireAt = ttl > 0 ? Date.now() + ttl * 1000 : Infinity
|
|
281
|
+
this._memoryStore.set(key, {value, expireAt})
|
|
282
|
+
response = true
|
|
283
|
+
break
|
|
284
|
+
}
|
|
285
|
+
case 'get': {
|
|
286
|
+
const data = this._memoryStore.get(key)
|
|
287
|
+
if (data) {
|
|
288
|
+
if (data.expireAt !== Infinity && Date.now() > data.expireAt) {
|
|
289
|
+
this._memoryStore.delete(key)
|
|
290
|
+
response = null
|
|
291
|
+
} else {
|
|
292
|
+
response = data.value
|
|
293
|
+
}
|
|
294
|
+
} else {
|
|
295
|
+
response = null
|
|
296
|
+
}
|
|
297
|
+
break
|
|
298
|
+
}
|
|
299
|
+
case 'del':
|
|
300
|
+
response = this._memoryStore.delete(key)
|
|
301
|
+
break
|
|
302
|
+
case 'subscribe':
|
|
303
|
+
if (!this._memorySubs.has(channel)) {
|
|
304
|
+
this._memorySubs.set(channel, new Set())
|
|
305
|
+
}
|
|
306
|
+
this._memorySubs.get(channel).add(worker.id)
|
|
307
|
+
break
|
|
308
|
+
case 'unsubscribe':
|
|
309
|
+
if (this._memorySubs.has(channel)) {
|
|
310
|
+
this._memorySubs.get(channel).delete(worker.id)
|
|
311
|
+
if (this._memorySubs.get(channel).size === 0) {
|
|
312
|
+
this._memorySubs.delete(channel)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
break
|
|
316
|
+
case 'publish': {
|
|
317
|
+
// Relay to all subscribed workers
|
|
318
|
+
const workers = this._memorySubs.get(channel)
|
|
319
|
+
if (workers) {
|
|
320
|
+
workers.forEach(wId => {
|
|
321
|
+
// Don't echo back to sender if desired? Usually pub/sub receives own too if subbed.
|
|
322
|
+
// Redis publishes to all subscribers.
|
|
323
|
+
const w = cluster.workers[wId]
|
|
324
|
+
if (w) w.send({type: 'ipc:message', channel, message})
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
break
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (id) {
|
|
332
|
+
worker.send({type: 'ipc:response', id, data: response})
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = new Ipc()
|
package/src/Lang.js
CHANGED
|
@@ -43,9 +43,16 @@ class Lang {
|
|
|
43
43
|
|
|
44
44
|
set(lang) {
|
|
45
45
|
if (!lang || lang.length !== 2 || !this.#odac.Var(lang).is('alpha')) {
|
|
46
|
-
if (
|
|
46
|
+
if (
|
|
47
|
+
this.#odac.Request &&
|
|
48
|
+
this.#odac.Request.header &&
|
|
49
|
+
this.#odac.Request.header('ACCEPT-LANGUAGE') &&
|
|
50
|
+
this.#odac.Request.header('ACCEPT-LANGUAGE').length > 1
|
|
51
|
+
) {
|
|
47
52
|
lang = this.#odac.Request.header('ACCEPT-LANGUAGE').substr(0, 2)
|
|
48
|
-
else
|
|
53
|
+
} else {
|
|
54
|
+
lang = this.#odac.Config.lang?.default || 'en'
|
|
55
|
+
}
|
|
49
56
|
}
|
|
50
57
|
this.#lang = lang
|
|
51
58
|
if (fs.existsSync(__dir + '/storage/language/' + lang + '.json'))
|