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/Route.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const fs = require('fs')
2
+ const fsPromises = fs.promises
2
3
 
3
4
  const Cron = require('./Route/Cron.js')
4
5
  const Internal = require('./Route/Internal.js')
@@ -68,7 +69,12 @@ class Route {
68
69
  routes = {}
69
70
  middlewares = {}
70
71
  _pendingMiddlewares = []
72
+ _pendingRouteLoads = []
71
73
  #wsServer = new WebSocketServer()
74
+ #configCache = {}
75
+ #paramIndex = {}
76
+ #paramIndexDirty = false
77
+ #publicCache = {}
72
78
  auth = {
73
79
  page: (path, authFile, file) => this.authPage(path, authFile, file),
74
80
  post: (path, authFile, file) => this.authPost(path, authFile, file),
@@ -115,6 +121,21 @@ class Route {
115
121
  const middlewareResult = await this.#runMiddlewares(Odac, controller.middlewares)
116
122
  if (middlewareResult !== undefined) return middlewareResult
117
123
 
124
+ if (controller.action) {
125
+ const ControllerClass = controller.cache
126
+ try {
127
+ const instance = new ControllerClass(Odac)
128
+ if (typeof instance[controller.action] === 'function') {
129
+ return instance[controller.action](Odac)
130
+ }
131
+ } catch {
132
+ if (typeof ControllerClass[controller.action] === 'function') {
133
+ return ControllerClass[controller.action](Odac)
134
+ }
135
+ }
136
+ return Odac.Request.abort(500)
137
+ }
138
+
118
139
  if (typeof controller.cache === 'function') {
119
140
  return controller.cache(Odac)
120
141
  }
@@ -131,6 +152,7 @@ class Route {
131
152
  if (['post', 'put', 'patch', 'delete'].includes(Odac.Request.method)) {
132
153
  const formToken = await Odac.request('_odac_form_token')
133
154
  if (formToken) {
155
+ Odac.Request.setSession()
134
156
  await Internal.processForm(Odac)
135
157
  }
136
158
  }
@@ -157,20 +179,40 @@ class Route {
157
179
  Odac.Request.isAjaxLoad = true
158
180
  Odac.Request.clientSkeleton = Odac.Request.header('X-Odac-Skeleton')
159
181
  }
160
- if (Odac.Config && Odac.Config.route && Odac.Config.route[url]) {
161
- if (fs.existsSync(Odac.Config.route[url])) {
162
- let stat = fs.lstatSync(Odac.Config.route[url])
163
- if (stat.isFile()) {
164
- let type = 'text/html'
165
- if (Odac.Config.route[url].includes('.')) {
166
- let arr = Odac.Config.route[url].split('.')
167
- type = mime[arr[arr.length - 1]]
182
+ if (Odac.Config?.route?.[url]) {
183
+ // PROD CACHE HIT
184
+ if (!Odac.Config.debug && this.#configCache[url]) {
185
+ const cached = this.#configCache[url]
186
+ Odac.Request.header('Content-Type', cached.type)
187
+ Odac.Request.header('Cache-Control', 'public, max-age=31536000')
188
+ Odac.Request.header('Content-Length', cached.size)
189
+ return cached.content
190
+ }
191
+
192
+ const filePath = Odac.Config.route[url]
193
+ try {
194
+ const content = await fsPromises.readFile(filePath)
195
+ let type = 'text/html'
196
+ if (filePath.includes('.')) {
197
+ let arr = filePath.split('.')
198
+ type = mime[arr[arr.length - 1]]
199
+ }
200
+
201
+ // PROD CACHE SET
202
+ if (!Odac.Config.debug) {
203
+ this.#configCache[url] = {
204
+ content,
205
+ type,
206
+ size: content.length
168
207
  }
169
- Odac.Request.header('Content-Type', type)
170
- Odac.Request.header('Cache-Control', 'public, max-age=31536000')
171
- Odac.Request.header('Content-Length', stat.size)
172
- return fs.readFileSync(Odac.Config.route[url])
173
208
  }
209
+
210
+ Odac.Request.header('Content-Type', type)
211
+ Odac.Request.header('Cache-Control', 'public, max-age=31536000')
212
+ Odac.Request.header('Content-Length', content.length)
213
+ return content
214
+ } catch {
215
+ // File not found or error, continue routing
174
216
  }
175
217
  }
176
218
  for (let method of ['#' + Odac.Request.method, Odac.Request.method]) {
@@ -207,65 +249,153 @@ class Route {
207
249
  if (typeof page === 'string') Odac.Request.page = page
208
250
  return await this.#executeController(Odac, pageController)
209
251
  }
210
- if (url && !url.includes('/../') && fs.existsSync(`${__dir}/public${url}`)) {
211
- let stat = fs.lstatSync(`${__dir}/public${url}`)
212
- if (stat.isFile()) {
213
- let type = 'text/html'
214
- if (url.includes('.')) {
215
- let arr = url.split('.')
216
- type = mime[arr[arr.length - 1]]
217
- }
218
- Odac.Request.header('Content-Type', type)
252
+ if (url && !url.includes('/../')) {
253
+ const publicPath = `${__dir}/public${url}`
254
+
255
+ // PROD CACHE HIT (Metadata)
256
+ if (!Odac.Config.debug && this.#publicCache[publicPath]) {
257
+ const cached = this.#publicCache[publicPath]
258
+ Odac.Request.header('Content-Type', cached.type)
219
259
  Odac.Request.header('Cache-Control', 'public, max-age=31536000')
220
- Odac.Request.header('Content-Length', stat.size)
221
- return fs.createReadStream(`${__dir}/public${url}`)
260
+ Odac.Request.header('Content-Length', cached.size)
261
+ return fs.createReadStream(publicPath)
262
+ }
263
+
264
+ try {
265
+ const stat = await fsPromises.stat(publicPath)
266
+ if (stat.isFile()) {
267
+ let type = 'text/html'
268
+ if (url.includes('.')) {
269
+ let arr = url.split('.')
270
+ type = mime[arr[arr.length - 1]]
271
+ }
272
+
273
+ // PROD CACHE SET (Metadata Only)
274
+ if (!Odac.Config.debug) {
275
+ this.#publicCache[publicPath] = {
276
+ type,
277
+ size: stat.size
278
+ }
279
+ }
280
+
281
+ Odac.Request.header('Content-Type', type)
282
+ Odac.Request.header('Cache-Control', 'public, max-age=31536000')
283
+ Odac.Request.header('Content-Length', stat.size)
284
+ return fs.createReadStream(publicPath)
285
+ }
286
+ } catch {
287
+ // File not found in public
222
288
  }
223
289
  }
224
290
 
225
291
  return Odac.Request.abort(404)
226
292
  }
227
293
 
294
+ /**
295
+ * Resolves a URL to its matching route controller using a two-phase strategy:
296
+ * Phase 1 — O(1) exact hash-map match for static routes.
297
+ * Phase 2 — Pre-indexed parametric match grouped by segment count to avoid full iteration.
298
+ */
228
299
  #controller(route, method, url) {
229
- if (!this.routes[route] || !this.routes[route][method]) return false
230
- if (this.routes[route][method][url]) return this.routes[route][method][url]
231
- let arr = url.split('/')
232
- for (let key in this.routes[route][method]) {
233
- if (!key.includes('{') || !key.includes('}')) continue
234
- let route_arr = key.split('/')
235
- if (route_arr.length !== arr.length) continue
236
- let params = {}
237
- let next = false
238
- for (let i = 0; i < route_arr.length; i++) {
239
- if (route_arr[i].includes('{') && route_arr[i].includes('}')) {
240
- params[route_arr[i].replace('{', '').replace('}', '')] = arr[i]
241
- arr[i] = route_arr[i]
242
- } else if (route_arr[i] !== arr[i]) {
243
- next = true
244
- break
245
- }
300
+ const methodRoutes = this.routes[route]?.[method]
301
+ if (!methodRoutes) return false
302
+
303
+ // Phase 1: O(1) exact match
304
+ if (methodRoutes[url]) return methodRoutes[url]
305
+
306
+ // Phase 2: Parametric match via pre-built index (lazy rebuild on dirty)
307
+ if (this.#paramIndexDirty) {
308
+ this.#buildParamIndex()
309
+ this.#paramIndexDirty = false
310
+ }
311
+ const methodIndex = this.#paramIndex[route]?.[method]
312
+ if (!methodIndex) return false
313
+
314
+ const urlSegments = url.split('/')
315
+ const candidates = methodIndex.get(urlSegments.length)
316
+ if (!candidates) return false
317
+
318
+ candidateLoop: for (const {paramMap, staticChecks, routeObj} of candidates) {
319
+ for (const {index, value} of staticChecks) {
320
+ if (urlSegments[index] !== value) continue candidateLoop
321
+ }
322
+
323
+ const params = {}
324
+ for (const {index, name} of paramMap) {
325
+ params[name] = urlSegments[index]
326
+ }
327
+
328
+ return {
329
+ action: routeObj.action,
330
+ cache: routeObj.cache,
331
+ file: routeObj.file,
332
+ middlewares: routeObj.middlewares,
333
+ params,
334
+ token: routeObj.token
246
335
  }
247
- if (next) continue
248
- if (arr.join('/') === key)
249
- return {
250
- params: params,
251
- cache: this.routes[route][method][key].cache,
252
- token: this.routes[route][method][key].token,
253
- middlewares: this.routes[route][method][key].middlewares,
254
- file: this.routes[route][method][key].file
255
- }
256
336
  }
337
+
257
338
  return false
258
339
  }
259
340
 
260
- #loadMiddlewares() {
341
+ /**
342
+ * Builds a segment-count-grouped index of parametric routes for O(1) lookup by segment count.
343
+ * Eliminates per-request string parsing by pre-computing segment metadata at init time.
344
+ * Called after every route initialization cycle (startup + hot-reload).
345
+ */
346
+ #buildParamIndex() {
347
+ const index = {}
348
+ for (const route in this.routes) {
349
+ index[route] = {}
350
+ for (const method in this.routes[route]) {
351
+ const methodMap = new Map()
352
+ for (const key in this.routes[route][method]) {
353
+ if (!key.includes('{')) continue
354
+ const routeObj = this.routes[route][method][key]
355
+ if (!routeObj) continue
356
+
357
+ const segments = key.split('/')
358
+ const paramMap = []
359
+ const staticChecks = []
360
+ for (let i = 0; i < segments.length; i++) {
361
+ if (segments[i].startsWith('{') && segments[i].endsWith('}')) {
362
+ paramMap.push({index: i, name: segments[i].slice(1, -1)})
363
+ } else {
364
+ staticChecks.push({index: i, value: segments[i]})
365
+ }
366
+ }
367
+
368
+ const count = segments.length
369
+ let bucket = methodMap.get(count)
370
+ if (!bucket) {
371
+ bucket = []
372
+ methodMap.set(count, bucket)
373
+ }
374
+ bucket.push({key, paramMap, routeObj, staticChecks})
375
+ }
376
+ if (methodMap.size > 0) {
377
+ index[route][method] = methodMap
378
+ }
379
+ }
380
+ }
381
+ this.#paramIndex = index
382
+ }
383
+
384
+ async #loadMiddlewares() {
261
385
  const middlewareDir = `${__dir}/middleware/`
262
- if (!fs.existsSync(middlewareDir)) return
386
+ try {
387
+ await fsPromises.access(middlewareDir)
388
+ } catch {
389
+ return
390
+ }
263
391
 
264
- for (const file of fs.readdirSync(middlewareDir)) {
392
+ const files = await fsPromises.readdir(middlewareDir)
393
+ for (const file of files) {
265
394
  if (!file.endsWith('.js')) continue
266
395
  const name = file.replace('.js', '')
267
396
  const path = `${middlewareDir}${file}`
268
- const mtime = fs.statSync(path).mtimeMs
397
+ const stat = await fsPromises.stat(path)
398
+ const mtime = stat.mtimeMs
269
399
 
270
400
  if (this.middlewares[name] && this.middlewares[name].mtime >= mtime - 1000) continue
271
401
 
@@ -278,70 +408,127 @@ class Route {
278
408
  }
279
409
  }
280
410
 
281
- #init() {
411
+ async #init() {
282
412
  if (this.loading) return
283
413
  this.loading = true
284
- this.#loadMiddlewares()
414
+ await this.#loadMiddlewares()
285
415
  const classDir = `${__dir}/class/`
286
- if (fs.existsSync(classDir)) {
287
- for (const file of fs.readdirSync(classDir)) {
416
+ try {
417
+ await fsPromises.access(classDir)
418
+ const files = await fsPromises.readdir(classDir)
419
+ for (const file of files) {
288
420
  if (!file.endsWith('.js')) continue
289
421
  let name = file.replace('.js', '')
290
422
  if (!Odac.Route.class) Odac.Route.class = {}
423
+ const filePath = `${__dir}/class/${file}`
424
+
425
+ let shouldLoad = true
426
+ let stat = null
427
+
291
428
  if (Odac.Route.class[name]) {
292
- const fileStat = fs.statSync(Odac.Route.class[name].path)
293
- if (Odac.Route.class[name].mtime >= fileStat.mtimeMs || Date.now() < fileStat.mtimeMs + 1000) continue
294
- delete require.cache[require.resolve(Odac.Route.class[name].path)]
429
+ stat = await fsPromises.stat(Odac.Route.class[name].path)
430
+ if (Odac.Route.class[name].mtime >= stat.mtimeMs || Date.now() < stat.mtimeMs + 1000) {
431
+ shouldLoad = false
432
+ } else {
433
+ delete require.cache[require.resolve(Odac.Route.class[name].path)]
434
+ }
435
+ } else {
436
+ stat = await fsPromises.stat(filePath)
295
437
  }
296
- Odac.Route.class[name] = {
297
- path: `${__dir}/class/${file}`,
298
- mtime: fs.statSync(`${__dir}/class/${file}`).mtimeMs,
299
- module: require(`${__dir}/class/${file}`)
438
+
439
+ if (shouldLoad) {
440
+ Odac.Route.class[name] = {
441
+ path: filePath,
442
+ mtime: stat.mtimeMs,
443
+ module: require(filePath)
444
+ }
300
445
  }
301
446
  }
447
+ } catch {
448
+ // Class dir might not exist
302
449
  }
303
- let dir = fs.readdirSync(`${__dir}/route/`)
304
- for (const file of dir) {
305
- if (!file.endsWith('.js')) continue
306
- let mtime = fs.statSync(`${__dir}/route/${file}`).mtimeMs
307
- Odac.Route.buff = file.replace('.js', '')
308
- if (!routes2[Odac.Route.buff] || routes2[Odac.Route.buff] < mtime - 1000) {
309
- delete require.cache[require.resolve(`${__dir}/route/${file}`)]
310
- routes2[Odac.Route.buff] = mtime
311
- const routeModule = require(`${__dir}/route/${file}`)
312
- if (typeof routeModule === 'function') {
313
- routeModule(Odac)
450
+
451
+ try {
452
+ const dir = await fsPromises.readdir(`${__dir}/route/`)
453
+ for (const file of dir) {
454
+ if (!file.endsWith('.js')) continue
455
+ const filePath = `${__dir}/route/${file}`
456
+ const stat = await fsPromises.stat(filePath)
457
+ let mtime = stat.mtimeMs
458
+ Odac.Route.buff = file.replace('.js', '')
459
+
460
+ if (!routes2[Odac.Route.buff] || routes2[Odac.Route.buff] < mtime - 1000) {
461
+ delete require.cache[require.resolve(filePath)]
462
+ routes2[Odac.Route.buff] = mtime
463
+ const routeModule = require(filePath)
464
+ if (typeof routeModule === 'function') {
465
+ // routeModule calls .set(), which pushes promises to _pendingRouteLoads
466
+ routeModule(Odac)
467
+ }
314
468
  }
315
- }
316
- for (const type of ['page', '#page', 'post', '#post', 'get', '#get', 'error']) {
317
- if (!this.routes[Odac.Route.buff]) continue
318
- if (!this.routes[Odac.Route.buff][type]) continue
319
- for (const route in this.routes[Odac.Route.buff][type]) {
320
- if (routes2[Odac.Route.buff] > this.routes[Odac.Route.buff][type][route].loaded) {
321
- delete require.cache[require.resolve(this.routes[Odac.Route.buff][type][route].path)]
322
- delete this.routes[Odac.Route.buff][type][route]
323
- } else if (this.routes[Odac.Route.buff][type][route]) {
324
- if (typeof this.routes[Odac.Route.buff][type][route].type === 'function') continue
325
- if (this.routes[Odac.Route.buff][type][route].mtime < fs.statSync(this.routes[Odac.Route.buff][type][route].path).mtimeMs) {
326
- delete require.cache[require.resolve(this.routes[Odac.Route.buff][type][route].path)]
327
- this.routes[Odac.Route.buff][type][route].cache = require(this.routes[Odac.Route.buff][type][route].path)
328
- this.routes[Odac.Route.buff][type][route].mtime = fs.statSync(this.routes[Odac.Route.buff][type][route].path).mtimeMs
469
+
470
+ // Wait for all route sets to complete for this file
471
+ await Promise.all(this._pendingRouteLoads)
472
+ this._pendingRouteLoads = []
473
+
474
+ // Clean up deleted routes logic
475
+ for (const type of ['page', '#page', 'post', '#post', 'get', '#get', 'error']) {
476
+ if (!this.routes[Odac.Route.buff]) continue
477
+ if (!this.routes[Odac.Route.buff][type]) continue
478
+ for (const route in this.routes[Odac.Route.buff][type]) {
479
+ const routeObj = this.routes[Odac.Route.buff][type][route]
480
+ if (!routeObj) continue
481
+
482
+ if (routes2[Odac.Route.buff] > routeObj.loaded) {
483
+ if (routeObj.path) {
484
+ try {
485
+ delete require.cache[require.resolve(routeObj.path)]
486
+ } catch {
487
+ // Silently fail
488
+ }
489
+ }
490
+ delete this.routes[Odac.Route.buff][type][route]
491
+ } else if (routeObj) {
492
+ if (typeof routeObj.type === 'function') continue
493
+ // Check if controller file modified
494
+ try {
495
+ const cStat = await fsPromises.stat(routeObj.path)
496
+ if (routeObj.mtime < cStat.mtimeMs) {
497
+ delete require.cache[require.resolve(routeObj.path)]
498
+ routeObj.cache = require(routeObj.path)
499
+ routeObj.mtime = cStat.mtimeMs
500
+ }
501
+ } catch {
502
+ // Controller file might have been deleted?
503
+ }
329
504
  }
330
505
  }
331
506
  }
507
+ delete Odac.Route.buff
508
+ }
509
+ } catch (e) {
510
+ if (e.code === 'ENOENT') {
511
+ console.error(`[ODAC] Route directory not found: \x1b[33m${__dir}/route/\x1b[0m`)
512
+ } else {
513
+ console.error(e)
332
514
  }
333
- delete Odac.Route.buff
334
515
  }
516
+
335
517
  Cron.init()
518
+ this.#buildParamIndex()
336
519
  this.loading = false
337
520
  }
338
521
 
339
- init() {
340
- this.#init()
522
+ async init() {
523
+ await this.#init()
341
524
  this.#registerInternalRoutes()
342
- setInterval(() => {
343
- this.#init()
344
- }, 5000)
525
+
526
+ // Hot Reload only in Debug Mode
527
+ if (Odac.Config.debug) {
528
+ setInterval(async () => {
529
+ await this.#init()
530
+ }, 5000)
531
+ }
345
532
  }
346
533
 
347
534
  #registerInternalRoutes() {
@@ -456,6 +643,8 @@ class Route {
456
643
  }
457
644
 
458
645
  set(type, url, file, options = {}) {
646
+ const capturedMiddlewares = this._pendingMiddlewares.length > 0 ? [...this._pendingMiddlewares] : undefined
647
+
459
648
  if (Array.isArray(type)) {
460
649
  type = type.map(t => t.toLowerCase())
461
650
  for (const t of type) {
@@ -473,39 +662,84 @@ class Route {
473
662
  const isFunction = typeof file === 'function'
474
663
  let path = `${__dir}/route/${Odac.Route.buff}.js`
475
664
 
665
+ let action = null
666
+
476
667
  if (!isFunction && file) {
477
- path = `${__dir}/controller/${type.replace('#', '')}/${file}.js`
478
- if (typeof file === 'string' && file.includes('.')) {
479
- let arr = file.split('.')
480
- path = `${__dir}/controller/${arr[0]}/${type.replace('#', '')}/${arr.slice(1).join('.')}.js`
668
+ if (typeof file === 'string' && file.includes('@')) {
669
+ let arr = file.split('@')
670
+ file = arr[0]
671
+ action = arr[1]
672
+ path = `${__dir}/controller/${file.replace(/\./g, '/')}.js`
673
+ } else {
674
+ path = `${__dir}/controller/${type.replace('#', '')}/${file}.js`
675
+ if (typeof file === 'string' && file.includes('.')) {
676
+ let arr = file.split('.')
677
+ path = `${__dir}/controller/${arr[0]}/${type.replace('#', '')}/${arr.slice(1).join('.')}.js`
678
+ }
481
679
  }
482
680
  }
483
681
 
484
682
  if (!this.routes[Odac.Route.buff]) this.routes[Odac.Route.buff] = {}
485
683
  if (!this.routes[Odac.Route.buff][type]) this.routes[Odac.Route.buff][type] = {}
486
684
 
487
- if (this.routes[Odac.Route.buff][type][url]) {
488
- this.routes[Odac.Route.buff][type][url].loaded = routes2[Odac.Route.buff]
489
- if (!isFunction && this.routes[Odac.Route.buff][type][url].mtime < fs.statSync(path).mtimeMs) {
490
- delete this.routes[Odac.Route.buff][type][url]
491
- delete require.cache[require.resolve(path)]
492
- } else return this
685
+ const task = async () => {
686
+ if (this.routes[Odac.Route.buff][type][url]) {
687
+ this.routes[Odac.Route.buff][type][url].loaded = routes2[Odac.Route.buff]
688
+ if (!isFunction) {
689
+ try {
690
+ const stat = await fsPromises.stat(path)
691
+ if (this.routes[Odac.Route.buff][type][url].mtime < stat.mtimeMs) {
692
+ delete this.routes[Odac.Route.buff][type][url]
693
+ delete require.cache[require.resolve(path)]
694
+ } else {
695
+ return
696
+ }
697
+ } catch {
698
+ // File error, proceed to reload or re-set
699
+ }
700
+ } else {
701
+ return
702
+ }
703
+ }
704
+
705
+ if (isFunction) {
706
+ if (!this.routes[Odac.Route.buff][type][url]) this.routes[Odac.Route.buff][type][url] = {}
707
+ this.routes[Odac.Route.buff][type][url].cache = file
708
+ this.routes[Odac.Route.buff][type][url].type = 'function'
709
+ this.routes[Odac.Route.buff][type][url].file = file
710
+ this.routes[Odac.Route.buff][type][url].mtime = Date.now()
711
+ this.routes[Odac.Route.buff][type][url].path = path
712
+ this.routes[Odac.Route.buff][type][url].loaded = routes2[Odac.Route.buff]
713
+ this.routes[Odac.Route.buff][type][url].token = options.token ?? true
714
+ this.routes[Odac.Route.buff][type][url].action = action
715
+
716
+ this.routes[Odac.Route.buff][type][url].middlewares = capturedMiddlewares
717
+ } else {
718
+ try {
719
+ const stat = await fsPromises.stat(path)
720
+ if (!this.routes[Odac.Route.buff][type][url]) this.routes[Odac.Route.buff][type][url] = {}
721
+ this.routes[Odac.Route.buff][type][url].cache = require(path)
722
+ this.routes[Odac.Route.buff][type][url].type = 'controller'
723
+ this.routes[Odac.Route.buff][type][url].file = file
724
+ this.routes[Odac.Route.buff][type][url].mtime = stat.mtimeMs
725
+ this.routes[Odac.Route.buff][type][url].path = path
726
+ this.routes[Odac.Route.buff][type][url].loaded = routes2[Odac.Route.buff]
727
+ this.routes[Odac.Route.buff][type][url].token = options.token ?? true
728
+ this.routes[Odac.Route.buff][type][url].action = action
729
+
730
+ this.routes[Odac.Route.buff][type][url].middlewares = capturedMiddlewares
731
+ } catch {
732
+ if (file && typeof file === 'string') {
733
+ console.error(`\x1b[31m[Odac]\x1b[0m Controller not found: \x1b[33m${path}\x1b[0m`)
734
+ }
735
+ }
736
+ }
493
737
  }
494
738
 
495
- if (isFunction || fs.existsSync(path)) {
496
- if (!this.routes[Odac.Route.buff][type][url]) this.routes[Odac.Route.buff][type][url] = {}
497
- this.routes[Odac.Route.buff][type][url].cache = isFunction ? file : require(path)
498
- this.routes[Odac.Route.buff][type][url].type = isFunction ? 'function' : 'controller'
499
- this.routes[Odac.Route.buff][type][url].file = file
500
- this.routes[Odac.Route.buff][type][url].mtime = isFunction ? Date.now() : fs.statSync(path).mtimeMs
501
- this.routes[Odac.Route.buff][type][url].path = path
502
- this.routes[Odac.Route.buff][type][url].loaded = routes2[Odac.Route.buff]
503
- this.routes[Odac.Route.buff][type][url].token = options.token ?? true
739
+ this._pendingRouteLoads.push(task())
504
740
 
505
- this.routes[Odac.Route.buff][type][url].middlewares = this._pendingMiddlewares.length > 0 ? [...this._pendingMiddlewares] : undefined
506
- } else if (file && typeof file === 'string') {
507
- console.error(`\x1b[31m[Odac]\x1b[0m Controller not found: \x1b[33m${path}\x1b[0m`)
508
- }
741
+ // Mark parametric index for rebuild when route contains dynamic segments
742
+ if (url.includes('{')) this.#paramIndexDirty = true
509
743
 
510
744
  return this
511
745
  }
@@ -566,15 +800,31 @@ class Route {
566
800
  return this
567
801
  }
568
802
 
569
- authPost(path, authFile, file) {
570
- if (authFile) this.set('#post', path, authFile)
571
- if (file) this.post(path, file)
803
+ authPost(path, authFile, file, options) {
804
+ let opts = options
805
+ let publicFile = file
806
+
807
+ if (publicFile && typeof publicFile === 'object' && !opts) {
808
+ opts = publicFile
809
+ publicFile = undefined
810
+ }
811
+
812
+ if (authFile) this.set('#post', path, authFile, opts)
813
+ if (publicFile) this.post(path, publicFile, opts)
572
814
  return this
573
815
  }
574
816
 
575
- authGet(path, authFile, file) {
576
- if (authFile) this.set('#get', path, authFile)
577
- if (file) this.get(path, file)
817
+ authGet(path, authFile, file, options) {
818
+ let opts = options
819
+ let publicFile = file
820
+
821
+ if (publicFile && typeof publicFile === 'object' && !opts) {
822
+ opts = publicFile
823
+ publicFile = undefined
824
+ }
825
+
826
+ if (authFile) this.set('#get', path, authFile, opts)
827
+ if (publicFile) this.get(path, publicFile, opts)
578
828
  return this
579
829
  }
580
830
 
@@ -686,7 +936,7 @@ class Route {
686
936
  return res
687
937
  }
688
938
 
689
- this.#wsServer.route(path, wrappedHandler)
939
+ this.#wsServer.route(path, wrappedHandler, options)
690
940
  }
691
941
 
692
942
  handleWebSocketUpgrade(req, socket, head, Odac) {