From 55f54462523bc4c737b62e36323698af93e5788c Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Fri, 21 Aug 2026 10:22:25 -0700 Subject: [PATCH] Expose and performance optimize validation --- README.md | 9 +- package-lock.json | 588 +++++++++++++++++++++++++++++++++++++++++ package.json | 11 +- src/format.spec.ts | 8 + src/index.bench.ts | 22 +- src/index.ts | 128 ++++++--- src/validation.spec.ts | 65 +++++ 7 files changed, 792 insertions(+), 39 deletions(-) create mode 100644 src/validation.spec.ts diff --git a/README.md b/README.md index 5f68348..113d10f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ const obj = contentType.parse("image/svg+xml; charset=utf-8"); Parse a `Content-Type` header. This will return an object with the following properties (examples are shown for the string `'image/svg+xml; charset=utf-8'`): -- `type`: The media type. Example: `'image/svg+xml'`. +- `type`: The media type (always lower case). Example: `'image/svg+xml'`. - `parameters`: An object of the parameters in the media type (parameter name is always lower case). Example: `{charset: 'utf-8'}`. - `index`: The index where parsing stopped. Example: `33`. @@ -56,6 +56,13 @@ Format an object into a `Content-Type` header. This will return a string of the Throws a `TypeError` if the object contains an invalid type or parameter names. +### Validation + +This package exposes the validation functions used by `format`: + +- `isTypeValid` Validates the MIME type against RFC 9110. +- `isTokenValid` Validates a token against RFC 9110 (used for the parameter name). + ## License [MIT](LICENSE) diff --git a/package-lock.json b/package-lock.json index cfef838..e48dd30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "MIT", "devDependencies": { "@borderless/ts-scripts": "^0.15.0", + "@size-limit/preset-small-lib": "^13.0.3", "@vitest/coverage-v8": "^4.1.11", + "size-limit": "^13.0.3", "typescript": "^7.0.2", "vitest": "^4.1.11" }, @@ -266,6 +268,448 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -681,6 +1125,70 @@ "dev": true, "license": "MIT" }, + "node_modules/@size-limit/esbuild": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/@size-limit/esbuild/-/esbuild-13.0.3.tgz", + "integrity": "sha512-g24wsTxM3N/SaGv1MiiDjTShNrIna1WCNJ7NXMrlsWBHWv1OL0nCyllR1bDDHf+gV41lF7QxX7vknswoOr71DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.28.1", + "nanoid": "^6.0.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "size-limit": "13.0.3" + } + }, + "node_modules/@size-limit/esbuild/node_modules/nanoid": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-6.0.1.tgz", + "integrity": "sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^22 || ^24 || >=26" + } + }, + "node_modules/@size-limit/file": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/@size-limit/file/-/file-13.0.3.tgz", + "integrity": "sha512-PWTITIXH5p9aGIf6qq2Fruihn/b9nBQyfkyoAyb6DzFJgS1Ek9MSPJYKxKFLO8jdo0aqSgBPd3sevbS6PyBiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "size-limit": "13.0.3" + } + }, + "node_modules/@size-limit/preset-small-lib": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/@size-limit/preset-small-lib/-/preset-small-lib-13.0.3.tgz", + "integrity": "sha512-rqKn1+JkVF5ckZRmcxeQPZ8g0e9Fqddh6bjmDotijXJtN40KJQ+5TG6pTiYq3RaA4nkuEkixHULpDBGcgAARAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@size-limit/esbuild": "13.0.3", + "@size-limit/file": "13.0.3", + "size-limit": "13.0.3" + }, + "peerDependencies": { + "size-limit": "13.0.3" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1298,6 +1806,16 @@ "node": ">=8" } }, + "node_modules/bytes-iec": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bytes-iec/-/bytes-iec-3.1.1.tgz", + "integrity": "sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1558,6 +2076,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2562,6 +3122,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanospinner": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/nanospinner/-/nanospinner-1.2.2.tgz", + "integrity": "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1" + } + }, "node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -2986,6 +3556,24 @@ "dev": true, "license": "ISC" }, + "node_modules/size-limit": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/size-limit/-/size-limit-13.0.3.tgz", + "integrity": "sha512-KVb2aNEU49BwTR21SVjD+2QHP9gBV/nWsTHzNB/heRwXtHyA7lLQiDZDQ1TiNh/B/TZXKAZrHYyTt+cvBUrzYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes-iec": "^3.1.1", + "lilconfig": "^3.1.3", + "nanospinner": "^1.2.2" + }, + "bin": { + "size-limit": "bin.js" + }, + "engines": { + "node": "^22.18.0 || ^24.0.0 || >=26.0.0" + } + }, "node_modules/slice-ansi": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", diff --git a/package.json b/package.json index dc28893..bd0b00b 100644 --- a/package.json +++ b/package.json @@ -29,18 +29,27 @@ "build": "ts-scripts build", "format": "ts-scripts format", "prepare": "ts-scripts install && npm run build", + "size": "size-limit", "specs": "ts-scripts specs", - "test": "ts-scripts test" + "test": "ts-scripts test && npm run size" }, "devDependencies": { "@borderless/ts-scripts": "^0.15.0", + "@size-limit/preset-small-lib": "^13.0.3", "@vitest/coverage-v8": "^4.1.11", + "size-limit": "^13.0.3", "typescript": "^7.0.2", "vitest": "^4.1.11" }, "engines": { "node": ">=22" }, + "size-limit": [ + { + "path": "dist/index.js", + "limit": "1.15 kB" + } + ], "ts-scripts": { "dist": [ "dist" diff --git a/src/format.spec.ts b/src/format.spec.ts index 0aa3d80..b270633 100644 --- a/src/format.spec.ts +++ b/src/format.spec.ts @@ -49,6 +49,14 @@ describe("format(obj)", function () { assert.strictEqual(str, 'text/html; foo="bar or \\"baz\\""'); }); + it("should escape backslashes and quotes in parameter values", function () { + const str = format({ + type: "text/html", + parameters: { foo: String.raw`bar\"baz` }, + }); + assert.strictEqual(str, String.raw`text/html; foo="bar\\\"baz"`); + }); + it("should format type with parameter with empty value", function () { const str = format({ type: "text/html", diff --git a/src/index.bench.ts b/src/index.bench.ts index e98a48b..f3ff0d8 100644 --- a/src/index.bench.ts +++ b/src/index.bench.ts @@ -1,5 +1,25 @@ import { bench, describe } from "vitest"; -import { format, parse } from "./index.js"; +import { format, isTokenValid, isTypeValid, parse } from "./index.js"; + +describe("isTypeValid", () => { + bench("valid", () => { + isTypeValid("application/ld+json"); + }); + + bench("invalid", () => { + isTypeValid("application/ld json"); + }); +}); + +describe("isTokenValid", () => { + bench("valid", () => { + isTokenValid("profile-version"); + }); + + bench("invalid", () => { + isTokenValid("profile version"); + }); +}); describe("parse", () => { const BASIC_HEADER = "text/html"; diff --git a/src/index.ts b/src/index.ts index 936bdd5..a8f2cc0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,24 +4,6 @@ * MIT Licensed */ -const TEXT_REGEXP = /^[\u0009\u0020-\u007e\u0080-\u00ff]*$/; -const TOKEN_REGEXP = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; - -/** - * RegExp to match chars that must be quoted-pair in RFC 9110 sec 5.6.4 - */ -const QUOTE_REGEXP = /[\\"]/g; - -/** - * RegExp to match type in RFC 9110 sec 8.3.1 - * - * media-type = type "/" subtype - * type = token - * subtype = token - */ -const TYPE_REGEXP = - /^[!#$%&'*+.^_`|~0-9A-Za-z-]+\/[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; - const SP = 32; // " " const HTAB = 9; // "\t" const SEMI = 59; // ";" @@ -34,6 +16,7 @@ const LOWER_CASE = 1; const OWS = 2; const SEMI_FLAG = 4; const COMMA_FLAG = 8; +const TOKEN_FLAG = 16; const NON_ASCII = 0xff00; const CASE_FLAGS = LOWER_CASE | NON_ASCII; @@ -43,10 +26,6 @@ const CASE_FLAGS = LOWER_CASE | NON_ASCII; */ const CHAR_MAP = new Uint8Array(0x100); -for (let code = 0x41 /* A */; code <= 0x5a /* Z */; code++) { - CHAR_MAP[code] |= LOWER_CASE; -} - CHAR_MAP[HTAB] |= OWS; CHAR_MAP[SP] |= OWS; CHAR_MAP[SEMI] |= SEMI_FLAG; @@ -55,6 +34,19 @@ for (let code = 0x80 /* non-ASCII */; code <= 0xff; code++) { CHAR_MAP[code] |= LOWER_CASE; } +for (const char of "!#$%&'*+-.^_`|~") { + CHAR_MAP[char.charCodeAt(0)] |= TOKEN_FLAG; +} +for (let code = 0x30 /* 0 */; code <= 0x39 /* 9 */; code++) { + CHAR_MAP[code] |= TOKEN_FLAG; +} +for (let code = 0x41 /* A */; code <= 0x5a /* Z */; code++) { + CHAR_MAP[code] |= LOWER_CASE | TOKEN_FLAG; +} +for (let code = 0x61 /* a */; code <= 0x7a /* z */; code++) { + CHAR_MAP[code] |= TOKEN_FLAG; +} + /** * Null object perf optimization. Faster than `Object.create(null)` and `{ __proto__: null }`. */ @@ -73,13 +65,87 @@ export interface ContentType { parameters: Record; } +/** + * Validate a type string against RFC 9110. + */ +export function isTypeValid(type: string): boolean { + const len = type.length; + let hasSlash = false; + + for (let index = 0; index < len; index++) { + const code = type.charCodeAt(index); + + if (code === 47 /* / */) { + if (hasSlash || index === 0 || index === len - 1) return false; + hasSlash = true; + } else if (!isTokenCode(code)) { + return false; + } + } + + return hasSlash; +} + +/** + * Validate a token against RFC 9110. + */ +export function isTokenValid(name: string): boolean { + const len = name.length; + if (len === 0) return false; + + for (let index = 0; index < len; index++) { + if (!isTokenCode(name.charCodeAt(index))) return false; + } + + return true; +} + +/** + * Check whether a character code belongs to the token production in RFC 9110. + */ +function isTokenCode(code: number): boolean { + return (CHAR_MAP[code] & TOKEN_FLAG) !== 0; +} + +/** + * Serialize a parameter value. + */ +function parameterValue(str: string): string { + const len = str.length; + if (len === 0) return '""'; + + let index = 0; + while (index < len && isTokenCode(str.charCodeAt(index))) index++; + if (index === len) return str; + + let result = '"'; + let start = 0; + + while (index < len) { + const code = str.charCodeAt(index); + + if (code !== HTAB && (code < SP || code === 127 || code > 255)) { + throw new TypeError(`Invalid parameter value: ${str}`); + } + + if (code === 34 /* " */ || code === 92 /* \\ */) { + result += `${str.slice(start, index)}\\`; + start = index; + } + + index++; + } + + return `${result}${str.slice(start)}"`; +} + /** * Format an object into a `Content-Type` header. */ export function format(obj: Partial): string { const { type, parameters } = obj; - if (!type || !TYPE_REGEXP.test(type)) { + if (!type || !isTypeValid(type)) { throw new TypeError(`Invalid type: ${type}`); } @@ -87,11 +153,11 @@ export function format(obj: Partial): string { if (parameters) { for (const param of Object.keys(parameters)) { - if (!TOKEN_REGEXP.test(param)) { + if (!isTokenValid(param)) { throw new TypeError(`Invalid parameter name: ${param}`); } - result += `; ${param}=${qstring(parameters[param])}`; + result += `; ${param}=${parameterValue(parameters[param])}`; } } @@ -193,7 +259,7 @@ function parseParameters( const code = header.charCodeAt(index); const flags = CHAR_MAP[code]; if ((flags & stopFlags) !== 0) { - if (flags === COMMA_FLAG) break parameter; + if ((flags & COMMA_FLAG) !== 0) break parameter; continue parameter; } @@ -308,13 +374,3 @@ function unescapeQuotedPairs(str: string, start: number, end: number): string { return result + str.slice(start, end); } - -/** - * Serialize a parameter value. - */ -function qstring(str: string): string { - if (TOKEN_REGEXP.test(str)) return str; - if (TEXT_REGEXP.test(str)) return `"${str.replace(QUOTE_REGEXP, "\\$&")}"`; - - throw new TypeError(`Invalid parameter value: ${str}`); -} diff --git a/src/validation.spec.ts b/src/validation.spec.ts new file mode 100644 index 0000000..3f36e29 --- /dev/null +++ b/src/validation.spec.ts @@ -0,0 +1,65 @@ +import { assert, describe, it } from "vitest"; +import { isTokenValid, isTypeValid } from "./index.js"; + +describe("isTypeValid(type)", function () { + it.each([ + "text/html", + "IMAGE/SVG+XML", + "application/vnd.example+json", + "*/*", + "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz/!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + ])("should accept valid type: %s", function (type) { + assert.strictEqual(isTypeValid(type), true); + }); + + it.each([ + "", + "/", + "text", + "text/", + "/plain", + "text/plain/extra", + "text /plain", + "text/ plain", + "text/plain; charset=utf-8", + "text/(plain)", + "text/pläin", + "text/\u0000plain", + ])("should reject invalid type: %s", function (type) { + assert.strictEqual(isTypeValid(type), false); + }); +}); + +describe("isTokenValid(name)", function () { + it.each([ + "charset", + "Charset", + "profile-version", + "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + ])("should accept valid parameter name: %s", function (name) { + assert.strictEqual(isTokenValid(name), true); + }); + + it.each([ + "", + "profile version", + "profile\tversion", + "profile/version", + 'profile"version', + "profile(version)", + "profile,version", + "profile:version", + "profile;version", + "profile", + "profile=version", + "profile?version", + "profile@version", + "profile[version]", + "profile\\version", + "profile{version}", + "pröfile", + "profile\u0000version", + ])("should reject invalid parameter name: %s", function (name) { + assert.strictEqual(isTokenValid(name), false); + }); +});