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/View/Form.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const nodeCrypto = require('crypto')
2
2
 
3
3
  class Form {
4
- static FORM_TYPES = ['register', 'login', 'form']
4
+ static FORM_TYPES = ['register', 'login', 'magic-login', 'form']
5
5
 
6
6
  static parse(content, Odac) {
7
7
  for (const type of this.FORM_TYPES) {
@@ -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
17
-
18
- for (const match of matches) {
19
- const formToken = nodeCrypto.randomBytes(32).toString('hex')
20
- const formConfig = this.extractConfig(match, formToken, type)
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
+ }
21
22
 
22
- this.storeConfig(formToken, formConfig, Odac, 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
23
31
 
24
- const generatedForm = this.generateForm(match, formConfig, formToken, type)
25
- content = content.replace(match, generatedForm)
26
- }
32
+ this.storeConfig(token, config, Odac, type)
27
33
 
28
- return content
34
+ return this.generateForm(originalHtml, config, token, type)
29
35
  }
30
36
 
31
37
  static extractConfig(html, formToken, type) {
@@ -33,6 +39,8 @@ class Form {
33
39
  return this.extractRegisterConfig(html, formToken)
34
40
  } else if (type === 'login') {
35
41
  return this.extractLoginConfig(html, formToken)
42
+ } else if (type === 'magic-login') {
43
+ return this.extractMagicLoginConfig(html, formToken)
36
44
  } else if (type === 'form') {
37
45
  return this.extractFormConfig(html, formToken)
38
46
  }
@@ -43,6 +51,8 @@ class Form {
43
51
  this.storeRegisterConfig(token, config, Odac)
44
52
  } else if (type === 'login') {
45
53
  this.storeLoginConfig(token, config, Odac)
54
+ } else if (type === 'magic-login') {
55
+ this.storeMagicLoginConfig(token, config, Odac)
46
56
  } else if (type === 'form') {
47
57
  this.storeFormConfig(token, config, Odac)
48
58
  }
@@ -53,6 +63,8 @@ class Form {
53
63
  return this.generateRegisterForm(originalHtml, config, formToken)
54
64
  } else if (type === 'login') {
55
65
  return this.generateLoginForm(originalHtml, config, formToken)
66
+ } else if (type === 'magic-login') {
67
+ return this.generateMagicLoginForm(originalHtml, config, formToken)
56
68
  } else if (type === 'form') {
57
69
  return this.generateCustomForm(originalHtml, config, formToken)
58
70
  }
@@ -97,10 +109,10 @@ class Form {
97
109
  if (idMatch) config.submitId = idMatch[1]
98
110
  }
99
111
 
100
- const fieldMatches = html.match(/<odac:field[\s\S]*?<\/odac:field>/g)
112
+ const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
101
113
  if (fieldMatches) {
102
114
  for (const fieldHtml of fieldMatches) {
103
- const field = this.parseField(fieldHtml)
115
+ const field = this.parseInput(fieldHtml)
104
116
  if (field) config.fields.push(field)
105
117
  }
106
118
  }
@@ -116,8 +128,8 @@ class Form {
116
128
  return config
117
129
  }
118
130
 
119
- static parseField(html) {
120
- const fieldTagMatch = html.match(/<odac:field([^>]*?)(?:\/>|>)/)
131
+ static parseInput(html) {
132
+ const fieldTagMatch = html.match(/<odac:input([^>]*?)(?:\/>|>)/)
121
133
  if (!fieldTagMatch) return null
122
134
 
123
135
  const fieldTag = fieldTagMatch[0]
@@ -167,6 +179,26 @@ class Form {
167
179
  }
168
180
  }
169
181
 
182
+ // Capture generic attributes
183
+ const extraAttrs = {}
184
+ const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip']
185
+ const attrRegex = /(\w+)(?:=(["'])((?:(?!\2).)*)\2|=([^\s>]+))?/g
186
+ let attrMatch
187
+ // Clean tag to just attributes part for safer regex matching if needed,
188
+ // or just run on fieldTag from start
189
+ const attributesString = fieldTag.replace(/^<odac:input/, '').replace(/\/?>$/, '')
190
+
191
+ while ((attrMatch = attrRegex.exec(attributesString))) {
192
+ const key = attrMatch[1]
193
+ // If value is undefined, it's a boolean attribute (e.g. required, autofocus) -> set as true (or empty string)
194
+ const value = attrMatch[3] !== undefined ? attrMatch[3] : attrMatch[4] !== undefined ? attrMatch[4] : ''
195
+
196
+ if (!knownAttrs.includes(key)) {
197
+ extraAttrs[key] = value
198
+ }
199
+ }
200
+ field.extraAttributes = extraAttrs
201
+
170
202
  return field
171
203
  }
172
204
 
@@ -218,8 +250,8 @@ class Form {
218
250
 
219
251
  let innerContent = originalHtml.replace(/<odac:register[^>]*>/, '').replace(/<\/odac:register>/, '')
220
252
 
221
- innerContent = innerContent.replace(/<odac:field[\s\S]*?<\/odac:field>/g, fieldMatch => {
222
- const field = this.parseField(fieldMatch)
253
+ innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
254
+ const field = this.parseInput(fieldMatch)
223
255
  if (!field) return fieldMatch
224
256
  return this.generateFieldHtml(field)
225
257
  })
@@ -277,6 +309,23 @@ class Form {
277
309
  return html
278
310
  }
279
311
 
312
+ static appendExtraAttributes(attrs, field) {
313
+ if (field.extraAttributes) {
314
+ for (const key in field.extraAttributes) {
315
+ const val = field.extraAttributes[key]
316
+ // If val is empty string, render as boolean attribute if typical, or key=""
317
+ // For HTML5 boolean attrs like autofocus, required, checked, readonly, disabled, multiple, selected
318
+ // presence is enough.
319
+ if (val === '') {
320
+ attrs += ` ${key}`
321
+ } else {
322
+ attrs += ` ${key}="${val.replace(/"/g, '&quot;')}"`
323
+ }
324
+ }
325
+ }
326
+ return attrs
327
+ }
328
+
280
329
  static buildHtml5Attributes(field) {
281
330
  let attrs = ''
282
331
  const html5Rules = {
@@ -363,6 +412,8 @@ class Form {
363
412
  if (errorMessages.pattern) attrs += ` data-error-pattern="${errorMessages.pattern.replace(/"/g, '&quot;')}"`
364
413
  if (errorMessages.email) attrs += ` data-error-email="${errorMessages.email.replace(/"/g, '&quot;')}"`
365
414
 
415
+ attrs = this.appendExtraAttributes(attrs, field)
416
+
366
417
  return attrs
367
418
  }
368
419
 
@@ -401,10 +452,10 @@ class Form {
401
452
  if (idMatch) config.submitId = idMatch[1]
402
453
  }
403
454
 
404
- const fieldMatches = html.match(/<odac:field[\s\S]*?<\/odac:field>/g)
455
+ const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
405
456
  if (fieldMatches) {
406
457
  for (const fieldHtml of fieldMatches) {
407
- const field = this.parseField(fieldHtml)
458
+ const field = this.parseInput(fieldHtml)
408
459
  if (field) config.fields.push(field)
409
460
  }
410
461
  }
@@ -435,8 +486,8 @@ class Form {
435
486
 
436
487
  let innerContent = originalHtml.replace(/<odac:login[^>]*>/, '').replace(/<\/odac:login>/, '')
437
488
 
438
- innerContent = innerContent.replace(/<odac:field[\s\S]*?<\/odac:field>/g, fieldMatch => {
439
- const field = this.parseField(fieldMatch)
489
+ innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
490
+ const field = this.parseInput(fieldMatch)
440
491
  if (!field) return fieldMatch
441
492
  return this.generateFieldHtml(field)
442
493
  })
@@ -519,10 +570,10 @@ class Form {
519
570
  if (idMatch) config.submitId = idMatch[1]
520
571
  }
521
572
 
522
- const fieldMatches = html.match(/<odac:field[\s\S]*?<\/odac:field>/g)
573
+ const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
523
574
  if (fieldMatches) {
524
575
  for (const fieldHtml of fieldMatches) {
525
- const field = this.parseField(fieldHtml)
576
+ const field = this.parseInput(fieldHtml)
526
577
  if (field) config.fields.push(field)
527
578
  }
528
579
  }
@@ -558,13 +609,14 @@ class Form {
558
609
  static generateCustomForm(originalHtml, config, formToken) {
559
610
  const submitText = config.submitText || 'Submit'
560
611
  const submitLoading = config.submitLoading || 'Processing...'
561
- const action = config.action || '/_odac/form'
612
+ // Always post to internal handler, real action is in session config
613
+ const formAction = '/_odac/form'
562
614
  const method = config.method || 'POST'
563
615
 
564
616
  let innerContent = originalHtml.replace(/<odac:form[^>]*>/, '').replace(/<\/odac:form>/, '')
565
617
 
566
- innerContent = innerContent.replace(/<odac:field[\s\S]*?<\/odac:field>/g, fieldMatch => {
567
- const field = this.parseField(fieldMatch)
618
+ innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
619
+ const field = this.parseInput(fieldMatch)
568
620
  if (!field) return fieldMatch
569
621
  return this.generateFieldHtml(field)
570
622
  })
@@ -584,7 +636,7 @@ class Form {
584
636
 
585
637
  innerContent = innerContent.replace(/<odac:set[^>]*\/?>/g, '')
586
638
 
587
- let formAttrs = `class="odac-custom-form${config.class ? ' ' + escapeHtml(config.class) : ''}" data-odac-form="${escapeHtml(formToken)}" method="${escapeHtml(method)}" action="${escapeHtml(action)}" novalidate`
639
+ let formAttrs = `class="odac-custom-form${config.class ? ' ' + escapeHtml(config.class) : ''}" data-odac-form="${escapeHtml(formToken)}" method="${escapeHtml(method)}" action="${escapeHtml(formAction)}" novalidate`
588
640
  if (config.id) formAttrs += ` id="${escapeHtml(config.id)}"`
589
641
 
590
642
  let html = `<form ${formAttrs}>\n`
@@ -595,6 +647,136 @@ class Form {
595
647
 
596
648
  return html
597
649
  }
650
+
651
+ static extractMagicLoginConfig(html, formToken) {
652
+ const config = {
653
+ token: formToken,
654
+ redirect: null,
655
+ submitText: 'Send Magic Link',
656
+ submitLoading: 'Sending...',
657
+ fields: []
658
+ }
659
+
660
+ const tagMatch = html.match(/<odac:magic-login([^>]*)>/)
661
+ if (!tagMatch) return config
662
+
663
+ const tag = tagMatch[0]
664
+ const redirectMatch = tag.match(/redirect=["']([^"']+)["']/)
665
+ const emailLabelMatch = tag.match(/email-label=["']([^"']+)["']/)
666
+
667
+ if (redirectMatch) config.redirect = redirectMatch[1]
668
+
669
+ // Auto-add email field if not manually specified (simplified usage)
670
+ const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
671
+
672
+ if (fieldMatches) {
673
+ // Custom fields included
674
+ for (const fieldHtml of fieldMatches) {
675
+ const field = this.parseInput(fieldHtml)
676
+ if (field) config.fields.push(field)
677
+ }
678
+ } else {
679
+ // Default Email Field
680
+ config.fields.push({
681
+ name: 'email',
682
+ type: 'email',
683
+ placeholder: 'e.g. user@example.com',
684
+ label: emailLabelMatch ? emailLabelMatch[1] : 'Email Address',
685
+ class: '',
686
+ id: null,
687
+ unique: false,
688
+ skip: false,
689
+ validations: [
690
+ {rule: 'required', message: 'Email is required'},
691
+ {rule: 'email', message: 'Invalid email format'}
692
+ ]
693
+ })
694
+ }
695
+
696
+ const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
697
+ if (submitMatch) {
698
+ const submitTag = submitMatch[1]
699
+ const textMatch = submitTag.match(/text=["']([^"']+)["']/)
700
+ const loadingMatch = submitTag.match(/loading=["']([^"']+)["']/)
701
+ const classMatch = submitTag.match(/class=["']([^"']+)["']/)
702
+ const styleMatch = submitTag.match(/style=["']([^"']+)["']/)
703
+ const idMatch = submitTag.match(/id=["']([^"']+)["']/)
704
+
705
+ if (textMatch) config.submitText = textMatch[1]
706
+ else if (submitMatch[2]) config.submitText = submitMatch[2].trim()
707
+
708
+ if (loadingMatch) config.submitLoading = loadingMatch[1]
709
+ if (classMatch) config.submitClass = classMatch[1]
710
+ if (styleMatch) config.submitStyle = styleMatch[1]
711
+ if (idMatch) config.submitId = idMatch[1]
712
+ } else {
713
+ // Check for submit-text attribute on main tag if no submit tag
714
+ const submitTextAttr = tag.match(/submit-text=["']([^"']+)["']/)
715
+ if (submitTextAttr) config.submitText = submitTextAttr[1]
716
+ }
717
+
718
+ return config
719
+ }
720
+
721
+ static storeMagicLoginConfig(token, config, Odac) {
722
+ if (!Odac.View) Odac.View = {}
723
+ if (!Odac.View.magicLoginForms) Odac.View.magicLoginForms = {}
724
+
725
+ const formData = {
726
+ config: config,
727
+ created: Date.now(),
728
+ expires: Date.now() + 30 * 60 * 1000,
729
+ sessionId: Odac.Request.session('_client'),
730
+ userAgent: Odac.Request.header('user-agent'),
731
+ ip: Odac.Request.ip
732
+ }
733
+
734
+ Odac.View.magicLoginForms[token] = formData
735
+ Odac.Request.session(`_magic_login_form_${token}`, formData)
736
+ }
737
+
738
+ static generateMagicLoginForm(originalHtml, config, formToken) {
739
+ const submitText = config.submitText || 'Send Magic Link'
740
+ const submitLoading = config.submitLoading || 'Sending...'
741
+
742
+ let innerContent = originalHtml.replace(/<odac:magic-login[^>]*>/, '').replace(/<\/odac:magic-login>/, '')
743
+
744
+ // If no custom fields were present in HTML but we added default email in config
745
+ if (!originalHtml.includes('<odac:input')) {
746
+ const emailField = config.fields.find(f => f.name === 'email')
747
+ if (emailField) {
748
+ innerContent += this.generateFieldHtml(emailField)
749
+ }
750
+ } else {
751
+ innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
752
+ const field = this.parseInput(fieldMatch)
753
+ if (!field) return fieldMatch
754
+ return this.generateFieldHtml(field)
755
+ })
756
+ }
757
+
758
+ const submitMatch = innerContent.match(/<odac:submit[\s\S]*?(?:<\/odac:submit>|\/?>)/)
759
+ if (submitMatch) {
760
+ let submitAttrs = `type="submit" data-submit-text="${submitText}" data-loading-text="${submitLoading}"`
761
+ if (config.submitClass) submitAttrs += ` class="${config.submitClass}"`
762
+ if (config.submitStyle) submitAttrs += ` style="${config.submitStyle}"`
763
+ if (config.submitId) submitAttrs += ` id="${config.submitId}"`
764
+ const submitButton = `<button ${submitAttrs}>${submitText}</button>`
765
+ innerContent = innerContent.replace(submitMatch[0], submitButton)
766
+ } else if (!innerContent.includes('type="submit"')) {
767
+ // Auto add submit button if missing
768
+ const submitButton = `<button type="submit" data-submit-text="${submitText}" data-loading-text="${submitLoading}">${submitText}</button>`
769
+ innerContent += `\n${submitButton}`
770
+ }
771
+
772
+ let html = `<form class="odac-magic-login-form" data-odac-magic-login="${formToken}" method="POST" action="/_odac/magic-login" novalidate>\n`
773
+ html += ` <input type="hidden" name="_odac_magic_login_token" value="${formToken}">\n`
774
+ html += innerContent
775
+ html += `\n <span class="odac-form-success" style="display:none;"></span>\n`
776
+ html += `</form>`
777
+
778
+ return html
779
+ }
598
780
  }
599
781
 
600
782
  module.exports = Form
package/src/View.js CHANGED
@@ -1,12 +1,14 @@
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
 
7
+ const TITLE_REGEX = /<title[^>]*>([^<]*)<\/title>/i
8
+
6
9
  const CACHE_DIR = './storage/.cache'
7
10
 
8
11
  class View {
9
- #cache = {}
10
12
  #earlyHints = null
11
13
  #functions = {
12
14
  '{!!': {
@@ -114,6 +116,8 @@ class View {
114
116
  } else {
115
117
  this.#earlyHints = global.Odac.View.EarlyHints
116
118
  }
119
+ global.Odac.View.Form = Form
120
+ this.Form = Form
117
121
  }
118
122
 
119
123
  all(name) {
@@ -140,12 +144,45 @@ class View {
140
144
  }
141
145
 
142
146
  // Render requested elements
147
+ let title = null
143
148
  for (let element of this.#odac.Request.ajaxLoad) {
144
149
  if (this.#part[element]) {
145
150
  let viewPath = this.#part[element]
146
151
  if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
147
152
  if (fs.existsSync(`./view/${element}/${viewPath}.html`)) {
148
- output[element] = await this.#render(`./view/${element}/${viewPath}.html`)
153
+ const html = await this.#render(`./view/${element}/${viewPath}.html`)
154
+ output[element] = html
155
+
156
+ // Extract title if present inside the part
157
+ const titleMatch = html.match(TITLE_REGEX)
158
+ if (titleMatch && titleMatch[1]) {
159
+ title = titleMatch[1]
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ // If title not found in parts, try to extract from 'head', 'header' or 'meta' parts
166
+ if (!title) {
167
+ const priorityParts = ['head', 'header', 'meta']
168
+ for (const key of priorityParts) {
169
+ if (this.#part[key] && !this.#odac.Request.ajaxLoad.includes(key)) {
170
+ let viewPath = this.#part[key]
171
+ if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
172
+ if (fs.existsSync(`./view/${key}/${viewPath}.html`)) {
173
+ try {
174
+ const partHtml = await this.#render(`./view/${key}/${viewPath}.html`)
175
+ const titleMatch = partHtml.match(TITLE_REGEX)
176
+ if (titleMatch && titleMatch[1]) {
177
+ title = titleMatch[1]
178
+ break
179
+ }
180
+ } catch (e) {
181
+ if (this.#odac.Config?.debug) {
182
+ console.warn(`Odac: Failed to render part '${key}' while searching for title:`, e)
183
+ }
184
+ }
185
+ }
149
186
  }
150
187
  }
151
188
  }
@@ -161,6 +198,8 @@ class View {
161
198
  this.#odac.Request.end({
162
199
  output: output,
163
200
  variables: variables,
201
+ data: this.#odac.Request.sharedData,
202
+ title: title,
164
203
  skeletonChanged: skeletonChanged
165
204
  })
166
205
  return
@@ -168,8 +207,8 @@ class View {
168
207
 
169
208
  // Normal page rendering
170
209
  let result = ''
171
- if (this.#part.skeleton && fs.existsSync(`./skeleton/${this.#part.skeleton}.html`)) {
172
- 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`)
173
212
 
174
213
  // Add data-odac-navigate to content wrapper for auto-navigation
175
214
  result = this.#addNavigateAttribute(result)
@@ -207,6 +246,14 @@ class View {
207
246
  this.#earlyHints.cacheHints(routePath, detectedResources)
208
247
  }
209
248
  }
249
+
250
+ // Inject Shared Data
251
+ const sharedScript = `<script type="application/json" id="odac-data">${JSON.stringify(this.#odac.Request.sharedData || {})}</script>`
252
+ if (result.includes('</body>')) {
253
+ result = result.replace('</body>', `${sharedScript}</body>`)
254
+ } else {
255
+ result += sharedScript
256
+ }
210
257
  }
211
258
 
212
259
  this.#odac.Request.header('Content-Type', 'text/html')
@@ -318,10 +365,42 @@ class View {
318
365
  }
319
366
 
320
367
  async #render(file) {
321
- let mtime = fs.statSync(file).mtimeMs
322
- 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 = 0
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
323
392
 
324
- if (this.#cache[file]?.mtime !== mtime) {
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
+ }
402
+
403
+ if (content !== null) {
325
404
  content = Form.parse(content, this.#odac)
326
405
 
327
406
  const jsBlocks = []
@@ -505,6 +584,28 @@ class View {
505
584
  this.#odac.Request.setEarlyHints(hints)
506
585
  }
507
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
+ }
508
609
  }
509
610
 
510
611
  module.exports = View
package/src/WebSocket.js CHANGED
@@ -22,11 +22,16 @@ class WebSocketClient {
22
22
 
23
23
  constructor(socket, server, id) {
24
24
  this.#socket = socket
25
+ this.#socket.pause()
25
26
  this.#server = server
26
27
  this.#id = id
27
28
  this.#setupListeners()
28
29
  }
29
30
 
31
+ resume() {
32
+ this.#socket.resume()
33
+ }
34
+
30
35
  get id() {
31
36
  return this.#id
32
37
  }
@@ -129,6 +134,7 @@ class WebSocketClient {
129
134
  break
130
135
  case OPCODE.PING:
131
136
  this.#sendFrame(OPCODE.PONG, frame.payload)
137
+ this.#emit('ping', frame.payload)
132
138
  break
133
139
  case OPCODE.PONG:
134
140
  this.#emit('pong')
@@ -152,6 +158,8 @@ class WebSocketClient {
152
158
  if (this.#closed) return
153
159
  this.#closed = true
154
160
 
161
+ this.#socket.removeAllListeners()
162
+
155
163
  for (const room of this.#rooms) {
156
164
  this.#server.leaveRoom(this.#id, room)
157
165
  }
@@ -239,6 +247,7 @@ class WebSocketClient {
239
247
 
240
248
  this.#sendFrame(OPCODE.CLOSE, payload)
241
249
  this.#socket.end()
250
+ this.#socket.removeAllListeners()
242
251
 
243
252
  for (const room of this.#rooms) {
244
253
  this.#server.leaveRoom(this.#id, room)
@@ -330,13 +339,19 @@ class WebSocketServer {
330
339
  'HTTP/1.1 101 Switching Protocols',
331
340
  'Upgrade: websocket',
332
341
  'Connection: Upgrade',
333
- `Sec-WebSocket-Accept: ${acceptKey}`,
334
- '',
335
- ''
342
+ `Sec-WebSocket-Accept: ${acceptKey}`
336
343
  ]
337
344
 
345
+ if (req.headers['sec-websocket-protocol']) {
346
+ responseHeaders.push(`Sec-WebSocket-Protocol: ${req.headers['sec-websocket-protocol']}`)
347
+ }
348
+
349
+ responseHeaders.push('', '')
350
+
338
351
  socket.write(responseHeaders.join('\r\n'))
339
352
 
353
+ if (head && head.length > 0) socket.unshift(head)
354
+
340
355
  const clientId = nodeCrypto.randomUUID()
341
356
  const client = new WebSocketClient(socket, this, clientId)
342
357
  this.#clients.set(clientId, client)
@@ -0,0 +1,5 @@
1
+ {
2
+ "route": {
3
+ "/assets/js/odac.js": "${odac}/odac.js"
4
+ }
5
+ }
@@ -3,7 +3,9 @@
3
3
  "version": "1.0.0",
4
4
  "description": "Website for {{domain_original}}",
5
5
  "scripts": {
6
- "dev": "odac dev"
6
+ "dev": "odac dev",
7
+ "build": "odac build",
8
+ "start": "odac start"
7
9
  },
8
10
  "dependencies": {
9
11
  "odac": "*"
@@ -5,15 +5,17 @@
5
5
  // Controllers are located in controller/page/ directory
6
6
 
7
7
  // Home page - displays welcome message, features, and interactive demos
8
- Odac.Route.page('/', 'index')
8
+ module.exports = function (Odac) {
9
+ Odac.Route.page('/', 'index')
9
10
 
10
- // About page - provides information about Odac
11
- Odac.Route.page('/about', 'about')
11
+ // About page - provides information about Odac
12
+ Odac.Route.page('/about', 'about')
12
13
 
13
- // ============================================
14
- // API Routes
15
- // ============================================
16
- // Add your API routes here
17
- // Example:
18
- // Odac.Route.post('/api/contact', 'contact')
19
- // Odac.Route.get('/api/data', 'data')
14
+ // ============================================
15
+ // API Routes
16
+ // ============================================
17
+ // Add your API routes here
18
+ // Example:
19
+ // Odac.Route.post('/api/contact', 'contact')
20
+ // Odac.Route.get('/api/data', 'data')
21
+ }
@@ -60,9 +60,9 @@
60
60
 
61
61
  <div class="structure-item">
62
62
  <div class="structure-icon">⚙️</div>
63
- <h3 class="structure-title">config.json</h3>
63
+ <h3 class="structure-title">odac.json</h3>
64
64
  <p class="structure-description">Configuration file for database, routes, and other settings.</p>
65
- <code class="structure-path">config.json</code>
65
+ <code class="structure-path">odac.json</code>
66
66
  </div>
67
67
  </div>
68
68
  </div>
@@ -156,7 +156,7 @@
156
156
  <div class="feature-icon">🗄️</div>
157
157
  <div class="feature-content">
158
158
  <h3>Database Ready</h3>
159
- <p>MySQL and SQLite support built-in. Configure in <code>config.json</code></p>
159
+ <p>MySQL and SQLite support built-in. Configure in <code>odac.json</code></p>
160
160
  </div>
161
161
  </div>
162
162