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.
Files changed (143) hide show
  1. package/.agent/rules/coding.md +27 -0
  2. package/.agent/rules/memory.md +33 -0
  3. package/.agent/rules/project.md +30 -0
  4. package/.agent/rules/workflow.md +16 -0
  5. package/.github/workflows/auto-pr-description.yml +3 -1
  6. package/.github/workflows/release.yml +42 -1
  7. package/.github/workflows/test-coverage.yml +6 -5
  8. package/.github/workflows/test-publish.yml +36 -0
  9. package/.husky/pre-commit +10 -0
  10. package/.husky/pre-push +13 -0
  11. package/.releaserc.js +3 -3
  12. package/CHANGELOG.md +184 -0
  13. package/README.md +53 -34
  14. package/bin/odac.js +181 -49
  15. package/client/odac.js +878 -995
  16. package/docs/backend/01-overview/03-development-server.md +39 -46
  17. package/docs/backend/02-structure/01-typical-project-layout.md +59 -25
  18. package/docs/backend/03-config/00-configuration-overview.md +15 -6
  19. package/docs/backend/03-config/01-database-connection.md +3 -3
  20. package/docs/backend/03-config/02-static-route-mapping-optional.md +1 -1
  21. package/docs/backend/03-config/03-request-timeout.md +1 -1
  22. package/docs/backend/03-config/04-environment-variables.md +4 -4
  23. package/docs/backend/03-config/05-early-hints.md +2 -2
  24. package/docs/backend/04-routing/02-controller-less-view-routes.md +9 -3
  25. package/docs/backend/04-routing/03-api-and-data-routes.md +18 -0
  26. package/docs/backend/04-routing/07-cron-jobs.md +17 -1
  27. package/docs/backend/04-routing/09-websocket.md +29 -0
  28. package/docs/backend/05-controllers/01-how-to-build-a-controller.md +48 -3
  29. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +2 -0
  30. package/docs/backend/05-controllers/03-controller-classes.md +61 -55
  31. package/docs/backend/05-forms/01-custom-forms.md +103 -95
  32. package/docs/backend/05-forms/02-automatic-database-insert.md +21 -21
  33. package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +17 -0
  34. package/docs/backend/07-views/02-rendering-a-view.md +1 -1
  35. package/docs/backend/07-views/03-variables.md +5 -5
  36. package/docs/backend/07-views/04-request-data.md +1 -1
  37. package/docs/backend/07-views/08-backend-javascript.md +1 -1
  38. package/docs/backend/07-views/10-styling-and-tailwind.md +93 -0
  39. package/docs/backend/08-database/01-getting-started.md +100 -0
  40. package/docs/backend/08-database/02-basics.md +136 -0
  41. package/docs/backend/08-database/03-advanced.md +84 -0
  42. package/docs/backend/08-database/04-migrations.md +48 -0
  43. package/docs/backend/09-validation/01-the-validator-service.md +1 -0
  44. package/docs/backend/10-authentication/03-register.md +9 -2
  45. package/docs/backend/10-authentication/04-odac-register-forms.md +48 -48
  46. package/docs/backend/10-authentication/05-session-management.md +16 -2
  47. package/docs/backend/10-authentication/06-odac-login-forms.md +50 -50
  48. package/docs/backend/10-authentication/07-magic-links.md +134 -0
  49. package/docs/backend/11-mail/01-the-mail-service.md +118 -28
  50. package/docs/backend/12-streaming/01-streaming-overview.md +2 -2
  51. package/docs/backend/13-utilities/01-odac-var.md +7 -7
  52. package/docs/backend/13-utilities/02-ipc.md +73 -0
  53. package/docs/frontend/01-overview/01-introduction.md +5 -1
  54. package/docs/frontend/02-ajax-navigation/01-quick-start.md +1 -1
  55. package/docs/index.json +21 -125
  56. package/eslint.config.mjs +5 -47
  57. package/jest.config.js +1 -1
  58. package/package.json +16 -7
  59. package/src/Auth.js +414 -121
  60. package/src/Config.js +12 -7
  61. package/src/Database.js +188 -0
  62. package/src/Env.js +3 -1
  63. package/src/Ipc.js +337 -0
  64. package/src/Lang.js +9 -2
  65. package/src/Mail.js +408 -37
  66. package/src/Odac.js +105 -40
  67. package/src/Request.js +71 -49
  68. package/src/Route/Cron.js +62 -18
  69. package/src/Route/Internal.js +215 -12
  70. package/src/Route/Middleware.js +7 -2
  71. package/src/Route.js +372 -109
  72. package/src/Server.js +118 -12
  73. package/src/Storage.js +169 -0
  74. package/src/Token.js +6 -4
  75. package/src/Validator.js +95 -3
  76. package/src/Var.js +22 -6
  77. package/src/View/EarlyHints.js +43 -33
  78. package/src/View/Form.js +210 -28
  79. package/src/View.js +108 -7
  80. package/src/WebSocket.js +18 -3
  81. package/template/odac.json +5 -0
  82. package/template/package.json +3 -1
  83. package/template/route/www.js +12 -10
  84. package/template/view/content/home.html +3 -3
  85. package/template/view/head/main.html +2 -2
  86. package/test/Client.test.js +168 -0
  87. package/test/Config.test.js +112 -0
  88. package/test/Lang.test.js +92 -0
  89. package/test/Odac.test.js +86 -0
  90. package/test/{framework/middleware.test.js → Route/Middleware.test.js} +2 -2
  91. package/test/{framework/Route.test.js → Route.test.js} +1 -1
  92. package/test/{framework/View → View}/EarlyHints.test.js +1 -1
  93. package/test/{framework/WebSocket.test.js → WebSocket.test.js} +2 -2
  94. package/test/scripts/check-coverage.js +4 -4
  95. package/docs/backend/08-database/01-database-connection.md +0 -99
  96. package/docs/backend/08-database/02-using-mysql.md +0 -322
  97. package/src/Mysql.js +0 -575
  98. package/template/config.json +0 -5
  99. package/test/cli/Cli.test.js +0 -36
  100. package/test/core/Candy.test.js +0 -234
  101. package/test/core/Commands.test.js +0 -538
  102. package/test/core/Config.test.js +0 -1432
  103. package/test/core/Lang.test.js +0 -250
  104. package/test/core/Process.test.js +0 -156
  105. package/test/server/Api.test.js +0 -647
  106. package/test/server/DNS.test.js +0 -2050
  107. package/test/server/DNS.test.js.bak +0 -2084
  108. package/test/server/Hub.test.js +0 -497
  109. package/test/server/Log.test.js +0 -73
  110. package/test/server/Mail.account.test_.js +0 -460
  111. package/test/server/Mail.init.test_.js +0 -411
  112. package/test/server/Mail.test_.js +0 -1340
  113. package/test/server/SSL.test_.js +0 -1491
  114. package/test/server/Server.test.js +0 -765
  115. package/test/server/Service.test_.js +0 -1127
  116. package/test/server/Subdomain.test.js +0 -440
  117. package/test/server/Web/Firewall.test.js +0 -175
  118. package/test/server/Web/Proxy.test.js +0 -397
  119. package/test/server/Web.test.js +0 -1494
  120. package/test/server/__mocks__/acme-client.js +0 -17
  121. package/test/server/__mocks__/bcrypt.js +0 -50
  122. package/test/server/__mocks__/child_process.js +0 -389
  123. package/test/server/__mocks__/crypto.js +0 -432
  124. package/test/server/__mocks__/fs.js +0 -450
  125. package/test/server/__mocks__/globalOdac.js +0 -227
  126. package/test/server/__mocks__/http.js +0 -575
  127. package/test/server/__mocks__/https.js +0 -272
  128. package/test/server/__mocks__/index.js +0 -249
  129. package/test/server/__mocks__/mail/server.js +0 -100
  130. package/test/server/__mocks__/mail/smtp.js +0 -31
  131. package/test/server/__mocks__/mailparser.js +0 -81
  132. package/test/server/__mocks__/net.js +0 -369
  133. package/test/server/__mocks__/node-forge.js +0 -328
  134. package/test/server/__mocks__/os.js +0 -320
  135. package/test/server/__mocks__/path.js +0 -291
  136. package/test/server/__mocks__/selfsigned.js +0 -8
  137. package/test/server/__mocks__/server/src/mail/server.js +0 -100
  138. package/test/server/__mocks__/server/src/mail/smtp.js +0 -31
  139. package/test/server/__mocks__/smtp-server.js +0 -106
  140. package/test/server/__mocks__/sqlite3.js +0 -394
  141. package/test/server/__mocks__/testFactories.js +0 -299
  142. package/test/server/__mocks__/testHelpers.js +0 -363
  143. 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: 'candy_auth'
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 + '/config.json')) {
34
+ if (fs.existsSync(__dir + '/odac.json')) {
30
35
  let config = {}
31
36
  try {
32
- config = JSON.parse(fs.readFileSync(__dir + '/config.json'))
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 + '/config.json', err.message)
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('md5').update(this.encrypt.key).digest('hex')
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(/\/framework\/src$/, '')
52
+ return __dirname.replace(/\/src$/, '/client')
48
53
  }
49
54
  // Environment variables
50
55
  return process.env[key] || ''
@@ -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] = value
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 (this.#odac.Request.header('ACCEPT-LANGUAGE') && this.#odac.Request.header('ACCEPT-LANGUAGE').length > 1)
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 lang = this.#odac.Config.lang?.default || 'en'
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'))