├── .editorconfig ├── .github └── workflows │ ├── checks.yml │ ├── labels.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── bin └── test.ts ├── eslint.config.js ├── index.ts ├── lodash └── lodash.types.d.ts ├── package.json ├── src ├── assert.ts ├── base64.ts ├── compose.ts ├── define_static_property.ts ├── exception.ts ├── exceptions │ └── main.ts ├── flatten.ts ├── fs_import_all.ts ├── fs_read_all.ts ├── import_default.ts ├── is_script_file.ts ├── json │ ├── main.ts │ ├── safe_parse.ts │ └── safe_stringify.ts ├── message_builder.ts ├── natural_sort.ts ├── object_builder.ts ├── safe_equal.ts ├── secret.ts ├── slash.ts ├── string │ ├── main.ts │ └── string_builder.ts └── types.ts ├── test_helpers └── index.ts ├── tests ├── assert.spec.ts ├── base64.spec.ts ├── compose.spec.ts ├── define_static_property.spec.ts ├── flatten.spec.ts ├── fs_import_all.spec.ts ├── fs_read_all.spec.ts ├── import_default.spec.ts ├── lodash.spec.ts ├── message_builder.spec.ts ├── safe_equal.spec.ts ├── safe_parse.spec.ts ├── safe_stringify.spec.ts ├── secret.spec.ts └── slash.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_call 6 | 7 | jobs: 8 | test: 9 | uses: poppinss/.github/.github/workflows/test.yml@main 10 | 11 | lint: 12 | uses: poppinss/.github/.github/workflows/lint.yml@main 13 | 14 | typecheck: 15 | uses: poppinss/.github/.github/workflows/typecheck.yml@main 16 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | on: 3 | workflow_dispatch: 4 | permissions: 5 | issues: write 6 | jobs: 7 | labels: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: EndBug/label-sync@v2 12 | with: 13 | config-file: 'https://raw.githubusercontent.com/thetutlage/static/main/labels.yml' 14 | delete-other-labels: true 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: workflow_dispatch 3 | permissions: 4 | contents: write 5 | id-token: write 6 | jobs: 7 | checks: 8 | uses: ./.github/workflows/checks.yml 9 | release: 10 | needs: checks 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | - name: git config 20 | run: | 21 | git config user.name "${GITHUB_ACTOR}" 22 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 23 | - name: Init npm config 24 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 25 | env: 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | - run: npm install 28 | - run: npm run release -- --ci 29 | env: 30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 0 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: 'This issue has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still need help on this issue' 13 | stale-pr-message: 'This pull request has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still intend to submit this pull request' 14 | close-issue-message: 'This issue has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still need help on this issue' 15 | close-pr-message: 'This pull request has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still intend to submit this pull request' 16 | days-before-stale: 21 17 | days-before-close: 5 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .DS_STORE 4 | .nyc_output 5 | .idea 6 | .vscode/ 7 | *.sublime-project 8 | *.sublime-workspace 9 | *.log 10 | build 11 | dist 12 | yarn.lock 13 | shrinkwrap.yaml 14 | test/__app 15 | package-lock.json 16 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | *.html 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2021 Harminder Virk, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @poppinss/utils 2 | 3 | > A toolkit of utilities used across all the AdonisJS, Edge, and Japa packages 4 | 5 | [![gh-workflow-image]][gh-workflow-url] [![typescript-image]][typescript-url] [![npm-image]][npm-url] [![license-image]][license-url] 6 | 7 | ## Why this package exists? 8 | 9 | Many of my open source projects (including AdonisJS) use many single-purpose utility packages from npm. Over the years, I have faced the following challenges when using these packages. 10 | 11 | - It takes a lot of time to find a perfect package for the use case. The package should be well maintained, have good test coverage, and not accumulate debt by supporting some old versions of Node.js. 12 | - Some packages are great, but they end up pulling a lot of unnecessary dependencies like [(requiring TypeScript as a prod dependency)](https://github.com/blakeembrey/change-case/issues/281) 13 | - Sometimes I end up using different packages for the same utility (because, I cannot remember what I used last time in that other package). So I want to spend time once choosing the one I need and then bundle it inside `@poppinss/utils`. 14 | - Some authors introduce breaking changes too often (not a criticism). Therefore, I prefer wrapping their packages with my external API only to absorb breaking changes in one place. 15 | - Rest are some handwritten utilities to fit my needs 16 | 17 | > **Note**: If you are creating an AdonisJS package, I highly recommend using this package since it is already part of the user's project dependencies. 18 | 19 | > **Warning**: This package is not for general use (outside the AdonisJS ecosystem). I will not add new helpers or remove any to cater to a broader audience. 20 | 21 | ## Other packages to use 22 | 23 | A note to self and others to consider the following packages. 24 | 25 | | Package | Description | 26 | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------ | 27 | | [he](https://www.npmjs.com/package/he) | For escaping HTML entities and encoding unicode symbols. Has zero dependencies | 28 | | [@sindresorhus/is](https://www.npmjs.com/package/@sindresorhus/is) | For advanced type checking. Has zero dependencies | 29 | 30 | ## Package size 31 | 32 | Even though I do not care much about the package size (most of work is consumed on server side), I am mindful around the utilities and ensure not end up using really big packages for smaller use-cases. 33 | 34 | Here's the last checked install size of this package. 35 | 36 | 37 | 38 | 39 | 40 | ## Installation 41 | 42 | Install the package from the npm registry as follows: 43 | 44 | ```sh 45 | npm i @poppinss/utils 46 | 47 | # Yarn lovers 48 | yarn add @poppinss/utils 49 | ``` 50 | 51 | ## Exported modules 52 | 53 | Following are the exported modules. Only the generic helpers are shipped from the main path. The rest of the helpers are grouped inside sub-modules. 54 | 55 | ```ts 56 | // string sub-module 57 | import string from '@poppinss/utils/string' 58 | 59 | // string builder 60 | import string from '@poppinss/utils/string_builder' 61 | 62 | // json sub-module 63 | import json from '@poppinss/utils/json' 64 | 65 | // lodash sub-module 66 | import lodash from '@poppinss/utils/lodash' 67 | 68 | // assert sub-module 69 | import assert from '@poppinss/utils/assert' 70 | 71 | // main module 72 | import { base64, fsReadAll } from '@poppinss/utils' 73 | 74 | // types sub-module 75 | import { ReadAllFilesOptions } from '@poppinss/utils/types' 76 | ``` 77 | 78 | ### String helpers 79 | 80 | A collection of helpers to perform operations on/related to a string value. 81 | 82 | ```ts 83 | import string from '@poppinss/utils/string' 84 | ``` 85 | 86 | #### excerpt 87 | 88 | Generate an excerpt from a string value. If the input value contains HTML tags, we will remove them from the excerpt. 89 | 90 | ```ts 91 | const html = `

AdonisJS is a Node.js framework, and hence it requires Node.js to be installed on your computer. To be precise, we need at least the latest release of Node.js v14.

` 92 | 93 | console.log(string.excerpt(html, 70)) 94 | // AdonisJS is a Node.js framework, and hence it requires Node.js to be i... 95 | ``` 96 | 97 | | Argument | Type | Description | 98 | | ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- | 99 | | `sentence` | string | The value for which to generate excerpt | 100 | | `charactersLimit` | string | The number of characters to keep | 101 | | `options.completeWords` | boolean | When set to `true`, the truncation will happen only after complete words. This option might go over the defined characters limit | 102 | | `options.suffix` | string | The value to append after the truncated string. Defaults to three dots `...` | 103 | 104 | #### truncate 105 | 106 | Truncate a string value to a certain length. The method is the same as the `excerpt` method but does not remove any HTML tags. It is a great fit when you are truncating a non-HTML string. 107 | 108 | ```ts 109 | const text = `AdonisJS is a Node.js framework, and hence it requires Node.js to be installed on your computer. To be precise, we need at least the latest release of Node.js 14.` 110 | 111 | console.log(string.truncate(text, 70)) 112 | // AdonisJS is a Node.js framework, and hence it requires Node.js to be i... 113 | ``` 114 | 115 | | Argument | Type | Description | 116 | | ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- | 117 | | `sentence` | string | The value to truncate | 118 | | `charactersLimit` | string | The number of characters to keep | 119 | | `options.completeWords` | boolean | When set to `true`, the truncation will happen only after complete words. This option might go over the defined characters limit | 120 | | `options.suffix` | string | The value to append after the truncated string. Defaults to three dots `...` | 121 | 122 | #### slug 123 | 124 | Generate slug for a string value. The method is exported directly from the [slugify](https://www.npmjs.com/package/slugify) package. 125 | 126 | Please check the package documentation for [available options](https://www.npmjs.com/package/slugify#options). 127 | 128 | ```ts 129 | console.log(string.slug('hello ♥ world')) 130 | // hello-love-world 131 | ``` 132 | 133 | You can add custom replacements for Unicode values as follows. 134 | 135 | ```ts 136 | string.slug.extend({ '☢': 'radioactive' }) 137 | 138 | console.log(string.slug('unicode ♥ is ☢')) 139 | // unicode-love-is-radioactive 140 | ``` 141 | 142 | #### interpolate 143 | 144 | Interpolate variables inside a string. The variables must be inside double curly braces. 145 | 146 | ```ts 147 | string.interpolate('hello {{ user.username }}', { user: { username: 'virk' } }) 148 | 149 | // hello virk 150 | ``` 151 | 152 | You can also replace array values by mentioning the array index. 153 | 154 | ```ts 155 | string.interpolate('hello {{ users.0 }}', { users: ['virk'] }) 156 | 157 | // hello virk 158 | ``` 159 | 160 | You can escape the curly braces by prefixing them with `\\`. 161 | 162 | ```ts 163 | string.interpolate('hello \\{{ users.0 }}', {}) 164 | 165 | // hello {{ users.0 }} 166 | ``` 167 | 168 | #### plural 169 | 170 | Convert a word to its plural form. The method is exported directly from the [pluralize](https://www.npmjs.com/package/pluralize) package. 171 | 172 | ```ts 173 | string.plural('test') 174 | // tests 175 | ``` 176 | 177 | #### singular 178 | 179 | Convert a word to its singular form. The method is exported directly from the [pluralize](https://www.npmjs.com/package/pluralize) package. 180 | 181 | ```ts 182 | string.singular('tests') 183 | // test 184 | ``` 185 | 186 | #### pluralize 187 | 188 | This method combines the `singular` and `plural` methods and uses one or the other based on the count. For example: 189 | 190 | ```ts 191 | string.pluralize('box', 1) // box 192 | string.pluralize('box', 2) // boxes 193 | string.pluralize('box', 0) // boxes 194 | 195 | string.pluralize('boxes', 1) // box 196 | string.pluralize('boxes', 2) // boxes 197 | string.pluralize('boxes', 0) // boxes 198 | ``` 199 | 200 | The `addPluralRule`, `addSingularRule`, `addIrregularRule`, and `addUncountableRule` methods exposed by the pluralize package can be called as follows. 201 | 202 | ```ts 203 | string.pluralize.addUncountableRule('paper') 204 | string.pluralize.addSingularRule(/singles$/i, 'singular') 205 | ``` 206 | 207 | #### isPlural 208 | 209 | Find if a word is already in plural form. The method is exported directly from the [pluralize](https://www.npmjs.com/package/pluralize) package. 210 | 211 | ```ts 212 | string.isPlural('tests') // true 213 | ``` 214 | 215 | #### isSingular 216 | 217 | Find if a word is already in a singular form. The method is exported directly from the [pluralize](https://www.npmjs.com/package/pluralize) package. 218 | 219 | ```ts 220 | string.isSingular('test') // true 221 | ``` 222 | 223 | #### camelCase 224 | 225 | Convert a string value to camelcase. 226 | 227 | ```ts 228 | string.camelCase('user_name') // userName 229 | ``` 230 | 231 | Following are some of the conversion examples. 232 | 233 | | Input | Output | 234 | | ---------------- | ------------- | 235 | | 'test' | 'test' | 236 | | 'test string' | 'testString' | 237 | | 'Test String' | 'testString' | 238 | | 'TestV2' | 'testV2' | 239 | | '_foo_bar_' | 'fooBar' | 240 | | 'version 1.2.10' | 'version1210' | 241 | | 'version 1.21.0' | 'version1210' | 242 | 243 | #### capitalCase 244 | 245 | Convert a string value to a capital case. 246 | 247 | ```ts 248 | string.capitalCase('helloWorld') // Hello World 249 | ``` 250 | 251 | Following are some of the conversion examples. 252 | 253 | | Input | Output | 254 | | ---------------- | ---------------- | 255 | | 'test' | 'Test' | 256 | | 'test string' | 'Test String' | 257 | | 'Test String' | 'Test String' | 258 | | 'TestV2' | 'Test V 2' | 259 | | 'version 1.2.10' | 'Version 1.2.10' | 260 | | 'version 1.21.0' | 'Version 1.21.0' | 261 | 262 | #### dashCase 263 | 264 | Convert a string value to a dash case. 265 | 266 | ```ts 267 | string.dashCase('helloWorld') // hello-world 268 | ``` 269 | 270 | Optionally, you can capitalize the first letter of each word. 271 | 272 | ```ts 273 | string.dashCase('helloWorld', { capitalize: true }) // Hello-World 274 | ``` 275 | 276 | Following are some of the conversion examples. 277 | 278 | | Input | Output | 279 | | ---------------- | -------------- | 280 | | 'test' | 'test' | 281 | | 'test string' | 'test-string' | 282 | | 'Test String' | 'test-string' | 283 | | 'Test V2' | 'test-v2' | 284 | | 'TestV2' | 'test-v-2' | 285 | | 'version 1.2.10' | 'version-1210' | 286 | | 'version 1.21.0' | 'version-1210' | 287 | 288 | #### dotCase 289 | 290 | Convert a string value to a dot case. 291 | 292 | ```ts 293 | string.dotCase('helloWorld') // hello.World 294 | ``` 295 | 296 | Optionally, you can also convert the first letter of all the words to lowercase. 297 | 298 | ```ts 299 | string.dotCase('helloWorld', { lowerCase: true }) // hello.world 300 | ``` 301 | 302 | Following are some of the conversion examples. 303 | 304 | | Input | Output | 305 | | ---------------- | -------------- | 306 | | 'test' | 'test' | 307 | | 'test string' | 'test.string' | 308 | | 'Test String' | 'Test.String' | 309 | | 'dot.case' | 'dot.case' | 310 | | 'path/case' | 'path.case' | 311 | | 'TestV2' | 'Test.V.2' | 312 | | 'version 1.2.10' | 'version.1210' | 313 | | 'version 1.21.0' | 'version.1210' | 314 | 315 | #### noCase 316 | 317 | Remove all sorts of casing from a string value. 318 | 319 | ```ts 320 | string.noCase('helloWorld') // hello world 321 | ``` 322 | 323 | Following are some of the conversion examples. 324 | 325 | | Input | Output | 326 | | ---------------------- | ---------------------- | 327 | | 'test' | 'test' | 328 | | 'TEST' | 'test' | 329 | | 'testString' | 'test string' | 330 | | 'testString123' | 'test string123' | 331 | | 'testString_1_2_3' | 'test string 1 2 3' | 332 | | 'ID123String' | 'id123 string' | 333 | | 'foo bar123' | 'foo bar123' | 334 | | 'a1bStar' | 'a1b star' | 335 | | 'CONSTANT_CASE ' | 'constant case' | 336 | | 'CONST123_FOO' | 'const123 foo' | 337 | | 'FOO_bar' | 'foo bar' | 338 | | 'XMLHttpRequest' | 'xml http request' | 339 | | 'IQueryAArgs' | 'i query a args' | 340 | | 'dot.case' | 'dot case' | 341 | | 'path/case' | 'path case' | 342 | | 'snake_case' | 'snake case' | 343 | | 'snake_case123' | 'snake case123' | 344 | | 'snake_case_123' | 'snake case 123' | 345 | | '"quotes"' | 'quotes' | 346 | | 'version 0.45.0' | 'version 0 45 0' | 347 | | 'version 0..78..9' | 'version 0 78 9' | 348 | | 'version 4_99/4' | 'version 4 99 4' | 349 | | ' test ' | 'test' | 350 | | 'something_2014_other' | 'something 2014 other' | 351 | | 'amazon s3 data' | 'amazon s3 data' | 352 | | 'foo_13_bar' | 'foo 13 bar' | 353 | 354 | #### pascalCase 355 | 356 | Convert a string value to pascal case. Great for generating JavaScript class names. 357 | 358 | ```ts 359 | string.pascalCase('user team') // UserTeam 360 | ``` 361 | 362 | Following are some of the conversion examples. 363 | 364 | | Input | Output | 365 | | ---------------- | ------------- | 366 | | 'test' | 'Test' | 367 | | 'test string' | 'TestString' | 368 | | 'Test String' | 'TestString' | 369 | | 'TestV2' | 'TestV2' | 370 | | 'version 1.2.10' | 'Version1210' | 371 | | 'version 1.21.0' | 'Version1210' | 372 | 373 | #### sentenceCase 374 | 375 | Convert a value to a sentence. 376 | 377 | ```ts 378 | string.sentenceCase('getting-started-with-adonisjs') 379 | // Getting started with adonisjs 380 | ``` 381 | 382 | Following are some of the conversion examples. 383 | 384 | | Input | Output | 385 | | ---------------- | ---------------- | 386 | | 'test' | 'Test' | 387 | | 'test string' | 'Test string' | 388 | | 'Test String' | 'Test string' | 389 | | 'TestV2' | 'Test v2' | 390 | | 'version 1.2.10' | 'Version 1 2 10' | 391 | | 'version 1.21.0' | 'Version 1 21 0' | 392 | 393 | #### snakeCase 394 | 395 | Convert value to snake case. 396 | 397 | ```ts 398 | string.snakeCase('user team') // user_team 399 | ``` 400 | 401 | Following are some of the conversion examples. 402 | 403 | | Input | Output | 404 | | ---------------- | -------------- | 405 | | '\_id' | 'id' | 406 | | 'test' | 'test' | 407 | | 'test string' | 'test_string' | 408 | | 'Test String' | 'test_string' | 409 | | 'Test V2' | 'test_v2' | 410 | | 'TestV2' | 'test_v_2' | 411 | | 'version 1.2.10' | 'version_1210' | 412 | | 'version 1.21.0' | 'version_1210' | 413 | 414 | #### titleCase 415 | 416 | Convert a string value to title case. 417 | 418 | ```ts 419 | string.titleCase('small word ends on') 420 | // Small Word Ends On 421 | ``` 422 | 423 | Following are some of the conversion examples. 424 | 425 | | Input | Output | 426 | | ---------------------------------- | ---------------------------------- | 427 | | 'one. two.' | 'One. Two.' | 428 | | 'a small word starts' | 'A Small Word Starts' | 429 | | 'small word ends on' | 'Small Word Ends On' | 430 | | 'we keep NASA capitalized' | 'We Keep NASA Capitalized' | 431 | | 'pass camelCase through' | 'Pass camelCase Through' | 432 | | 'follow step-by-step instructions' | 'Follow Step-by-Step Instructions' | 433 | | 'this vs. that' | 'This vs. That' | 434 | | 'this vs that' | 'This vs That' | 435 | | 'newcastle upon tyne' | 'Newcastle upon Tyne' | 436 | | 'newcastle \*upon\* tyne' | 'Newcastle \*upon\* Tyne' | 437 | 438 | #### random 439 | 440 | Generate a cryptographically secure random string of a given length. The output value is URL safe base64 encoded string. 441 | 442 | ```ts 443 | string.random(32) 444 | // 8mejfWWbXbry8Rh7u8MW3o-6dxd80Thk 445 | ``` 446 | 447 | #### toSentence 448 | 449 | Convert an array of words to a comma-separated sentence. 450 | 451 | ```ts 452 | string.toSentence(['routes', 'controllers', 'middleware']) 453 | // routes, controllers, and middleware 454 | ``` 455 | 456 | You can replace the `and` with an `or` by specifying the `options.lastSeparator` property. 457 | 458 | ```ts 459 | string.toSentence(['routes', 'controllers', 'middleware'], { 460 | lastSeparator: ', or ', 461 | }) 462 | ``` 463 | 464 | In the following example, the two words are combined using the `and` separator, not the comma (usually advocated in English). However, you can use a custom separator for a pair of words. 465 | 466 | ```ts 467 | string.toSentence(['routes', 'controllers']) 468 | // routes and controllers 469 | 470 | string.toSentence(['routes', 'controllers'], { 471 | pairSeparator: ', and ', 472 | }) 473 | // routes, and controllers 474 | ``` 475 | 476 | #### condenseWhitespace 477 | 478 | Remove multiple whitespaces from a string to a single whitespace. 479 | 480 | ```ts 481 | string.condenseWhitespace('hello world') 482 | // hello world 483 | 484 | string.condenseWhitespace(' hello world ') 485 | // hello world 486 | ``` 487 | 488 | #### ordinal 489 | 490 | Get the ordinal letter for a given number. 491 | 492 | ```ts 493 | string.ordinal(1) // 1st 494 | string.ordinal(2) // '2nd' 495 | string.ordinal(3) // '3rd' 496 | string.ordinal(4) // '4th' 497 | 498 | string.ordinal(23) // '23rd' 499 | string.ordinal(24) // '24th' 500 | ``` 501 | 502 | #### seconds.(parse/format) 503 | 504 | Parse a string-based time expression to seconds. 505 | 506 | ```ts 507 | string.seconds.parse('10h') // 36000 508 | string.seconds.parse('1 day') // 86400 509 | ``` 510 | 511 | Passing a numeric value to the `parse` method is returned as it is, assuming the value is already in seconds. 512 | 513 | ```ts 514 | string.seconds.parse(180) // 180 515 | ``` 516 | 517 | You can format seconds to a pretty string using the `format` method. 518 | 519 | ```ts 520 | string.seconds.format(36000) // 10h 521 | string.seconds.format(36000, true) // 10 hours 522 | ``` 523 | 524 | #### milliseconds.(parse/format) 525 | 526 | Parse a string-based time expression to milliseconds. 527 | 528 | ```ts 529 | string.milliseconds.parse('1 h') // 3.6e6 530 | string.milliseconds.parse('1 day') // 8.64e7 531 | ``` 532 | 533 | Passing a numeric value to the `parse` method is returned as it is, assuming the value is already in milliseconds. 534 | 535 | ```ts 536 | string.milliseconds.parse(180) // 180 537 | ``` 538 | 539 | Using the `format` method, you can format milliseconds to a pretty string. 540 | 541 | ```ts 542 | string.seconds.format(3.6e6) // 1h 543 | string.seconds.format(3.6e6, true) // 1 hour 544 | ``` 545 | 546 | #### bytes.(parse/format) 547 | 548 | Parse a string-based unit expression to bytes. 549 | 550 | ```ts 551 | string.bytes.parse('1KB') // 1024 552 | string.bytes.parse('1MB') // 1048576 553 | ``` 554 | 555 | Passing a numeric value to the `parse` method is returned as it is, assuming the value is already in bytes. 556 | 557 | ```ts 558 | string.bytes.parse(1024) // 1024 559 | ``` 560 | 561 | Using the `format` method, you can format bytes to a pretty string. The method is exported directly from the [bytes](https://www.npmjs.com/package/bytes) package. Please reference the package README for available options. 562 | 563 | ```ts 564 | string.bytes.format(1048576) // 1MB 565 | string.bytes.format(1024 * 1024 * 1000) // 1000MB 566 | string.bytes.format(1024 * 1024 * 1000, { thousandsSeparator: ',' }) // 1,000MB 567 | ``` 568 | 569 | ### String builder 570 | 571 | The string builder offers a fluent API for applying a set of transforms on a string value. You can create an instance of the string builder as follows. 572 | 573 | ```ts 574 | import StringBuilder from '@poppinss/utils/string_builder' 575 | const builder = new StringBuilder('hello world') 576 | 577 | const value = builder.snakeCase().suffix('_controller').toString() 578 | assert(value === 'hello_world_controller') 579 | ``` 580 | 581 | ### JSON helpers 582 | 583 | Following are the helpers we use to `stringify` and `parse` JSON. 584 | 585 | #### safeParse 586 | 587 | The native implementation of `JSON.parse` opens up the possibility for [prototype poisoning](https://medium.com/intrinsic-blog/javascript-prototype-poisoning-vulnerabilities-in-the-wild-7bc15347c96). The `safeParse` method removes the `__proto__` and the `constructor.prototype` properties from the JSON string at the time of parsing it. 588 | 589 | The method is a wrapper over [secure-json-parse](https://github.com/fastify/secure-json-parse) package. 590 | 591 | #### safeStringify 592 | 593 | The native implementation of `JSON.stringify` cannot handle circular references or language-specific data types like `BigInt`. 594 | 595 | Therefore, we use the [safe-stable-stringify](https://github.com/BridgeAR/safe-stable-stringify) package under the hood to overcome the limitations of native implementation. 596 | 597 | ```ts 598 | import { safeStringify } from '@poppinss/utils/json' 599 | 600 | const value = { 601 | b: 2, 602 | c: BigInt(10), 603 | } 604 | 605 | // Circular reference 606 | value.a = value 607 | 608 | safeStringify(value) 609 | // '{"b":2,"c":10}' 610 | ``` 611 | 612 | - The circular references are removed from the final JSON string. 613 | - The BigInt values are converted to a string. 614 | 615 | The `safeStringify` API is the same as the `JSON.stringify` method. 616 | 617 | - You can pass a replacer function as the second parameter. 618 | - And number of spaces as the third parameter. 619 | 620 | ### Lodash helpers 621 | 622 | Lodash is quite a big library, and we do not use all its helper methods. Therefore we create a custom build using the lodash CLI and bundle only the once we need. 623 | 624 | > **Why not use something else**: All other helpers I have used are not as accurate or well implemented as lodash. 625 | 626 | - pick 627 | - omit 628 | - has 629 | - get 630 | - set 631 | - unset 632 | - mergeWith 633 | - merge 634 | - size 635 | - clone 636 | - cloneDeep 637 | - toPath 638 | 639 | You can use the methods as follows. 640 | 641 | ```ts 642 | import lodash from '@poppinss/utils/lodash' 643 | 644 | lodash.pick(collection, keys) 645 | ``` 646 | 647 | ### Assertion helpers 648 | 649 | The following assertion methods offers type-safe approach for writing conditionals and throwing error when the variable has unexpected values. 650 | 651 | #### assertExists(message?: string) 652 | 653 | Throws [AssertionError](https://nodejs.org/api/assert.html#new-assertassertionerroroptions) when the value is `false`, `null`, or `undefined`. 654 | 655 | ```ts 656 | import { assertExists } from '@poppinss/utils/assert' 657 | 658 | const value = false as string | false 659 | assertExists(value) 660 | 661 | // value is string 662 | ``` 663 | 664 | #### assertNotNull(value: unknown, message?: string) 665 | 666 | Throws [AssertionError](https://nodejs.org/api/assert.html#new-assertassertionerroroptions) when the value is `null`. 667 | 668 | ```ts 669 | import { assertNotNull } from '@poppinss/utils/assert' 670 | 671 | const value = null as string | null 672 | assertNotNull(value) 673 | 674 | // value is string 675 | ``` 676 | 677 | #### assertIsDefined(value: unknown, message?: string) 678 | 679 | Throws [AssertionError](https://nodejs.org/api/assert.html#new-assertassertionerroroptions) when the value is `undefined`. 680 | 681 | ```ts 682 | import { assertIsDefined } from '@poppinss/utils/assert' 683 | 684 | const value = undefined as string | undefined 685 | assertIsDefined(value) 686 | 687 | // value is string 688 | ``` 689 | 690 | #### assertUnreachable(value: unknown) 691 | 692 | Throws [AssertionError](https://nodejs.org/api/assert.html#new-assertassertionerroroptions) when the method is invoked. In other words, this method always throws an exception. 693 | 694 | ```ts 695 | import { assertUnreachable } from '@poppinss/utils/assert' 696 | assertUnreachable() 697 | ``` 698 | 699 | ### All other helpers 700 | 701 | The following helpers are exported from the package main module. 702 | 703 | ```ts 704 | import { base64, compose } from '@poppinss/utils' 705 | ``` 706 | 707 | #### base64 708 | 709 | Utility methods to base64 encode and decode values. 710 | 711 | ```ts 712 | import { base64 } from '@poppinss/utils' 713 | 714 | base64.encode('hello world') 715 | // aGVsbG8gd29ybGQ= 716 | ``` 717 | 718 | Similar to the `encode` method, you can use the `urlEncode` to generate a base64 string safe to pass in a URL. 719 | 720 | The `urlEncode` method performs the following replacements. 721 | 722 | - Replace `+` with `-`. 723 | - Replace `/` with `_`. 724 | - And remove the `=` sign from the end of the string. 725 | 726 | ```ts 727 | base64.urlEncode('hello world') 728 | // aGVsbG8gd29ybGQ 729 | ``` 730 | 731 | You can use the `decode` and the `urlDecode` methods to decode a previously encoded base64 string. 732 | 733 | ```ts 734 | base64.decode(base64.encode('hello world')) 735 | // hello world 736 | 737 | base64.urlDecode(base64.urlEncode('hello world')) 738 | // hello world 739 | ``` 740 | 741 | The `decode` and the `urlDecode` methods return `null` when the input value is an invalid base64 string. You can turn on the `strict` mode to raise an exception instead. 742 | 743 | ```ts 744 | base64.decode('hello world') // null 745 | base64.decode('hello world', 'utf-8', true) // raises exception 746 | ``` 747 | 748 | #### compose 749 | 750 | The `compose` helper allows you to use TypeScript class mixins with a cleaner API. Following is an example of mixins usage without the compose helper. 751 | 752 | ```ts 753 | class User extends UserWithAttributes(UserWithAge(UserWithPassword(UserWithEmail(BaseModel)))) {} 754 | ``` 755 | 756 | Following is an example with the `compose` helper. 757 | 758 | - There is no nesting. 759 | - The order of mixins is from left to right. Whereas earlier, it was inside out. 760 | 761 | ```ts 762 | import { compose } from '@poppinss/utils' 763 | 764 | class User extends compose( 765 | BaseModel, 766 | UserWithEmail, 767 | UserWithPassword, 768 | UserWithAge, 769 | UserWithAttributes 770 | ) {} 771 | ``` 772 | 773 | #### defineStaticProperty 774 | 775 | The `defineStaticProperty` method allows you to define static properties on a class with different reference strategies. 776 | 777 | If you use class inheritance alongside static properties, then either, you will share properties by reference, or you will define them directly on the parent class. 778 | 779 | In the following example, we are not inherting `columns` from the `AppModel`. Instead, we define a new set of columns on the `UserModel`. 780 | 781 | ```ts 782 | class AppModel { 783 | static columns = ['id'] 784 | } 785 | 786 | class UserModel extends AppModel { 787 | static columns = ['username'] 788 | } 789 | ``` 790 | 791 | In the following example, we are inherting `columns` from the `AppModel`. However, the mutations (array.push) from the `UserModel` will reflect on the `AppModel` as well. 792 | 793 | ```ts 794 | class AppModel { 795 | static columns = ['id'] 796 | } 797 | 798 | class UserModel extends AppModel {} 799 | UserModel.columns.push('username') 800 | ``` 801 | 802 | The ideal behavior is to deep clone the `columns` array and then push new values to it. 803 | 804 | ```ts 805 | import lodash from '@poppinss/utils/lodash' 806 | 807 | class AppModel { 808 | static columns = ['id'] 809 | } 810 | 811 | const inheritedColumns = lodash.cloneDeep(AppModel.columns) 812 | class UserModel extends AppModel { 813 | static columns = inheritedColumns.push('username') 814 | } 815 | ``` 816 | 817 | The `defineStaticProperty` method abstracts the logic to clone and also performs some interal checks to see if the value is already defined as an `ownProperty` or not. 818 | 819 | ```ts 820 | class UserModel extends AppModel {} 821 | 822 | defineStaticProperty(UserModel, 'columns', { 823 | strategy: 'inherit', 824 | initialValue: [], 825 | }) 826 | ``` 827 | 828 | - The `inherit` strategy clones the value from the parent class. 829 | - The `define` strategy always re-defines the property, discarding any values on the parent class. 830 | - The `strategy` value can be function to perform a custom clone operations. 831 | 832 | #### Exception 833 | 834 | A custom exception class with support for defining the error status, error code, and help description. This class aims to standardize exceptions within your projects. 835 | 836 | ```ts 837 | import { Exception } from '@poppinss/utils/exception' 838 | 839 | class ResourceNotFound extends Exception { 840 | static code = 'E_RESOURCE_NOT_FOUND' 841 | static status = 404 842 | static message = 'Unable to find resource' 843 | } 844 | 845 | throw new ResourceNotFound() 846 | ``` 847 | 848 | #### Anonymous error classes 849 | 850 | You can also create an anonymous exception class using the `createError` method. The return value is a class 851 | constructor that accepts an array of values to use for interpolation. 852 | 853 | The interpolation of error message is performed using the `util.format` message. 854 | 855 | ```ts 856 | import { createError } from '@poppinss/utils/exception' 857 | const E_RESOURCE_NOT_FOUND = createError( 858 | 'Unable to find resource with id %d', 859 | 'E_RESOURCE_NOT_FOUND' 860 | ) 861 | 862 | const id = 1 863 | throw new E_RESOURCE_NOT_FOUND([id]) 864 | ``` 865 | 866 | #### flatten 867 | 868 | Create a flat object from a nested object/array. The nested keys are combined with a dot-notation (`.`). The method is exported from the [flattie](https://www.npmjs.com/package/flattie) package. 869 | 870 | ```ts 871 | import { flatten } from '@poppinss/utils' 872 | 873 | flatten({ 874 | a: 'hi', 875 | b: { 876 | a: null, 877 | b: ['foo', '', null, 'bar'], 878 | d: 'hello', 879 | e: { 880 | a: 'yo', 881 | b: undefined, 882 | c: 'sup', 883 | d: 0, 884 | f: [ 885 | { foo: 123, bar: 123 }, 886 | { foo: 465, bar: 456 }, 887 | ], 888 | }, 889 | }, 890 | c: 'world', 891 | }) 892 | 893 | // { 894 | // 'a': 'hi', 895 | // 'b.b.0': 'foo', 896 | // 'b.b.1': '', 897 | // 'b.b.3': 'bar', 898 | // 'b.d': 'hello', 899 | // 'b.e.a': 'yo', 900 | // 'b.e.c': 'sup', 901 | // 'b.e.d': 0, 902 | // 'b.e.f.0.foo': 123, 903 | // 'b.e.f.0.bar': 123, 904 | // 'b.e.f.1.foo': 465, 905 | // 'b.e.f.1.bar': 456, 906 | // 'c': 'world' 907 | // } 908 | ``` 909 | 910 | #### fsReadAll 911 | 912 | Get a list of all the files from a directory. The method recursively fetches files from the main and the sub-folders. The dotfiles are ignored implicitly. 913 | 914 | ```ts 915 | import { fsReadAll } from '@poppinss/utils' 916 | 917 | const files = await fsReadAll(new URL('./config', import.meta.url), { pathType: 'url' }) 918 | await Promise.all(files.map((file) => import(file))) 919 | ``` 920 | 921 | You can also pass the options along with the directory path as the second argument. 922 | 923 | ```ts 924 | type Options = { 925 | ignoreMissingRoot?: boolean 926 | filter?: (filePath: string, index: number) => boolean 927 | sort?: (current: string, next: string) => number 928 | pathType?: 'relative' | 'unixRelative' | 'absolute' | 'unixAbsolute' | 'url' 929 | } 930 | 931 | const options: Partial = {} 932 | await fsReadAll(location, options) 933 | ``` 934 | 935 | | Argument | Type | Description | 936 | | ------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 937 | | `ignoreMissingRoot` | boolean | By default, an exception is raised when the root directory is missing. Setting `ignoreMissingRoot` to true will not result in an error and an empty array is returned back. | 938 | | `filter` | method | Define a filter to ignore certain paths. The method is called on the final list of files. | 939 | | `sort` | method | Define a custom method to sort file paths. By default, the files are sorted using natural sort. | 940 | | `pathType` | enum | Define how to return the collected paths. By default, OS-specific relative paths are returned. If you want to import the collected files, you must set the `pathType = 'url'` | 941 | 942 | #### fsImportAll 943 | 944 | The `fsImportAll` method imports all the files recursively from a given directory and set the exported value from each module on an object. 945 | 946 | ```ts 947 | import { fsImportAll } from '@poppinss/utils' 948 | 949 | const collection = await fsImportAll(new URL('./config', import.meta.url)) 950 | console.log(collection) 951 | ``` 952 | 953 | - Collection is an object with a tree of key-value pair. 954 | - The key is the nested object created from the file path. 955 | - Value is the exported values from the module. If a module exports both the `default` and `named` values, then only the default values are used. 956 | 957 | The second param is the options to customize the import behavior. 958 | 959 | | Argument | Type | Description | 960 | | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 961 | | `ignoreMissingRoot` | boolean | By default, an exception is raised when the root directory is missing. Setting `ignoreMissingRoot` to true will not result in an error and an empty object is returned back. | 962 | | `filter` | method | Define a filter to ignore certain paths. By default only files ending with `.js`, `.ts`, `.json`, `.cjs`, and `.mjs` are imported. | 963 | | `sort` | method | Define a custom method to sort file paths. By default, the files are sorted using natural sort. | 964 | | `transformKeys` | method | Define a callback method to transform the keys for the final object. The method receives an array of nested keys and must return an array back. | 965 | 966 | #### isScriptFile 967 | 968 | A filter to know if the file path ends with `.js`, `.json`, `.cjs`, `.mjs` or `.ts`. In the case of `.ts` files, the `.d.ts` returns false. 969 | 970 | ```ts 971 | import { isScriptFile } from '@poppinss/utils' 972 | 973 | isScriptFile('foo.js') // true 974 | isScriptFile('foo/bar.cjs') // true 975 | isScriptFile('foo/bar.mjs') // true 976 | isScriptFile('foo.json') // true 977 | 978 | isScriptFile('foo/bar.ts') // true 979 | isScriptFile('foo/bar.d.ts') // false 980 | ``` 981 | 982 | The goal of this method is to use it as a filter with the `fsReadAll` method. 983 | 984 | ```ts 985 | import { fsReadAll, isScriptFile } from '@poppinss/utils' 986 | 987 | const dir = new URL('./config', import.meta.url) 988 | const options = { pathType: 'url', filter: isScriptFile } 989 | 990 | const files = await fsReadAll(dir, options) 991 | 992 | await Promise.all( 993 | files.map((file) => { 994 | if (file.endsWith('.json')) { 995 | return import(file, { with: { type: 'json' } }) 996 | } 997 | 998 | return import(file) 999 | }) 1000 | ) 1001 | ``` 1002 | 1003 | #### importDefault 1004 | 1005 | A helper function that assert a lazy import function output to have a `default export`, otherwise raises an exception. 1006 | 1007 | We use dynamic default exports a lot in AdonisJS apps, so extracting the check to a helper function. 1008 | 1009 | ```ts 1010 | import { importDefault } from '@poppinss/utils' 1011 | const defaultVal = await importDefault(() => import('./some_module.js')) 1012 | ``` 1013 | 1014 | #### naturalSort 1015 | 1016 | A sorting function to use natural sort for ordering an array. 1017 | 1018 | ```ts 1019 | import { naturalSort } from '@poppinss/utils' 1020 | 1021 | const values = ['1_foo_bar', '12_foo_bar'].sort() 1022 | // Default sorting: ['12_foo_bar', '1_foo_bar'] 1023 | 1024 | const values = ['1_foo_bar', '12_foo_bar'].sort(naturalSort) 1025 | // Default sorting: ['1_foo_bar', '12_foo_bar'] 1026 | ``` 1027 | 1028 | #### safeEqual 1029 | 1030 | Check if two buffer or string values are the same. This method does not leak any timing information and prevents [timing attack](https://javascript.plainenglish.io/what-are-timing-attacks-and-how-to-prevent-them-using-nodejs-158cc7e2d70c). 1031 | 1032 | Under the hood, this method uses Node.js [crypto.timeSafeEqual](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b) method, with support for comparing string values. _(crypto.timeSafeEqual does not support string comparison)_ 1033 | 1034 | ```ts 1035 | import { safeEqual } from '@poppinss/utils' 1036 | 1037 | /** 1038 | * The trusted value, it might be saved inside the db 1039 | */ 1040 | const trustedValue = 'hello world' 1041 | 1042 | /** 1043 | * Untrusted user input 1044 | */ 1045 | const userInput = 'hello' 1046 | 1047 | if (safeEqual(trustedValue, userInput)) { 1048 | // both are same 1049 | } else { 1050 | // value mis-match 1051 | } 1052 | ``` 1053 | 1054 | #### slash 1055 | 1056 | Convert OS-specific file paths to Unix file paths. Credits [https://github.com/sindresorhus/slash](https://github.com/sindresorhus/slash) 1057 | 1058 | ```ts 1059 | import { slash } from '@poppinss/utils/slash' 1060 | slash('foo\\bar') // foo/bar 1061 | ``` 1062 | 1063 | #### MessageBuilder 1064 | 1065 | Message builder is a convenience layer to stringify JavaScript values with an expiry date and a purpose. For example: 1066 | 1067 | ```ts 1068 | import { MessageBuilder } from '@poppinss/utils' 1069 | 1070 | const builder = new MessageBuilder() 1071 | const encoded = builder.build( 1072 | { 1073 | token: string.random(32), 1074 | }, 1075 | '1 hour', 1076 | 'email_verification' 1077 | ) 1078 | 1079 | /** 1080 | * { 1081 | * "message": { 1082 | * "token":"GZhbeG5TvgA-7JCg5y4wOBB1qHIRtX6q" 1083 | * }, 1084 | * "purpose":"email_verification", 1085 | * "expiryDate":"2022-10-03T04:07:13.860Z" 1086 | * } 1087 | */ 1088 | ``` 1089 | 1090 | Once you have the JSON string with the expiry and the purpose, you can encrypt it (to prevent tampering) and share it with the client. 1091 | 1092 | During the email verification, you can decrypt the key and then ask the `MessageBuilder` to verify the payload. 1093 | 1094 | ```ts 1095 | const decoded = builder.verify(value, 'email_verification') 1096 | if (!decoded) { 1097 | return 'Invalid token' 1098 | } 1099 | 1100 | console.log(decoded.token) 1101 | ``` 1102 | 1103 | Now let's imagine someone presents the same token to reset their account password. In the following example, the validation will fail since the purpose of the original token is not the same as the purpose set during the `verify` method call. 1104 | 1105 | ```ts 1106 | const decoded = builder.verify(value, 'reset_password') 1107 | ``` 1108 | 1109 | #### ObjectBuilder 1110 | 1111 | The `ObjectBuilder` is a convenience class to create an object with dynamic properties. Consider the following example, where we wrap our code inside conditionals before adding the property `b` to the `startingObject`. 1112 | 1113 | ```ts 1114 | const startingObject = { 1115 | a: 1 1116 | // Add "b", if it exists 1117 | ...(b ? { b } : {}) 1118 | } 1119 | 1120 | // OR 1121 | if (b) { 1122 | startingObject.b = b 1123 | } 1124 | ``` 1125 | 1126 | Instead of writing conditionals, you can consider using the Object builder fluent API. 1127 | 1128 | ```ts 1129 | const builder = new ObjectBuilder({ a: 1 }) 1130 | 1131 | const plainObject = builder.add('b', b).toObject() 1132 | ``` 1133 | 1134 | By default, only the `undefined` values are ignored. However, you can also ignore `null` values. 1135 | 1136 | ```ts 1137 | const ignoreNullValues = true 1138 | const builder = new ObjectBuilder({ a: 1 }, ignoreNullValues) 1139 | ``` 1140 | 1141 | Following are the available methods on the `ObjectBuilder` class. 1142 | 1143 | ```ts 1144 | builder.remove(key) 1145 | builder.has(key) 1146 | builder.get(key) 1147 | builder.add(key) 1148 | 1149 | builder.toObject() // get plain object 1150 | ``` 1151 | 1152 | #### Secret 1153 | 1154 | Creates a secret value that prevents itself from getting logged inside `console.log` statements, during JSON serialization, and string concatenation. 1155 | 1156 | To understand why you need a special `Secret` object, you need to understand the root of the problem. Let's start with an example. 1157 | 1158 | Given that you have a `Token` class that generates an opaque token for a user and persists its hash inside the database. The plain token (aka raw value) is shared with the user and it should only be visible once (for security reasons). Here is a dummy implementation of the same. 1159 | 1160 | ```ts 1161 | class Token { 1162 | generate() { 1163 | return { 1164 | value: 'opaque_raw_token', 1165 | hash: 'hash_of_raw_token_inside_db', 1166 | } 1167 | } 1168 | } 1169 | 1170 | const token = new Token().generate() 1171 | return response.send(token) 1172 | ``` 1173 | 1174 | At the same time, you want to drop a log statement inside your application that you can later use to debug the flow of the application, and this is how you log the token. 1175 | 1176 | ```ts 1177 | const token = new Token().generate() 1178 | 1179 | logger.log('token generated %O', token) 1180 | // token generated {"value":"opaque_raw_token","hash":"hash_of_raw_token_inside_db"} 1181 | 1182 | return response.send(token) 1183 | ``` 1184 | 1185 | BOOM! You have weakened the security of your app. Now, anyone monitoring the logs can grab raw token values from the log and use them to perform the actions on behalf of the user. 1186 | 1187 | Now, to prevent this from happening, you **should work with a branded data type**. Our [old friend PHP has it](https://www.php.net/manual/en/class.sensitiveparametervalue.php), so we need it as well. 1188 | 1189 | This is what exactly the `Secret` utility class does for you. Create values that prevent themselves from leaking inside logs or during JSON serialization. 1190 | 1191 | ```ts 1192 | import { Secret } from '@poppinss/utils' 1193 | 1194 | class Token { 1195 | generate() { 1196 | return { 1197 | // THIS LINE 👇 1198 | value: new Secret('opaque_raw_token'), 1199 | hash: 'hash_of_raw_token_inside_db', 1200 | } 1201 | } 1202 | } 1203 | 1204 | const token = new Token().generate() 1205 | 1206 | logger.log('token generated %O', token) 1207 | // AND THIS LOG 👇 1208 | // token generated {"value":"[redacted]","hash":"hash_of_raw_token_inside_db"} 1209 | 1210 | return response.send(token) 1211 | ``` 1212 | 1213 | **Need the original value back?** 1214 | You can call the `release` method to get the secret value back. Again, the idea is not to prevent your code from accessing the raw value. It's to stop the logging and serialization layer from reading it. 1215 | 1216 | ```ts 1217 | const secret = new Secret('opaque_raw_token') 1218 | const rawValue = secret.release() 1219 | 1220 | rawValue === opaque_raw_token // true 1221 | ``` 1222 | 1223 | > Shoutout to [https://transcend.io/blog/keep-sensitive-values-out-of-your-logs-with-types](transcend.io's article) to helping me design the API. In fact, I have ripped their implementation for my personal use. 1224 | 1225 | #### dirname/filename 1226 | 1227 | ES modules does not have magic variables `__filename` and `__dirname`. You can use these helpers to get the current directory and filenames as follows. 1228 | 1229 | ```ts 1230 | import { getDirname, getFilename } from '@poppinss/utils' 1231 | 1232 | const dirname = getDirname(import.meta.url) 1233 | const filename = getFilename(import.meta.url) 1234 | ``` 1235 | 1236 | #### joinToURL 1237 | 1238 | Similar to the Node.js `path.join`, but instead expects the first parameter to be a URL instance or a string with the `file:///` protocol. 1239 | 1240 | The return value is an absolute file system path without the `file:///` protocol. 1241 | 1242 | ```ts 1243 | import { joinToURL } from '@poppinss/utils' 1244 | 1245 | // With URL as a string 1246 | const APP_PATH = joinToURL(import.meta.url, 'app') 1247 | 1248 | // With URL instance 1249 | const APP_PATH = joinToURL(new URL('./', import.meta.url), 'app') 1250 | ``` 1251 | 1252 | [gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/poppinss/utils/checks.yml?style=for-the-badge 1253 | [gh-workflow-url]: https://github.com/poppinss/utils/actions/workflows/checks.yml 'Github action' 1254 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 1255 | [typescript-url]: "typescript" 1256 | [npm-image]: https://img.shields.io/npm/v/@poppinss/utils.svg?style=for-the-badge&logo=npm 1257 | [npm-url]: https://npmjs.org/package/@poppinss/utils 'npm' 1258 | [license-image]: https://img.shields.io/npm/l/@poppinss/utils?color=blueviolet&style=for-the-badge 1259 | [license-url]: LICENSE.md 'license' 1260 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@japa/assert' 2 | import { expectTypeOf } from '@japa/expect-type' 3 | import { processCLIArgs, configure, run } from '@japa/runner' 4 | 5 | /* 6 | |-------------------------------------------------------------------------- 7 | | Configure tests 8 | |-------------------------------------------------------------------------- 9 | | 10 | | The configure method accepts the configuration to configure the Japa 11 | | tests runner. 12 | | 13 | | The first method call "processCliArgs" process the command line arguments 14 | | and turns them into a config object. Using this method is not mandatory. 15 | | 16 | | Please consult japa.dev/runner-config for the config docs. 17 | */ 18 | processCLIArgs(process.argv.slice(2)), 19 | configure({ 20 | files: ['tests/**/*.spec.(ts|js)'], 21 | plugins: [assert(), expectTypeOf()], 22 | }) 23 | 24 | /* 25 | |-------------------------------------------------------------------------- 26 | | Run tests 27 | |-------------------------------------------------------------------------- 28 | | 29 | | The following "run" method is required to execute all the tests. 30 | | 31 | */ 32 | run() 33 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | export default configPkg() 3 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { fileURLToPath } from 'node:url' 11 | import { join as pathJoin, dirname as pathDirname } from 'node:path' 12 | 13 | export { Secret } from './src/secret.js' 14 | export { base64 } from './src/base64.js' 15 | export { compose } from './src/compose.js' 16 | export { importDefault } from './src/import_default.js' 17 | export { defineStaticProperty } from './src/define_static_property.js' 18 | export { Exception, createError } from './src/exception.js' 19 | export { flatten } from './src/flatten.js' 20 | export { fsImportAll } from './src/fs_import_all.js' 21 | export { fsReadAll } from './src/fs_read_all.js' 22 | export { isScriptFile } from './src/is_script_file.js' 23 | export { MessageBuilder } from './src/message_builder.js' 24 | export { naturalSort } from './src/natural_sort.js' 25 | export { ObjectBuilder } from './src/object_builder.js' 26 | export { safeEqual } from './src/safe_equal.js' 27 | export { slash } from './src/slash.js' 28 | export { RuntimeException, InvalidArgumentsException } from './src/exceptions/main.js' 29 | 30 | /** 31 | * Get dirname for a given file path URL 32 | */ 33 | export function getDirname(url: string | URL) { 34 | return pathDirname(getFilename(url)) 35 | } 36 | 37 | /** 38 | * Get filename for a given file path URL 39 | */ 40 | export function getFilename(url: string | URL) { 41 | return fileURLToPath(url) 42 | } 43 | 44 | /** 45 | * Join paths to a URL instance or a URL string. The return 46 | * value will be a file path without the `file:///` protocol. 47 | */ 48 | export function joinToURL(url: string | URL, ...str: string[]) { 49 | return pathJoin(getDirname(url), ...str) 50 | } 51 | -------------------------------------------------------------------------------- /lodash/lodash.types.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | type PropertyName = string | number | symbol 11 | type PropertyNames = PropertyName | ReadonlyArray 12 | 13 | /** 14 | * Instead of using lodash as a dependency (which is around 4MB), we create a 15 | * customized build of lodash with only the following methods. 16 | * 17 | * I know that 4MB is not a huge deal on the server, but I am okay shoving that 18 | * off, if the work to maintain custom build is not too much. 19 | * 20 | * How about Lodash per methods packages? 21 | * They are out of date from the main lodash. That is the main problem 22 | */ 23 | declare module '@poppinss/utils/lodash' { 24 | type LodashMethods = { 25 | pick: (object: T | null | undefined, ...props: Array) => Partial 26 | omit: ( 27 | object: T | null | undefined, 28 | ...paths: Array 29 | ) => Partial 30 | has: (object: T, path: PropertyNames) => boolean 31 | get: (object: any, path: PropertyNames, defaultValue?: any) => any 32 | set: (object: any, path: PropertyNames, value: any) => any 33 | unset: (object: any, path: PropertyNames) => boolean 34 | mergeWith: (object: any, ...otherArgs: any[]) => any 35 | merge: (object: any, ...otherArgs: any[]) => any 36 | size: (collection: object | string | null | undefined) => number 37 | clone: (value: T) => T 38 | cloneDeep: (value: T) => T 39 | toPath: (value: any) => string[] 40 | } 41 | 42 | const lodash: LodashMethods 43 | export default lodash 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@poppinss/utils", 3 | "version": "6.9.3", 4 | "description": "Handy utilities for repetitive work", 5 | "main": "build/index.js", 6 | "type": "module", 7 | "files": [ 8 | "build", 9 | "!build/tests", 10 | "!build/test_helpers", 11 | "!build/bin", 12 | "lodash" 13 | ], 14 | "exports": { 15 | ".": "./build/index.js", 16 | "./lodash": { 17 | "types": "./lodash/lodash.types.d.ts", 18 | "node": "./build/lodash/main.cjs", 19 | "default": "./build/lodash/main.cjs" 20 | }, 21 | "./assert": "./build/src/assert.js", 22 | "./string": "./build/src/string/main.js", 23 | "./string_builder": "./build/src/string/string_builder.js", 24 | "./json": "./build/src/json/main.js", 25 | "./types": "./build/src/types.js", 26 | "./exceptions": "./build/src/exceptions/main.js", 27 | "./exception": "./build/src/exception.js", 28 | "./slash": "./build/src/slash.js" 29 | }, 30 | "engines": { 31 | "node": ">=18.16.0" 32 | }, 33 | "scripts": { 34 | "pretest": "npm run lint", 35 | "test": "npm run build:lodash && c8 npm run quick:test", 36 | "build:lodash": "lodash include=\"pick,omit,has,get,set,unset,mergeWith,merge,size,clone,cloneDeep,toPath\" --production && move-file ./lodash.custom.min.js build/lodash/main.cjs", 37 | "lint": "eslint", 38 | "format": "prettier --write .", 39 | "typecheck": "tsc --noEmit", 40 | "precompile": "npm run lint", 41 | "compile": "tsup-node && tsc --emitDeclarationOnly --declaration", 42 | "build": "npm run compile && npm run build:lodash", 43 | "version": "npm run build", 44 | "prepublishOnly": "npm run build", 45 | "release": "release-it", 46 | "quick:test": "node --import=ts-node-maintained/register/esm bin/test.ts" 47 | }, 48 | "devDependencies": { 49 | "@adonisjs/eslint-config": "^2.0.0", 50 | "@adonisjs/logger": "^6.0.6", 51 | "@adonisjs/prettier-config": "^1.4.4", 52 | "@adonisjs/tsconfig": "^1.4.0", 53 | "@japa/assert": "^4.0.1", 54 | "@japa/expect-type": "^2.0.3", 55 | "@japa/runner": "^4.2.0", 56 | "@release-it/conventional-changelog": "^10.0.1", 57 | "@swc/core": "1.10.7", 58 | "@types/fs-extra": "^11.0.4", 59 | "@types/node": "^22.15.3", 60 | "c8": "^10.1.3", 61 | "eslint": "^9.26.0", 62 | "fs-extra": "^11.3.0", 63 | "lodash": "^4.17.21", 64 | "lodash-cli": "^4.17.5", 65 | "move-file-cli": "^3.0.0", 66 | "prettier": "^3.5.3", 67 | "release-it": "^19.0.2", 68 | "ts-node-maintained": "^10.9.5", 69 | "tsup": "^8.4.0", 70 | "typescript": "^5.8.3" 71 | }, 72 | "dependencies": { 73 | "@poppinss/exception": "^1.2.1", 74 | "@poppinss/object-builder": "^1.1.0", 75 | "@poppinss/string": "^1.3.0", 76 | "flattie": "^1.1.1", 77 | "safe-stable-stringify": "^2.5.0", 78 | "secure-json-parse": "^4.0.0" 79 | }, 80 | "homepage": "https://github.com/poppinss/utils#readme", 81 | "repository": { 82 | "type": "git", 83 | "url": "git+https://github.com/poppinss/utils.git" 84 | }, 85 | "bugs": { 86 | "url": "https://github.com/poppinss/utils/issues" 87 | }, 88 | "keywords": [ 89 | "toolkit", 90 | "utilities" 91 | ], 92 | "author": "Harminder Virk ", 93 | "license": "MIT", 94 | "publishConfig": { 95 | "access": "public", 96 | "provenance": true 97 | }, 98 | "tsup": { 99 | "entry": [ 100 | "./index.ts", 101 | "./src/assert.ts", 102 | "./src/string/main.ts", 103 | "./src/string/string_builder.ts", 104 | "./src/json/main.ts", 105 | "./src/types.ts", 106 | "./src/slash.ts", 107 | "./src/exception.ts", 108 | "./src/exceptions/main.ts" 109 | ], 110 | "outDir": "./build", 111 | "clean": true, 112 | "format": "esm", 113 | "dts": false, 114 | "sourcemap": false, 115 | "target": "esnext" 116 | }, 117 | "release-it": { 118 | "git": { 119 | "requireCleanWorkingDir": true, 120 | "requireUpstream": true, 121 | "commitMessage": "chore(release): ${version}", 122 | "tagAnnotation": "v${version}", 123 | "push": true, 124 | "tagName": "v${version}" 125 | }, 126 | "github": { 127 | "release": true 128 | }, 129 | "npm": { 130 | "publish": true, 131 | "skipChecks": true 132 | }, 133 | "plugins": { 134 | "@release-it/conventional-changelog": { 135 | "preset": { 136 | "name": "angular" 137 | } 138 | } 139 | } 140 | }, 141 | "c8": { 142 | "reporter": [ 143 | "text", 144 | "html" 145 | ], 146 | "exclude": [ 147 | "**/build/lodash/**", 148 | "tests/**", 149 | "test_helpers/**" 150 | ] 151 | }, 152 | "prettier": "@adonisjs/prettier-config" 153 | } 154 | -------------------------------------------------------------------------------- /src/assert.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { inspect } from 'node:util' 11 | import { AssertionError } from 'node:assert' 12 | 13 | /** 14 | * @alias "assertExists" 15 | */ 16 | export function assert(value: unknown, message?: string): asserts value { 17 | return assertExists(value, message) 18 | } 19 | 20 | /** 21 | * Assert the value is turthy or raise an exception. 22 | * 23 | * Truthy value excludes, undefined, null, and false values. 24 | */ 25 | export function assertExists(value: unknown, message?: string): asserts value { 26 | if (!value) { 27 | throw new AssertionError({ message: message ?? 'value is falsy' }) 28 | } 29 | } 30 | 31 | /** 32 | * Throws error when method is called 33 | */ 34 | export function assertUnreachable(x?: never): never { 35 | throw new AssertionError({ message: `unreachable code executed: ${inspect(x)}` }) 36 | } 37 | 38 | /** 39 | * Assert the value is not null. 40 | */ 41 | export function assertNotNull( 42 | value: T | null, 43 | message?: string 44 | ): asserts value is Exclude { 45 | if (value === null) { 46 | throw new AssertionError({ message: message ?? 'unexpected null value' }) 47 | } 48 | } 49 | 50 | /** 51 | * Assert the value is not undefined. 52 | */ 53 | export function assertIsDefined( 54 | value: T | undefined, 55 | message?: string 56 | ): asserts value is Exclude { 57 | if (value === undefined) { 58 | throw new AssertionError({ message: message ?? 'unexpected undefined value' }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/base64.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /** 11 | * Helper class to base64 encode/decode values with option 12 | * for url encoding and decoding 13 | */ 14 | class Base64 { 15 | /** 16 | * Base64 encode Buffer or string 17 | */ 18 | encode(arrayBuffer: ArrayBuffer | Buffer | SharedArrayBuffer): string 19 | encode(data: string, encoding?: BufferEncoding): string 20 | encode( 21 | data: ArrayBuffer | Buffer | SharedArrayBuffer | string, 22 | encoding?: BufferEncoding 23 | ): string { 24 | if (typeof data === 'string') { 25 | return Buffer.from(data, encoding).toString('base64') 26 | } 27 | 28 | if (Buffer.isBuffer(data)) { 29 | return data.toString('base64') 30 | } 31 | 32 | return Buffer.from(data).toString('base64') 33 | } 34 | 35 | /** 36 | * Base64 decode a previously encoded string or Buffer. 37 | */ 38 | decode(encode: string, encoding: BufferEncoding, strict: true): string 39 | decode(encode: string, encoding: undefined, strict: true): string 40 | decode(encode: string, encoding?: BufferEncoding, strict?: false): string | null 41 | decode(encode: Buffer, encoding?: BufferEncoding): string 42 | decode( 43 | encoded: string | Buffer, 44 | encoding: BufferEncoding = 'utf-8', 45 | strict: boolean = false 46 | ): string | null { 47 | if (Buffer.isBuffer(encoded)) { 48 | return encoded.toString(encoding) 49 | } 50 | 51 | const decoded = Buffer.from(encoded, 'base64').toString(encoding) 52 | const isInvalid = this.encode(decoded, encoding) !== encoded 53 | 54 | if (strict && isInvalid) { 55 | throw new Error('Cannot decode malformed value') 56 | } 57 | 58 | return isInvalid ? null : decoded 59 | } 60 | 61 | /** 62 | * Base64 encode Buffer or string to be URL safe. (RFC 4648) 63 | */ 64 | urlEncode(arrayBuffer: ArrayBuffer | Buffer | SharedArrayBuffer): string 65 | urlEncode(data: string, encoding?: BufferEncoding): string 66 | urlEncode( 67 | data: ArrayBuffer | Buffer | SharedArrayBuffer | string, 68 | encoding?: BufferEncoding 69 | ): string { 70 | const encoded = typeof data === 'string' ? this.encode(data, encoding) : this.encode(data) 71 | return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/\=/g, '') 72 | } 73 | 74 | /** 75 | * Base64 URL decode a previously encoded string or Buffer. (RFC 4648) 76 | */ 77 | urlDecode(encode: string, encoding: BufferEncoding, strict: true): string 78 | urlDecode(encode: string, encoding: undefined, strict: true): string 79 | urlDecode(encode: string, encoding?: BufferEncoding, strict?: false): string | null 80 | urlDecode(encode: Buffer, encoding?: BufferEncoding): string 81 | urlDecode( 82 | encoded: string | Buffer, 83 | encoding: BufferEncoding = 'utf-8', 84 | strict: boolean = false 85 | ): string | null { 86 | if (Buffer.isBuffer(encoded)) { 87 | return encoded.toString(encoding) 88 | } 89 | 90 | const decoded = Buffer.from(encoded, 'base64').toString(encoding) 91 | const isInvalid = this.urlEncode(decoded, encoding) !== encoded 92 | 93 | if (strict && isInvalid) { 94 | throw new Error('Cannot urlDecode malformed value') 95 | } 96 | 97 | return isInvalid ? null : decoded 98 | } 99 | } 100 | 101 | export const base64 = new Base64() 102 | -------------------------------------------------------------------------------- /src/compose.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { Constructor } from './types.js' 11 | 12 | interface UnaryFunction { 13 | (source: T): R 14 | } 15 | 16 | /** 17 | * Compose a class by applying mixins to it. 18 | * The code is inspired by https://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/, its 19 | * just that I have added the support for static types too. 20 | */ 21 | export function compose(superclass: T, mixin: UnaryFunction): A 22 | export function compose( 23 | superclass: T, 24 | mixin: UnaryFunction, 25 | mixinB: UnaryFunction 26 | ): B 27 | export function compose( 28 | superclass: T, 29 | mixin: UnaryFunction, 30 | mixinB: UnaryFunction, 31 | mixinC: UnaryFunction 32 | ): C 33 | export function compose( 34 | superclass: T, 35 | mixin: UnaryFunction, 36 | mixinB: UnaryFunction, 37 | mixinC: UnaryFunction, 38 | mixinD: UnaryFunction 39 | ): D 40 | export function compose( 41 | superclass: T, 42 | mixin: UnaryFunction, 43 | mixinB: UnaryFunction, 44 | mixinC: UnaryFunction, 45 | mixinD: UnaryFunction, 46 | mixinE: UnaryFunction 47 | ): E 48 | export function compose( 49 | superclass: T, 50 | mixin: UnaryFunction, 51 | mixinB: UnaryFunction, 52 | mixinC: UnaryFunction, 53 | mixinD: UnaryFunction, 54 | mixinF: UnaryFunction 55 | ): F 56 | export function compose( 57 | superclass: T, 58 | mixin: UnaryFunction, 59 | mixinB: UnaryFunction, 60 | mixinC: UnaryFunction, 61 | mixinD: UnaryFunction, 62 | mixinF: UnaryFunction, 63 | mixinG: UnaryFunction 64 | ): G 65 | export function compose( 66 | superclass: T, 67 | mixin: UnaryFunction, 68 | mixinB: UnaryFunction, 69 | mixinC: UnaryFunction, 70 | mixinD: UnaryFunction, 71 | mixinF: UnaryFunction, 72 | mixinG: UnaryFunction, 73 | mixinH: UnaryFunction 74 | ): H 75 | export function compose( 76 | superclass: T, 77 | mixin: UnaryFunction, 78 | mixinB: UnaryFunction, 79 | mixinC: UnaryFunction, 80 | mixinD: UnaryFunction, 81 | mixinF: UnaryFunction, 82 | mixinG: UnaryFunction, 83 | mixinH: UnaryFunction, 84 | mixinI: UnaryFunction 85 | ): I 86 | export function compose>( 87 | superclass: T, 88 | ...mixins: Mixins[] 89 | ) { 90 | return mixins.reduce((c, mixin) => mixin(c), superclass) 91 | } 92 | -------------------------------------------------------------------------------- /src/define_static_property.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import lodash from '@poppinss/utils/lodash' 11 | 12 | type Constructor = new (...args: any[]) => any 13 | type AbstractConstructor = abstract new (...args: any[]) => any 14 | 15 | /** 16 | * Define static properties on a class with inheritance in play. 17 | */ 18 | export function defineStaticProperty< 19 | T extends Constructor | AbstractConstructor, 20 | Prop extends keyof T, 21 | >( 22 | self: T, 23 | propertyName: Prop, 24 | { 25 | initialValue, 26 | strategy, 27 | }: { 28 | initialValue: T[Prop] 29 | strategy: 'inherit' | 'define' | ((value: T[Prop]) => T[Prop]) 30 | } 31 | ) { 32 | if (!self.hasOwnProperty(propertyName)) { 33 | const value = self[propertyName] 34 | 35 | /** 36 | * Define the property as it is when the strategy is set 37 | * to "define". Or the value on the prototype chain 38 | * is set to undefined. 39 | */ 40 | if (strategy === 'define' || value === undefined) { 41 | Object.defineProperty(self, propertyName, { 42 | value: initialValue, 43 | configurable: true, 44 | enumerable: true, 45 | writable: true, 46 | }) 47 | return 48 | } 49 | 50 | Object.defineProperty(self, propertyName, { 51 | value: typeof strategy === 'function' ? strategy(value) : lodash.cloneDeep(value), 52 | configurable: true, 53 | enumerable: true, 54 | writable: true, 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/exception.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export { Exception, createError } from '@poppinss/exception' 11 | -------------------------------------------------------------------------------- /src/exceptions/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export { InvalidArgumentsException, RuntimeException } from '@poppinss/exception' 11 | -------------------------------------------------------------------------------- /src/flatten.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { flattie } from 'flattie' 11 | 12 | /** 13 | * Recursively flatten an object/array. 14 | */ 15 | export function flatten, Y = unknown>( 16 | input: Y, 17 | glue?: string, 18 | keepNullish?: boolean 19 | ): X { 20 | return flattie(input, glue, keepNullish) 21 | } 22 | -------------------------------------------------------------------------------- /src/fs_import_all.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { fileURLToPath } from 'node:url' 11 | import lodash from '@poppinss/utils/lodash' 12 | import { extname, relative, sep } from 'node:path' 13 | 14 | import { fsReadAll } from './fs_read_all.js' 15 | import { ImportAllFilesOptions } from './types.js' 16 | import { isScriptFile } from './is_script_file.js' 17 | 18 | /** 19 | * Import the file and update the values collection with the default 20 | * export. 21 | */ 22 | async function importFile( 23 | basePath: string, 24 | fileURL: string, 25 | values: any, 26 | options: ImportAllFilesOptions 27 | ) { 28 | /** 29 | * Converting URL to file path 30 | */ 31 | const filePath = fileURLToPath(fileURL) 32 | 33 | /** 34 | * Grab file extension 35 | */ 36 | const fileExtension = extname(filePath) 37 | 38 | const collectionKey = relative(basePath, filePath) // Get file relative path 39 | .replace(new RegExp(`${fileExtension}$`), '') // Get rid of the file extension 40 | .split(sep) // Convert nested paths to an array of keys 41 | 42 | /** 43 | * Import module 44 | */ 45 | const exportedValue = 46 | fileExtension === '.json' 47 | ? await import(fileURL, { with: { type: 'json' } }) 48 | : await import(fileURL) 49 | 50 | lodash.set( 51 | values, 52 | options.transformKeys ? options.transformKeys(collectionKey) : collectionKey, 53 | exportedValue.default ? exportedValue.default : { ...exportedValue } 54 | ) 55 | } 56 | 57 | /** 58 | * Returns an array of file paths from the given location. You can 59 | * optionally filter and sort files by passing relevant options 60 | * 61 | * ```ts 62 | * await fsReadAll(new URL('./', import.meta.url)) 63 | * 64 | * await fsReadAll(new URL('./', import.meta.url), { 65 | * filter: (filePath) => filePath.endsWith('.js') 66 | * }) 67 | 68 | * await fsReadAll(new URL('./', import.meta.url), { 69 | * absolute: true, 70 | * unixPaths: true 71 | * }) 72 | * ``` 73 | */ 74 | export async function fsImportAll( 75 | location: string | URL, 76 | options?: ImportAllFilesOptions 77 | ): Promise { 78 | options = options || {} 79 | const collection: any = {} 80 | const normalizedLocation = typeof location === 'string' ? location : fileURLToPath(location) 81 | const files = await fsReadAll(normalizedLocation, { 82 | filter: isScriptFile, 83 | ...options, 84 | pathType: 'url', 85 | }) 86 | 87 | /** 88 | * Parallelly import all the files and mutate the values collection 89 | */ 90 | await Promise.all(files.map((file) => importFile(normalizedLocation, file, collection, options!))) 91 | 92 | return collection 93 | } 94 | -------------------------------------------------------------------------------- /src/fs_read_all.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'node:path' 11 | import { readdir, stat } from 'node:fs/promises' 12 | import { fileURLToPath, pathToFileURL } from 'node:url' 13 | 14 | import { slash } from './slash.js' 15 | import { naturalSort } from './natural_sort.js' 16 | import { ReadAllFilesOptions } from './types.js' 17 | 18 | /** 19 | * Filter to remove dot files 20 | */ 21 | function filterDotFiles(fileName: string) { 22 | return fileName[0] !== '.' 23 | } 24 | 25 | /** 26 | * Read all files from the directory recursively 27 | */ 28 | async function readFiles( 29 | root: string, 30 | files: string[], 31 | options: ReadAllFilesOptions, 32 | relativePath: string 33 | ): Promise { 34 | const location = join(root, relativePath) 35 | const stats = await stat(location) 36 | 37 | if (stats.isDirectory()) { 38 | let locationFiles = await readdir(location) 39 | 40 | await Promise.all( 41 | locationFiles.filter(filterDotFiles).map((file) => { 42 | return readFiles(root, files, options, join(relativePath, file)) 43 | }) 44 | ) 45 | 46 | return 47 | } 48 | 49 | const pathType = options.pathType || 'relative' 50 | switch (pathType) { 51 | case 'relative': 52 | files.push(relativePath) 53 | break 54 | case 'absolute': 55 | files.push(location) 56 | break 57 | case 'unixRelative': 58 | files.push(slash(relativePath)) 59 | break 60 | case 'unixAbsolute': 61 | files.push(slash(location)) 62 | break 63 | case 'url': 64 | files.push(pathToFileURL(location).href) 65 | } 66 | } 67 | 68 | /** 69 | * Returns an array of file paths from the given location. You can 70 | * optionally filter and sort files by passing relevant options 71 | * 72 | * ```ts 73 | * await fsReadAll(new URL('./', import.meta.url)) 74 | * 75 | * await fsReadAll(new URL('./', import.meta.url), { 76 | * filter: (filePath) => filePath.endsWith('.js') 77 | * }) 78 | 79 | * await fsReadAll(new URL('./', import.meta.url), { 80 | * absolute: true, 81 | * unixPaths: true 82 | * }) 83 | * ``` 84 | */ 85 | export async function fsReadAll( 86 | location: string | URL, 87 | options?: ReadAllFilesOptions 88 | ): Promise { 89 | const normalizedLocation = typeof location === 'string' ? location : fileURLToPath(location) 90 | const normalizedOptions = Object.assign({ absolute: false, sort: naturalSort }, options) 91 | const files: string[] = [] 92 | 93 | /** 94 | * Check to see if the root directory exists and ignore 95 | * error when "ignoreMissingRoot" is set to true 96 | */ 97 | try { 98 | await stat(normalizedLocation) 99 | } catch (error) { 100 | if (normalizedOptions.ignoreMissingRoot) { 101 | return [] 102 | } 103 | 104 | throw error 105 | } 106 | 107 | await readFiles(normalizedLocation, files, normalizedOptions, '') 108 | 109 | if (normalizedOptions.filter) { 110 | return files.filter(normalizedOptions.filter).sort(normalizedOptions.sort) 111 | } 112 | 113 | return files.sort(normalizedOptions.sort) 114 | } 115 | -------------------------------------------------------------------------------- /src/import_default.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { RuntimeException } from './exceptions/main.js' 11 | 12 | /** 13 | * Dynamically import a module and ensure it has a default export 14 | */ 15 | export async function importDefault( 16 | importFn: () => Promise, 17 | filePath?: string 18 | ): Promise { 19 | const moduleExports = await importFn() 20 | 21 | /** 22 | * Make sure a default export exists 23 | */ 24 | if (!('default' in moduleExports)) { 25 | const errorMessage = filePath 26 | ? `Missing "export default" in module "${filePath}"` 27 | : `Missing "export default" from lazy import "${importFn}"` 28 | 29 | throw new RuntimeException(errorMessage, { 30 | cause: { 31 | source: importFn, 32 | }, 33 | }) 34 | } 35 | 36 | return moduleExports.default as Promise 37 | } 38 | -------------------------------------------------------------------------------- /src/is_script_file.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { extname } from 'node:path' 11 | const JS_MODULES = ['.js', '.json', '.cjs', '.mjs'] 12 | 13 | /** 14 | * Returns `true` when file ends with `.js`, `.json` or 15 | * `.ts` but not `.d.ts`. 16 | */ 17 | export function isScriptFile(filePath: string) { 18 | const ext = extname(filePath) 19 | 20 | if (JS_MODULES.includes(ext)) { 21 | return true 22 | } 23 | 24 | if (ext === '.ts' && !filePath.endsWith('.d.ts')) { 25 | return true 26 | } 27 | 28 | return false 29 | } 30 | -------------------------------------------------------------------------------- /src/json/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { safeParse } from './safe_parse.js' 11 | import { safeStringify } from './safe_stringify.js' 12 | 13 | const json = { 14 | safeParse, 15 | safeStringify, 16 | } 17 | 18 | export default json 19 | -------------------------------------------------------------------------------- /src/json/safe_parse.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { parse } from 'secure-json-parse' 11 | import { JSONReviver } from '../types.js' 12 | 13 | /** 14 | * A drop-in replacement for JSON.parse with prototype poisoning protection. 15 | */ 16 | export function safeParse(jsonString: string, reviver?: JSONReviver): any { 17 | return parse(jsonString, reviver, { 18 | protoAction: 'remove', 19 | constructorAction: 'remove', 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/json/safe_stringify.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { configure } from 'safe-stable-stringify' 11 | import { JSONReplacer } from '../types.js' 12 | 13 | const stringify = configure({ 14 | bigint: false, 15 | circularValue: undefined, 16 | deterministic: false, 17 | }) 18 | 19 | /** 20 | * Replacer to handle custom data types. 21 | * 22 | * - Bigints are converted to string 23 | */ 24 | function jsonStringifyReplacer(replacer?: JSONReplacer): JSONReplacer { 25 | return function (key, value) { 26 | const val = replacer ? replacer.call(this, key, value) : value 27 | 28 | if (typeof val === 'bigint') { 29 | return val.toString() 30 | } 31 | 32 | return val 33 | } 34 | } 35 | 36 | /** 37 | * String Javascript values to a JSON string. Handles circular 38 | * references and bigints 39 | */ 40 | export function safeStringify( 41 | value: any, 42 | replacer?: JSONReplacer, 43 | space?: string | number 44 | ): string | undefined { 45 | return stringify(value, jsonStringifyReplacer(replacer), space) 46 | } 47 | -------------------------------------------------------------------------------- /src/message_builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import json from './json/main.js' 11 | import string from '@poppinss/string' 12 | 13 | /** 14 | * Message builder exposes an API to "JSON.stringify" values by 15 | * encoding purpose and expiry date inside them. 16 | * 17 | * The return value must be further encrypted to prevent tempering. 18 | */ 19 | export class MessageBuilder { 20 | #getExpiryDate(expiresIn?: string | number): undefined | Date { 21 | if (!expiresIn) { 22 | return undefined 23 | } 24 | 25 | const expiryMs = string.milliseconds.parse(expiresIn) 26 | return new Date(Date.now() + expiryMs) 27 | } 28 | 29 | /** 30 | * Returns a boolean telling, if message has been expired or not 31 | */ 32 | #isExpired(message: any) { 33 | if (!message.expiryDate) { 34 | return false 35 | } 36 | 37 | const expiryDate = new Date(message.expiryDate) 38 | return Number.isNaN(expiryDate.getTime()) || expiryDate < new Date() 39 | } 40 | 41 | /** 42 | * Builds a message by encoding expiry date and purpose inside it. 43 | */ 44 | build(message: any, expiresIn?: string | number, purpose?: string): string { 45 | const expiryDate = this.#getExpiryDate(expiresIn) 46 | return json.safeStringify({ message, purpose, expiryDate })! 47 | } 48 | 49 | /** 50 | * Verifies the message for expiry and purpose. 51 | */ 52 | verify(message: any, purpose?: string): null | T { 53 | const parsed = json.safeParse(message) 54 | 55 | /** 56 | * After JSON.parse we do not receive a valid object 57 | */ 58 | if (typeof parsed !== 'object' || !parsed) { 59 | return null 60 | } 61 | 62 | /** 63 | * Missing ".message" property 64 | */ 65 | if (!parsed.message) { 66 | return null 67 | } 68 | 69 | /** 70 | * Ensure purposes are same. 71 | */ 72 | if (parsed.purpose !== purpose) { 73 | return null 74 | } 75 | 76 | /** 77 | * Ensure isn't expired 78 | */ 79 | if (this.#isExpired(parsed)) { 80 | return null 81 | } 82 | 83 | return parsed.message 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/natural_sort.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /** 11 | * Perform natural sorting with "Array.sort()" method 12 | */ 13 | export function naturalSort(current: string, next: string) { 14 | return current.localeCompare(next, undefined, { numeric: true, sensitivity: 'base' }) 15 | } 16 | -------------------------------------------------------------------------------- /src/object_builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export * from '@poppinss/object-builder' 11 | -------------------------------------------------------------------------------- /src/safe_equal.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Buffer } from 'node:buffer' 11 | import { timingSafeEqual } from 'node:crypto' 12 | 13 | type BufferSafeValue = 14 | | ArrayBuffer 15 | | SharedArrayBuffer 16 | | number[] 17 | | string 18 | | { valueOf(): string | object } 19 | | { [Symbol.toPrimitive](hint: 'string'): string } 20 | 21 | /** 22 | * Compare two values to see if they are equal. The comparison is done in 23 | * a way to avoid timing-attacks. 24 | */ 25 | export function safeEqual( 26 | trustedValue: T, 27 | userInput: U 28 | ): boolean { 29 | if (typeof trustedValue === 'string' && typeof userInput === 'string') { 30 | /** 31 | * The length of the comparison value. 32 | */ 33 | const trustedLength = Buffer.byteLength(trustedValue) 34 | 35 | /** 36 | * Expected value 37 | */ 38 | const trustedValueBuffer = Buffer.alloc(trustedLength, 0, 'utf-8') 39 | trustedValueBuffer.write(trustedValue) 40 | 41 | /** 42 | * Actual value (taken from user input) 43 | */ 44 | const userValueBuffer = Buffer.alloc(trustedLength, 0, 'utf-8') 45 | userValueBuffer.write(userInput) 46 | 47 | /** 48 | * Ensure values are same and also have same length 49 | */ 50 | return ( 51 | timingSafeEqual(trustedValueBuffer, userValueBuffer) && 52 | trustedLength === Buffer.byteLength(userInput) 53 | ) 54 | } 55 | 56 | return timingSafeEqual( 57 | Buffer.from(trustedValue as ArrayBuffer | SharedArrayBuffer), 58 | Buffer.from(userInput as ArrayBuffer | SharedArrayBuffer) 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/secret.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | const REDACTED = '[redacted]' 11 | 12 | /** 13 | * Define a Secret value that hides itself from the logs or the console 14 | * statements. 15 | * 16 | * The idea is to prevent accidental leaking of sensitive information. 17 | * Idea borrowed from. 18 | * https://transcend.io/blog/keep-sensitive-values-out-of-your-logs-with-types 19 | */ 20 | export class Secret { 21 | /** The secret value */ 22 | #value: T 23 | #keyword: string 24 | 25 | constructor(value: T, redactedKeyword?: string) { 26 | this.#value = value 27 | this.#keyword = redactedKeyword || REDACTED 28 | } 29 | 30 | toJSON(): string { 31 | return this.#keyword 32 | } 33 | valueOf(): string { 34 | return this.#keyword 35 | } 36 | [Symbol.for('nodejs.util.inspect.custom')](): string { 37 | return this.#keyword 38 | } 39 | toLocaleString(): string { 40 | return this.#keyword 41 | } 42 | toString(): string { 43 | return this.#keyword 44 | } 45 | 46 | /** 47 | * Returns the original value 48 | */ 49 | release(): T { 50 | return this.#value 51 | } 52 | 53 | /** 54 | * Transform the original value and create a new 55 | * secret from it. 56 | */ 57 | map(transformFunc: (value: T) => R): Secret { 58 | return new Secret(transformFunc(this.#value)) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/slash.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /** 11 | * Copy-pasted from https://github.com/sindresorhus/slash/blob/main/index.d.ts 12 | * @credit - https://github.com/sindresorhus/slash 13 | */ 14 | 15 | /** 16 | * Convert Windows backslash paths to slash paths: `foo\\bar` ➔ `foo/bar`. 17 | * [Forward-slash paths can be used in Windows](http://superuser.com/a/176395/6877) as long as 18 | * they're not extended-length paths. 19 | * 20 | * @example 21 | * ``` 22 | * import path from 'node:path'; 23 | * import slash from 'slash'; 24 | * 25 | * const string = path.join('foo', 'bar'); 26 | * // Unix => foo/bar 27 | * // Windows => foo\\bar 28 | * 29 | * slash(string); 30 | * // Unix => foo/bar 31 | * // Windows => foo/bar 32 | * ``` 33 | */ 34 | export function slash(path: string): string { 35 | const isExtendedLengthPath = path.startsWith('\\\\?\\') 36 | if (isExtendedLengthPath) { 37 | return path 38 | } 39 | return path.replace(/\\/g, '/') 40 | } 41 | -------------------------------------------------------------------------------- /src/string/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@poppinss/string' 11 | export default string 12 | -------------------------------------------------------------------------------- /src/string/string_builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@poppinss/string/builder' 11 | 12 | export * from '@poppinss/string/builder' 13 | export default string 14 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | type PickKeysByValue = { [K in keyof T]: T[K] extends V ? K : never }[keyof T] 11 | export type OmitProperties = Omit> 12 | 13 | type ScanFsBaseOptions = { 14 | ignoreMissingRoot?: boolean 15 | filter?: (filePath: string, index: number) => boolean 16 | sort?: (current: string, next: string) => number 17 | } 18 | export type ImportAllFilesOptions = ScanFsBaseOptions & { 19 | /** 20 | * A custom method to transform collection keys 21 | */ 22 | transformKeys?: (keys: string[]) => string[] 23 | } 24 | 25 | export type ReadAllFilesOptions = ScanFsBaseOptions & { 26 | pathType?: 'relative' | 'unixRelative' | 'absolute' | 'unixAbsolute' | 'url' 27 | } 28 | 29 | export type JSONReplacer = (this: any, key: string, value: any) => any 30 | export type JSONReviver = (this: any, key: string, value: any) => any 31 | 32 | export type Constructor = new (...args: any[]) => any 33 | 34 | /** 35 | * Normalizes constructor to work with mixins. There is an open bug for mixins 36 | * to allow constructors other than `...args: any[]` 37 | * 38 | * https://github.com/microsoft/TypeScript/issues/37142 39 | */ 40 | export type NormalizeConstructor = { 41 | new (...args: any[]): InstanceType 42 | } & Omit 43 | 44 | declare const opaqueProp: unique symbol 45 | export type Opaque = T & { [opaqueProp]: K } 46 | -------------------------------------------------------------------------------- /test_helpers/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { normalize as pathNormalize } from 'node:path' 11 | 12 | export function normalize(filePath: string) { 13 | return pathNormalize(filePath) 14 | } 15 | -------------------------------------------------------------------------------- /tests/assert.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { assert, assertIsDefined, assertNotNull, assertUnreachable } from '../src/assert.js' 12 | 13 | test.group('assert', () => { 14 | test('throw exception when value is falsy', ({ expectTypeOf }) => { 15 | const value = false as string | false 16 | assert(value) 17 | 18 | expectTypeOf(value).toMatchTypeOf() 19 | }).throws('value is falsy') 20 | 21 | test('throw exception with custom message when value is falsy', ({ expectTypeOf }) => { 22 | const value = null as string | null 23 | assert(value, 'Value must be defined') 24 | 25 | expectTypeOf(value).toMatchTypeOf() 26 | }).throws('Value must be defined') 27 | 28 | test('do not throw exception when value is truthy', () => { 29 | assert(true) 30 | }) 31 | }) 32 | 33 | test.group('assertNotNull', () => { 34 | test('throw exception when value is null', ({ expectTypeOf }) => { 35 | const value = null as string | null 36 | assertNotNull(value) 37 | 38 | expectTypeOf(value).toMatchTypeOf() 39 | }).throws('unexpected null value') 40 | 41 | test('throw exception with custom message when value is null', ({ expectTypeOf }) => { 42 | const value = null as string | null 43 | assertNotNull(value, 'Cannot be null') 44 | 45 | expectTypeOf(value).toMatchTypeOf() 46 | }).throws('Cannot be null') 47 | 48 | test('do not throw when value is not null', ({ expectTypeOf }) => { 49 | const value = undefined as string | undefined | null 50 | assertNotNull(value) 51 | expectTypeOf(value).toMatchTypeOf() 52 | }) 53 | }) 54 | 55 | test.group('assertIsDefined', () => { 56 | test('throw exception when value is undefined', ({ expectTypeOf }) => { 57 | const value = undefined as string | undefined 58 | assertIsDefined(value) 59 | 60 | expectTypeOf(value).toMatchTypeOf() 61 | }).throws('unexpected undefined value') 62 | 63 | test('throw exception with custom message when value is undefined', ({ expectTypeOf }) => { 64 | const value = undefined as string | undefined 65 | assertIsDefined(value, 'Cannot be undefined') 66 | 67 | expectTypeOf(value).toMatchTypeOf() 68 | }).throws('Cannot be undefined') 69 | 70 | test('do not throw when value is not undefined', ({ expectTypeOf }) => { 71 | const value = null as string | undefined | null 72 | assertIsDefined(value) 73 | expectTypeOf(value).toMatchTypeOf() 74 | }) 75 | }) 76 | 77 | test.group('assertUnreachable', () => { 78 | test('throw exception when method is called', () => { 79 | assertUnreachable() 80 | }).throws('unreachable code executed: undefined') 81 | }) 82 | -------------------------------------------------------------------------------- /tests/base64.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { base64 } from '../src/base64.js' 12 | 13 | const base64String = 14 | 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==' 15 | 16 | const base64UrlEncodedString = 17 | 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn-AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq-wsbKztLW2t7i5uru8vb6_wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t_g4eLj5OXm5-jp6uvs7e7v8PHy8_T19vf4-fr7_P3-_w' 18 | 19 | const binaryData = unescape( 20 | '%00%01%02%03%04%05%06%07%08%09%0A%0B%0C%0D%0E%0F%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%20%21%22%23%24%25%26%27%28%29*+%2C-./0123456789%3A%3B%3C%3D%3E%3F@ABCDEFGHIJKLMNOPQRSTUVWXYZ%5B%5C%5D%5E_%60abcdefghijklmnopqrstuvwxyz%7B%7C%7D%7E%7F%80%81%82%83%84%85%86%87%88%89%8A%8B%8C%8D%8E%8F%90%91%92%93%94%95%96%97%98%99%9A%9B%9C%9D%9E%9F%A0%A1%A2%A3%A4%A5%A6%A7%A8%A9%AA%AB%AC%AD%AE%AF%B0%B1%B2%B3%B4%B5%B6%B7%B8%B9%BA%BB%BC%BD%BE%BF%C0%C1%C2%C3%C4%C5%C6%C7%C8%C9%CA%CB%CC%CD%CE%CF%D0%D1%D2%D3%D4%D5%D6%D7%D8%D9%DA%DB%DC%DD%DE%DF%E0%E1%E2%E3%E4%E5%E6%E7%E8%E9%EA%EB%EC%ED%EE%EF%F0%F1%F2%F3%F4%F5%F6%F7%F8%F9%FA%FB%FC%FD%FE%FF' 21 | ) 22 | 23 | test.group('Base 64 | encode', () => { 24 | test('encode binary data', ({ assert }) => { 25 | assert.equal(base64.urlEncode(binaryData, 'binary'), base64UrlEncodedString) 26 | assert.equal(base64.encode(binaryData, 'binary'), base64String) 27 | }) 28 | 29 | test('encode hex value', ({ assert }) => { 30 | const value = Buffer.from(binaryData, 'binary').toString('hex') 31 | 32 | assert.equal(base64.urlEncode(value, 'hex'), base64UrlEncodedString) 33 | assert.equal(base64.encode(value, 'hex'), base64String) 34 | }) 35 | 36 | test('encode buffer', ({ assert }) => { 37 | const value = Buffer.from(binaryData, 'binary') 38 | 39 | assert.equal(base64.urlEncode(value), base64UrlEncodedString) 40 | assert.equal(base64.encode(value), base64String) 41 | }) 42 | 43 | test('encode ArrayBuffer', ({ assert }) => { 44 | const value = new ArrayBuffer(10) 45 | 46 | assert.equal(base64.urlEncode(value), 'AAAAAAAAAAAAAA') 47 | assert.equal(base64.encode(value), 'AAAAAAAAAAAAAA==') 48 | }) 49 | }) 50 | 51 | test.group('Base 64 | decode', () => { 52 | test('decode as binary value', ({ assert }) => { 53 | assert.equal(base64.urlDecode(base64UrlEncodedString, 'binary'), binaryData) 54 | assert.equal(base64.decode(base64String, 'binary'), binaryData) 55 | }) 56 | 57 | test('decode as hex value', ({ assert }) => { 58 | const expectedValue = Buffer.from(binaryData, 'binary').toString('hex') 59 | 60 | assert.equal(base64.urlDecode(base64UrlEncodedString, 'hex'), expectedValue) 61 | assert.equal(base64.decode(base64String, 'hex'), expectedValue) 62 | }) 63 | 64 | test('decode as plain string', ({ assert }) => { 65 | const value = 'hello+world' 66 | assert.equal(base64.urlDecode(base64.urlEncode(value)), value) 67 | assert.equal(base64.decode(base64.encode(value)), value) 68 | }) 69 | 70 | test('return null when unable to decode value (strict: false)', ({ assert }) => { 71 | const value = 'hello+world' 72 | assert.isNull(base64.urlDecode(value, 'utf-8')) 73 | assert.isNull(base64.decode(value, 'utf-8')) 74 | }) 75 | 76 | test('throw error when unable to decode value (strict: true)', ({ assert }) => { 77 | const value = 'hello+world' 78 | assert.throws(() => base64.urlDecode(value, 'utf-8', true), 'Cannot urlDecode malformed value') 79 | assert.throws(() => base64.decode(value, 'utf-8', true), 'Cannot decode malformed value') 80 | }) 81 | 82 | test('decode a buffer', ({ assert }) => { 83 | assert.equal(base64.urlDecode(Buffer.from('hello world'), 'utf-8'), 'hello world') 84 | assert.equal(base64.decode(Buffer.from('hello world'), 'utf-8'), 'hello world') 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /tests/compose.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { compose } from '../src/compose.js' 12 | import { NormalizeConstructor } from '../src/types.js' 13 | 14 | test.group('compose', () => { 15 | test('apply multiple mixins to a given base class', async ({ assert, expectTypeOf }) => { 16 | class BaseClass { 17 | constructor(public username: string) {} 18 | static foo = 'bar' 19 | } 20 | 21 | const UserWithEmailMixin = >( 22 | superclass: T 23 | ) => { 24 | return class UserEmail extends superclass { 25 | declare email: string 26 | static validatesEmail = false 27 | } 28 | } 29 | 30 | const UserWithPasswordMixin = >( 31 | superclass: T 32 | ) => { 33 | return class UserPassword extends superclass { 34 | declare password: string 35 | static validatesPassword = false 36 | } 37 | } 38 | 39 | class User extends compose(BaseClass, UserWithEmailMixin, UserWithPasswordMixin) { 40 | constructor(public username: string) { 41 | super(username) 42 | } 43 | } 44 | 45 | expectTypeOf(User).toMatchTypeOf<{ 46 | validatesPassword: boolean 47 | validatesEmail: boolean 48 | foo: string 49 | }>() 50 | 51 | assert.isFalse(User.validatesPassword) 52 | assert.isFalse(User.validatesEmail) 53 | assert.equal(User.foo, 'bar') 54 | 55 | const user = new User('virk') 56 | expectTypeOf(user).toMatchTypeOf<{ 57 | username: string 58 | email: string 59 | password: string 60 | }>() 61 | 62 | user.email = 'virk@adonisjs.com' 63 | user.password = 'secret' 64 | 65 | assert.equal(user.username, 'virk') 66 | assert.equal(user.email, 'virk@adonisjs.com') 67 | assert.equal(user.password, 'secret') 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /tests/define_static_property.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { defineStaticProperty } from '../src/define_static_property.js' 12 | 13 | test.group('Define static property', () => { 14 | test('inherit property from the base class', ({ assert }) => { 15 | class Base { 16 | static hooks: any = {} 17 | 18 | static boot() { 19 | defineStaticProperty(this, 'hooks', { 20 | initialValue: {}, 21 | strategy: 'inherit', 22 | }) 23 | } 24 | } 25 | 26 | class Main extends Base {} 27 | assert.deepEqual(Main.hooks, {}) 28 | 29 | /** 30 | * The native inheritance shares the property by reference 31 | */ 32 | assert.strictEqual(Main.hooks, Base.hooks) 33 | 34 | /** 35 | * Reference broken 36 | */ 37 | Main.boot() 38 | assert.notStrictEqual(Main.hooks, Base.hooks) 39 | }) 40 | 41 | test('multiple sub-class should maintain their own copy of base value', ({ assert }) => { 42 | class Base { 43 | static hooks: any = {} 44 | 45 | static boot() { 46 | defineStaticProperty(this, 'hooks', { 47 | initialValue: {}, 48 | strategy: 'inherit', 49 | }) 50 | } 51 | } 52 | 53 | class MyBase extends Base {} 54 | MyBase.boot() 55 | MyBase.hooks.run = true 56 | 57 | class Main extends MyBase {} 58 | Main.boot() 59 | Main.hooks.jump = true 60 | 61 | assert.deepEqual(Main.hooks, { run: true, jump: true }) 62 | assert.deepEqual(MyBase.hooks, { run: true }) 63 | assert.notStrictEqual(Main.hooks, MyBase.hooks) 64 | }) 65 | 66 | test('define property without any inheritance', ({ assert }) => { 67 | class Base { 68 | static hooks: any = { initial: true } 69 | 70 | static boot() { 71 | defineStaticProperty(this, 'hooks', { 72 | initialValue: {}, 73 | strategy: 'define', 74 | }) 75 | } 76 | } 77 | 78 | class MyBase extends Base {} 79 | MyBase.boot() 80 | MyBase.hooks.run = true 81 | 82 | class Main extends MyBase {} 83 | Main.boot() 84 | Main.hooks.jump = true 85 | 86 | assert.deepEqual(Main.hooks, { jump: true }) 87 | assert.deepEqual(MyBase.hooks, { run: true }) 88 | assert.notStrictEqual(Main.hooks, MyBase.hooks) 89 | }) 90 | 91 | test('handle deep inheritance', ({ assert }) => { 92 | class Base { 93 | static hooks: any = { initial: true } 94 | 95 | static boot() { 96 | defineStaticProperty(this, 'hooks', { 97 | initialValue: {}, 98 | strategy: 'inherit', 99 | }) 100 | } 101 | } 102 | 103 | class MyBase extends Base {} 104 | MyBase.boot() 105 | MyBase.hooks.run = true 106 | 107 | class MySuperBase extends MyBase {} 108 | MySuperBase.boot() 109 | MySuperBase.hooks.crawl = true 110 | 111 | class MyAppBase extends MySuperBase {} 112 | MyAppBase.boot() 113 | MyAppBase.hooks.hide = true 114 | 115 | class Main extends MyAppBase {} 116 | Main.boot() 117 | Main.hooks.jump = true 118 | 119 | assert.deepEqual(Main.hooks, { run: true, jump: true, crawl: true, hide: true, initial: true }) 120 | assert.deepEqual(MyAppBase.hooks, { run: true, crawl: true, hide: true, initial: true }) 121 | assert.deepEqual(MySuperBase.hooks, { run: true, crawl: true, initial: true }) 122 | assert.deepEqual(MyBase.hooks, { run: true, initial: true }) 123 | }) 124 | 125 | test('handle cross inheritance', ({ assert }) => { 126 | class Base { 127 | static hooks: any = { initial: true } 128 | 129 | static boot() { 130 | defineStaticProperty(this, 'hooks', { 131 | initialValue: {}, 132 | strategy: 'inherit', 133 | }) 134 | } 135 | } 136 | 137 | class MyBase extends Base {} 138 | MyBase.boot() 139 | MyBase.hooks.run = true 140 | 141 | class MySuperBase extends MyBase {} 142 | MySuperBase.boot() 143 | MySuperBase.hooks.crawl = true 144 | 145 | class MyAppBase extends MyBase {} 146 | MyAppBase.boot() 147 | MyAppBase.hooks.hide = true 148 | 149 | class Main extends MyAppBase {} 150 | Main.boot() 151 | Main.hooks.jump = true 152 | 153 | assert.deepEqual(Main.hooks, { run: true, jump: true, hide: true, initial: true }) 154 | assert.deepEqual(MyAppBase.hooks, { run: true, hide: true, initial: true }) 155 | assert.deepEqual(MySuperBase.hooks, { run: true, crawl: true, initial: true }) 156 | assert.deepEqual(MyBase.hooks, { run: true, initial: true }) 157 | }) 158 | 159 | test('allow overwriting the defined property', ({ assert }) => { 160 | class Base { 161 | static hooks: any 162 | 163 | static boot() { 164 | defineStaticProperty(this, 'hooks', { 165 | initialValue: {}, 166 | strategy: 'inherit', 167 | }) 168 | } 169 | } 170 | 171 | class MyBase extends Base {} 172 | MyBase.boot() 173 | MyBase.hooks.run = true 174 | 175 | class Main extends MyBase {} 176 | Main.boot() 177 | Main.hooks = { run: false, jump: true } 178 | 179 | assert.deepEqual(Main.hooks, { run: false, jump: true }) 180 | assert.deepEqual(MyBase.hooks, { run: true }) 181 | }) 182 | 183 | test('define a custom strategy function for defining the merge value', ({ assert }) => { 184 | class Base { 185 | static hooks: { 186 | before: Set 187 | after: Set 188 | } 189 | 190 | static boot() { 191 | defineStaticProperty(this, 'hooks', { 192 | initialValue: { 193 | before: new Set(), 194 | after: new Set(), 195 | }, 196 | strategy: (value) => { 197 | return { 198 | before: value.before, 199 | after: value.after, 200 | } 201 | }, 202 | }) 203 | } 204 | } 205 | 206 | Base.hooks = { 207 | before: new Set(['1']), 208 | after: new Set(['2']), 209 | } 210 | class Main extends Base {} 211 | Main.boot() 212 | 213 | assert.deepEqual(Main.hooks.before, new Set(['1'])) 214 | assert.deepEqual(Main.hooks.after, new Set(['2'])) 215 | }) 216 | 217 | test('allow inheritance with abstract base class', ({ assert }) => { 218 | abstract class Base { 219 | static hooks: any 220 | 221 | static boot() { 222 | defineStaticProperty(this, 'hooks', { 223 | initialValue: {}, 224 | strategy: 'inherit', 225 | }) 226 | } 227 | } 228 | 229 | class Main extends Base {} 230 | Main.boot() 231 | 232 | assert.deepEqual(Main.hooks, {}) 233 | }) 234 | }) 235 | -------------------------------------------------------------------------------- /tests/flatten.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { flatten } from '../src/flatten.js' 12 | 13 | test.group('flatten', () => { 14 | test('flatten nested object', ({ assert }) => { 15 | assert.deepEqual( 16 | flatten({ 17 | a: 'hi', 18 | b: { 19 | a: null, 20 | b: ['foo', '', null, 'bar'], 21 | d: 'hello', 22 | e: { 23 | a: 'yo', 24 | b: undefined, 25 | c: 'sup', 26 | d: 0, 27 | f: [ 28 | { foo: 123, bar: 123 }, 29 | { foo: 465, bar: 456 }, 30 | ], 31 | }, 32 | }, 33 | c: 'world', 34 | }), 35 | { 36 | 'a': 'hi', 37 | 'b.b.0': 'foo', 38 | 'b.b.1': '', 39 | 'b.b.3': 'bar', 40 | 'b.d': 'hello', 41 | 'b.e.a': 'yo', 42 | 'b.e.c': 'sup', 43 | 'b.e.d': 0, 44 | 'b.e.f.0.foo': 123, 45 | 'b.e.f.0.bar': 123, 46 | 'b.e.f.1.foo': 465, 47 | 'b.e.f.1.bar': 456, 48 | 'c': 'world', 49 | } 50 | ) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /tests/fs_import_all.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'node:path' 11 | import { test } from '@japa/runner' 12 | import { ensureDir, remove, outputFile } from 'fs-extra' 13 | 14 | import { joinToURL } from '../index.js' 15 | import { fsImportAll } from '../src/fs_import_all.js' 16 | 17 | const BASE_PATH = joinToURL(import.meta.url, 'app') 18 | 19 | test.group('importAll', (group) => { 20 | group.tap((t) => t.skip(import.meta.url.endsWith('.js'), 'Need ts-node')) 21 | 22 | group.each.setup(async () => { 23 | await ensureDir(BASE_PATH) 24 | return () => remove(BASE_PATH) 25 | }) 26 | 27 | test('import script files from the disk', async ({ assert }) => { 28 | await outputFile( 29 | join(BASE_PATH, 'app.ts'), 30 | `export default { 31 | loaded: true 32 | }` 33 | ) 34 | 35 | await outputFile(join(BASE_PATH, 'server.ts'), 'export const loaded = true') 36 | await outputFile(join(BASE_PATH, 'config.cjs'), 'module.exports = { loaded: true }') 37 | await outputFile(join(BASE_PATH, 'main.json'), '{ "loaded": true }') 38 | 39 | const collection = await fsImportAll(BASE_PATH) 40 | assert.deepEqual(collection, { 41 | app: { loaded: true }, 42 | server: { loaded: true }, 43 | config: { loaded: true }, 44 | main: { loaded: true }, 45 | }) 46 | }) 47 | 48 | test('import script files from a URL', async ({ assert }) => { 49 | await outputFile( 50 | join(BASE_PATH, 'app.ts'), 51 | `export default { 52 | loaded: true 53 | }` 54 | ) 55 | 56 | await outputFile(join(BASE_PATH, 'server.ts'), 'export const loaded = true') 57 | await outputFile(join(BASE_PATH, 'config.cjs'), 'module.exports = { loaded: true }') 58 | await outputFile(join(BASE_PATH, 'main.json'), '{ "loaded": true }') 59 | 60 | const collection = await fsImportAll(new URL('./app', import.meta.url)) 61 | assert.deepEqual(collection, { 62 | app: { loaded: true }, 63 | server: { loaded: true }, 64 | config: { loaded: true }, 65 | main: { loaded: true }, 66 | }) 67 | }) 68 | 69 | test('import files recursively', async ({ assert }) => { 70 | await outputFile( 71 | join(BASE_PATH, 'ts/app.ts'), 72 | `export default { 73 | loaded: true 74 | }` 75 | ) 76 | 77 | await outputFile(join(BASE_PATH, 'ts/server.ts'), 'export const loaded = true') 78 | await outputFile(join(BASE_PATH, 'js/config.cjs'), 'module.exports = { loaded: true }') 79 | await outputFile(join(BASE_PATH, 'json/main.json'), '{ "loaded": true }') 80 | 81 | const collection = await fsImportAll(BASE_PATH) 82 | assert.deepEqual(collection, { 83 | ts: { 84 | app: { loaded: true }, 85 | server: { loaded: true }, 86 | }, 87 | js: { 88 | config: { loaded: true }, 89 | }, 90 | json: { 91 | main: { loaded: true }, 92 | }, 93 | }) 94 | }) 95 | 96 | test('ignore .d.ts files', async ({ assert }) => { 97 | await outputFile( 98 | join(BASE_PATH, 'ts/app.ts'), 99 | `export default { 100 | loaded: true 101 | }` 102 | ) 103 | 104 | await outputFile(join(BASE_PATH, 'ts/server.d.ts'), 'export const loaded = true') 105 | await outputFile(join(BASE_PATH, 'js/config.cjs'), 'module.exports = { loaded: true }') 106 | await outputFile(join(BASE_PATH, 'json/main.json'), '{ "loaded": true }') 107 | 108 | const collection = await fsImportAll(BASE_PATH) 109 | assert.deepEqual(collection, { 110 | ts: { 111 | app: { loaded: true }, 112 | }, 113 | js: { 114 | config: { loaded: true }, 115 | }, 116 | json: { 117 | main: { loaded: true }, 118 | }, 119 | }) 120 | }) 121 | 122 | test('raise error when root directory is missing', async ({ assert }) => { 123 | const fn = () => fsImportAll(join(BASE_PATH, 'foo')) 124 | await assert.rejects(fn, `ENOENT: no such file or directory, stat '${join(BASE_PATH, 'foo')}'`) 125 | }) 126 | 127 | test('allow missing root directory', async ({ assert }) => { 128 | const collection = await fsImportAll(join(BASE_PATH, 'foo'), { ignoreMissingRoot: true }) 129 | assert.deepEqual(collection, {}) 130 | }) 131 | 132 | test('define custom filter', async ({ assert }) => { 133 | await outputFile( 134 | join(BASE_PATH, 'app.ts'), 135 | `export default { 136 | loaded: true 137 | }` 138 | ) 139 | 140 | await outputFile(join(BASE_PATH, 'server.ts'), 'export const loaded = true') 141 | await outputFile(join(BASE_PATH, 'config.js'), 'module.exports = { loaded: true }') 142 | await outputFile(join(BASE_PATH, 'main.json'), '{ "loaded": true }') 143 | 144 | const collection = await fsImportAll(BASE_PATH, { 145 | filter: (filePath) => filePath.endsWith('.json'), 146 | }) 147 | 148 | assert.deepEqual(collection, { 149 | main: { loaded: true }, 150 | }) 151 | }) 152 | 153 | test('define key name for the file', async ({ assert }) => { 154 | await outputFile(join(BASE_PATH, 'foo/bar/main.json'), '{ "loaded": true }') 155 | 156 | const collection = await fsImportAll(BASE_PATH, { 157 | transformKeys: (keys) => { 158 | return keys.map((key) => key.toUpperCase()) 159 | }, 160 | }) 161 | 162 | assert.deepEqual(collection, { 163 | FOO: { 164 | BAR: { 165 | MAIN: { loaded: true }, 166 | }, 167 | }, 168 | }) 169 | }) 170 | }) 171 | -------------------------------------------------------------------------------- /tests/fs_read_all.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { join } from 'node:path' 12 | import { ensureDir, remove, outputFile } from 'fs-extra' 13 | 14 | import { joinToURL } from '../index.js' 15 | import { slash } from '../src/slash.js' 16 | import { fsReadAll } from '../src/fs_read_all.js' 17 | import { normalize } from '../test_helpers/index.js' 18 | 19 | const BASE_PATH = joinToURL(import.meta.url, 'app') 20 | 21 | test.group('FS read all | relative paths', (group) => { 22 | group.each.setup(async () => { 23 | await ensureDir(BASE_PATH) 24 | return () => remove(BASE_PATH) 25 | }) 26 | 27 | test('get a list of all files from a directory', async ({ assert, expectTypeOf }) => { 28 | await outputFile(join(BASE_PATH, 'app.ts'), '') 29 | await outputFile(join(BASE_PATH, 'server.ts'), '') 30 | await outputFile(join(BASE_PATH, 'config.js'), '') 31 | await outputFile(join(BASE_PATH, 'main.json'), '') 32 | 33 | const files = await fsReadAll(BASE_PATH) 34 | 35 | expectTypeOf(files).toEqualTypeOf() 36 | assert.deepEqual(files, ['app.ts', 'config.js', 'main.json', 'server.ts'].map(normalize)) 37 | }) 38 | 39 | test('get a list of all files from a URL', async ({ assert, expectTypeOf }) => { 40 | await outputFile(join(BASE_PATH, 'app.ts'), '') 41 | await outputFile(join(BASE_PATH, 'server.ts'), '') 42 | await outputFile(join(BASE_PATH, 'config.js'), '') 43 | await outputFile(join(BASE_PATH, 'main.json'), '') 44 | 45 | const files = await fsReadAll(new URL('./app', import.meta.url)) 46 | 47 | expectTypeOf(files).toEqualTypeOf() 48 | assert.deepEqual(files, ['app.ts', 'config.js', 'main.json', 'server.ts'].map(normalize)) 49 | }) 50 | 51 | test('recursively get a list of all files from a directory', async ({ assert, expectTypeOf }) => { 52 | await outputFile(join(BASE_PATH, 'app.ts'), '') 53 | await outputFile(join(BASE_PATH, 'app/server.ts'), '') 54 | await outputFile(join(BASE_PATH, 'config/config.js'), '') 55 | await outputFile(join(BASE_PATH, 'config/main.json'), '') 56 | 57 | const files = await fsReadAll(BASE_PATH) 58 | 59 | expectTypeOf(files).toEqualTypeOf() 60 | assert.deepEqual( 61 | files, 62 | ['app.ts', 'app/server.ts', 'config/config.js', 'config/main.json'].map(normalize) 63 | ) 64 | }) 65 | 66 | test('ignore dot files and directories', async ({ assert, expectTypeOf }) => { 67 | await outputFile(join(BASE_PATH, 'app.ts'), '') 68 | await outputFile(join(BASE_PATH, '.gitignore'), '') 69 | await outputFile(join(BASE_PATH, 'app/.gitignore'), '') 70 | await outputFile(join(BASE_PATH, '.github/workflow.yaml'), '') 71 | await outputFile(join(BASE_PATH, 'app/server.ts'), '') 72 | await outputFile(join(BASE_PATH, 'config/config.js'), '') 73 | await outputFile(join(BASE_PATH, 'config/main.json'), '') 74 | 75 | const files = await fsReadAll(BASE_PATH) 76 | 77 | expectTypeOf(files).toEqualTypeOf() 78 | assert.deepEqual( 79 | files, 80 | ['app.ts', 'app/server.ts', 'config/config.js', 'config/main.json'].map(normalize) 81 | ) 82 | }) 83 | 84 | test('apply filter to ignore certain files', async ({ assert, expectTypeOf }) => { 85 | await outputFile(join(BASE_PATH, 'app.ts'), '') 86 | await outputFile(join(BASE_PATH, '.gitignore'), '') 87 | await outputFile(join(BASE_PATH, 'app/.gitignore'), '') 88 | await outputFile(join(BASE_PATH, '.github/workflow.yaml'), '') 89 | await outputFile(join(BASE_PATH, 'app/server.ts'), '') 90 | await outputFile(join(BASE_PATH, 'config/config.js'), '') 91 | await outputFile(join(BASE_PATH, 'config/main.json'), '') 92 | 93 | const files = await fsReadAll(BASE_PATH, { 94 | filter: (filePath) => filePath.endsWith('.ts'), 95 | }) 96 | 97 | expectTypeOf(files).toEqualTypeOf() 98 | assert.deepEqual(files, ['app.ts', 'app/server.ts'].map(normalize)) 99 | }) 100 | 101 | test('apply custom sort', async ({ assert, expectTypeOf }) => { 102 | await outputFile(join(BASE_PATH, 'app.ts'), '') 103 | await outputFile(join(BASE_PATH, '.gitignore'), '') 104 | await outputFile(join(BASE_PATH, 'app/.gitignore'), '') 105 | await outputFile(join(BASE_PATH, '.github/workflow.yaml'), '') 106 | await outputFile(join(BASE_PATH, 'app/server.ts'), '') 107 | await outputFile(join(BASE_PATH, 'config/config.js'), '') 108 | await outputFile(join(BASE_PATH, 'config/main.json'), '') 109 | 110 | const files = await fsReadAll(BASE_PATH, { 111 | sort: (current, next) => { 112 | if (next < current) { 113 | return -1 114 | } 115 | 116 | return 0 117 | }, 118 | }) 119 | 120 | expectTypeOf(files).toEqualTypeOf() 121 | assert.deepEqual( 122 | files, 123 | ['config/main.json', 'config/config.js', 'app/server.ts', 'app.ts'].map(normalize) 124 | ) 125 | }) 126 | 127 | test('get unix paths', async ({ assert, expectTypeOf }) => { 128 | await outputFile(join(BASE_PATH, 'app.ts'), '') 129 | await outputFile(join(BASE_PATH, 'app/server.ts'), '') 130 | await outputFile(join(BASE_PATH, 'config/config.js'), '') 131 | await outputFile(join(BASE_PATH, 'config/main.json'), '') 132 | 133 | const files = await fsReadAll(BASE_PATH, { pathType: 'unixRelative' }) 134 | 135 | expectTypeOf(files).toEqualTypeOf() 136 | assert.deepEqual(files, ['app.ts', 'app/server.ts', 'config/config.js', 'config/main.json']) 137 | }) 138 | }) 139 | 140 | test.group('FS read all | absolute paths', (group) => { 141 | group.each.setup(async () => { 142 | await ensureDir(BASE_PATH) 143 | return () => remove(BASE_PATH) 144 | }) 145 | 146 | test('get a list of all files from a directory', async ({ assert, expectTypeOf }) => { 147 | await outputFile(join(BASE_PATH, 'app.ts'), '') 148 | await outputFile(join(BASE_PATH, 'server.ts'), '') 149 | await outputFile(join(BASE_PATH, 'config.js'), '') 150 | await outputFile(join(BASE_PATH, 'main.json'), '') 151 | 152 | const files = await fsReadAll(BASE_PATH, { 153 | pathType: 'absolute', 154 | }) 155 | 156 | expectTypeOf(files).toEqualTypeOf() 157 | assert.deepEqual( 158 | files, 159 | ['app.ts', 'config.js', 'main.json', 'server.ts'].map((filePath) => 160 | join(BASE_PATH, normalize(filePath)) 161 | ) 162 | ) 163 | }) 164 | 165 | test('recursively get a list of all files from a directory', async ({ assert, expectTypeOf }) => { 166 | await outputFile(join(BASE_PATH, 'app.ts'), '') 167 | await outputFile(join(BASE_PATH, 'app/server.ts'), '') 168 | await outputFile(join(BASE_PATH, 'config/config.js'), '') 169 | await outputFile(join(BASE_PATH, 'config/main.json'), '') 170 | 171 | const files = await fsReadAll(BASE_PATH, { 172 | pathType: 'absolute', 173 | }) 174 | 175 | expectTypeOf(files).toEqualTypeOf() 176 | assert.deepEqual( 177 | files, 178 | ['app.ts', 'app/server.ts', 'config/config.js', 'config/main.json'].map((filePath) => 179 | join(BASE_PATH, normalize(filePath)) 180 | ) 181 | ) 182 | }) 183 | 184 | test('ignore dot files and directories', async ({ assert, expectTypeOf }) => { 185 | await outputFile(join(BASE_PATH, 'app.ts'), '') 186 | await outputFile(join(BASE_PATH, '.gitignore'), '') 187 | await outputFile(join(BASE_PATH, 'app/.gitignore'), '') 188 | await outputFile(join(BASE_PATH, '.github/workflow.yaml'), '') 189 | await outputFile(join(BASE_PATH, 'app/server.ts'), '') 190 | await outputFile(join(BASE_PATH, 'config/config.js'), '') 191 | await outputFile(join(BASE_PATH, 'config/main.json'), '') 192 | 193 | const files = await fsReadAll(BASE_PATH, { 194 | pathType: 'absolute', 195 | }) 196 | 197 | expectTypeOf(files).toEqualTypeOf() 198 | assert.deepEqual( 199 | files, 200 | ['app.ts', 'app/server.ts', 'config/config.js', 'config/main.json'].map((filePath) => 201 | join(BASE_PATH, normalize(filePath)) 202 | ) 203 | ) 204 | }) 205 | 206 | test('apply filter to ignore certain files', async ({ assert, expectTypeOf }) => { 207 | await outputFile(join(BASE_PATH, 'app.ts'), '') 208 | await outputFile(join(BASE_PATH, '.gitignore'), '') 209 | await outputFile(join(BASE_PATH, 'app/.gitignore'), '') 210 | await outputFile(join(BASE_PATH, '.github/workflow.yaml'), '') 211 | await outputFile(join(BASE_PATH, 'app/server.ts'), '') 212 | await outputFile(join(BASE_PATH, 'config/config.js'), '') 213 | await outputFile(join(BASE_PATH, 'config/main.json'), '') 214 | 215 | const files = await fsReadAll(BASE_PATH, { 216 | pathType: 'absolute', 217 | filter: (filePath) => filePath.endsWith('.ts'), 218 | }) 219 | 220 | expectTypeOf(files).toEqualTypeOf() 221 | assert.deepEqual( 222 | files, 223 | ['app.ts', 'app/server.ts'].map((filePath) => join(BASE_PATH, normalize(filePath))) 224 | ) 225 | }) 226 | 227 | test('apply custom sort', async ({ assert, expectTypeOf }) => { 228 | await outputFile(join(BASE_PATH, 'app.ts'), '') 229 | await outputFile(join(BASE_PATH, '.gitignore'), '') 230 | await outputFile(join(BASE_PATH, 'app/.gitignore'), '') 231 | await outputFile(join(BASE_PATH, '.github/workflow.yaml'), '') 232 | await outputFile(join(BASE_PATH, 'app/server.ts'), '') 233 | await outputFile(join(BASE_PATH, 'config/config.js'), '') 234 | await outputFile(join(BASE_PATH, 'config/main.json'), '') 235 | 236 | const files = await fsReadAll(BASE_PATH, { 237 | pathType: 'absolute', 238 | sort: (current, next) => { 239 | if (next < current) { 240 | return -1 241 | } 242 | 243 | return 0 244 | }, 245 | }) 246 | 247 | expectTypeOf(files).toEqualTypeOf() 248 | assert.deepEqual( 249 | files, 250 | ['config/main.json', 'config/config.js', 'app/server.ts', 'app.ts'].map((filePath) => 251 | join(BASE_PATH, normalize(filePath)) 252 | ) 253 | ) 254 | }) 255 | 256 | test('get unix paths', async ({ assert, expectTypeOf }) => { 257 | await outputFile(join(BASE_PATH, 'app.ts'), '') 258 | await outputFile(join(BASE_PATH, 'app/server.ts'), '') 259 | await outputFile(join(BASE_PATH, 'config/config.js'), '') 260 | await outputFile(join(BASE_PATH, 'config/main.json'), '') 261 | 262 | const files = await fsReadAll(BASE_PATH, { 263 | pathType: 'unixAbsolute', 264 | }) 265 | 266 | expectTypeOf(files).toEqualTypeOf() 267 | assert.deepEqual( 268 | files, 269 | ['app.ts', 'app/server.ts', 'config/config.js', 'config/main.json'].map( 270 | (filePath) => `${slash(BASE_PATH)}/${filePath}` 271 | ) 272 | ) 273 | }) 274 | }) 275 | -------------------------------------------------------------------------------- /tests/import_default.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { importDefault } from '../index.js' 12 | 13 | test.group('Import default', () => { 14 | test('raise error when import function does not return a default export', async ({ assert }) => { 15 | await assert.rejects(async () => { 16 | await importDefault(async () => { 17 | return { 18 | foo: 'bar', 19 | } 20 | }) 21 | }, /Missing "export default" from lazy import/) 22 | }) 23 | 24 | test('print error with filePath when specified', async ({ assert }) => { 25 | await assert.rejects( 26 | () => 27 | importDefault(async () => { 28 | return { 29 | foo: 'bar', 30 | } 31 | }, './foo.ts'), 32 | 'Missing "export default" in module "./foo.ts"' 33 | ) 34 | }) 35 | 36 | test('get default export value from a module', async ({ assert, expectTypeOf }) => { 37 | const value = await importDefault(async () => { 38 | return { 39 | default: { 40 | foo: 'bar', 41 | }, 42 | } 43 | }, './foo.ts') 44 | 45 | expectTypeOf(value).toMatchTypeOf<{ foo: string }>() 46 | assert.deepEqual(value, { 47 | foo: 'bar', 48 | }) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /tests/lodash.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import lodash from '@poppinss/utils/lodash' 12 | 13 | test.group('Lodash', () => { 14 | test('pick method', ({ assert }) => { 15 | assert.deepEqual(lodash.pick({ username: 'virk', email: 'virk@adonisjs.com' }, ['username']), { 16 | username: 'virk', 17 | }) 18 | }) 19 | 20 | test('omit method', ({ assert }) => { 21 | assert.deepEqual(lodash.omit({ username: 'virk', email: 'virk@adonisjs.com' }, ['username']), { 22 | email: 'virk@adonisjs.com', 23 | }) 24 | }) 25 | 26 | test('has method', ({ assert }) => { 27 | assert.isTrue(lodash.has({ username: 'virk', email: 'virk@adonisjs.com' }, ['username'])) 28 | assert.isFalse(lodash.has({ username: 'virk', email: 'virk@adonisjs.com' }, ['age'])) 29 | }) 30 | 31 | test('get method', ({ assert }) => { 32 | assert.equal(lodash.get({ username: 'virk', email: 'virk@adonisjs.com' }, 'username'), 'virk') 33 | assert.equal(lodash.get({ username: 'virk', email: 'virk@adonisjs.com' }, 'age'), undefined) 34 | assert.equal(lodash.get({ username: 'virk', email: 'virk@adonisjs.com' }, 'age', 28), 28) 35 | }) 36 | 37 | test('set method', ({ assert }) => { 38 | const collection = { username: 'virk', email: 'virk@adonisjs.com' } 39 | lodash.set(collection, 'username', 'nikk') 40 | assert.equal(lodash.get(collection, 'username'), 'nikk') 41 | }) 42 | 43 | test('unset method', ({ assert }) => { 44 | const collection = { username: 'virk', email: 'virk@adonisjs.com' } 45 | lodash.unset(collection, 'username') 46 | assert.equal(lodash.get(collection, 'username'), undefined) 47 | }) 48 | 49 | test('size method', ({ assert }) => { 50 | assert.equal(lodash.size([1, 2, 3, 4]), 4) 51 | assert.equal(lodash.size({ username: 'virk', email: 'virk@adonisjs.com' }), 2) 52 | }) 53 | 54 | test('merge method', ({ assert }) => { 55 | const collection = { username: 'virk' } 56 | lodash.merge(collection, { email: 'virk@adonisjs.com' }) 57 | 58 | assert.deepEqual(collection, { username: 'virk', email: 'virk@adonisjs.com' }) 59 | }) 60 | 61 | test('clone method', ({ assert }) => { 62 | const collection = { username: 'virk' } 63 | const cloned = lodash.clone(collection) 64 | lodash.merge(cloned, { email: 'virk@adonisjs.com' }) 65 | 66 | assert.deepEqual(collection, { username: 'virk' }) 67 | assert.deepEqual(cloned, { username: 'virk', email: 'virk@adonisjs.com' }) 68 | }) 69 | 70 | test('toPath method', ({ assert }) => { 71 | assert.deepEqual(lodash.toPath('a.b.c'), ['a', 'b', 'c']) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /tests/message_builder.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import string from '@poppinss/string' 12 | import { MessageBuilder } from '../src/message_builder.js' 13 | 14 | test.group('MessageBuilder | build', () => { 15 | test('build a number as a message', ({ assert }) => { 16 | const message = new MessageBuilder() 17 | assert.equal(message.build(22), '{"message":22}') 18 | const encoded = message.build( 19 | { 20 | token: string.random(32), 21 | }, 22 | '1 hour', 23 | 'email_verification' 24 | ) 25 | console.log(encoded) 26 | }) 27 | 28 | test('build a string as a message', ({ assert }) => { 29 | const message = new MessageBuilder() 30 | assert.equal(message.build('hello'), '{"message":"hello"}') 31 | }) 32 | 33 | test('build a boolean as a message', ({ assert }) => { 34 | const message = new MessageBuilder() 35 | assert.equal(message.build(true), '{"message":true}') 36 | }) 37 | 38 | test('build a date as a message', ({ assert }) => { 39 | const message = new MessageBuilder() 40 | const date = new Date() 41 | assert.equal(message.build(date), `{"message":"${date.toISOString()}"}`) 42 | }) 43 | 44 | test('build circular references', ({ assert }) => { 45 | const message = new MessageBuilder() 46 | const profile: any = {} 47 | 48 | const user = { 49 | name: 'virk', 50 | profile, 51 | } 52 | 53 | profile.user = user 54 | assert.equal(message.build(user), `{"message":{"name":"virk","profile":{}}}`) 55 | }) 56 | }) 57 | 58 | test.group('MessageBuilder | verify', () => { 59 | test('return message value after verification', ({ assert }) => { 60 | const message = new MessageBuilder() 61 | assert.equal(message.verify(message.build(22)), 22) 62 | }) 63 | 64 | test('return null when message is invalid', ({ assert }) => { 65 | const message = new MessageBuilder() 66 | assert.isNull(message.verify('"hello"')) 67 | }) 68 | 69 | test('return null when message property is missing', ({ assert }) => { 70 | const message = new MessageBuilder() 71 | assert.isNull(message.verify(JSON.stringify({}))) 72 | }) 73 | 74 | test('return null when purpose was defined during build, but not verify', ({ assert }) => { 75 | const message = new MessageBuilder() 76 | assert.isNull(message.verify(message.build(22, undefined, 'login'))) 77 | }) 78 | 79 | test('return null when no purpose was defined during build', ({ assert }) => { 80 | const message = new MessageBuilder() 81 | assert.isNull(message.verify(message.build(22), 'login')) 82 | }) 83 | 84 | test('return null when purpose mismatch', ({ assert }) => { 85 | const message = new MessageBuilder() 86 | assert.isNull(message.verify(message.build(22, undefined, 'register'), 'login')) 87 | }) 88 | 89 | test('return null when message has been expired', ({ assert }) => { 90 | const message = new MessageBuilder() 91 | assert.isNull(message.verify(message.build(22, -10))) 92 | }) 93 | 94 | test('return null when expiredAt is not a valid date', ({ assert }) => { 95 | const message = new MessageBuilder() 96 | const built = JSON.parse(message.build(22)) 97 | built.expiryDate = 'foo' 98 | assert.isNull(message.verify(JSON.stringify(built))) 99 | }) 100 | 101 | test('return null when message is malformed', ({ assert }) => { 102 | const message = new MessageBuilder() 103 | assert.isNull(message.verify(JSON.stringify('foo'))) 104 | }) 105 | 106 | test('return message when not expired', ({ assert }) => { 107 | const message = new MessageBuilder() 108 | assert.equal(message.verify(message.build(22, '10 hours')), 22) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /tests/safe_equal.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { safeEqual } from '../src/safe_equal.js' 12 | 13 | test.group('safeEqual', () => { 14 | test('return true when two strings are the same', ({ assert }) => { 15 | assert.isTrue(safeEqual('hello', 'hello')) 16 | }) 17 | 18 | test('return true when two unicode values are the same', ({ assert }) => { 19 | assert.isTrue(safeEqual('\u00e8', '\u00e8')) 20 | }) 21 | }) 22 | 23 | test.group('safeEqual | not equals', () => { 24 | test('return false when two strings are different with same length', ({ assert }) => { 25 | assert.isFalse(safeEqual('foo', 'bar')) 26 | }) 27 | 28 | test('return false when two strings are different with different length', ({ assert }) => { 29 | assert.isFalse(safeEqual('foo', 'helloworld')) 30 | }) 31 | 32 | test('return false when expected value is a subset of actual value', ({ assert }) => { 33 | assert.isFalse(safeEqual('pre', 'prefix')) 34 | }) 35 | 36 | test('return false when actual value is a subset of expected value', ({ assert }) => { 37 | assert.isFalse(safeEqual('prefix', 'pre')) 38 | }) 39 | 40 | test('compare two buffers', ({ assert }) => { 41 | assert.isTrue(safeEqual(Buffer.from('hello'), Buffer.from('hello'))) 42 | }) 43 | 44 | test('raise error any buffer has less bytes', ({ assert }) => { 45 | assert.throws( 46 | () => safeEqual(Buffer.from('llo'), Buffer.from('hello')), 47 | 'Input buffers must have the same byte length' 48 | ) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /tests/safe_parse.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import json from '../src/json/main.js' 12 | 13 | test.group('Parser', () => { 14 | test('parses object string', ({ assert }) => { 15 | assert.deepEqual(json.safeParse('{"a": 5, "b": 6}'), { a: 5, b: 6 }) 16 | }) 17 | 18 | test('parses null string', ({ assert }) => { 19 | assert.deepEqual(json.safeParse('null'), null) 20 | }) 21 | 22 | test('parses zero string', ({ assert }) => { 23 | assert.deepEqual(json.safeParse('0'), 0) 24 | }) 25 | 26 | test('parses string string', ({ assert }) => { 27 | assert.deepEqual(json.safeParse('"x"'), 'x') 28 | }) 29 | 30 | test('parses object string (reviver)', ({ assert }) => { 31 | const reviver = (_: string, value: any) => { 32 | return typeof value === 'number' ? value + 1 : value 33 | } 34 | 35 | assert.deepEqual(json.safeParse('{"a": 5, "b": 6}', reviver), { a: 6, b: 7 }) 36 | }) 37 | 38 | test('sanitizes object string (reviver, options)', ({ assert }) => { 39 | const reviver = (_: string, value: any) => { 40 | return typeof value === 'number' ? value + 1 : value 41 | } 42 | 43 | assert.deepEqual(json.safeParse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }', reviver), { 44 | a: 6, 45 | b: 7, 46 | }) 47 | }) 48 | 49 | test('sanitizes object string (options)', ({ assert }) => { 50 | assert.deepEqual(json.safeParse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }'), { a: 5, b: 6 }) 51 | }) 52 | 53 | test('sanitizes object string (undefined, options)', ({ assert }) => { 54 | assert.deepEqual(json.safeParse('{ "a": 5, "b": 6, "__proto__": { "x": 7 } }', undefined), { 55 | a: 5, 56 | b: 6, 57 | }) 58 | }) 59 | 60 | test('sanitizes nested object string', ({ assert }) => { 61 | const text = 62 | '{ "a": 5, "b": 6, "__proto__": { "x": 7 }, "c": { "d": 0, "e": "text", "__proto__": { "y": 8 }, "f": { "g": 2 } } }' 63 | assert.deepEqual(json.safeParse(text), { 64 | a: 5, 65 | b: 6, 66 | c: { d: 0, e: 'text', f: { g: 2 } }, 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /tests/safe_stringify.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import json from '../src/json/main.js' 12 | 13 | test.group('Stringify', () => { 14 | test('stringify number', ({ assert }) => { 15 | assert.deepEqual(json.safeStringify(1), '1') 16 | }) 17 | 18 | test('stringify boolean', ({ assert }) => { 19 | assert.deepEqual(json.safeStringify(false), 'false') 20 | }) 21 | 22 | test('stringify date/time', ({ assert }) => { 23 | const date = new Date() 24 | assert.deepEqual(json.safeStringify(date), `"${date.toISOString()}"`) 25 | }) 26 | 27 | test('stringify object', ({ assert }) => { 28 | assert.deepEqual(json.safeStringify({ b: 2, a: 1 }), '{"b":2,"a":1}') 29 | }) 30 | 31 | test('stringify object with circular reference', ({ assert }) => { 32 | const a: any = { 33 | b: 2, 34 | } 35 | a.a = a 36 | assert.deepEqual(json.safeStringify(a), '{"b":2}') 37 | }) 38 | 39 | test('stringify object with bigint', ({ assert }) => { 40 | const a: any = { 41 | b: BigInt(10), 42 | a: 10, 43 | } 44 | assert.deepEqual(json.safeStringify(a), '{"b":"10","a":10}') 45 | }) 46 | 47 | test('stringify object with bigint and circular reference', ({ assert }) => { 48 | const a: any = { 49 | b: BigInt(10), 50 | } 51 | a.a = a 52 | assert.deepEqual(json.safeStringify(a), '{"b":"10"}') 53 | }) 54 | 55 | test('stringifies object', ({ assert }) => { 56 | assert.deepEqual(json.safeStringify({ a: 18, b: 4 }), '{"a":18,"b":4}') 57 | }) 58 | 59 | test('stringifies object (replacer)', ({ assert }) => { 60 | const replacer = (_: any, value: any) => { 61 | return typeof value === 'number' ? value + 1 : value 62 | } 63 | 64 | assert.deepEqual(json.safeStringify({ a: 18, b: 4 }, replacer), '{"a":19,"b":5}') 65 | }) 66 | 67 | test('stringifies object removing circular reference', ({ assert }) => { 68 | const o: any = { a: 18, b: 4 } 69 | o.o = o 70 | assert.deepEqual(json.safeStringify(o), '{"a":18,"b":4}') 71 | }) 72 | 73 | test('stringifies object with bigint', ({ assert }) => { 74 | assert.deepEqual(json.safeStringify({ a: BigInt(18), b: 4 }), '{"a":"18","b":4}') 75 | }) 76 | 77 | test('stringifies object with bigint (replacer returns bigint)', ({ assert }) => { 78 | const replacer = (_: any, value: any) => { 79 | return typeof value === 'bigint' ? value + BigInt(1) : value 80 | } 81 | 82 | assert.deepEqual(json.safeStringify({ a: BigInt(18), b: 4 }, replacer), '{"a":"19","b":4}') 83 | }) 84 | 85 | test('stringifies object with bigint (replacer handles bigint)', ({ assert }) => { 86 | const replacer = (_: any, value: any) => { 87 | return typeof value === 'bigint' ? `${value.toString()}n` : value 88 | } 89 | 90 | assert.deepEqual(json.safeStringify({ a: BigInt(18), b: 4 }, replacer), '{"a":"18n","b":4}') 91 | }) 92 | 93 | test('call toJSON', ({ assert }) => { 94 | assert.deepEqual( 95 | json.safeStringify({ 96 | toJSON() { 97 | return { 98 | foo: 'bar', 99 | baz: { 100 | toJSON() { 101 | return 'baz' 102 | }, 103 | }, 104 | } 105 | }, 106 | }), 107 | '{"foo":"bar","baz":"baz"}' 108 | ) 109 | }) 110 | 111 | test('raise exception when toJSON returns error', ({ assert }) => { 112 | assert.throws( 113 | () => 114 | json.safeStringify({ 115 | toJSON() { 116 | throw new Error('blow up') 117 | }, 118 | }), 119 | 'blow up' 120 | ) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /tests/secret.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { inspect } from 'node:util' 11 | import { test } from '@japa/runner' 12 | import { LoggerFactory } from '@adonisjs/logger/factories' 13 | 14 | import { Secret } from '../index.js' 15 | 16 | test.group('Secret', () => { 17 | test('create a secret value and release it', ({ assert }) => { 18 | const secret = new Secret('asecretkey') 19 | assert.equal(secret.release(), 'asecretkey') 20 | }) 21 | 22 | test('transform value to create a new secret', ({ assert }) => { 23 | const secret = new Secret('asecretkey') 24 | assert.equal(secret.map((value) => value.toUpperCase()).release(), 'ASECRETKEY') 25 | }) 26 | 27 | test('prevent secret from leaking inside sprintf transformations', ({ assert }) => { 28 | const logsCollections: string[] = [] 29 | const logger = new LoggerFactory().merge({ enabled: true }).pushLogsTo(logsCollections).create() 30 | 31 | class Token { 32 | generate() { 33 | return { 34 | value: new Secret('asecretkey'), 35 | hash: 'value persisted to db', 36 | } 37 | } 38 | } 39 | 40 | const token = new Token().generate() 41 | logger.info('token generated %O', token) 42 | 43 | assert.lengthOf(logsCollections, 1) 44 | assert.deepEqual( 45 | JSON.parse(logsCollections[0]).msg, 46 | `token generated {"value":"[redacted]","hash":"value persisted to db"}` 47 | ) 48 | }) 49 | 50 | test('prevent secret from leaking inside log data', ({ assert }) => { 51 | const logsCollections: string[] = [] 52 | const logger = new LoggerFactory().merge({ enabled: true }).pushLogsTo(logsCollections).create() 53 | 54 | class Token { 55 | generate() { 56 | return { 57 | value: new Secret('asecretkey'), 58 | hash: 'value persisted to db', 59 | } 60 | } 61 | } 62 | 63 | const token = new Token().generate() 64 | logger.info({ token }, 'token generated') 65 | 66 | assert.lengthOf(logsCollections, 1) 67 | assert.deepEqual(JSON.parse(logsCollections[0]).token, { 68 | hash: 'value persisted to db', 69 | value: '[redacted]', 70 | }) 71 | }) 72 | 73 | test('hide secret value from serialization', ({ assert }) => { 74 | const secret = new Secret('asecretkey') 75 | 76 | assert.equal(inspect(secret), '[redacted]') 77 | assert.equal(JSON.stringify(secret), '"[redacted]"') 78 | assert.equal(`${secret}world`, '[redacted]world') 79 | // @ts-expect-error 80 | assert.equal(new Secret(4) + 2, '[redacted]2') 81 | assert.equal(secret.toLocaleString(), '[redacted]') 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /tests/slash.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @poppinss/utils 3 | * 4 | * (c) Poppinss 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { slash } from '../src/slash.js' 12 | 13 | test('convert backwards-slash paths to forward slash paths', ({ assert }) => { 14 | assert.equal(slash('c:/aaaa\\bbbb'), 'c:/aaaa/bbbb') 15 | assert.equal(slash('c:\\aaaa\\bbbb'), 'c:/aaaa/bbbb') 16 | assert.equal(slash('c:\\aaaa\\bbbb\\★'), 'c:/aaaa/bbbb/★') 17 | }) 18 | 19 | test('not convert extended-length paths', ({ assert }) => { 20 | const path = '\\\\?\\c:\\aaaa\\bbbb' 21 | assert.equal(slash(path), path) 22 | }) 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build" 6 | } 7 | } 8 | --------------------------------------------------------------------------------