snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,853 @@
# @rocket.chat/http-router
## 7.9.23
### Patch Changes
- <details><summary>Updated dependencies [278b84f78360e53792a2e5d7620615039a0e15e9, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 4e99ff6c1a99ac569cf444f9de9b5fe5ac3262b2, 8c0e16ca29b393cfa50b425520db48ba5a74f678]:</summary>
- @rocket.chat/core-typings@8.4.0
- @rocket.chat/rest-typings@8.4.0
</details>
## 7.9.23-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.4.0-rc.2
- @rocket.chat/rest-typings@8.4.0-rc.2
</details>
## 7.9.23-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.4.0-rc.1
- @rocket.chat/rest-typings@8.4.0-rc.1
</details>
## 7.9.22
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.2
- @rocket.chat/rest-typings@8.3.2
</details>
## 7.9.21
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.1
- @rocket.chat/rest-typings@8.3.1
</details>
## 7.9.23-rc.0
### Patch Changes
- <details><summary>Updated dependencies [278b84f78360e53792a2e5d7620615039a0e15e9, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 4e99ff6c1a99ac569cf444f9de9b5fe5ac3262b2, 8c0e16ca29b393cfa50b425520db48ba5a74f678]:</summary>
- @rocket.chat/core-typings@8.4.0-rc.0
- @rocket.chat/rest-typings@8.4.0-rc.0
</details>
## 7.9.20
### Patch Changes
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
- <details><summary>Updated dependencies [a4e3c1635d55ec4ce04cbde741426770e43581fb, 539659af22bc19880eda047dfc0b152472ccb65c, 5518503736b72674753e711ba4089d177ab988a5, a4341ec67d1f0413f30bbabfd292d1b0a41728b2, 40253146de8d8f83737e71b0ade7c67e0c295a28, 803b8075514de54c9ff34ba0c9aa3ee5fc3bbe61, 1361a1f4f1e3c0cc3f2a191cef8eccc12a714cde, 2a2701098536b32143003be8d267891978c708c9, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, 37acece030bc9f39bdaa86ab0130eb818332033e, d8baf395181b70fef9ce448eb509f65b66049615, ddc0ed34b03072362d166f1160104a9332b362e8, d83a1a9753464ee916845b3c88757bbcf76884a5, 4c2e444216efd514ab406fe8e9cd127ef971d566, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, 78b3fe3ef20e3a545b84551ba3f85cb40e862ba7, 98a6c58a38c053c60db2b4d53a9df0e94fecf0ba, 29b453e1def8092a8d78c28736e2bfb24229717b, 39f2e87e1caa6842e69155f033205cfdc4767b9e, c117492ad90d291a361eedc929506f557495caf7, 7c7324184589a15bf3e67b4f0c1cc222f8d48db3]:</summary>
- @rocket.chat/rest-typings@8.3.0
- @rocket.chat/core-typings@8.3.0
- @rocket.chat/logger@1.0.1
</details>
## 7.9.20-rc.4
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.0-rc.4
- @rocket.chat/rest-typings@8.3.0-rc.4
</details>
## 7.9.20-rc.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.0-rc.3
- @rocket.chat/rest-typings@8.3.0-rc.3
</details>
## 7.9.20-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.0-rc.2
- @rocket.chat/rest-typings@8.3.0-rc.2
</details>
## 7.9.20-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.0-rc.1
- @rocket.chat/rest-typings@8.3.0-rc.1
</details>
## 7.9.20-rc.0
### Patch Changes
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
- <details><summary>Updated dependencies [a4e3c1635d55ec4ce04cbde741426770e43581fb, 539659af22bc19880eda047dfc0b152472ccb65c, 5518503736b72674753e711ba4089d177ab988a5, a4341ec67d1f0413f30bbabfd292d1b0a41728b2, 40253146de8d8f83737e71b0ade7c67e0c295a28, 803b8075514de54c9ff34ba0c9aa3ee5fc3bbe61, 1361a1f4f1e3c0cc3f2a191cef8eccc12a714cde, 2a2701098536b32143003be8d267891978c708c9, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, 37acece030bc9f39bdaa86ab0130eb818332033e, d8baf395181b70fef9ce448eb509f65b66049615, ddc0ed34b03072362d166f1160104a9332b362e8, d83a1a9753464ee916845b3c88757bbcf76884a5, 4c2e444216efd514ab406fe8e9cd127ef971d566, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, 78b3fe3ef20e3a545b84551ba3f85cb40e862ba7, 98a6c58a38c053c60db2b4d53a9df0e94fecf0ba, 29b453e1def8092a8d78c28736e2bfb24229717b, 39f2e87e1caa6842e69155f033205cfdc4767b9e, c117492ad90d291a361eedc929506f557495caf7, 7c7324184589a15bf3e67b4f0c1cc222f8d48db3]:</summary>
- @rocket.chat/rest-typings@8.3.0-rc.0
- @rocket.chat/core-typings@8.3.0-rc.0
- @rocket.chat/logger@1.0.1-rc.0
</details>
## 7.9.19
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.2.1
- @rocket.chat/rest-typings@8.2.1
</details>
## 7.9.18
### Patch Changes
- ([#38319](https://github.com/RocketChat/Rocket.Chat/pull/38319)) Fixes incoming webhook integrations not receiving parsed JSON from x-www-form-urlencoded payload field.
- <details><summary>Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34, 88da141f3c2af6f91980c7ca8b8777161f99a068, 75d089ca40248af963d7cd2a8034c3c6de6b971e, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573]:</summary>
- @rocket.chat/core-typings@8.2.0
- @rocket.chat/rest-typings@8.2.0
</details>
## 7.9.18-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.2.0-rc.2
- @rocket.chat/rest-typings@8.2.0-rc.2
</details>
## 7.9.18-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.2.0-rc.1
- @rocket.chat/rest-typings@8.2.0-rc.1
</details>
## 7.9.18-rc.0
### Patch Changes
- ([#38319](https://github.com/RocketChat/Rocket.Chat/pull/38319)) Fixes incoming webhook integrations not receiving parsed JSON from x-www-form-urlencoded payload field.
- <details><summary>Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34, 88da141f3c2af6f91980c7ca8b8777161f99a068, 75d089ca40248af963d7cd2a8034c3c6de6b971e, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573]:</summary>
- @rocket.chat/core-typings@8.2.0-rc.0
- @rocket.chat/rest-typings@8.2.0-rc.0
</details>
## 7.9.17
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.1.1
- @rocket.chat/rest-typings@8.1.1
</details>
## 7.9.16
### Patch Changes
- ([#38071](https://github.com/RocketChat/Rocket.Chat/pull/38071)) Improves file upload flow to prevent buffering of contents in memory
- <details><summary>Updated dependencies [fd319d58a621d66c20029fd962ddb24a1a24ecc3, 5c3c52b1fc8b0bd52d4744614ad4d2b8e2e03937, 6654c5b481f91bdcb03d68ee0f3a12d58201137e]:</summary>
- @rocket.chat/rest-typings@8.1.0
- @rocket.chat/core-typings@8.1.0
</details>
## 7.9.16-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.1.0-rc.2
- @rocket.chat/rest-typings@8.1.0-rc.2
</details>
## 7.9.16-rc.1
### Patch Changes
- <details><summary>Updated dependencies [fd319d58a621d66c20029fd962ddb24a1a24ecc3]:</summary>
- @rocket.chat/rest-typings@8.1.0-rc.1
- @rocket.chat/core-typings@8.1.0-rc.1
</details>
## 7.9.16-rc.0
### Patch Changes
- ([#38071](https://github.com/RocketChat/Rocket.Chat/pull/38071)) Improves file upload flow to prevent buffering of contents in memory
- <details><summary>Updated dependencies [5c3c52b1fc8b0bd52d4744614ad4d2b8e2e03937, 6654c5b481f91bdcb03d68ee0f3a12d58201137e]:</summary>
- @rocket.chat/rest-typings@8.1.0-rc.0
- @rocket.chat/core-typings@8.1.0-rc.0
</details>
## 7.9.15
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.1
- @rocket.chat/rest-typings@8.0.1
</details>
## 7.9.14
### Patch Changes
- <details><summary>Updated dependencies [347b8f973440f3e2239f79c00c2d9b430859eef2, b802430fbfdc7fa69a976468dc6dee6f3c4de26f, 872da49986436d2efa65fc42e416b45d706fd59c, 176d5eae3fb249d7d20c3e260d9fadc1a56a2fca, ac11ea05ffadeca978c794ff38d5199d9acb2c29, f3f0b273ab49e9d1048ba43d52eb36005274905e, ac11ea05ffadeca978c794ff38d5199d9acb2c29, a1d65f493fdb039d34ef4f65d243a97931763f4b, ddc935727e9a7275813006d9dcaa7fe866610844, a5a7343a835b04812c70699be1b13e54f0e10d48, 73d9eb2783176954f42aa2cbeda8abf1d49ac260, 476a070b0099b95e4c463ee85960c4dcfbd87120, 239f4b1171bcf448cfba345cc90c4b5cd7c21afc, dc67590d14d510b069dee074c55314c56f74bb11, dccdcc5b4a0da4814f72a020bc4eccb8ea2497d8, 5ac1863be4c6e82666989f4b569928c0805691ff, ec0f8b435dd12c218adffa8892737c7ced4debb8, cb3c5e3455606a045f95f168dae6ed32a387697c, 5b3f93c47a03b628d613a77005e92021cd6cee4b]:</summary>
- @rocket.chat/rest-typings@8.0.0
- @rocket.chat/core-typings@8.0.0
- @rocket.chat/logger@1.0.0
</details>
## 7.9.14-rc.5
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.5
- @rocket.chat/rest-typings@8.0.0-rc.5
</details>
## 7.9.14-rc.4
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.4
- @rocket.chat/rest-typings@8.0.0-rc.4
</details>
## 7.9.14-rc.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.3
- @rocket.chat/rest-typings@8.0.0-rc.3
</details>
## 7.9.14-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.2
- @rocket.chat/rest-typings@8.0.0-rc.2
</details>
## 7.9.14-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.1
- @rocket.chat/rest-typings@8.0.0-rc.1
</details>
## 7.9.14-rc.0
### Patch Changes
- <details><summary>Updated dependencies [347b8f973440f3e2239f79c00c2d9b430859eef2, b802430fbfdc7fa69a976468dc6dee6f3c4de26f, 872da49986436d2efa65fc42e416b45d706fd59c, 176d5eae3fb249d7d20c3e260d9fadc1a56a2fca, ac11ea05ffadeca978c794ff38d5199d9acb2c29, f3f0b273ab49e9d1048ba43d52eb36005274905e, ac11ea05ffadeca978c794ff38d5199d9acb2c29, a1d65f493fdb039d34ef4f65d243a97931763f4b, ddc935727e9a7275813006d9dcaa7fe866610844, a5a7343a835b04812c70699be1b13e54f0e10d48, 73d9eb2783176954f42aa2cbeda8abf1d49ac260, 476a070b0099b95e4c463ee85960c4dcfbd87120, 239f4b1171bcf448cfba345cc90c4b5cd7c21afc, dc67590d14d510b069dee074c55314c56f74bb11, dccdcc5b4a0da4814f72a020bc4eccb8ea2497d8, 5ac1863be4c6e82666989f4b569928c0805691ff, ec0f8b435dd12c218adffa8892737c7ced4debb8, cb3c5e3455606a045f95f168dae6ed32a387697c, 5b3f93c47a03b628d613a77005e92021cd6cee4b]:</summary>
- @rocket.chat/rest-typings@8.0.0-rc.0
- @rocket.chat/core-typings@8.0.0-rc.0
- @rocket.chat/logger@1.0.0-rc.0
</details>
## 7.9.13
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.13.2
- @rocket.chat/rest-typings@7.13.2
</details>
## 7.9.12
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.13.1
- @rocket.chat/rest-typings@7.13.1
</details>
## 7.9.11
### Patch Changes
- <details><summary>Updated dependencies [7f1b834a55b1240c226afde77713262da47f45dc, 5c7e8ec1de894e7b8eeb6e57b0c8a43bd22d2d46, 65fbcbed9f64004b953dd9d4182b3fccb8147339, 035e0c7c387d6c99703513c7c00bf37e86538b71]:</summary>
- @rocket.chat/core-typings@7.13.0
- @rocket.chat/rest-typings@7.13.0
</details>
## 7.9.11-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.13.0-rc.2
- @rocket.chat/rest-typings@7.13.0-rc.2
</details>
## 7.9.11-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.13.0-rc.1
- @rocket.chat/rest-typings@7.13.0-rc.1
</details>
## 7.9.11-rc.0
### Patch Changes
- <details><summary>Updated dependencies [7f1b834a55b1240c226afde77713262da47f45dc, 5c7e8ec1de894e7b8eeb6e57b0c8a43bd22d2d46, 65fbcbed9f64004b953dd9d4182b3fccb8147339, 035e0c7c387d6c99703513c7c00bf37e86538b71]:</summary>
- @rocket.chat/core-typings@7.13.0-rc.0
- @rocket.chat/rest-typings@7.13.0-rc.0
## 7.9.10
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.12.2
- @rocket.chat/rest-typings@7.12.2
</details>
## 7.9.9
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.12.1
- @rocket.chat/rest-typings@7.12.1
</details>
## 7.9.8
### Patch Changes
- <details><summary>Updated dependencies [48d1442a07e860fefd6991c97e94d6a9de345afe, d166e2a1ffba4e59361d5f79e8c376fca5cbf12f, 4ab2b7fdb9314538d45abdf4474ea9d2409799be, fd4f9b2523dae23518561cdcfa8d931f87d8964d]:</summary>
- @rocket.chat/rest-typings@7.12.0
- @rocket.chat/core-typings@7.12.0
</details>
## 7.9.8-rc.4
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.12.0-rc.4
- @rocket.chat/rest-typings@7.12.0-rc.4
</details>
## 7.9.8-rc.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.12.0-rc.3
- @rocket.chat/rest-typings@7.12.0-rc.3
</details>
## 7.9.8-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.12.0-rc.2
- @rocket.chat/rest-typings@7.12.0-rc.2
</details>
## 7.9.8-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.12.0-rc.1
- @rocket.chat/rest-typings@7.12.0-rc.1
</details>
## 7.9.8-rc.0
### Patch Changes
- <details><summary>Updated dependencies [48d1442a07e860fefd6991c97e94d6a9de345afe, d166e2a1ffba4e59361d5f79e8c376fca5cbf12f, 4ab2b7fdb9314538d45abdf4474ea9d2409799be, fd4f9b2523dae23518561cdcfa8d931f87d8964d]:</summary>
- @rocket.chat/rest-typings@7.12.0-rc.0
- @rocket.chat/core-typings@7.12.0-rc.0
</details>
## 7.9.7
### Patch Changes
- <details><summary>Updated dependencies [d76a5578ed0e14fae2a041c0e09d565b28630d76, 8d2ad40c46334aaf35816e52a3b28dc2ac23ce2d, ba793542644d2b725924c4cef253e7d84bc2ff71, 3e177dbd0b65d70bce8587287ba73b30170eb0f7, c5ee569cc3120bb616001fd78687dbf623a6a504, 00611aceec7f158064863f2e829c5a6698687512, c15a47ee1d55118342d0f0ece7af77f6f41132b7, 276c270f34293729182e9ebab61e1ff2c35398fc, b0a4602a4461200b9872b2b073ec56fa55ecb466, 80fbb2eb421253e6066207949eaf2b41871e4428, 90a1759df57814306cbc859409fe041b33db7a39, b74ab6f18ff1343f4835975f663211b005ccc5a1]:</summary>
- @rocket.chat/rest-typings@7.11.0
- @rocket.chat/core-typings@7.11.0
</details>
## 7.9.7-rc.7
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.7
- @rocket.chat/rest-typings@7.11.0-rc.7
</details>
## 7.9.7-rc.6
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.6
- @rocket.chat/rest-typings@7.11.0-rc.6
</details>
## 7.9.6-rc.5
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.5
- @rocket.chat/rest-typings@7.11.0-rc.5
</details>
## 7.9.6-rc.4
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.4
- @rocket.chat/rest-typings@7.11.0-rc.4
</details>
## 7.9.6-rc.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.3
- @rocket.chat/rest-typings@7.11.0-rc.3
</details>
## 7.9.6-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.2
- @rocket.chat/rest-typings@7.11.0-rc.2
</details>
## 7.9.6-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.1
- @rocket.chat/rest-typings@7.11.0-rc.1
</details>
## 7.9.6-rc.0
### Patch Changes
- <details><summary>Updated dependencies [d76a5578ed0e14fae2a041c0e09d565b28630d76, 8d2ad40c46334aaf35816e52a3b28dc2ac23ce2d, ba793542644d2b725924c4cef253e7d84bc2ff71, 3e177dbd0b65d70bce8587287ba73b30170eb0f7, c5ee569cc3120bb616001fd78687dbf623a6a504, 00611aceec7f158064863f2e829c5a6698687512, c15a47ee1d55118342d0f0ece7af77f6f41132b7, 276c270f34293729182e9ebab61e1ff2c35398fc, b0a4602a4461200b9872b2b073ec56fa55ecb466, 80fbb2eb421253e6066207949eaf2b41871e4428, 90a1759df57814306cbc859409fe041b33db7a39, b74ab6f18ff1343f4835975f663211b005ccc5a1]:</summary>
- @rocket.chat/rest-typings@7.11.0-rc.0
- @rocket.chat/core-typings@7.11.0-rc.0
</details>
## 7.9.6
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.10.2
- @rocket.chat/rest-typings@7.10.2
</details>
## 7.9.5
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.10.1
- @rocket.chat/rest-typings@7.10.1
</details>
## 7.9.4
### Patch Changes
- ([#36535](https://github.com/RocketChat/Rocket.Chat/pull/36535) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) This change fixes the HTTP route validation to return the correct error message 'invalid-params' instead of 'error-invalid-params', ensuring consistency with our API error codes. The body validation should now return 'invalid-params' instead of 'error-invalid-params'.'
- <details><summary>Updated dependencies [f040b27ff67c31188026a0aed9ba1e9c4f717f08, a54f8837338246842585d037a0d0327a79245811, c5f0be15b31d1de03256f74bd277ad4ab753ada2, c86fbce9b44942662dc25a599fc12b009fd40a74, 2fea1a79b831999f148505b9442cd584e1b06d09, 2f162a0dca79274d4458a5853afe64c506a7554f, 1ca92c346e45486e9b6afc66566ae38fac65b48f, 5d7dec3a68f7281b4b4531fa708d7fc7589a863c, 17bca96ecbf23ea807aba2e6e8abc95ebd66b0d0, dc6acda84bf7452d96f375be3cd97748ed016bfc, c7db598e9f3c2ad47f6a6be2a9ba7078533c245b]:</summary>
- @rocket.chat/rest-typings@7.10.0
- @rocket.chat/core-typings@7.10.0
</details>
## 7.9.4-rc.6
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.10.0-rc.6
- @rocket.chat/rest-typings@7.10.0-rc.6
</details>
## 7.9.4-rc.5
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.10.0-rc.5
- @rocket.chat/rest-typings@7.10.0-rc.5
</details>
## 7.9.1-rc.4
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.10.0-rc.4
- @rocket.chat/rest-typings@7.10.0-rc.4
</details>
## 7.9.1-rc.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.10.0-rc.3
- @rocket.chat/rest-typings@7.10.0-rc.3
</details>
## 7.9.1-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.10.0-rc.2
- @rocket.chat/rest-typings@7.10.0-rc.2
</details>
## 7.9.1-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.10.0-rc.1
- @rocket.chat/rest-typings@7.10.0-rc.1
</details>
## 7.9.1-rc.0
### Patch Changes
- ([#36535](https://github.com/RocketChat/Rocket.Chat/pull/36535) by [@ahmed-n-abdeltwab](https://github.com/ahmed-n-abdeltwab)) This change fixes the HTTP route validation to return the correct error message 'invalid-params' instead of 'error-invalid-params', ensuring consistency with our API error codes. The body validation should now return 'invalid-params' instead of 'error-invalid-params'.'
- <details><summary>Updated dependencies [f040b27ff67c31188026a0aed9ba1e9c4f717f08, a54f8837338246842585d037a0d0327a79245811, c5f0be15b31d1de03256f74bd277ad4ab753ada2, c86fbce9b44942662dc25a599fc12b009fd40a74, 2fea1a79b831999f148505b9442cd584e1b06d09, 2f162a0dca79274d4458a5853afe64c506a7554f, 1ca92c346e45486e9b6afc66566ae38fac65b48f, 5d7dec3a68f7281b4b4531fa708d7fc7589a863c, 17bca96ecbf23ea807aba2e6e8abc95ebd66b0d0, dc6acda84bf7452d96f375be3cd97748ed016bfc, c7db598e9f3c2ad47f6a6be2a9ba7078533c245b]:</summary>
- @rocket.chat/rest-typings@7.10.0-rc.0
- @rocket.chat/core-typings@7.10.0-rc.0
</details>
## 7.9.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.9.3
- @rocket.chat/rest-typings@7.9.3
</details>
## 7.9.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.9.2
- @rocket.chat/rest-typings@7.9.2
</details>
## 7.9.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.9.1
- @rocket.chat/rest-typings@7.9.1
</details>
## 7.9.0
### Patch Changes
- <details><summary>Updated dependencies [0de9318f344b49cb4afbce6bf8b26f943346343d, 2cec8acd5beddf5ad0c67c29fe632487cb82b026, d4226a564d699e44eb6264095da8de3850071d6b, 6f205101215b4d9890ac62f7acc21257f0cd3cf4, fd478a7d45a4505ad53d2d7aec8b44e9bf8fa41a]:</summary>
- @rocket.chat/rest-typings@7.9.0
- @rocket.chat/core-typings@7.9.0
</details>
## 7.9.0-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.9.0-rc.2
- @rocket.chat/rest-typings@7.9.0-rc.2
</details>
## 7.9.0-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.9.0-rc.1
- @rocket.chat/rest-typings@7.9.0-rc.1
</details>
## 7.9.0-rc.0
### Patch Changes
- <details><summary>Updated dependencies [0de9318f344b49cb4afbce6bf8b26f943346343d, 2cec8acd5beddf5ad0c67c29fe632487cb82b026, d4226a564d699e44eb6264095da8de3850071d6b, 6f205101215b4d9890ac62f7acc21257f0cd3cf4, fd478a7d45a4505ad53d2d7aec8b44e9bf8fa41a]:</summary>
- @rocket.chat/rest-typings@7.9.0-rc.0
- @rocket.chat/core-typings@7.9.0-rc.0
</details>
## 7.8.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.8.3
- @rocket.chat/rest-typings@7.8.3
</details>
## 7.8.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.8.2
- @rocket.chat/rest-typings@7.8.2
</details>
## 7.8.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.8.1
- @rocket.chat/rest-typings@7.8.1
</details>
## 7.8.0
### Patch Changes
- <details><summary>Updated dependencies [5cd7b20d5fb6def0a76743576eaa19fc0bf01c4c, 1eb53d289e1280b5ac4ce4a6995e7fe28d50601a, da288f69a4c169eee942af73f490e7570fe41cc5, 3d024a900426c8bbf646e7ebedce0e17c9f7c140, 3779de0e8c5787f266bdeda5052b27c023c65f1c, d58457d25a9a899ebcc7fd0e1fe11d9d370c50d8]:</summary>
- @rocket.chat/rest-typings@7.8.0
- @rocket.chat/core-typings@7.8.0
</details>
## 7.8.0-rc.9
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.8.0-rc.9
- @rocket.chat/rest-typings@7.8.0-rc.9
</details>
## 7.8.0-rc.8
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.8.0-rc.8
- @rocket.chat/rest-typings@7.8.0-rc.8
</details>
## 7.8.0-rc.7
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.8.0-rc.7
- @rocket.chat/rest-typings@7.8.0-rc.7
</details>
## 7.8.0-rc.6
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.8.0-rc.6
- @rocket.chat/rest-typings@7.8.0-rc.6
</details>
## 7.8.0-rc.5
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.8.0-rc.5
- @rocket.chat/rest-typings@7.8.0-rc.5
</details>
## 7.8.0-rc.4
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.8.0-rc.4
- @rocket.chat/rest-typings@7.8.0-rc.4
</details>
## 7.8.0-rc.3
### Patch Changes
- <details><summary>Updated dependencies [d58457d25a9a899ebcc7fd0e1fe11d9d370c50d8]:</summary>
- @rocket.chat/rest-typings@7.8.0-rc.3
- @rocket.chat/core-typings@7.8.0-rc.3
</details>
## 7.8.0-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.8.0-rc.2
- @rocket.chat/rest-typings@7.8.0-rc.2
</details>
## 7.8.0-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.8.0-rc.1
- @rocket.chat/rest-typings@7.8.0-rc.1
</details>
## 7.8.0-rc.0
### Patch Changes
- <details><summary>Updated dependencies [5cd7b20d5fb6def0a76743576eaa19fc0bf01c4c, 1eb53d289e1280b5ac4ce4a6995e7fe28d50601a, da288f69a4c169eee942af73f490e7570fe41cc5, 3d024a900426c8bbf646e7ebedce0e17c9f7c140, 3779de0e8c5787f266bdeda5052b27c023c65f1c]:</summary>
- @rocket.chat/rest-typings@7.8.0-rc.0
- @rocket.chat/core-typings@7.8.0-rc.0
</details>

View File

@@ -0,0 +1,128 @@
# Rocket.Chat HTTP Router
This package provides a flexible HTTP routing solution for Rocket.Chat services, built on top of Hono and Express.
## Features
- Type-safe route definitions with TypeScript
- Express compatibility
- Request validation via JSON Schema
- Response validation
- Middleware support
- Nested routers
- Strong TypeScript types for request and response objects
## Installation
```sh
yarn add @rocket.chat/http-router
```
## Usage
```typescript
import { Router } from '@rocket.chat/http-router';
import express from 'express';
// Create a new router
const api = new Router('api');
// Define routes with typed handlers
api.get(
'users',
{
// Request validation options
query: {
schema: {
type: 'object',
properties: {
limit: { type: 'number' },
skip: { type: 'number' }
}
}
},
// Response validation
response: {
'200': {
schema: {
type: 'object',
properties: {
users: { type: 'array' },
total: { type: 'number' }
}
}
}
}
},
// Optional middleware
async (c, next) => {
console.log('Middleware 1');
await next();
},
// Route handler
async (c) => {
const { limit, skip } = c.req.valid('query');
return {
body: {
users: [],
total: 0
},
statusCode: 200
};
}
);
// Create an Express app and use the router
const app = express();
app.use(api.router);
```
## Middleware
The router supports middleware functions that can be used for authentication, logging, metrics, and more:
```typescript
// Authentication middleware
const authMiddleware = async (c, next) => {
const token = c.req.header('Authorization');
if (!token) {
return {
body: { error: 'Unauthorized' },
statusCode: 401
};
}
// Set context variables for later use
c.set('userId', 'user123');
await next();
};
api.get('protected-route', {}, authMiddleware, async (c) => {
const userId = c.get('userId');
return {
body: { message: `Hello, ${userId}` },
statusCode: 200
};
});
```
## Express Integration
This router is designed to work seamlessly with Express applications:
```typescript
import express from 'express';
import { Router } from '@rocket.chat/http-router';
const app = express();
const api = new Router('api');
// Define routes...
// Use the router with Express
app.use(api.router);
app.listen(3000, () => {
console.log('Server running on port 3000');
});
```

View File

@@ -0,0 +1,6 @@
import server from '@rocket.chat/jest-presets/server';
import type { Config } from 'jest';
export default {
preset: server.preset,
} satisfies Config;

View File

@@ -0,0 +1,43 @@
{
"name": "@rocket.chat/http-router",
"version": "7.9.23",
"private": true,
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "rm -rf dist && tsc",
"dev": "tsc --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "jest",
"testunit": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/logger": "workspace:^",
"@rocket.chat/rest-typings": "workspace:^",
"ajv": "^8.20.0",
"express": "^4.21.2",
"hono": "4.13.5",
"qs": "^6.15.3"
},
"devDependencies": {
"@rocket.chat/jest-presets": "workspace:^",
"@rocket.chat/tsconfig": "workspace:*",
"@types/express": "^4.17.25",
"@types/jest": "~30.0.0",
"@types/supertest": "~6.0.3",
"eslint": "~9.39.5",
"jest": "~30.2.0",
"supertest": "~7.1.4",
"ts-jest": "~29.4.12",
"typescript": "~5.9.3"
},
"volta": {
"extends": "../../package.json"
}
}

View File

@@ -0,0 +1,755 @@
import Ajv from 'ajv';
import express from 'express';
import request from 'supertest';
import { Router } from './Router';
describe('Router', () => {
const ajv = new Ajv();
const dummyValidator = ajv.compile({
type: 'object',
additionalProperties: true,
});
describe('Basic routing', () => {
it('should handle GET requests', async () => {
const app = express();
const api = new Router('/api');
api.get(
'hello',
{
response: {
200: dummyValidator,
},
},
async () => ({
statusCode: 200,
body: { message: 'Hello, World!' },
}),
);
app.use(api.router);
const response = await request(app).get('/api/hello');
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Hello, World!' });
});
it('should handle POST requests', async () => {
const app = express();
const api = new Router('/api');
api.post(
'echo',
{
response: {
201: dummyValidator,
},
},
async (c) => {
const bodyParams = await c.req.json();
return {
statusCode: 201,
body: { received: bodyParams },
};
},
);
app.use(api.router);
const testData = { message: 'Hello from POST' };
const response = await request(app).post('/api/echo').send(testData);
expect(response.status).toBe(201);
expect(response.body).toEqual({ received: testData });
});
it('should handle PUT requests', async () => {
const app = express();
const api = new Router('/api');
api.put(
'update/:id',
{
response: {
200: dummyValidator,
},
},
async (c) => {
const id = c.req.param('id');
const bodyParams = await c.req.json();
return {
statusCode: 200,
body: {
id,
updated: bodyParams,
},
};
},
);
app.use(api.router);
const testData = { name: 'Updated Resource' };
const response = await request(app).put('/api/update/123').send(testData);
expect(response.status).toBe(200);
expect(response.body).toEqual({ id: '123', updated: testData });
});
it('should handle DELETE requests', async () => {
const app = express();
const api = new Router('/api');
api.delete(
'remove/:id',
{
response: {
200: dummyValidator,
},
},
async (c) => {
const id = c.req.param('id');
return {
statusCode: 200,
body: { deleted: id },
};
},
);
app.use(api.router);
const response = await request(app).delete('/api/remove/123');
expect(response.status).toBe(200);
expect(response.body).toEqual({ deleted: '123' });
});
});
describe('Validation', () => {
it('should validate query parameters', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
api.get(
'validate-query',
{
query: ajv.compile({
type: 'object',
properties: {
required: { type: 'string' },
},
required: ['required'],
additionalProperties: false,
}),
response: {
200: dummyValidator,
400: dummyValidator,
},
},
async () => ({
statusCode: 200,
body: { success: true },
}),
);
app.use(api.router);
const validResponse = await request(app).get('/api/validate-query?required=value');
expect(validResponse.status).toBe(200);
const invalidResponse = await request(app).get('/api/validate-query');
expect(invalidResponse.status).toBe(400);
expect(invalidResponse.body).toHaveProperty('errorType', 'error-invalid-params');
});
it('should validate request body', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
api.post(
'validate-body',
{
body: ajv.compile({
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name', 'age'],
additionalProperties: false,
}),
response: {
200: dummyValidator,
400: dummyValidator,
},
},
async () => ({
statusCode: 200,
body: { success: true },
}),
);
app.use(api.router);
const validResponse = await request(app).post('/api/validate-body').send({ name: 'John', age: 30 });
expect(validResponse.status).toBe(200);
const invalidResponse = await request(app).post('/api/validate-body').send({ name: 'John' });
expect(invalidResponse.status).toBe(400);
expect(invalidResponse.body).toHaveProperty('errorType', 'invalid-params');
const invalidTypeResponse = await request(app).post('/api/validate-body').send({ name: 'John', age: 'thirty' });
expect(invalidTypeResponse.status).toBe(400);
expect(invalidTypeResponse.body).toHaveProperty('errorType', 'invalid-params');
});
it('should validate response body in test mode', async () => {
process.env.TEST_MODE = 'true';
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
api.get(
'validate-response',
{
typed: true,
response: {
200: ajv.compile({
type: 'object',
properties: {
result: { type: 'string' },
},
required: ['result'],
additionalProperties: false,
}),
},
},
async () => ({
statusCode: 200,
body: { wrongField: 'wrong' },
}),
);
app.use(api.router);
const response = await request(app).get('/api/validate-response');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('errorType', 'error-invalid-body');
delete process.env.TEST_MODE;
});
});
describe('Middleware', () => {
it('should apply middleware to routes', async () => {
const app = express();
const api = new Router('/api');
const authMiddleware = async (c: any, next: () => Promise<void>) => {
const token = c.req.header('Authorization');
if (!token || token !== 'Bearer valid-token') {
return c.json(
{
success: false,
errorType: 'unauthorized',
error: 'Unauthorized access',
},
401,
);
}
c.set('userId', 'user-123');
await next();
};
api.get(
'protected',
{
response: {
200: dummyValidator,
401: dummyValidator,
},
},
authMiddleware,
async (c) => {
const userId = c.get('userId');
return {
statusCode: 200,
body: { userId, message: 'Protected resource accessed' },
};
},
);
app.use(api.router);
const unauthorizedResponse = await request(app).get('/api/protected');
expect(unauthorizedResponse.status).toBe(401);
const authorizedResponse = await request(app).get('/api/protected').set('Authorization', 'Bearer valid-token');
expect(authorizedResponse.status).toBe(200);
expect(authorizedResponse.body).toEqual({
userId: 'user-123',
message: 'Protected resource accessed',
});
});
it('should support global middleware via use()', async () => {
const app = express();
const api = new Router('/api');
api.use(async (c, next) => {
c.set('requestTime', 'test-time');
await next();
});
api.get(
'time',
{
response: {
200: dummyValidator,
},
},
async (c) => {
const time = c.get('requestTime');
return {
statusCode: 200,
body: { time },
};
},
);
app.use(api.router);
const response = await request(app).get('/api/time');
expect(response.status).toBe(200);
expect(response.body).toEqual({ time: 'test-time' });
});
});
describe('Nested routers', () => {
it('should support nested routers with the use() method', async () => {
const app = express();
const api = new Router('/api');
const v1 = new Router('/v1');
const users = new Router('/users');
users.get(
':id',
{
response: {
200: dummyValidator,
},
},
async (c) => {
const id = c.req.param('id');
return {
statusCode: 200,
body: { userId: id },
};
},
);
v1.use(users);
api.use(v1);
app.use(api.router);
const response = await request(app).get('/api/v1/users/123');
expect(response.status).toBe(200);
expect(response.body).toEqual({ userId: '123' });
});
it('should pass middleware from parent to child routers', async () => {
const app = express();
const api = new Router('/api');
const v1 = new Router('/v1');
v1.use(async (c, next) => {
c.header('API-Version', 'v1');
await next();
});
v1.get(
'info',
{
response: {
200: dummyValidator,
},
},
async () => ({
statusCode: 200,
body: { version: 'v1' },
}),
);
api.use(v1);
app.use(api.router);
const response = await request(app).get('/api/v1/info');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('api-version', 'v1');
expect(response.body).toEqual({ version: 'v1' });
});
});
describe('Error handling', () => {
it('should handle errors thrown in route handlers', async () => {
const app = express();
const api = new Router('/api');
const originalConsoleError = console.error;
console.error = jest.fn();
api.get(
'error',
{
response: {
200: dummyValidator,
500: dummyValidator,
},
},
async () => {
throw new Error('Test error');
},
);
app.use(api.router);
try {
const response = await request(app).get('/api/error');
expect(response.status).toBe(500);
} finally {
console.error = originalConsoleError;
}
});
});
describe('Content types', () => {
it('should not auto-parse multipart/form-data and provide raw request for manual parsing', async () => {
const app = express();
const api = new Router('/api');
api.post(
'form-data',
{
response: {
200: dummyValidator,
},
},
async (c) => {
// For multipart/form-data, routes use c.env.incoming for manual parsing
// In production, routes call UploadService.parse(c.env.incoming)
const hasIncoming = !!c.env.incoming;
const contentType = c.env.incoming?.headers['content-type'];
const isMultipart = contentType?.includes('multipart/form-data');
return {
statusCode: 200,
body: {
receivedRawRequest: hasIncoming,
receivedIncoming: isMultipart,
},
};
},
);
app.use(api.router);
const response = await request(app).post('/api/form-data').field('name', 'Test User');
expect(response.status).toBe(200);
expect(response.body).toEqual({
receivedRawRequest: true,
receivedIncoming: true,
});
});
it('should set custom response headers', async () => {
const app = express();
const api = new Router('/api');
api.get(
'custom-headers',
{
response: {
200: dummyValidator,
},
},
async () => ({
statusCode: 200,
body: { message: 'With custom headers' },
headers: {
'X-Custom-Header': 'custom-value',
'Cache-Control': 'private, max-age=3600',
},
}),
);
app.use(api.router);
const response = await request(app).get('/api/custom-headers');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-custom-header', 'custom-value');
expect(response.headers).toHaveProperty('cache-control', 'private, max-age=3600');
});
});
it('should fail if the query request is not valid', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const test = new Router('/test').get(
'/',
{
typed: true,
query: ajv.compile({
type: 'object',
properties: {
customProperty: { type: 'string' },
},
additionalProperties: false,
required: ['customProperty'],
}),
response: {
200: ajv.compile({
type: 'object',
properties: {
customProperty: { type: 'string' },
},
additionalProperties: false,
}),
},
},
async function action() {
return {
statusCode: 200,
body: { customProperty: 'valid' }, // Ensure valid response for schema
};
},
);
app.use(api.use(test).router);
const response = await request(app).get('/api/test');
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty('error', "must have required property 'customProperty'");
});
it('should fail if the body request is not valid', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const test = new Router('/test').post(
'/',
{
typed: true,
body: ajv.compile({
type: 'object',
properties: {
customProperty: { type: 'string' },
},
additionalProperties: false,
required: ['customProperty'],
}),
response: {
200: ajv.compile({
type: 'object',
properties: {
customProperty: { type: 'string' },
},
additionalProperties: false,
}),
},
},
async function action() {
return {
statusCode: 200,
body: { customProperty: 'valid' }, // Ensure valid response for schema
};
},
);
app.use(api.use(test).router);
const response = await request(app).post('/api/test').send({});
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty('error', "must have required property 'customProperty'");
});
it('should fail if the body response has additional properties', async () => {
process.env.NODE_ENV = 'test';
process.env.TEST_MODE = 'true';
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const test = new Router('/test').get(
'/',
{
typed: true,
response: {
200: ajv.compile({
type: 'object',
properties: {
customProperty: { type: 'string' },
},
additionalProperties: false,
}),
},
},
async () => {
return {
statusCode: 200,
body: { asda: 1 }, // This body is invalid according to the schema
};
},
);
app.use(api.use(test).router);
const response = await request(app).get('/api/test');
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty(
'error',
"Invalid response for endpoint GET - http://localhost/api/test. Error: at path '(root)': must NOT have additional properties (additionalProperty: asda)",
);
});
it('middleware should be applied only on the right path', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const v1 = new Router('/v1').use(async (x, next) => {
x.header('x-api-version', 'v1');
await next();
});
const v2 = new Router('/v2');
const test = new Router('/test').get(
'/',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
customProperty: { type: 'string' },
},
additionalProperties: false,
required: ['customProperty'],
}),
},
},
async () => {
return {
statusCode: 200,
body: {
customProperty: 'customProperty',
},
};
},
);
app.use(api.use(v1.use(test)).use(v2.use(test)).router);
const response1 = await request(app).get('/api/v1/test');
expect(response1.statusCode).toBe(200);
expect(response1.headers).toHaveProperty('x-api-version', 'v1');
const response2 = await request(app).get('/api/v2/test');
expect(response2.statusCode).toBe(200);
expect(response2.headers).not.toHaveProperty('x-api-version');
});
it('should fail if the delete request body is not valid', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const test = new Router('/test').delete(
'/',
{
typed: true,
body: ajv.compile({
type: 'object',
properties: {
customProperty: { type: 'string' },
},
additionalProperties: false,
required: ['customProperty'],
}),
response: {
200: ajv.compile({
type: 'object',
properties: {
customProperty: { type: 'string' },
},
additionalProperties: false,
}),
},
},
async function action() {
return {
statusCode: 200,
body: { customProperty: 'test' },
};
},
);
app.use(api.use(test).router);
const response = await request(app).delete('/api/test').send({});
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty('error', "must have required property 'customProperty'");
});
it('should fail if the put request body is not valid', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const test = new Router('/test').put(
'/',
{
typed: true,
body: ajv.compile({
type: 'object',
properties: {
customProperty: { type: 'string' },
},
additionalProperties: false,
required: ['customProperty'],
}),
response: {
200: ajv.compile({
type: 'object',
properties: {
customProperty: { type: 'string' },
},
additionalProperties: false,
}),
},
},
async function action() {
return {
statusCode: 200,
body: { customProperty: 'test' },
};
},
);
app.use(api.use(test).router);
const response = await request(app).put('/api/test').send({});
expect(response.statusCode).toBe(400);
expect(response.body).toHaveProperty('error', "must have required property 'customProperty'");
});
});

View File

@@ -0,0 +1,471 @@
import { Logger } from '@rocket.chat/logger';
import type { Method } from '@rocket.chat/rest-typings';
import type { AnySchema } from 'ajv';
import express from 'express';
import type { Context, HonoRequest, MiddlewareHandler } from 'hono';
import { Hono } from 'hono';
import type { StatusCode } from 'hono/utils/http-status';
import type { ResponseSchema, TypedOptions } from './definition';
import { honoAdapterForExpress } from './middlewares/honoAdapterForExpress';
import { parseQueryParams } from './parseQueryParams';
const logger = new Logger('HttpRouter');
type MiddlewareHandlerListAndActionHandler<TOptions extends TypedOptions, TContext = (c: Context) => Promise<ResponseSchema<TOptions>>> = [
...MiddlewareHandler[],
TContext,
];
function splitArray<T, U>(arr: [...T[], U]): [T[], U] {
const last = arr[arr.length - 1];
const rest = arr.slice(0, -1) as T[];
return [rest, last as U];
}
function coerceDatesToStrings(obj: unknown): unknown {
if (Array.isArray(obj)) {
return obj.map(coerceDatesToStrings);
}
if (obj && typeof obj === 'object') {
const newObj: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (value instanceof Date) {
newObj[key] = value.toISOString();
} else {
newObj[key] = coerceDatesToStrings(value);
}
}
return newObj;
}
return obj;
}
export type Route = {
responses: Record<
number,
{
description: string;
content: {
'application/json': {
schema: AnySchema;
};
};
}
>;
parameters?: {
schema: AnySchema;
in: 'query';
name: 'query';
required: true;
}[];
requestBody?: {
required: true;
content: {
'application/json': {
schema: AnySchema;
};
};
};
security?: {
userId: [];
authToken: [];
}[];
tags?: string[];
};
export abstract class AbstractRouter<TActionCallback = (c: Context) => Promise<ResponseSchema<TypedOptions>>> {
protected abstract convertActionToHandler(action: TActionCallback, logger: Logger): (c: Context) => Promise<ResponseSchema<TypedOptions>>;
}
type InnerRouter = Hono<{
Variables: {
remoteAddress: string;
bodyParams: Record<string, unknown>;
queryParams: Record<string, unknown>;
};
}>;
export class Router<
TBasePath extends string,
TOperations extends {
[x: string]: unknown;
} = NonNullable<unknown>,
TActionCallback = (c: Context) => Promise<ResponseSchema<TypedOptions>>,
> extends AbstractRouter<TActionCallback> {
protected innerRouter: InnerRouter;
constructor(readonly base: TBasePath) {
super();
this.innerRouter = new Hono();
}
public typedRoutes: Record<string, Record<string, Route>> = {};
protected registerTypedRoutes<
TSubPathPattern extends string,
TOptions extends TypedOptions,
TPathPattern extends `${TBasePath}/${TSubPathPattern}`,
>(method: Method, subpath: TSubPathPattern, options: TOptions): void {
const path = `/${this.base}/${subpath}`.replaceAll('//', '/') as TPathPattern;
this.typedRoutes = this.typedRoutes || {};
this.typedRoutes[path] = this.typedRoutes[path] || {};
const { query, response = {}, authRequired, body, tags, ...rest } = options;
this.typedRoutes[path][method.toLowerCase()] = {
responses: Object.fromEntries(
Object.entries(response).map(([status, schema]) => [
parseInt(status, 10),
{
description: '',
content: {
'application/json': { schema: 'schema' in schema ? schema.schema : schema },
},
},
]),
),
...(query && {
parameters: [
{
schema: query.schema,
in: 'query',
name: 'query',
required: true,
},
],
}),
...(body && {
requestBody: {
required: true,
content: {
'application/json': { schema: body.schema },
},
},
}),
...(authRequired && {
...rest,
security: [
{
userId: [],
authToken: [],
},
],
}),
tags,
};
}
protected async parseBodyParams({ request }: { request: HonoRequest }): Promise<NonNullable<unknown>> {
try {
const contentType = request.header('content-type') || '';
if (contentType.includes('application/json')) {
return (await request.raw.clone().json()) as NonNullable<unknown>;
}
if (contentType.includes('application/x-www-form-urlencoded')) {
const req = await request.raw.clone().formData();
return Object.fromEntries(req.entries());
}
return {};
} catch {
// No problem if there is error, just means the endpoint is going to have to parse the body itself if necessary
return {};
}
}
protected parseQueryParams(request: HonoRequest) {
return parseQueryParams(request.raw.url.split('?')?.[1] || '');
}
protected method<TSubPathPattern extends string, TOptions extends TypedOptions>(
method: Method,
subpath: TSubPathPattern,
options: TOptions,
...actions: MiddlewareHandlerListAndActionHandler<TOptions, TActionCallback>
): Router<TBasePath, TOperations, TActionCallback> {
const [middlewares, action] = splitArray<MiddlewareHandler, TActionCallback>(actions);
const convertedAction = this.convertActionToHandler(action, logger);
const path = `/${subpath}`.replace('//', '/');
(
this.innerRouter[method.toLowerCase() as Lowercase<Method>] as (
path: string,
...handlers: Array<MiddlewareHandler | ((c: Context) => Promise<ResponseSchema<TypedOptions>>)>
) => InnerRouter
)(path, ...middlewares, async (c: Context) => {
const { req, res } = c;
let queryParams: Record<string, any>;
try {
queryParams = this.parseQueryParams(req);
c.set('queryParams', queryParams);
} catch (e) {
logger.warn({ msg: 'Error parsing query params for request', path: req.path, err: e });
return c.json({ success: false, error: 'Invalid query parameters' }, 400);
}
if (options.query) {
const validatorFn = options.query;
if (typeof options.query === 'function' && !validatorFn(queryParams)) {
logger.warn({
msg: 'Query parameters validation failed - route spec does not match request payload',
method: req.method,
path: req.url,
error: validatorFn.errors?.map((error: any) => error.message).join('\n '),
bodyParams: undefined,
queryParams,
});
return c.json(
{
success: false,
errorType: 'error-invalid-params',
error: validatorFn.errors?.map((error: any) => error.message).join('\n '),
},
400,
);
}
}
const bodyParams = await this.parseBodyParams({ request: req });
c.set('bodyParams', bodyParams);
if (options.body) {
const validatorFn = options.body;
if (typeof options.body === 'function' && !validatorFn((req as any).bodyParams || bodyParams)) {
logger.warn({
msg: 'Request body validation failed - route spec does not match request payload',
method: req.method,
path: req.url,
error: validatorFn.errors?.map((error: any) => error.message).join('\n '),
bodyParams,
queryParams: undefined,
});
return c.json(
{
success: false,
errorType: 'invalid-params',
error: validatorFn.errors?.map((error: any) => error.message).join('\n '),
},
400,
);
}
}
const response = await convertedAction(c);
const { body, statusCode, headers } = response as {
body: any;
statusCode: number;
headers?: Record<string, string>;
};
if (process.env.NODE_ENV === 'test' || process.env.TEST_MODE) {
const responseValidatorFn = options?.response?.[statusCode as keyof typeof options.response];
/* c8 ignore next 3 */
if (!responseValidatorFn && options.typed) {
throw new Error(`Missing response validator for endpoint ${req.method} - ${req.url} with status code ${statusCode}`);
}
if (responseValidatorFn && !responseValidatorFn(coerceDatesToStrings(body))) {
const errorMessage = responseValidatorFn.errors
?.map((error: any) => {
const pathDesc = error.instancePath ? `at path '${error.instancePath}'` : "at path '(root)'";
const paramsStr = Object.keys(error.params || {}).length
? ` (${Object.entries(error.params)
.map(([key, value]) => `${key}: ${value}`)
.join(', ')})`
: '';
return `${pathDesc}: ${error.message}${paramsStr}`;
})
.join('\n');
logger.warn({
msg: 'Response validation failed - response does not match route spec',
method: req.method,
path: req.url,
error: errorMessage,
originalResponse: body,
});
return c.json(
{
success: false,
body,
errorType: 'error-invalid-body',
error: `Invalid response for endpoint ${req.method} - ${req.url}. Error: ${errorMessage}`,
},
400,
);
}
}
const responseHeaders = Object.fromEntries(
Object.entries({
...res.headers,
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Pragma': 'no-cache',
...headers,
}).map(([key, value]) => [key.toLowerCase(), value]),
);
const contentType = (responseHeaders['content-type'] || 'application/json') as string;
const isContentLess = (statusCode: number): statusCode is 101 | 204 | 205 | 304 => {
return [101, 204, 205, 304].includes(statusCode);
};
if (isContentLess(statusCode)) {
return c.status(statusCode);
}
Object.entries(responseHeaders).forEach(([key, value]) => {
if (value) {
c.header(key, String(value));
}
});
return c.body(contentType?.match(/json|javascript/) ? JSON.stringify(body) : body, statusCode as StatusCode);
});
this.registerTypedRoutes(method, subpath, options);
return this;
}
protected convertActionToHandler(action: TActionCallback, _logger: Logger): (c: Context) => Promise<ResponseSchema<TypedOptions>> {
// Default implementation simply passes through the action
// Subclasses can override this to provide custom handling
return action as (c: Context) => Promise<ResponseSchema<TypedOptions>>;
}
get<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
subpath: TSubPathPattern,
options: TOptions,
...action: MiddlewareHandlerListAndActionHandler<TOptions, TActionCallback>
): Router<
TBasePath,
| TOperations
| ({
method: 'GET';
path: TPathPattern;
} & Omit<TOptions, 'response'>),
TActionCallback
> {
return this.method('GET', subpath, options, ...action);
}
post<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
subpath: TSubPathPattern,
options: TOptions,
...action: MiddlewareHandlerListAndActionHandler<TOptions, TActionCallback>
): Router<
TBasePath,
| TOperations
| ({
method: 'POST';
path: TPathPattern;
} & Omit<TOptions, 'response'>),
TActionCallback
> {
return this.method('POST', subpath, options, ...action);
}
put<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
subpath: TSubPathPattern,
options: TOptions,
...action: MiddlewareHandlerListAndActionHandler<TOptions, TActionCallback>
): Router<
TBasePath,
| TOperations
| ({
method: 'PUT';
path: TPathPattern;
} & Omit<TOptions, 'response'>),
TActionCallback
> {
return this.method('PUT', subpath, options, ...action);
}
delete<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
subpath: TSubPathPattern,
options: TOptions,
...action: MiddlewareHandlerListAndActionHandler<TOptions, TActionCallback>
): Router<
TBasePath,
| TOperations
| ({
method: 'DELETE';
path: TPathPattern;
} & Omit<TOptions, 'response'>),
TActionCallback
> {
return this.method('DELETE', subpath, options, ...action);
}
use<FN extends MiddlewareHandler>(fn: FN): Router<TBasePath, TOperations, TActionCallback>;
use<IRouter extends Router<any, any, any>>(
innerRouter: IRouter,
): IRouter extends Router<any, infer IOperations, any>
? Router<TBasePath, ConcatPathOptions<TBasePath, IOperations, TOperations>, TActionCallback>
: never;
use(innerRouter: unknown): any {
if (innerRouter instanceof Router) {
this.typedRoutes = {
...this.typedRoutes,
...Object.fromEntries(Object.entries(innerRouter.typedRoutes).map(([path, routes]) => [`${this.base}${path}`, routes])),
};
this.innerRouter.route(innerRouter.base, innerRouter.innerRouter);
}
if (typeof innerRouter === 'function') {
this.innerRouter.use(innerRouter as any);
}
return this as any;
}
get router(): express.Router {
// eslint-disable-next-line new-cap
const router = express.Router();
const hono = new Hono();
router.use(
this.base,
honoAdapterForExpress(
hono.route(this.base, this.innerRouter).options('*', (c) => {
return c.body('OK');
}),
),
);
return router;
}
getHonoRouter(): InnerRouter {
return this.innerRouter;
}
}
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
type ConcatPathOptions<
TPath extends string,
TOptions extends {
[x: string]: unknown;
},
TOther extends {
[x: string]: unknown;
},
> = Prettify<
Filter<
{
[x in keyof TOptions]: x extends 'path' ? (TOptions[x] extends string ? `${TPath}${TOptions[x]}` : never) : TOptions[x];
} & TOther
>
>;
type Filter<
TOther extends {
[x: string]: unknown;
},
> = TOther extends { method: Method; path: string } ? TOther : never;
export type ExtractRouterEndpoints<TRoute extends Router<any, any, any>> =
TRoute extends Router<any, infer TOperations, any> ? TOperations : never;

View File

@@ -0,0 +1,57 @@
import type { LicenseModule } from '@rocket.chat/core-typings';
import type { ValidateFunction } from 'ajv';
import type { Request } from 'express';
type Range<N extends number, Result extends number[] = []> = Result['length'] extends N
? Result[number]
: Range<N, [...Result, Result['length']]>;
export type SuccessStatusCodes = Exclude<Range<208>, Range<200>>;
export type RedirectStatusCodes = Exclude<Range<308>, Range<300>>;
export type AuthorizationStatusCodes = Exclude<Range<451>, Range<400>>;
export type ErrorStatusCodes = Exclude<Exclude<Range<511>, Range<500>>, 509>;
type HTTPStatusCodes = SuccessStatusCodes | RedirectStatusCodes | AuthorizationStatusCodes | ErrorStatusCodes;
export type ResponseSchema<T extends TypedOptions> = {
[K in keyof T['response']]: K extends HTTPStatusCodes
? {
statusCode: K;
body: Parameters<NonNullable<T['response'][K]>>[0];
headers?: Record<string, string>;
}
: never;
}[keyof T['response']];
export type TypedRequest = Request & {
response: {
setHeader: (name: string, value: string) => void;
};
routePath: string;
queryParams: Record<string, any>;
bodyParams: Record<string, any>;
urlParams: Record<string, any>;
requestIp: string;
path: string;
};
export type TypedResponse = {
statusCode?: number;
body: any;
headers?: Record<string, string>;
};
export type TypedOptions = {
response: {
[K in HTTPStatusCodes]?: ValidateFunction;
};
query?: ValidateFunction;
body?: ValidateFunction;
tags?: string[];
typed?: boolean;
license?: LicenseModule[];
authRequired?: boolean;
};

View File

@@ -0,0 +1,3 @@
export * from './Router';
export type * from './definition';
export * from './middlewares/honoAdapterForExpress';

View File

@@ -0,0 +1,219 @@
import { Readable, PassThrough } from 'node:stream';
import type { Request, Response as ExpressResponse } from 'express';
import type { Hono } from 'hono';
import { honoAdapterForExpress } from './honoAdapterForExpress';
const stringToReadableStream = (str: string): ReadableStream<Uint8Array> => {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(str));
controller.close();
},
});
};
const bufferToReadableStream = (buffer: Buffer): ReadableStream<Uint8Array> => {
return new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(buffer));
controller.close();
},
});
};
const createMockResponse = () => {
const passthrough = new PassThrough();
const chunks: Buffer[] = [];
passthrough.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
const mockResBase = passthrough as any;
mockResBase.status = jest.fn().mockReturnThis();
mockResBase.setHeader = jest.fn().mockReturnThis();
mockResBase.send = jest.fn().mockReturnThis();
mockResBase.getHeader = jest.fn();
mockResBase.getHeaders = jest.fn();
const getOutputBuffer = () => Buffer.concat(chunks);
const getOutputString = (encoding: BufferEncoding = 'utf-8') => getOutputBuffer().toString(encoding);
return { mockRes: mockResBase as ExpressResponse, getOutputBuffer, getOutputString };
};
describe('honoAdapterForExpress', () => {
let mockHono: Hono;
let mockExpressRequest: Request;
beforeEach(() => {
mockHono = {
request: jest.fn(),
} as unknown as Hono;
mockExpressRequest = {
originalUrl: '/test',
method: 'GET',
headers: { 'x-test-header': 'test-value' },
body: undefined,
on: jest.fn(),
readable: true,
socket: { encrypted: false } as any,
} as unknown as Request;
(mockExpressRequest as any).duplex = 'half';
});
it('should adapt a Hono response with JSON body', async () => {
const jsonData = { message: 'hello' };
const jsonString = JSON.stringify(jsonData);
const mockHonoResponse = new Response(stringToReadableStream(jsonString), {
status: 200,
headers: { 'content-type': 'application/json' },
});
(mockHono.request as jest.Mock).mockResolvedValue(mockHonoResponse);
const { mockRes, getOutputString } = createMockResponse();
const adapter = honoAdapterForExpress(mockHono);
await adapter(mockExpressRequest, mockRes);
expect(mockHono.request).toHaveBeenCalledWith(
'/test',
expect.objectContaining({
method: 'GET',
headers: expect.any(Headers),
}),
expect.anything(),
);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.setHeader).toHaveBeenCalledWith('content-type', 'application/json');
await new Promise((resolve) => (mockRes as any).on('finish', resolve));
expect(getOutputString()).toBe(jsonString);
});
it('should adapt a Hono response with text body', async () => {
const textData = 'Hello, world!';
const mockHonoResponse = new Response(stringToReadableStream(textData), {
status: 201,
headers: { 'content-type': 'text/plain' },
});
(mockHono.request as jest.Mock).mockResolvedValue(mockHonoResponse);
const { mockRes, getOutputString } = createMockResponse();
const adapter = honoAdapterForExpress(mockHono);
await adapter(mockExpressRequest, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.setHeader).toHaveBeenCalledWith('content-type', 'text/plain');
await new Promise((resolve) => (mockRes as any).on('finish', resolve));
expect(getOutputString()).toBe(textData);
});
it('should adapt a Hono response with image (Buffer) body', async () => {
const imageBuffer = Buffer.from([1, 2, 3, 4, 5]);
const mockHonoResponse = new Response(bufferToReadableStream(imageBuffer), {
status: 200,
headers: { 'content-type': 'image/png' },
});
(mockHono.request as jest.Mock).mockResolvedValue(mockHonoResponse);
const { mockRes, getOutputBuffer } = createMockResponse();
const adapter = honoAdapterForExpress(mockHono);
await adapter(mockExpressRequest, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.setHeader).toHaveBeenCalledWith('content-type', 'image/png');
await new Promise((resolve) => (mockRes as any).on('finish', resolve));
expect(getOutputBuffer()).toEqual(imageBuffer);
});
it('should adapt a Hono response with no body', async () => {
const mockHonoResponse = new Response(null, {
status: 204,
headers: { 'x-custom-header': 'empty' },
});
(mockHono.request as jest.Mock).mockResolvedValue(mockHonoResponse);
const { mockRes, getOutputString } = createMockResponse();
const adapter = honoAdapterForExpress(mockHono);
await adapter(mockExpressRequest, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(204);
expect(mockRes.setHeader).toHaveBeenCalledWith('x-custom-header', 'empty');
await new Promise((resolve) => (mockRes as any).on('finish', resolve));
expect(getOutputString()).toBe('');
});
it('should handle POST request with body', async () => {
const postBody = { data: 'some data' };
const postBodyString = JSON.stringify(postBody);
const mockPostRequest = {
...mockExpressRequest,
method: 'POST',
body: Readable.from(postBodyString) as any,
headers: { 'content-type': 'application/json', 'content-length': String(postBodyString.length) },
} as Request;
(mockPostRequest as any).duplex = 'half';
const mockHonoResponse = new Response(stringToReadableStream('{"status":"ok"}'), {
status: 200,
headers: { 'content-type': 'application/json' },
});
(mockHono.request as jest.Mock).mockResolvedValue(mockHonoResponse);
const { mockRes, getOutputString } = createMockResponse();
const adapter = honoAdapterForExpress(mockHono);
await adapter(mockPostRequest, mockRes);
expect(mockHono.request).toHaveBeenCalledWith(
'/test',
expect.objectContaining({
method: 'POST',
body: expect.anything(),
headers: expect.any(Headers),
}),
expect.anything(),
);
const calledWithHeaders = (mockHono.request as jest.Mock).mock.calls[0][1].headers as Headers;
expect(calledWithHeaders.get('content-type')).toBe('application/json');
expect(mockRes.status).toHaveBeenCalledWith(200);
await new Promise((resolve) => (mockRes as any).on('finish', resolve));
expect(getOutputString()).toBe('{"status":"ok"}');
});
it('should correctly set duplex and handle isDisturbed for request stream', async () => {
const mockHonoResponse = new Response(null, { status: 204 });
(mockHono.request as jest.Mock).mockResolvedValue(mockHonoResponse);
const { mockRes } = createMockResponse();
const adapter = honoAdapterForExpress(mockHono);
mockExpressRequest.on = jest.fn();
Object.defineProperty(mockExpressRequest, 'readableFlowing', { value: null, writable: true });
jest.spyOn(Readable, 'isDisturbed').mockReturnValue(false);
await adapter(mockExpressRequest, mockRes);
expect((mockExpressRequest as any).duplex).toBe('half');
expect(mockHono.request).toHaveBeenCalledTimes(1);
(mockHono.request as jest.Mock).mockClear();
jest.spyOn(Readable, 'isDisturbed').mockReturnValue(true);
await adapter(mockExpressRequest, mockRes);
expect(mockHono.request).not.toHaveBeenCalled();
jest.restoreAllMocks();
});
});

View File

@@ -0,0 +1,42 @@
import { Readable } from 'node:stream';
import type { Request, Response } from 'express';
import type { Hono } from 'hono';
export const honoAdapterForExpress = (hono: Hono) => async (expressReq: Request, res: Response) => {
(expressReq as unknown as any).duplex = 'half';
if (Readable.isDisturbed(expressReq)) {
return;
}
const { body, ...req } = expressReq;
const honoRes = await hono.request(
expressReq.originalUrl,
{
...req,
...(['POST', 'PUT', 'DELETE'].includes(expressReq.method) && { body: expressReq as unknown as ReadableStream }),
headers: new Headers(Object.fromEntries(Object.entries(expressReq.headers)) as Record<string, string>),
},
{
incoming: expressReq,
},
);
res.status(honoRes.status);
honoRes.headers.forEach((value, key) => res.setHeader(key, value));
if (!honoRes.body) {
res.end();
return;
}
if (honoRes.body instanceof ReadableStream) {
Readable.fromWeb(honoRes.body as any).pipe(res);
return;
}
// Converting it to a Buffer because res.send appends always a charset to the Content-Type
// https://github.com/expressjs/express/issues/2238
res.send(Buffer.from(await honoRes.text()));
};

View File

@@ -0,0 +1,60 @@
import { parseQueryParams } from './parseQueryParams';
describe('parseQueryParams', () => {
it('should parse simple query string', () => {
const result = parseQueryParams('foo=bar');
expect(result).toEqual({ foo: 'bar' });
});
it('should parse multiple query parameters', () => {
const result = parseQueryParams('foo=bar&baz=qux');
expect(result).toEqual({ foo: 'bar', baz: 'qux' });
});
it('should parse array parameters', () => {
const result = parseQueryParams('ids[]=1&ids[]=2&ids[]=3');
expect(result).toEqual({ ids: ['1', '2', '3'] });
});
it('should parse nested objects', () => {
const result = parseQueryParams('user[name]=john&user[age]=30');
expect(result).toEqual({ user: { name: 'john', age: '30' } });
});
it('should handle empty query string', () => {
const result = parseQueryParams('');
expect(result).toEqual({});
});
it('should decode URL encoded values', () => {
const result = parseQueryParams('name=John%20Doe');
expect(result).toEqual({ name: 'John Doe' });
});
it('should handle boolean-like values as strings', () => {
const result = parseQueryParams('active=true&disabled=false');
expect(result).toEqual({ active: 'true', disabled: 'false' });
});
it('should throw error when array limit is exceeded', () => {
const largeArray = Array(501)
.fill(0)
.map((_, i) => `ids[]=${i}`)
.join('&');
expect(() => parseQueryParams(largeArray)).toThrow();
});
it('should parse arrays within the limit', () => {
const array = Array(500)
.fill(0)
.map((_, i) => `ids[]=${i}`)
.join('&');
const result = parseQueryParams(array);
expect(result.ids).toHaveLength(500);
});
it('should parse as array even without brackets', () => {
const result = parseQueryParams('ids=1&ids=2');
expect(result.ids).toHaveLength(2);
});
});

View File

@@ -0,0 +1,5 @@
import qs from 'qs'; // Using qs specifically to keep express compatibility
export function parseQueryParams(url: string) {
return qs.parse(url, { arrayLimit: 500, throwOnLimitExceeded: true });
}

View File

@@ -0,0 +1,10 @@
{
"extends": "@rocket.chat/tsconfig/server.json",
"compilerOptions": {
"lib": ["esnext"],
"declaration": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["./src/**/*"]
}