From c3b962ba61ffa51f418a5f1b2fe8ed82dc18e9a7 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 1 May 2026 12:52:52 -0400 Subject: [PATCH 01/27] chore: drop Node.js 18 support for web-api and webhook --- .changeset/drop-node18-web-api.md | 5 +++++ .changeset/drop-node18-webhook.md | 5 +++++ .github/workflows/ci-build.yml | 14 +++++++++++++- packages/web-api/README.md | 2 +- packages/web-api/package.json | 4 ++-- packages/webhook/README.md | 2 +- packages/webhook/package.json | 4 ++-- 7 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 .changeset/drop-node18-web-api.md create mode 100644 .changeset/drop-node18-webhook.md diff --git a/.changeset/drop-node18-web-api.md b/.changeset/drop-node18-web-api.md new file mode 100644 index 000000000..1cc69602b --- /dev/null +++ b/.changeset/drop-node18-web-api.md @@ -0,0 +1,5 @@ +--- +"@slack/web-api": major +--- + +Drop Node.js 18 support. The minimum supported Node.js version is now 20. diff --git a/.changeset/drop-node18-webhook.md b/.changeset/drop-node18-webhook.md new file mode 100644 index 000000000..ea953e2d3 --- /dev/null +++ b/.changeset/drop-node18-webhook.md @@ -0,0 +1,5 @@ +--- +"@slack/webhook": major +--- + +Drop Node.js 18 support. The minimum supported Node.js version is now 20. diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index b7c00562e..cc675f93a 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -68,8 +68,20 @@ jobs: - name: Build docs if: matrix.node-version == env.LATEST_SUPPORTED_NODE run: npm run docs + - name: Run tests (Node 18 - excludes packages requiring Node >= 20) + if: matrix.node-version == '18.x' + # @slack/web-api and @slack/webhook require Node >= 20. + # Update this list when adding new packages. + run: | + npm test --workspace=@slack/cli-hooks + npm test --workspace=@slack/cli-test + npm test --workspace=@slack/logger + npm test --workspace=@slack/oauth + npm test --workspace=@slack/rtm-api + npm test --workspace=@slack/socket-mode + npm test --workspace=@slack/types - name: Run tests - if: matrix.node-version != env.LATEST_SUPPORTED_NODE + if: matrix.node-version != env.LATEST_SUPPORTED_NODE && matrix.node-version != '18.x' run: npm test - name: Run test coverage if: matrix.node-version == env.LATEST_SUPPORTED_NODE diff --git a/packages/web-api/README.md b/packages/web-api/README.md index c5b39509b..86f7bab41 100644 --- a/packages/web-api/README.md +++ b/packages/web-api/README.md @@ -8,7 +8,7 @@ The `@slack/web-api` package contains a simple, convenient, and configurable HTT ## Requirements -This package supports Node v18 and higher. It's highly recommended to use [the latest LTS version of +This package supports Node v20 and higher. It's highly recommended to use [the latest LTS version of node](https://github.com/nodejs/Release#release-schedule), and the documentation is written using syntax and features from that version. diff --git a/packages/web-api/package.json b/packages/web-api/package.json index 22a38cddc..6d9cb831b 100644 --- a/packages/web-api/package.json +++ b/packages/web-api/package.json @@ -21,7 +21,7 @@ "dist/**/*" ], "engines": { - "node": ">= 18", + "node": ">= 20", "npm": ">= 8.6.0" }, "repository": { @@ -50,7 +50,7 @@ "dependencies": { "@slack/logger": "^4.0.1", "@slack/types": "^2.20.1", - "@types/node": ">=18", + "@types/node": ">=20", "@types/retry": "0.12.0", "axios": "^1.15.0", "eventemitter3": "^5.0.1", diff --git a/packages/webhook/README.md b/packages/webhook/README.md index 5461a2819..a6ed6955d 100644 --- a/packages/webhook/README.md +++ b/packages/webhook/README.md @@ -6,7 +6,7 @@ The `@slack/webhook` package contains a helper for making requests to Slack's [I Webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks). Use it in your app to send a notification to a channel. ## Requirements -This package supports Node v18 and higher. It's highly recommended to use [the latest LTS version of +This package supports Node v20 and higher. It's highly recommended to use [the latest LTS version of node](https://github.com/nodejs/Release#release-schedule), and the documentation is written using syntax and features from that version. diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 709cf3138..aeafe0d09 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -18,7 +18,7 @@ "dist/**/*" ], "engines": { - "node": ">= 18", + "node": ">= 20", "npm": ">= 8.6.0" }, "repository": { @@ -42,7 +42,7 @@ }, "dependencies": { "@slack/types": "^2.20.1", - "@types/node": ">=18", + "@types/node": ">=20", "axios": "^1.15.0" }, "devDependencies": { From 5033bcd12efb677cfb849395cf91909f88f88267 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 1 May 2026 12:58:20 -0400 Subject: [PATCH 02/27] chore: sync package-lock.json after dropping Node.js 18 support Co-Authored-By: Claude --- package-lock.json | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5d4c8952a..bc70c0cbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6464,7 +6464,7 @@ "dependencies": { "@slack/logger": "^4.0.1", "@slack/types": "^2.20.1", - "@types/node": ">=18", + "@types/node": ">=20", "@types/retry": "0.12.0", "axios": "^1.15.0", "eventemitter3": "^5.0.1", @@ -6484,10 +6484,19 @@ "tsd": "^0.33.0" }, "engines": { - "node": ">= 18", + "node": ">= 20", "npm": ">= 8.6.0" } }, + "packages/web-api/node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, "packages/web-api/node_modules/is-stream": { "version": "2.0.1", "license": "MIT", @@ -6498,22 +6507,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/web-api/node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, "packages/webhook": { "name": "@slack/webhook", "version": "7.0.9", "license": "MIT", "dependencies": { "@slack/types": "^2.20.1", - "@types/node": ">=18", + "@types/node": ">=20", "axios": "^1.15.0" }, "devDependencies": { "nock": "^14.0.6" }, "engines": { - "node": ">= 18", + "node": ">= 20", "npm": ">= 8.6.0" } + }, + "packages/webhook/node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "packages/webhook/node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" } } } From ea9eefaea907d415dfd785c57f3fead7a6f02c04 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 1 May 2026 13:01:04 -0400 Subject: [PATCH 03/27] improve naming --- .github/workflows/ci-build.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index cc675f93a..b39cbdde3 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -68,10 +68,9 @@ jobs: - name: Build docs if: matrix.node-version == env.LATEST_SUPPORTED_NODE run: npm run docs - - name: Run tests (Node 18 - excludes packages requiring Node >= 20) + - name: Run tests (Node 18) if: matrix.node-version == '18.x' # @slack/web-api and @slack/webhook require Node >= 20. - # Update this list when adding new packages. run: | npm test --workspace=@slack/cli-hooks npm test --workspace=@slack/cli-test From cdcf0675d4b008ec053c68bd6d4ff55e78338d02 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 1 May 2026 16:16:15 -0400 Subject: [PATCH 04/27] fix: move from axios to fetch --- package-lock.json | 157 +----- packages/web-api/package.json | 4 - packages/web-api/src/WebClient.test.ts | 200 ++----- packages/web-api/src/WebClient.ts | 515 +++++++----------- packages/web-api/src/chat-stream.ts | 69 ++- packages/web-api/src/errors.ts | 42 +- packages/web-api/src/index.ts | 3 +- packages/web-api/src/methods.ts | 19 +- .../web-api/test/types/methods/chat.test-d.ts | 12 +- packages/webhook/package.json | 5 +- packages/webhook/src/IncomingWebhook.test.ts | 50 +- packages/webhook/src/IncomingWebhook.ts | 101 ++-- packages/webhook/src/errors.ts | 13 +- packages/webhook/src/index.ts | 2 + 14 files changed, 486 insertions(+), 706 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc70c0cbe..59a79d420 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1582,12 +1582,6 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1604,17 +1598,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/axios": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", - "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/babel-runtime": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", @@ -1870,6 +1853,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2086,18 +2070,6 @@ "node": ">=12.20" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/compare-module-exports": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/compare-module-exports/-/compare-module-exports-2.1.0.tgz", @@ -2408,15 +2380,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/des.js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", @@ -2505,6 +2468,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -2611,6 +2575,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2620,6 +2585,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2629,6 +2595,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2637,21 +2604,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -2877,26 +2829,6 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -2913,22 +2845,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -2963,6 +2879,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2972,6 +2889,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2996,6 +2914,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -3069,6 +2988,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3121,6 +3041,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3133,6 +3054,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -3173,6 +3095,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3363,12 +3286,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-electron": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", - "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", - "license": "MIT" - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3812,6 +3729,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3931,27 +3849,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4653,15 +4550,6 @@ "node": ">= 8" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/proxyquire": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", @@ -6466,11 +6354,7 @@ "@slack/types": "^2.20.1", "@types/node": ">=20", "@types/retry": "0.12.0", - "axios": "^1.15.0", "eventemitter3": "^5.0.1", - "form-data": "^4.0.4", - "is-electron": "2.2.2", - "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" @@ -6497,16 +6381,6 @@ "undici-types": "~7.19.0" } }, - "packages/web-api/node_modules/is-stream": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/web-api/node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -6515,12 +6389,11 @@ }, "packages/webhook": { "name": "@slack/webhook", - "version": "7.0.9", + "version": "8.0.0-rc.1", "license": "MIT", "dependencies": { "@slack/types": "^2.20.1", - "@types/node": ">=20", - "axios": "^1.15.0" + "@types/node": ">=20" }, "devDependencies": { "nock": "^14.0.6" diff --git a/packages/web-api/package.json b/packages/web-api/package.json index 6d9cb831b..5d19950c3 100644 --- a/packages/web-api/package.json +++ b/packages/web-api/package.json @@ -52,11 +52,7 @@ "@slack/types": "^2.20.1", "@types/node": ">=20", "@types/retry": "0.12.0", - "axios": "^1.15.0", "eventemitter3": "^5.0.1", - "form-data": "^4.0.4", - "is-electron": "2.2.2", - "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" diff --git a/packages/web-api/src/WebClient.test.ts b/packages/web-api/src/WebClient.test.ts index 6a4b1d355..31534c95c 100644 --- a/packages/web-api/src/WebClient.test.ts +++ b/packages/web-api/src/WebClient.test.ts @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { afterEach, beforeEach, describe, it } from 'node:test'; import type { ContextActionsBlock } from '@slack/types'; -import axios, { type InternalAxiosRequestConfig } from 'axios'; import nock, { type ReplyHeaders } from 'nock'; import sinon from 'sinon'; import { @@ -20,13 +19,7 @@ import { import { addAppMetadata } from './instrument'; import { type Logger, LogLevel } from './logger'; import { rapidRetryPolicy } from './retry-policies'; -import { - buildThreadTsWarningMessage, - type RequestConfig, - type WebAPICallResult, - WebClient, - WebClientEvent, -} from './WebClient'; +import { buildThreadTsWarningMessage, type WebAPICallResult, WebClient, WebClientEvent } from './WebClient'; const token = 'xoxb-faketoken'; @@ -123,12 +116,19 @@ describe('WebClient', () => { it('should throw error if timeout exceeded', async () => { const timeoutOverride = 1; // ms, guaranteed failure - // Mock a slow response to trigger timeout - delayConnection simulates network latency - nock('https://slack.com').post('/api/users.list').delayConnection(100).reply(200, { ok: true }); + const slowFetch: typeof globalThis.fetch = (_input, init) => + new Promise((_resolve, reject) => { + const timer = setTimeout(() => reject(new Error('should have been aborted')), 5000); + init?.signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(init.signal?.reason); + }); + }); const client = new WebClient(undefined, { timeout: timeoutOverride, retryConfig: { retries: 0 }, + fetch: slowFetch, }); try { @@ -1050,116 +1050,53 @@ describe('WebClient', () => { }); }); - describe('requestInterceptor', () => { - function configureMockServer(expectedBody: () => Record) { - nock('https://slack.com/api', { - reqheaders: { - test: 'static-header-value', - 'Content-Type': 'application/json', - }, - }) - .post(/method/, (requestBody) => { - assert.deepStrictEqual(requestBody, expectedBody()); - return true; - }) - .reply(200, (_uri, requestBody) => { - assert.deepStrictEqual(requestBody, expectedBody()); - return { ok: true, response_metadata: requestBody }; + describe('custom fetch', () => { + it('should use a custom fetch function when provided via constructor', async () => { + let fetchCalled = false; + const customFetch: typeof globalThis.fetch = async () => { + fetchCalled = true; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, }); - } - - it('can intercept out going requests, synchronously modifying the request body and headers', async () => { - let expectedBody: Record; - - const client = new WebClient(token, { - requestInterceptor: (config: RequestConfig) => { - expectedBody = Object.freeze({ - method: config.method, - base_url: config.baseURL, - path: config.url, - body: config.data ?? {}, - query: config.params ?? {}, - headers: structuredClone(config.headers), - test: 'static-body-value', - }); - config.data = expectedBody; - - config.headers.test = 'static-header-value'; - config.headers['Content-Type'] = 'application/json'; - - return config; - }, - }); - - configureMockServer(() => expectedBody); - + }; + const client = new WebClient(token, { fetch: customFetch, retryConfig: { retries: 0 } }); await client.apiCall('method'); + assert.ok(fetchCalled); }); - it('can intercept out going requests, asynchronously modifying the request body and headers', async () => { - let expectedBody: Record; - - const client = new WebClient(token, { - requestInterceptor: async (config: RequestConfig) => { - expectedBody = Object.freeze({ - method: config.method, - base_url: config.baseURL, - path: config.url, - body: config.data ?? {}, - query: config.params ?? {}, - headers: structuredClone(config.headers), - test: 'static-body-value', - }); - - config.data = expectedBody; - - config.headers.test = 'static-header-value'; - config.headers['Content-Type'] = 'application/json'; - - return config; - }, - }); - - configureMockServer(() => expectedBody); - - await client.apiCall('method'); + it('should use a per-request fetch override', async () => { + let fetchCalled = false; + const customFetch: typeof globalThis.fetch = async () => { + fetchCalled = true; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + const client = new WebClient(token, { retryConfig: { retries: 0 } }); + await client.apiCall('method', {}, { fetch: customFetch }); + assert.ok(fetchCalled); }); - }); - describe('adapter', () => { - it('allows for custom handling of requests with preconfigured http client', async () => { - nock('https://slack.com/api', { - reqheaders: { - 'User-Agent': 'custom-axios-client', - }, - }) - .post(/method/) - .reply(200, (_uri, requestBody) => { - return { ok: true, response_metadata: requestBody }; + it('should use a per-request signal for abort', async () => { + const slowFetch: typeof globalThis.fetch = (_input, init) => { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })), 5000); + init?.signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(init.signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError')); + }); }); - - const customLoggingInterceptor = (config: InternalAxiosRequestConfig) => { - // client with custom logging behaviour - return config; }; - const customLoggingSpy = sinon.spy(customLoggingInterceptor); - - const customAxiosClient = axios.create(); - customAxiosClient.interceptors.request.use(customLoggingSpy); - - const customClientRequestSpy = sinon.spy(customAxiosClient, 'request'); - - const client = new WebClient(token, { - adapter: (config: RequestConfig) => { - config.headers['User-Agent'] = 'custom-axios-client'; - return customAxiosClient.request(config); - }, - }); - - await client.apiCall('method'); - - assert.strictEqual(customLoggingSpy.calledOnce, true); - assert.strictEqual(customClientRequestSpy.calledOnce, true); + const client = new WebClient(token, { fetch: slowFetch, retryConfig: { retries: 0 } }); + try { + await client.apiCall('method', {}, { signal: AbortSignal.timeout(10) }); + assert.fail('expected error to be thrown'); + } catch (error) { + assert.ok(error instanceof Error); + assert.strictEqual((error as WebAPIRequestError).code, ErrorCode.RequestError); + } }); }); @@ -1962,52 +1899,21 @@ describe('WebClient', () => { }); }); - describe('has an option to suppress request error from Axios', () => { - let scope: nock.Scope; - beforeEach(() => { - scope = nock('https://slack.com').post(/api/).replyWithError('Request failed!!'); - }); - - it("the 'original' property is attached when the option, attachOriginalToWebAPIRequestError is absent", async () => { - const client = new WebClient(token, { - retryConfig: { retries: 0 }, - }); - - try { - await client.apiCall('conversations/list'); - } catch (error) { - assert.ok(Object.hasOwn(error, 'original')); - scope.done(); - } - }); - - it("the 'original' property is attached when the option, attachOriginalToWebAPIRequestError is set to true", async () => { + describe('request errors always attach original', () => { + it("the 'original' property is always attached to request errors", async () => { + const scope = nock('https://slack.com').post(/api/).replyWithError('Request failed!!'); const client = new WebClient(token, { - attachOriginalToWebAPIRequestError: true, retryConfig: { retries: 0 }, }); try { await client.apiCall('conversations/list'); + assert.fail('Should have thrown'); } catch (error) { assert.ok(Object.hasOwn(error, 'original')); scope.done(); } }); - - it("the 'original' property is not attached when the option, attachOriginalToWebAPIRequestError is set to false", async () => { - const client = new WebClient(token, { - attachOriginalToWebAPIRequestError: false, - retryConfig: { retries: 0 }, - }); - - try { - await client.apiCall('conversations/list'); - } catch (error) { - assert.ok(!Object.hasOwn(error, 'original')); - scope.done(); - } - }); }); }); diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index 3a5185a33..a0f873c47 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -1,20 +1,8 @@ -import type { Agent } from 'node:http'; import { basename } from 'node:path'; import { stringify as qsStringify } from 'node:querystring'; -import type { SecureContextOptions } from 'node:tls'; import { TextDecoder } from 'node:util'; import zlib from 'node:zlib'; -import axios, { - type AxiosAdapter, - type AxiosHeaderValue, - type AxiosInstance, - type AxiosResponse, - type InternalAxiosRequestConfig, -} from 'axios'; -import FormData from 'form-data'; -import isElectron from 'is-electron'; -import isStream from 'is-stream'; import pQueue from 'p-queue'; import pRetry, { AbortError } from 'p-retry'; import { ChatStreamer, type ChatStreamerOptions } from './chat-stream'; @@ -54,20 +42,6 @@ import type { /* * Helpers */ -// Props on axios default headers object to ignore when retrieving full list of actual headers sent in any HTTP requests -const axiosHeaderPropsToIgnore = [ - 'delete', - 'common', - 'get', - 'put', - 'head', - 'post', - 'link', - 'patch', - 'purge', - 'unlink', - 'options', -]; const defaultFilename = 'Untitled'; const defaultPageSize = 200; const noopPageReducer: PageReducer = () => undefined; @@ -88,8 +62,11 @@ export interface WebClientOptions { logLevel?: LogLevel; maxRequestConcurrency?: number; retryConfig?: RetryOptions; - agent?: Agent; - tls?: TLSOptions; + /** + * A custom `fetch` implementation conforming to the WHATWG Fetch standard. + * Defaults to `globalThis.fetch`. Use this to configure proxies, TLS, or other transport-level behavior. + */ + fetch?: FetchFunction; timeout?: number; rejectRateLimitedCalls?: boolean; headers?: Record; @@ -103,31 +80,8 @@ export interface WebClientOptions { * @default true */ allowAbsoluteUrls?: boolean; - /** - * Indicates whether to attach the original error to a Web API request error. - * When set to true, the original error object will be attached to the Web API request error. - * @type {boolean} - * @default true - */ - attachOriginalToWebAPIRequestError?: boolean; - /** - * Custom function to modify outgoing requests. See {@link https://axios-http.com/docs/interceptors Axios interceptor documentation} for more details. - * @type {Function | undefined} - * @default undefined - */ - requestInterceptor?: RequestInterceptor; - /** - * Custom functions for modifing and handling outgoing requests. - * Useful if you would like to manage outgoing request with a custom http client. - * See {@link https://github.com/axios/axios/blob/v1.x/README.md?plain=1#L586 Axios adapter documentation} for more information. - * @type {Function | undefined} - * @default undefined - */ - adapter?: AdapterConfig; } -export type TLSOptions = Pick; - export enum WebClientEvent { // TODO: safe to rename this to conform to PascalCase enum type naming convention? RATE_LIMITED = 'rate_limited', @@ -163,23 +117,14 @@ export type PageAccumulator = R extends ( ? A : never; -/** - * An alias to {@link https://github.com/axios/axios/blob/v1.x/index.d.ts#L367 Axios' `InternalAxiosRequestConfig`} object, - * which is the main parameter type provided to Axios interceptors and adapters. - */ -export type RequestConfig = InternalAxiosRequestConfig; - -/** - * An alias to {@link https://github.com/axios/axios/blob/v1.x/index.d.ts#L489 Axios' `AxiosInterceptorManager` onFufilled} method, - * which controls the custom request interceptor logic - */ -export type RequestInterceptor = (config: RequestConfig) => RequestConfig | Promise; +export type FetchFunction = typeof globalThis.fetch; -/** - * An alias to {@link https://github.com/axios/axios/blob/v1.x/index.d.ts#L112 Axios' `AxiosAdapter`} interface, - * which is the contract required to specify an adapter - */ -export type AdapterConfig = AxiosAdapter; +export interface RequestOptions { + /** Override the client-level `fetch` for this single request. */ + fetch?: FetchFunction; + /** Override the client-level timeout signal. Replaces the SDK's default AbortController. */ + signal?: AbortSignal; +} /** * A client for Slack's Web API @@ -210,14 +155,19 @@ export class WebClient extends Methods { private requestQueue: pQueue; /** - * Axios HTTP client instance used by this client + * The fetch function used for HTTP requests + */ + private fetchFn: FetchFunction; + + /** + * Request timeout in milliseconds */ - private axios: AxiosInstance; + private timeout: number; /** - * Configuration for custom TLS handling + * Default headers sent with every request */ - private tlsConfig: TLSOptions; + private defaultHeaders: Record; /** * Preference for immediately rejecting API calls which result in a rate-limited response @@ -239,27 +189,11 @@ export class WebClient extends Methods { */ private teamId?: string; - /** - * Determines if a dynamic method name being an absolute URL overrides the configured slackApiUrl. - * When set to false, the URL used in Slack API requests will always begin with the slackApiUrl. - * - * See {@link https://docs.slack.dev/tools/node-slack-sdk/web-api/#call-a-method} for more details. - * See {@link https://github.com/axios/axios?tab=readme-ov-file#request-config} for more details. - * @default true - */ private allowAbsoluteUrls: boolean; - /** - * Configuration to opt-out of attaching the original error - * (obtained from the HTTP client) to WebAPIRequestError. - */ - private attachOriginalToWebAPIRequestError: boolean; - /** * @param token - An API token to authenticate/authorize with Slack (usually start with `xoxp`, `xoxb`) * @param {Object} [webClientOptions] - Configuration options. - * @param {Function} [webClientOptions.requestInterceptor] - An interceptor to mutate outgoing requests. See {@link https://axios-http.com/docs/interceptors Axios interceptors} - * @param {Function} [webClientOptions.adapter] - An adapter to allow custom handling of requests. Useful if you would like to use a pre-configured http client. See {@link https://github.com/axios/axios/blob/v1.x/README.md?plain=1#L586 Axios adapter} */ public constructor( token?: string, @@ -269,16 +203,12 @@ export class WebClient extends Methods { logLevel = undefined, maxRequestConcurrency = 100, retryConfig = tenRetriesInAboutThirtyMinutes, - agent = undefined, - tls = undefined, + fetch = undefined, timeout = 0, rejectRateLimitedCalls = false, headers = {}, teamId = undefined, allowAbsoluteUrls = true, - attachOriginalToWebAPIRequestError = true, - requestInterceptor = undefined, - adapter = undefined, }: WebClientOptions = {}, ) { super(); @@ -291,12 +221,9 @@ export class WebClient extends Methods { this.retryConfig = retryConfig; this.requestQueue = new pQueue({ concurrency: maxRequestConcurrency }); - // NOTE: may want to filter the keys to only those acceptable for TLS options - this.tlsConfig = tls !== undefined ? tls : {}; this.rejectRateLimitedCalls = rejectRateLimitedCalls; this.teamId = teamId; this.allowAbsoluteUrls = allowAbsoluteUrls; - this.attachOriginalToWebAPIRequestError = attachOriginalToWebAPIRequestError; // Logging if (typeof logger !== 'undefined') { @@ -310,30 +237,9 @@ export class WebClient extends Methods { if (this.token && !headers.Authorization) headers.Authorization = `Bearer ${this.token}`; - this.axios = axios.create({ - adapter: adapter ? (config: InternalAxiosRequestConfig) => adapter({ ...config, adapter: undefined }) : undefined, - timeout, - baseURL: this.slackApiUrl, - headers: isElectron() ? headers : { 'User-Agent': getUserAgent(), ...headers }, - httpAgent: agent, - httpsAgent: agent, - validateStatus: () => true, // all HTTP status codes should result in a resolved promise (as opposed to only 2xx) - maxRedirects: 0, - // disabling axios' automatic proxy support: - // axios would read from envvars to configure a proxy automatically, but it doesn't support TLS destinations. - // for compatibility with https://api.slack.com, and for a larger set of possible proxies (SOCKS or other - // protocols), users of this package should use the `agent` option to configure a proxy. - proxy: false, - }); - // serializeApiCallData will always determine the appropriate content-type - this.axios.defaults.headers.post['Content-Type'] = undefined; - - // request interceptors have reversed execution order - // see: https://github.com/axios/axios/blob/v1.x/test/specs/interceptors.spec.js#L88 - if (requestInterceptor) { - this.axios.interceptors.request.use(requestInterceptor, null); - } - this.axios.interceptors.request.use(this.serializeApiCallData.bind(this), null); + this.fetchFn = fetch ?? globalThis.fetch; + this.timeout = timeout; + this.defaultHeaders = { 'User-Agent': getUserAgent(), ...headers }; this.logger.debug('initialized'); } @@ -341,34 +247,39 @@ export class WebClient extends Methods { /** * Generic method for calling a Web API method * @param method - the Web API method to call {@link https://docs.slack.dev/reference/methods} - * @param options - options + * @param args - arguments for the Web API method */ - public async apiCall(method: string, options: Record = {}): Promise { + public async apiCall( + method: string, + args: Record = {}, + options?: RequestOptions, + ): Promise { this.logger.debug(`apiCall('${method}') start`); warnDeprecations(method, this.logger); - warnIfFallbackIsMissing(method, this.logger, options); - warnIfThreadTsIsNotString(method, this.logger, options); + warnIfFallbackIsMissing(method, this.logger, args); + warnIfThreadTsIsNotString(method, this.logger, args); - if (typeof options === 'string' || typeof options === 'number' || typeof options === 'boolean') { - throw new TypeError(`Expected an options argument but instead received a ${typeof options}`); + if (typeof args === 'string' || typeof args === 'number' || typeof args === 'boolean') { + throw new TypeError(`Expected an options argument but instead received a ${typeof args}`); } warnIfNotUsingFilesUploadV2(method, this.logger); // @ts-expect-error insufficient overlap between Record and FilesUploadV2Arguments - if (method === 'files.uploadV2') return this.filesUploadV2(options as FilesUploadV2Arguments); + if (method === 'files.uploadV2') return this.filesUploadV2(args as FilesUploadV2Arguments, options); const headers: Record = {}; - if (options.token) headers.Authorization = `Bearer ${options.token}`; + if (args.token) headers.Authorization = `Bearer ${args.token}`; const url = this.deriveRequestUrl(method); const response = await this.makeRequest( url, { team_id: this.teamId, - ...options, + ...args, }, headers, + options, ); const result = await this.buildResult(response); this.logger.debug(`http request result: ${JSON.stringify(result)}`); @@ -401,7 +312,7 @@ export class WebClient extends Methods { // If result's content is gzip, "ok" property is not returned with successful response // TODO: look into simplifying this code block to only check for the second condition // if an { ok: false } body applies for all API errors - if (!result.ok && response.headers['content-type'] !== 'application/gzip') { + if (!result.ok && response.headers.get('content-type') !== 'application/gzip') { throw platformErrorFromResult(result as WebAPICallResult & { error: string }); } if ('ok' in result && result.ok === false) { @@ -426,28 +337,28 @@ export class WebClient extends Methods { * to use it in earlier JavaScript runtimes by transpiling your source with a tool like Babel. However, the * transpiled code will likely sacrifice performance. * @param method - the cursor-paginated Web API method to call {@link https://docs.slack.dev/apis/web-api/paginationn} - * @param options - options + * @param args - arguments for the Web API method * @param shouldStop - a predicate that is called with each page, and should return true when pagination can end. * @param reduce - a callback that can be used to accumulate a value that the return promise is resolved to */ - public paginate(method: string, options?: Record): AsyncIterable; - public paginate(method: string, options: Record, shouldStop: PaginatePredicate): Promise; + public paginate(method: string, args?: Record): AsyncIterable; + public paginate(method: string, args: Record, shouldStop: PaginatePredicate): Promise; public paginate>( method: string, - options: Record, + args: Record, shouldStop: PaginatePredicate, reduce?: PageReducer, ): Promise; public paginate>( method: string, - options?: Record, + args?: Record, shouldStop?: PaginatePredicate, reduce?: PageReducer, ): Promise | AsyncIterable { const pageSize = (() => { - if (options !== undefined && typeof options.limit === 'number') { - const { limit } = options; - options.limit = undefined; + if (args !== undefined && typeof args.limit === 'number') { + const { limit } = args; + args.limit = undefined; return limit; } return defaultPageSize; @@ -456,18 +367,18 @@ export class WebClient extends Methods { async function* generatePages(this: WebClient): AsyncIterableIterator { // when result is undefined, that signals that the first of potentially many calls has not yet been made let result: WebAPICallResult | undefined; - // paginationOptions stores pagination options not already stored in the options argument + // paginationOptions stores pagination options not already stored in the args argument let paginationOptions: CursorPaginationEnabled | undefined = { limit: pageSize, }; - if (options !== undefined && options.cursor !== undefined) { - paginationOptions.cursor = options.cursor as string; + if (args !== undefined && args.cursor !== undefined) { + paginationOptions.cursor = args.cursor as string; } // NOTE: test for the situation where you're resuming a pagination using and existing cursor while (result === undefined || paginationOptions !== undefined) { - result = await this.apiCall(method, Object.assign(options !== undefined ? options : {}, paginationOptions)); + result = await this.apiCall(method, Object.assign(args !== undefined ? args : {}, paginationOptions)); yield result; paginationOptions = paginationOptionsForNextPage(result, pageSize); } @@ -554,15 +465,16 @@ export class WebClient extends Methods { * URLs returned from step 1 (e.g. https://files.slack.com/upload/v1/...\") * * **#3**: Complete uploads {@link https://docs.slack.dev/reference/methods/files.completeuploadexternal files.completeUploadExternal} - * @param options + * @param args */ public async filesUploadV2( - options: FilesUploadV2Arguments, + args: FilesUploadV2Arguments, + options?: RequestOptions, ): Promise { this.logger.debug('files.uploadV2() start'); // 1 - const fileUploads = await this.getAllFileUploads(options); - const fileUploadsURLRes = await this.fetchAllUploadURLExternal(fileUploads); + const fileUploads = await this.getAllFileUploads(args); + const fileUploadsURLRes = await this.fetchAllUploadURLExternal(fileUploads, options); // set the upload_url and file_id returned from Slack fileUploadsURLRes.forEach((res, idx) => { fileUploads[idx].upload_url = res.upload_url; @@ -570,10 +482,10 @@ export class WebClient extends Methods { }); // 2 - await this.postFileUploadsToExternalURL(fileUploads, options); + await this.postFileUploadsToExternalURL(fileUploads, args, options); // 3 - const completion = await this.completeFileUploads(fileUploads); + const completion = await this.completeFileUploads(fileUploads, options); return { ok: true, files: completion }; } @@ -586,20 +498,21 @@ export class WebClient extends Methods { */ private async fetchAllUploadURLExternal( fileUploads: FileUploadV2Job[], + options?: RequestOptions, ): Promise> { return Promise.all( fileUploads.map((upload: FileUploadV2Job) => { - const options = { + const args = { filename: upload.filename, length: upload.length, alt_text: upload.alt_text, snippet_type: upload.snippet_type, } as FilesGetUploadURLExternalArguments; if ('token' in upload) { - options.token = upload.token; + args.token = upload.token; } - return this.files.getUploadURLExternal(options); + return this.files.getUploadURLExternal(args, options); }), ); } @@ -609,9 +522,12 @@ export class WebClient extends Methods { * @param fileUploads * @returns */ - private async completeFileUploads(fileUploads: FileUploadV2Job[]): Promise { + private async completeFileUploads( + fileUploads: FileUploadV2Job[], + options?: RequestOptions, + ): Promise { const toComplete: FilesCompleteUploadExternalArguments[] = Object.values(getAllFileUploadsToComplete(fileUploads)); - return Promise.all(toComplete.map((job) => this.files.completeUploadExternal(job))); + return Promise.all(toComplete.map((job) => this.files.completeUploadExternal(job, options))); } /** @@ -621,7 +537,8 @@ export class WebClient extends Methods { */ private async postFileUploadsToExternalURL( fileUploads: FileUploadV2Job[], - options: FilesUploadV2Arguments, + args: FilesUploadV2Arguments, + options?: RequestOptions, ): Promise> { return Promise.all( fileUploads.map(async (upload: FileUploadV2Job) => { @@ -632,7 +549,7 @@ export class WebClient extends Methods { // try to post to external url if (upload_url) { const headers: Record = {}; - if (options.token) headers.Authorization = `Bearer ${options.token}`; + if (args.token) headers.Authorization = `Bearer ${args.token}`; const uploadRes = await this.makeRequest( upload_url, @@ -640,11 +557,13 @@ export class WebClient extends Methods { body, }, headers, + options, ); if (uploadRes.status !== 200) { return Promise.reject(Error(`Failed to upload file (id:${file_id}, filename: ${filename})`)); } - const returnData = { ok: true, body: uploadRes.data } as WebAPICallResult; + const responseBody = await uploadRes.text(); + const returnData = { ok: true, body: responseBody } as WebAPICallResult; return Promise.resolve(returnData); } return Promise.reject(Error(`No upload url found for file (id: ${file_id}, filename: ${filename}`)); @@ -653,20 +572,20 @@ export class WebClient extends Methods { } /** - * @param options All file uploads arguments + * @param args All file uploads arguments * @returns An array of file upload entries */ - private async getAllFileUploads(options: FilesUploadV2Arguments): Promise { + private async getAllFileUploads(args: FilesUploadV2Arguments): Promise { let fileUploads: FileUploadV2Job[] = []; // add single file data to uploads if file or content exists at the top level - if ('file' in options || 'content' in options) { - fileUploads.push(await getFileUploadJob(options, this.logger)); + if ('file' in args || 'content' in args) { + fileUploads.push(await getFileUploadJob(args, this.logger)); } // add multiple files data when file_uploads is supplied - if ('file_uploads' in options) { - fileUploads = fileUploads.concat(await getMultipleFileUploadJobs(options, this.logger)); + if ('file_uploads' in args) { + fileUploads = fileUploads.concat(await getMultipleFileUploadJobs(args, this.logger)); } return fileUploads; } @@ -678,48 +597,38 @@ export class WebClient extends Methods { url: string, body: Record, headers: Record = {}, - ): Promise { - // TODO: better input types - remove any + options?: RequestOptions, + ): Promise { const task = () => this.requestQueue.add(async () => { - try { - // biome-ignore lint/suspicious/noExplicitAny: TODO: type this - const config: any = { - headers, - ...this.tlsConfig, - }; - // admin.analytics.getFile returns a binary response - // To be able to parse it, it should be read as an ArrayBuffer - if (url.endsWith('admin.analytics.getFile')) { - config.responseType = 'arraybuffer'; - } - // apps.event.authorizations.list will reject HTTP requests that send token in the body - // TODO: consider applying this change to all methods - though that will require thorough integration testing - if (url.endsWith('apps.event.authorizations.list')) { - body.token = undefined; - } - this.logger.debug(`http request url: ${url}`); - this.logger.debug(`http request body: ${JSON.stringify(redact(body))}`); - // compile all headers - some set by default under the hood by axios - that will be sent along - let allHeaders: Record = Object.keys( - this.axios.defaults.headers, - ).reduce( - (acc, cur) => { - if (!axiosHeaderPropsToIgnore.includes(cur)) { - acc[cur] = this.axios.defaults.headers[cur]; - } - return acc; - }, - {} as Record, - ); + // apps.event.authorizations.list will reject HTTP requests that send token in the body + if (url.endsWith('apps.event.authorizations.list')) { + body.token = undefined; + } + + const { serializedBody, contentHeaders } = this.serializeBody(body); + const allHeaders: Record = { ...this.defaultHeaders, ...contentHeaders, ...headers }; + + this.logger.debug(`http request url: ${url}`); + this.logger.debug(`http request body: ${JSON.stringify(redact(body))}`); + this.logger.debug(`http request headers: ${JSON.stringify(redact(allHeaders))}`); + + const effectiveFetch = options?.fetch ?? this.fetchFn; + const effectiveSignal = options?.signal; + + const controller = new AbortController(); + const timer = + !effectiveSignal && this.timeout > 0 ? setTimeout(() => controller.abort(), this.timeout) : undefined; + const signal = effectiveSignal ?? (timer ? controller.signal : undefined); - allHeaders = { - ...this.axios.defaults.headers.common, - ...allHeaders, - ...headers, - }; - this.logger.debug(`http request headers: ${JSON.stringify(redact(allHeaders))}`); - const response = await this.axios.post(url, body, config); + try { + const response = await effectiveFetch(url, { + method: 'POST', + headers: allHeaders, + body: serializedBody, + redirect: 'manual', + ...(signal ? { signal } : {}), + }); this.logger.debug('http response received'); if (response.status === 429) { @@ -730,47 +639,46 @@ export class WebClient extends Methods { throw new AbortError(rateLimitedErrorWithDelay(retrySec)); } this.logger.info(`API Call failed due to rate limiting. Will retry in ${retrySec} seconds.`); - // pause the request queue and then delay the rejection by the amount of time in the retry header this.requestQueue.pause(); - // NOTE: if there was a way to introspect the current RetryOperation and know what the next timeout - // would be, then we could subtract that time from the following delay, knowing that it the next - // attempt still wouldn't occur until after the rate-limit header has specified. an even better - // solution would be to subtract the time from only the timeout of this next attempt of the - // RetryOperation. this would result in the staying paused for the entire duration specified in the - // header, yet this operation not having to pay the timeout cost in addition to that. await delay(retrySec * 1000); - // resume the request queue and throw a non-abort error to signal a retry this.requestQueue.start(); - // TODO: We may want to have more detailed info such as team_id, params except tokens, and so on. throw new Error(`A rate limit was exceeded (url: ${url}, retry-after: ${retrySec})`); } - // TODO: turn this into some CodedError throw new AbortError( new Error( - `Retry header did not contain a valid timeout (url: ${url}, retry-after header: ${response.headers['retry-after']})`, + `Retry header did not contain a valid timeout (url: ${url}, retry-after header: ${response.headers.get('retry-after')})`, ), ); } // Slack's Web API doesn't use meaningful status codes besides 429 and 200 if (response.status !== 200) { - throw httpErrorFromResponse(response); + const responseBody = await response.text(); + throw httpErrorFromResponse( + response.status, + response.statusText, + Object.fromEntries(response.headers.entries()), + responseBody, + ); } return response; } catch (error) { - // To make this compatible with tsd, casting here instead of `catch (error: any)` // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const e = error as any; this.logger.warn('http request failed', e.message); - if (e.request) { - throw requestErrorWithOriginal(e, this.attachOriginalToWebAPIRequestError); + if (error instanceof AbortError) { + throw error; + } + if (e.code !== undefined && typeof e.code === 'string') { + throw error; } - throw error; + throw requestErrorWithOriginal(e instanceof Error ? e : new Error(String(e))); + } finally { + if (timer) clearTimeout(timer); } }); - // biome-ignore lint/suspicious/noExplicitAny: http responses can be anything - return pRetry(task, this.retryConfig) as Promise>; + return pRetry(task, this.retryConfig) as Promise; } /** @@ -782,21 +690,18 @@ export class WebClient extends Methods { if (isAbsoluteURL && this.allowAbsoluteUrls) { return url; } - return `${this.axios.getUri() + url}`; + return `${this.slackApiUrl}${url}`; } /** - * Transforms options (a simple key-value object) into an acceptable value for a body. This can be either - * a string, used when posting with a content-type of url-encoded. Or, it can be a readable stream, used - * when the options contain a binary (a stream or a buffer) and the upload should be done with content-type - * multipart/form-data. - * @param config - The Axios request configuration object + * Transforms a key-value object into a serialized body suitable for fetch. + * Returns either a FormData (for binary uploads) or a URL-encoded string, + * along with any content-type headers that should be set. */ - private serializeApiCallData(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig { - const { data, headers } = config; - - // The following operation both flattens complex objects into a JSON-encoded strings and searches the values for - // binary content + private serializeBody(data: Record): { + serializedBody: FormData | string; + contentHeaders: Record; + } { let containsBinaryData = false; // biome-ignore lint/suspicious/noExplicitAny: HTTP request data can be anything const flattened = Object.entries(data).map<[string, any] | []>(([key, value]) => { @@ -806,61 +711,41 @@ export class WebClient extends Methods { let serializedValue = value; - if (Buffer.isBuffer(value) || isStream(value)) { + if (Buffer.isBuffer(value)) { containsBinaryData = true; } else if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { - // if value is anything other than string, number, boolean, binary data, a Stream, or a Buffer, then encode it - // as a JSON string. serializedValue = JSON.stringify(value); } return [key, serializedValue]; }); - // A body with binary content should be serialized as multipart/form-data if (containsBinaryData) { this.logger.debug('Request arguments contain binary data'); - const form = flattened.reduce((frm, [key, value]) => { - if (Buffer.isBuffer(value) || isStream(value)) { - const opts: FormData.AppendOptions = {}; - opts.filename = (() => { - // attempt to find filename from `value`. adapted from: - // https://github.com/form-data/form-data/blob/028c21e0f93c5fefa46a7bbf1ba753e4f627ab7a/lib/form_data.js#L227-L230 - // formidable and the browser add a name property - // fs- and request- streams have path property - // biome-ignore lint/suspicious/noExplicitAny: form values can be anything - const streamOrBuffer: any = value as any; - if (typeof streamOrBuffer.name === 'string') { - return basename(streamOrBuffer.name); - } - if (typeof streamOrBuffer.path === 'string') { - return basename(streamOrBuffer.path); - } - return defaultFilename; - })(); - frm.append(key as string, value, opts); - } else if (key !== undefined && value !== undefined) { - frm.append(key, value); - } - return frm; - }, new FormData()); - if (headers) { - // Copying FormData-generated headers into headers param - // not reassigning to headers param since it is passed by reference and behaves as an inout param - for (const [header, value] of Object.entries(form.getHeaders())) { - headers[header] = value; + const form = new FormData(); + for (const [key, value] of flattened) { + if (key === undefined || value === undefined) continue; + if (Buffer.isBuffer(value)) { + // biome-ignore lint/suspicious/noExplicitAny: form values can be anything + const streamOrBuffer: any = value as any; + let filename = defaultFilename; + if (typeof streamOrBuffer.name === 'string') { + filename = basename(streamOrBuffer.name); + } else if (typeof streamOrBuffer.path === 'string') { + filename = basename(streamOrBuffer.path); + } + form.append(key, new Blob([new Uint8Array(value)]), filename); + } else { + form.append(key, String(value)); } } - config.data = form; - config.headers = headers; - return config; + // Do not set Content-Type — fetch auto-generates the multipart boundary + return { serializedBody: form, contentHeaders: {} }; } - // Otherwise, a simple key-value object is returned - if (headers) headers['Content-Type'] = 'application/x-www-form-urlencoded'; // biome-ignore lint/suspicious/noExplicitAny: form values can be anything const initialValue: { [key: string]: any } = {}; - config.data = qsStringify( + const encoded = qsStringify( flattened.reduce((accumulator, [key, value]) => { if (key !== undefined && value !== undefined) { accumulator[key] = value; @@ -868,8 +753,10 @@ export class WebClient extends Methods { return accumulator; }, initialValue), ); - config.headers = headers; - return config; + return { + serializedBody: encoded, + contentHeaders: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }; } /** @@ -877,26 +764,25 @@ export class WebClient extends Methods { * HTTP headers into the object. * @param response - an http response */ - private async buildResult(response: AxiosResponse): Promise { - let { data } = response; - const isGzipResponse = response.headers['content-type'] === 'application/gzip'; + private async buildResult(response: Response): Promise { + const contentType = response.headers.get('content-type'); + const isGzipResponse = contentType === 'application/gzip'; + + // biome-ignore lint/suspicious/noExplicitAny: HTTP response data can be anything + let data: any; // Check for GZIP response - if so, it is a successful response from admin.analytics.getFile if (isGzipResponse) { - // admin.analytics.getFile will return a Buffer that can be unzipped try { + const buffer = Buffer.from(await response.arrayBuffer()); const unzippedData = await new Promise((resolve, reject) => { - zlib.unzip(data, (err, buf) => { + zlib.unzip(buffer, (err, buf) => { if (err) { return reject(err); } return resolve(buf.toString().split('\n')); }); - }) - .then((res) => res) - .catch((err) => { - throw err; - }); + }); const fileData: Array< AdminAnalyticsMemberDetails | AdminAnalyticsPublicChannelDetails | AdminAnalyticsPublicChannelMetadataDetails > = []; @@ -911,19 +797,17 @@ export class WebClient extends Methods { } catch (err) { data = { ok: false, error: err }; } - } else if (!isGzipResponse && response.request.path === '/api/admin.analytics.getFile') { + } else if (!isGzipResponse && response.url.endsWith('admin.analytics.getFile')) { // if it isn't a Gzip response but is from the admin.analytics.getFile request, // decode the ArrayBuffer to JSON read the error - data = JSON.parse(new TextDecoder().decode(data)); - } - - if (typeof data === 'string') { - // response.data can be a string, not an object for some reason + const buffer = await response.arrayBuffer(); + data = JSON.parse(new TextDecoder().decode(buffer)); + } else { + const text = await response.text(); try { - data = JSON.parse(data); + data = JSON.parse(text); } catch (_) { - // failed to parse the string value as JSON data - data = { ok: false, error: data }; + data = { ok: false, error: text }; } } @@ -932,13 +816,13 @@ export class WebClient extends Methods { } // add scopes metadata from headers - if (response.headers['x-oauth-scopes'] !== undefined) { - data.response_metadata.scopes = (response.headers['x-oauth-scopes'] as string).trim().split(/\s*,\s*/); + const oauthScopes = response.headers.get('x-oauth-scopes'); + if (oauthScopes !== null) { + data.response_metadata.scopes = oauthScopes.trim().split(/\s*,\s*/); } - if (response.headers['x-accepted-oauth-scopes'] !== undefined) { - data.response_metadata.acceptedScopes = (response.headers['x-accepted-oauth-scopes'] as string) - .trim() - .split(/\s*,\s*/); + const acceptedOauthScopes = response.headers.get('x-accepted-oauth-scopes'); + if (acceptedOauthScopes !== null) { + data.response_metadata.acceptedScopes = acceptedOauthScopes.trim().split(/\s*,\s*/); } // add retry metadata from headers @@ -980,9 +864,10 @@ function paginationOptionsForNextPage( * Extract the amount of time (in seconds) the platform has recommended this client wait before sending another request * from a rate-limited HTTP response (statusCode = 429). */ -function parseRetryHeaders(response: AxiosResponse): number | undefined { - if (response.headers['retry-after'] !== undefined) { - const retryAfter = Number.parseInt(response.headers['retry-after'] as string, 10); +function parseRetryHeaders(response: Response): number | undefined { + const retryAfterHeader = response.headers.get('retry-after'); + if (retryAfterHeader !== null) { + const retryAfter = Number.parseInt(retryAfterHeader, 10); if (!Number.isNaN(retryAfter)) { return retryAfter; @@ -1015,34 +900,34 @@ function warnDeprecations(method: string, logger: Logger): void { * Log a warning when using chat.postMessage without text argument or attachments with fallback argument * @param method api method being called * @param logger instance of we clients logger - * @param options arguments for the Web API method + * @param args arguments for the Web API method */ -function warnIfFallbackIsMissing(method: string, logger: Logger, options?: Record): void { +function warnIfFallbackIsMissing(method: string, logger: Logger, args?: Record): void { const targetMethods = ['chat.postEphemeral', 'chat.postMessage', 'chat.scheduleMessage']; const isTargetMethod = targetMethods.includes(method); - const hasAttachments = (args: Record) => Array.isArray(args.attachments) && args.attachments.length; + const hasAttachments = (a: Record) => Array.isArray(a.attachments) && a.attachments.length; - const missingAttachmentFallbackDetected = (args: Record) => - Array.isArray(args.attachments) && - args.attachments.some((attachment) => !attachment.fallback || attachment.fallback.trim() === ''); + const missingAttachmentFallbackDetected = (a: Record) => + Array.isArray(a.attachments) && + a.attachments.some((attachment) => !attachment.fallback || attachment.fallback.trim() === ''); - const isEmptyText = (args: Record) => - (args.text === undefined || args.text === null || args.text === '') && - (args.markdown_text === undefined || args.markdown === null || args.markdown_text === ''); + const isEmptyText = (a: Record) => + (a.text === undefined || a.text === null || a.text === '') && + (a.markdown_text === undefined || a.markdown === null || a.markdown_text === ''); const buildMissingTextWarning = () => `The top-level \`text\` argument is missing in the request payload for a ${method} call - It's a best practice to always provide a \`text\` argument when posting a message. The \`text\` is used in places where the content cannot be rendered such as: system push notifications, assistive technology such as screen readers, etc.`; const buildMissingFallbackWarning = () => `Additionally, the attachment-level \`fallback\` argument is missing in the request payload for a ${method} call - To avoid this warning, it is recommended to always provide a top-level \`text\` argument when posting a message. Alternatively, you can provide an attachment-level \`fallback\` argument, though this is now considered a legacy field (see https://docs.slack.dev/legacy/legacy-messaging/legacy-secondary-message-attachments for more details).`; - if (isTargetMethod && typeof options === 'object') { - if (hasAttachments(options)) { - if (missingAttachmentFallbackDetected(options) && isEmptyText(options)) { + if (isTargetMethod && typeof args === 'object') { + if (hasAttachments(args)) { + if (missingAttachmentFallbackDetected(args) && isEmptyText(args)) { logger.warn(buildMissingTextWarning()); logger.warn(buildMissingFallbackWarning()); } - } else if (isEmptyText(options)) { + } else if (isEmptyText(args)) { logger.warn(buildMissingTextWarning()); } } @@ -1052,13 +937,13 @@ function warnIfFallbackIsMissing(method: string, logger: Logger, options?: Recor * Log a warning when thread_ts is not a string * @param method api method being called * @param logger instance of web clients logger - * @param options arguments for the Web API method + * @param args arguments for the Web API method */ -function warnIfThreadTsIsNotString(method: string, logger: Logger, options?: Record): void { +function warnIfThreadTsIsNotString(method: string, logger: Logger, args?: Record): void { const targetMethods = ['chat.postEphemeral', 'chat.postMessage', 'chat.scheduleMessage', 'files.upload']; const isTargetMethod = targetMethods.includes(method); - if (isTargetMethod && options?.thread_ts !== undefined && typeof options?.thread_ts !== 'string') { + if (isTargetMethod && args?.thread_ts !== undefined && typeof args?.thread_ts !== 'string') { logger.warn(buildThreadTsWarningMessage(method)); } } @@ -1088,7 +973,7 @@ function redact(body: Record): Record { } // when value is buffer or stream we can avoid logging it - if (Buffer.isBuffer(value) || isStream(value)) { + if (Buffer.isBuffer(value)) { serializedValue = '[[BINARY VALUE OMITTED]]'; } else if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { serializedValue = JSON.stringify(value); diff --git a/packages/web-api/src/chat-stream.ts b/packages/web-api/src/chat-stream.ts index 314117f2f..9bd1a829e 100644 --- a/packages/web-api/src/chat-stream.ts +++ b/packages/web-api/src/chat-stream.ts @@ -3,6 +3,7 @@ import type { AnyChunk } from '@slack/types'; import type { ChatAppendStreamArguments, ChatStartStreamArguments, ChatStopStreamArguments } from './types/request'; import type { ChatAppendStreamResponse, ChatStartStreamResponse, ChatStopStreamResponse } from './types/response'; import type WebClient from './WebClient'; +import type { RequestOptions } from './WebClient'; export interface ChatStreamerOptions { /** @@ -10,13 +11,18 @@ export interface ChatStreamerOptions { * @default 256 */ buffer_size?: number; + /** + * @description Request options that apply to all internal API calls made by this streamer. + */ + requestOptions?: RequestOptions; } export class ChatStreamer { private buffer = ''; private client: WebClient; private logger: Logger; - private options: Required; + private options: Required>; + private requestOptions: RequestOptions | undefined; private state: 'starting' | 'in_progress' | 'completed'; private streamArgs: ChatStartStreamArguments; private streamTs: string | undefined; @@ -50,6 +56,7 @@ export class ChatStreamer { this.options = { buffer_size: options.buffer_size ?? 256, }; + this.requestOptions = options.requestOptions; this.state = 'starting'; this.streamArgs = args; } @@ -132,10 +139,13 @@ export class ChatStreamer { this.buffer += markdown_text; } if (!this.streamTs) { - const response = await this.client.chat.startStream({ - ...this.streamArgs, - token: this.token, - }); + const response = await this.client.chat.startStream( + { + ...this.streamArgs, + token: this.token, + }, + this.requestOptions, + ); if (!response.ts) { throw new Error('failed to stop stream: stream not started'); } @@ -152,13 +162,16 @@ export class ChatStreamer { if (chunks) { chunksToFlush.push(...chunks); } - const response = await this.client.chat.stopStream({ - token: this.token, - channel: this.streamArgs.channel, - ts: this.streamTs, - chunks: chunksToFlush, - ...opts, - }); + const response = await this.client.chat.stopStream( + { + token: this.token, + channel: this.streamArgs.channel, + ts: this.streamTs, + chunks: chunksToFlush, + ...opts, + }, + this.requestOptions, + ); this.state = 'completed'; return response; } @@ -178,24 +191,30 @@ export class ChatStreamer { chunksToFlush.push(...chunks); } if (!this.streamTs) { - const response = await this.client.chat.startStream({ - ...this.streamArgs, - token: this.token, - chunks: chunksToFlush, - ...opts, - }); + const response = await this.client.chat.startStream( + { + ...this.streamArgs, + token: this.token, + chunks: chunksToFlush, + ...opts, + }, + this.requestOptions, + ); this.buffer = ''; this.streamTs = response.ts; this.state = 'in_progress'; return response; } - const response = await this.client.chat.appendStream({ - token: this.token, - channel: this.streamArgs.channel, - ts: this.streamTs, - chunks: chunksToFlush, - ...opts, - }); + const response = await this.client.chat.appendStream( + { + token: this.token, + channel: this.streamArgs.channel, + ts: this.streamTs, + chunks: chunksToFlush, + ...opts, + }, + this.requestOptions, + ); this.buffer = ''; return response; } diff --git a/packages/web-api/src/errors.ts b/packages/web-api/src/errors.ts index c6698acf6..32ebfe467 100644 --- a/packages/web-api/src/errors.ts +++ b/packages/web-api/src/errors.ts @@ -1,7 +1,5 @@ import type { IncomingHttpHeaders } from 'node:http'; -import type { AxiosResponse } from 'axios'; - import type { WebAPICallResult } from './WebClient'; /** @@ -75,38 +73,46 @@ export function errorWithCode(error: Error, code: ErrorCode): CodedError { /** * A factory to create WebAPIRequestError objects * @param original - original error - * @param attachOriginal - config indicating if 'original' property should be added on the error object */ -export function requestErrorWithOriginal(original: Error, attachOriginal: boolean): WebAPIRequestError { +export function requestErrorWithOriginal(original: Error): WebAPIRequestError { const error = errorWithCode( new Error(`A request error occurred: ${original.message}`), ErrorCode.RequestError, ) as Partial; - if (attachOriginal) { - error.original = original; - } + error.original = original; return error as WebAPIRequestError; } /** * A factory to create WebAPIHTTPError objects - * @param response - original error + * @param status - HTTP status code + * @param statusText - HTTP status text + * @param headers - response headers + * @param body - response body */ -export function httpErrorFromResponse(response: AxiosResponse): WebAPIHTTPError { +export function httpErrorFromResponse( + status: number, + statusText: string, + headers: Record, + // biome-ignore lint/suspicious/noExplicitAny: HTTP response bodies might be anything + body?: any, +): WebAPIHTTPError { const error = errorWithCode( - new Error(`An HTTP protocol error occurred: statusCode = ${response.status}`), + new Error(`An HTTP protocol error occurred: statusCode = ${status}`), ErrorCode.HTTPError, ) as Partial; - error.statusCode = response.status; - error.statusMessage = response.statusText; - const nonNullHeaders: Record = {}; - for (const k of Object.keys(response.headers)) { - if (k && response.headers[k]) { - nonNullHeaders[k] = response.headers[k]; + error.statusCode = status; + error.statusMessage = statusText; + error.headers = headers; + if (typeof body === 'string') { + try { + error.body = JSON.parse(body); + } catch { + error.body = body; } + } else { + error.body = body; } - error.headers = nonNullHeaders; - error.body = response.data; return error as WebAPIHTTPError; } diff --git a/packages/web-api/src/index.ts b/packages/web-api/src/index.ts index 534f0e34d..0f8dc19d3 100644 --- a/packages/web-api/src/index.ts +++ b/packages/web-api/src/index.ts @@ -22,10 +22,11 @@ export * from './types/response/index'; export { ChatStreamer, ChatStreamerOptions } from './chat-stream'; export { + FetchFunction, PageAccumulator, PageReducer, PaginatePredicate, - TLSOptions, + RequestOptions, WebAPICallResult, WebClient, WebClientEvent, diff --git a/packages/web-api/src/methods.ts b/packages/web-api/src/methods.ts index d6e584d9e..14e82dcb6 100644 --- a/packages/web-api/src/methods.ts +++ b/packages/web-api/src/methods.ts @@ -550,16 +550,18 @@ import type { WorkflowsUpdateStepResponse, } from './types/response/index'; -import { type WebAPICallResult, WebClient, type WebClientEvent } from './WebClient'; +import { type RequestOptions, type WebAPICallResult, WebClient, type WebClientEvent } from './WebClient'; /** * Generic method definition */ type MethodWithRequiredArgument = ( - options: MethodArguments, + args: MethodArguments, + options?: RequestOptions, ) => Promise; type MethodWithOptionalArgument = ( - options?: MethodArguments, + args?: MethodArguments, + options?: RequestOptions, ) => Promise; export default MethodWithOptionalArgument; @@ -606,8 +608,15 @@ export abstract class Methods extends EventEmitter { } } - public abstract apiCall(method: string, options?: Record): Promise; - public abstract filesUploadV2(options: FilesUploadV2Arguments): Promise; + public abstract apiCall( + method: string, + args?: Record, + options?: RequestOptions, + ): Promise; + public abstract filesUploadV2( + args: FilesUploadV2Arguments, + options?: RequestOptions, + ): Promise; public readonly admin = { analytics: { diff --git a/packages/web-api/test/types/methods/chat.test-d.ts b/packages/web-api/test/types/methods/chat.test-d.ts index 1f744f80f..afd4b336d 100644 --- a/packages/web-api/test/types/methods/chat.test-d.ts +++ b/packages/web-api/test/types/methods/chat.test-d.ts @@ -1,6 +1,6 @@ import { CustomFieldType, type EntityMetadata } from '@slack/types'; import { expectAssignable, expectError } from 'tsd'; -import { WebClient } from '../../../src/WebClient'; +import { type RequestOptions, WebClient } from '../../../src/WebClient'; const web = new WebClient('TOKEN'); @@ -1138,3 +1138,13 @@ expectAssignable>([ reply_broadcast: true, // can broadcast a threaded msg }, ]); + +// RequestOptions can be passed as a second argument to any method +expectAssignable>([ + { channel: 'C1234', text: 'hello' }, + { signal: new AbortController().signal } satisfies RequestOptions, +]); +expectAssignable>([ + { channel: 'C1234', text: 'hello' }, + { fetch: globalThis.fetch } satisfies RequestOptions, +]); diff --git a/packages/webhook/package.json b/packages/webhook/package.json index aeafe0d09..892793195 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -1,6 +1,6 @@ { "name": "@slack/webhook", - "version": "7.0.9", + "version": "8.0.0-rc.1", "description": "Official library for using the Slack Platform's Incoming Webhooks", "author": "Slack Technologies, LLC", "license": "MIT", @@ -42,8 +42,7 @@ }, "dependencies": { "@slack/types": "^2.20.1", - "@types/node": ">=20", - "axios": "^1.15.0" + "@types/node": ">=20" }, "devDependencies": { "nock": "^14.0.6" diff --git a/packages/webhook/src/IncomingWebhook.test.ts b/packages/webhook/src/IncomingWebhook.test.ts index a719309fb..93fb18a5a 100644 --- a/packages/webhook/src/IncomingWebhook.test.ts +++ b/packages/webhook/src/IncomingWebhook.test.ts @@ -22,14 +22,25 @@ describe('IncomingWebhook', () => { it('should create a default webhook with a default timeout', () => { const webhook = new IncomingWebhook(url); // biome-ignore lint/suspicious/noExplicitAny: accessing private property for test assertion - assert.strictEqual((webhook as any).defaults.timeout, 0); + assert.strictEqual((webhook as any).timeout, 0); }); - it('should create an axios instance that has the timeout passed by the user', () => { + it('should store the timeout passed by the user', () => { const givenTimeout = 100; const webhook = new IncomingWebhook(url, { timeout: givenTimeout }); // biome-ignore lint/suspicious/noExplicitAny: accessing private property for test assertion - assert.strictEqual((webhook as any).axios.defaults.timeout, givenTimeout); + assert.strictEqual((webhook as any).timeout, givenTimeout); + }); + + it('should use a custom fetch function when provided', async () => { + let fetchCalled = false; + const customFetch: typeof globalThis.fetch = async () => { + fetchCalled = true; + return new Response('ok', { status: 200 }); + }; + const webhook = new IncomingWebhook(url, { fetch: customFetch }); + await webhook.send('Hello'); + assert.ok(fetchCalled); }); }); @@ -116,5 +127,38 @@ describe('IncomingWebhook', () => { } }); }); + + describe('per-request options', () => { + it('should use a per-request fetch override', async () => { + let fetchCalled = false; + const customFetch: typeof globalThis.fetch = async () => { + fetchCalled = true; + return new Response('custom', { status: 200 }); + }; + const result = await webhook.send('Hello', { fetch: customFetch }); + assert.ok(fetchCalled); + assert.strictEqual(result.text, 'custom'); + }); + + it('should use a per-request signal for abort', async () => { + const slowFetch: typeof globalThis.fetch = (_input, init) => { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(new Response('ok', { status: 200 })), 5000); + init?.signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(init.signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError')); + }); + }); + }; + + try { + await webhook.send('Hello', { fetch: slowFetch, signal: AbortSignal.timeout(10) }); + assert.fail('expected rejection'); + } catch (error) { + assert.ok(error instanceof Error); + assert.strictEqual((error as CodedError).code, ErrorCode.RequestError); + } + }); + }); }); }); diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 8b5046121..0e426e4aa 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -1,11 +1,16 @@ -import type { Agent } from 'node:http'; - import type { Block, KnownBlock, MessageAttachment } from '@slack/types'; // TODO: Block and KnownBlock will be merged into AnyBlock in upcoming types release -import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; +import type { CodedError } from './errors'; import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors'; import { getUserAgent } from './instrument'; +export type FetchFunction = typeof globalThis.fetch; + +export interface RequestOptions { + fetch?: FetchFunction; + signal?: AbortSignal; +} + /** * A client for Slack's Incoming Webhooks */ @@ -21,9 +26,19 @@ export class IncomingWebhook { private defaults: IncomingWebhookDefaultArguments; /** - * Axios HTTP client instance used by this client + * The fetch function used for HTTP requests */ - private axios: AxiosInstance; + private fetchFn: FetchFunction; + + /** + * Request timeout in milliseconds + */ + private timeout: number; + + /** + * Default headers sent with every request + */ + private headers: Record; public constructor( url: string, @@ -36,29 +51,26 @@ export class IncomingWebhook { } this.url = url; - this.defaults = defaults; - - this.axios = axios.create({ - baseURL: url, - httpAgent: defaults.agent, - httpsAgent: defaults.agent, - maxRedirects: 0, - proxy: false, - timeout: defaults.timeout, - headers: { - 'User-Agent': getUserAgent(), - }, - }); - - this.defaults.agent = undefined; + this.fetchFn = defaults.fetch ?? globalThis.fetch; + this.timeout = defaults.timeout ?? 0; + this.headers = { + 'User-Agent': getUserAgent(), + }; + + // Remove transport options so they don't leak into payloads + const { fetch: _fetch, timeout: _timeout, ...messageDefaults } = defaults; + this.defaults = messageDefaults; } /** * Send a notification to a conversation * @param message - the message (a simple string, or an object describing the message) + * @param options - optional request-level overrides (custom fetch, abort signal) */ - public async send(message: string | IncomingWebhookSendArguments): Promise { - // NOTE: no support for TLS config + public async send( + message: string | IncomingWebhookSendArguments, + options?: RequestOptions, + ): Promise { let payload: IncomingWebhookSendArguments = { ...this.defaults }; if (typeof message === 'string') { @@ -67,28 +79,47 @@ export class IncomingWebhook { payload = Object.assign(payload, message); } + const effectiveFetch = options?.fetch ?? this.fetchFn; + const effectiveSignal = options?.signal; + + const controller = new AbortController(); + const timer = !effectiveSignal && this.timeout > 0 ? setTimeout(() => controller.abort(), this.timeout) : undefined; + const signal = effectiveSignal ?? (timer ? controller.signal : undefined); + try { - const response = await this.axios.post(this.url, payload); - return this.buildResult(response); - // biome-ignore lint/suspicious/noExplicitAny: errors can be anything - } catch (error: any) { - // Wrap errors in this packages own error types (abstract the implementation details' types) - if (error.response !== undefined) { - throw httpErrorWithOriginal(error); + const response = await effectiveFetch(this.url, { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + redirect: 'manual', + ...(signal ? { signal } : {}), + }); + + if (!response.ok) { + const body = await response.text(); + throw httpErrorWithOriginal(response.status, body); } - if (error.request !== undefined) { - throw requestErrorWithOriginal(error); + + return await this.buildResult(response); + } catch (error) { + if (error instanceof Error && 'code' in error && typeof (error as CodedError).code === 'string') { + throw error; } - throw error; + throw requestErrorWithOriginal(error instanceof Error ? error : new Error(String(error))); + } finally { + if (timer) clearTimeout(timer); } } /** * Processes an HTTP response into an IncomingWebhookResult. */ - private buildResult(response: AxiosResponse): IncomingWebhookResult { + private async buildResult(response: Response): Promise { return { - text: response.data, + text: await response.text(), }; } } @@ -104,7 +135,7 @@ export interface IncomingWebhookDefaultArguments { channel?: string; text?: string; link_names?: boolean; - agent?: Agent; + fetch?: FetchFunction; timeout?: number; } diff --git a/packages/webhook/src/errors.ts b/packages/webhook/src/errors.ts index 1252b190a..2cf97cd95 100644 --- a/packages/webhook/src/errors.ts +++ b/packages/webhook/src/errors.ts @@ -1,5 +1,3 @@ -import type { AxiosError, AxiosResponse } from 'axios'; - /** * All errors produced by this package adhere to this interface */ @@ -41,7 +39,7 @@ function errorWithCode(error: Error, code: ErrorCode): CodedError { * A factory to create IncomingWebhookRequestError objects * @param original The original error */ -export function requestErrorWithOriginal(original: AxiosError): IncomingWebhookRequestError { +export function requestErrorWithOriginal(original: Error): IncomingWebhookRequestError { const error = errorWithCode( new Error(`A request error occurred: ${original.message}`), ErrorCode.RequestError, @@ -52,13 +50,14 @@ export function requestErrorWithOriginal(original: AxiosError): IncomingWebhookR /** * A factory to create IncomingWebhookHTTPError objects - * @param original The original error + * @param status The HTTP status code + * @param body The response body text */ -export function httpErrorWithOriginal(original: AxiosError & { response: AxiosResponse }): IncomingWebhookHTTPError { +export function httpErrorWithOriginal(status: number, body: string): IncomingWebhookHTTPError { const error = errorWithCode( - new Error(`An HTTP protocol error occurred: statusCode = ${original.response.status}`), + new Error(`An HTTP protocol error occurred: statusCode = ${status}`), ErrorCode.HTTPError, ) as Partial; - error.original = original; + error.original = new Error(`An HTTP protocol error occurred: statusCode = ${status}, body = ${body}`); return error as IncomingWebhookHTTPError; } diff --git a/packages/webhook/src/index.ts b/packages/webhook/src/index.ts index 74420ffba..75bb1dc9f 100644 --- a/packages/webhook/src/index.ts +++ b/packages/webhook/src/index.ts @@ -9,8 +9,10 @@ export { } from './errors'; export { + FetchFunction, IncomingWebhook, IncomingWebhookDefaultArguments, IncomingWebhookResult, IncomingWebhookSendArguments, + RequestOptions, } from './IncomingWebhook'; From 13183aa7f5d9a2ea77ca0fd2cdaeacfa6f3b1b6c Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 4 May 2026 15:01:15 -0400 Subject: [PATCH 05/27] make rtm use older version of packages --- package-lock.json | 335 ++++++++++++++++++++++++++++++---- packages/logger/package.json | 2 +- packages/rtm-api/package.json | 4 +- packages/web-api/package.json | 2 +- 4 files changed, 304 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5b48f559d..c9b9c487d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1582,6 +1582,12 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1598,6 +1604,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/babel-runtime": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", @@ -1853,7 +1870,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2070,6 +2086,18 @@ "node": ">=12.20" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/compare-module-exports": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/compare-module-exports/-/compare-module-exports-2.1.0.tgz", @@ -2380,6 +2408,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/des.js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", @@ -2468,7 +2505,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -2575,7 +2611,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2585,7 +2620,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2595,7 +2629,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2604,6 +2637,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -2829,6 +2877,26 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -2845,6 +2913,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -2879,7 +2963,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2889,7 +2972,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2914,7 +2996,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -2988,7 +3069,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3041,7 +3121,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3054,7 +3133,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -3095,7 +3173,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3286,6 +3363,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3729,7 +3812,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3849,6 +3931,27 @@ "dev": true, "license": "MIT" }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4550,6 +4653,15 @@ "node": ">= 8" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/proxyquire": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", @@ -6224,7 +6336,7 @@ }, "engines": { "node": ">= 20", - "npm": ">= 8.6.0" + "npm": ">=9.6.4" } }, "packages/cli-test": { @@ -6241,19 +6353,20 @@ "sinon": "^21.0.0" }, "engines": { - "node": ">=20" + "node": ">=20", + "npm": ">=9.6.4" } }, "packages/logger": { "name": "@slack/logger", - "version": "4.0.1", + "version": "5.0.0", "license": "MIT", "dependencies": { "@types/node": ">=20" }, "engines": { "node": ">= 20", - "npm": ">= 8.6.0" + "npm": ">=9.6.4" } }, "packages/logger/node_modules/@types/node": { @@ -6289,7 +6402,44 @@ }, "engines": { "node": ">=20", - "npm": ">=8.6.0" + "npm": ">=9.6.4" + } + }, + "packages/oauth/node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "packages/oauth/node_modules/@slack/web-api": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.15.1.tgz", + "integrity": "sha512-y+TAF7TszcmFzbVtBkFqAdBwKSoD+8shkNxhp4WIfFwXmCKdFje9WD6evROApPa2FTy1v1uc9yBaJs3609PPgg==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.20.1", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.15.0", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" } }, "packages/oauth/node_modules/@types/node": { @@ -6301,6 +6451,18 @@ "undici-types": "~7.19.0" } }, + "packages/oauth/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/oauth/node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -6312,9 +6474,9 @@ "version": "7.0.4", "license": "MIT", "dependencies": { - "@slack/logger": "^4", - "@slack/web-api": "^7.10.0", - "@types/node": ">=20", + "@slack/logger": "npm:@slack/logger@^4", + "@slack/web-api": "npm:@slack/web-api@^7.10.0", + "@types/node": ">=18", "eventemitter3": "^5", "finity": "^0.5.4", "p-cancelable": "^2", @@ -6327,10 +6489,47 @@ "sinon": "^21" }, "engines": { - "node": ">=20", + "node": ">=18", "npm": ">=8.6.0" } }, + "packages/rtm-api/node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "packages/rtm-api/node_modules/@slack/web-api": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.15.1.tgz", + "integrity": "sha512-y+TAF7TszcmFzbVtBkFqAdBwKSoD+8shkNxhp4WIfFwXmCKdFje9WD6evROApPa2FTy1v1uc9yBaJs3609PPgg==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.20.1", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.15.0", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, "packages/rtm-api/node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -6348,6 +6547,18 @@ "@types/sinonjs__fake-timers": "*" } }, + "packages/rtm-api/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/rtm-api/node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -6374,7 +6585,44 @@ "sinon": "^21" }, "engines": { - "node": ">= 20", + "node": ">=20", + "npm": ">=9.6.4" + } + }, + "packages/socket-mode/node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "packages/socket-mode/node_modules/@slack/web-api": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.15.1.tgz", + "integrity": "sha512-y+TAF7TszcmFzbVtBkFqAdBwKSoD+8shkNxhp4WIfFwXmCKdFje9WD6evROApPa2FTy1v1uc9yBaJs3609PPgg==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.20.1", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.15.0", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", "npm": ">= 8.6.0" } }, @@ -6387,6 +6635,18 @@ "undici-types": "~7.19.0" } }, + "packages/socket-mode/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/socket-mode/node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -6402,12 +6662,12 @@ }, "engines": { "node": ">= 20", - "npm": ">= 8.6.0" + "npm": ">=9.6.4" } }, "packages/web-api": { "name": "@slack/web-api", - "version": "7.15.1", + "version": "8.0.0", "license": "MIT", "dependencies": { "@slack/logger": "^4.0.1", @@ -6429,6 +6689,19 @@ }, "engines": { "node": ">= 20", + "npm": ">=9.6.4" + } + }, + "packages/web-api/node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", "npm": ">= 8.6.0" } }, @@ -6441,13 +6714,6 @@ "undici-types": "~7.19.0" } }, - "packages/web-api/node_modules/is-stream": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "undici-types": "~7.19.0" - } - }, "packages/web-api/node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -6460,15 +6726,14 @@ "license": "MIT", "dependencies": { "@slack/types": "^2.20.1", - "@types/node": ">=20", - "axios": "^1.15.0" + "@types/node": ">=20" }, "devDependencies": { "nock": "^14.0.6" }, "engines": { "node": ">= 20", - "npm": ">= 8.6.0" + "npm": ">=9.6.4" } }, "packages/webhook/node_modules/@types/node": { diff --git a/packages/logger/package.json b/packages/logger/package.json index da5c45d75..be874e4d8 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -1,6 +1,6 @@ { "name": "@slack/logger", - "version": "4.0.1", + "version": "5.0.0", "description": "Logging utility used by Node Slack SDK", "author": "Slack Technologies, LLC", "license": "MIT", diff --git a/packages/rtm-api/package.json b/packages/rtm-api/package.json index 01aa0e9cc..f06a72aef 100644 --- a/packages/rtm-api/package.json +++ b/packages/rtm-api/package.json @@ -45,8 +45,8 @@ "test:integration": "npm run build && node --import tsx --test test/integration.test.js" }, "dependencies": { - "@slack/logger": "^4", - "@slack/web-api": "^7.10.0", + "@slack/logger": "npm:@slack/logger@^4", + "@slack/web-api": "npm:@slack/web-api@^7.10.0", "@types/node": ">=18", "eventemitter3": "^5", "finity": "^0.5.4", diff --git a/packages/web-api/package.json b/packages/web-api/package.json index 047bff32b..b62d8033a 100644 --- a/packages/web-api/package.json +++ b/packages/web-api/package.json @@ -1,6 +1,6 @@ { "name": "@slack/web-api", - "version": "7.15.1", + "version": "8.0.0", "description": "Official library for using the Slack Platform's Web API", "author": "Slack Technologies, LLC", "license": "MIT", From b0063a008178ce3fa9dc725e9a563b972174b598 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 4 May 2026 16:39:20 -0400 Subject: [PATCH 06/27] use undici in socket-mode --- package-lock.json | 51 ++----- packages/socket-mode/examples/proxy.js | 8 +- packages/socket-mode/package.json | 5 +- .../socket-mode/src/SlackWebSocket.test.ts | 27 ++-- packages/socket-mode/src/SlackWebSocket.ts | 134 ++++++++++++------ packages/socket-mode/src/SocketModeClient.ts | 40 +++--- packages/socket-mode/src/SocketModeOptions.ts | 10 +- 7 files changed, 153 insertions(+), 122 deletions(-) diff --git a/package-lock.json b/package-lock.json index c9b9c487d..01c03e9a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1431,6 +1431,7 @@ "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -6043,6 +6044,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.2.0.tgz", + "integrity": "sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -6571,11 +6581,10 @@ "license": "MIT", "dependencies": { "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", + "@slack/web-api": "^8.0.0", "@types/node": ">=20", - "@types/ws": "^8", "eventemitter3": "^5", - "ws": "^8" + "undici": "^8" }, "devDependencies": { "@types/proxyquire": "^1.3.31", @@ -6602,30 +6611,6 @@ "npm": ">= 8.6.0" } }, - "packages/socket-mode/node_modules/@slack/web-api": { - "version": "7.15.1", - "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.15.1.tgz", - "integrity": "sha512-y+TAF7TszcmFzbVtBkFqAdBwKSoD+8shkNxhp4WIfFwXmCKdFje9WD6evROApPa2FTy1v1uc9yBaJs3609PPgg==", - "license": "MIT", - "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/types": "^2.20.1", - "@types/node": ">=18", - "@types/retry": "0.12.0", - "axios": "^1.15.0", - "eventemitter3": "^5.0.1", - "form-data": "^4.0.4", - "is-electron": "2.2.2", - "is-stream": "^2", - "p-queue": "^6", - "p-retry": "^4", - "retry": "^0.13.1" - }, - "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" - } - }, "packages/socket-mode/node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -6635,18 +6620,6 @@ "undici-types": "~7.19.0" } }, - "packages/socket-mode/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/socket-mode/node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", diff --git a/packages/socket-mode/examples/proxy.js b/packages/socket-mode/examples/proxy.js index e2886fb22..bfef8bd3d 100644 --- a/packages/socket-mode/examples/proxy.js +++ b/packages/socket-mode/examples/proxy.js @@ -1,17 +1,17 @@ const { SocketModeClient, LogLevel } = require('@slack/socket-mode'); -const HttpsProxyAgent = require('https-proxy-agent'); -const clientOptions = { agent: new HttpsProxyAgent('http://localhost:9001') }; +const { ProxyAgent } = require('undici'); +const dispatcher = new ProxyAgent('http://localhost:9001'); const socketModeClient = new SocketModeClient({ appToken: process.env.SLACK_APP_TOKEN, logLevel: LogLevel.DEBUG, - clientOptions, + dispatcher, }); // const { WebClient } = require('@slack/web-api'); // const webClient = new WebClient(process.env.SLACK_BOT_TOKEN, { // logLevel: LogLevel.DEBUG, -// clientOptions, +// fetch: (url, options) => fetch(url, { ...options, dispatcher }) // }); socketModeClient.on('slack_event', async ({ ack, body }) => { diff --git a/packages/socket-mode/package.json b/packages/socket-mode/package.json index c808e8e23..4c3ac395f 100644 --- a/packages/socket-mode/package.json +++ b/packages/socket-mode/package.json @@ -51,11 +51,10 @@ }, "dependencies": { "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", + "@slack/web-api": "^8.0.0", "@types/node": ">=20", - "@types/ws": "^8", "eventemitter3": "^5", - "ws": "^8" + "undici": "^8" }, "devDependencies": { "@types/proxyquire": "^1.3.31", diff --git a/packages/socket-mode/src/SlackWebSocket.test.ts b/packages/socket-mode/src/SlackWebSocket.test.ts index 3b89477b8..5d2df7299 100644 --- a/packages/socket-mode/src/SlackWebSocket.test.ts +++ b/packages/socket-mode/src/SlackWebSocket.test.ts @@ -9,12 +9,15 @@ proxyquire.noPreserveCache(); import logModule from './logger'; -// A slightly spruced up event emitter aiming at mocking out the `ws` library's `WebSocket` class -class WSMock extends EventEmitter { - // biome-ignore lint/suspicious/noExplicitAny: event listeners can accept any args - addEventListener(evt: string, fn: (...args: any[]) => void) { - this.addListener.call(this, evt, fn); - } +// Minimal mock of undici's WebSocket (EventTarget-based) +class WSMock extends EventTarget { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + readyState = 1; + close() {} + send(_data: string) {} } describe('SlackWebSocket', () => { @@ -22,8 +25,9 @@ describe('SlackWebSocket', () => { let SlackWebSocket: typeof import('./SlackWebSocket').SlackWebSocket; beforeEach(() => { SlackWebSocket = proxyquire.load('./SlackWebSocket', { - ws: { + undici: { WebSocket: WSMock, + ping: () => {}, }, }).SlackWebSocket; }); @@ -58,28 +62,27 @@ describe('SlackWebSocket', () => { }); describe('WebSocket event handling', () => { it('should call disconnect() if websocket emits an error', async () => { - // an exposed event emitter pretending it's a websocket const ws = new WSMock(); - // mock out the `ws` library and have it return our event emitter mock SlackWebSocket = proxyquire.load('./SlackWebSocket', { - ws: { + undici: { WebSocket: class Fake { constructor() { // biome-ignore lint/correctness/noConstructorReturn: for test mocking purposes return ws; } }, + ping: () => {}, }, }).SlackWebSocket; const sws = new SlackWebSocket({ - url: 'whatevs', + url: 'wss://test.slack.com', client: new EventEmitter(), clientPingTimeoutMS: 1, serverPingTimeoutMS: 1, }); const discStub = sinon.stub(sws, 'disconnect'); sws.connect(); - ws.emit('error', { error: new Error('boom') }); + ws.dispatchEvent(new ErrorEvent('error', { error: new Error('boom'), message: 'boom' })); sinon.assert.calledOnce(discStub); }); }); diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index 85262f42f..512655edb 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -1,14 +1,18 @@ -import type { Agent } from 'node:http'; +import { channel } from 'node:diagnostics_channel'; import type { EventEmitter } from 'eventemitter3'; -import { WebSocket, type ClientOptions as WebSocketClientOptions } from 'ws'; +import { type Dispatcher, WebSocket, ping as wsPing } from 'undici'; import { websocketErrorWithOriginal } from './errors'; import log, { type Logger, LogLevel } from './logger'; -// Maps ws `readyState` to human readable labels https://github.com/websockets/ws/blob/HEAD/doc/ws.md#ready-state-constants export const WS_READY_STATES = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; +interface PingPongMessage { + websocket: WebSocket; + payload: Buffer; +} + export interface SlackWebSocketOptions { /** @description The Slack WebSocket URL to connect to. */ url: string; @@ -20,8 +24,8 @@ export interface SlackWebSocketOptions { logger?: Logger; /** @description Delay between this client sending a `ping` message, in milliseconds. */ pingInterval?: number; - /** @description The HTTP Agent to use when establishing a WebSocket connection. */ - httpAgent?: Agent; + /** @description An undici Dispatcher used to establish the WebSocket connection (e.g. ProxyAgent). */ + dispatcher?: Dispatcher; /** @description Whether this WebSocket should DEBUG log ping and pong events. `false` by default. */ pingPongLoggingEnabled?: boolean; /** @@ -71,10 +75,21 @@ export class SlackWebSocket { */ private clientPingTimeout: NodeJS.Timeout | undefined; + private openHandler: (() => void) | null = null; + private errorHandler: ((event: Event) => void) | null = null; + private messageHandler: ((event: Event) => void) | null = null; + private closeHandler: ((event: Event) => void) | null = null; + + private pingHandler: ((msg: unknown) => void) | null = null; + private pongHandler: ((msg: unknown) => void) | null = null; + + private static pingChannel = channel('undici:websocket:ping'); + private static pongChannel = channel('undici:websocket:pong'); + public constructor({ url, client, - httpAgent, + dispatcher, logger, logLevel = LogLevel.INFO, pingInterval = 5000, @@ -85,7 +100,7 @@ export class SlackWebSocket { this.options = { url, client, - httpAgent, + dispatcher, logLevel, pingInterval, pingPongLoggingEnabled, @@ -106,47 +121,59 @@ export class SlackWebSocket { */ public connect(): void { this.logger.debug('Initiating new WebSocket connection.'); - const options: WebSocketClientOptions = { - perMessageDeflate: false, - agent: this.options.httpAgent, - }; - this.websocket = new WebSocket(this.options.url, options); + this.websocket = new WebSocket(this.options.url, { dispatcher: this.options.dispatcher }); - this.websocket.addEventListener('open', (_event) => { + this.openHandler = () => { this.logger.debug('WebSocket open event received (connection established)!'); this.monitorPingToSlack(); - }); - this.websocket.addEventListener('error', (event) => { - this.logger.error(`WebSocket error occurred: ${event.message}`); + }; + this.websocket.addEventListener('open', this.openHandler); + + this.errorHandler = (event: Event) => { + const errorEvent = event as ErrorEvent; + this.logger.error(`WebSocket error occurred: ${errorEvent.message}`); this.disconnect(); - this.options.client.emit('error', websocketErrorWithOriginal(event.error)); - }); - this.websocket.on('message', (msg, isBinary) => { - this.options.client.emit('ws_message', msg, isBinary); - }); - this.websocket.on('close', (code: number, data: Buffer) => { - this.logger.debug(`WebSocket close frame received (code: ${code}, reason: ${data.toString()})`); + this.options.client.emit('error', websocketErrorWithOriginal(errorEvent.error ?? new Error(errorEvent.message))); + }; + this.websocket.addEventListener('error', this.errorHandler); + + this.messageHandler = (event: Event) => { + const msgEvent = event as MessageEvent; + const isBinary = typeof msgEvent.data !== 'string'; + this.options.client.emit('ws_message', msgEvent.data, isBinary); + }; + this.websocket.addEventListener('message', this.messageHandler); + + this.closeHandler = (event: Event) => { + const closeEvent = event as CloseEvent; + this.logger.debug(`WebSocket close frame received (code: ${closeEvent.code}, reason: ${closeEvent.reason})`); this.closeFrameReceived = true; this.disconnect(); - }); + }; + this.websocket.addEventListener('close', this.closeHandler); - // Confirm WebSocket connection is still active - this.websocket.on('ping', (data) => { - // Note that ws' `autoPong` option is true by default, so no need to respond to ping. - // see https://github.com/websockets/ws/blob/2aa0405a5e96754b296fef6bd6ebdfb2f11967fc/doc/ws.md#new-websocketaddress-protocols-options + // Subscribe to undici diagnostics_channel for WebSocket ping/pong frame events. + // These channels fire for ALL undici WebSocket instances, so we filter by matching instance. + this.pingHandler = (raw: unknown) => { + const msg = raw as PingPongMessage; + if (msg.websocket !== this.websocket) return; if (this.options.pingPongLoggingEnabled) { - this.logger.debug(`WebSocket received ping from Slack server (data: ${data.toString()})`); + this.logger.debug(`WebSocket received ping from Slack server (data: ${msg.payload?.toString()})`); } this.monitorPingFromSlack(); - }); + }; + SlackWebSocket.pingChannel.subscribe(this.pingHandler); - this.websocket.on('pong', (data) => { + this.pongHandler = (raw: unknown) => { + const msg = raw as PingPongMessage; + if (msg.websocket !== this.websocket) return; if (this.options.pingPongLoggingEnabled) { - this.logger.debug(`WebSocket received pong from Slack server (data: ${data.toString()})`); + this.logger.debug(`WebSocket received pong from Slack server (data: ${msg.payload?.toString()})`); } this.lastPongReceivedTimestamp = Date.now(); - }); + }; + SlackWebSocket.pongChannel.subscribe(this.pongHandler); } /** @@ -158,30 +185,42 @@ export class SlackWebSocket { // If so, we can terminate the underlying socket connection and let the client know. if (this.closeFrameReceived) { this.logger.debug('Terminating WebSocket (close frame received).'); - this.terminate(); + this.cleanup(); } else if (this.websocket.readyState === WebSocket.CLOSING) { // A close frame was already sent but the peer hasn't responded. Force-terminate rather than // waiting for the ws library's closeTimeout (~30s) while the ping monitor logs repeated warnings. this.logger.debug('Terminating WebSocket (close frame sent but no response, force-terminating).'); - this.terminate(); + this.cleanup(); } else { // If we haven't received a close frame yet, then we send one to the peer, expecting to receive a close frame // in response. this.logger.debug('Sending close frame (status=1000).'); - this.websocket.close(1000); // 1000 = Normal Closure + this.websocket.close(1000); } } else { this.logger.debug('WebSocket already disconnected, flushing remainder.'); - this.terminate(); + this.cleanup(); } } /** * Clean up any underlying intervals, timeouts and the WebSocket. */ - private terminate(): void { - this.websocket?.removeAllListeners(); - this.websocket?.terminate(); + private cleanup(): void { + if (this.websocket) { + if (this.openHandler) this.websocket.removeEventListener('open', this.openHandler); + if (this.errorHandler) this.websocket.removeEventListener('error', this.errorHandler); + if (this.messageHandler) this.websocket.removeEventListener('message', this.messageHandler); + if (this.closeHandler) this.websocket.removeEventListener('close', this.closeHandler); + } + this.openHandler = null; + this.errorHandler = null; + this.messageHandler = null; + this.closeHandler = null; + if (this.pingHandler) SlackWebSocket.pingChannel.unsubscribe(this.pingHandler); + if (this.pongHandler) SlackWebSocket.pongChannel.unsubscribe(this.pongHandler); + this.pingHandler = null; + this.pongHandler = null; this.websocket = null; clearTimeout(this.serverPingTimeout); clearInterval(this.clientPingTimeout); @@ -192,7 +231,6 @@ export class SlackWebSocket { /** * Returns true if the underlying WebSocket connection is active, meaning the underlying - * {@link https://github.com/websockets/ws/blob/master/doc/ws.md#ready-state-constants WebSocket ready state is "OPEN"}. */ public isActive(): boolean { // python equiv: SocketModeClient.is_connected @@ -201,13 +239,12 @@ export class SlackWebSocket { return false; } this.logger.debug(`isActive(): websocket ready state is ${WS_READY_STATES[this.websocket.readyState]}`); - return this.websocket.readyState === 1; // readyState=1 is "OPEN" + return this.websocket.readyState === WebSocket.OPEN; } /** * Retrieve the underlying WebSocket readyState. Returns `undefined` if the WebSocket has not been instantiated, * otherwise will return a number between 0 and 3 inclusive representing the ready states. - * The ready state constants are documented in the {@link https://github.com/websockets/ws/blob/master/doc/ws.md#ready-state-constants `ws` API docs } */ public get readyState(): number | undefined { return this.websocket?.readyState; @@ -217,7 +254,12 @@ export class SlackWebSocket { * Sends data via the underlying WebSocket. Accepts an errorback argument. */ public send(data: string, cb: (err: Error | undefined) => void): void { - this.websocket?.send(data, cb); + try { + this.websocket?.send(data); + cb(undefined); + } catch (err) { + cb(err as Error); + } } /** @@ -246,7 +288,9 @@ export class SlackWebSocket { const now = Date.now(); try { const pingMessage = `Ping from client (${now})`; - this.websocket?.ping(pingMessage); + if (this.websocket) { + wsPing(this.websocket, Buffer.from(pingMessage)); + } if (this.lastPongReceivedTimestamp === undefined) { pingAttemptCount += 1; } else { diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index ac2f86659..53c4675a0 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -8,7 +8,7 @@ import { } from '@slack/web-api'; import { EventEmitter } from 'eventemitter3'; -import type WebSocket from 'ws'; +import { type Dispatcher, fetch as undiciFetch } from 'undici'; import packageJson from '../package.json'; import { sendWhileDisconnectedError, sendWhileNotReadyError, websocketErrorWithOriginal } from './errors'; @@ -55,14 +55,15 @@ export class SocketModeClient extends EventEmitter { private webClient: WebClient; /** - * WebClient options we pass to our WebClient instance - * We also reuse agent and tls for our WebSocket connection + * WebClient options we pass to our WebClient instance. */ private webClientOptions: WebClientOptions; /** - * The underlying WebSocket client instance + * The undici Dispatcher used for both WebSocket and HTTP connections. */ + private dispatcher?: Dispatcher; + public websocket?: SlackWebSocket; /** @@ -103,6 +104,7 @@ export class SocketModeClient extends EventEmitter { serverPingTimeout = 30000, appToken = '', clientOptions = {}, + dispatcher = undefined, }: SocketModeOptions = { appToken: '' }, ) { super(); @@ -113,6 +115,7 @@ export class SocketModeClient extends EventEmitter { this.clientPingTimeoutMS = clientPingTimeout; this.serverPingTimeoutMS = serverPingTimeout; // Setup the logger + this.dispatcher = dispatcher; if (typeof logger !== 'undefined') { this.customLoggerProvided = true; this.logger = logger; @@ -127,11 +130,20 @@ export class SocketModeClient extends EventEmitter { // For faster retries of apps.connections.open API calls for reconnecting this.webClientOptions.retryConfig = { retries: 100, factor: 1.3 }; } + // When a dispatcher is provided, wrap undici.fetch with it for the WebClient's HTTP calls. + // Cast through unknown because undici's fetch types have minor differences from globalThis.fetch. + const fetchOption: typeof globalThis.fetch | undefined = + (clientOptions as WebClientOptions).fetch ?? + (dispatcher + ? (((url: Parameters[0], init?: Parameters[1]) => + undiciFetch(url, { ...init, dispatcher })) as unknown as typeof globalThis.fetch) + : undefined); this.webClient = new WebClient('', { logger, logLevel: this.logger.getLevel(), headers: { Authorization: `Bearer ${appToken}` }, ...clientOptions, + ...(fetchOption ? { fetch: fetchOption } : {}), }); this.autoReconnectEnabled = autoReconnectEnabled; @@ -171,7 +183,7 @@ export class SocketModeClient extends EventEmitter { client: this, logLevel: this.logger.getLevel(), logger: this.customLoggerProvided ? this.logger : undefined, - httpAgent: this.webClientOptions.agent, + dispatcher: this.dispatcher, clientPingTimeoutMS: this.clientPingTimeoutMS, serverPingTimeoutMS: this.serverPingTimeoutMS, pingPongLoggingEnabled: this.pingPongLoggingEnabled, @@ -279,20 +291,12 @@ export class SocketModeClient extends EventEmitter { } } - /** - * `onmessage` handler for the client's WebSocket. - * This will parse the payload and dispatch the application-relevant events for each incoming message. - * Mediates: - * - raising the State.Connected event (when Slack sends a type:hello message) - * - disconnecting the underlying socket (when Slack sends a type:disconnect message) - */ - protected async onWebSocketMessage(data: WebSocket.RawData, isBinary: boolean): Promise { + protected async onWebSocketMessage(data: string | ArrayBuffer, isBinary: boolean): Promise { if (isBinary) { this.logger.debug('Unexpected binary message received, ignoring.'); return; } - const payload = data.toString(); - // TODO: should we redact things in here? + const payload = data as string; this.logger.debug(`Received a message on the WebSocket: ${payload}`); // Parse message into slack event @@ -302,9 +306,9 @@ export class SocketModeClient extends EventEmitter { // biome-ignore lint/suspicious/noExplicitAny: untyped connection callback parameters payload: Record; envelope_id: string; - retry_attempt?: number; // type: events_api - retry_reason?: string; // type: events_api - accepts_response_payload?: boolean; // type: events_api, slash_commands, interactive + retry_attempt?: number; + retry_reason?: string; + accepts_response_payload?: boolean; }; try { diff --git a/packages/socket-mode/src/SocketModeOptions.ts b/packages/socket-mode/src/SocketModeOptions.ts index 7b2c553de..a3b9813bd 100644 --- a/packages/socket-mode/src/SocketModeOptions.ts +++ b/packages/socket-mode/src/SocketModeOptions.ts @@ -1,4 +1,5 @@ import type { WebClientOptions } from '@slack/web-api'; +import type { Dispatcher } from 'undici'; import type { Logger, LogLevel } from './logger'; export interface SocketModeOptions { @@ -41,7 +42,14 @@ export interface SocketModeOptions { pingPongLoggingEnabled?: boolean; /** * The `@slack/web-api` `WebClientOptions` to provide to the HTTP client interacting with Slack's HTTP API. - * Useful for setting retry configurations, TLS and HTTP Agent options. + * Useful for setting retry configurations and custom fetch implementations. */ clientOptions?: Omit; + /** + * An undici `Dispatcher` used for both the WebSocket connection and HTTP API calls. + * Use this to configure proxies (e.g. `new ProxyAgent('http://proxy:3128')`) or custom TLS behavior. + * + * @see https://undici.nodejs.org/#/docs/api/ProxyAgent + */ + dispatcher?: Dispatcher; } From 9482c573796b7e232868c8fb6655e762fa63823c Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 5 May 2026 10:56:07 -0400 Subject: [PATCH 07/27] improve behavior based on review --- packages/web-api/src/WebClient.test.ts | 68 +++++++++++++++++++- packages/web-api/src/WebClient.ts | 5 +- packages/web-api/src/errors.ts | 4 +- packages/webhook/src/IncomingWebhook.test.ts | 16 +++++ packages/webhook/src/IncomingWebhook.ts | 2 +- 5 files changed, 87 insertions(+), 8 deletions(-) diff --git a/packages/web-api/src/WebClient.test.ts b/packages/web-api/src/WebClient.test.ts index 31534c95c..606a32436 100644 --- a/packages/web-api/src/WebClient.test.ts +++ b/packages/web-api/src/WebClient.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { afterEach, beforeEach, describe, it } from 'node:test'; +import zlib from 'node:zlib'; import type { ContextActionsBlock } from '@slack/types'; import nock, { type ReplyHeaders } from 'nock'; import sinon from 'sinon'; @@ -112,7 +113,7 @@ describe('WebClient', () => { }); }); - describe('has an option to override the Axios timeout value', () => { + describe('has an option to override the timeout value', () => { it('should throw error if timeout exceeded', async () => { const timeoutOverride = 1; // ms, guaranteed failure @@ -138,6 +139,32 @@ describe('WebClient', () => { assert.ok(e instanceof Error); } }); + + it('should produce a WebAPIRequestError with original when timeout fires', async () => { + const slowFetch: typeof globalThis.fetch = (_input, init) => + new Promise((_resolve, reject) => { + const timer = setTimeout(() => reject(new Error('should have been aborted')), 5000); + init?.signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(init.signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError')); + }); + }); + + const client = new WebClient(undefined, { + timeout: 1, + retryConfig: { retries: 0 }, + fetch: slowFetch, + }); + + try { + await client.apiCall('users.list'); + assert.fail('expected error to be thrown'); + } catch (error) { + const e = error as WebAPIRequestError; + assert.strictEqual(e.code, ErrorCode.RequestError); + assert.ok(e.original instanceof Error); + } + }); }); describe('apiCall()', () => { @@ -401,6 +428,26 @@ describe('WebClient', () => { }); }); + describe('admin.analytics.getFile GZIP response', () => { + it('should decompress GZIP response and return file_data array', async () => { + const fileData = [ + { date: '2024-01-01', user_id: 'U123', messages_posted: 5 }, + { date: '2024-01-01', user_id: 'U456', messages_posted: 10 }, + ]; + const ndjson = fileData.map((d) => JSON.stringify(d)).join('\n'); + const gzipped = zlib.gzipSync(Buffer.from(ndjson)); + + const scope = nock('https://slack.com') + .post('/api/admin.analytics.getFile') + .reply(200, gzipped, { 'content-type': 'application/gzip' }); + + const client = new WebClient(token, { retryConfig: rapidRetryPolicy }); + const result = await client.apiCall('admin.analytics.getFile', { type: 'member' }); + assert.deepStrictEqual(result.file_data, fileData); + scope.done(); + }); + }); + describe('when an API call fails', () => { it('should return a Promise which rejects on error', async () => { const client = new WebClient(undefined, { retryConfig: { retries: 0 } }); @@ -450,6 +497,22 @@ describe('WebClient', () => { } }); + it('should set error.body to the raw string when HTTP error response is not valid JSON', async () => { + const htmlBody = '

502 Bad Gateway

'; + const scope = nock('https://slack.com').post(/api/).reply(502, htmlBody); + const client = new WebClient(token, { retryConfig: { retries: 0 } }); + try { + await client.apiCall('method'); + assert.fail('expected error to be thrown'); + } catch (error) { + const e = error as WebAPIHTTPError; + assert.strictEqual(e.code, ErrorCode.HTTPError); + assert.strictEqual(e.statusCode, 502); + assert.strictEqual(e.body, htmlBody); + scope.done(); + } + }); + it('should fail with WebAPIRequestError when the API request fails', async () => { // One known request error is when the node encounters an ECONNREFUSED. In order to simulate this, rather than // using nock, we send the request to a host:port that is not listening. @@ -1910,7 +1973,10 @@ describe('WebClient', () => { await client.apiCall('conversations/list'); assert.fail('Should have thrown'); } catch (error) { + const e = error as WebAPIRequestError; + assert.strictEqual(e.code, ErrorCode.RequestError); assert.ok(Object.hasOwn(error, 'original')); + assert.ok(e.original instanceof Error); scope.done(); } }); diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index a0f873c47..e0247714a 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -76,7 +76,6 @@ export interface WebClientOptions { * When set to false, the URL used in Slack API requests will always begin with the slackApiUrl. * * See {@link https://docs.slack.dev/tools/node-slack-sdk/web-api/#call-a-method} for more details. - * See {@link https://github.com/axios/axios?tab=readme-ov-file#request-config} for more details. * @default true */ allowAbsoluteUrls?: boolean; @@ -626,7 +625,7 @@ export class WebClient extends Methods { method: 'POST', headers: allHeaders, body: serializedBody, - redirect: 'manual', + redirect: 'error', ...(signal ? { signal } : {}), }); this.logger.debug('http response received'); @@ -666,13 +665,13 @@ export class WebClient extends Methods { } catch (error) { // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const e = error as any; - this.logger.warn('http request failed', e.message); if (error instanceof AbortError) { throw error; } if (e.code !== undefined && typeof e.code === 'string') { throw error; } + this.logger.warn('http request failed', e.message); throw requestErrorWithOriginal(e instanceof Error ? e : new Error(String(e))); } finally { if (timer) clearTimeout(timer); diff --git a/packages/web-api/src/errors.ts b/packages/web-api/src/errors.ts index 32ebfe467..b90865f45 100644 --- a/packages/web-api/src/errors.ts +++ b/packages/web-api/src/errors.ts @@ -1,5 +1,3 @@ -import type { IncomingHttpHeaders } from 'node:http'; - import type { WebAPICallResult } from './WebClient'; /** @@ -50,7 +48,7 @@ export interface WebAPIHTTPError extends CodedError { code: ErrorCode.HTTPError; statusCode: number; statusMessage: string; - headers: IncomingHttpHeaders; + headers: Record; // biome-ignore lint/suspicious/noExplicitAny: HTTP response bodies might be anything body?: any; } diff --git a/packages/webhook/src/IncomingWebhook.test.ts b/packages/webhook/src/IncomingWebhook.test.ts index 93fb18a5a..c432e0e0d 100644 --- a/packages/webhook/src/IncomingWebhook.test.ts +++ b/packages/webhook/src/IncomingWebhook.test.ts @@ -5,6 +5,7 @@ import nock from 'nock'; import type { CodedError } from './errors'; import { ErrorCode } from './errors'; import { IncomingWebhook } from './IncomingWebhook'; +import { getUserAgent } from './instrument'; const url = 'https://hooks.slack.com/services/FAKEWEBHOOK'; @@ -77,6 +78,21 @@ describe('IncomingWebhook', () => { }); }); + describe('User-Agent header', () => { + it('should send the User-Agent header with every request', async () => { + let capturedHeaders: HeadersInit | undefined; + const capturingFetch: typeof globalThis.fetch = async (_input, init) => { + capturedHeaders = init?.headers; + return new Response('ok', { status: 200 }); + }; + const webhook = new IncomingWebhook(url, { fetch: capturingFetch }); + await webhook.send('Hello'); + assert.ok(capturedHeaders); + const headers = capturedHeaders as Record; + assert.strictEqual(headers['User-Agent'], getUserAgent()); + }); + }); + describe('when the call fails', () => { let statusCode: number; let scope: nock.Scope; diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 0e426e4aa..79ddabec6 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -94,7 +94,7 @@ export class IncomingWebhook { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), - redirect: 'manual', + redirect: 'error', ...(signal ? { signal } : {}), }); From 57a72fc04bfd51361ceda349adf3713d5e52501a Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 5 May 2026 14:17:02 -0400 Subject: [PATCH 08/27] imporve based on review --- .github/workflows/ci-build.yml | 13 +--- .../socket-mode/src/SlackWebSocket.test.ts | 8 +- packages/socket-mode/src/SlackWebSocket.ts | 76 +++++++++++++------ packages/socket-mode/src/SocketModeClient.ts | 14 +++- 4 files changed, 72 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 2b66acb89..f51efbbfe 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -67,19 +67,8 @@ jobs: - name: Build docs if: matrix.node-version == env.LATEST_SUPPORTED_NODE run: npm run docs - - name: Run tests (Node 18) - if: matrix.node-version == '18.x' - # @slack/web-api and @slack/webhook require Node >= 20. - run: | - npm test --workspace=@slack/cli-hooks - npm test --workspace=@slack/cli-test - npm test --workspace=@slack/logger - npm test --workspace=@slack/oauth - npm test --workspace=@slack/rtm-api - npm test --workspace=@slack/socket-mode - npm test --workspace=@slack/types - name: Run tests - if: matrix.node-version != env.LATEST_SUPPORTED_NODE && matrix.node-version != '18.x' + if: matrix.node-version != env.LATEST_SUPPORTED_NODE run: npm test - name: Run test coverage if: matrix.node-version == env.LATEST_SUPPORTED_NODE diff --git a/packages/socket-mode/src/SlackWebSocket.test.ts b/packages/socket-mode/src/SlackWebSocket.test.ts index 5d2df7299..e6df9c76a 100644 --- a/packages/socket-mode/src/SlackWebSocket.test.ts +++ b/packages/socket-mode/src/SlackWebSocket.test.ts @@ -27,6 +27,9 @@ describe('SlackWebSocket', () => { SlackWebSocket = proxyquire.load('./SlackWebSocket', { undici: { WebSocket: WSMock, + CloseEvent, + ErrorEvent, + MessageEvent, ping: () => {}, }, }).SlackWebSocket; @@ -71,11 +74,14 @@ describe('SlackWebSocket', () => { return ws; } }, + CloseEvent, + ErrorEvent, + MessageEvent, ping: () => {}, }, }).SlackWebSocket; const sws = new SlackWebSocket({ - url: 'wss://test.slack.com', + url: 'whatevs', client: new EventEmitter(), clientPingTimeoutMS: 1, serverPingTimeoutMS: 1, diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index 512655edb..83d143de1 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -1,7 +1,7 @@ import { channel } from 'node:diagnostics_channel'; import type { EventEmitter } from 'eventemitter3'; -import { type Dispatcher, WebSocket, ping as wsPing } from 'undici'; +import { CloseEvent, type Dispatcher, ErrorEvent, MessageEvent, ping, WebSocket } from 'undici'; import { websocketErrorWithOriginal } from './errors'; import log, { type Logger, LogLevel } from './logger'; @@ -13,6 +13,19 @@ interface PingPongMessage { payload: Buffer; } +function isPingPongMessage(message: unknown): message is PingPongMessage { + if (typeof message !== 'object' || message === null) { + return false; + } + if (!('websocket' in message && message.websocket instanceof WebSocket)) { + return false; + } + if (!('payload' in message && Buffer.isBuffer(message.payload))) { + return false; + } + return true; +} + export interface SlackWebSocketOptions { /** @description The Slack WebSocket URL to connect to. */ url: string; @@ -80,8 +93,8 @@ export class SlackWebSocket { private messageHandler: ((event: Event) => void) | null = null; private closeHandler: ((event: Event) => void) | null = null; - private pingHandler: ((msg: unknown) => void) | null = null; - private pongHandler: ((msg: unknown) => void) | null = null; + private pingHandler: ((message: unknown) => void) | null = null; + private pongHandler: ((message: unknown) => void) | null = null; private static pingChannel = channel('undici:websocket:ping'); private static pongChannel = channel('undici:websocket:pong'); @@ -131,23 +144,32 @@ export class SlackWebSocket { this.websocket.addEventListener('open', this.openHandler); this.errorHandler = (event: Event) => { - const errorEvent = event as ErrorEvent; - this.logger.error(`WebSocket error occurred: ${errorEvent.message}`); + if (!(event instanceof ErrorEvent)) { + this.logger.warn(`Expected ErrorEvent but received ${event.constructor.name} (type: ${event.type})`); + return; + } + this.logger.error(`WebSocket error occurred: ${event.message}`); this.disconnect(); - this.options.client.emit('error', websocketErrorWithOriginal(errorEvent.error ?? new Error(errorEvent.message))); + this.options.client.emit('error', websocketErrorWithOriginal(event.error ?? new Error(event.message))); }; this.websocket.addEventListener('error', this.errorHandler); this.messageHandler = (event: Event) => { - const msgEvent = event as MessageEvent; - const isBinary = typeof msgEvent.data !== 'string'; - this.options.client.emit('ws_message', msgEvent.data, isBinary); + if (!(event instanceof MessageEvent)) { + this.logger.warn(`Expected MessageEvent but received ${event.constructor.name} (type: ${event.type})`); + return; + } + const isBinary = typeof event.data !== 'string'; + this.options.client.emit('ws_message', event.data, isBinary); }; this.websocket.addEventListener('message', this.messageHandler); this.closeHandler = (event: Event) => { - const closeEvent = event as CloseEvent; - this.logger.debug(`WebSocket close frame received (code: ${closeEvent.code}, reason: ${closeEvent.reason})`); + if (!(event instanceof CloseEvent)) { + this.logger.warn(`Expected CloseEvent but received ${event.constructor.name} (type: ${event.type})`); + return; + } + this.logger.debug(`WebSocket close frame received (code: ${event.code}, reason: ${event.reason})`); this.closeFrameReceived = true; this.disconnect(); }; @@ -155,21 +177,27 @@ export class SlackWebSocket { // Subscribe to undici diagnostics_channel for WebSocket ping/pong frame events. // These channels fire for ALL undici WebSocket instances, so we filter by matching instance. - this.pingHandler = (raw: unknown) => { - const msg = raw as PingPongMessage; - if (msg.websocket !== this.websocket) return; + this.pingHandler = (message: unknown) => { + if (!isPingPongMessage(message)) { + this.logger.warn('Received unexpected ping diagnostics message format'); + return; + } + if (message.websocket !== this.websocket) return; if (this.options.pingPongLoggingEnabled) { - this.logger.debug(`WebSocket received ping from Slack server (data: ${msg.payload?.toString()})`); + this.logger.debug(`WebSocket received ping from Slack server (data: ${message.payload?.toString()})`); } this.monitorPingFromSlack(); }; SlackWebSocket.pingChannel.subscribe(this.pingHandler); - this.pongHandler = (raw: unknown) => { - const msg = raw as PingPongMessage; - if (msg.websocket !== this.websocket) return; + this.pongHandler = (message: unknown) => { + if (!isPingPongMessage(message)) { + this.logger.warn('Received unexpected pong diagnostics message format'); + return; + } + if (message.websocket !== this.websocket) return; if (this.options.pingPongLoggingEnabled) { - this.logger.debug(`WebSocket received pong from Slack server (data: ${msg.payload?.toString()})`); + this.logger.debug(`WebSocket received pong from Slack server (data: ${message.payload?.toString()})`); } this.lastPongReceivedTimestamp = Date.now(); }; @@ -207,6 +235,8 @@ export class SlackWebSocket { * Clean up any underlying intervals, timeouts and the WebSocket. */ private cleanup(): void { + clearTimeout(this.serverPingTimeout); + clearInterval(this.clientPingTimeout); if (this.websocket) { if (this.openHandler) this.websocket.removeEventListener('open', this.openHandler); if (this.errorHandler) this.websocket.removeEventListener('error', this.errorHandler); @@ -222,8 +252,6 @@ export class SlackWebSocket { this.pingHandler = null; this.pongHandler = null; this.websocket = null; - clearTimeout(this.serverPingTimeout); - clearInterval(this.clientPingTimeout); // Emit event back to client letting it know connection has closed (in case it needs to reconnect if // reconnecting is enabled) this.options.client.emit('close'); @@ -288,9 +316,11 @@ export class SlackWebSocket { const now = Date.now(); try { const pingMessage = `Ping from client (${now})`; - if (this.websocket) { - wsPing(this.websocket, Buffer.from(pingMessage)); + if (!this.websocket) { + this.logger.error('WebSocket not available, skipping ping.'); + return; } + ping(this.websocket, Buffer.from(pingMessage)); if (this.lastPongReceivedTimestamp === undefined) { pingAttemptCount += 1; } else { diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index 53c4675a0..d2f332945 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -291,12 +291,20 @@ export class SocketModeClient extends EventEmitter { } } + /** + * `onmessage` handler for the client's WebSocket. + * This will parse the payload and dispatch the application-relevant events for each incoming message. + * Mediates: + * - raising the State.Connected event (when Slack sends a type:hello message) + * - disconnecting the underlying socket (when Slack sends a type:disconnect message) + */ protected async onWebSocketMessage(data: string | ArrayBuffer, isBinary: boolean): Promise { if (isBinary) { this.logger.debug('Unexpected binary message received, ignoring.'); return; } const payload = data as string; + // TODO: should we redact things in here? this.logger.debug(`Received a message on the WebSocket: ${payload}`); // Parse message into slack event @@ -306,9 +314,9 @@ export class SocketModeClient extends EventEmitter { // biome-ignore lint/suspicious/noExplicitAny: untyped connection callback parameters payload: Record; envelope_id: string; - retry_attempt?: number; - retry_reason?: string; - accepts_response_payload?: boolean; + retry_attempt?: number; // type: events_api + retry_reason?: string; // type: events_api + accepts_response_payload?: boolean; // type: events_api, slash_commands, interactive }; try { From 5ecd0c18df8bfa0dfb1a3ad7562209db33abe9ef Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 5 May 2026 15:43:40 -0400 Subject: [PATCH 09/27] improve based on prototyping --- packages/socket-mode/src/SlackWebSocket.ts | 2 +- packages/socket-mode/src/SocketModeClient.ts | 18 +++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index 83d143de1..e709d7338 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -223,7 +223,7 @@ export class SlackWebSocket { // If we haven't received a close frame yet, then we send one to the peer, expecting to receive a close frame // in response. this.logger.debug('Sending close frame (status=1000).'); - this.websocket.close(1000); + this.websocket.close(1000); // 1000 = Normal Closure } } else { this.logger.debug('WebSocket already disconnected, flushing remainder.'); diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index d2f332945..bd05f482a 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -114,8 +114,8 @@ export class SocketModeClient extends EventEmitter { this.pingPongLoggingEnabled = pingPongLoggingEnabled; this.clientPingTimeoutMS = clientPingTimeout; this.serverPingTimeoutMS = serverPingTimeout; - // Setup the logger this.dispatcher = dispatcher; + // Setup the logger if (typeof logger !== 'undefined') { this.customLoggerProvided = true; this.logger = logger; @@ -126,24 +126,20 @@ export class SocketModeClient extends EventEmitter { this.logger = log.getLogger(SocketModeClient.loggerName, logLevel ?? LogLevel.INFO, logger); } this.webClientOptions = clientOptions; + if (this.dispatcher) { + const { dispatcher } = this; + this.webClientOptions.fetch = ((input: Parameters[0], init?: Parameters[1]) => + undiciFetch(input, { ...init, dispatcher })) as unknown as typeof globalThis.fetch; + } if (this.webClientOptions.retryConfig === undefined) { // For faster retries of apps.connections.open API calls for reconnecting this.webClientOptions.retryConfig = { retries: 100, factor: 1.3 }; } - // When a dispatcher is provided, wrap undici.fetch with it for the WebClient's HTTP calls. - // Cast through unknown because undici's fetch types have minor differences from globalThis.fetch. - const fetchOption: typeof globalThis.fetch | undefined = - (clientOptions as WebClientOptions).fetch ?? - (dispatcher - ? (((url: Parameters[0], init?: Parameters[1]) => - undiciFetch(url, { ...init, dispatcher })) as unknown as typeof globalThis.fetch) - : undefined); this.webClient = new WebClient('', { logger, logLevel: this.logger.getLevel(), headers: { Authorization: `Bearer ${appToken}` }, - ...clientOptions, - ...(fetchOption ? { fetch: fetchOption } : {}), + ...this.webClientOptions, }); this.autoReconnectEnabled = autoReconnectEnabled; From 0be385668214df329fc6d00e2af67db9a97198d9 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 5 May 2026 16:34:47 -0400 Subject: [PATCH 10/27] improve based on review and prototyping --- package-lock.json | 3 +- packages/socket-mode/package.json | 3 +- packages/socket-mode/src/SlackWebSocket.ts | 2 +- .../socket-mode/src/SocketModeClient.test.ts | 43 +++++++++++++++++++ packages/socket-mode/src/SocketModeClient.ts | 11 +++-- packages/socket-mode/src/SocketModeOptions.ts | 5 ++- 6 files changed, 59 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index d92584c82..84578fdf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6591,7 +6591,8 @@ "@types/sinon": "^21", "nodemon": "^3.1.0", "proxyquire": "^2.1.3", - "sinon": "^21" + "sinon": "^21", + "ws": "^8" }, "engines": { "node": ">=20", diff --git a/packages/socket-mode/package.json b/packages/socket-mode/package.json index 4c3ac395f..e85751e4a 100644 --- a/packages/socket-mode/package.json +++ b/packages/socket-mode/package.json @@ -61,6 +61,7 @@ "@types/sinon": "^21", "nodemon": "^3.1.0", "proxyquire": "^2.1.3", - "sinon": "^21" + "sinon": "^21", + "ws": "^8" } } diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index e709d7338..efcf09f71 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -47,7 +47,7 @@ export interface SlackWebSocketOptions { */ serverPingTimeoutMS: number; /** - * @description How many milliseconds to wait between ping events from the server before deeming the connection + * @description How many milliseconds to wait for a pong response after sending a ping before deeming the connection * stale. Defaults to 5,000. */ clientPingTimeoutMS: number; diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index da73676d0..f6edb640c 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it } from 'node:test'; import { ConsoleLogger } from '@slack/logger'; +import proxyquire from 'proxyquire'; import sinon from 'sinon'; +import type { Dispatcher } from 'undici'; import logModule from './logger'; import { SocketModeClient } from './SocketModeClient'; @@ -29,7 +31,48 @@ describe('SocketModeClient', () => { new SocketModeClient({ appToken: 'xapp-' }); assert.strictEqual(logFactory.called, true); }); + describe('dispatcher option', () => { + let capturedWebClientOptions: Record; + let ProxiedSocketModeClient: typeof SocketModeClient; + + beforeEach(() => { + capturedWebClientOptions = {}; + ProxiedSocketModeClient = proxyquire('./SocketModeClient', { + '@slack/web-api': { + WebClient: class { + constructor(_token: string, options: Record) { + capturedWebClientOptions = options; + } + }, + addAppMetadata: () => {}, + }, + }).SocketModeClient; + }); + + it('should wrap dispatcher into fetch when no custom fetch is provided', () => { + const fakeDispatcher = {} as unknown as Dispatcher; + new ProxiedSocketModeClient({ appToken: 'xapp-', dispatcher: fakeDispatcher }); + assert.strictEqual(typeof capturedWebClientOptions.fetch, 'function'); + }); + + it('should not overwrite fetch when a custom fetch is provided', () => { + const fakeDispatcher = {} as unknown as Dispatcher; + const customFetch = (() => {}) as unknown as typeof globalThis.fetch; + new ProxiedSocketModeClient({ + appToken: 'xapp-', + dispatcher: fakeDispatcher, + clientOptions: { fetch: customFetch }, + }); + assert.strictEqual(capturedWebClientOptions.fetch, customFetch); + }); + + it('should leave fetch undefined when no dispatcher is provided', () => { + new ProxiedSocketModeClient({ appToken: 'xapp-' }); + assert.strictEqual(capturedWebClientOptions.fetch, undefined); + }); + }); }); + describe('start()', () => { it('should resolve once Connected state emitted'); it('should reject once Disconnected state emitted'); diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index bd05f482a..cacaafd87 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -60,7 +60,8 @@ export class SocketModeClient extends EventEmitter { private webClientOptions: WebClientOptions; /** - * The undici Dispatcher used for both WebSocket and HTTP connections. + * The undici Dispatcher used for WebSocket connections. Also wrapped into a custom fetch for HTTP calls + * unless `clientOptions.fetch` was provided by the user. */ private dispatcher?: Dispatcher; @@ -126,10 +127,12 @@ export class SocketModeClient extends EventEmitter { this.logger = log.getLogger(SocketModeClient.loggerName, logLevel ?? LogLevel.INFO, logger); } this.webClientOptions = clientOptions; - if (this.dispatcher) { + if (dispatcher && this.webClientOptions.fetch === undefined) { const { dispatcher } = this; - this.webClientOptions.fetch = ((input: Parameters[0], init?: Parameters[1]) => - undiciFetch(input, { ...init, dispatcher })) as unknown as typeof globalThis.fetch; + this.webClientOptions.fetch = (( + input: Parameters[0], + init?: Parameters[1], + ) => undiciFetch(input, { ...init, dispatcher })) as unknown as typeof globalThis.fetch; } if (this.webClientOptions.retryConfig === undefined) { // For faster retries of apps.connections.open API calls for reconnecting diff --git a/packages/socket-mode/src/SocketModeOptions.ts b/packages/socket-mode/src/SocketModeOptions.ts index a3b9813bd..20df00737 100644 --- a/packages/socket-mode/src/SocketModeOptions.ts +++ b/packages/socket-mode/src/SocketModeOptions.ts @@ -46,7 +46,10 @@ export interface SocketModeOptions { */ clientOptions?: Omit; /** - * An undici `Dispatcher` used for both the WebSocket connection and HTTP API calls. + * An undici `Dispatcher` used for the WebSocket connection and, if no custom `fetch` is provided + * via `clientOptions`, also wrapped into a custom fetch for HTTP API calls. + * If `clientOptions.fetch` is already defined, the dispatcher is only used for the WebSocket connection. + * * Use this to configure proxies (e.g. `new ProxyAgent('http://proxy:3128')`) or custom TLS behavior. * * @see https://undici.nodejs.org/#/docs/api/ProxyAgent From c4f96b53e52828ce4a1d0d20a3e695bdfe0731dd Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 5 May 2026 16:37:49 -0400 Subject: [PATCH 11/27] Update SlackWebSocket.ts --- packages/socket-mode/src/SlackWebSocket.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index efcf09f71..004761926 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -235,8 +235,6 @@ export class SlackWebSocket { * Clean up any underlying intervals, timeouts and the WebSocket. */ private cleanup(): void { - clearTimeout(this.serverPingTimeout); - clearInterval(this.clientPingTimeout); if (this.websocket) { if (this.openHandler) this.websocket.removeEventListener('open', this.openHandler); if (this.errorHandler) this.websocket.removeEventListener('error', this.errorHandler); @@ -252,6 +250,8 @@ export class SlackWebSocket { this.pingHandler = null; this.pongHandler = null; this.websocket = null; + clearTimeout(this.serverPingTimeout); + clearInterval(this.clientPingTimeout); // Emit event back to client letting it know connection has closed (in case it needs to reconnect if // reconnecting is enabled) this.options.client.emit('close'); From db2e2901ae36d4de29ce3a20d7cf196d4ac0b582 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 5 May 2026 17:04:26 -0400 Subject: [PATCH 12/27] simplify changes --- .../socket-mode/src/SocketModeClient.test.ts | 2 +- packages/web-api/src/WebClient.test.ts | 34 ----- packages/web-api/src/WebClient.ts | 142 ++++++++---------- packages/web-api/src/chat-stream.ts | 69 +++------ packages/web-api/src/index.ts | 1 - packages/web-api/src/methods.ts | 19 +-- packages/webhook/src/IncomingWebhook.test.ts | 33 ---- packages/webhook/src/IncomingWebhook.ts | 22 +-- packages/webhook/src/index.ts | 2 - 9 files changed, 95 insertions(+), 229 deletions(-) diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index f6edb640c..f2b72167d 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -72,7 +72,7 @@ describe('SocketModeClient', () => { }); }); }); - + describe('start()', () => { it('should resolve once Connected state emitted'); it('should reject once Disconnected state emitted'); diff --git a/packages/web-api/src/WebClient.test.ts b/packages/web-api/src/WebClient.test.ts index 606a32436..ed7faaf30 100644 --- a/packages/web-api/src/WebClient.test.ts +++ b/packages/web-api/src/WebClient.test.ts @@ -1127,40 +1127,6 @@ describe('WebClient', () => { await client.apiCall('method'); assert.ok(fetchCalled); }); - - it('should use a per-request fetch override', async () => { - let fetchCalled = false; - const customFetch: typeof globalThis.fetch = async () => { - fetchCalled = true; - return new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - }; - const client = new WebClient(token, { retryConfig: { retries: 0 } }); - await client.apiCall('method', {}, { fetch: customFetch }); - assert.ok(fetchCalled); - }); - - it('should use a per-request signal for abort', async () => { - const slowFetch: typeof globalThis.fetch = (_input, init) => { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })), 5000); - init?.signal?.addEventListener('abort', () => { - clearTimeout(timer); - reject(init.signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError')); - }); - }); - }; - const client = new WebClient(token, { fetch: slowFetch, retryConfig: { retries: 0 } }); - try { - await client.apiCall('method', {}, { signal: AbortSignal.timeout(10) }); - assert.fail('expected error to be thrown'); - } catch (error) { - assert.ok(error instanceof Error); - assert.strictEqual((error as WebAPIRequestError).code, ErrorCode.RequestError); - } - }); }); it('should throw an error if the response has no retry info', async () => { diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index e0247714a..26f53d588 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -118,13 +118,6 @@ export type PageAccumulator = R extends ( export type FetchFunction = typeof globalThis.fetch; -export interface RequestOptions { - /** Override the client-level `fetch` for this single request. */ - fetch?: FetchFunction; - /** Override the client-level timeout signal. Replaces the SDK's default AbortController. */ - signal?: AbortSignal; -} - /** * A client for Slack's Web API * @@ -246,39 +239,34 @@ export class WebClient extends Methods { /** * Generic method for calling a Web API method * @param method - the Web API method to call {@link https://docs.slack.dev/reference/methods} - * @param args - arguments for the Web API method + * @param options - arguments for the Web API method */ - public async apiCall( - method: string, - args: Record = {}, - options?: RequestOptions, - ): Promise { + public async apiCall(method: string, options: Record = {}): Promise { this.logger.debug(`apiCall('${method}') start`); warnDeprecations(method, this.logger); - warnIfFallbackIsMissing(method, this.logger, args); - warnIfThreadTsIsNotString(method, this.logger, args); + warnIfFallbackIsMissing(method, this.logger, options); + warnIfThreadTsIsNotString(method, this.logger, options); - if (typeof args === 'string' || typeof args === 'number' || typeof args === 'boolean') { - throw new TypeError(`Expected an options argument but instead received a ${typeof args}`); + if (typeof options === 'string' || typeof options === 'number' || typeof options === 'boolean') { + throw new TypeError(`Expected an options argument but instead received a ${typeof options}`); } warnIfNotUsingFilesUploadV2(method, this.logger); // @ts-expect-error insufficient overlap between Record and FilesUploadV2Arguments - if (method === 'files.uploadV2') return this.filesUploadV2(args as FilesUploadV2Arguments, options); + if (method === 'files.uploadV2') return this.filesUploadV2(options as FilesUploadV2Arguments); const headers: Record = {}; - if (args.token) headers.Authorization = `Bearer ${args.token}`; + if (options.token) headers.Authorization = `Bearer ${options.token}`; const url = this.deriveRequestUrl(method); const response = await this.makeRequest( url, { team_id: this.teamId, - ...args, + ...options, }, headers, - options, ); const result = await this.buildResult(response); this.logger.debug(`http request result: ${JSON.stringify(result)}`); @@ -336,28 +324,28 @@ export class WebClient extends Methods { * to use it in earlier JavaScript runtimes by transpiling your source with a tool like Babel. However, the * transpiled code will likely sacrifice performance. * @param method - the cursor-paginated Web API method to call {@link https://docs.slack.dev/apis/web-api/paginationn} - * @param args - arguments for the Web API method + * @param options - options * @param shouldStop - a predicate that is called with each page, and should return true when pagination can end. * @param reduce - a callback that can be used to accumulate a value that the return promise is resolved to */ - public paginate(method: string, args?: Record): AsyncIterable; - public paginate(method: string, args: Record, shouldStop: PaginatePredicate): Promise; + public paginate(method: string, options?: Record): AsyncIterable; + public paginate(method: string, options: Record, shouldStop: PaginatePredicate): Promise; public paginate>( method: string, - args: Record, + options: Record, shouldStop: PaginatePredicate, reduce?: PageReducer
, ): Promise; public paginate>( method: string, - args?: Record, + options?: Record, shouldStop?: PaginatePredicate, reduce?: PageReducer, ): Promise | AsyncIterable { const pageSize = (() => { - if (args !== undefined && typeof args.limit === 'number') { - const { limit } = args; - args.limit = undefined; + if (options !== undefined && typeof options.limit === 'number') { + const { limit } = options; + options.limit = undefined; return limit; } return defaultPageSize; @@ -366,18 +354,18 @@ export class WebClient extends Methods { async function* generatePages(this: WebClient): AsyncIterableIterator { // when result is undefined, that signals that the first of potentially many calls has not yet been made let result: WebAPICallResult | undefined; - // paginationOptions stores pagination options not already stored in the args argument + // paginationOptions stores pagination options not already stored in the options argument let paginationOptions: CursorPaginationEnabled | undefined = { limit: pageSize, }; - if (args !== undefined && args.cursor !== undefined) { - paginationOptions.cursor = args.cursor as string; + if (options !== undefined && options.cursor !== undefined) { + paginationOptions.cursor = options.cursor as string; } // NOTE: test for the situation where you're resuming a pagination using and existing cursor while (result === undefined || paginationOptions !== undefined) { - result = await this.apiCall(method, Object.assign(args !== undefined ? args : {}, paginationOptions)); + result = await this.apiCall(method, Object.assign(options !== undefined ? options : {}, paginationOptions)); yield result; paginationOptions = paginationOptionsForNextPage(result, pageSize); } @@ -464,16 +452,15 @@ export class WebClient extends Methods { * URLs returned from step 1 (e.g. https://files.slack.com/upload/v1/...\") * * **#3**: Complete uploads {@link https://docs.slack.dev/reference/methods/files.completeuploadexternal files.completeUploadExternal} - * @param args + * @param options */ public async filesUploadV2( - args: FilesUploadV2Arguments, - options?: RequestOptions, + options: FilesUploadV2Arguments, ): Promise { this.logger.debug('files.uploadV2() start'); // 1 - const fileUploads = await this.getAllFileUploads(args); - const fileUploadsURLRes = await this.fetchAllUploadURLExternal(fileUploads, options); + const fileUploads = await this.getAllFileUploads(options); + const fileUploadsURLRes = await this.fetchAllUploadURLExternal(fileUploads); // set the upload_url and file_id returned from Slack fileUploadsURLRes.forEach((res, idx) => { fileUploads[idx].upload_url = res.upload_url; @@ -481,10 +468,10 @@ export class WebClient extends Methods { }); // 2 - await this.postFileUploadsToExternalURL(fileUploads, args, options); + await this.postFileUploadsToExternalURL(fileUploads, options); // 3 - const completion = await this.completeFileUploads(fileUploads, options); + const completion = await this.completeFileUploads(fileUploads); return { ok: true, files: completion }; } @@ -497,21 +484,20 @@ export class WebClient extends Methods { */ private async fetchAllUploadURLExternal( fileUploads: FileUploadV2Job[], - options?: RequestOptions, ): Promise> { return Promise.all( fileUploads.map((upload: FileUploadV2Job) => { - const args = { + const options = { filename: upload.filename, length: upload.length, alt_text: upload.alt_text, snippet_type: upload.snippet_type, } as FilesGetUploadURLExternalArguments; if ('token' in upload) { - args.token = upload.token; + options.token = upload.token; } - return this.files.getUploadURLExternal(args, options); + return this.files.getUploadURLExternal(options); }), ); } @@ -521,12 +507,9 @@ export class WebClient extends Methods { * @param fileUploads * @returns */ - private async completeFileUploads( - fileUploads: FileUploadV2Job[], - options?: RequestOptions, - ): Promise { + private async completeFileUploads(fileUploads: FileUploadV2Job[]): Promise { const toComplete: FilesCompleteUploadExternalArguments[] = Object.values(getAllFileUploadsToComplete(fileUploads)); - return Promise.all(toComplete.map((job) => this.files.completeUploadExternal(job, options))); + return Promise.all(toComplete.map((job) => this.files.completeUploadExternal(job))); } /** @@ -536,8 +519,7 @@ export class WebClient extends Methods { */ private async postFileUploadsToExternalURL( fileUploads: FileUploadV2Job[], - args: FilesUploadV2Arguments, - options?: RequestOptions, + options: FilesUploadV2Arguments, ): Promise> { return Promise.all( fileUploads.map(async (upload: FileUploadV2Job) => { @@ -548,7 +530,7 @@ export class WebClient extends Methods { // try to post to external url if (upload_url) { const headers: Record = {}; - if (args.token) headers.Authorization = `Bearer ${args.token}`; + if (options.token) headers.Authorization = `Bearer ${options.token}`; const uploadRes = await this.makeRequest( upload_url, @@ -556,7 +538,6 @@ export class WebClient extends Methods { body, }, headers, - options, ); if (uploadRes.status !== 200) { return Promise.reject(Error(`Failed to upload file (id:${file_id}, filename: ${filename})`)); @@ -571,20 +552,20 @@ export class WebClient extends Methods { } /** - * @param args All file uploads arguments + * @param options All file uploads arguments * @returns An array of file upload entries */ - private async getAllFileUploads(args: FilesUploadV2Arguments): Promise { + private async getAllFileUploads(options: FilesUploadV2Arguments): Promise { let fileUploads: FileUploadV2Job[] = []; // add single file data to uploads if file or content exists at the top level - if ('file' in args || 'content' in args) { - fileUploads.push(await getFileUploadJob(args, this.logger)); + if ('file' in options || 'content' in options) { + fileUploads.push(await getFileUploadJob(options, this.logger)); } // add multiple files data when file_uploads is supplied - if ('file_uploads' in args) { - fileUploads = fileUploads.concat(await getMultipleFileUploadJobs(args, this.logger)); + if ('file_uploads' in options) { + fileUploads = fileUploads.concat(await getMultipleFileUploadJobs(options, this.logger)); } return fileUploads; } @@ -596,7 +577,6 @@ export class WebClient extends Methods { url: string, body: Record, headers: Record = {}, - options?: RequestOptions, ): Promise { const task = () => this.requestQueue.add(async () => { @@ -612,16 +592,12 @@ export class WebClient extends Methods { this.logger.debug(`http request body: ${JSON.stringify(redact(body))}`); this.logger.debug(`http request headers: ${JSON.stringify(redact(allHeaders))}`); - const effectiveFetch = options?.fetch ?? this.fetchFn; - const effectiveSignal = options?.signal; - const controller = new AbortController(); - const timer = - !effectiveSignal && this.timeout > 0 ? setTimeout(() => controller.abort(), this.timeout) : undefined; - const signal = effectiveSignal ?? (timer ? controller.signal : undefined); + const timer = this.timeout > 0 ? setTimeout(() => controller.abort(), this.timeout) : undefined; + const signal = timer ? controller.signal : undefined; try { - const response = await effectiveFetch(url, { + const response = await this.fetchFn(url, { method: 'POST', headers: allHeaders, body: serializedBody, @@ -899,34 +875,34 @@ function warnDeprecations(method: string, logger: Logger): void { * Log a warning when using chat.postMessage without text argument or attachments with fallback argument * @param method api method being called * @param logger instance of we clients logger - * @param args arguments for the Web API method + * @param options arguments for the Web API method */ -function warnIfFallbackIsMissing(method: string, logger: Logger, args?: Record): void { +function warnIfFallbackIsMissing(method: string, logger: Logger, options?: Record): void { const targetMethods = ['chat.postEphemeral', 'chat.postMessage', 'chat.scheduleMessage']; const isTargetMethod = targetMethods.includes(method); - const hasAttachments = (a: Record) => Array.isArray(a.attachments) && a.attachments.length; + const hasAttachments = (args: Record) => Array.isArray(args.attachments) && args.attachments.length; - const missingAttachmentFallbackDetected = (a: Record) => - Array.isArray(a.attachments) && - a.attachments.some((attachment) => !attachment.fallback || attachment.fallback.trim() === ''); + const missingAttachmentFallbackDetected = (args: Record) => + Array.isArray(args.attachments) && + args.attachments.some((attachment) => !attachment.fallback || attachment.fallback.trim() === ''); - const isEmptyText = (a: Record) => - (a.text === undefined || a.text === null || a.text === '') && - (a.markdown_text === undefined || a.markdown === null || a.markdown_text === ''); + const isEmptyText = (args: Record) => + (args.text === undefined || args.text === null || args.text === '') && + (args.markdown_text === undefined || args.markdown === null || args.markdown_text === ''); const buildMissingTextWarning = () => `The top-level \`text\` argument is missing in the request payload for a ${method} call - It's a best practice to always provide a \`text\` argument when posting a message. The \`text\` is used in places where the content cannot be rendered such as: system push notifications, assistive technology such as screen readers, etc.`; const buildMissingFallbackWarning = () => `Additionally, the attachment-level \`fallback\` argument is missing in the request payload for a ${method} call - To avoid this warning, it is recommended to always provide a top-level \`text\` argument when posting a message. Alternatively, you can provide an attachment-level \`fallback\` argument, though this is now considered a legacy field (see https://docs.slack.dev/legacy/legacy-messaging/legacy-secondary-message-attachments for more details).`; - if (isTargetMethod && typeof args === 'object') { - if (hasAttachments(args)) { - if (missingAttachmentFallbackDetected(args) && isEmptyText(args)) { + if (isTargetMethod && typeof options === 'object') { + if (hasAttachments(options)) { + if (missingAttachmentFallbackDetected(options) && isEmptyText(options)) { logger.warn(buildMissingTextWarning()); logger.warn(buildMissingFallbackWarning()); } - } else if (isEmptyText(args)) { + } else if (isEmptyText(options)) { logger.warn(buildMissingTextWarning()); } } @@ -936,13 +912,13 @@ function warnIfFallbackIsMissing(method: string, logger: Logger, args?: Record): void { +function warnIfThreadTsIsNotString(method: string, logger: Logger, options?: Record): void { const targetMethods = ['chat.postEphemeral', 'chat.postMessage', 'chat.scheduleMessage', 'files.upload']; const isTargetMethod = targetMethods.includes(method); - if (isTargetMethod && args?.thread_ts !== undefined && typeof args?.thread_ts !== 'string') { + if (isTargetMethod && options?.thread_ts !== undefined && typeof options?.thread_ts !== 'string') { logger.warn(buildThreadTsWarningMessage(method)); } } diff --git a/packages/web-api/src/chat-stream.ts b/packages/web-api/src/chat-stream.ts index 9bd1a829e..314117f2f 100644 --- a/packages/web-api/src/chat-stream.ts +++ b/packages/web-api/src/chat-stream.ts @@ -3,7 +3,6 @@ import type { AnyChunk } from '@slack/types'; import type { ChatAppendStreamArguments, ChatStartStreamArguments, ChatStopStreamArguments } from './types/request'; import type { ChatAppendStreamResponse, ChatStartStreamResponse, ChatStopStreamResponse } from './types/response'; import type WebClient from './WebClient'; -import type { RequestOptions } from './WebClient'; export interface ChatStreamerOptions { /** @@ -11,18 +10,13 @@ export interface ChatStreamerOptions { * @default 256 */ buffer_size?: number; - /** - * @description Request options that apply to all internal API calls made by this streamer. - */ - requestOptions?: RequestOptions; } export class ChatStreamer { private buffer = ''; private client: WebClient; private logger: Logger; - private options: Required>; - private requestOptions: RequestOptions | undefined; + private options: Required; private state: 'starting' | 'in_progress' | 'completed'; private streamArgs: ChatStartStreamArguments; private streamTs: string | undefined; @@ -56,7 +50,6 @@ export class ChatStreamer { this.options = { buffer_size: options.buffer_size ?? 256, }; - this.requestOptions = options.requestOptions; this.state = 'starting'; this.streamArgs = args; } @@ -139,13 +132,10 @@ export class ChatStreamer { this.buffer += markdown_text; } if (!this.streamTs) { - const response = await this.client.chat.startStream( - { - ...this.streamArgs, - token: this.token, - }, - this.requestOptions, - ); + const response = await this.client.chat.startStream({ + ...this.streamArgs, + token: this.token, + }); if (!response.ts) { throw new Error('failed to stop stream: stream not started'); } @@ -162,16 +152,13 @@ export class ChatStreamer { if (chunks) { chunksToFlush.push(...chunks); } - const response = await this.client.chat.stopStream( - { - token: this.token, - channel: this.streamArgs.channel, - ts: this.streamTs, - chunks: chunksToFlush, - ...opts, - }, - this.requestOptions, - ); + const response = await this.client.chat.stopStream({ + token: this.token, + channel: this.streamArgs.channel, + ts: this.streamTs, + chunks: chunksToFlush, + ...opts, + }); this.state = 'completed'; return response; } @@ -191,30 +178,24 @@ export class ChatStreamer { chunksToFlush.push(...chunks); } if (!this.streamTs) { - const response = await this.client.chat.startStream( - { - ...this.streamArgs, - token: this.token, - chunks: chunksToFlush, - ...opts, - }, - this.requestOptions, - ); + const response = await this.client.chat.startStream({ + ...this.streamArgs, + token: this.token, + chunks: chunksToFlush, + ...opts, + }); this.buffer = ''; this.streamTs = response.ts; this.state = 'in_progress'; return response; } - const response = await this.client.chat.appendStream( - { - token: this.token, - channel: this.streamArgs.channel, - ts: this.streamTs, - chunks: chunksToFlush, - ...opts, - }, - this.requestOptions, - ); + const response = await this.client.chat.appendStream({ + token: this.token, + channel: this.streamArgs.channel, + ts: this.streamTs, + chunks: chunksToFlush, + ...opts, + }); this.buffer = ''; return response; } diff --git a/packages/web-api/src/index.ts b/packages/web-api/src/index.ts index 0f8dc19d3..d7dd01551 100644 --- a/packages/web-api/src/index.ts +++ b/packages/web-api/src/index.ts @@ -26,7 +26,6 @@ export { PageAccumulator, PageReducer, PaginatePredicate, - RequestOptions, WebAPICallResult, WebClient, WebClientEvent, diff --git a/packages/web-api/src/methods.ts b/packages/web-api/src/methods.ts index 14e82dcb6..d6e584d9e 100644 --- a/packages/web-api/src/methods.ts +++ b/packages/web-api/src/methods.ts @@ -550,18 +550,16 @@ import type { WorkflowsUpdateStepResponse, } from './types/response/index'; -import { type RequestOptions, type WebAPICallResult, WebClient, type WebClientEvent } from './WebClient'; +import { type WebAPICallResult, WebClient, type WebClientEvent } from './WebClient'; /** * Generic method definition */ type MethodWithRequiredArgument = ( - args: MethodArguments, - options?: RequestOptions, + options: MethodArguments, ) => Promise; type MethodWithOptionalArgument = ( - args?: MethodArguments, - options?: RequestOptions, + options?: MethodArguments, ) => Promise; export default MethodWithOptionalArgument; @@ -608,15 +606,8 @@ export abstract class Methods extends EventEmitter { } } - public abstract apiCall( - method: string, - args?: Record, - options?: RequestOptions, - ): Promise; - public abstract filesUploadV2( - args: FilesUploadV2Arguments, - options?: RequestOptions, - ): Promise; + public abstract apiCall(method: string, options?: Record): Promise; + public abstract filesUploadV2(options: FilesUploadV2Arguments): Promise; public readonly admin = { analytics: { diff --git a/packages/webhook/src/IncomingWebhook.test.ts b/packages/webhook/src/IncomingWebhook.test.ts index c432e0e0d..00219c468 100644 --- a/packages/webhook/src/IncomingWebhook.test.ts +++ b/packages/webhook/src/IncomingWebhook.test.ts @@ -143,38 +143,5 @@ describe('IncomingWebhook', () => { } }); }); - - describe('per-request options', () => { - it('should use a per-request fetch override', async () => { - let fetchCalled = false; - const customFetch: typeof globalThis.fetch = async () => { - fetchCalled = true; - return new Response('custom', { status: 200 }); - }; - const result = await webhook.send('Hello', { fetch: customFetch }); - assert.ok(fetchCalled); - assert.strictEqual(result.text, 'custom'); - }); - - it('should use a per-request signal for abort', async () => { - const slowFetch: typeof globalThis.fetch = (_input, init) => { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => resolve(new Response('ok', { status: 200 })), 5000); - init?.signal?.addEventListener('abort', () => { - clearTimeout(timer); - reject(init.signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError')); - }); - }); - }; - - try { - await webhook.send('Hello', { fetch: slowFetch, signal: AbortSignal.timeout(10) }); - assert.fail('expected rejection'); - } catch (error) { - assert.ok(error instanceof Error); - assert.strictEqual((error as CodedError).code, ErrorCode.RequestError); - } - }); - }); }); }); diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 79ddabec6..df84f5c72 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -4,12 +4,7 @@ import type { CodedError } from './errors'; import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors'; import { getUserAgent } from './instrument'; -export type FetchFunction = typeof globalThis.fetch; - -export interface RequestOptions { - fetch?: FetchFunction; - signal?: AbortSignal; -} +type FetchFunction = typeof globalThis.fetch; /** * A client for Slack's Incoming Webhooks @@ -65,12 +60,8 @@ export class IncomingWebhook { /** * Send a notification to a conversation * @param message - the message (a simple string, or an object describing the message) - * @param options - optional request-level overrides (custom fetch, abort signal) */ - public async send( - message: string | IncomingWebhookSendArguments, - options?: RequestOptions, - ): Promise { + public async send(message: string | IncomingWebhookSendArguments): Promise { let payload: IncomingWebhookSendArguments = { ...this.defaults }; if (typeof message === 'string') { @@ -79,15 +70,12 @@ export class IncomingWebhook { payload = Object.assign(payload, message); } - const effectiveFetch = options?.fetch ?? this.fetchFn; - const effectiveSignal = options?.signal; - const controller = new AbortController(); - const timer = !effectiveSignal && this.timeout > 0 ? setTimeout(() => controller.abort(), this.timeout) : undefined; - const signal = effectiveSignal ?? (timer ? controller.signal : undefined); + const timer = this.timeout > 0 ? setTimeout(() => controller.abort(), this.timeout) : undefined; + const signal = timer ? controller.signal : undefined; try { - const response = await effectiveFetch(this.url, { + const response = await this.fetchFn(this.url, { method: 'POST', headers: { ...this.headers, diff --git a/packages/webhook/src/index.ts b/packages/webhook/src/index.ts index 75bb1dc9f..74420ffba 100644 --- a/packages/webhook/src/index.ts +++ b/packages/webhook/src/index.ts @@ -9,10 +9,8 @@ export { } from './errors'; export { - FetchFunction, IncomingWebhook, IncomingWebhookDefaultArguments, IncomingWebhookResult, IncomingWebhookSendArguments, - RequestOptions, } from './IncomingWebhook'; From a4162b29a2c6c3b5e1f9d309a541b7fa2c24195a Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 5 May 2026 17:12:17 -0400 Subject: [PATCH 13/27] Update package.json --- packages/webhook/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 05dc61f84..460e64db3 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -1,6 +1,6 @@ { "name": "@slack/webhook", - "version": "8.0.0-rc.1", + "version": "7.0.9", "description": "Official library for using the Slack Platform's Incoming Webhooks", "author": "Slack Technologies, LLC", "license": "MIT", From 6fa7843926f783cebb1f8e6c53a25635404df4d3 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 6 May 2026 11:34:47 -0400 Subject: [PATCH 14/27] improve based on review --- packages/socket-mode/src/SocketModeClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index cacaafd87..790b65a0a 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -302,7 +302,7 @@ export class SocketModeClient extends EventEmitter { this.logger.debug('Unexpected binary message received, ignoring.'); return; } - const payload = data as string; + const payload = typeof data === 'string' ? data : new TextDecoder().decode(data); // TODO: should we redact things in here? this.logger.debug(`Received a message on the WebSocket: ${payload}`); From 27d87bf2415e9d12c392c582f1bba795a7dc46d7 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 6 May 2026 11:54:42 -0400 Subject: [PATCH 15/27] clean up the unit tests --- packages/web-api/src/WebClient.test.ts | 36 -------------------- packages/webhook/src/IncomingWebhook.test.ts | 18 ++-------- 2 files changed, 2 insertions(+), 52 deletions(-) diff --git a/packages/web-api/src/WebClient.test.ts b/packages/web-api/src/WebClient.test.ts index 1efaa7452..0e588811d 100644 --- a/packages/web-api/src/WebClient.test.ts +++ b/packages/web-api/src/WebClient.test.ts @@ -428,26 +428,6 @@ describe('WebClient', () => { }); }); - describe('admin.analytics.getFile GZIP response', () => { - it('should decompress GZIP response and return file_data array', async () => { - const fileData = [ - { date: '2024-01-01', user_id: 'U123', messages_posted: 5 }, - { date: '2024-01-01', user_id: 'U456', messages_posted: 10 }, - ]; - const ndjson = fileData.map((d) => JSON.stringify(d)).join('\n'); - const gzipped = zlib.gzipSync(Buffer.from(ndjson)); - - const scope = nock('https://slack.com') - .post('/api/admin.analytics.getFile') - .reply(200, gzipped, { 'content-type': 'application/gzip' }); - - const client = new WebClient(token, { retryConfig: rapidRetryPolicy }); - const result = await client.apiCall('admin.analytics.getFile', { type: 'member' }); - assert.deepStrictEqual(result.file_data, fileData); - scope.done(); - }); - }); - describe('when an API call fails', () => { it('should return a Promise which rejects on error', async () => { const client = new WebClient(undefined, { retryConfig: { retries: 0 } }); @@ -497,22 +477,6 @@ describe('WebClient', () => { } }); - it('should set error.body to the raw string when HTTP error response is not valid JSON', async () => { - const htmlBody = '

502 Bad Gateway

'; - const scope = nock('https://slack.com').post(/api/).reply(502, htmlBody); - const client = new WebClient(token, { retryConfig: { retries: 0 } }); - try { - await client.apiCall('method'); - assert.fail('expected error to be thrown'); - } catch (error) { - const e = error as WebAPIHTTPError; - assert.strictEqual(e.code, ErrorCode.HTTPError); - assert.strictEqual(e.statusCode, 502); - assert.strictEqual(e.body, htmlBody); - scope.done(); - } - }); - it('should fail with WebAPIRequestError when the API request fails', async () => { // One known request error is when the node encounters an ECONNREFUSED. In order to simulate this, rather than // using nock, we send the request to a host:port that is not listening. diff --git a/packages/webhook/src/IncomingWebhook.test.ts b/packages/webhook/src/IncomingWebhook.test.ts index 84a1005c5..0373054a9 100644 --- a/packages/webhook/src/IncomingWebhook.test.ts +++ b/packages/webhook/src/IncomingWebhook.test.ts @@ -78,21 +78,6 @@ describe('IncomingWebhook', () => { }); }); - describe('User-Agent header', () => { - it('should send the User-Agent header with every request', async () => { - let capturedHeaders: HeadersInit | undefined; - const capturingFetch: typeof globalThis.fetch = async (_input, init) => { - capturedHeaders = init?.headers; - return new Response('ok', { status: 200 }); - }; - const webhook = new IncomingWebhook(url, { fetch: capturingFetch }); - await webhook.send('Hello'); - assert.ok(capturedHeaders); - const headers = capturedHeaders as Record; - assert.strictEqual(headers['User-Agent'], getUserAgent()); - }); - }); - describe('when the call fails', () => { let statusCode: number; let scope: nock.Scope; @@ -149,7 +134,8 @@ describe('IncomingWebhook', () => { const scope = nock('https://hooks.slack.com', { reqheaders: { 'User-Agent': (value) => { - return /@slack:webhook/.test(value); + assert.strictEqual(value, getUserAgent()); + return true; }, }, }) From 6af858d61d8168f5a3ce073df49d4b82605014cb Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 6 May 2026 13:09:17 -0400 Subject: [PATCH 16/27] make things work --- package-lock.json | 82 +++++++------------ packages/oauth/package.json | 2 +- packages/socket-mode/package.json | 4 +- .../socket-mode/src/SlackWebSocket.test.ts | 1 + packages/web-api/src/WebClient.ts | 2 + 5 files changed, 37 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index 84578fdf5..a52056c8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6044,15 +6044,6 @@ "dev": true, "license": "MIT" }, - "node_modules/undici": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.2.0.tgz", - "integrity": "sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -6369,7 +6360,7 @@ }, "packages/logger": { "name": "@slack/logger", - "version": "5.0.0", + "version": "5.0.0-rc.1", "license": "MIT", "dependencies": { "@types/node": ">=20" @@ -6400,7 +6391,7 @@ "license": "MIT", "dependencies": { "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", + "@slack/web-api": "^8.0.0-rc.1", "@types/jsonwebtoken": "^9", "@types/node": ">=20", "jsonwebtoken": "^9" @@ -6428,30 +6419,6 @@ "npm": ">= 8.6.0" } }, - "packages/oauth/node_modules/@slack/web-api": { - "version": "7.15.1", - "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.15.1.tgz", - "integrity": "sha512-y+TAF7TszcmFzbVtBkFqAdBwKSoD+8shkNxhp4WIfFwXmCKdFje9WD6evROApPa2FTy1v1uc9yBaJs3609PPgg==", - "license": "MIT", - "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/types": "^2.20.1", - "@types/node": ">=18", - "@types/retry": "0.12.0", - "axios": "^1.15.0", - "eventemitter3": "^5.0.1", - "form-data": "^4.0.4", - "is-electron": "2.2.2", - "is-stream": "^2", - "p-queue": "^6", - "p-retry": "^4", - "retry": "^0.13.1" - }, - "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" - } - }, "packages/oauth/node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -6461,18 +6428,6 @@ "undici-types": "~7.19.0" } }, - "packages/oauth/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/oauth/node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -6581,10 +6536,10 @@ "license": "MIT", "dependencies": { "@slack/logger": "^4.0.1", - "@slack/web-api": "^8.0.0", + "@slack/web-api": "^8.0.0-rc.1", "@types/node": ">=20", "eventemitter3": "^5", - "undici": "^8" + "undici": "^7.25.0" }, "devDependencies": { "@types/proxyquire": "^1.3.31", @@ -6621,6 +6576,15 @@ "undici-types": "~7.19.0" } }, + "packages/socket-mode/node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "packages/socket-mode/node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -6641,14 +6605,18 @@ }, "packages/web-api": { "name": "@slack/web-api", - "version": "8.0.0", + "version": "8.0.0-rc.1", "license": "MIT", "dependencies": { "@slack/logger": "^4.0.1", "@slack/types": "^2.21.0", "@types/node": ">=20", "@types/retry": "0.12.0", + "axios": "^1.15.0", "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" @@ -6688,6 +6656,18 @@ "undici-types": "~7.19.0" } }, + "packages/web-api/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/web-api/node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -6696,7 +6676,7 @@ }, "packages/webhook": { "name": "@slack/webhook", - "version": "8.0.0-rc.1", + "version": "7.0.9", "license": "MIT", "dependencies": { "@slack/types": "^2.20.1", diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 05404c8ee..67266b966 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -41,7 +41,7 @@ }, "dependencies": { "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", + "@slack/web-api": "^8.0.0-rc.1", "@types/jsonwebtoken": "^9", "@types/node": ">=20", "jsonwebtoken": "^9" diff --git a/packages/socket-mode/package.json b/packages/socket-mode/package.json index e85751e4a..2e638b94e 100644 --- a/packages/socket-mode/package.json +++ b/packages/socket-mode/package.json @@ -51,10 +51,10 @@ }, "dependencies": { "@slack/logger": "^4.0.1", - "@slack/web-api": "^8.0.0", + "@slack/web-api": "^8.0.0-rc.1", "@types/node": ">=20", "eventemitter3": "^5", - "undici": "^8" + "undici": "^7.25.0" }, "devDependencies": { "@types/proxyquire": "^1.3.31", diff --git a/packages/socket-mode/src/SlackWebSocket.test.ts b/packages/socket-mode/src/SlackWebSocket.test.ts index e6df9c76a..5a3563bcb 100644 --- a/packages/socket-mode/src/SlackWebSocket.test.ts +++ b/packages/socket-mode/src/SlackWebSocket.test.ts @@ -4,6 +4,7 @@ import { ConsoleLogger } from '@slack/logger'; import EventEmitter from 'eventemitter3'; import proxyquire from 'proxyquire'; import sinon from 'sinon'; +import { CloseEvent, ErrorEvent, MessageEvent } from 'undici'; proxyquire.noPreserveCache(); diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index 26f53d588..62dcf6d25 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -689,6 +689,8 @@ export class WebClient extends Methods { if (Buffer.isBuffer(value)) { containsBinaryData = true; } else if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + // if value is anything other than string, number, boolean, binary data, a Stream, or a Buffer, then encode it + // as a JSON string. serializedValue = JSON.stringify(value); } From 52ed3d8158c8bb235ffcc5ef86c3e0ce0cec2ab8 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 6 May 2026 13:30:14 -0400 Subject: [PATCH 17/27] restore preveously deleted comments --- packages/web-api/src/WebClient.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index 62dcf6d25..e37658691 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -581,6 +581,7 @@ export class WebClient extends Methods { const task = () => this.requestQueue.add(async () => { // apps.event.authorizations.list will reject HTTP requests that send token in the body + // TODO: consider applying this change to all methods - though that will require thorough integration testing if (url.endsWith('apps.event.authorizations.list')) { body.token = undefined; } @@ -614,11 +615,21 @@ export class WebClient extends Methods { throw new AbortError(rateLimitedErrorWithDelay(retrySec)); } this.logger.info(`API Call failed due to rate limiting. Will retry in ${retrySec} seconds.`); + // pause the request queue and then delay the rejection by the amount of time in the retry header this.requestQueue.pause(); + // NOTE: if there was a way to introspect the current RetryOperation and know what the next timeout + // would be, then we could subtract that time from the following delay, knowing that the next + // attempt still wouldn't occur until after the rate-limit header has specified. An even better + // solution would be to subtract the time from only the timeout of this next attempt of the + // RetryOperation. This would result in staying paused for the entire duration specified in the + // header, yet this operation not having to pay the timeout cost in addition to that. await delay(retrySec * 1000); + // resume the request queue and throw a non-abort error to signal a retry this.requestQueue.start(); + // TODO: We may want to have more detailed info such as team_id, params except tokens, and so on. throw new Error(`A rate limit was exceeded (url: ${url}, retry-after: ${retrySec})`); } + // TODO: turn this into some CodedError throw new AbortError( new Error( `Retry header did not contain a valid timeout (url: ${url}, retry-after header: ${response.headers.get('retry-after')})`, @@ -670,7 +681,8 @@ export class WebClient extends Methods { /** * Transforms a key-value object into a serialized body suitable for fetch. - * Returns either a FormData (for binary uploads) or a URL-encoded string, + * Flattens complex objects into JSON-encoded strings, detects binary content, + * and returns either a FormData (for binary uploads) or a URL-encoded string, * along with any content-type headers that should be set. */ private serializeBody(data: Record): { @@ -697,6 +709,7 @@ export class WebClient extends Methods { return [key, serializedValue]; }); + // A body with binary content should be serialized as multipart/form-data if (containsBinaryData) { this.logger.debug('Request arguments contain binary data'); const form = new FormData(); @@ -705,6 +718,8 @@ export class WebClient extends Methods { if (Buffer.isBuffer(value)) { // biome-ignore lint/suspicious/noExplicitAny: form values can be anything const streamOrBuffer: any = value as any; + // attempt to find filename from `value` + // formidable and the browser add a name property; fs streams have a path property let filename = defaultFilename; if (typeof streamOrBuffer.name === 'string') { filename = basename(streamOrBuffer.name); @@ -720,6 +735,7 @@ export class WebClient extends Methods { return { serializedBody: form, contentHeaders: {} }; } + // Otherwise, serialize as url-encoded key-value pairs // biome-ignore lint/suspicious/noExplicitAny: form values can be anything const initialValue: { [key: string]: any } = {}; const encoded = qsStringify( @@ -748,7 +764,7 @@ export class WebClient extends Methods { // biome-ignore lint/suspicious/noExplicitAny: HTTP response data can be anything let data: any; - // Check for GZIP response - if so, it is a successful response from admin.analytics.getFile + // admin.analytics.getFile returns a gzip binary response that can be unzipped if (isGzipResponse) { try { const buffer = Buffer.from(await response.arrayBuffer()); @@ -784,6 +800,7 @@ export class WebClient extends Methods { try { data = JSON.parse(text); } catch (_) { + // failed to parse the response body as JSON data = { ok: false, error: text }; } } From 79b798ef5810c505495e72ff216719acc3611a8d Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 6 May 2026 13:51:49 -0400 Subject: [PATCH 18/27] update the type from the package --- packages/socket-mode/src/SocketModeClient.test.ts | 3 ++- packages/web-api/src/WebClient.test.ts | 8 ++++---- packages/webhook/src/IncomingWebhook.test.ts | 4 ++-- packages/webhook/src/IncomingWebhook.ts | 2 +- packages/webhook/src/index.ts | 1 + 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index f2b72167d..3ceea0aaa 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it } from 'node:test'; import { ConsoleLogger } from '@slack/logger'; +import type { FetchFunction } from '@slack/web-api'; import proxyquire from 'proxyquire'; import sinon from 'sinon'; import type { Dispatcher } from 'undici'; @@ -57,7 +58,7 @@ describe('SocketModeClient', () => { it('should not overwrite fetch when a custom fetch is provided', () => { const fakeDispatcher = {} as unknown as Dispatcher; - const customFetch = (() => {}) as unknown as typeof globalThis.fetch; + const customFetch = (() => {}) as unknown as FetchFunction; new ProxiedSocketModeClient({ appToken: 'xapp-', dispatcher: fakeDispatcher, diff --git a/packages/web-api/src/WebClient.test.ts b/packages/web-api/src/WebClient.test.ts index 0e588811d..79c7bf4c3 100644 --- a/packages/web-api/src/WebClient.test.ts +++ b/packages/web-api/src/WebClient.test.ts @@ -20,7 +20,7 @@ import { import { addAppMetadata } from './instrument'; import { type Logger, LogLevel } from './logger'; import { rapidRetryPolicy } from './retry-policies'; -import { buildThreadTsWarningMessage, type WebAPICallResult, WebClient, WebClientEvent } from './WebClient'; +import { buildThreadTsWarningMessage, type FetchFunction, type WebAPICallResult, WebClient, WebClientEvent } from './WebClient'; const token = 'xoxb-faketoken'; @@ -117,7 +117,7 @@ describe('WebClient', () => { it('should throw error if timeout exceeded', async () => { const timeoutOverride = 1; // ms, guaranteed failure - const slowFetch: typeof globalThis.fetch = (_input, init) => + const slowFetch: FetchFunction = (_input, init) => new Promise((_resolve, reject) => { const timer = setTimeout(() => reject(new Error('should have been aborted')), 5000); init?.signal?.addEventListener('abort', () => { @@ -141,7 +141,7 @@ describe('WebClient', () => { }); it('should produce a WebAPIRequestError with original when timeout fires', async () => { - const slowFetch: typeof globalThis.fetch = (_input, init) => + const slowFetch: FetchFunction = (_input, init) => new Promise((_resolve, reject) => { const timer = setTimeout(() => reject(new Error('should have been aborted')), 5000); init?.signal?.addEventListener('abort', () => { @@ -1118,7 +1118,7 @@ describe('WebClient', () => { describe('custom fetch', () => { it('should use a custom fetch function when provided via constructor', async () => { let fetchCalled = false; - const customFetch: typeof globalThis.fetch = async () => { + const customFetch: FetchFunction = async () => { fetchCalled = true; return new Response(JSON.stringify({ ok: true }), { status: 200, diff --git a/packages/webhook/src/IncomingWebhook.test.ts b/packages/webhook/src/IncomingWebhook.test.ts index 0373054a9..7c8ab751e 100644 --- a/packages/webhook/src/IncomingWebhook.test.ts +++ b/packages/webhook/src/IncomingWebhook.test.ts @@ -4,7 +4,7 @@ import nock from 'nock'; import type { CodedError } from './errors'; import { ErrorCode } from './errors'; -import { IncomingWebhook } from './IncomingWebhook'; +import { type FetchFunction, IncomingWebhook } from './IncomingWebhook'; import { getUserAgent } from './instrument'; const url = 'https://hooks.slack.com/services/FAKEWEBHOOK'; @@ -35,7 +35,7 @@ describe('IncomingWebhook', () => { it('should use a custom fetch function when provided', async () => { let fetchCalled = false; - const customFetch: typeof globalThis.fetch = async () => { + const customFetch: FetchFunction = async () => { fetchCalled = true; return new Response('ok', { status: 200 }); }; diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index df84f5c72..2432e5b7e 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -4,7 +4,7 @@ import type { CodedError } from './errors'; import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors'; import { getUserAgent } from './instrument'; -type FetchFunction = typeof globalThis.fetch; +export type FetchFunction = typeof globalThis.fetch; /** * A client for Slack's Incoming Webhooks diff --git a/packages/webhook/src/index.ts b/packages/webhook/src/index.ts index 74420ffba..331d6f8ca 100644 --- a/packages/webhook/src/index.ts +++ b/packages/webhook/src/index.ts @@ -9,6 +9,7 @@ export { } from './errors'; export { + FetchFunction, IncomingWebhook, IncomingWebhookDefaultArguments, IncomingWebhookResult, From 811a029e34605f995d5de6d810cfd6f499077388 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 7 May 2026 09:57:30 -0400 Subject: [PATCH 19/27] Update package-lock.json --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index a52056c8a..43277d8bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6612,7 +6612,7 @@ "@slack/types": "^2.21.0", "@types/node": ">=20", "@types/retry": "0.12.0", - "axios": "^1.15.0", + "axios": "^1.16.0", "eventemitter3": "^5.0.1", "form-data": "^4.0.4", "is-electron": "2.2.2", From 5ecb21d52f72690b64de1ddec7392885f105e1a6 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 7 May 2026 17:04:12 -0400 Subject: [PATCH 20/27] fix accept header being set by axios previously --- packages/web-api/src/WebClient.test.ts | 34 +++++++++++++++++++++++++- packages/web-api/src/WebClient.ts | 2 +- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/web-api/src/WebClient.test.ts b/packages/web-api/src/WebClient.test.ts index 79c7bf4c3..f0d075183 100644 --- a/packages/web-api/src/WebClient.test.ts +++ b/packages/web-api/src/WebClient.test.ts @@ -20,7 +20,13 @@ import { import { addAppMetadata } from './instrument'; import { type Logger, LogLevel } from './logger'; import { rapidRetryPolicy } from './retry-policies'; -import { buildThreadTsWarningMessage, type FetchFunction, type WebAPICallResult, WebClient, WebClientEvent } from './WebClient'; +import { + buildThreadTsWarningMessage, + type FetchFunction, + type WebAPICallResult, + WebClient, + WebClientEvent, +} from './WebClient'; const token = 'xoxb-faketoken'; @@ -690,6 +696,32 @@ describe('WebClient', () => { }); }); + describe('apiCall() - default Accept header', () => { + it('should include Accept: application/json header by default', async () => { + const scope = nock('https://slack.com', { + reqheaders: { + Accept: 'application/json', + }, + }) + .post(/api/) + .reply(200, { ok: true }); + await client.apiCall('method'); + scope.done(); + }); + it('should allow overriding Accept header via constructor options', async () => { + const customClient = new WebClient(token, { headers: { Accept: 'text/plain' } }); + const scope = nock('https://slack.com', { + reqheaders: { + Accept: 'text/plain', + }, + }) + .post(/api/) + .reply(200, { ok: true }); + await customClient.apiCall('method'); + scope.done(); + }); + }); + describe('named method aliases (facets)', () => { beforeEach(() => { client = new WebClient(token, { retryConfig: rapidRetryPolicy }); diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index e37658691..0f22bc7e9 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -231,7 +231,7 @@ export class WebClient extends Methods { this.fetchFn = fetch ?? globalThis.fetch; this.timeout = timeout; - this.defaultHeaders = { 'User-Agent': getUserAgent(), ...headers }; + this.defaultHeaders = { 'User-Agent': getUserAgent(), Accept: 'application/json', ...headers }; this.logger.debug('initialized'); } From c5a944d141b798547a0ae47ef69f4e81f413be3e Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 7 May 2026 17:18:13 -0400 Subject: [PATCH 21/27] fix issue where axios was added back in --- package-lock.json | 16 ---------------- packages/web-api/package.json | 4 ---- 2 files changed, 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 696db0814..aa7661f14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6612,11 +6612,7 @@ "@slack/types": "^2.21.0", "@types/node": ">=20", "@types/retry": "0.12.0", - "axios": "^1.16.0", "eventemitter3": "^5.0.1", - "form-data": "^4.0.4", - "is-electron": "2.2.2", - "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" @@ -6656,18 +6652,6 @@ "undici-types": "~7.19.0" } }, - "packages/web-api/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/web-api/node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", diff --git a/packages/web-api/package.json b/packages/web-api/package.json index de12b5f1c..46a734e09 100644 --- a/packages/web-api/package.json +++ b/packages/web-api/package.json @@ -52,11 +52,7 @@ "@slack/types": "^2.21.0", "@types/node": ">=20", "@types/retry": "0.12.0", - "axios": "^1.16.0", "eventemitter3": "^5.0.1", - "form-data": "^4.0.4", - "is-electron": "2.2.2", - "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" From 2f57822cf075148933e6ebd88e76b16e155ec790 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 7 May 2026 18:04:50 -0400 Subject: [PATCH 22/27] we aren't returning the correct response from start --- packages/socket-mode/src/SocketModeClient.ts | 5 ++++- packages/socket-mode/test/integration.test.js | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index 790b65a0a..3cc93df21 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -65,6 +65,8 @@ export class SocketModeClient extends EventEmitter { */ private dispatcher?: Dispatcher; + private connectionResponse?: AppsConnectionsOpenResponse; + public websocket?: SlackWebSocket; /** @@ -266,6 +268,7 @@ export class SocketModeClient extends EventEmitter { throw new Error(msg); } this.numOfConsecutiveReconnectionFailures = 0; + this.connectionResponse = resp; this.emit(State.Authenticated, resp); return resp.url; } catch (error) { @@ -328,7 +331,7 @@ export class SocketModeClient extends EventEmitter { // Slack has finalized the handshake with a hello message; we are good to go. if (event.type === 'hello') { - this.emit(State.Connected); + this.emit(State.Connected, this.connectionResponse); return; } diff --git a/packages/socket-mode/test/integration.test.js b/packages/socket-mode/test/integration.test.js index 03eccbcef..8635a2445 100644 --- a/packages/socket-mode/test/integration.test.js +++ b/packages/socket-mode/test/integration.test.js @@ -69,6 +69,12 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => await client.start(); await client.disconnect(); }); + it('start() resolves with the apps.connections.open API response', async () => { + const result = await client.start(); + assert.equal(result.ok, true); + assert.equal(result.url, `ws://localhost:${WSS_PORT}/`); + await client.disconnect(); + }); it('can call `disconnect()` even if already disconnected without issue', async () => { await client.disconnect(); }); From 234a516b4136c016c5ec2809334fbde7fa6aeb47 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 8 May 2026 12:42:30 -0400 Subject: [PATCH 23/27] make undici a peer dependency --- package-lock.json | 8 ++++++-- packages/socket-mode/package.json | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index aa7661f14..6159569d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6538,8 +6538,7 @@ "@slack/logger": "^4.0.1", "@slack/web-api": "^8.0.0-rc.1", "@types/node": ">=20", - "eventemitter3": "^5", - "undici": "^7.25.0" + "eventemitter3": "^5" }, "devDependencies": { "@types/proxyquire": "^1.3.31", @@ -6547,11 +6546,15 @@ "nodemon": "^3.1.0", "proxyquire": "^2.1.3", "sinon": "^21", + "undici": "^7.25.0", "ws": "^8" }, "engines": { "node": ">=20", "npm": ">=9.6.4" + }, + "peerDependencies": { + "undici": "^7.0.0" } }, "packages/socket-mode/node_modules/@slack/logger": { @@ -6580,6 +6583,7 @@ "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/packages/socket-mode/package.json b/packages/socket-mode/package.json index 2e638b94e..e83e11ba0 100644 --- a/packages/socket-mode/package.json +++ b/packages/socket-mode/package.json @@ -53,8 +53,10 @@ "@slack/logger": "^4.0.1", "@slack/web-api": "^8.0.0-rc.1", "@types/node": ">=20", - "eventemitter3": "^5", - "undici": "^7.25.0" + "eventemitter3": "^5" + }, + "peerDependencies": { + "undici": "^7.0.0" }, "devDependencies": { "@types/proxyquire": "^1.3.31", @@ -62,6 +64,7 @@ "nodemon": "^3.1.0", "proxyquire": "^2.1.3", "sinon": "^21", + "undici": "^7.25.0", "ws": "^8" } } From e3bf6ed9e7a1e6b12673e1af4b3ccc121cb6119e Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 8 May 2026 16:03:02 -0400 Subject: [PATCH 24/27] improve devxp of fetch typing --- packages/socket-mode/src/SocketModeClient.ts | 8 ++--- packages/web-api/src/WebClient.ts | 34 ++++++++++++++++--- .../test/types/fetch-function.test-d.ts | 27 +++++++++++++++ packages/webhook/src/IncomingWebhook.ts | 28 +++++++++++++-- 4 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 packages/web-api/test/types/fetch-function.test-d.ts diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index 3cc93df21..2c5e2867a 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -8,7 +8,7 @@ import { } from '@slack/web-api'; import { EventEmitter } from 'eventemitter3'; -import { type Dispatcher, fetch as undiciFetch } from 'undici'; +import { type Dispatcher, type RequestInit, fetch as undiciFetch } from 'undici'; import packageJson from '../package.json'; import { sendWhileDisconnectedError, sendWhileNotReadyError, websocketErrorWithOriginal } from './errors'; @@ -130,11 +130,7 @@ export class SocketModeClient extends EventEmitter { } this.webClientOptions = clientOptions; if (dispatcher && this.webClientOptions.fetch === undefined) { - const { dispatcher } = this; - this.webClientOptions.fetch = (( - input: Parameters[0], - init?: Parameters[1], - ) => undiciFetch(input, { ...init, dispatcher })) as unknown as typeof globalThis.fetch; + this.webClientOptions.fetch = (url, init) => undiciFetch(url, { ...init, dispatcher } as RequestInit); } if (this.webClientOptions.retryConfig === undefined) { // For faster retries of apps.connections.open API calls for reconnecting diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index 0f22bc7e9..53902ac04 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -116,7 +116,31 @@ export type PageAccumulator = R extends ( ? A : never; -export type FetchFunction = typeof globalThis.fetch; +export interface FetchHeaders { + get(name: string): string | null; + entries(): Iterable<[string, string]>; +} + +export interface FetchResponse { + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly url: string; + readonly headers: FetchHeaders; + arrayBuffer(): Promise; + json(): Promise; + text(): Promise; +} + +export interface FetchRequestInit { + method?: string; + headers?: Record; + body?: string | FormData; + redirect?: 'error' | 'follow' | 'manual'; + signal?: AbortSignal | null; +} + +export type FetchFunction = (url: string | URL, init?: FetchRequestInit) => Promise; /** * A client for Slack's Web API @@ -577,7 +601,7 @@ export class WebClient extends Methods { url: string, body: Record, headers: Record = {}, - ): Promise { + ): Promise { const task = () => this.requestQueue.add(async () => { // apps.event.authorizations.list will reject HTTP requests that send token in the body @@ -664,7 +688,7 @@ export class WebClient extends Methods { if (timer) clearTimeout(timer); } }); - return pRetry(task, this.retryConfig) as Promise; + return pRetry(task, this.retryConfig) as Promise; } /** @@ -757,7 +781,7 @@ export class WebClient extends Methods { * HTTP headers into the object. * @param response - an http response */ - private async buildResult(response: Response): Promise { + private async buildResult(response: FetchResponse): Promise { const contentType = response.headers.get('content-type'); const isGzipResponse = contentType === 'application/gzip'; @@ -858,7 +882,7 @@ function paginationOptionsForNextPage( * Extract the amount of time (in seconds) the platform has recommended this client wait before sending another request * from a rate-limited HTTP response (statusCode = 429). */ -function parseRetryHeaders(response: Response): number | undefined { +function parseRetryHeaders(response: FetchResponse): number | undefined { const retryAfterHeader = response.headers.get('retry-after'); if (retryAfterHeader !== null) { const retryAfter = Number.parseInt(retryAfterHeader, 10); diff --git a/packages/web-api/test/types/fetch-function.test-d.ts b/packages/web-api/test/types/fetch-function.test-d.ts new file mode 100644 index 000000000..06dc7b080 --- /dev/null +++ b/packages/web-api/test/types/fetch-function.test-d.ts @@ -0,0 +1,27 @@ +import { expectAssignable } from 'tsd'; +import type { FetchFunction } from '../../'; + +// globalThis.fetch satisfies FetchFunction +expectAssignable(globalThis.fetch); + +// A custom wrapper function satisfies FetchFunction +const customFetch: FetchFunction = async (url, init) => { + return globalThis.fetch(url, init); +}; +expectAssignable(customFetch); + +// A minimal mock satisfies FetchFunction +const mockFetch: FetchFunction = async () => ({ + ok: true, + status: 200, + statusText: 'OK', + url: 'https://example.com', + headers: { + get: () => null, + entries: () => [][Symbol.iterator](), + }, + arrayBuffer: async () => new ArrayBuffer(0), + json: async () => ({}), + text: async () => '', +}); +expectAssignable(mockFetch); diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 2432e5b7e..1d0b0f9cd 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -4,7 +4,31 @@ import type { CodedError } from './errors'; import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors'; import { getUserAgent } from './instrument'; -export type FetchFunction = typeof globalThis.fetch; +export interface FetchHeaders { + get(name: string): string | null; + entries(): Iterable<[string, string]>; +} + +export interface FetchResponse { + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly url: string; + readonly headers: FetchHeaders; + arrayBuffer(): Promise; + json(): Promise; + text(): Promise; +} + +export interface FetchRequestInit { + method?: string; + headers?: Record; + body?: string | FormData; + redirect?: 'error' | 'follow' | 'manual'; + signal?: AbortSignal | null; +} + +export type FetchFunction = (url: string | URL, init?: FetchRequestInit) => Promise; /** * A client for Slack's Incoming Webhooks @@ -105,7 +129,7 @@ export class IncomingWebhook { /** * Processes an HTTP response into an IncomingWebhookResult. */ - private async buildResult(response: Response): Promise { + private async buildResult(response: FetchResponse): Promise { return { text: await response.text(), }; From 6d9ad8e4bd95e832b609ff813050d15a568c8d7a Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 11 May 2026 15:32:52 -0400 Subject: [PATCH 25/27] Improve abstraction between our lib and undici types --- packages/socket-mode/src/SlackWebSocket.ts | 5 +++-- packages/socket-mode/src/SocketModeClient.ts | 7 ++++--- packages/socket-mode/src/SocketModeOptions.ts | 15 +++++++++++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index 004761926..ee9869219 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -5,6 +5,7 @@ import { CloseEvent, type Dispatcher, ErrorEvent, MessageEvent, ping, WebSocket import { websocketErrorWithOriginal } from './errors'; import log, { type Logger, LogLevel } from './logger'; +import type { SocketModeDispatcher } from './SocketModeOptions'; export const WS_READY_STATES = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; @@ -38,7 +39,7 @@ export interface SlackWebSocketOptions { /** @description Delay between this client sending a `ping` message, in milliseconds. */ pingInterval?: number; /** @description An undici Dispatcher used to establish the WebSocket connection (e.g. ProxyAgent). */ - dispatcher?: Dispatcher; + dispatcher?: SocketModeDispatcher; /** @description Whether this WebSocket should DEBUG log ping and pong events. `false` by default. */ pingPongLoggingEnabled?: boolean; /** @@ -135,7 +136,7 @@ export class SlackWebSocket { public connect(): void { this.logger.debug('Initiating new WebSocket connection.'); - this.websocket = new WebSocket(this.options.url, { dispatcher: this.options.dispatcher }); + this.websocket = new WebSocket(this.options.url, { dispatcher: this.options.dispatcher as Dispatcher }); this.openHandler = () => { this.logger.debug('WebSocket open event received (connection established)!'); diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index 2c5e2867a..4061e8110 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -14,7 +14,7 @@ import packageJson from '../package.json'; import { sendWhileDisconnectedError, sendWhileNotReadyError, websocketErrorWithOriginal } from './errors'; import log, { type Logger, LogLevel } from './logger'; import { SlackWebSocket, WS_READY_STATES } from './SlackWebSocket'; -import type { SocketModeOptions } from './SocketModeOptions'; +import type { SocketModeDispatcher, SocketModeOptions } from './SocketModeOptions'; import { UnrecoverableSocketModeStartError } from './UnrecoverableSocketModeStartError'; // Lifecycle events as described in the README @@ -63,7 +63,7 @@ export class SocketModeClient extends EventEmitter { * The undici Dispatcher used for WebSocket connections. Also wrapped into a custom fetch for HTTP calls * unless `clientOptions.fetch` was provided by the user. */ - private dispatcher?: Dispatcher; + private dispatcher?: SocketModeDispatcher; private connectionResponse?: AppsConnectionsOpenResponse; @@ -130,7 +130,8 @@ export class SocketModeClient extends EventEmitter { } this.webClientOptions = clientOptions; if (dispatcher && this.webClientOptions.fetch === undefined) { - this.webClientOptions.fetch = (url, init) => undiciFetch(url, { ...init, dispatcher } as RequestInit); + this.webClientOptions.fetch = (url, init) => + undiciFetch(url, { ...init, dispatcher: dispatcher as Dispatcher } as RequestInit); } if (this.webClientOptions.retryConfig === undefined) { // For faster retries of apps.connections.open API calls for reconnecting diff --git a/packages/socket-mode/src/SocketModeOptions.ts b/packages/socket-mode/src/SocketModeOptions.ts index 20df00737..018f91a03 100644 --- a/packages/socket-mode/src/SocketModeOptions.ts +++ b/packages/socket-mode/src/SocketModeOptions.ts @@ -1,7 +1,18 @@ import type { WebClientOptions } from '@slack/web-api'; -import type { Dispatcher } from 'undici'; import type { Logger, LogLevel } from './logger'; +/** + * A structural type representing an HTTP dispatcher compatible with undici's fetch and WebSocket. + * Any undici `Agent`, `ProxyAgent`, `Client`, or custom `Dispatcher` subclass satisfies this interface. + * + * Defining this structurally allows consumers to use different compatible undici versions + * without type conflicts. + */ +export interface SocketModeDispatcher { + // biome-ignore lint/suspicious/noExplicitAny: structural compatibility with any undici Dispatcher version + dispatch(options: any, handler: any): boolean; +} + export interface SocketModeOptions { /** * The App-level token associated with your app, located under the Basic Information page on api.slack.com/apps. @@ -54,5 +65,5 @@ export interface SocketModeOptions { * * @see https://undici.nodejs.org/#/docs/api/ProxyAgent */ - dispatcher?: Dispatcher; + dispatcher?: SocketModeDispatcher; } From 6d262526face92b0dc35c02060c773edac957165 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 11 May 2026 15:45:43 -0400 Subject: [PATCH 26/27] Improve typing of socketmode dispatcher configuration --- package-lock.json | 1 + packages/socket-mode/package.json | 5 ++++ .../socket-mode/src/SocketModeClient.test.ts | 6 ++--- packages/socket-mode/src/index.ts | 2 +- .../test/types/dispatcher.test-d.ts | 24 +++++++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 packages/socket-mode/test/types/dispatcher.test-d.ts diff --git a/package-lock.json b/package-lock.json index 6159569d5..b0a31d67f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6546,6 +6546,7 @@ "nodemon": "^3.1.0", "proxyquire": "^2.1.3", "sinon": "^21", + "tsd": "^0.33.0", "undici": "^7.25.0", "ws": "^8" }, diff --git a/packages/socket-mode/package.json b/packages/socket-mode/package.json index e83e11ba0..c0b816af6 100644 --- a/packages/socket-mode/package.json +++ b/packages/socket-mode/package.json @@ -46,6 +46,7 @@ "test": "npm run test:unit && npm run test:integration", "test:coverage": "npm run build && node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/*.test.ts", "test:integration": "npm run build && node --import tsx --test test/integration.test.js", + "test:types": "tsd", "test:unit": "npm run build && bash -c 'node --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/*.test.ts'", "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm test" }, @@ -64,7 +65,11 @@ "nodemon": "^3.1.0", "proxyquire": "^2.1.3", "sinon": "^21", + "tsd": "^0.33.0", "undici": "^7.25.0", "ws": "^8" + }, + "tsd": { + "directory": "test/types" } } diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index 3ceea0aaa..15f942338 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -4,10 +4,10 @@ import { ConsoleLogger } from '@slack/logger'; import type { FetchFunction } from '@slack/web-api'; import proxyquire from 'proxyquire'; import sinon from 'sinon'; -import type { Dispatcher } from 'undici'; import logModule from './logger'; import { SocketModeClient } from './SocketModeClient'; +import type { SocketModeDispatcher } from './SocketModeOptions'; describe('SocketModeClient', () => { const sandbox = sinon.createSandbox(); @@ -51,13 +51,13 @@ describe('SocketModeClient', () => { }); it('should wrap dispatcher into fetch when no custom fetch is provided', () => { - const fakeDispatcher = {} as unknown as Dispatcher; + const fakeDispatcher: SocketModeDispatcher = { dispatch: () => true }; new ProxiedSocketModeClient({ appToken: 'xapp-', dispatcher: fakeDispatcher }); assert.strictEqual(typeof capturedWebClientOptions.fetch, 'function'); }); it('should not overwrite fetch when a custom fetch is provided', () => { - const fakeDispatcher = {} as unknown as Dispatcher; + const fakeDispatcher: SocketModeDispatcher = { dispatch: () => true }; const customFetch = (() => {}) as unknown as FetchFunction; new ProxiedSocketModeClient({ appToken: 'xapp-', diff --git a/packages/socket-mode/src/index.ts b/packages/socket-mode/src/index.ts index 366e13f24..3ad0e2225 100644 --- a/packages/socket-mode/src/index.ts +++ b/packages/socket-mode/src/index.ts @@ -12,5 +12,5 @@ export { export { Logger, LogLevel } from './logger'; export { SocketModeClient } from './SocketModeClient'; -export { SocketModeOptions } from './SocketModeOptions'; +export { SocketModeDispatcher, SocketModeOptions } from './SocketModeOptions'; export { UnrecoverableSocketModeStartError } from './UnrecoverableSocketModeStartError'; diff --git a/packages/socket-mode/test/types/dispatcher.test-d.ts b/packages/socket-mode/test/types/dispatcher.test-d.ts new file mode 100644 index 000000000..10ad2406b --- /dev/null +++ b/packages/socket-mode/test/types/dispatcher.test-d.ts @@ -0,0 +1,24 @@ +import { expectAssignable, expectNotAssignable } from 'tsd'; +import { Agent, ProxyAgent } from 'undici'; +import type { SocketModeDispatcher } from '../../'; + +// undici Agent satisfies SocketModeDispatcher +expectAssignable(new Agent()); + +// undici ProxyAgent satisfies SocketModeDispatcher +expectAssignable(new ProxyAgent('http://proxy:3128')); + +// A custom object with dispatch() satisfies SocketModeDispatcher +const customDispatcher = { + // biome-ignore lint/suspicious/noExplicitAny: testing structural compatibility with arbitrary dispatch implementations + dispatch(_options: any, _handler: any): boolean { + return true; + }, +}; +expectAssignable(customDispatcher); + +// An empty object does NOT satisfy SocketModeDispatcher +expectNotAssignable({}); + +// A string does NOT satisfy SocketModeDispatcher +expectNotAssignable('not-a-dispatcher'); From b585c95760d369bf3fc64e8e1bfc67fbb2e8ff41 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 11 May 2026 16:16:17 -0400 Subject: [PATCH 27/27] improve type casting behaviors --- packages/socket-mode/src/SocketModeClient.test.ts | 2 +- packages/web-api/src/WebClient.ts | 14 +++++--------- packages/web-api/test/types/methods/chat.test-d.ts | 10 ---------- packages/webhook/src/IncomingWebhook.ts | 3 +-- 4 files changed, 7 insertions(+), 22 deletions(-) diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index 15f942338..368644556 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -58,7 +58,7 @@ describe('SocketModeClient', () => { it('should not overwrite fetch when a custom fetch is provided', () => { const fakeDispatcher: SocketModeDispatcher = { dispatch: () => true }; - const customFetch = (() => {}) as unknown as FetchFunction; + const customFetch: FetchFunction = async () => new Response(); new ProxiedSocketModeClient({ appToken: 'xapp-', dispatcher: fakeDispatcher, diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index 53902ac04..9b8c63e6a 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -674,16 +674,15 @@ export class WebClient extends Methods { return response; } catch (error) { - // biome-ignore lint/suspicious/noExplicitAny: errors can be anything - const e = error as any; if (error instanceof AbortError) { throw error; } - if (e.code !== undefined && typeof e.code === 'string') { + if (error instanceof Error && 'code' in error && typeof error.code === 'string') { throw error; } - this.logger.warn('http request failed', e.message); - throw requestErrorWithOriginal(e instanceof Error ? e : new Error(String(e))); + const message = error instanceof Error ? error.message : String(error); + this.logger.warn('http request failed', message); + throw requestErrorWithOriginal(error instanceof Error ? error : new Error(String(error))); } finally { if (timer) clearTimeout(timer); } @@ -740,10 +739,7 @@ export class WebClient extends Methods { for (const [key, value] of flattened) { if (key === undefined || value === undefined) continue; if (Buffer.isBuffer(value)) { - // biome-ignore lint/suspicious/noExplicitAny: form values can be anything - const streamOrBuffer: any = value as any; - // attempt to find filename from `value` - // formidable and the browser add a name property; fs streams have a path property + const streamOrBuffer = value as Buffer & { name?: string; path?: string }; let filename = defaultFilename; if (typeof streamOrBuffer.name === 'string') { filename = basename(streamOrBuffer.name); diff --git a/packages/web-api/test/types/methods/chat.test-d.ts b/packages/web-api/test/types/methods/chat.test-d.ts index 137e3b1b4..3980bf0ed 100644 --- a/packages/web-api/test/types/methods/chat.test-d.ts +++ b/packages/web-api/test/types/methods/chat.test-d.ts @@ -1169,13 +1169,3 @@ expectAssignable>([ reply_broadcast: true, // can broadcast a threaded msg }, ]); - -// RequestOptions can be passed as a second argument to any method -expectAssignable>([ - { channel: 'C1234', text: 'hello' }, - { signal: new AbortController().signal } satisfies RequestOptions, -]); -expectAssignable>([ - { channel: 'C1234', text: 'hello' }, - { fetch: globalThis.fetch } satisfies RequestOptions, -]); diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 1d0b0f9cd..1d508f503 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -1,6 +1,5 @@ import type { Block, KnownBlock, MessageAttachment } from '@slack/types'; // TODO: Block and KnownBlock will be merged into AnyBlock in upcoming types release -import type { CodedError } from './errors'; import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors'; import { getUserAgent } from './instrument'; @@ -117,7 +116,7 @@ export class IncomingWebhook { return await this.buildResult(response); } catch (error) { - if (error instanceof Error && 'code' in error && typeof (error as CodedError).code === 'string') { + if (error instanceof Error && 'code' in error && typeof error.code === 'string') { throw error; } throw requestErrorWithOriginal(error instanceof Error ? error : new Error(String(error)));