odac 1.4.15 → 1.4.17

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 (39) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/client/odac.js +38 -1
  3. package/docs/ai/skills/backend/forms.md +54 -3
  4. package/docs/ai/skills/backend/validation.md +30 -2
  5. package/docs/backend/05-forms/01-custom-forms.md +52 -0
  6. package/docs/backend/09-validation/01-the-validator-service.md +40 -0
  7. package/docs/backend/13-utilities/02-ipc.md +26 -1
  8. package/docs/frontend/03-forms/01-form-handling.md +30 -13
  9. package/package.json +2 -1
  10. package/src/Auth.js +252 -24
  11. package/src/Config.js +5 -1
  12. package/src/Ipc.js +87 -14
  13. package/src/Odac.js +23 -4
  14. package/src/Request.js +235 -34
  15. package/src/Route/Internal.js +32 -3
  16. package/src/Validator.js +143 -2
  17. package/src/View/Form.js +56 -0
  18. package/src/View/Image.js +15 -6
  19. package/src/View.js +2 -2
  20. package/src/WebSocket.js +20 -1
  21. package/test/Auth/check.test.js +219 -18
  22. package/test/Auth/verifyMagicLink.test.js +8 -1
  23. package/test/Ipc/subscribe.test.js +154 -0
  24. package/test/Ipc/subscribeRedis.test.js +141 -0
  25. package/test/Odac/cache.test.js +5 -2
  26. package/test/Odac/image.test.js +8 -5
  27. package/test/Odac/instance.test.js +5 -2
  28. package/test/Request/cache.test.js +1 -0
  29. package/test/Request/multipart.test.js +164 -0
  30. package/test/Validator/file.test.js +172 -0
  31. package/test/View/Form/generateFieldHtml.test.js +56 -0
  32. package/test/View/Image/serve.test.js +9 -4
  33. package/test/View/Image/url.test.js +10 -4
  34. package/test/View/addNavigateAttribute.test.js +10 -1
  35. package/test/View/print.test.js +10 -1
  36. package/test/WebSocket/Client/fragmentation.test.js +24 -5
  37. package/test/WebSocket/Client/limits.test.js +21 -2
  38. package/test/WebSocket/Client/readyState.test.js +29 -10
  39. package/test/WebSocket/Client/send.test.js +148 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  ### 🛠️ Fixes & Improvements
2
2
 
3
+ - repair broken redis pub/sub delivery and align unsubscribe() across drivers
4
+ - send() no longer JSON-serializes Buffer/TypedArray into TEXT frames
5
+ - **test:** close WebSocketClient instances left open after each test
6
+
7
+
8
+
9
+ ---
10
+
11
+ Powered by [⚡ ODAC](https://odac.run)
12
+
13
+ ### ✨ What's New
14
+
15
+ - add multipart file upload support with validation
16
+
17
+ ### 🛠️ Fixes & Improvements
18
+
19
+ - **auth:** enhance token validation and anomaly detection mechanisms
20
+ - patch file descriptor leak, polling anti-pattern, mimetype guard and duplicate move check
21
+ - **test:** improve temp file cleanup logic in multipart tests for reliability
22
+ - update cache directory handling to use global.__dir for consistency
23
+
24
+
25
+
26
+ ---
27
+
28
+ Powered by [⚡ ODAC](https://odac.run)
29
+
30
+ ### 🛠️ Fixes & Improvements
31
+
3
32
  - **docs:** update references from 'odac' to 'Odac' for consistency
4
33
  - **form:** improve form submission handler ownership to prevent duplicate submissions
5
34
  - **form:** prevent multiple submit handlers on re-bound forms
package/client/odac.js CHANGED
@@ -479,6 +479,43 @@ if (typeof window !== 'undefined') {
479
479
  errorSpan.textContent = ''
480
480
  }
481
481
 
482
+ if (input.type === 'file') {
483
+ const files = input.files
484
+ if (input.hasAttribute('required') && files.length === 0) {
485
+ showError(input, 'required')
486
+ break
487
+ }
488
+ const maxFiles = parseInt(input.getAttribute('data-maxfiles'))
489
+ if (maxFiles && files.length > maxFiles) {
490
+ showError(input, 'maxfiles')
491
+ break
492
+ }
493
+ const maxSize = parseInt(input.getAttribute('data-maxsize'))
494
+ if (maxSize && [...files].some(f => f.size > maxSize)) {
495
+ showError(input, 'maxsize')
496
+ break
497
+ }
498
+ const minSize = parseInt(input.getAttribute('data-minsize'))
499
+ if (minSize && [...files].some(f => f.size < minSize)) {
500
+ showError(input, 'minsize')
501
+ break
502
+ }
503
+ const accept = input.getAttribute('accept')
504
+ if (accept && files.length) {
505
+ const tokens = accept.split(',').map(t => t.trim().toLowerCase())
506
+ const ok = [...files].every(f =>
507
+ tokens.some(t =>
508
+ t.startsWith('.') ? f.name.toLowerCase().endsWith(t) : t.endsWith('/*') ? f.type.startsWith(t.slice(0, -1)) : f.type === t
509
+ )
510
+ )
511
+ if (!ok) {
512
+ showError(input, 'accept')
513
+ break
514
+ }
515
+ }
516
+ continue
517
+ }
518
+
482
519
  if (input.hasAttribute('required')) {
483
520
  const isEmpty = input.type === 'checkbox' || input.type === 'radio' ? !input.checked : !input.value.trim()
484
521
  if (isEmpty) {
@@ -523,7 +560,7 @@ if (typeof window !== 'undefined') {
523
560
  let datastring, cache, contentType, processData
524
561
  if (formElement.querySelector('input[type=file]')) {
525
562
  datastring = new FormData(formElement)
526
- datastring.append('token', this.token())
563
+ datastring.append('_token', this.token())
527
564
  cache = false
528
565
  contentType = false
529
566
  processData = false
@@ -101,12 +101,63 @@ module.exports = class Contact {
101
101
  ```
102
102
 
103
103
  ## Field-Level Variants
104
- - **Input types**: `text`, `email`, `password`, `number`, `url`, `textarea`, `checkbox`.
105
- - **Validation mapping**: `required|minlen|maxlen|min|max|alpha|alphanumeric|numeric|email|url|accepted`.
106
- - **Pass-through attrs**: Unrecognized `<odac:input ...>` attributes are preserved into generated HTML input/textarea.
104
+ - **Input types**: `text`, `email`, `password`, `number`, `url`, `textarea`, `checkbox`, `file`.
105
+ - **Validation mapping**: `required|minlen|maxlen|min|max|alpha|alphanumeric|numeric|email|url|accepted|maxsize|minsize|mimetype|ext|maxfiles`.
106
+ - **Pass-through attrs**: Unrecognized `<odac:input ...>` attributes are preserved into generated HTML input/textarea/input[type=file].
107
107
  - **Skip persistence**: Use `skip` on `<odac:input>` to validate a field but exclude it from final payload.
108
108
  - **Unique shorthand**: `unique` attribute on `<odac:input>` enables auth-register uniqueness list.
109
109
 
110
+ ## File Upload Fields
111
+
112
+ File inputs work end-to-end with zero-config validation, like all other input types:
113
+
114
+ ```html
115
+ <odac:form action="Profile.saveAvatar" enctype="multipart/form-data">
116
+ <odac:input type="file" name="avatar" label="Avatar">
117
+ <odac:validate rule="required|maxsize:2MB|mimetype:image/png,image/jpeg" message="PNG/JPEG, max 2MB"/>
118
+ </odac:input>
119
+ <odac:submit text="Save"/>
120
+ </odac:form>
121
+ ```
122
+
123
+ ### File Rules
124
+ - **`required`**: file must be present (at least one file for `multiple`).
125
+ - **`maxsize:2MB`** / **`minsize:10KB`**: size constraints (supports B, KB, MB, GB suffixes); enforced server-side via busboy limits + client-side byte checks.
126
+ - **`mimetype:image/png,image/jpeg`** (alias **`accept:`**): comma-separated MIME list; wildcards like `image/*` supported. Validated via claimed MIME, extension map, and (for images) magic-byte sniffing.
127
+ - **`ext:jpg,png`**: file extension whitelist (case-insensitive).
128
+ - **`maxfiles:3`**: max file count for `multiple` inputs.
129
+
130
+ ### Controller Access
131
+ In action forms, access uploaded files via `form.file(name)`:
132
+ ```javascript
133
+ async saveAvatar(form) {
134
+ const avatar = await form.file('avatar')
135
+ if (!avatar) return form.error('avatar', 'No file uploaded')
136
+
137
+ const dest = await avatar.move(`${__dir}/storage/avatars/${user.id}.${avatar.ext}`)
138
+ user.avatar = dest
139
+ return form.success('Avatar saved')
140
+ }
141
+ ```
142
+
143
+ ### Table Forms
144
+ For `table="..."` forms, file fields are auto-stored relative to `uploadDir` (configured in `odac.json` or `$ODAC_UPLOAD_DIR`) and the path string is persisted in the column.
145
+
146
+ ### File Object Shape
147
+ ```javascript
148
+ {
149
+ field: 'avatar',
150
+ name: 'photo.jpg', // original filename
151
+ ext: 'jpg', // extension (lowercase)
152
+ mimetype: 'image/jpeg', // client-claimed
153
+ size: 123456, // bytes
154
+ path: '/tmp/odac-...', // temp path (null if truncated)
155
+ truncated: false, // true if size > maxFileSize
156
+ stored: false, // true after move()
157
+ async move(dest) {...} // move to permanent location
158
+ }
159
+ ```
160
+
110
161
  ## Patterns
111
162
  ```javascript
112
163
  // Custom form action in class/Contact.js
@@ -37,7 +37,7 @@ module.exports = async Odac => {
37
37
  - `post(key)`: Validate POST payload field.
38
38
  - `get(key)`: Validate querystring field.
39
39
  - `var(name, value)`: Validate computed/custom value.
40
- - `file(name)`: Validate uploaded file object.
40
+ - `file(name)`: Validate uploaded file(s) object.
41
41
  - `check(rules | boolean)`: Apply pipe rules or direct boolean validation.
42
42
  - `message(text)`: Set message for the latest check on current field.
43
43
  - `error()`: Runs validation and returns `true` if any error exists.
@@ -65,6 +65,14 @@ module.exports = async Odac => {
65
65
  - `regex:pattern`
66
66
  - `mindate:YYYY-MM-DD`, `maxdate:YYYY-MM-DD`
67
67
 
68
+ ### File upload rules (for `file()` fields)
69
+ - `required`: file must be present.
70
+ - `maxsize:2MB` / `minsize:10KB`: size limits (B, KB, MB, GB suffixes).
71
+ - `mimetype:image/png,image/jpeg`: MIME type whitelist; wildcards like `image/*` supported. Validated via claimed MIME, extension map, and (for images) magic-byte sniffing to detect spoofing.
72
+ - `accept:...`: alias for `mimetype:`.
73
+ - `ext:jpg,png`: file extension whitelist (case-insensitive, comma-separated).
74
+ - `maxfiles:N`: max file count for `multiple` file inputs.
75
+
68
76
  ### Auth/security rules
69
77
  - `usercheck`: Must be authenticated.
70
78
  - `user:field`: Input must match authenticated user field.
@@ -125,7 +133,27 @@ module.exports = async Odac => {
125
133
  }
126
134
  ```
127
135
 
128
- ### 4) Brute-force on auth endpoint
136
+ ### 4) File upload validation
137
+ ```javascript
138
+ module.exports = async Odac => {
139
+ const validator = Odac.validator()
140
+
141
+ validator.file('avatar').check('required|maxsize:2MB|mimetype:image/png,image/jpeg')
142
+ .message('Avatar required (PNG/JPEG, max 2MB)')
143
+ validator.file('documents').check('maxfiles:5|ext:pdf,docx')
144
+ .message('Max 5 PDFs/Word docs allowed')
145
+
146
+ if (await validator.error()) return await validator.result('Upload validation failed')
147
+
148
+ // Access files in controller
149
+ const avatar = await Odac.file('avatar')
150
+ await avatar.move(`${__dir}/storage/avatars/${user.id}.${avatar.ext}`)
151
+
152
+ return await validator.success('Files uploaded')
153
+ }
154
+ ```
155
+
156
+ ### 5) Brute-force on auth endpoint
129
157
  ```javascript
130
158
  module.exports = async Odac => {
131
159
  const validator = Odac.validator()
@@ -105,6 +105,16 @@ Supports all standard HTML input types:
105
105
  <odac:validate rule="minlen:2" message="Name must be at least 2 characters"/>
106
106
  <odac:validate rule="maxlen:50" message="Name is too long"/>
107
107
  </odac:input>
108
+
109
+ <!-- File upload -->
110
+ <odac:input name="avatar" type="file" label="Profile Picture">
111
+ <odac:validate rule="required|maxsize:2MB|mimetype:image/png,image/jpeg" message="PNG or JPEG, max 2MB"/>
112
+ </odac:input>
113
+
114
+ <!-- Multiple file upload -->
115
+ <odac:input name="documents" type="file" label="Documents" multiple>
116
+ <odac:validate rule="maxfiles:5|ext:pdf,docx" message="Max 5 PDFs or Word docs"/>
117
+ </odac:input>
108
118
  ```
109
119
 
110
120
  ### Field Attributes
@@ -130,6 +140,7 @@ Add validation rules to fields:
130
140
 
131
141
  ### Available Rules
132
142
 
143
+ #### Text/Input Rules
133
144
  - `required` - Field is required
134
145
  - `email` - Must be valid email
135
146
  - `url` - Must be valid URL
@@ -142,6 +153,15 @@ Add validation rules to fields:
142
153
  - `alphanumeric` - Letters and numbers only
143
154
  - `accepted` - Checkbox must be checked
144
155
 
156
+ #### File Upload Rules
157
+ - `required` - File must be provided
158
+ - `maxsize:2MB` - Maximum file size (supports B, KB, MB, GB)
159
+ - `minsize:10KB` - Minimum file size
160
+ - `mimetype:image/png,image/jpeg` - Allowed MIME types (comma-separated; wildcards like `image/*` supported)
161
+ - `accept:...` - Alias for `mimetype:`
162
+ - `ext:jpg,png` - Allowed file extensions (case-insensitive)
163
+ - `maxfiles:5` - Maximum number of files for `multiple` uploads
164
+
145
165
  ### Multiple Rules
146
166
 
147
167
  Combine rules with `|`:
@@ -306,6 +326,38 @@ module.exports = class Contact {
306
326
 
307
327
  **Note:** No route definition is needed for the action! The form system handles the routing securely.
308
328
 
329
+ ## File Upload Handling
330
+
331
+ Access uploaded files in your controller via `form.file(name)`:
332
+
333
+ ```javascript
334
+ module.exports = class Profile {
335
+ constructor(Odac) {
336
+ this.Odac = Odac
337
+ }
338
+
339
+ async updateAvatar(form) {
340
+ const avatar = await form.file('avatar')
341
+ if (!avatar) {
342
+ return form.error('avatar', 'No file uploaded')
343
+ }
344
+
345
+ // Store the file permanently
346
+ const userId = this.Odac.Auth.user('id')
347
+ const dest = await avatar.move(`${__dir}/../storage/avatars/${userId}.${avatar.ext}`)
348
+
349
+ // Update user record with file path
350
+ // await this.Odac.DB.users.where('id', userId).update({avatar: dest})
351
+
352
+ return form.success('Avatar updated!', '/profile')
353
+ }
354
+ }
355
+ ```
356
+
357
+ For `table="..."` forms with file fields, files are automatically stored in the configured `uploadDir` and the file path is saved to the database column.
358
+
359
+ > **Security:** `move(dest)` writes to whatever path you pass, just like `fs` — the file object's own `.name` is already stripped to a bare filename, but if you build `dest` from raw user input, sanitize it yourself to avoid path traversal. Server-generated names (as in the example above) are always safe. Uploads are validated for size/type before your handler runs, but content sniffing only covers raster images (JPEG/PNG/GIF/WebP); other types are trusted by extension + declared MIME, and `image/*` also permits SVG (an XSS vector) — list explicit types like `mimetype:image/png,image/jpeg` when you need to exclude it.
360
+
309
361
  ## Automatic Database Insert
310
362
 
311
363
  Forms can automatically insert data into database without writing a controller:
@@ -72,6 +72,15 @@ if (await validator.error()) {
72
72
  - `regex:pattern` - Must match regex pattern
73
73
  - `!disposable` - Block disposable/temporary email providers (List is automatically updated daily)
74
74
 
75
+ **File Upload Validation:**
76
+ - `required` - File must be uploaded
77
+ - `maxsize:2MB` - Maximum file size (B, KB, MB, GB)
78
+ - `minsize:10KB` - Minimum file size
79
+ - `mimetype:image/png,image/jpeg` - Allowed MIME types (comma-separated; wildcards like `image/*` supported)
80
+ - `accept:...` - Alias for `mimetype:`
81
+ - `ext:jpg,png` - Allowed file extensions (case-insensitive)
82
+ - `maxfiles:5` - Max files for multi-upload
83
+
75
84
  **Security:**
76
85
  - `xss` - Check for HTML tags (XSS protection)
77
86
  - `usercheck` - User must be authenticated
@@ -358,6 +367,37 @@ module.exports = async function (Odac) {
358
367
  }
359
368
  ```
360
369
 
370
+ #### Example: File Upload Validation
371
+
372
+ ```javascript
373
+ module.exports = async function (Odac) {
374
+ const validator = Odac.Validator
375
+
376
+ validator
377
+ .file('avatar')
378
+ .check('required').message('Avatar is required')
379
+ .check('maxsize:2MB').message('Avatar must not exceed 2MB')
380
+ .check('mimetype:image/png,image/jpeg').message('Avatar must be PNG or JPEG')
381
+
382
+ validator
383
+ .file('documents')
384
+ .check('maxfiles:5').message('Maximum 5 documents allowed')
385
+ .check('ext:pdf,docx').message('Only PDF and Word documents allowed')
386
+
387
+ if (await validator.error()) {
388
+ return validator.result('Upload validation failed')
389
+ }
390
+
391
+ // Access and store the files
392
+ const avatar = await Odac.file('avatar')
393
+ const userId = Odac.Auth.user('id')
394
+
395
+ await avatar.move(`${__dir}/../storage/avatars/${userId}.${avatar.ext}`)
396
+
397
+ return validator.success('Files uploaded successfully')
398
+ }
399
+ ```
400
+
361
401
  #### Frontend Integration
362
402
 
363
403
  When using `Odac.form()` on the frontend, validation errors are automatically displayed:
@@ -72,6 +72,29 @@ await Odac.Ipc.publish('chat:global', { user: 'Emre', text: 'Hello World' });
72
72
  > [!TIP]
73
73
  > When using `memory` driver, the subscription listener is registered in the current worker. When a message is published, it goes to the Main process and is then broadcasted to all subscribed workers.
74
74
 
75
+ #### Unsubscribing
76
+
77
+ A channel can carry several independent subscribers, so a subscription is identified by its callback — not by the channel name alone. `subscribe()` returns a handle that removes exactly the subscription it created, which saves you from holding on to the callback yourself.
78
+
79
+ ```javascript
80
+ const stats = await Odac.Ipc.subscribe('server:1:stream', onStats); // long-lived
81
+ const ack = await Odac.Ipc.subscribe('server:1:stream', onAck); // short-lived
82
+
83
+ await ack.unsubscribe(); // only onAck stops; onStats keeps receiving
84
+ ```
85
+
86
+ `unsubscribe(channel, callback)` does the same thing when you already hold the callback. To drop every subscriber on a channel at once, ask for it explicitly:
87
+
88
+ ```javascript
89
+ await Odac.Ipc.unsubscribeAll('server:1:stream');
90
+ ```
91
+
92
+ > [!WARNING]
93
+ > `unsubscribe(channel)` without a callback cannot tell which subscriber is leaving, so it does nothing and logs a warning. It will throw in the next major version — pass the callback or the handle, or use `unsubscribeAll()`.
94
+
95
+ > [!TIP]
96
+ > Subscriptions created through the request-scoped `Odac.Ipc` are released automatically when the request ends, so you only need to unsubscribe by hand for subscriptions that should end earlier than the request.
97
+
75
98
  ### Atomic Counters
76
99
 
77
100
  Use `incrBy` / `decrBy` to atomically increment or decrement a numeric key. These are safe to call from multiple workers simultaneously — no read-then-write race conditions.
@@ -186,5 +209,7 @@ try {
186
209
  | `srem(key, ...members)` | Remove members from a set |
187
210
  | `lock(key, ttl)` | Acquire a mutex lock |
188
211
  | `unlock(key)` | Release a mutex lock |
189
- | `subscribe(channel, handler)` | Subscribe to a Pub/Sub channel |
212
+ | `subscribe(channel, handler)` | Subscribe to a Pub/Sub channel; returns a handle with `unsubscribe()` |
213
+ | `unsubscribe(channel, handler)` | Remove a single subscription, leaving other subscribers intact |
214
+ | `unsubscribeAll(channel)` | Remove every subscription on a channel |
190
215
  | `publish(channel, message)` | Publish a message to a channel |
@@ -174,43 +174,60 @@ Odac.form('#my-form', function(data) {
174
174
 
175
175
  ## File Uploads
176
176
 
177
- ### Basic File Upload
177
+ ### Basic File Upload with `<odac:form>`
178
+
179
+ File upload in Odac forms is seamless — just add a `type="file"` input and validation rules:
178
180
 
179
181
  ```html
180
- <form id="upload-form" action="/api/upload" method="POST">
181
- <input name="file" type="file" required>
182
- <button type="submit">Upload</button>
183
- </form>
182
+ <odac:form action="Profile.saveAvatar" id="avatar-form">
183
+ <odac:input type="file" name="avatar" label="Profile Picture">
184
+ <odac:validate rule="required|maxsize:2MB|mimetype:image/png,image/jpeg" message="PNG/JPEG, max 2MB"/>
185
+ </odac:input>
186
+ <odac:submit text="Upload"/>
187
+ </odac:form>
184
188
  ```
185
189
 
186
190
  ```javascript
187
- Odac.form('#upload-form', function(data) {
191
+ Odac.form('#avatar-form', function(data) {
188
192
  if (data.result.success) {
189
- console.log('File uploaded:', data.result.filename)
193
+ alert('Avatar uploaded!')
190
194
  }
191
195
  })
192
196
  ```
193
197
 
194
- ### Upload Progress
198
+ **Features:**
199
+ - ✅ Client-side validation (size, MIME type, extension)
200
+ - ✅ Server-side validation with magic-byte sniffing for images
201
+ - ✅ Automatic multipart/form-data handling
202
+ - ✅ File size progress tracking
203
+
204
+ ### Upload Progress Tracking
195
205
 
196
206
  ```javascript
197
207
  Odac.form({
198
- form: '#upload-form',
208
+ form: '#avatar-form',
199
209
  loading: function(percent) {
200
210
  document.querySelector('#progress').style.width = percent + '%'
201
- document.querySelector('#progress-text').textContent = percent + '%'
202
211
  }
203
212
  }, function(data) {
204
- console.log('Upload complete!')
213
+ if (data.result.success) {
214
+ console.log('Upload complete!')
215
+ }
205
216
  })
206
217
  ```
207
218
 
208
- ### Multiple Files
219
+ ### Multiple File Upload
220
+
221
+ For multiple file selection, add the `multiple` attribute and use `maxfiles` validation:
209
222
 
210
223
  ```html
211
- <input name="files[]" type="file" multiple>
224
+ <odac:input type="file" name="documents" label="Documents" multiple>
225
+ <odac:validate rule="maxfiles:5|ext:pdf,docx" message="Max 5 PDFs/Word docs"/>
226
+ </odac:input>
212
227
  ```
213
228
 
229
+ **Note:** File inputs are never repopulated on validation error (for security). Users must re-select files after an error.
230
+
214
231
  ## Advanced Features
215
232
 
216
233
  ### Disable Messages
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "mail@emre.red",
8
8
  "url": "https://emre.red"
9
9
  },
10
- "version": "1.4.15",
10
+ "version": "1.4.17",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@tailwindcss/cli": "^4.1.18",
25
+ "busboy": "^1.6.0",
25
26
  "esbuild": "^0.25.12",
26
27
  "knex": "^3.1.0",
27
28
  "lmdb": "^3.4.4",