odac 1.1.0 → 1.3.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 (117) hide show
  1. package/.agent/rules/coding.md +27 -0
  2. package/.agent/rules/memory.md +42 -0
  3. package/.agent/rules/project.md +30 -0
  4. package/.agent/rules/workflow.md +16 -0
  5. package/.github/workflows/release.yml +68 -1
  6. package/.github/workflows/test-coverage.yml +6 -5
  7. package/.husky/pre-commit +10 -0
  8. package/.husky/pre-push +13 -0
  9. package/.releaserc.js +1 -1
  10. package/CHANGELOG.md +99 -0
  11. package/README.md +16 -0
  12. package/bin/odac.js +196 -38
  13. package/client/odac.js +238 -171
  14. package/docs/backend/01-overview/03-development-server.md +38 -45
  15. package/docs/backend/02-structure/01-typical-project-layout.md +59 -26
  16. package/docs/backend/03-config/00-configuration-overview.md +6 -6
  17. package/docs/backend/03-config/01-database-connection.md +2 -2
  18. package/docs/backend/03-config/02-static-route-mapping-optional.md +1 -1
  19. package/docs/backend/03-config/03-request-timeout.md +1 -1
  20. package/docs/backend/03-config/04-environment-variables.md +4 -4
  21. package/docs/backend/03-config/05-early-hints.md +2 -2
  22. package/docs/backend/04-routing/03-api-and-data-routes.md +18 -0
  23. package/docs/backend/04-routing/07-cron-jobs.md +17 -1
  24. package/docs/backend/04-routing/09-websocket.md +14 -1
  25. package/docs/backend/05-controllers/01-how-to-build-a-controller.md +48 -3
  26. package/docs/backend/05-controllers/03-controller-classes.md +40 -20
  27. package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +17 -0
  28. package/docs/backend/07-views/10-styling-and-tailwind.md +93 -0
  29. package/docs/backend/08-database/01-getting-started.md +2 -2
  30. package/docs/backend/10-authentication/03-register.md +1 -1
  31. package/docs/backend/10-authentication/04-odac-register-forms.md +2 -2
  32. package/docs/backend/10-authentication/05-session-management.md +15 -1
  33. package/docs/backend/10-authentication/06-odac-login-forms.md +2 -2
  34. package/docs/backend/10-authentication/07-magic-links.md +1 -1
  35. package/docs/index.json +5 -1
  36. package/jest.config.js +1 -1
  37. package/package.json +18 -14
  38. package/src/Auth.js +58 -23
  39. package/src/Config.js +7 -7
  40. package/src/Database.js +1 -1
  41. package/src/Env.js +3 -1
  42. package/src/Ipc.js +7 -0
  43. package/src/Lang.js +9 -2
  44. package/src/Mail.js +19 -9
  45. package/src/Odac.js +56 -44
  46. package/src/Request.js +6 -2
  47. package/src/Route/Cron.js +58 -17
  48. package/src/Route/Internal.js +1 -1
  49. package/src/Route.js +375 -125
  50. package/src/Server.js +40 -3
  51. package/src/Storage.js +4 -0
  52. package/src/Token.js +6 -4
  53. package/src/Validator.js +24 -15
  54. package/src/Var.js +22 -6
  55. package/src/View/EarlyHints.js +43 -33
  56. package/src/View/Form.js +17 -11
  57. package/src/View.js +68 -12
  58. package/src/WebSocket.js +45 -12
  59. package/template/package.json +3 -1
  60. package/template/view/content/home.html +3 -3
  61. package/template/view/head/main.html +2 -2
  62. package/test/Client.test.js +168 -0
  63. package/test/Config.test.js +112 -0
  64. package/test/Lang.test.js +92 -0
  65. package/test/Odac.test.js +88 -0
  66. package/test/{framework/middleware.test.js → Route/Middleware.test.js} +2 -2
  67. package/test/{framework/Route.test.js → Route.test.js} +105 -1
  68. package/test/{framework/View → View}/EarlyHints.test.js +1 -1
  69. package/test/WebSocket.test.js +238 -0
  70. package/test/scripts/check-coverage.js +4 -4
  71. package/test/cli/Cli.test.js +0 -36
  72. package/test/core/Commands.test.js +0 -538
  73. package/test/core/Config.test.js +0 -1432
  74. package/test/core/Lang.test.js +0 -250
  75. package/test/core/Odac.test.js +0 -234
  76. package/test/core/Process.test.js +0 -156
  77. package/test/framework/WebSocket.test.js +0 -100
  78. package/test/server/Api.test.js +0 -647
  79. package/test/server/DNS.test.js +0 -2050
  80. package/test/server/DNS.test.js.bak +0 -2084
  81. package/test/server/Hub.test.js +0 -497
  82. package/test/server/Log.test.js +0 -73
  83. package/test/server/Mail.account.test_.js +0 -460
  84. package/test/server/Mail.init.test_.js +0 -411
  85. package/test/server/Mail.test_.js +0 -1340
  86. package/test/server/SSL.test_.js +0 -1491
  87. package/test/server/Server.test.js +0 -765
  88. package/test/server/Service.test_.js +0 -1127
  89. package/test/server/Subdomain.test.js +0 -440
  90. package/test/server/Web/Firewall.test.js +0 -175
  91. package/test/server/Web/Proxy.test.js +0 -397
  92. package/test/server/Web.test.js +0 -1494
  93. package/test/server/__mocks__/acme-client.js +0 -17
  94. package/test/server/__mocks__/bcrypt.js +0 -50
  95. package/test/server/__mocks__/child_process.js +0 -389
  96. package/test/server/__mocks__/crypto.js +0 -432
  97. package/test/server/__mocks__/fs.js +0 -450
  98. package/test/server/__mocks__/globalOdac.js +0 -227
  99. package/test/server/__mocks__/http.js +0 -575
  100. package/test/server/__mocks__/https.js +0 -272
  101. package/test/server/__mocks__/index.js +0 -249
  102. package/test/server/__mocks__/mail/server.js +0 -100
  103. package/test/server/__mocks__/mail/smtp.js +0 -31
  104. package/test/server/__mocks__/mailparser.js +0 -81
  105. package/test/server/__mocks__/net.js +0 -369
  106. package/test/server/__mocks__/node-forge.js +0 -328
  107. package/test/server/__mocks__/os.js +0 -320
  108. package/test/server/__mocks__/path.js +0 -291
  109. package/test/server/__mocks__/selfsigned.js +0 -8
  110. package/test/server/__mocks__/server/src/mail/server.js +0 -100
  111. package/test/server/__mocks__/server/src/mail/smtp.js +0 -31
  112. package/test/server/__mocks__/smtp-server.js +0 -106
  113. package/test/server/__mocks__/sqlite3.js +0 -394
  114. package/test/server/__mocks__/testFactories.js +0 -299
  115. package/test/server/__mocks__/testHelpers.js +0 -363
  116. package/test/server/__mocks__/tls.js +0 -229
  117. /package/template/{config.json → odac.json} +0 -0
package/src/Server.js CHANGED
@@ -7,13 +7,17 @@ module.exports = {
7
7
  init: function () {
8
8
  let args = process.argv.slice(2)
9
9
  if (args[0] == 'framework' && args[1] == 'run') args = args.slice(2)
10
- let port = parseInt(args[0] ?? '1071')
10
+ let port = parseInt(args[0])
11
+ if (isNaN(port)) port = parseInt(process.env.PORT || '1071')
11
12
 
12
13
  if (cluster.isPrimary) {
13
- const numCPUs = os.cpus().length
14
+ const numCPUs = Odac.Config.debug ? 1 : os.cpus().length
14
15
  let isShuttingDown = false
15
16
 
16
- console.log(`Odac Server running on \x1b]8;;http://127.0.0.1:${port}\x1b\\\x1b[4mhttp://127.0.0.1:${port}\x1b[0m\x1b]8;;\x1b\\.`)
17
+ const mode = Odac.Config.debug ? '\x1b[33mDevelopment\x1b[0m' : '\x1b[32mProduction\x1b[0m'
18
+ console.log(
19
+ `Odac Server running on \x1b]8;;http://127.0.0.1:${port}\x1b\\\x1b[4mhttp://127.0.0.1:${port}\x1b[0m\x1b]8;;\x1b\\ in ${mode} mode.`
20
+ )
17
21
 
18
22
  // Start session garbage collector (runs every hour, expires after 7 days)
19
23
  Odac.Storage.startSessionGC()
@@ -68,6 +72,39 @@ module.exports = {
68
72
  return Odac.Route.request(req, res)
69
73
  })
70
74
 
75
+ /**
76
+ * ENTERPRISE PERFORMANCE CONFIGURATION
77
+ * ------------------------------------
78
+ * 1. Keep-Alive: Set higher than the upstream Load Balancer/Proxy (usually 60s).
79
+ * This prevents the "502 Bad Gateway" race condition where Node closes
80
+ * an idle connection while the proxy attempts to reuse it.
81
+ */
82
+ server.keepAliveTimeout = 65000 // 65 seconds
83
+ server.headersTimeout = 66000 // 66 seconds (Must be > keepAliveTimeout)
84
+
85
+ /**
86
+ * 2. Low Latency: Disable Nagle's Algorithm.
87
+ * We want to send data immediately, even if the packet is small.
88
+ * Critical for sub-millisecond API responses.
89
+ */
90
+ server.on('connection', socket => {
91
+ socket.setNoDelay(true)
92
+ })
93
+
94
+ /**
95
+ * 3. Connection Rotation: Force reset after 10k requests.
96
+ * - Helps with Load Balancing (clients are forced to reconnect and potentially pick a new pod/worker).
97
+ * - Mitigates long-term memory leaks in the TLS/Socket layer.
98
+ */
99
+ server.maxRequestsPerSocket = 10000
100
+
101
+ /**
102
+ * 4. Hard Timeout: Kill connection if request processing (headers + body) takes too long.
103
+ * - Defaults to 0 (unlimited) or 5min in older Node.
104
+ * - 30s is more than enough for an API; fail fast if the client is stuck.
105
+ */
106
+ server.requestTimeout = 30000 // 30 seconds
107
+
71
108
  server.on('upgrade', (req, socket, head) => {
72
109
  const id = nodeCrypto.randomBytes(16).toString('hex')
73
110
  const param = Odac.instance(id, req, null)
package/src/Storage.js CHANGED
@@ -22,6 +22,10 @@ class OdacStorage {
22
22
  this.db = open({
23
23
  path: dbPath,
24
24
  compression: true
25
+ // CLUSTER SAFETY NOTE:
26
+ // LMDB uses memory-mapped files with OS-level locking logic.
27
+ // Multiple workers can safely read/write to this DB simultaneously.
28
+ // Data committed by Worker A is immediately visible to Worker B.
25
29
  })
26
30
  this.ready = true
27
31
  } catch (error) {
package/src/Token.js CHANGED
@@ -1,6 +1,9 @@
1
1
  const nodeCrypto = require('crypto')
2
2
 
3
3
  class Token {
4
+ // CLUSTER SAFETY NOTE:
5
+ // This is a request-scoped local cache (debounce) for performance.
6
+ // Valid tokens represent state persisted in Session (LMDB), shared across all workers.
4
7
  confirmed = []
5
8
 
6
9
  constructor(Request) {
@@ -22,10 +25,9 @@ class Token {
22
25
 
23
26
  // - GENERATE TOKEN
24
27
  generate() {
25
- let token = nodeCrypto
26
- .createHash('md5')
27
- .update(this.Request.id + Date.now().toString() + Math.random().toString())
28
- .digest('hex')
28
+ // Enterprise Standard: Use CSPRNG (Cryptographically Secure Pseudo-Random Number Generator)
29
+ // Replaced weak MD5(Math.random) with randomBytes(32)
30
+ let token = nodeCrypto.randomBytes(32).toString('hex')
29
31
  let tokens = this.Request.session('_token') || []
30
32
  tokens.push(token)
31
33
  if (tokens.length > 50) tokens = tokens.slice(-50)
package/src/Validator.js CHANGED
@@ -1,10 +1,10 @@
1
1
  const https = require('https')
2
2
  const fs = require('fs')
3
- const os = require('os')
3
+ const fsPromises = fs.promises
4
4
  const path = require('path')
5
5
 
6
6
  let disposableDomains = null
7
- const CACHE_FILE = path.join(os.tmpdir(), 'odac_disposable_domains.conf')
7
+ const CACHE_FILE = path.join(global.__dir || process.cwd(), 'storage', '.cache', 'odac_disposable_domains.conf')
8
8
  const SOURCE_URL = 'https://hub.odac.run/blocklist/disposable-emails'
9
9
 
10
10
  async function loadDisposableDomains() {
@@ -16,16 +16,16 @@ async function loadDisposableDomains() {
16
16
 
17
17
  try {
18
18
  try {
19
- const fd = fs.openSync(CACHE_FILE, 'r')
19
+ const handle = await fsPromises.open(CACHE_FILE, 'r')
20
20
  try {
21
- const stats = fs.fstatSync(fd)
21
+ const stats = await handle.stat()
22
22
  const ageInHours = (new Date() - stats.mtime) / (1000 * 60 * 60)
23
23
  if (ageInHours < 24) {
24
- content = fs.readFileSync(fd, 'utf8')
24
+ content = await handle.readFile('utf8')
25
25
  shouldUpdate = false
26
26
  }
27
27
  } finally {
28
- fs.closeSync(fd)
28
+ await handle.close()
29
29
  }
30
30
  } catch {
31
31
  // Cache error check failed, proceed to validation update
@@ -48,18 +48,25 @@ async function loadDisposableDomains() {
48
48
  req.end()
49
49
  })
50
50
  const tempFile = `${CACHE_FILE}_${Date.now()}_${Math.random().toString(36).slice(2)}`
51
- const fd = fs.openSync(tempFile, 'wx')
51
+ await fsPromises.mkdir(path.dirname(CACHE_FILE), {recursive: true})
52
+ const handle = await fsPromises.open(tempFile, 'wx', 0o600)
52
53
  try {
53
- // Sanitize content before writing to file to avoid injection attacks
54
+ // SECURITY NOTE: Supply Chain Attack Mitigation
55
+ // We strictly sanitize the content fetched from the remote source before writing it to the local file system.
56
+ // The regex whitelist /[^a-zA-Z0-9.\-\n\r]/g restricts the content to only alphanumeric characters, dots, hyphens, and newlines.
57
+ // This aggressively neutralizes any potential malicious payloads (e.g., specific code injection, shell commands, or scrips)
58
+ // even if the remote source (hub.odac.run) is compromised.
54
59
  const sanitizedContent = content.replace(/[^a-zA-Z0-9.\-\n\r]/g, '')
55
- fs.writeSync(fd, sanitizedContent)
60
+ await handle.write(sanitizedContent)
56
61
  } finally {
57
- fs.closeSync(fd)
62
+ await handle.close()
58
63
  }
59
- fs.renameSync(tempFile, CACHE_FILE)
64
+ await fsPromises.rename(tempFile, CACHE_FILE)
60
65
  } catch {
61
- if (fs.existsSync(CACHE_FILE)) {
62
- content = fs.readFileSync(CACHE_FILE, 'utf8')
66
+ try {
67
+ content = await fsPromises.readFile(CACHE_FILE, 'utf8')
68
+ } catch {
69
+ // No cache file available
63
70
  }
64
71
  }
65
72
  }
@@ -73,7 +80,9 @@ async function loadDisposableDomains() {
73
80
  })
74
81
  }
75
82
  } catch (error) {
76
- console.error('Validator Warning: Could not load disposable domains.', error.message)
83
+ console.error(
84
+ JSON.stringify({level: 'error', tag: 'ODAC Validator', message: 'Could not load disposable domains.', error: error.message})
85
+ )
77
86
  }
78
87
  }
79
88
 
@@ -278,7 +287,7 @@ class Validator {
278
287
  error = true
279
288
  } else {
280
289
  const userData = Odac.Auth.user(vars[1])
281
- if (Odac.Var(userData).is('bcrypt')) {
290
+ if (Odac.Var(userData).is('hash')) {
282
291
  error = !Odac.Var(userData).hashCheck(value)
283
292
  } else {
284
293
  error = value !== userData
package/src/Var.js CHANGED
@@ -1,6 +1,5 @@
1
1
  const fs = require('fs')
2
2
  const nodeCrypto = require('crypto')
3
- const bcrypt = require('bcrypt')
4
3
 
5
4
  class Var {
6
5
  #value = null
@@ -79,17 +78,34 @@ class Var {
79
78
  return encrypted.toString('base64')
80
79
  }
81
80
 
82
- hash(salt = 10) {
83
- return bcrypt.hashSync(this.#value, bcrypt.genSaltSync(salt))
81
+ hash() {
82
+ const salt = nodeCrypto.randomBytes(16).toString('hex')
83
+ const derivedKey = nodeCrypto.scryptSync(this.#value, salt, 64)
84
+ return `$scrypt$${salt}$${derivedKey.toString('hex')}`
84
85
  }
85
86
 
86
87
  hashCheck(check) {
87
- return bcrypt.compareSync(check, this.#value)
88
+ if (!this.#value.startsWith('$scrypt$')) return false
89
+ const parts = this.#value.split('$')
90
+ if (parts.length < 4) return false
91
+
92
+ const salt = parts[2]
93
+ const originalHash = Buffer.from(parts[3], 'hex')
94
+
95
+ const derivedKey = nodeCrypto.scryptSync(check, salt, 64)
96
+ return nodeCrypto.timingSafeEqual(originalHash, derivedKey)
88
97
  }
89
98
 
90
99
  html() {
91
100
  if (this.#value === null || this.#value === undefined) return ''
92
- return String(this.#value).replace(/</g, '&lt;').replace(/>/g, '&gt;')
101
+ const map = {
102
+ '&': '&amp;',
103
+ '<': '&lt;',
104
+ '>': '&gt;',
105
+ '"': '&quot;',
106
+ "'": '&#39;'
107
+ }
108
+ return String(this.#value).replace(/[&<>"']/g, m => map[m])
93
109
  }
94
110
 
95
111
  is(...args) {
@@ -102,7 +118,7 @@ class Var {
102
118
  if (args.includes('alphaspace')) result = (result || any) && ((any && result) || /^[A-Za-z\s]+$/.test(this.#value))
103
119
  if (args.includes('alphanumeric')) result = (result || any) && ((any && result) || /^[A-Za-z0-9]+$/.test(this.#value))
104
120
  if (args.includes('alphanumericspace')) result = (result || any) && ((any && result) || /^[A-Za-z0-9\s]+$/.test(this.#value))
105
- if (args.includes('bcrypt')) result = (result || any) && ((any && result) || /^\$2[ayb]\$.{56}$/.test(this.#value))
121
+ if (args.includes('hash')) result = (result || any) && ((any && result) || /^\$scrypt\$[a-f0-9]+\$[a-f0-9]+$/.test(this.#value))
106
122
  if (args.includes('date')) result = (result || any) && ((any && result) || !isNaN(Date.parse(this.#value)))
107
123
  if (args.includes('domain')) result = (result || any) && ((any && result) || /^([a-z0-9-]+\.){1,2}[a-z]{2,6}$/i.test(this.#value))
108
124
  if (args.includes('email'))
@@ -1,4 +1,4 @@
1
- const fs = require('fs')
1
+ const fs = require('fs').promises
2
2
  const path = require('path')
3
3
 
4
4
  class EarlyHints {
@@ -15,61 +15,71 @@ class EarlyHints {
15
15
  }
16
16
  }
17
17
 
18
- init() {
18
+ async init() {
19
19
  if (this.#initialized) return
20
20
  this.#initialized = true
21
21
 
22
22
  if (!this.#config.enabled) return
23
23
 
24
- this.#buildManifest()
24
+ await this.#buildManifest()
25
25
  }
26
26
 
27
- #buildManifest() {
27
+ async #buildManifest() {
28
28
  const viewDir = path.join(process.cwd(), 'view')
29
29
  const skeletonDir = path.join(process.cwd(), 'skeleton')
30
30
 
31
31
  try {
32
- if (fs.existsSync(viewDir)) {
33
- const files = this.#getAllViewFiles(viewDir)
34
- for (const file of files) {
35
- const html = fs.readFileSync(file, 'utf8')
36
- const resources = this.#extractResources(html)
37
-
38
- const relativePath = path.relative(viewDir, file)
39
- const viewName = 'view/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
40
-
41
- if (resources.length > 0) {
42
- this.#manifest[viewName] = resources
43
- }
44
- }
32
+ try {
33
+ await fs.access(viewDir)
34
+ const files = await this.#getAllViewFiles(viewDir)
35
+ await Promise.all(
36
+ files.map(async file => {
37
+ const html = await fs.readFile(file, 'utf8')
38
+ const resources = this.#extractResources(html)
39
+
40
+ const relativePath = path.relative(viewDir, file)
41
+ const viewName = 'view/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
42
+
43
+ if (resources.length > 0) {
44
+ this.#manifest[viewName] = resources
45
+ }
46
+ })
47
+ )
48
+ } catch {
49
+ // viewDir might not exist
45
50
  }
46
51
 
47
- if (fs.existsSync(skeletonDir)) {
48
- const files = this.#getAllViewFiles(skeletonDir)
49
- for (const file of files) {
50
- const html = fs.readFileSync(file, 'utf8')
51
- const resources = this.#extractResources(html)
52
-
53
- const relativePath = path.relative(skeletonDir, file)
54
- const viewName = 'skeleton/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
55
-
56
- if (resources.length > 0) {
57
- this.#manifest[viewName] = resources
58
- }
59
- }
52
+ try {
53
+ await fs.access(skeletonDir)
54
+ const files = await this.#getAllViewFiles(skeletonDir)
55
+ await Promise.all(
56
+ files.map(async file => {
57
+ const html = await fs.readFile(file, 'utf8')
58
+ const resources = this.#extractResources(html)
59
+
60
+ const relativePath = path.relative(skeletonDir, file)
61
+ const viewName = 'skeleton/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
62
+
63
+ if (resources.length > 0) {
64
+ this.#manifest[viewName] = resources
65
+ }
66
+ })
67
+ )
68
+ } catch {
69
+ // skeletonDir might not exist
60
70
  }
61
71
  } catch {
62
72
  // Silently fail, manifest building is optional
63
73
  }
64
74
  }
65
75
 
66
- #getAllViewFiles(dir, files = []) {
67
- const entries = fs.readdirSync(dir, {withFileTypes: true})
76
+ async #getAllViewFiles(dir, files = []) {
77
+ const entries = await fs.readdir(dir, {withFileTypes: true})
68
78
 
69
79
  for (const entry of entries) {
70
80
  const fullPath = path.join(dir, entry.name)
71
81
  if (entry.isDirectory()) {
72
- this.#getAllViewFiles(fullPath, files)
82
+ await this.#getAllViewFiles(fullPath, files)
73
83
  } else if (entry.isFile() && entry.name.endsWith('.html')) {
74
84
  files.push(fullPath)
75
85
  }
package/src/View/Form.js CHANGED
@@ -12,20 +12,26 @@ class Form {
12
12
 
13
13
  static parseFormType(content, Odac, type) {
14
14
  const regex = new RegExp(`<odac:${type}[\\s\\S]*?<\\/odac:${type}>`, 'g')
15
- const matches = content.match(regex)
16
- if (!matches) return content
15
+ return content.replace(regex, match => {
16
+ const formConfig = this.extractConfig(match, null, type)
17
+ const configStr = JSON.stringify(formConfig)
18
+ const matchStr = JSON.stringify(match)
19
+ return `<script:odac>html += await Odac.View.Form.runtime(Odac, '${type}', ${configStr}, ${matchStr});</script:odac>`
20
+ })
21
+ }
17
22
 
18
- for (const match of matches) {
19
- const formToken = nodeCrypto.randomBytes(32).toString('hex')
20
- const formConfig = this.extractConfig(match, formToken, type)
23
+ /**
24
+ * Generates the form at runtime to ensure a fresh token is created and stored
25
+ * in the current session for every request. This prevents "session expired"
26
+ * errors caused by caching the form token in the compiled view.
27
+ */
28
+ static async runtime(Odac, type, config, originalHtml) {
29
+ const token = nodeCrypto.randomBytes(32).toString('hex')
30
+ config.token = token
21
31
 
22
- this.storeConfig(formToken, formConfig, Odac, type)
32
+ this.storeConfig(token, config, Odac, type)
23
33
 
24
- const generatedForm = this.generateForm(match, formConfig, formToken, type)
25
- content = content.replace(match, generatedForm)
26
- }
27
-
28
- return content
34
+ return this.generateForm(originalHtml, config, token, type)
29
35
  }
30
36
 
31
37
  static extractConfig(html, formToken, type) {
package/src/View.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const nodeCrypto = require('crypto')
2
2
  const fs = require('fs')
3
+ const fsPromises = fs.promises
3
4
  const Form = require('./View/Form')
4
5
  const EarlyHints = require('./View/EarlyHints')
5
6
 
@@ -8,7 +9,6 @@ const TITLE_REGEX = /<title[^>]*>([^<]*)<\/title>/i
8
9
  const CACHE_DIR = './storage/.cache'
9
10
 
10
11
  class View {
11
- #cache = {}
12
12
  #earlyHints = null
13
13
  #functions = {
14
14
  '{!!': {
@@ -116,6 +116,8 @@ class View {
116
116
  } else {
117
117
  this.#earlyHints = global.Odac.View.EarlyHints
118
118
  }
119
+ global.Odac.View.Form = Form
120
+ this.Form = Form
119
121
  }
120
122
 
121
123
  all(name) {
@@ -147,7 +149,7 @@ class View {
147
149
  if (this.#part[element]) {
148
150
  let viewPath = this.#part[element]
149
151
  if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
150
- if (fs.existsSync(`./view/${element}/${viewPath}.html`)) {
152
+ if (await this.#exists(`./view/${element}/${viewPath}.html`)) {
151
153
  const html = await this.#render(`./view/${element}/${viewPath}.html`)
152
154
  output[element] = html
153
155
 
@@ -167,7 +169,7 @@ class View {
167
169
  if (this.#part[key] && !this.#odac.Request.ajaxLoad.includes(key)) {
168
170
  let viewPath = this.#part[key]
169
171
  if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
170
- if (fs.existsSync(`./view/${key}/${viewPath}.html`)) {
172
+ if (await this.#exists(`./view/${key}/${viewPath}.html`)) {
171
173
  try {
172
174
  const partHtml = await this.#render(`./view/${key}/${viewPath}.html`)
173
175
  const titleMatch = partHtml.match(TITLE_REGEX)
@@ -205,8 +207,8 @@ class View {
205
207
 
206
208
  // Normal page rendering
207
209
  let result = ''
208
- if (this.#part.skeleton && fs.existsSync(`./skeleton/${this.#part.skeleton}.html`)) {
209
- result = fs.readFileSync(`./skeleton/${this.#part.skeleton}.html`, 'utf8')
210
+ if (this.#part.skeleton && (await this.#exists(`./skeleton/${this.#part.skeleton}.html`))) {
211
+ result = await this.#readSkeleton(`./skeleton/${this.#part.skeleton}.html`)
210
212
 
211
213
  // Add data-odac-navigate to content wrapper for auto-navigation
212
214
  result = this.#addNavigateAttribute(result)
@@ -215,7 +217,7 @@ class View {
215
217
  if (['all', 'skeleton'].includes(key)) continue
216
218
  if (!this.#part[key]) continue
217
219
  if (this.#part[key].includes('.')) this.#part[key] = this.#part[key].replace(/\./g, '/')
218
- if (fs.existsSync(`./view/${key}/${this.#part[key]}.html`)) {
220
+ if (await this.#exists(`./view/${key}/${this.#part[key]}.html`)) {
219
221
  result = result.replace(`{{ ${key.toUpperCase()} }}`, await this.#render(`./view/${key}/${this.#part[key]}.html`))
220
222
  }
221
223
  }
@@ -227,7 +229,7 @@ class View {
227
229
  let file = this.#part.all.split('.')
228
230
  file.splice(-1, 0, part.toLowerCase())
229
231
  file = file.join('/')
230
- if (fs.existsSync(`./view/${file}.html`)) {
232
+ if (await this.#exists(`./view/${file}.html`)) {
231
233
  result = result.replace(`{{ ${part.toUpperCase()} }}`, await this.#render(`./view/${file}.html`))
232
234
  }
233
235
  }
@@ -363,10 +365,42 @@ class View {
363
365
  }
364
366
 
365
367
  async #render(file) {
366
- let mtime = fs.statSync(file).mtimeMs
367
- let content = fs.readFileSync(file, 'utf8')
368
+ if (!global.Odac.View) global.Odac.View = {}
369
+ if (!global.Odac.View.cache) global.Odac.View.cache = {}
370
+
371
+ // Performance: In Production, skip stat check if cached
372
+ if (!this.#odac.Config?.debug && global.Odac.View.cache[file]) {
373
+ try {
374
+ return await require(`${__dir}/${CACHE_DIR}/${global.Odac.View.cache[file].cache}`)(
375
+ this.#odac,
376
+ key => this.#odac.Request.get(key),
377
+ (...args) => this.#odac.Lang.get(...args)
378
+ )
379
+ } catch {
380
+ // Fallback if cache file missing
381
+ }
382
+ }
383
+
384
+ let mtime
385
+ let content = null
386
+
387
+ try {
388
+ const handle = await fsPromises.open(file, 'r')
389
+ try {
390
+ const stats = await handle.stat()
391
+ mtime = stats.mtimeMs
392
+
393
+ if (global.Odac.View.cache[file]?.mtime !== mtime) {
394
+ content = await handle.readFile('utf8')
395
+ }
396
+ } finally {
397
+ await handle.close()
398
+ }
399
+ } catch {
400
+ return ''
401
+ }
368
402
 
369
- if (this.#cache[file]?.mtime !== mtime) {
403
+ if (content !== null) {
370
404
  content = Form.parse(content, this.#odac)
371
405
 
372
406
  const jsBlocks = []
@@ -451,8 +485,8 @@ class View {
451
485
  }
452
486
  }
453
487
  let cache = `${nodeCrypto.createHash('md5').update(file).digest('hex')}`
454
- if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, {recursive: true})
455
- fs.writeFileSync(
488
+ await fsPromises.mkdir(CACHE_DIR, {recursive: true})
489
+ await fsPromises.writeFile(
456
490
  `${CACHE_DIR}/${cache}`,
457
491
  `module.exports = async (Odac, get, __) => {\nlet html = '';\n${result}\nreturn html.trim()\n}`
458
492
  )
@@ -550,6 +584,28 @@ class View {
550
584
  this.#odac.Request.setEarlyHints(hints)
551
585
  }
552
586
  }
587
+
588
+ async #exists(path) {
589
+ try {
590
+ await fsPromises.access(path)
591
+ return true
592
+ } catch {
593
+ return false
594
+ }
595
+ }
596
+
597
+ async #readSkeleton(path) {
598
+ if (!global.Odac.View.skeletons) global.Odac.View.skeletons = {}
599
+
600
+ // In production (debug=false), cache logic
601
+ if (!this.#odac.Config?.debug && global.Odac.View.skeletons[path]) {
602
+ return global.Odac.View.skeletons[path]
603
+ }
604
+
605
+ const content = await fsPromises.readFile(path, 'utf8')
606
+ global.Odac.View.skeletons[path] = content
607
+ return content
608
+ }
553
609
  }
554
610
 
555
611
  module.exports = View