odac 1.2.0 → 1.4.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 (55) hide show
  1. package/.agent/rules/memory.md +16 -1
  2. package/.github/workflows/release.yml +27 -5
  3. package/.husky/pre-push +3 -3
  4. package/.releaserc.js +2 -2
  5. package/AGENTS.md +47 -0
  6. package/CHANGELOG.md +64 -0
  7. package/README.md +1 -1
  8. package/bin/odac.js +187 -8
  9. package/client/odac.js +243 -178
  10. package/docs/ai/README.md +49 -0
  11. package/docs/ai/skills/SKILL.md +39 -0
  12. package/docs/ai/skills/backend/authentication.md +67 -0
  13. package/docs/ai/skills/backend/config.md +32 -0
  14. package/docs/ai/skills/backend/controllers.md +62 -0
  15. package/docs/ai/skills/backend/cron.md +50 -0
  16. package/docs/ai/skills/backend/database.md +21 -0
  17. package/docs/ai/skills/backend/forms.md +19 -0
  18. package/docs/ai/skills/backend/ipc.md +55 -0
  19. package/docs/ai/skills/backend/mail.md +34 -0
  20. package/docs/ai/skills/backend/request_response.md +35 -0
  21. package/docs/ai/skills/backend/routing.md +51 -0
  22. package/docs/ai/skills/backend/storage.md +43 -0
  23. package/docs/ai/skills/backend/streaming.md +34 -0
  24. package/docs/ai/skills/backend/structure.md +57 -0
  25. package/docs/ai/skills/backend/translations.md +42 -0
  26. package/docs/ai/skills/backend/utilities.md +24 -0
  27. package/docs/ai/skills/backend/validation.md +53 -0
  28. package/docs/ai/skills/backend/views.md +61 -0
  29. package/docs/ai/skills/frontend/core.md +66 -0
  30. package/docs/ai/skills/frontend/forms.md +21 -0
  31. package/docs/ai/skills/frontend/navigation.md +20 -0
  32. package/docs/ai/skills/frontend/realtime.md +47 -0
  33. package/docs/backend/04-routing/09-websocket.md +14 -1
  34. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +2 -0
  35. package/docs/backend/10-authentication/05-session-management.md +25 -3
  36. package/package.json +13 -13
  37. package/src/Auth.js +100 -15
  38. package/src/Database.js +1 -1
  39. package/src/Mail.js +19 -9
  40. package/src/Odac.js +17 -14
  41. package/src/Request.js +5 -1
  42. package/src/Route/Internal.js +21 -18
  43. package/src/Route/MimeTypes.js +56 -0
  44. package/src/Route.js +136 -92
  45. package/src/Validator.js +23 -14
  46. package/src/View/Form.js +91 -51
  47. package/src/View.js +15 -10
  48. package/src/WebSocket.js +45 -12
  49. package/test/Auth.test.js +249 -0
  50. package/test/Client.test.js +29 -0
  51. package/test/Odac.test.js +4 -2
  52. package/test/Route.test.js +104 -0
  53. package/test/View/Form.test.js +37 -0
  54. package/test/WebSocket.test.js +141 -3
  55. package/.github/workflows/test-publish.yml +0 -36
package/src/View/Form.js CHANGED
@@ -3,6 +3,18 @@ const nodeCrypto = require('crypto')
3
3
  class Form {
4
4
  static FORM_TYPES = ['register', 'login', 'magic-login', 'form']
5
5
 
6
+ static escapeHtml(value) {
7
+ if (value === null || value === undefined) return ''
8
+ const map = {
9
+ '&': '&',
10
+ '<': '&lt;',
11
+ '>': '&gt;',
12
+ '"': '&quot;',
13
+ "'": '&#39;'
14
+ }
15
+ return String(value).replace(/[&<>"']/g, ch => map[ch])
16
+ }
17
+
6
18
  static parse(content, Odac) {
7
19
  for (const type of this.FORM_TYPES) {
8
20
  content = this.parseFormType(content, Odac, type)
@@ -14,8 +26,14 @@ class Form {
14
26
  const regex = new RegExp(`<odac:${type}[\\s\\S]*?<\\/odac:${type}>`, 'g')
15
27
  return content.replace(regex, match => {
16
28
  const formConfig = this.extractConfig(match, null, type)
17
- const configStr = JSON.stringify(formConfig)
18
- const matchStr = JSON.stringify(match)
29
+ let configStr = JSON.stringify(formConfig)
30
+ let matchStr = JSON.stringify(match)
31
+
32
+ // Unquote dynamic variables to make them live JS expressions in the compiled view
33
+ // We avoid {{ }} here because View engine would turn them into ${ } which is invalid in naked JS
34
+ configStr = configStr.replace(/"\{\{([\s\S]*?)\}\}"/g, '(await $1)')
35
+ matchStr = matchStr.replace(/\{\{([\s\S]*?)\}\}/g, '" + (await Odac.Var(await $1).html()) + "')
36
+
19
37
  return `<script:odac>html += await Odac.View.Form.runtime(Odac, '${type}', ${configStr}, ${matchStr});</script:odac>`
20
38
  })
21
39
  }
@@ -91,7 +109,7 @@ class Form {
91
109
  if (redirectMatch) config.redirect = redirectMatch[1]
92
110
  if (autologinMatch) config.autologin = autologinMatch[1] !== 'false'
93
111
 
94
- const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
112
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
95
113
  if (submitMatch) {
96
114
  const submitTag = submitMatch[1]
97
115
  const textMatch = submitTag.match(/text=["']([^"']+)["']/)
@@ -145,22 +163,25 @@ class Form {
145
163
  id: null,
146
164
  unique: false,
147
165
  skip: false,
166
+ value: null,
148
167
  validations: []
149
168
  }
150
169
 
151
- const typeMatch = fieldTag.match(/type=["']([^"']+)["']/)
152
- const placeholderMatch = fieldTag.match(/placeholder=["']([^"']+)["']/)
153
- const labelMatch = fieldTag.match(/label=["']([^"']+)["']/)
154
- const classMatch = fieldTag.match(/class=["']([^"']+)["']/)
155
- const idMatch = fieldTag.match(/id=["']([^"']+)["']/)
170
+ const typeMatch = fieldTag.match(/type=(["'])(.*?)\1/)
171
+ const placeholderMatch = fieldTag.match(/placeholder=(["'])(.*?)\1/)
172
+ const labelMatch = fieldTag.match(/label=(["'])(.*?)\1/)
173
+ const classMatch = fieldTag.match(/class=(["'])(.*?)\1/)
174
+ const idMatch = fieldTag.match(/id=(["'])(.*?)\1/)
175
+ const valueMatch = fieldTag.match(/value=(["'])(.*?)\1/)
156
176
  const uniqueMatch = fieldTag.match(/unique=["']([^"']+)["']/) || fieldTag.match(/\sunique[\s/>]/)
157
177
  const skipMatch = fieldTag.match(/skip=["']([^"']+)["']/) || fieldTag.match(/\sskip[\s/>]/)
158
178
 
159
- if (typeMatch) field.type = typeMatch[1]
160
- if (placeholderMatch) field.placeholder = placeholderMatch[1]
161
- if (labelMatch) field.label = labelMatch[1]
162
- if (classMatch) field.class = classMatch[1]
163
- if (idMatch) field.id = idMatch[1]
179
+ if (typeMatch) field.type = typeMatch[2]
180
+ if (placeholderMatch) field.placeholder = placeholderMatch[2]
181
+ if (labelMatch) field.label = labelMatch[2]
182
+ if (classMatch) field.class = classMatch[2]
183
+ if (idMatch) field.id = idMatch[2]
184
+ if (valueMatch) field.value = valueMatch[2]
164
185
  if (uniqueMatch) field.unique = uniqueMatch[1] !== 'false'
165
186
  if (skipMatch) field.skip = skipMatch[1] !== 'false'
166
187
 
@@ -181,7 +202,7 @@ class Form {
181
202
 
182
203
  // Capture generic attributes
183
204
  const extraAttrs = {}
184
- const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip']
205
+ const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip', 'value']
185
206
  const attrRegex = /(\w+)(?:=(["'])((?:(?!\2).)*)\2|=([^\s>]+))?/g
186
207
  let attrMatch
187
208
  // Clean tag to just attributes part for safer regex matching if needed,
@@ -203,11 +224,11 @@ class Form {
203
224
  }
204
225
 
205
226
  static parseSet(html) {
206
- const nameMatch = html.match(/name=["']([^"']+)["']/)
227
+ const nameMatch = html.match(/name=(["'])(.*?)\1/)
207
228
  if (!nameMatch) return null
208
229
 
209
230
  const set = {
210
- name: nameMatch[1],
231
+ name: nameMatch[2],
211
232
  value: null,
212
233
  compute: null,
213
234
  callback: null,
@@ -215,14 +236,14 @@ class Form {
215
236
  }
216
237
 
217
238
  const valueMatch = html.match(/value=(["'])(.*?)\1/)
218
- const computeMatch = html.match(/compute=["']([^"']+)["']/)
219
- const callbackMatch = html.match(/callback=["']([^"']+)["']/)
220
- const ifEmptyMatch = html.match(/if-empty=["']([^"']+)["']/) || html.match(/\sif-empty[\s/>]/)
239
+ const computeMatch = html.match(/compute=(["'])(.*?)\1/)
240
+ const callbackMatch = html.match(/callback=(["'])(.*?)\1/)
241
+ const ifEmptyMatch = html.match(/if-empty=(["'])(.*?)\1/) || html.match(/\sif-empty[\s/>]/)
221
242
 
222
243
  if (valueMatch) set.value = valueMatch[2]
223
- if (computeMatch) set.compute = computeMatch[1]
224
- if (callbackMatch) set.callback = callbackMatch[1]
225
- if (ifEmptyMatch) set.ifEmpty = ifEmptyMatch[1] !== 'false'
244
+ if (computeMatch) set.compute = computeMatch[2]
245
+ if (callbackMatch) set.callback = callbackMatch[2]
246
+ if (ifEmptyMatch) set.ifEmpty = ifEmptyMatch[2] !== 'false'
226
247
 
227
248
  return set
228
249
  }
@@ -253,6 +274,9 @@ class Form {
253
274
  innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
254
275
  const field = this.parseInput(fieldMatch)
255
276
  if (!field) return fieldMatch
277
+ // Sync with resolved config value if available
278
+ const configField = config.fields.find(f => f.name === field.name)
279
+ if (configField) field.value = configField.value
256
280
  return this.generateFieldHtml(field)
257
281
  })
258
282
 
@@ -279,31 +303,38 @@ class Form {
279
303
 
280
304
  static generateFieldHtml(field) {
281
305
  let html = ''
306
+ const escapedName = this.escapeHtml(field.name)
307
+ const escapedType = this.escapeHtml(field.type)
308
+ const escapedPlaceholder = this.escapeHtml(field.placeholder)
282
309
 
283
310
  if (field.label && field.type !== 'checkbox') {
284
- const fieldId = field.id || `odac-${field.name}`
285
- html += `<label for="${fieldId}">${field.label}</label>\n`
311
+ const fieldId = this.escapeHtml(field.id || `odac-${field.name}`)
312
+ html += `<label for="${fieldId}">${this.escapeHtml(field.label)}</label>\n`
286
313
  }
287
314
 
288
- const classAttr = field.class ? ` class="${field.class}"` : ''
289
- const idAttr = field.id ? ` id="${field.id}"` : ` id="odac-${field.name}"`
315
+ const classAttr = field.class ? ` class="${this.escapeHtml(field.class)}"` : ''
316
+ const idAttr = field.id ? ` id="${this.escapeHtml(field.id)}"` : ` id="${this.escapeHtml(`odac-${field.name}`)}"`
317
+ const valueAttr = field.value !== null ? ` value="${this.escapeHtml(field.value)}"` : ''
290
318
 
291
319
  if (field.type === 'checkbox') {
292
320
  const attrs = this.buildHtml5Attributes(field)
321
+ const checkedAttr = field.value === '1' || field.value === true || field.value === 'true' ? ' checked' : ''
293
322
  if (field.label) {
294
323
  html += `<label>\n`
295
- html += ` <input type="checkbox"${idAttr} name="${field.name}" value="1"${classAttr}${attrs}>\n`
296
- html += ` ${field.label}\n`
324
+ html += ` <input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
325
+ html += ` ${this.escapeHtml(field.label)}\n`
297
326
  html += `</label>\n`
298
327
  } else {
299
- html += `<input type="checkbox"${idAttr} name="${field.name}" value="1"${classAttr}${attrs}>\n`
328
+ html += `<input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
300
329
  }
301
330
  } else if (field.type === 'textarea') {
302
331
  const attrs = this.buildHtml5Attributes(field)
303
- html += `<textarea${idAttr} name="${field.name}" placeholder="${field.placeholder}"${classAttr}${attrs}></textarea>\n`
332
+ html += `<textarea${idAttr} name="${escapedName}" placeholder="${escapedPlaceholder}"${classAttr}${attrs}>${this.escapeHtml(
333
+ field.value || ''
334
+ )}</textarea>\n`
304
335
  } else {
305
336
  const attrs = this.buildHtml5Attributes(field)
306
- html += `<input type="${field.type}"${idAttr} name="${field.name}" placeholder="${field.placeholder}"${classAttr}${attrs}>\n`
337
+ html += `<input type="${escapedType}"${idAttr} name="${escapedName}"${valueAttr} placeholder="${escapedPlaceholder}"${classAttr}${attrs}>\n`
307
338
  }
308
339
 
309
340
  return html
@@ -319,7 +350,7 @@ class Form {
319
350
  if (val === '') {
320
351
  attrs += ` ${key}`
321
352
  } else {
322
- attrs += ` ${key}="${val.replace(/"/g, '&quot;')}"`
353
+ attrs += ` ${key}="${this.escapeHtml(val)}"`
323
354
  }
324
355
  }
325
356
  }
@@ -406,11 +437,11 @@ class Form {
406
437
  if (html5Rules.max) attrs += ` max="${html5Rules.max}"`
407
438
  if (html5Rules.pattern) attrs += ` pattern="${html5Rules.pattern}"`
408
439
 
409
- if (errorMessages.required) attrs += ` data-error-required="${errorMessages.required.replace(/"/g, '&quot;')}"`
410
- if (errorMessages.minlength) attrs += ` data-error-minlength="${errorMessages.minlength.replace(/"/g, '&quot;')}"`
411
- if (errorMessages.maxlength) attrs += ` data-error-maxlength="${errorMessages.maxlength.replace(/"/g, '&quot;')}"`
412
- if (errorMessages.pattern) attrs += ` data-error-pattern="${errorMessages.pattern.replace(/"/g, '&quot;')}"`
413
- if (errorMessages.email) attrs += ` data-error-email="${errorMessages.email.replace(/"/g, '&quot;')}"`
440
+ if (errorMessages.required) attrs += ` data-error-required="${this.escapeHtml(errorMessages.required)}"`
441
+ if (errorMessages.minlength) attrs += ` data-error-minlength="${this.escapeHtml(errorMessages.minlength)}"`
442
+ if (errorMessages.maxlength) attrs += ` data-error-maxlength="${this.escapeHtml(errorMessages.maxlength)}"`
443
+ if (errorMessages.pattern) attrs += ` data-error-pattern="${this.escapeHtml(errorMessages.pattern)}"`
444
+ if (errorMessages.email) attrs += ` data-error-email="${this.escapeHtml(errorMessages.email)}"`
414
445
 
415
446
  attrs = this.appendExtraAttributes(attrs, field)
416
447
 
@@ -434,7 +465,7 @@ class Form {
434
465
 
435
466
  if (redirectMatch) config.redirect = redirectMatch[1]
436
467
 
437
- const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
468
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
438
469
  if (submitMatch) {
439
470
  const submitTag = submitMatch[1]
440
471
  const textMatch = submitTag.match(/text=["']([^"']+)["']/)
@@ -489,6 +520,9 @@ class Form {
489
520
  innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
490
521
  const field = this.parseInput(fieldMatch)
491
522
  if (!field) return fieldMatch
523
+ // Sync with resolved config value if available
524
+ const configField = config.fields.find(f => f.name === field.name)
525
+ if (configField) field.value = configField.value
492
526
  return this.generateFieldHtml(field)
493
527
  })
494
528
 
@@ -551,8 +585,10 @@ class Form {
551
585
  if (tableMatch) config.table = tableMatch
552
586
  if (redirectMatch) config.redirect = redirectMatch
553
587
  if (successMatch) config.successMessage = successMatch
588
+ const clearMatch = extractAttr('clear')
589
+ if (clearMatch !== null) config.clear = clearMatch === 'true' || clearMatch === ''
554
590
 
555
- const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
591
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
556
592
  if (submitMatch) {
557
593
  const submitTag = submitMatch[1]
558
594
  const textMatch = submitTag.match(/text=["']([^"']+)["']/)
@@ -618,29 +654,30 @@ class Form {
618
654
  innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
619
655
  const field = this.parseInput(fieldMatch)
620
656
  if (!field) return fieldMatch
657
+ // Sync with resolved config value if available
658
+ const configField = config.fields.find(f => f.name === field.name)
659
+ if (configField) field.value = configField.value
621
660
  return this.generateFieldHtml(field)
622
661
  })
623
662
 
624
- const escapeHtml = str =>
625
- String(str).replace(/[&<>"']/g, m => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[m])
626
-
627
663
  const submitMatch = innerContent.match(/<odac:submit[\s\S]*?(?:<\/odac:submit>|\/?>)/)
628
664
  if (submitMatch) {
629
- let submitAttrs = `type="submit" data-submit-text="${escapeHtml(submitText)}" data-loading-text="${escapeHtml(submitLoading)}"`
630
- if (config.submitClass) submitAttrs += ` class="${escapeHtml(config.submitClass)}"`
631
- if (config.submitStyle) submitAttrs += ` style="${escapeHtml(config.submitStyle)}"`
632
- if (config.submitId) submitAttrs += ` id="${escapeHtml(config.submitId)}"`
633
- const submitButton = `<button ${submitAttrs}>${escapeHtml(submitText)}</button>`
665
+ let submitAttrs = `type="submit" data-submit-text="${this.escapeHtml(submitText)}" data-loading-text="${this.escapeHtml(submitLoading)}"`
666
+ if (config.submitClass) submitAttrs += ` class="${this.escapeHtml(config.submitClass)}"`
667
+ if (config.submitStyle) submitAttrs += ` style="${this.escapeHtml(config.submitStyle)}"`
668
+ if (config.submitId) submitAttrs += ` id="${this.escapeHtml(config.submitId)}"`
669
+ const submitButton = `<button ${submitAttrs}>${this.escapeHtml(submitText)}</button>`
634
670
  innerContent = innerContent.replace(submitMatch[0], submitButton)
635
671
  }
636
672
 
637
673
  innerContent = innerContent.replace(/<odac:set[^>]*\/?>/g, '')
638
674
 
639
- let formAttrs = `class="odac-custom-form${config.class ? ' ' + escapeHtml(config.class) : ''}" data-odac-form="${escapeHtml(formToken)}" method="${escapeHtml(method)}" action="${escapeHtml(formAction)}" novalidate`
640
- if (config.id) formAttrs += ` id="${escapeHtml(config.id)}"`
675
+ let formAttrs = `class="odac-custom-form${config.class ? ' ' + this.escapeHtml(config.class) : ''}" data-odac-form="${this.escapeHtml(formToken)}" method="${this.escapeHtml(method)}" action="${this.escapeHtml(formAction)}" novalidate`
676
+ if (config.id) formAttrs += ` id="${this.escapeHtml(config.id)}"`
677
+ if (config.clear !== undefined) formAttrs += ` clear="${config.clear}"`
641
678
 
642
679
  let html = `<form ${formAttrs}>\n`
643
- html += ` <input type="hidden" name="_odac_form_token" value="${escapeHtml(formToken)}">\n`
680
+ html += ` <input type="hidden" name="_odac_form_token" value="${this.escapeHtml(formToken)}">\n`
644
681
  html += innerContent
645
682
  html += `\n <span class="odac-form-success" style="display:none;"></span>\n`
646
683
  html += `</form>`
@@ -693,7 +730,7 @@ class Form {
693
730
  })
694
731
  }
695
732
 
696
- const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
733
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
697
734
  if (submitMatch) {
698
735
  const submitTag = submitMatch[1]
699
736
  const textMatch = submitTag.match(/text=["']([^"']+)["']/)
@@ -751,6 +788,9 @@ class Form {
751
788
  innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
752
789
  const field = this.parseInput(fieldMatch)
753
790
  if (!field) return fieldMatch
791
+ // Sync with resolved config value if available
792
+ const configField = config.fields.find(f => f.name === field.name)
793
+ if (configField) field.value = configField.value
754
794
  return this.generateFieldHtml(field)
755
795
  })
756
796
  }
package/src/View.js CHANGED
@@ -58,6 +58,7 @@ class View {
58
58
  function: '{ let _arr = $constructor; for(let $key in _arr){ let $value = _arr[$key];',
59
59
  end: '}}',
60
60
  arguments: {
61
+ in: null,
61
62
  var: null,
62
63
  get: null,
63
64
  key: 'key',
@@ -83,6 +84,7 @@ class View {
83
84
  },
84
85
  list: {
85
86
  arguments: {
87
+ in: null,
86
88
  var: null,
87
89
  get: null,
88
90
  key: 'key',
@@ -149,7 +151,7 @@ class View {
149
151
  if (this.#part[element]) {
150
152
  let viewPath = this.#part[element]
151
153
  if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
152
- if (fs.existsSync(`./view/${element}/${viewPath}.html`)) {
154
+ if (await this.#exists(`./view/${element}/${viewPath}.html`)) {
153
155
  const html = await this.#render(`./view/${element}/${viewPath}.html`)
154
156
  output[element] = html
155
157
 
@@ -169,7 +171,7 @@ class View {
169
171
  if (this.#part[key] && !this.#odac.Request.ajaxLoad.includes(key)) {
170
172
  let viewPath = this.#part[key]
171
173
  if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
172
- if (fs.existsSync(`./view/${key}/${viewPath}.html`)) {
174
+ if (await this.#exists(`./view/${key}/${viewPath}.html`)) {
173
175
  try {
174
176
  const partHtml = await this.#render(`./view/${key}/${viewPath}.html`)
175
177
  const titleMatch = partHtml.match(TITLE_REGEX)
@@ -217,7 +219,7 @@ class View {
217
219
  if (['all', 'skeleton'].includes(key)) continue
218
220
  if (!this.#part[key]) continue
219
221
  if (this.#part[key].includes('.')) this.#part[key] = this.#part[key].replace(/\./g, '/')
220
- if (fs.existsSync(`./view/${key}/${this.#part[key]}.html`)) {
222
+ if (await this.#exists(`./view/${key}/${this.#part[key]}.html`)) {
221
223
  result = result.replace(`{{ ${key.toUpperCase()} }}`, await this.#render(`./view/${key}/${this.#part[key]}.html`))
222
224
  }
223
225
  }
@@ -229,7 +231,7 @@ class View {
229
231
  let file = this.#part.all.split('.')
230
232
  file.splice(-1, 0, part.toLowerCase())
231
233
  file = file.join('/')
232
- if (fs.existsSync(`./view/${file}.html`)) {
234
+ if (await this.#exists(`./view/${file}.html`)) {
233
235
  result = result.replace(`{{ ${part.toUpperCase()} }}`, await this.#render(`./view/${file}.html`))
234
236
  }
235
237
  }
@@ -381,7 +383,7 @@ class View {
381
383
  }
382
384
  }
383
385
 
384
- let mtime = 0
386
+ let mtime
385
387
  let content = null
386
388
 
387
389
  try {
@@ -453,12 +455,15 @@ class View {
453
455
  let fun = func.function
454
456
 
455
457
  if (key === 'for' || key === 'list') {
456
- if (!vars.var && !vars.get) {
457
- console.error(`"var" or "get" is required for "${match}"\n in "${file}"`)
458
+ if (!vars.var && !vars.get && !vars.in) {
459
+ console.error(`"var", "get" or "in" is required for "${match}"\n in "${file}"`)
458
460
  continue
459
461
  }
460
462
  let constructor
461
- if (vars.var) {
463
+ if (vars.in) {
464
+ constructor = `await ${vars.in}`
465
+ delete vars.in
466
+ } else if (vars.var) {
462
467
  constructor = `await ${vars.var}`
463
468
  delete vars.var
464
469
  } else if (vars.get) {
@@ -485,8 +490,8 @@ class View {
485
490
  }
486
491
  }
487
492
  let cache = `${nodeCrypto.createHash('md5').update(file).digest('hex')}`
488
- if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, {recursive: true})
489
- fs.writeFileSync(
493
+ await fsPromises.mkdir(CACHE_DIR, {recursive: true})
494
+ await fsPromises.writeFile(
490
495
  `${CACHE_DIR}/${cache}`,
491
496
  `module.exports = async (Odac, get, __) => {\nlet html = '';\n${result}\nreturn html.trim()\n}`
492
497
  )
package/src/WebSocket.js CHANGED
@@ -1,7 +1,10 @@
1
1
  const nodeCrypto = require('crypto')
2
2
 
3
3
  const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
4
- const MAX_PAYLOAD_LENGTH = 10 * 1024 * 1024
4
+ const DEFAULT_MAX_PAYLOAD = 10 * 1024 * 1024
5
+ const DEFAULT_RATE_LIMIT_MAX = 50
6
+ const DEFAULT_RATE_LIMIT_WINDOW = 1000
7
+
5
8
  const OPCODE = {
6
9
  CONTINUATION: 0x0,
7
10
  TEXT: 0x1,
@@ -18,13 +21,29 @@ class WebSocketClient {
18
21
  #server
19
22
  #id
20
23
  #rooms = new Set()
24
+ #maxPayload
25
+ #rateLimitMax
26
+ #rateLimitWindow
27
+ #messageCount = 0
28
+ #rateLimitTimer
21
29
  data = {}
22
30
 
23
- constructor(socket, server, id) {
31
+ constructor(socket, server, id, options = {}) {
24
32
  this.#socket = socket
25
33
  this.#socket.pause()
26
34
  this.#server = server
27
35
  this.#id = id
36
+ this.#maxPayload = options.maxPayload || DEFAULT_MAX_PAYLOAD
37
+
38
+ this.#rateLimitMax = options.rateLimit?.max ?? DEFAULT_RATE_LIMIT_MAX
39
+ this.#rateLimitWindow = options.rateLimit?.window ?? DEFAULT_RATE_LIMIT_WINDOW
40
+
41
+ if (this.#rateLimitMax > 0) {
42
+ this.#rateLimitTimer = setInterval(() => {
43
+ this.#messageCount = 0
44
+ }, this.#rateLimitWindow)
45
+ }
46
+
28
47
  this.#setupListeners()
29
48
  }
30
49
 
@@ -93,7 +112,7 @@ class WebSocketClient {
93
112
  offset = 10
94
113
  }
95
114
 
96
- if (payloadLength > MAX_PAYLOAD_LENGTH) {
115
+ if (payloadLength > this.#maxPayload) {
97
116
  this.close(1009, 'Payload too large')
98
117
  return null
99
118
  }
@@ -125,6 +144,14 @@ class WebSocketClient {
125
144
  }
126
145
 
127
146
  #handleFrame(frame) {
147
+ if (this.#rateLimitMax > 0) {
148
+ this.#messageCount++
149
+ if (this.#messageCount > this.#rateLimitMax) {
150
+ this.close(1008, 'Rate limit exceeded')
151
+ return
152
+ }
153
+ }
154
+
128
155
  switch (frame.opcode) {
129
156
  case OPCODE.TEXT:
130
157
  this.#handleMessage(frame.payload.toString('utf8'))
@@ -158,6 +185,8 @@ class WebSocketClient {
158
185
  if (this.#closed) return
159
186
  this.#closed = true
160
187
 
188
+ if (this.#rateLimitTimer) clearInterval(this.#rateLimitTimer)
189
+
161
190
  this.#socket.removeAllListeners()
162
191
 
163
192
  for (const room of this.#rooms) {
@@ -169,8 +198,7 @@ class WebSocketClient {
169
198
  }
170
199
 
171
200
  #sendFrame(opcode, data) {
172
- if (this.#closed) return
173
-
201
+ if (this.#closed && opcode !== OPCODE.CLOSE) return
174
202
  const payload = Buffer.isBuffer(data) ? data : Buffer.from(data)
175
203
  const length = payload.length
176
204
 
@@ -240,6 +268,8 @@ class WebSocketClient {
240
268
  if (this.#closed) return
241
269
  this.#closed = true
242
270
 
271
+ if (this.#rateLimitTimer) clearInterval(this.#rateLimitTimer)
272
+
243
273
  const reasonBuffer = Buffer.from(reason)
244
274
  const payload = Buffer.alloc(2 + reasonBuffer.length)
245
275
  payload.writeUInt16BE(code, 0)
@@ -287,14 +317,14 @@ class WebSocketServer {
287
317
  #rooms = new Map()
288
318
  #routes = new Map()
289
319
 
290
- route(path, handler) {
291
- this.#routes.set(path, handler)
320
+ route(path, handler, options = {}) {
321
+ this.#routes.set(path, {handler, options})
292
322
  }
293
323
 
294
324
  getRoute(path) {
295
325
  if (this.#routes.has(path)) return this.#routes.get(path)
296
326
 
297
- for (const [pattern, handler] of this.#routes) {
327
+ for (const [pattern, config] of this.#routes) {
298
328
  if (!pattern.includes('{')) continue
299
329
  const regex = new RegExp('^' + pattern.replace(/\{[^}]+\}/g, '([^/]+)') + '$')
300
330
  const match = path.match(regex)
@@ -304,7 +334,11 @@ class WebSocketServer {
304
334
  paramNames.forEach((name, i) => {
305
335
  params[name.slice(1, -1)] = match[i + 1]
306
336
  })
307
- return {handler, params}
337
+ return {
338
+ handler: config.handler,
339
+ options: config.options,
340
+ params
341
+ }
308
342
  }
309
343
  }
310
344
  return null
@@ -320,8 +354,7 @@ class WebSocketServer {
320
354
  return
321
355
  }
322
356
 
323
- const handler = typeof routeInfo === 'function' ? routeInfo : routeInfo.handler
324
- const params = routeInfo.params || {}
357
+ const {handler, params = {}, options = {}} = routeInfo
325
358
 
326
359
  const key = req.headers['sec-websocket-key']
327
360
  if (!key) {
@@ -353,7 +386,7 @@ class WebSocketServer {
353
386
  if (head && head.length > 0) socket.unshift(head)
354
387
 
355
388
  const clientId = nodeCrypto.randomUUID()
356
- const client = new WebSocketClient(socket, this, clientId)
389
+ const client = new WebSocketClient(socket, this, clientId, options)
357
390
  this.#clients.set(clientId, client)
358
391
 
359
392
  if (params) {