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/client/odac.js CHANGED
@@ -23,9 +23,7 @@ class OdacWebSocket {
23
23
  connect() {
24
24
  if (this.#isClosed) return
25
25
 
26
- this.#socket = this.#protocols.length > 0
27
- ? new WebSocket(this.#url, this.#protocols)
28
- : new WebSocket(this.#url)
26
+ this.#socket = this.#protocols.length > 0 ? new WebSocket(this.#url, this.#protocols) : new WebSocket(this.#url)
29
27
 
30
28
  this.#socket.onopen = () => {
31
29
  this.#reconnectAttempts = 0
@@ -43,11 +41,7 @@ class OdacWebSocket {
43
41
 
44
42
  this.#socket.onclose = e => {
45
43
  this.emit('close', e)
46
- if (
47
- this.#options.autoReconnect &&
48
- !this.#isClosed &&
49
- this.#reconnectAttempts < this.#options.maxReconnectAttempts
50
- ) {
44
+ if (this.#options.autoReconnect && !this.#isClosed && this.#reconnectAttempts < this.#options.maxReconnectAttempts) {
51
45
  this.#reconnectAttempts++
52
46
  this.#reconnectTimer = setTimeout(() => this.connect(), this.#options.reconnectDelay)
53
47
  }
@@ -112,13 +106,13 @@ if (typeof window !== 'undefined') {
112
106
  #formSubmitHandlers = new Map()
113
107
  #loader = {elements: {}, callback: null}
114
108
  #isNavigating = false
115
-
109
+
116
110
  constructor() {
117
111
  // In constructor we can't call this.data() easily if it uses 'this' for caching properly before init
118
112
  // But based on original code logic:
119
113
  this.#data = this.data()
120
114
  }
121
-
115
+
122
116
  #ajax(options) {
123
117
  const {
124
118
  url,
@@ -132,19 +126,19 @@ if (typeof window !== 'undefined') {
132
126
  contentType = 'application/x-www-form-urlencoded; charset=UTF-8',
133
127
  xhr: xhrFactory
134
128
  } = options
135
-
129
+
136
130
  const xhr = xhrFactory ? xhrFactory() : new XMLHttpRequest()
137
-
131
+
138
132
  xhr.open(type, url, true)
139
-
133
+
140
134
  Object.keys(headers).forEach(key => {
141
135
  xhr.setRequestHeader(key, headers[key])
142
136
  })
143
-
137
+
144
138
  if (contentType && !(data instanceof FormData)) {
145
139
  xhr.setRequestHeader('Content-Type', contentType)
146
140
  }
147
-
141
+
148
142
  xhr.onload = () => {
149
143
  if (xhr.status >= 200 && xhr.status < 300) {
150
144
  let responseData = xhr.responseText
@@ -157,42 +151,42 @@ if (typeof window !== 'undefined') {
157
151
  return
158
152
  }
159
153
  }
160
-
154
+
161
155
  document.dispatchEvent(
162
156
  new CustomEvent('odac:ajaxSuccess', {
163
157
  detail: {response: responseData, status: xhr.statusText, xhr, requestUrl: url}
164
158
  })
165
159
  )
166
-
160
+
167
161
  success(responseData, xhr.statusText, xhr)
168
162
  } else {
169
163
  error(xhr, xhr.statusText)
170
164
  }
171
165
  }
172
-
166
+
173
167
  xhr.onerror = () => error(xhr, 'error')
174
168
  xhr.onloadend = () => complete()
175
169
  xhr.send(data)
176
170
  }
177
-
171
+
178
172
  #fade(element, type, duration = 400, callback) {
179
173
  const isIn = type === 'in'
180
174
  const startOpacity = isIn ? 0 : 1
181
175
  const endOpacity = isIn ? 1 : 0
182
-
176
+
183
177
  element.style.opacity = startOpacity
184
178
  if (isIn) {
185
179
  element.style.display = 'block'
186
180
  }
187
-
181
+
188
182
  let startTime = null
189
-
183
+
190
184
  const animate = currentTime => {
191
185
  if (!startTime) startTime = currentTime
192
186
  const progress = currentTime - startTime
193
187
  const opacity = startOpacity + (endOpacity - startOpacity) * Math.min(progress / duration, 1)
194
188
  element.style.opacity = opacity
195
-
189
+
196
190
  if (progress < duration) {
197
191
  requestAnimationFrame(animate)
198
192
  } else {
@@ -204,15 +198,15 @@ if (typeof window !== 'undefined') {
204
198
  }
205
199
  requestAnimationFrame(animate)
206
200
  }
207
-
201
+
208
202
  #fadeIn(element, duration, callback) {
209
203
  this.#fade(element, 'in', duration, callback)
210
204
  }
211
-
205
+
212
206
  #fadeOut(element, duration, callback) {
213
207
  this.#fade(element, 'out', duration, callback)
214
208
  }
215
-
209
+
216
210
  #on(element, event, selector, handler) {
217
211
  element.addEventListener(event, e => {
218
212
  let target = e.target.closest(selector)
@@ -221,7 +215,7 @@ if (typeof window !== 'undefined') {
221
215
  }
222
216
  })
223
217
  }
224
-
218
+
225
219
  #serialize(form) {
226
220
  const params = []
227
221
  form.querySelectorAll('input, select, textarea').forEach(el => {
@@ -243,22 +237,21 @@ if (typeof window !== 'undefined') {
243
237
  })
244
238
  return params.join('&')
245
239
  }
246
-
240
+
247
241
  action(obj) {
248
242
  if (obj.function) for (let func in obj.function) this.fn[func] = obj.function[func]
249
-
243
+
250
244
  if (obj.navigate !== undefined && obj.navigate !== false) {
251
245
  let selector, elements, callback
252
-
246
+
253
247
  if (typeof obj.navigate === 'string') {
254
248
  selector = 'a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)'
255
249
  elements = {content: obj.navigate}
256
250
  callback = null
257
- }
258
- else if (typeof obj.navigate === 'object') {
251
+ } else if (typeof obj.navigate === 'object') {
259
252
  let baseSelector = obj.navigate.links || obj.navigate.selector || 'a[href^="/"]'
260
253
  selector = `${baseSelector}:not([data-navigate="false"]):not(.no-navigate)`
261
-
254
+
262
255
  if (obj.navigate.update) {
263
256
  elements = typeof obj.navigate.update === 'string' ? {content: obj.navigate.update} : obj.navigate.update
264
257
  } else if (obj.navigate.elements) {
@@ -266,15 +259,14 @@ if (typeof window !== 'undefined') {
266
259
  } else {
267
260
  elements = {content: 'main'}
268
261
  }
269
-
262
+
270
263
  callback = obj.navigate.on || obj.navigate.callback || null
271
- }
272
- else if (obj.navigate === true) {
264
+ } else if (obj.navigate === true) {
273
265
  selector = 'a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)'
274
266
  elements = {content: 'main'}
275
267
  callback = null
276
268
  }
277
-
269
+
278
270
  if (document.readyState === 'loading') {
279
271
  document.addEventListener('DOMContentLoaded', () => {
280
272
  this.loader(selector, elements, callback)
@@ -283,7 +275,7 @@ if (typeof window !== 'undefined') {
283
275
  this.loader(selector, elements, callback)
284
276
  }
285
277
  }
286
-
278
+
287
279
  if (obj.start) document.addEventListener('DOMContentLoaded', () => obj.start())
288
280
  if (obj.load) {
289
281
  if (!this.actions.load) this.actions.load = []
@@ -312,12 +304,11 @@ if (typeof window !== 'undefined') {
312
304
  if (typeof obj[key][key2] == 'function') {
313
305
  this.#on(document, key, key2, obj[key][key2])
314
306
  } else {
315
- let func = ''
316
307
  let split = ''
317
308
  if (obj[key][key2].includes('.')) split = '.'
318
309
  else if (obj[key][key2].includes('#')) split = '#'
319
310
  else if (obj[key][key2].includes(' ')) split = ' '
320
- func = split != '' ? obj[key][key2].split(split) : [obj[key][key2]]
311
+ const func = split != '' ? obj[key][key2].split(split) : [obj[key][key2]]
321
312
  if (func != '') {
322
313
  let getfunc = obj
323
314
  func.forEach(function (item) {
@@ -329,12 +320,12 @@ if (typeof window !== 'undefined') {
329
320
  }
330
321
  }
331
322
  }
332
-
323
+
333
324
  client() {
334
325
  if (!document.cookie.includes('odac_client=')) return null
335
326
  return document.cookie.split('odac_client=')[1].split(';')[0]
336
327
  }
337
-
328
+
338
329
  data(key) {
339
330
  if (!this.#data) {
340
331
  const script = document.getElementById('odac-data')
@@ -346,30 +337,30 @@ if (typeof window !== 'undefined') {
346
337
  }
347
338
  }
348
339
  }
349
-
340
+
350
341
  if (this.#data) {
351
342
  if (key) return this.#data[key] ?? null
352
343
  return this.#data
353
344
  }
354
-
345
+
355
346
  return null
356
347
  }
357
-
348
+
358
349
  form(obj, callback) {
359
350
  if (typeof obj != 'object') obj = {form: obj}
360
351
  const formSelector = obj.form
361
-
352
+
362
353
  if (this.#formSubmitHandlers.has(formSelector)) {
363
354
  const oldHandler = this.#formSubmitHandlers.get(formSelector)
364
355
  document.removeEventListener('submit', oldHandler)
365
356
  }
366
-
357
+
367
358
  const handler = e => {
368
359
  const formElement = e.target.closest(formSelector)
369
360
  if (!formElement) return
370
-
361
+
371
362
  e.preventDefault()
372
-
363
+
373
364
  if (obj.messages !== false) {
374
365
  if (obj.messages == undefined || obj.messages == true || obj.messages.includes('error')) {
375
366
  formElement.querySelectorAll('*[odac-form-error]').forEach(el => (el.style.display = 'none'))
@@ -378,29 +369,31 @@ if (typeof window !== 'undefined') {
378
369
  formElement.querySelectorAll('*[odac-form-success]').forEach(el => (el.style.display = 'none'))
379
370
  }
380
371
  }
381
-
372
+
382
373
  const inputs = formElement.querySelectorAll('input:not([type="hidden"]), textarea, select')
383
374
  let isValid = true
384
375
  let firstInvalidInput = null
385
-
376
+
386
377
  const showError = (input, errorType) => {
387
378
  isValid = false
388
379
  firstInvalidInput = input
389
-
380
+
390
381
  if (input.type !== 'checkbox' && input.type !== 'radio') {
391
382
  input.style.borderColor = '#dc3545'
392
383
  }
393
-
384
+
394
385
  const customMessage = input.getAttribute(`data-error-${errorType}`)
395
386
  if (customMessage) {
396
387
  let errorSpan = formElement.querySelector(`[odac-form-error="${input.name}"]`)
397
-
388
+
398
389
  if (!errorSpan) {
399
390
  errorSpan = document.createElement('span')
400
391
  errorSpan.setAttribute('odac-form-error', input.name)
401
392
  if ((input.type === 'checkbox' || input.type === 'radio') && input.id) {
402
393
  const label = formElement.querySelector(`label[for="${input.id}"]`)
403
- label ? label.parentNode.insertBefore(errorSpan, label.nextSibling) : input.parentNode.insertBefore(errorSpan, input.nextSibling)
394
+ label
395
+ ? label.parentNode.insertBefore(errorSpan, label.nextSibling)
396
+ : input.parentNode.insertBefore(errorSpan, input.nextSibling)
404
397
  } else {
405
398
  input.parentNode.insertBefore(errorSpan, input.nextSibling)
406
399
  }
@@ -409,7 +402,7 @@ if (typeof window !== 'undefined') {
409
402
  errorSpan.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
410
403
  }
411
404
  }
412
-
405
+
413
406
  for (const input of inputs) {
414
407
  input.style.borderColor = ''
415
408
  const errorSpan = formElement.querySelector(`[odac-form-error="${input.name}"]`)
@@ -417,49 +410,62 @@ if (typeof window !== 'undefined') {
417
410
  errorSpan.style.display = 'none'
418
411
  errorSpan.textContent = ''
419
412
  }
420
-
413
+
421
414
  if (input.hasAttribute('required')) {
422
415
  const isEmpty = input.type === 'checkbox' || input.type === 'radio' ? !input.checked : !input.value.trim()
423
- if (isEmpty) { showError(input, 'required'); break; }
416
+ if (isEmpty) {
417
+ showError(input, 'required')
418
+ break
419
+ }
424
420
  }
425
-
421
+
426
422
  if (input.hasAttribute('minlength') && input.value && input.value.trim().length < parseInt(input.getAttribute('minlength'))) {
427
- showError(input, 'minlength'); break;
423
+ showError(input, 'minlength')
424
+ break
428
425
  }
429
-
426
+
430
427
  if (input.hasAttribute('maxlength') && input.value && input.value.trim().length > parseInt(input.getAttribute('maxlength'))) {
431
- showError(input, 'maxlength'); break;
428
+ showError(input, 'maxlength')
429
+ break
432
430
  }
433
-
431
+
434
432
  if (input.hasAttribute('pattern') && input.value) {
435
- if (!new RegExp(input.getAttribute('pattern')).test(input.value.trim())) { showError(input, 'pattern'); break; }
433
+ if (!new RegExp(input.getAttribute('pattern')).test(input.value.trim())) {
434
+ showError(input, 'pattern')
435
+ break
436
+ }
436
437
  }
437
-
438
+
438
439
  if (input.type === 'email' && input.value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.value.trim())) {
439
- showError(input, 'email'); break;
440
+ showError(input, 'email')
441
+ break
440
442
  }
441
443
  }
442
-
444
+
443
445
  if (!isValid) {
444
446
  if (firstInvalidInput) firstInvalidInput.focus()
445
447
  return
446
448
  }
447
-
449
+
448
450
  if (this.actions.odac?.form?.input?.class?.invalid) {
449
451
  const invalidClass = this.actions.odac.form.input.class.invalid
450
452
  formElement.querySelectorAll(`.${invalidClass}`).forEach(el => el.classList.remove(invalidClass))
451
453
  }
452
-
454
+
453
455
  let datastring, cache, contentType, processData
454
456
  if (formElement.querySelector('input[type=file]')) {
455
457
  datastring = new FormData(formElement)
456
458
  datastring.append('token', this.token())
457
- cache = false; contentType = false; processData = false
459
+ cache = false
460
+ contentType = false
461
+ processData = false
458
462
  } else {
459
463
  datastring = this.#serialize(formElement) + '&_token=' + this.token()
460
- cache = true; contentType = 'application/x-www-form-urlencoded; charset=UTF-8'; processData = true
464
+ cache = true
465
+ contentType = 'application/x-www-form-urlencoded; charset=UTF-8'
466
+ processData = true
461
467
  }
462
-
468
+
463
469
  const submitButtons = formElement.querySelectorAll('button[type="submit"], input[type="submit"]')
464
470
  submitButtons.forEach(btn => {
465
471
  btn.disabled = true
@@ -469,9 +475,9 @@ if (typeof window !== 'undefined') {
469
475
  btn.textContent = loadingText
470
476
  }
471
477
  })
472
-
478
+
473
479
  formElement.querySelectorAll('input:not([type="hidden"]), textarea, select').forEach(el => (el.disabled = true))
474
-
480
+
475
481
  this.#ajax({
476
482
  type: formElement.getAttribute('method'),
477
483
  url: formElement.getAttribute('action'),
@@ -494,39 +500,46 @@ if (typeof window !== 'undefined') {
494
500
  span.innerHTML = this.textToHtml(data.result.message)
495
501
  formElement.appendChild(span)
496
502
  }
497
-
503
+
498
504
  if (data.result._token) {
499
- const tokenInput = formElement.querySelector('input[name="_odac_form_token"]')
500
- if (tokenInput) tokenInput.value = data.result._token
501
-
502
- const formTokenAttr = formElement.getAttribute('data-odac-form')
503
- if (formTokenAttr) {
504
- formElement.setAttribute('data-odac-form', data.result._token)
505
- if (!formElement.matches(formSelector)) {
506
- if (this.#formSubmitHandlers.has(formSelector)) {
507
- document.removeEventListener('submit', this.#formSubmitHandlers.get(formSelector))
508
- this.#formSubmitHandlers.delete(formSelector)
509
- }
510
- const newObj = {...obj, form: `form[data-odac-form="${data.result._token}"]`}
511
- this.form(newObj, callback)
512
- }
513
- }
505
+ const tokenInput = formElement.querySelector('input[name="_odac_form_token"]')
506
+ if (tokenInput) tokenInput.value = data.result._token
507
+
508
+ const formTokenAttr = formElement.getAttribute('data-odac-form')
509
+ if (formTokenAttr) {
510
+ formElement.setAttribute('data-odac-form', data.result._token)
511
+ if (!formElement.matches(formSelector)) {
512
+ if (this.#formSubmitHandlers.has(formSelector)) {
513
+ document.removeEventListener('submit', this.#formSubmitHandlers.get(formSelector))
514
+ this.#formSubmitHandlers.delete(formSelector)
515
+ }
516
+ const newObj = {...obj, form: `form[data-odac-form="${data.result._token}"]`}
517
+ this.form(newObj, callback)
518
+ }
519
+ }
514
520
  }
515
-
516
- if (obj.clear !== false && formElement.getAttribute('clear') !== 'false' && !data.result.redirect) {
517
- formElement.querySelectorAll('input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([readonly]), textarea, select').forEach(el => {
518
- if (el.type === 'checkbox' || el.type === 'radio') el.checked = false
519
- else if (el.tagName === 'SELECT') el.selectedIndex = 0
520
- else el.value = ''
521
+
522
+ if (obj.clear !== false && formElement.getAttribute('clear') !== 'false' && !data.result.redirect) {
523
+ formElement
524
+ .querySelectorAll(
525
+ 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([readonly]), textarea, select'
526
+ )
527
+ .forEach(el => {
528
+ if (el.type === 'checkbox' || el.type === 'radio') el.checked = false
529
+ else if (el.tagName === 'SELECT') el.selectedIndex = 0
530
+ else el.value = ''
521
531
  })
522
- }
532
+ }
523
533
  } else if (!data.result.success && data.errors) {
524
534
  Object.entries(data.errors).forEach(([name, message]) => {
525
535
  if (message) {
526
536
  let errorEl = formElement.querySelector(`[odac-form-error="${name}"]`)
527
537
  if (errorEl) {
528
538
  errorEl.innerHTML = this.textToHtml(message)
529
- errorEl.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
539
+ errorEl.style.display = 'block'
540
+ if (!errorEl.style.color) errorEl.style.color = '#dc3545'
541
+ if (!errorEl.style.fontSize) errorEl.style.fontSize = '0.875rem'
542
+ if (!errorEl.style.marginTop) errorEl.style.marginTop = '0.25rem'
530
543
  } else {
531
544
  const inputEl = formElement.querySelector(`*[name="${name}"]`)
532
545
  if (inputEl) {
@@ -534,10 +547,12 @@ if (typeof window !== 'undefined') {
534
547
  errorEl.setAttribute('odac-form-error', name)
535
548
  errorEl.innerHTML = this.textToHtml(message)
536
549
  errorEl.style.cssText = 'display:block;color:#dc3545;font-size:0.875rem;margin-top:0.25rem'
537
-
550
+
538
551
  if ((inputEl.type === 'checkbox' || inputEl.type === 'radio') && inputEl.id) {
539
552
  const label = formElement.querySelector(`label[for="${inputEl.id}"]`)
540
- label ? label.parentNode.insertBefore(errorEl, label.nextSibling) : inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
553
+ label
554
+ ? label.parentNode.insertBefore(errorEl, label.nextSibling)
555
+ : inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
541
556
  } else {
542
557
  inputEl.parentNode.insertBefore(errorEl, inputEl.nextSibling)
543
558
  }
@@ -545,7 +560,10 @@ if (typeof window !== 'undefined') {
545
560
  const errorEl = document.createElement('div')
546
561
  errorEl.setAttribute('odac-form-error', name)
547
562
  errorEl.innerHTML = this.textToHtml(message)
548
- errorEl.style.cssText = 'display:block;color:#dc3545;background-color:#f8d7da;border:1px solid #f5c2c7;border-radius:0.375rem;padding:0.75rem 1rem;margin-bottom:1rem;font-size:0.875rem'
563
+ errorEl.style.display = 'block'
564
+ errorEl.style.color = '#dc3545'
565
+ errorEl.style.fontSize = '0.875rem'
566
+ errorEl.style.marginBottom = '1rem'
549
567
  formElement.insertBefore(errorEl, formElement.firstChild)
550
568
  }
551
569
  }
@@ -553,12 +571,19 @@ if (typeof window !== 'undefined') {
553
571
  const inputEl = formElement.querySelector(`*[name="${name}"]`)
554
572
  if (inputEl) {
555
573
  if (inputEl.type !== 'checkbox' && inputEl.type !== 'radio') inputEl.style.borderColor = '#dc3545'
556
- inputEl.addEventListener('focus', function handler() {
574
+ inputEl.addEventListener(
575
+ 'focus',
576
+ function handler() {
557
577
  inputEl.style.borderColor = ''
558
578
  const errorEl = formElement.querySelector(`[odac-form-error="${name}"]`)
559
- if (errorEl) { errorEl.style.display = 'none'; errorEl.textContent = '' }
579
+ if (errorEl) {
580
+ errorEl.style.display = 'none'
581
+ errorEl.textContent = ''
582
+ }
560
583
  inputEl.removeEventListener('focus', handler)
561
- }, {once: true})
584
+ },
585
+ {once: true}
586
+ )
562
587
  }
563
588
  })
564
589
  }
@@ -572,9 +597,13 @@ if (typeof window !== 'undefined') {
572
597
  },
573
598
  xhr: () => {
574
599
  var xhr = new window.XMLHttpRequest()
575
- xhr.upload.addEventListener('progress', evt => {
576
- if (evt.lengthComputable && obj.loading) obj.loading(parseInt((100 / evt.total) * evt.loaded))
577
- }, false)
600
+ xhr.upload.addEventListener(
601
+ 'progress',
602
+ evt => {
603
+ if (evt.lengthComputable && obj.loading) obj.loading(parseInt((100 / evt.total) * evt.loaded))
604
+ },
605
+ false
606
+ )
578
607
  return xhr
579
608
  },
580
609
  error: () => console.error('Odac:', 'Request failed', '\nForm: ' + obj.form + '\nRequest: ' + formElement.getAttribute('action')),
@@ -582,35 +611,38 @@ if (typeof window !== 'undefined') {
582
611
  submitButtons.forEach(btn => {
583
612
  btn.disabled = false
584
613
  const originalText = btn.getAttribute('data-original-text')
585
- if (originalText) { btn.textContent = originalText; btn.removeAttribute('data-original-text') }
614
+ if (originalText) {
615
+ btn.textContent = originalText
616
+ btn.removeAttribute('data-original-text')
617
+ }
586
618
  })
587
619
  formElement.querySelectorAll('input:not([type="hidden"]), textarea, select').forEach(el => (el.disabled = false))
588
620
  }
589
621
  })
590
622
  }
591
-
623
+
592
624
  document.addEventListener('submit', handler)
593
625
  this.#formSubmitHandlers.set(formSelector, handler)
594
626
  }
595
-
627
+
596
628
  get(url, callback) {
597
629
  url = url + '?_token=' + this.token()
598
630
  this.#ajax({url: url, success: callback})
599
631
  }
600
-
632
+
601
633
  page() {
602
634
  if (!this.#page) {
603
635
  this.#page = document.documentElement.dataset.odacPage || ''
604
636
  }
605
637
  return this.#page
606
638
  }
607
-
639
+
608
640
  storage(key, value) {
609
641
  if (value === undefined) return localStorage.getItem(key)
610
642
  else if (value === null) return localStorage.removeItem(key)
611
643
  else localStorage.setItem(key, value)
612
644
  }
613
-
645
+
614
646
  token() {
615
647
  if (!this.#token.listener) {
616
648
  document.addEventListener('odac:ajaxSuccess', event => {
@@ -619,8 +651,8 @@ if (typeof window !== 'undefined') {
619
651
  try {
620
652
  const token = detail.xhr.getResponseHeader('X-Odac-Token')
621
653
  if (token) {
622
- this.#token.hash.push(token)
623
- if (this.#token.hash.length > 2) this.#token.hash.shift()
654
+ this.#token.hash.push(token)
655
+ if (this.#token.hash.length > 2) this.#token.hash.shift()
624
656
  }
625
657
  } catch (e) {
626
658
  console.error('Error in ajaxSuccess token handler:', e)
@@ -651,55 +683,66 @@ if (typeof window !== 'undefined') {
651
683
  })
652
684
  return return_token
653
685
  }
654
-
686
+
655
687
  textToHtml(str) {
656
688
  if (typeof str !== 'string') return str
657
689
  return str
658
- .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
659
- .replace(/"/g, '&quot;').replace(/'/g, '&#039;').replace(/\n/g, '<br>')
690
+ .replace(/&/g, '&amp;')
691
+ .replace(/</g, '&lt;')
692
+ .replace(/>/g, '&gt;')
693
+ .replace(/"/g, '&quot;')
694
+ .replace(/'/g, '&#039;')
695
+ .replace(/\n/g, '<br>')
660
696
  }
661
-
697
+
662
698
  load(url, callback, push = true) {
663
699
  if (this.#isNavigating) return false
664
-
700
+
665
701
  const currentUrl = window.location.href
666
702
  url = new URL(url, currentUrl).href
667
- if (url === '' || url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('vbscript:') || url.includes('#')) return false
668
-
703
+ if (url === '' || url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('vbscript:') || url.includes('#'))
704
+ return false
705
+
669
706
  this.#isNavigating = true
670
-
707
+
671
708
  const currentSkeleton = document.documentElement.dataset.odacSkeleton
672
709
  const elements = Object.entries(this.#loader.elements)
673
-
710
+
674
711
  const elementsToUpdate = []
675
712
  elements.forEach(([key, selector]) => {
676
713
  const element = document.querySelector(selector)
677
714
  if (element) elementsToUpdate.push({key, element})
678
715
  })
679
-
680
- let ajaxData = null, ajaxXhr = null, fadeOutComplete = false, ajaxComplete = false
681
-
716
+
717
+ let ajaxData = null,
718
+ ajaxXhr = null,
719
+ fadeOutComplete = false,
720
+ ajaxComplete = false
721
+
682
722
  const applyUpdate = () => {
683
723
  if (!fadeOutComplete || !ajaxComplete || !ajaxData) return
684
-
724
+
685
725
  const finalUrl = ajaxXhr.responseURL || url
686
- if (ajaxData.skeletonChanged) { window.location.href = finalUrl; return }
726
+ if (ajaxData.skeletonChanged) {
727
+ window.location.href = finalUrl
728
+ return
729
+ }
687
730
  if (finalUrl !== currentUrl && push) window.history.pushState(null, document.title, finalUrl)
688
-
731
+
689
732
  const newPage = ajaxXhr.getResponseHeader('X-Odac-Page')
690
733
  if (newPage !== null) {
691
734
  this.#page = newPage
692
735
  document.documentElement.dataset.odacPage = newPage
693
736
  }
694
-
737
+
695
738
  if (ajaxData.data) this.#data = ajaxData.data
696
739
  if (ajaxData.title) document.title = ajaxData.title
697
-
740
+
698
741
  if (elementsToUpdate.length === 0) {
699
742
  this.#handleLoadComplete(ajaxData, callback)
700
743
  return
701
744
  }
702
-
745
+
703
746
  let completed = 0
704
747
  elementsToUpdate.forEach(({key, element}) => {
705
748
  if (ajaxData.output && ajaxData.output[key] !== undefined) element.innerHTML = ajaxData.output[key]
@@ -709,7 +752,7 @@ if (typeof window !== 'undefined') {
709
752
  })
710
753
  })
711
754
  }
712
-
755
+
713
756
  if (elementsToUpdate.length > 0) {
714
757
  let fadeOutCount = 0
715
758
  elementsToUpdate.forEach(({element}) => {
@@ -724,7 +767,7 @@ if (typeof window !== 'undefined') {
724
767
  } else {
725
768
  fadeOutComplete = true
726
769
  }
727
-
770
+
728
771
  this.#ajax({
729
772
  url: url,
730
773
  type: 'GET',
@@ -735,7 +778,10 @@ if (typeof window !== 'undefined') {
735
778
  },
736
779
  dataType: 'json',
737
780
  success: (data, status, xhr) => {
738
- ajaxData = data; ajaxXhr = xhr; ajaxComplete = true; applyUpdate()
781
+ ajaxData = data
782
+ ajaxXhr = xhr
783
+ ajaxComplete = true
784
+ applyUpdate()
739
785
  },
740
786
  error: () => {
741
787
  this.#isNavigating = false
@@ -743,52 +789,72 @@ if (typeof window !== 'undefined') {
743
789
  }
744
790
  })
745
791
  }
746
-
792
+
747
793
  #handleLoadComplete(data, callback) {
748
- if (this.actions.load) (Array.isArray(this.actions.load) ? this.actions.load : [this.actions.load]).forEach(fn => fn(this.page(), data.variables))
749
- if (this.actions.page && this.actions.page[this.page()]) (Array.isArray(this.actions.page[this.page()]) ? this.actions.page[this.page()] : [this.actions.page[this.page()]]).forEach(fn => fn(data.variables))
750
-
794
+ if (this.actions.load)
795
+ (Array.isArray(this.actions.load) ? this.actions.load : [this.actions.load]).forEach(fn => fn(this.page(), data.variables))
796
+ if (this.actions.page && this.actions.page[this.page()])
797
+ (Array.isArray(this.actions.page[this.page()]) ? this.actions.page[this.page()] : [this.actions.page[this.page()]]).forEach(fn =>
798
+ fn(data.variables)
799
+ )
800
+
751
801
  if (callback && typeof callback === 'function') callback(this.page(), data.variables)
752
-
802
+
753
803
  window.scrollTo({top: 0, behavior: 'smooth'})
754
804
  this.#isNavigating = false
755
805
  }
756
-
806
+
757
807
  loader(selector, elements, callback) {
758
808
  this.#loader.elements = elements
759
809
  this.#loader.callback = callback
760
810
  const odacInstance = this
761
-
811
+
762
812
  this.#on(document, 'click', selector, function (e) {
763
813
  if (e.ctrlKey || e.metaKey) return
764
814
  const anchor = this
765
815
  if (!anchor) return
766
816
  const url = anchor.getAttribute('href')
767
817
  const target = anchor.getAttribute('target')
768
- if (!url || url === '' || url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('vbscript:') || url.startsWith('#')) return
818
+ if (
819
+ !url ||
820
+ url === '' ||
821
+ url.startsWith('javascript:') ||
822
+ url.startsWith('data:') ||
823
+ url.startsWith('vbscript:') ||
824
+ url.startsWith('#')
825
+ )
826
+ return
769
827
  const isExternal = url.includes('://') && !url.includes(window.location.host)
770
828
  if ((target === null || target === '_self') && !isExternal) {
771
829
  e.preventDefault()
772
830
  odacInstance.load(url, callback)
773
831
  }
774
832
  })
775
-
833
+
776
834
  window.addEventListener('popstate', () => {
777
835
  this.load(window.location.href, callback, false)
778
836
  })
779
837
  }
780
-
838
+
781
839
  listen(url, onMessage, options = {}) {
782
840
  const {onError = null, onOpen = null, autoReconnect = false, reconnectDelay = 3000} = options
783
- let eventSource = null, reconnectTimer = null, isClosed = false
784
-
841
+ let eventSource = null,
842
+ reconnectTimer = null,
843
+ isClosed = false
844
+
785
845
  const connect = () => {
786
846
  if (isClosed) return
787
847
  const urlWithToken = url + (url.includes('?') ? '&' : '?') + '_token=' + encodeURIComponent(this.token())
788
848
  eventSource = new EventSource(urlWithToken)
789
- eventSource.onopen = e => { if (onOpen) onOpen(e) }
849
+ eventSource.onopen = e => {
850
+ if (onOpen) onOpen(e)
851
+ }
790
852
  eventSource.onmessage = e => {
791
- try { onMessage(JSON.parse(e.data)) } catch { onMessage(e.data) }
853
+ try {
854
+ onMessage(JSON.parse(e.data))
855
+ } catch {
856
+ onMessage(e.data)
857
+ }
792
858
  }
793
859
  eventSource.onerror = e => {
794
860
  if (onError) onError(e)
@@ -799,24 +865,26 @@ if (typeof window !== 'undefined') {
799
865
  }
800
866
  }
801
867
  connect()
802
-
868
+
803
869
  return {
804
870
  close: () => {
805
871
  isClosed = true
806
872
  if (reconnectTimer) clearTimeout(reconnectTimer)
807
873
  if (eventSource) eventSource.close()
808
874
  },
809
- send: () => { throw new Error('SSE is one-way. Use POST requests to send data.') }
875
+ send: () => {
876
+ throw new Error('SSE is one-way. Use POST requests to send data.')
877
+ }
810
878
  }
811
879
  }
812
-
880
+
813
881
  ws(path, options = {}) {
814
882
  const {autoReconnect = true, reconnectDelay = 3000, maxReconnectAttempts = 10, shared = false, token = true} = options
815
-
883
+
816
884
  if (shared && typeof SharedWorker !== 'undefined') {
817
885
  return this.#createSharedWebSocket(path, {autoReconnect, reconnectDelay, maxReconnectAttempts, token})
818
886
  }
819
-
887
+
820
888
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
821
889
  const wsUrl = `${protocol}//${window.location.host}${path}`
822
890
  const protocols = []
@@ -824,10 +892,10 @@ if (typeof window !== 'undefined') {
824
892
  const csrfToken = this.token()
825
893
  if (csrfToken) protocols.push(`odac-token-${csrfToken}`)
826
894
  }
827
-
895
+
828
896
  return new OdacWebSocket(wsUrl, protocols, options)
829
897
  }
830
-
898
+
831
899
  #createSharedWebSocket(path, options) {
832
900
  const scriptUrl = (() => {
833
901
  if (document.currentScript) return document.currentScript.src
@@ -840,11 +908,11 @@ if (typeof window !== 'undefined') {
840
908
  const worker = new SharedWorker(scriptUrl, `odac-ws-${path}`)
841
909
  const handlers = {}
842
910
  let isConnected = false
843
-
911
+
844
912
  const emit = (event, ...args) => {
845
913
  if (handlers[event]) handlers[event].forEach(fn => fn(...args))
846
914
  }
847
-
915
+
848
916
  worker.port.onmessage = e => {
849
917
  const {type, data} = e.data
850
918
  switch (type) {
@@ -864,11 +932,11 @@ if (typeof window !== 'undefined') {
864
932
  break
865
933
  }
866
934
  }
867
-
935
+
868
936
  worker.port.start()
869
-
937
+
870
938
  const token = options.token ? this.token() : null
871
-
939
+
872
940
  worker.port.postMessage({
873
941
  type: 'connect',
874
942
  path,
@@ -877,7 +945,7 @@ if (typeof window !== 'undefined') {
877
945
  token,
878
946
  options
879
947
  })
880
-
948
+
881
949
  return {
882
950
  on: (event, handler) => {
883
951
  if (!handlers[event]) handlers[event] = []
@@ -910,9 +978,8 @@ if (typeof window !== 'undefined') {
910
978
  }
911
979
  }
912
980
  }
913
-
981
+
914
982
  window.Odac = new _odac()
915
-
916
983
  ;(function initAutoNavigate() {
917
984
  const init = () => {
918
985
  const contentEl = document.querySelector('[data-odac-navigate="content"]')
@@ -922,9 +989,9 @@ if (typeof window !== 'undefined') {
922
989
  }
923
990
  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init()
924
991
  })()
925
-
992
+
926
993
  document.addEventListener('DOMContentLoaded', () => {
927
- ['register', 'login'].forEach(type => {
994
+ ;['register', 'login'].forEach(type => {
928
995
  document.querySelectorAll(`form.odac-${type}-form[data-odac-${type}]`).forEach(form => {
929
996
  const token = form.getAttribute(`data-odac-${type}`)
930
997
  window.Odac.form({form: `form[data-odac-${type}="${token}"]`})
@@ -956,11 +1023,11 @@ if (typeof window !== 'undefined') {
956
1023
  socket = new OdacWebSocket(wsUrl, protocols, options)
957
1024
  socket.on('open', () => broadcast('open'))
958
1025
  socket.on('message', data => broadcast('message', data))
959
- socket.on('close', e => broadcast('close', e))
960
- socket.on('error', e => broadcast('error', e))
1026
+ socket.on('close', e => broadcast('close', {code: e?.code, reason: e?.reason, wasClean: e?.wasClean}))
1027
+ socket.on('error', e => broadcast('error', {message: e?.message || 'WebSocket error'}))
961
1028
  } else if (socket.connected) {
962
- // If already connected, notify the new port immediately
963
- port.postMessage({type: 'open'})
1029
+ // If already connected, notify the new port immediately
1030
+ port.postMessage({type: 'open'})
964
1031
  }
965
1032
  break
966
1033
  case 'send':