diff --git a/.changeset/drop-node18-cli-hooks.md b/.changeset/drop-node18-cli-hooks.md new file mode 100644 index 000000000..5c8321127 --- /dev/null +++ b/.changeset/drop-node18-cli-hooks.md @@ -0,0 +1,5 @@ +--- +"@slack/cli-hooks": major +--- + +Drop Node.js 18 support. The minimum supported Node.js version is now 20. diff --git a/.changeset/drop-node18-cli-test.md b/.changeset/drop-node18-cli-test.md new file mode 100644 index 000000000..5df35fe6d --- /dev/null +++ b/.changeset/drop-node18-cli-test.md @@ -0,0 +1,5 @@ +--- +"@slack/cli-test": major +--- + +Drop Node.js 18 support. The minimum supported Node.js version is now 20. diff --git a/.changeset/drop-node18-logger.md b/.changeset/drop-node18-logger.md new file mode 100644 index 000000000..2a9e86428 --- /dev/null +++ b/.changeset/drop-node18-logger.md @@ -0,0 +1,5 @@ +--- +"@slack/logger": major +--- + +Drop Node.js 18 support. The minimum supported Node.js version is now 20. diff --git a/.changeset/drop-node18-oauth.md b/.changeset/drop-node18-oauth.md new file mode 100644 index 000000000..3c955190a --- /dev/null +++ b/.changeset/drop-node18-oauth.md @@ -0,0 +1,5 @@ +--- +"@slack/oauth": major +--- + +Drop Node.js 18 support. The minimum supported Node.js version is now 20. diff --git a/.changeset/drop-node18-socket-mode.md b/.changeset/drop-node18-socket-mode.md new file mode 100644 index 000000000..1e58d21d5 --- /dev/null +++ b/.changeset/drop-node18-socket-mode.md @@ -0,0 +1,5 @@ +--- +"@slack/socket-mode": major +--- + +Drop Node.js 18 support. The minimum supported Node.js version is now 20. diff --git a/.changeset/drop-node18-types.md b/.changeset/drop-node18-types.md new file mode 100644 index 000000000..bbcbcdbcd --- /dev/null +++ b/.changeset/drop-node18-types.md @@ -0,0 +1,5 @@ +--- +"@slack/types": major +--- + +Drop Node.js 18 support. The minimum supported Node.js version is now 20. 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/.changeset/oauth-web-api-v8-dependency.md b/.changeset/oauth-web-api-v8-dependency.md new file mode 100644 index 000000000..16aa81998 --- /dev/null +++ b/.changeset/oauth-web-api-v8-dependency.md @@ -0,0 +1,24 @@ +--- +"@slack/oauth": major +--- + +Updated the internal `@slack/web-api` dependency from `^7` to `^8`. If you pass `clientOptions` to `InstallProvider`, the following options are no longer available: + +- **`clientOptions.agent`** — Use `clientOptions.fetch` with a custom fetch implementation instead. +- **`clientOptions.tls`** — Configure TLS via `clientOptions.fetch` or the `NODE_EXTRA_CA_CERTS` environment variable. + +```js +import { InstallProvider } from '@slack/oauth'; +import { fetch, Agent } from 'undici'; + +const installer = new InstallProvider({ + clientId: process.env.SLACK_CLIENT_ID, + clientSecret: process.env.SLACK_CLIENT_SECRET, + stateSecret: 'my-secret', + clientOptions: { + fetch: (url, init) => fetch(url, { ...init, dispatcher: new Agent({ connect: { ca: myCA } }) }), + }, +}); +``` + +See the `@slack/web-api` v8 changelog for the full list of breaking changes that affect `clientOptions`. diff --git a/.changeset/socket-mode-error-classes.md b/.changeset/socket-mode-error-classes.md new file mode 100644 index 000000000..b73ddd4a3 --- /dev/null +++ b/.changeset/socket-mode-error-classes.md @@ -0,0 +1,23 @@ +--- +"@slack/socket-mode": major +--- + +Redesigned error handling to use proper `Error` subclasses instead of plain objects with a `code` property. + +**Migration:** Replace `if (error.code === ErrorCode.WebsocketError)` with `if (error instanceof SMWebsocketError)`. + +**New error classes:** +- `SMPlatformError` — Slack platform returned an error event +- `SMWebsocketError` — WebSocket connection failure (original error in `cause`) +- `SMNoReplyReceivedError` — Timed out waiting for a reply to an acknowledgement +- `SMSendWhileDisconnectedError` — Attempted to send while not connected +- `SMSendWhileNotReadyError` — Attempted to send before the connection was ready + +**Removed factory functions** (use `new` with the corresponding class instead): +- `websocketErrorWithOriginal()` → `new SMWebsocketError(original)` +- `platformErrorFromEvent()` → `new SMPlatformError(event)` +- `noReplyReceivedError()` → `new SMNoReplyReceivedError()` +- `sendWhileDisconnectedError()` → `new SMSendWhileDisconnectedError()` +- `sendWhileNotReadyError()` → `new SMSendWhileNotReadyError()` + +The `CodedError` interface is deprecated — use `instanceof` checks with specific error classes instead. The `error.code` property still exists for backward-compatible checks, but `error.name` values changed from generic `'Error'` to descriptive class names (e.g., `'SMWebsocketError'`). diff --git a/.changeset/socket-mode-undici-migration.md b/.changeset/socket-mode-undici-migration.md new file mode 100644 index 000000000..a95afd1d4 --- /dev/null +++ b/.changeset/socket-mode-undici-migration.md @@ -0,0 +1,29 @@ +--- +"@slack/socket-mode": major +--- + +Replaced the `ws` WebSocket library with a spec-compliant WebSocket implementation backed by `undici`. **`undici@^7` is now a peer dependency** that must be installed alongside `@slack/socket-mode`: + +```bash +npm install @slack/socket-mode undici@^7 +``` + +**Removed options:** +- **`clientOptions.agent`** (the `httpAgent` passed to the underlying web-api client). Use the new top-level `dispatcher` option instead. The dispatcher handles both the WebSocket connection and HTTP API calls (unless `clientOptions.fetch` is also provided, in which case `dispatcher` only applies to WebSocket). + +**New `dispatcher` option:** +```js +import { SocketModeClient } from '@slack/socket-mode'; +import { ProxyAgent } from 'undici'; + +const client = new SocketModeClient({ + appToken: process.env.SLACK_APP_TOKEN, + dispatcher: new ProxyAgent('http://proxy:3128'), +}); +``` + +For simple proxy use cases, prefer the Node.js built-in proxy support: call `http.setGlobalProxyFromEnv()` at startup or set `NODE_USE_ENV_PROXY=1` (Node.js 24+) with `HTTP_PROXY`/`HTTPS_PROXY` environment variables. + +The `dispatcher` option accepts any object implementing the `SocketModeDispatcher` interface (structurally compatible with undici's `Agent`, `ProxyAgent`, `Client`, etc.). + +This package now depends on `@slack/web-api@^8` — any `clientOptions` you pass are subject to web-api v8 breaking changes (e.g., the `agent` and `tls` options are no longer available; use `clientOptions.fetch` instead). diff --git a/.changeset/web-api-error-classes.md b/.changeset/web-api-error-classes.md new file mode 100644 index 000000000..9a7baed2c --- /dev/null +++ b/.changeset/web-api-error-classes.md @@ -0,0 +1,28 @@ +--- +"@slack/web-api": major +--- + +Redesigned error handling to use proper `Error` subclasses instead of plain objects with a `code` property. + +**Migration:** Replace `if (error.code === ErrorCode.PlatformError)` with `if (error instanceof WebAPIPlatformError)`. All error classes extend a common `SlackError` base class (which extends `Error`), so you can also catch all SDK errors with `if (error instanceof SlackError)`. + +**New error class hierarchy:** +- `SlackError` (abstract base) + - `WebAPIPlatformError` — Slack API returned `ok: false` + - `WebAPIRequestError` — Network/transport failure (original error in `cause`) + - `WebAPIHTTPError` — Non-200 HTTP status from Slack + - `WebAPIRateLimitedError` — HTTP 429 with `retryAfter` seconds + - `WebAPIFileUploadInvalidArgumentsError` — Invalid file upload arguments + - `WebAPIFileUploadReadFileDataError` — Failed to read file data for upload + +**Removed factory functions** (these were internal but exported — use `new` with the corresponding class instead): +- `errorWithCode()` +- `platformErrorFromResult()` → `new WebAPIPlatformError(...)` +- `requestErrorWithOriginal()` → `new WebAPIRequestError(...)` +- `httpErrorFromResponse()` → `new WebAPIHTTPError(...)` +- `rateLimitedErrorWithDelay()` → `new WebAPIRateLimitedError(...)` + +**Other breaking type changes:** +- `WebAPIHTTPError.headers` type changed from `IncomingHttpHeaders` to `Record`. +- The `CodedError` interface is deprecated — use `instanceof` checks with specific error classes instead. +- Error `.name` values changed from generic `'Error'` to descriptive class names (e.g., `'WebAPIPlatformError'`). diff --git a/.changeset/web-api-fetch-migration.md b/.changeset/web-api-fetch-migration.md new file mode 100644 index 000000000..b8a2d8732 --- /dev/null +++ b/.changeset/web-api-fetch-migration.md @@ -0,0 +1,22 @@ +--- +"@slack/web-api": major +--- + +Replaced `axios` with the standard Fetch API for all HTTP transport. The following options and types have been removed from `WebClientOptions`: + +- **`agent`** — Use the new `fetch` option to provide a custom fetch implementation with proxy or keep-alive support. For proxies, prefer the built-in `http.setGlobalProxyFromEnv()` or `NODE_USE_ENV_PROXY=1` (Node.js 24+). For advanced use cases: + ```ts + import { fetch, Agent } from 'undici'; + const client = new WebClient(token, { + fetch: (url, init) => fetch(url, { ...init, dispatcher: new Agent({ keepAliveTimeout: 60_000 }) }), + }); + ``` +- **`tls`** and **`TLSOptions`** — Configure TLS via a custom `fetch` implementation with an undici `Agent`, or use the `NODE_EXTRA_CA_CERTS` environment variable. +- **`requestInterceptor`** and **`RequestInterceptor`** type — Wrap the `fetch` function to intercept or modify requests before they are sent. +- **`adapter`** and **`AdapterConfig`** type — Use the `fetch` option instead. +- **`RequestConfig`** type (was an alias for Axios' `InternalAxiosRequestConfig`) — Removed entirely. +- **`attachOriginalToWebAPIRequestError`** option — Removed. The original error is now always available via the standard `cause` property on `WebAPIRequestError`. + +The dependencies `axios`, `form-data`, `is-electron`, and `is-stream` have been removed. The default `fetch` implementation is `globalThis.fetch` (available in Node.js 20+). + +New exported types for custom fetch implementations: `FetchFunction`, `FetchResponse`, `FetchRequestInit`, `FetchHeaders`. diff --git a/.changeset/web-api-remove-deprecated-methods.md b/.changeset/web-api-remove-deprecated-methods.md new file mode 100644 index 000000000..1f5b9ebe2 --- /dev/null +++ b/.changeset/web-api-remove-deprecated-methods.md @@ -0,0 +1,9 @@ +--- +"@slack/web-api": major +--- + +Removed previously-deprecated API methods and their associated request/response types: + +- **`files.upload`** — Use `filesUploadV2` instead (available since v6.7). The `filesUploadV2` method handles the multi-step upload process automatically. +- **`rtm.start`** — Use `rtm.connect` instead. The `rtm.start` method was deprecated by Slack in favor of the lighter-weight `rtm.connect`. +- **`workflows.stepCompleted`**, **`workflows.stepFailed`**, **`workflows.updateStep`** — These methods supported the retired [Steps from Apps](https://api.slack.com/changelog/2023-08-workflow-steps-from-apps-step-back) feature (deprecated August 2023, retired September 2024). The `workflows.featured.*` and `admin.workflows.*` methods for the current Workflow Builder remain available. diff --git a/.changeset/webhook-error-classes.md b/.changeset/webhook-error-classes.md new file mode 100644 index 000000000..994d32d46 --- /dev/null +++ b/.changeset/webhook-error-classes.md @@ -0,0 +1,24 @@ +--- +"@slack/webhook": major +--- + +Restructured error classes to use proper `Error` subclasses extending a new `SlackWebhookError` base class. + +**Breaking changes to `IncomingWebhookHTTPError`:** +- The `original` property has been removed. HTTP response details are now direct properties: + - `statusCode: number` + - `statusMessage: string` + - `body: string` +- Migrate from `error.original.response.status` to `error.statusCode`, `error.original.response.data` to `error.body`, etc. + +**Breaking changes to `IncomingWebhookRequestError`:** +- The `original` property is now a standard `Error` (previously it was an `AxiosError`). The original error is also available via the standard `cause` property. + +**Removed factory functions** (use `new` with the corresponding class instead): +- `requestErrorWithOriginal()` → `new IncomingWebhookRequestError(original)` +- `httpErrorWithOriginal()` → `new IncomingWebhookHTTPError(statusCode, statusMessage, body)` +- `errorWithCode()` — Use the specific error class directly. + +**Migration:** Replace `if (error.code === ErrorCode.HTTPError)` with `if (error instanceof IncomingWebhookHTTPError)`. You can also catch all webhook errors with `if (error instanceof SlackWebhookError)`. + +The `CodedError` interface is deprecated — use `instanceof` checks with the `SlackWebhookError` base class or specific error subclasses instead. diff --git a/.changeset/webhook-fetch-migration.md b/.changeset/webhook-fetch-migration.md new file mode 100644 index 000000000..c4e108c46 --- /dev/null +++ b/.changeset/webhook-fetch-migration.md @@ -0,0 +1,16 @@ +--- +"@slack/webhook": major +--- + +Replaced `axios` with the standard Fetch API for HTTP transport. + +**Removed options from `IncomingWebhookDefaultArguments`:** +- **`agent`** — Use the new `fetch` option to provide a custom fetch implementation with proxy or TLS support. For proxies, prefer the built-in `http.setGlobalProxyFromEnv()` or `NODE_USE_ENV_PROXY=1` (Node.js 24+). For advanced use cases: + ```ts + import { fetch, Agent } from 'undici'; + const webhook = new IncomingWebhook(url, { + fetch: (url, init) => fetch(url, { ...init, dispatcher: new Agent({ connect: { ca: myCA } }) }), + }); + ``` + +The `axios` dependency has been removed. The default fetch implementation is `globalThis.fetch` (available in Node.js 20+). The `timeout` option remains available and is implemented via `AbortController`. diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index cd14c80ec..3d14074f3 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -19,7 +19,6 @@ jobs: - "ubuntu-latest" - "windows-latest" node-version: - - "18.x" - "20.x" - "22.x" - "24.x" diff --git a/package-lock.json b/package-lock.json index 77a6fdc90..225c46a48 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": "*" @@ -6315,7 +6316,7 @@ }, "packages/cli-hooks": { "name": "@slack/cli-hooks", - "version": "1.3.2", + "version": "2.0.0-rc.1", "license": "MIT", "dependencies": { "minimist": "^1.2.8", @@ -6335,13 +6336,13 @@ "sinon": "^21.0.0" }, "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">=9.6.4" } }, "packages/cli-test": { "name": "@slack/cli-test", - "version": "3.0.2", + "version": "4.0.0-rc.1", "license": "MIT", "dependencies": { "tree-kill": "^1.2.2", @@ -6353,30 +6354,46 @@ "sinon": "^21.0.0" }, "engines": { - "node": ">=18.15.5" + "node": ">=20", + "npm": ">=9.6.4" } }, "packages/logger": { "name": "@slack/logger", - "version": "4.0.1", + "version": "5.0.0-rc.1", "license": "MIT", "dependencies": { - "@types/node": ">=18" + "@types/node": ">=20" }, "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">=9.6.4" } }, + "packages/logger/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/logger/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/oauth": { "name": "@slack/oauth", - "version": "3.0.5", + "version": "4.0.0-rc.1", "license": "MIT", "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", + "@slack/logger": "^5.0.0-rc.1", + "@slack/web-api": "^8.0.0-rc.1", "@types/jsonwebtoken": "^9", - "@types/node": ">=18", + "@types/node": ">=20", "jsonwebtoken": "^9" }, "devDependencies": { @@ -6385,17 +6402,32 @@ "sinon": "^21" }, "engines": { - "node": ">=18", - "npm": ">=8.6.0" + "node": ">=20", + "npm": ">=9.6.4" + } + }, + "packages/oauth/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/oauth/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/rtm-api": { "name": "@slack/rtm-api", "version": "7.0.4", "license": "MIT", "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", @@ -6413,6 +6445,52 @@ "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", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, "packages/rtm-api/node_modules/@types/sinon": { "version": "17.0.4", "dev": true, @@ -6421,56 +6499,99 @@ "@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", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, "packages/socket-mode": { "name": "@slack/socket-mode", - "version": "2.0.7", + "version": "3.0.0-rc.2", "license": "MIT", "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", - "@types/node": ">=18", - "@types/ws": "^8", - "eventemitter3": "^5", - "ws": "^8" + "@slack/logger": "^5.0.0-rc.1", + "@slack/web-api": "^8.0.0-rc.1", + "@types/node": ">=20", + "eventemitter3": "^5" }, "devDependencies": { "@types/proxyquire": "^1.3.31", "@types/sinon": "^21", "nodemon": "^3.1.0", "proxyquire": "^2.1.3", - "sinon": "^21" + "sinon": "^21", + "tsd": "^0.33.0", + "undici": "^7.25.0", + "ws": "^8" }, "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">=20", + "npm": ">=9.6.4" + }, + "peerDependencies": { + "undici": "^7.0.0" } }, + "packages/socket-mode/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/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==", + "dev": true, + "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", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, "packages/types": { "name": "@slack/types", - "version": "2.21.1", + "version": "3.0.0-rc.1", "license": "MIT", "devDependencies": { "tsd": "^0.33.0" }, "engines": { - "node": ">= 12.13.0", - "npm": ">= 6.12.0" + "node": ">= 20", + "npm": ">=9.6.4" } }, "packages/web-api": { "name": "@slack/web-api", - "version": "7.16.0", + "version": "8.0.0-rc.1", "license": "MIT", "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/types": "^2.21.0", - "@types/node": ">=18", + "@slack/logger": "^5.0.0-rc.1", + "@slack/types": "^3.0.0-rc.1", + "@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" @@ -6484,36 +6605,55 @@ "tsd": "^0.33.0" }, "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">=9.6.4" } }, - "packages/web-api/node_modules/is-stream": { - "version": "2.0.1", + "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", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "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", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, "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": ">=18", - "axios": "^1.16.0" + "@slack/types": "^3.0.0-rc.1", + "@types/node": ">=20" }, "devDependencies": { "nock": "^14.0.6" }, "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">=9.6.4" } + }, + "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" } } } diff --git a/packages/cli-hooks/README.md b/packages/cli-hooks/README.md index 5a6e38866..8a897d430 100644 --- a/packages/cli-hooks/README.md +++ b/packages/cli-hooks/README.md @@ -19,7 +19,7 @@ section. ## Requirements -This package supports Node v18 and higher. It's highly recommended to use [the +This package supports Node v20 and higher. It's highly recommended to use [the latest LTS version of Node][node]. An updated version of the Slack CLI is also encouraged while using this package. diff --git a/packages/cli-hooks/package.json b/packages/cli-hooks/package.json index de198fe84..45a48b1c2 100644 --- a/packages/cli-hooks/package.json +++ b/packages/cli-hooks/package.json @@ -1,6 +1,6 @@ { "name": "@slack/cli-hooks", - "version": "1.3.2", + "version": "2.0.0-rc.1", "description": "Node implementation of the contract between the Slack CLI and Bolt for JavaScript", "author": "Slack Technologies, LLC", "license": "MIT", @@ -20,8 +20,8 @@ "src/start.js" ], "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">=9.6.4" }, "repository": { "type": "git", diff --git a/packages/cli-test/package.json b/packages/cli-test/package.json index 25a96c02b..b84efa99c 100644 --- a/packages/cli-test/package.json +++ b/packages/cli-test/package.json @@ -1,6 +1,6 @@ { "name": "@slack/cli-test", - "version": "3.0.2", + "version": "4.0.0-rc.1", "description": "Node.js bindings for the Slack CLI for use in automated testing", "author": "Salesforce, Inc.", "license": "MIT", @@ -15,7 +15,8 @@ "dist/**/*" ], "engines": { - "node": ">=18.15.5" + "node": ">=20", + "npm": ">=9.6.4" }, "repository": { "type": "git", diff --git a/packages/logger/README.md b/packages/logger/README.md index 4529d9e88..1b99a4688 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -6,7 +6,7 @@ The `@slack/logger` package is intended to be used as a simple logging interface ## 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/logger/package.json b/packages/logger/package.json index de692522b..edabad1df 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-rc.1", "description": "Logging utility used by Node Slack SDK", "author": "Slack Technologies, LLC", "license": "MIT", @@ -14,8 +14,8 @@ "dist/**/*" ], "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">=9.6.4" }, "repository": { "type": "git", @@ -37,6 +37,6 @@ "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/index.test.ts" }, "dependencies": { - "@types/node": ">=18" + "@types/node": ">=20" } } diff --git a/packages/oauth/README.md b/packages/oauth/README.md index 0df249d0b..bfa17682f 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -364,7 +364,7 @@ const installer = new InstallProvider({ ### 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/oauth/package.json b/packages/oauth/package.json index b03ee1414..4fb40d961 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@slack/oauth", - "version": "3.0.5", + "version": "4.0.0-rc.1", "description": "Official library for interacting with Slack's Oauth endpoints", "author": "Slack Technologies, LLC", "license": "MIT", @@ -16,8 +16,8 @@ "dist/**/*" ], "engines": { - "node": ">=18", - "npm": ">=8.6.0" + "node": ">=20", + "npm": ">=9.6.4" }, "repository": { "type": "git", @@ -40,10 +40,10 @@ "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm run build" }, "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", + "@slack/logger": "^5.0.0-rc.1", + "@slack/web-api": "^8.0.0-rc.1", "@types/jsonwebtoken": "^9", - "@types/node": ">=18", + "@types/node": ">=20", "jsonwebtoken": "^9" }, "devDependencies": { 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/socket-mode/README.md b/packages/socket-mode/README.md index 542b1a92b..08224dc81 100644 --- a/packages/socket-mode/README.md +++ b/packages/socket-mode/README.md @@ -6,7 +6,7 @@ This package is designed to support [**Socket Mode**][socket-mode], which allows ## 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. ## Installation diff --git a/packages/socket-mode/examples/proxy.js b/packages/socket-mode/examples/proxy.js index e2886fb22..596181c2c 100644 --- a/packages/socket-mode/examples/proxy.js +++ b/packages/socket-mode/examples/proxy.js @@ -1,17 +1,18 @@ 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 f8d0235b7..db1dfeed8 100644 --- a/packages/socket-mode/package.json +++ b/packages/socket-mode/package.json @@ -1,6 +1,6 @@ { "name": "@slack/socket-mode", - "version": "2.0.7", + "version": "3.0.0-rc.2", "description": "Official library for using the Slack Platform's Socket Mode API", "author": "Slack Technologies, LLC", "license": "MIT", @@ -24,8 +24,8 @@ "dist/**/*" ], "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">=20", + "npm": ">=9.6.4" }, "repository": { "type": "git", @@ -44,24 +44,32 @@ "docs": "npx typedoc --plugin typedoc-plugin-markdown", "prepack": "npm run build", "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.test.js", - "test:integration": "npm run build && node --import tsx --test test/integration.test.js", + "test:coverage": "npm run build && bash -c '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/integrations/*.test.js'", + "test:integration": "npm run build && bash -c 'node --import tsx --test test/integrations/*.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" }, "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", - "@types/node": ">=18", - "@types/ws": "^8", - "eventemitter3": "^5", - "ws": "^8" + "@slack/logger": "^5.0.0-rc.1", + "@slack/web-api": "^8.0.0-rc.1", + "@types/node": ">=20", + "eventemitter3": "^5" + }, + "peerDependencies": { + "undici": "^7.0.0" }, "devDependencies": { "@types/proxyquire": "^1.3.31", "@types/sinon": "^21", "nodemon": "^3.1.0", "proxyquire": "^2.1.3", - "sinon": "^21" + "sinon": "^21", + "tsd": "^0.33.0", + "undici": "^7.25.0", + "ws": "^8" + }, + "tsd": { + "directory": "test/types" } } diff --git a/packages/socket-mode/src/SlackWebSocket.test.ts b/packages/socket-mode/src/SlackWebSocket.test.ts index 3b89477b8..5a3563bcb 100644 --- a/packages/socket-mode/src/SlackWebSocket.test.ts +++ b/packages/socket-mode/src/SlackWebSocket.test.ts @@ -4,17 +4,21 @@ 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(); 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 +26,12 @@ describe('SlackWebSocket', () => { let SlackWebSocket: typeof import('./SlackWebSocket').SlackWebSocket; beforeEach(() => { SlackWebSocket = proxyquire.load('./SlackWebSocket', { - ws: { + undici: { WebSocket: WSMock, + CloseEvent, + ErrorEvent, + MessageEvent, + ping: () => {}, }, }).SlackWebSocket; }); @@ -58,17 +66,19 @@ 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; } }, + CloseEvent, + ErrorEvent, + MessageEvent, + ping: () => {}, }, }).SlackWebSocket; const sws = new SlackWebSocket({ @@ -79,7 +89,7 @@ describe('SlackWebSocket', () => { }); 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..3fcb24a13 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -1,14 +1,32 @@ -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 { CloseEvent, type Dispatcher, ErrorEvent, MessageEvent, ping, WebSocket } from 'undici'; -import { websocketErrorWithOriginal } from './errors'; +import { SMWebsocketError } from './errors'; import log, { type Logger, LogLevel } from './logger'; +import type { SocketModeDispatcher } from './SocketModeOptions'; -// 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; +} + +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; @@ -20,8 +38,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?: SocketModeDispatcher; /** @description Whether this WebSocket should DEBUG log ping and pong events. `false` by default. */ pingPongLoggingEnabled?: boolean; /** @@ -30,7 +48,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; @@ -71,10 +89,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: ((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'); + public constructor({ url, client, - httpAgent, + dispatcher, logger, logLevel = LogLevel.INFO, pingInterval = 5000, @@ -85,7 +114,7 @@ export class SlackWebSocket { this.options = { url, client, - httpAgent, + dispatcher, logLevel, pingInterval, pingPongLoggingEnabled, @@ -106,47 +135,74 @@ 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 as 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.websocket.addEventListener('open', this.openHandler); + + this.errorHandler = (event: Event) => { + 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(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', new SMWebsocketError(event.error ?? new Error(event.message))); + }; + this.websocket.addEventListener('error', this.errorHandler); + + this.messageHandler = (event: Event) => { + 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) => { + 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(); - }); + }; + 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 = (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: ${data.toString()})`); + this.logger.debug(`WebSocket received ping from Slack server (data: ${message.payload?.toString()})`); } this.monitorPingFromSlack(); - }); + }; + SlackWebSocket.pingChannel.subscribe(this.pingHandler); - this.websocket.on('pong', (data) => { + 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: ${data.toString()})`); + this.logger.debug(`WebSocket received pong from Slack server (data: ${message.payload?.toString()})`); } this.lastPongReceivedTimestamp = Date.now(); - }); + }; + SlackWebSocket.pongChannel.subscribe(this.pongHandler); } /** @@ -158,12 +214,12 @@ 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. @@ -172,16 +228,28 @@ export class SlackWebSocket { } } 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 +260,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 +268,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 +283,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 +317,11 @@ export class SlackWebSocket { const now = Date.now(); try { const pingMessage = `Ping from client (${now})`; - this.websocket?.ping(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.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index da73676d0..368644556 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -1,10 +1,13 @@ 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 logModule from './logger'; import { SocketModeClient } from './SocketModeClient'; +import type { SocketModeDispatcher } from './SocketModeOptions'; describe('SocketModeClient', () => { const sandbox = sinon.createSandbox(); @@ -29,7 +32,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: 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: SocketModeDispatcher = { dispatch: () => true }; + const customFetch: FetchFunction = async () => new Response(); + 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 ac2f86659..9f07c58c4 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -1,20 +1,21 @@ import { - ErrorCode as APICallErrorCode, type AppsConnectionsOpenResponse, addAppMetadata, - type WebAPICallError, + WebAPIHTTPError, + WebAPIPlatformError, + WebAPIRequestError, WebClient, type WebClientOptions, } from '@slack/web-api'; import { EventEmitter } from 'eventemitter3'; -import type WebSocket from 'ws'; +import { type RequestInit, fetch as undiciFetch } from 'undici'; import packageJson from '../package.json'; -import { sendWhileDisconnectedError, sendWhileNotReadyError, websocketErrorWithOriginal } from './errors'; +import { SMSendWhileDisconnectedError, SMSendWhileNotReadyError, SMWebsocketError } 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 @@ -55,14 +56,19 @@ 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 WebSocket connections. Also wrapped into a custom fetch for HTTP calls + * unless `clientOptions.fetch` was provided by the user. */ + private dispatcher?: SocketModeDispatcher; + + /** The most recent response from `apps.connections.open`, used to establish the WebSocket URL. */ + private connectionResponse?: AppsConnectionsOpenResponse; + public websocket?: SlackWebSocket; /** @@ -93,6 +99,11 @@ export class SocketModeClient extends EventEmitter { */ private shuttingDown = false; + /** + * Timer handle for scheduling automatic reconnection attempts after a disconnect. + */ + private reconnectionTimer: ReturnType | undefined; + public constructor( { logger = undefined, @@ -103,6 +114,7 @@ export class SocketModeClient extends EventEmitter { serverPingTimeout = 30000, appToken = '', clientOptions = {}, + dispatcher = undefined, }: SocketModeOptions = { appToken: '' }, ) { super(); @@ -112,6 +124,7 @@ export class SocketModeClient extends EventEmitter { this.pingPongLoggingEnabled = pingPongLoggingEnabled; this.clientPingTimeoutMS = clientPingTimeout; this.serverPingTimeoutMS = serverPingTimeout; + this.dispatcher = dispatcher; // Setup the logger if (typeof logger !== 'undefined') { this.customLoggerProvided = true; @@ -123,6 +136,9 @@ export class SocketModeClient extends EventEmitter { this.logger = log.getLogger(SocketModeClient.loggerName, logLevel ?? LogLevel.INFO, logger); } this.webClientOptions = clientOptions; + if (dispatcher && this.webClientOptions.fetch === undefined) { + this.webClientOptions.fetch = (url, init) => undiciFetch(url, { ...init, dispatcher: dispatcher } as RequestInit); + } if (this.webClientOptions.retryConfig === undefined) { // For faster retries of apps.connections.open API calls for reconnecting this.webClientOptions.retryConfig = { retries: 100, factor: 1.3 }; @@ -131,7 +147,7 @@ export class SocketModeClient extends EventEmitter { logger, logLevel: this.logger.getLevel(), headers: { Authorization: `Bearer ${appToken}` }, - ...clientOptions, + ...this.webClientOptions, }); this.autoReconnectEnabled = autoReconnectEnabled; @@ -171,7 +187,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, @@ -204,6 +220,8 @@ export class SocketModeClient extends EventEmitter { */ public disconnect(): Promise { this.shuttingDown = true; + clearTimeout(this.reconnectionTimer); + this.reconnectionTimer = undefined; this.logger.debug('Manually disconnecting this Socket Mode client'); this.emit(State.Disconnecting); return new Promise((resolve, _reject) => { @@ -229,7 +247,8 @@ export class SocketModeClient extends EventEmitter { const msBeforeRetry = this.clientPingTimeoutMS * this.numOfConsecutiveReconnectionFailures; this.logger.debug(`Before trying to reconnect, this client will wait for ${msBeforeRetry} milliseconds`); return new Promise((res, _rej) => { - setTimeout(() => { + this.reconnectionTimer = setTimeout(() => { + this.reconnectionTimer = undefined; if (this.shuttingDown) { this.logger.debug('Client shutting down, will not attempt reconnect.'); } else { @@ -255,21 +274,21 @@ 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) { // TODO: Python catches rate limit errors when interacting with this API: https://github.com/slackapi/python-slack-sdk/blob/main/slack_sdk/socket_mode/client.py#L51 this.logger.error(`Failed to retrieve a new WSS URL (error: ${error})`); - const err = error as WebAPICallError; let isRecoverable = true; if ( - err.code === APICallErrorCode.PlatformError && - (Object.values(UnrecoverableSocketModeStartError) as string[]).includes(err.data.error) + error instanceof WebAPIPlatformError && + (Object.values(UnrecoverableSocketModeStartError) as string[]).includes(error.data.error) ) { isRecoverable = false; - } else if (err.code === APICallErrorCode.RequestError) { + } else if (error instanceof WebAPIRequestError) { isRecoverable = false; - } else if (err.code === APICallErrorCode.HTTPError) { + } else if (error instanceof WebAPIHTTPError) { isRecoverable = false; } if (this.autoReconnectEnabled && isRecoverable) { @@ -286,12 +305,12 @@ export class SocketModeClient extends EventEmitter { * - 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(); + 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}`); @@ -317,7 +336,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; } @@ -391,10 +410,10 @@ export class SocketModeClient extends EventEmitter { ); if (this.websocket === undefined) { this.logger.error('Failed to send a message as the client is not connected'); - reject(sendWhileDisconnectedError()); + reject(new SMSendWhileDisconnectedError()); } else if (!this.websocket.isActive()) { this.logger.error('Failed to send a message as the client has no active connection'); - reject(sendWhileNotReadyError()); + reject(new SMSendWhileNotReadyError()); } else { this.emit('outgoing_message', message); @@ -403,7 +422,7 @@ export class SocketModeClient extends EventEmitter { this.websocket.send(flatMessage, (error) => { if (error) { this.logger.error(`Failed to send a WebSocket message (error: ${error})`); - return reject(websocketErrorWithOriginal(error)); + return reject(new SMWebsocketError(error)); } return resolve(); }); diff --git a/packages/socket-mode/src/SocketModeOptions.ts b/packages/socket-mode/src/SocketModeOptions.ts index 7b2c553de..57da6f4db 100644 --- a/packages/socket-mode/src/SocketModeOptions.ts +++ b/packages/socket-mode/src/SocketModeOptions.ts @@ -1,6 +1,23 @@ import type { WebClientOptions } from '@slack/web-api'; 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 { + /** + * Dispatches an HTTP request through this dispatcher. + * @param options - The request options (method, path, headers, body, etc.) + * @param handler - The response handler that processes incoming data and events + */ + // 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. @@ -41,7 +58,22 @@ 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; + /** + * A {@link SocketModeDispatcher} 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 or custom TLS behavior. + * + * @example + * ```js + * // Using undici's ProxyAgent as the dispatcher + * import { ProxyAgent } from 'undici'; + * const dispatcher = new ProxyAgent('http://proxy:3128'); + * ``` + */ + dispatcher?: SocketModeDispatcher; } diff --git a/packages/socket-mode/src/errors.test.ts b/packages/socket-mode/src/errors.test.ts new file mode 100644 index 000000000..c93407171 --- /dev/null +++ b/packages/socket-mode/src/errors.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + ErrorCode, + SMNoReplyReceivedError, + SMPlatformError, + SMSendWhileDisconnectedError, + SMSendWhileNotReadyError, + SMWebsocketError, +} from './errors'; + +describe('error classes', () => { + describe('SMWebsocketError', () => { + it('should be an instance of Error and SMWebsocketError', () => { + const original = new Error('connection reset'); + const err = new SMWebsocketError(original); + assert.ok(err instanceof Error); + assert.ok(err instanceof SMWebsocketError); + assert.equal(err.code, ErrorCode.WebsocketError); + assert.equal(err.original, original); + assert.equal(err.cause, original); + assert.equal(err.message, 'connection reset'); + assert.equal(err.name, 'SMWebsocketError'); + }); + }); + + describe('SMPlatformError', () => { + it('should be an instance of Error and SMPlatformError', () => { + const event = { error: { msg: 'not_authed' } }; + const err = new SMPlatformError(event); + assert.ok(err instanceof Error); + assert.ok(err instanceof SMPlatformError); + assert.equal(err.code, ErrorCode.SendMessagePlatformError); + assert.equal(err.data, event); + assert.equal(err.message, 'An API error occurred: not_authed'); + assert.equal(err.name, 'SMPlatformError'); + }); + }); + + describe('SMNoReplyReceivedError', () => { + it('should be an instance of Error and SMNoReplyReceivedError', () => { + const err = new SMNoReplyReceivedError(); + assert.ok(err instanceof Error); + assert.ok(err instanceof SMNoReplyReceivedError); + assert.equal(err.code, ErrorCode.NoReplyReceivedError); + assert.equal(err.name, 'SMNoReplyReceivedError'); + assert.ok(err.message.includes('no server acknowledgement')); + }); + }); + + describe('SMSendWhileDisconnectedError', () => { + it('should be an instance of Error and SMSendWhileDisconnectedError', () => { + const err = new SMSendWhileDisconnectedError(); + assert.ok(err instanceof Error); + assert.ok(err instanceof SMSendWhileDisconnectedError); + assert.equal(err.code, ErrorCode.SendWhileDisconnectedError); + assert.equal(err.name, 'SMSendWhileDisconnectedError'); + assert.ok(err.message.includes('not connected')); + }); + }); + + describe('SMSendWhileNotReadyError', () => { + it('should be an instance of Error and SMSendWhileNotReadyError', () => { + const err = new SMSendWhileNotReadyError(); + assert.ok(err instanceof Error); + assert.ok(err instanceof SMSendWhileNotReadyError); + assert.equal(err.code, ErrorCode.SendWhileNotReadyError); + assert.equal(err.name, 'SMSendWhileNotReadyError'); + assert.ok(err.message.includes('not ready')); + }); + }); +}); diff --git a/packages/socket-mode/src/errors.ts b/packages/socket-mode/src/errors.ts index 6078e1a9f..d8a7d3d1b 100644 --- a/packages/socket-mode/src/errors.ts +++ b/packages/socket-mode/src/errors.ts @@ -1,5 +1,5 @@ /** - * All errors produced by this package adhere to this interface + * @deprecated Use `instanceof` checks with specific error classes (e.g. `SMWebsocketError`) instead. */ export interface CodedError extends Error { code: string; @@ -24,93 +24,61 @@ export type SMCallError = | SMSendWhileDisconnectedError | SMSendWhileNotReadyError; -export interface SMPlatformError extends CodedError { - code: ErrorCode.SendMessagePlatformError; +export class SMPlatformError extends Error { + readonly code = ErrorCode.SendMessagePlatformError; // biome-ignore lint/suspicious/noExplicitAny: errors can be anything - data: any; -} + readonly data: any; -export interface SMWebsocketError extends CodedError { - code: ErrorCode.WebsocketError; - original: Error; + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything + constructor(event: any & { error: { msg: string } }) { + super(`An API error occurred: ${event.error.msg}`); + this.name = 'SMPlatformError'; + Object.setPrototypeOf(this, new.target.prototype); + this.data = event; + } } -export interface SMNoReplyReceivedError extends CodedError { - code: ErrorCode.NoReplyReceivedError; -} +export class SMWebsocketError extends Error { + readonly code = ErrorCode.WebsocketError; + readonly original: Error; -export interface SMSendWhileDisconnectedError extends CodedError { - code: ErrorCode.SendWhileDisconnectedError; + constructor(original: Error) { + super(original.message, { cause: original }); + this.name = 'SMWebsocketError'; + Object.setPrototypeOf(this, new.target.prototype); + this.original = original; + } } -export interface SMSendWhileNotReadyError extends CodedError { - code: ErrorCode.SendWhileNotReadyError; -} +export class SMNoReplyReceivedError extends Error { + readonly code = ErrorCode.NoReplyReceivedError; -/** - * Factory for producing a {@link CodedError} from a generic error - */ -function errorWithCode(error: Error, code: ErrorCode): CodedError { - // NOTE: might be able to return something more specific than a CodedError with conditional typing - const codedError = error as Partial; - codedError.code = code; - return codedError as CodedError; -} - -/** - * A factory to create SMWebsocketError objects. - */ -export function websocketErrorWithOriginal(original: Error): SMWebsocketError { - const error = errorWithCode(new Error(original.message), ErrorCode.WebsocketError) as Partial; - error.original = original; - return error as SMWebsocketError; -} - -/** - * A factory to create SMPlatformError objects. - */ -export function platformErrorFromEvent( - // biome-ignore lint/suspicious/noExplicitAny: errors can be anything - event: any & { error: { msg: string } }, -): SMPlatformError { - const error = errorWithCode( - new Error(`An API error occurred: ${event.error.msg}`), - ErrorCode.SendMessagePlatformError, - ) as Partial; - error.data = event; - return error as SMPlatformError; -} - -// TODO: Is the below factory needed still? -/** - * A factory to create SMNoReplyReceivedError objects. - */ -export function noReplyReceivedError(): SMNoReplyReceivedError { - return errorWithCode( - new Error( + constructor() { + super( 'Message sent but no server acknowledgement was received. This may be caused by the client ' + 'changing connection state rather than any issue with the specific message. Check before resending.', - ), - ErrorCode.NoReplyReceivedError, - ) as SMNoReplyReceivedError; + ); + this.name = 'SMNoReplyReceivedError'; + Object.setPrototypeOf(this, new.target.prototype); + } } -/** - * A factory to create SMSendWhileDisconnectedError objects. - */ -export function sendWhileDisconnectedError(): SMSendWhileDisconnectedError { - return errorWithCode( - new Error('Failed to send a WebSocket message as the client is not connected'), - ErrorCode.NoReplyReceivedError, - ) as SMSendWhileDisconnectedError; +export class SMSendWhileDisconnectedError extends Error { + readonly code = ErrorCode.SendWhileDisconnectedError; + + constructor() { + super('Failed to send a WebSocket message as the client is not connected'); + this.name = 'SMSendWhileDisconnectedError'; + Object.setPrototypeOf(this, new.target.prototype); + } } -/** - * A factory to create SMSendWhileNotReadyError objects. - */ -export function sendWhileNotReadyError(): SMSendWhileNotReadyError { - return errorWithCode( - new Error('Failed to send a WebSocket message as the client is not ready'), - ErrorCode.NoReplyReceivedError, - ) as SMSendWhileNotReadyError; +export class SMSendWhileNotReadyError extends Error { + readonly code = ErrorCode.SendWhileNotReadyError; + + constructor() { + super('Failed to send a WebSocket message as the client is not ready'); + this.name = 'SMSendWhileNotReadyError'; + Object.setPrototypeOf(this, new.target.prototype); + } } 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/integration.test.js b/packages/socket-mode/test/integrations/integration.test.js similarity index 98% rename from packages/socket-mode/test/integration.test.js rename to packages/socket-mode/test/integrations/integration.test.js index 03eccbcef..6724da519 100644 --- a/packages/socket-mode/test/integration.test.js +++ b/packages/socket-mode/test/integrations/integration.test.js @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const { describe, it, beforeEach, afterEach } = require('node:test'); -const { SocketModeClient } = require('../src/SocketModeClient'); -const { LogLevel } = require('../src/logger'); +const { SocketModeClient } = require('../../src/SocketModeClient'); +const { LogLevel } = require('../../src/logger'); const { WebSocketServer } = require('ws'); const { createServer } = require('node:http'); const sinon = require('sinon'); @@ -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(); }); diff --git a/packages/socket-mode/test/integrations/pingPongIsolation.test.js b/packages/socket-mode/test/integrations/pingPongIsolation.test.js new file mode 100644 index 000000000..b8726f956 --- /dev/null +++ b/packages/socket-mode/test/integrations/pingPongIsolation.test.js @@ -0,0 +1,247 @@ +const assert = require('node:assert/strict'); +const { describe, it, afterEach } = require('node:test'); +const { SocketModeClient } = require('../../src/SocketModeClient'); +const { LogLevel } = require('../../src/logger'); +const { WebSocketServer } = require('ws'); +const { createServer } = require('node:http'); +const sinon = require('sinon'); + +describe('Multi-connection ping/pong isolation (diagnostics_channel)', { timeout: 10000 }, () => { + const HTTP_SERVER_PORT = 12349; + const WSS_PORT = 23460; + const HTTP_SERVER_PORT_B = 12351; + const WSS_PORT_B = 23462; + + let httpServer = null; + let wsServer = null; + + let httpServerB = null; + let wsServerB = null; + + function createClient(httpPort, options = {}) { + const client = new SocketModeClient({ + appToken: 'whatever', + logLevel: LogLevel.ERROR, + clientOptions: { slackApiUrl: `http://localhost:${httpPort}/` }, + clientPingTimeout: 100, + serverPingTimeout: 100, + pingPongLoggingEnabled: false, + ...options, + }); + return client; + } + + afterEach(async () => { + if (wsServer) wsServer.close(); + wsServer = null; + if (wsServerB) wsServerB.close(); + wsServerB = null; + if (httpServer) httpServer.close(); + httpServer = null; + if (httpServerB) httpServerB.close(); + httpServerB = null; + sinon.restore(); + }); + + it('pong timeout on one client does not affect the other', async () => { + // Server A: does NOT auto-respond with pong + httpServer = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, url: `ws://localhost:${WSS_PORT}/` })); + }); + httpServer.listen(HTTP_SERVER_PORT); + wsServer = new WebSocketServer({ port: WSS_PORT, autoPong: false }); + wsServer.on('connection', (ws) => { + ws.on('error', () => {}); + ws.send(JSON.stringify({ type: 'hello' })); + }); + + // Server B: auto-responds with pong (default) + httpServerB = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, url: `ws://localhost:${WSS_PORT_B}/` })); + }); + httpServerB.listen(HTTP_SERVER_PORT_B); + wsServerB = new WebSocketServer({ port: WSS_PORT_B }); + wsServerB.on('connection', (ws) => { + ws.on('error', () => {}); + ws.send(JSON.stringify({ type: 'hello' })); + }); + + const clientA = createClient(HTTP_SERVER_PORT, { autoReconnectEnabled: false }); + const clientB = createClient(HTTP_SERVER_PORT_B, { autoReconnectEnabled: false }); + + const clientADisconnected = sinon.spy(() => {}); + const clientBDisconnected = sinon.spy(() => {}); + clientA.on('disconnected', clientADisconnected); + clientB.on('disconnected', clientBDisconnected); + + await clientA.start(); + await clientB.start(); + + try { + await sleep(300); + assert.strictEqual(clientADisconnected.calledOnce, true, 'Client A should disconnect due to pong timeout'); + assert.strictEqual(clientBDisconnected.notCalled, true, 'Client B should remain connected'); + } finally { + await clientB.disconnect(); + } + }); + + it('server ping timeout on one client does not affect the other', async () => { + wsServer = new WebSocketServer({ port: WSS_PORT }); + httpServer = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, url: `ws://localhost:${WSS_PORT}/` })); + }); + httpServer.listen(HTTP_SERVER_PORT); + + let connectionCount = 0; + let pingIntervalB = null; + wsServer.on('connection', (ws) => { + connectionCount++; + ws.on('error', () => {}); + ws.send(JSON.stringify({ type: 'hello' })); + if (connectionCount === 1) { + // Client A: server sends one ping then stops + ws.ping(); + } else { + // Client B: server sends pings continuously + ws.ping(); + pingIntervalB = setInterval(() => ws.ping(), 50); + } + }); + + const clientA = createClient(HTTP_SERVER_PORT, { autoReconnectEnabled: false }); + const clientB = createClient(HTTP_SERVER_PORT, { autoReconnectEnabled: false }); + + let clientADisconnected = false; + let clientBDisconnected = false; + clientA.on('disconnected', () => { + clientADisconnected = true; + }); + clientB.on('disconnected', () => { + clientBDisconnected = true; + }); + + await clientA.start(); + await clientB.start(); + + try { + await sleep(300); + assert.strictEqual(clientADisconnected, true, 'Client A should disconnect due to server ping timeout'); + assert.strictEqual(clientBDisconnected, false, 'Client B should remain connected'); + } finally { + if (pingIntervalB) clearInterval(pingIntervalB); + await clientB.disconnect(); + } + }); + + it('reconnection of one client does not disrupt the other', async () => { + httpServer = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, url: `ws://localhost:${WSS_PORT}/` })); + }); + httpServer.listen(HTTP_SERVER_PORT); + wsServer = new WebSocketServer({ port: WSS_PORT }); + + let pingIntervalB = null; + let connectionCount = 0; + let exposedWebSocket = null; + wsServer.on('connection', (ws) => { + connectionCount++; + ws.on('error', () => {}); + ws.send(JSON.stringify({ type: 'hello' })); + if (connectionCount === 1) { + exposedWebSocket = ws; + } else if (connectionCount === 2) { + ws.ping(); + pingIntervalB = setInterval(() => ws.ping(), 50); + } else { + // Client A reconnecting + exposedWebSocket = ws; + } + }); + + const clientA = createClient(HTTP_SERVER_PORT, { autoReconnectEnabled: true }); + const clientB = createClient(HTTP_SERVER_PORT, { autoReconnectEnabled: true }); + + const clientBClosed = sinon.spy(() => {}); + clientB.on('close', clientBClosed); + + await clientA.start(); + await clientB.start(); + + try { + const reconnectedWaiter = new Promise((res) => clientA.on('connected', res)); + if (exposedWebSocket) exposedWebSocket.terminate(); + await reconnectedWaiter; + + assert.strictEqual(clientBClosed.notCalled, true, 'Client B should not have closed during Client A reconnection'); + + await sleep(200); + assert.strictEqual(clientBClosed.notCalled, true, 'Client B should remain connected after Client A reconnection'); + } finally { + if (pingIntervalB) clearInterval(pingIntervalB); + await clientA.disconnect(); + await clientB.disconnect(); + } + }); + + it('disconnected client handlers do not fire for other connections', async () => { + const loggerSpyA = sinon.stub(); + const noop = () => {}; + + httpServer = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, url: `ws://localhost:${WSS_PORT}/` })); + }); + httpServer.listen(HTTP_SERVER_PORT); + wsServer = new WebSocketServer({ port: WSS_PORT }); + + let connectionCount = 0; + let pingIntervalB = null; + wsServer.on('connection', (ws) => { + connectionCount++; + ws.on('error', () => {}); + ws.send(JSON.stringify({ type: 'hello' })); + if (connectionCount === 1) { + // do nothing on first connection + } else { + ws.ping(); + pingIntervalB = setInterval(() => ws.ping(), 50); + } + }); + + const clientA = createClient(HTTP_SERVER_PORT, { + autoReconnectEnabled: false, + pingPongLoggingEnabled: true, + logLevel: 'debug', + logger: { debug: loggerSpyA, info: noop, warn: noop, error: noop, getLevel: () => 'debug' }, + }); + const clientB = createClient(HTTP_SERVER_PORT, { autoReconnectEnabled: false }); + + await clientA.start(); + await clientB.start(); + + await clientA.disconnect(); + + loggerSpyA.resetHistory(); + + try { + await sleep(200); + + const pingPongLogs = loggerSpyA + .getCalls() + .filter((call) => call.args[0] && (call.args[0].includes('ping') || call.args[0].includes('pong'))); + assert.strictEqual(pingPongLogs.length, 0, 'Client A should not receive any ping/pong events after disconnect'); + } finally { + if (pingIntervalB) clearInterval(pingIntervalB); + await clientB.disconnect(); + } + }); +}); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} 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'); diff --git a/packages/types/README.md b/packages/types/README.md index c02d8c230..4a790af26 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -5,7 +5,7 @@ and constructs of all kinds. ## 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/types/package.json b/packages/types/package.json index 8d2c96148..216d211fc 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@slack/types", - "version": "2.21.1", + "version": "3.0.0-rc.1", "description": "Shared type definitions for the Node Slack SDK", "author": "Slack Technologies, LLC", "license": "MIT", @@ -16,8 +16,8 @@ "dist/**/*" ], "engines": { - "node": ">= 12.13.0", - "npm": ">= 6.12.0" + "node": ">= 20", + "npm": ">=9.6.4" }, "repository": { "type": "git", 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 b15f3ddf4..08fa5a161 100644 --- a/packages/web-api/package.json +++ b/packages/web-api/package.json @@ -1,6 +1,6 @@ { "name": "@slack/web-api", - "version": "7.16.0", + "version": "8.0.0-rc.1", "description": "Official library for using the Slack Platform's Web API", "author": "Slack Technologies, LLC", "license": "MIT", @@ -21,8 +21,8 @@ "dist/**/*" ], "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">=9.6.4" }, "repository": { "type": "git", @@ -48,15 +48,11 @@ "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm run build" }, "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/types": "^2.21.0", - "@types/node": ">=18", + "@slack/logger": "^5.0.0-rc.1", + "@slack/types": "^3.0.0-rc.1", + "@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" diff --git a/packages/web-api/src/WebClient.test.ts b/packages/web-api/src/WebClient.test.ts index 35925358d..cd4ce0259 100644 --- a/packages/web-api/src/WebClient.test.ts +++ b/packages/web-api/src/WebClient.test.ts @@ -3,27 +3,23 @@ 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 axios, { type InternalAxiosRequestConfig } from 'axios'; import nock, { type ReplyHeaders } from 'nock'; import sinon from 'sinon'; import { ErrorCode, - type WebAPIHTTPError, - type WebAPIPlatformError, - type WebAPIRateLimitedError, - type WebAPIRequestError, + SlackError, + WebAPIHTTPError, + WebAPIPlatformError, + WebAPIRateLimitedError, + WebAPIRequestError, } from './errors'; -import { - buildGeneralFilesUploadWarning, - buildInvalidFilesUploadParamError, - buildLegacyMethodWarning, -} from './file-upload'; +import { buildInvalidFilesUploadParamError } from './file-upload'; import { addAppMetadata } from './instrument'; import { type Logger, LogLevel } from './logger'; import { rapidRetryPolicy } from './retry-policies'; import { buildThreadTsWarningMessage, - type RequestConfig, + type FetchFunction, type WebAPICallResult, WebClient, WebClientEvent, @@ -120,16 +116,23 @@ 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 - // 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: FetchFunction = (_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 { @@ -139,6 +142,34 @@ describe('WebClient', () => { assert.ok(e instanceof Error); } }); + + it('should produce a WebAPIRequestError with original when timeout fires', async () => { + const slowFetch: FetchFunction = (_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) { + assert.ok(error instanceof WebAPIRequestError); + assert.ok(error instanceof SlackError); + assert.strictEqual(error.code, ErrorCode.RequestError); + assert.ok(error.original instanceof Error); + assert.strictEqual(error.cause, error.original); + } + }); }); describe('apiCall()', () => { @@ -272,7 +303,6 @@ describe('WebClient', () => { { method: 'chat.postEphemeral' }, { method: 'chat.postMessage' }, { method: 'chat.scheduleMessage' }, - { method: 'files.upload' }, ]; const threadPatterns = threadTsTestPatterns.reduce((acc, { method }) => { @@ -321,40 +351,15 @@ describe('WebClient', () => { }); } - it('warns when user is accessing the files.upload (legacy) method', async () => { - const client = new WebClient(token, { logLevel: LogLevel.INFO, logger }); - await client.apiCall('files.upload', {}); - - // both must be true to pass this test - let warnedAboutLegacyFilesUpload = false; - let infoAboutRecommendedFilesUploadV2 = false; - - // check the warn spy for whether it was called with the correct warning - for (const call of (logger.warn as sinon.SinonStub).getCalls()) { - if (call.args[0] === buildLegacyMethodWarning('files.upload')) { - warnedAboutLegacyFilesUpload = true; - } - } - // check the info spy for whether it was called with the correct warning - for (const call of (logger.info as sinon.SinonStub).getCalls()) { - if (call.args[0] === buildGeneralFilesUploadWarning()) { - infoAboutRecommendedFilesUploadV2 = true; - } - } - if (!warnedAboutLegacyFilesUpload || !infoAboutRecommendedFilesUploadV2) { - assert.fail('Should have logged a warning and info when files.upload is used'); - } - }); - it('warns when user is accessing a deprecated method', async () => { const client = new WebClient(token, { logLevel: LogLevel.INFO, logger }); - await client.apiCall('workflows.stepCompleted', {}); + await client.apiCall('oauth.access', {}); let warnedAboutDeprecatedMethod = false; for (const call of (logger.warn as sinon.SinonStub).getCalls()) { if ( call.args[0] === - 'workflows.stepCompleted is deprecated. Please check on https://docs.slack.dev/reference/methods for an alternative.' + 'oauth.access is deprecated. Please check on https://docs.slack.dev/reference/methods for an alternative.' ) { warnedAboutDeprecatedMethod = true; } @@ -424,11 +429,11 @@ describe('WebClient', () => { await client.apiCall('method'); assert.fail('expected thrown exception'); } catch (error) { - assert.ok(error instanceof Error); - const e = error as WebAPIPlatformError; - assert.strictEqual(e.code, ErrorCode.PlatformError); - assert.strictEqual(e.data.ok, false); - assert.strictEqual(e.data.error, 'bad error'); + assert.ok(error instanceof WebAPIPlatformError); + assert.ok(error instanceof SlackError); + assert.strictEqual(error.code, ErrorCode.PlatformError); + assert.strictEqual(error.data.ok, false); + assert.strictEqual(error.data.error, 'bad error'); scope.done(); } }); @@ -441,12 +446,12 @@ describe('WebClient', () => { 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, 500); - assert.ok(e.headers); - assert.deepStrictEqual(e.body, body); - assert.ok(error instanceof Error); + assert.ok(error instanceof WebAPIHTTPError); + assert.ok(error instanceof SlackError); + assert.strictEqual(error.code, ErrorCode.HTTPError); + assert.strictEqual(error.statusCode, 500); + assert.ok(error.headers); + assert.deepStrictEqual(error.body, body); scope.done(); } }); @@ -459,10 +464,11 @@ describe('WebClient', () => { await client.apiCall('method'); assert.fail('expected error to be thrown'); } catch (error) { - const e = error as WebAPIRequestError; - assert.strictEqual(e.code, ErrorCode.RequestError); - assert.ok(error instanceof Error); - assert.ok(e.original instanceof Error); + assert.ok(error instanceof WebAPIRequestError); + assert.ok(error instanceof SlackError); + assert.strictEqual(error.code, ErrorCode.RequestError); + assert.ok(error.original instanceof Error); + assert.strictEqual(error.cause, error.original); } }); @@ -474,10 +480,10 @@ describe('WebClient', () => { 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); + assert.ok(error instanceof WebAPIHTTPError); + assert.strictEqual(error.code, ErrorCode.HTTPError); + assert.strictEqual(error.statusCode, 502); + assert.strictEqual(error.body, htmlBody); } finally { scope.done(); } @@ -664,6 +670,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 }); @@ -941,8 +973,13 @@ describe('WebClient', () => { // verify that any requests after maxRequestConcurrency were delayed by the responseDelay const queuedResponses = responses.slice(100); - const minDiff = concurrentResponses[concurrentResponses.length - 1].diff + responseDelay; - for (const r of queuedResponses) assert.ok(r.diff >= minDiff); + const maxConcurrentDiff = Math.max(...concurrentResponses.map((r) => r.diff)); + for (const r of queuedResponses) { + // Queued request must have been dispatched AFTER all concurrent requests + assert.ok(r.diff > maxConcurrentDiff); + // Queued request must have waited at least one full responseDelay cycle + assert.ok(r.diff >= responseDelay); + } }); it('should allow concurrency to be set', async () => { @@ -958,9 +995,10 @@ describe('WebClient', () => { // verify that any requests after maxRequestConcurrency were delayed by the responseDelay const queuedResponses = responses.slice(1); // the second response - const minDiff = concurrentResponses[concurrentResponses.length - 1].diff + responseDelay; + const maxConcurrentDiff = Math.max(...concurrentResponses.map((r) => r.diff)); for (const r of queuedResponses) { - assert.ok(r.diff >= minDiff); + assert.ok(r.diff > maxConcurrentDiff); + assert.ok(r.diff >= responseDelay); } }); }); @@ -1001,10 +1039,12 @@ describe('WebClient', () => { await client.apiCall('method'); assert.fail('expected error to be thrown'); } catch (error) { - const e = error as WebAPIRateLimitedError; - assert.strictEqual(e.code, ErrorCode.RateLimitedError); - assert.strictEqual(e.retryAfter, retryAfter); - assert.ok(error instanceof Error); + assert.ok(error instanceof WebAPIRateLimitedError); + assert.ok(error instanceof SlackError); + assert.strictEqual(error.code, ErrorCode.RateLimitedError); + assert.strictEqual(error.retryAfter, retryAfter); + assert.strictEqual(error.name, 'WebAPIRateLimitedError'); + assert.ok(error.message.includes(String(retryAfter)), 'message includes retry-after seconds'); scope.done(); } }); @@ -1089,116 +1129,19 @@ 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: FetchFunction = 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); - - await client.apiCall('method'); - }); - - 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'); - }); - }); - - 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 }; - }); - - 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); - }, - }); - + const client = new WebClient(token, { fetch: customFetch, retryConfig: { retries: 0 } }); await client.apiCall('method'); - - assert.strictEqual(customLoggingSpy.calledOnce, true); - assert.strictEqual(customClientRequestSpy.calledOnce, true); + assert.ok(fetchCalled); }); }); @@ -2001,49 +1944,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 () => { - const client = new WebClient(token, { - attachOriginalToWebAPIRequestError: true, - retryConfig: { retries: 0 }, - }); - - try { - await client.apiCall('conversations/list'); - } 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 () => { + 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: false, retryConfig: { retries: 0 }, }); try { await client.apiCall('conversations/list'); + assert.fail('Should have thrown'); } catch (error) { - assert.ok(!Object.hasOwn(error, 'original')); + assert.ok(error instanceof WebAPIRequestError); + assert.strictEqual(error.code, ErrorCode.RequestError); + assert.ok(error.original instanceof Error); + assert.strictEqual(error.cause, error.original); scope.done(); } }); diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index 3a5185a33..674582c06 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -1,35 +1,13 @@ -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'; -import { - httpErrorFromResponse, - platformErrorFromResult, - rateLimitedErrorWithDelay, - requestErrorWithOriginal, -} from './errors'; -import { - getAllFileUploadsToComplete, - getFileUploadJob, - getMultipleFileUploadJobs, - warnIfNotUsingFilesUploadV2, -} from './file-upload'; +import { SlackError, WebAPIHTTPError, WebAPIPlatformError, WebAPIRateLimitedError, WebAPIRequestError } from './errors'; +import { getAllFileUploadsToComplete, getFileUploadJob, getMultipleFileUploadJobs } from './file-upload'; import delay from './helpers'; import { getUserAgent } from './instrument'; import { getLogger, type Logger, LogLevel } from './logger'; @@ -54,20 +32,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 +52,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; @@ -99,35 +66,11 @@ 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; - /** - * 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', @@ -135,7 +78,6 @@ export enum WebClientEvent { export interface WebAPICallResult { ok: boolean; - error?: string; response_metadata?: { warnings?: string[]; next_cursor?: string; // is this too specific to be encoded into this type? @@ -163,23 +105,31 @@ 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; +export interface FetchHeaders { + get(name: string): string | null; + entries(): Iterable<[string, string]>; +} -/** - * 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 interface FetchResponse { + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly url: string; + readonly headers: FetchHeaders; + arrayBuffer(): Promise; + json(): Promise; + text(): Promise; +} -/** - * 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 FetchRequestInit { + method?: string; + headers?: Record; + body?: string | FormData; + redirect?: 'error' | 'follow' | 'manual'; + signal?: AbortSignal; +} + +export type FetchFunction = (url: string | URL, init?: FetchRequestInit) => Promise; /** * A client for Slack's Web API @@ -210,14 +160,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 axios: AxiosInstance; + private fetchFn: FetchFunction; /** - * Configuration for custom TLS handling + * Request timeout in milliseconds */ - private tlsConfig: TLSOptions; + private timeout: number; + + /** + * Default headers sent with every request + */ + private defaultHeaders: Record; /** * Preference for immediately rejecting API calls which result in a rate-limited response @@ -239,27 +194,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 +208,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 +226,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 +242,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(), Accept: 'application/json', ...headers }; this.logger.debug('initialized'); } @@ -341,7 +252,7 @@ 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 options - arguments for the Web API method */ public async apiCall(method: string, options: Record = {}): Promise { this.logger.debug(`apiCall('${method}') start`); @@ -354,7 +265,6 @@ export class WebClient extends Methods { 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(options as FilesUploadV2Arguments); @@ -401,11 +311,11 @@ 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') { - throw platformErrorFromResult(result as WebAPICallResult & { error: string }); + if (!result.ok && response.headers.get('content-type') !== 'application/gzip') { + throw new WebAPIPlatformError(result as WebAPICallResult & { error: string }); } if ('ok' in result && result.ok === false) { - throw platformErrorFromResult(result as WebAPICallResult & { error: string }); + throw new WebAPIPlatformError(result as WebAPICallResult & { error: string }); } this.logger.debug(`apiCall('${method}') end`); return result; @@ -644,7 +554,8 @@ export class WebClient extends Methods { 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}`)); @@ -678,48 +589,34 @@ export class WebClient extends Methods { url: string, body: Record, headers: Record = {}, - ): Promise { - // TODO: better input types - remove any + ): 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 + // 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; + } - 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); + 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 controller = new AbortController(); + const timer = this.timeout > 0 ? setTimeout(() => controller.abort(), this.timeout) : undefined; + const signal = timer ? controller.signal : undefined; + + try { + const response = await this.fetchFn(url, { + method: 'POST', + headers: allHeaders, + body: serializedBody, + redirect: 'error', + ...(signal ? { signal } : {}), + }); this.logger.debug('http response received'); if (response.status === 429) { @@ -727,16 +624,16 @@ export class WebClient extends Methods { if (retrySec !== undefined) { this.emit(WebClientEvent.RATE_LIMITED, retrySec, { url, body }); if (this.rejectRateLimitedCalls) { - throw new AbortError(rateLimitedErrorWithDelay(retrySec)); + throw new AbortError(new WebAPIRateLimitedError(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 + // 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 the staying paused for the entire duration specified in 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 @@ -747,30 +644,38 @@ export class WebClient extends Methods { // 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 new WebAPIHTTPError( + 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; } - throw error; + if (error instanceof SlackError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + this.logger.warn('http request failed', message); + throw new WebAPIRequestError(error instanceof Error ? error : new Error(String(error))); + } 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 +687,19 @@ 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. + * 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 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,7 +709,7 @@ 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 @@ -820,47 +723,30 @@ export class WebClient extends Methods { // 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)) { + const streamOrBuffer = value as Buffer & { name?: string; path?: string }; + 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'; + // Otherwise, serialize as url-encoded key-value pairs // 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 +754,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 +765,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: FetchResponse): Promise { + const contentType = response.headers.get('content-type'); + const isGzipResponse = contentType === 'application/gzip'; - // Check for GZIP response - if so, it is a successful response from admin.analytics.getFile + // biome-ignore lint/suspicious/noExplicitAny: HTTP response data can be anything + let data: any; + + // admin.analytics.getFile returns a gzip binary response that can be unzipped 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 +798,18 @@ 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 }; + // failed to parse the response body as JSON + data = { ok: false, error: text }; } } @@ -932,13 +818,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 +866,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: FetchResponse): number | undefined { + const retryAfterHeader = response.headers.get('retry-after'); + if (retryAfterHeader !== null) { + const retryAfter = Number.parseInt(retryAfterHeader, 10); if (!Number.isNaN(retryAfter)) { return retryAfter; @@ -997,7 +884,7 @@ function parseRetryHeaders(response: AxiosResponse): number | undefined { * @param logger instance of web clients logger */ function warnDeprecations(method: string, logger: Logger): void { - const deprecatedMethods = ['workflows.stepCompleted', 'workflows.stepFailed', 'workflows.updateStep']; + const deprecatedMethods = ['oauth.access']; const isDeprecated = deprecatedMethods.some((depMethod) => { const re = new RegExp(`^${depMethod}`); @@ -1055,7 +942,7 @@ function warnIfFallbackIsMissing(method: string, logger: Logger, options?: Recor * @param options arguments for the Web API method */ function warnIfThreadTsIsNotString(method: string, logger: Logger, options?: Record): void { - const targetMethods = ['chat.postEphemeral', 'chat.postMessage', 'chat.scheduleMessage', 'files.upload']; + const targetMethods = ['chat.postEphemeral', 'chat.postMessage', 'chat.scheduleMessage']; const isTargetMethod = targetMethods.includes(method); if (isTargetMethod && options?.thread_ts !== undefined && typeof options?.thread_ts !== 'string') { @@ -1087,8 +974,8 @@ function redact(body: Record): Record { serializedValue = '[[REDACTED]]'; } - // when value is buffer or stream we can avoid logging it - if (Buffer.isBuffer(value) || isStream(value)) { + // when value is buffer we can avoid logging it + 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/errors.ts b/packages/web-api/src/errors.ts index c6698acf6..be7a3ec4b 100644 --- a/packages/web-api/src/errors.ts +++ b/packages/web-api/src/errors.ts @@ -1,11 +1,7 @@ -import type { IncomingHttpHeaders } from 'node:http'; - -import type { AxiosResponse } from 'axios'; - import type { WebAPICallResult } from './WebClient'; /** - * All errors produced by this package adhere to this interface + * @deprecated Use `instanceof` checks with specific error classes (e.g. `WebAPIPlatformError`) or the `SlackError` base class instead. */ export interface CodedError extends NodeJS.ErrnoException { code: ErrorCode; @@ -27,111 +23,83 @@ export enum ErrorCode { } export type WebAPICallError = WebAPIPlatformError | WebAPIRequestError | WebAPIHTTPError | WebAPIRateLimitedError; -export type WebAPIFilesUploadError = WebAPIFileUploadInvalidArgumentsError; +export type WebAPIFilesUploadError = WebAPIFileUploadInvalidArgumentsError | WebAPIFileUploadReadFileDataError; -export interface WebAPIFileUploadInvalidArgumentsError extends CodedError { - code: ErrorCode.FileUploadInvalidArgumentsError; - data: WebAPICallResult & { - error: string; - }; -} +export abstract class SlackError extends Error { + abstract readonly code: ErrorCode; -export interface WebAPIPlatformError extends CodedError { - code: ErrorCode.PlatformError; - data: WebAPICallResult & { - error: string; - }; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = this.constructor.name; + Object.setPrototypeOf(this, new.target.prototype); + } } -export interface WebAPIRequestError extends CodedError { - code: ErrorCode.RequestError; - original: Error; -} +export class WebAPIPlatformError extends SlackError { + readonly code = ErrorCode.PlatformError; + readonly data: WebAPICallResult & { error: string }; -export interface WebAPIHTTPError extends CodedError { - code: ErrorCode.HTTPError; - statusCode: number; - statusMessage: string; - headers: IncomingHttpHeaders; - // biome-ignore lint/suspicious/noExplicitAny: HTTP response bodies might be anything - body?: any; + constructor(result: WebAPICallResult & { error: string }) { + super(`An API error occurred: ${result.error}`); + this.data = result; + } } -export interface WebAPIRateLimitedError extends CodedError { - code: ErrorCode.RateLimitedError; - retryAfter: number; -} +export class WebAPIRequestError extends SlackError { + readonly code = ErrorCode.RequestError; + readonly original: Error; -/** - * Factory for producing a {@link CodedError} from a generic error - */ -export function errorWithCode(error: Error, code: ErrorCode): CodedError { - // NOTE: might be able to return something more specific than a CodedError with conditional typing - const codedError = error as Partial; - codedError.code = code; - return codedError as CodedError; + constructor(original: Error) { + super(`A request error occurred: ${original.message}`, { cause: original }); + this.original = original; + } } -/** - * 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 { - const error = errorWithCode( - new Error(`A request error occurred: ${original.message}`), - ErrorCode.RequestError, - ) as Partial; - if (attachOriginal) { - error.original = original; +export class WebAPIHTTPError extends SlackError { + readonly code = ErrorCode.HTTPError; + readonly statusCode: number; + readonly statusMessage: string; + readonly headers: Record; + // biome-ignore lint/suspicious/noExplicitAny: HTTP response bodies might be anything + readonly body?: any; + + constructor( + statusCode: number, + statusMessage: string, + headers: Record, + // biome-ignore lint/suspicious/noExplicitAny: HTTP response bodies might be anything + body?: any, + ) { + super(`An HTTP protocol error occurred: statusCode = ${statusCode}`); + this.statusCode = statusCode; + this.statusMessage = statusMessage; + this.headers = headers; + if (typeof body === 'string') { + try { + this.body = JSON.parse(body); + } catch { + this.body = body; + } + } else { + this.body = body; + } } - return error as WebAPIRequestError; } -/** - * A factory to create WebAPIHTTPError objects - * @param response - original error - */ -export function httpErrorFromResponse(response: AxiosResponse): WebAPIHTTPError { - const error = errorWithCode( - new Error(`An HTTP protocol error occurred: statusCode = ${response.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]; - } +export class WebAPIRateLimitedError extends SlackError { + readonly code = ErrorCode.RateLimitedError; + readonly retryAfter: number; + + constructor(retryAfter: number) { + super(`A rate-limit has been reached, you may retry this request in ${retryAfter} seconds`); + this.retryAfter = retryAfter; } - error.headers = nonNullHeaders; - error.body = response.data; - return error as WebAPIHTTPError; } -/** - * A factory to create WebAPIPlatformError objects - * @param result - Web API call result - */ -export function platformErrorFromResult(result: WebAPICallResult & { error: string }): WebAPIPlatformError { - const error = errorWithCode( - new Error(`An API error occurred: ${result.error}`), - ErrorCode.PlatformError, - ) as Partial; - error.data = result; - return error as WebAPIPlatformError; +export class WebAPIFileUploadInvalidArgumentsError extends SlackError { + readonly code = ErrorCode.FileUploadInvalidArgumentsError; } -/** - * A factory to create WebAPIRateLimitedError objects - * @param retrySec - Number of seconds that the request can be retried in - */ -export function rateLimitedErrorWithDelay(retrySec: number): WebAPIRateLimitedError { - const error = errorWithCode( - new Error(`A rate-limit has been reached, you may retry this request in ${retrySec} seconds`), - ErrorCode.RateLimitedError, - ) as Partial; - error.retryAfter = retrySec; - return error as WebAPIRateLimitedError; +export class WebAPIFileUploadReadFileDataError extends SlackError { + readonly code = ErrorCode.FileUploadReadFileDataError; } diff --git a/packages/web-api/src/file-upload.test.ts b/packages/web-api/src/file-upload.test.ts index e9902e333..bd38a440e 100644 --- a/packages/web-api/src/file-upload.test.ts +++ b/packages/web-api/src/file-upload.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { createReadStream, statSync, unlinkSync, writeFileSync } from 'node:fs'; import { afterEach, beforeEach, describe, it } from 'node:test'; import sinon from 'sinon'; -import { ErrorCode, type WebAPIFileUploadInvalidArgumentsError } from './errors'; +import { ErrorCode, SlackError, WebAPIFileUploadInvalidArgumentsError } from './errors'; import { buildChannelsWarning, buildLegacyFileTypeWarning, @@ -157,12 +157,13 @@ describe('file-upload', () => { // if we get here this test is failed assert.fail(res.toString()); } catch (err) { - const e = err as WebAPIFileUploadInvalidArgumentsError; + assert.ok(err instanceof WebAPIFileUploadInvalidArgumentsError); + assert.ok(err instanceof SlackError); assert.strictEqual( - e.message, + err.message, 'Either a file or content field is required for valid file upload. You cannot supply both', ); - assert.strictEqual(e.code, ErrorCode.FileUploadInvalidArgumentsError); + assert.strictEqual(err.code, ErrorCode.FileUploadInvalidArgumentsError); } }); it('handles invalid input for file or content or when both supplied', async () => { @@ -177,12 +178,12 @@ describe('file-upload', () => { const res = await getFileData(invalidFileUpload); assert.fail(res.toString()); } catch (err) { - const e = err as WebAPIFileUploadInvalidArgumentsError; + assert.ok(err instanceof WebAPIFileUploadInvalidArgumentsError); assert.strictEqual( - e.message, + err.message, 'Either a file or content field is required for valid file upload. You cannot supply both', ); - assert.strictEqual(e.code, ErrorCode.FileUploadInvalidArgumentsError); + assert.strictEqual(err.code, ErrorCode.FileUploadInvalidArgumentsError); } // file supplied invalid type of valid @@ -195,9 +196,9 @@ describe('file-upload', () => { const res = await getFileData(invalidFileUpload2); assert.fail(res.toString()); } catch (err) { - const e = err as WebAPIFileUploadInvalidArgumentsError; - assert.strictEqual(e.message, 'file must be a valid string path, buffer or Readable'); - assert.strictEqual(e.code, ErrorCode.FileUploadInvalidArgumentsError); + assert.ok(err instanceof WebAPIFileUploadInvalidArgumentsError); + assert.strictEqual(err.message, 'file must be a valid string path, buffer or Readable'); + assert.strictEqual(err.code, ErrorCode.FileUploadInvalidArgumentsError); } // content supplied invalid type of field @@ -210,9 +211,9 @@ describe('file-upload', () => { const res = await getFileData(invalidFileUpload3); assert.fail(res.toString()); } catch (err) { - const e = err as WebAPIFileUploadInvalidArgumentsError; - assert.strictEqual(e.message, 'content must be a string'); - assert.strictEqual(e.code, ErrorCode.FileUploadInvalidArgumentsError); + assert.ok(err instanceof WebAPIFileUploadInvalidArgumentsError); + assert.strictEqual(err.message, 'content must be a string'); + assert.strictEqual(err.code, ErrorCode.FileUploadInvalidArgumentsError); } }); it('handles file as buffer', async () => { @@ -244,12 +245,12 @@ describe('file-upload', () => { const res = await getFileData(fileUpload); assert.fail(res.toString()); } catch (err) { - const e = err as WebAPIFileUploadInvalidArgumentsError; + assert.ok(err instanceof WebAPIFileUploadInvalidArgumentsError); assert.strictEqual( - e.message, + err.message, `Unable to resolve file data for ${fileUpload.file}. Please supply a filepath string, or binary data Buffer or String directly.`, ); - assert.strictEqual(e.code, ErrorCode.FileUploadInvalidArgumentsError); + assert.strictEqual(err.code, ErrorCode.FileUploadInvalidArgumentsError); } }); it('handles file as ReadStream', async () => { diff --git a/packages/web-api/src/file-upload.ts b/packages/web-api/src/file-upload.ts index b6beade2d..ca6b707e1 100644 --- a/packages/web-api/src/file-upload.ts +++ b/packages/web-api/src/file-upload.ts @@ -3,7 +3,7 @@ import { Readable } from 'node:stream'; import type { Logger } from '@slack/logger'; -import { ErrorCode, errorWithCode } from './errors'; +import { WebAPIFileUploadInvalidArgumentsError, WebAPIFileUploadReadFileDataError } from './errors'; import type { FilesCompleteUploadExternalArguments, FilesUploadV2Arguments, @@ -56,9 +56,8 @@ export async function getFileUploadJob( ...fileUploadJob, }; } - throw errorWithCode( - new Error('Either a file or content field is required for valid file upload. You must supply one'), - ErrorCode.FileUploadInvalidArgumentsError, + throw new WebAPIFileUploadInvalidArgumentsError( + 'Either a file or content field is required for valid file upload. You must supply one', ); } @@ -100,10 +99,7 @@ export async function getMultipleFileUploadJobs( // inside file_uploads. const { blocks, channel_id, channels, initial_comment, thread_ts } = upload as FileUploadV2; if (blocks || channel_id || channels || initial_comment || thread_ts) { - throw errorWithCode( - new Error(buildInvalidFilesUploadParamError()), - ErrorCode.FileUploadInvalidArgumentsError, - ); + throw new WebAPIFileUploadInvalidArgumentsError(buildInvalidFilesUploadParamError()); } // takes any channel_id, initial_comment and thread_ts // supplied at the top level. @@ -138,9 +134,8 @@ export async function getMultipleFileUploadJobs( logger, ); } - throw errorWithCode( - new Error('Either a file or content field is required for valid file upload. You must supply one'), - ErrorCode.FileUploadInvalidArgumentsError, + throw new WebAPIFileUploadInvalidArgumentsError( + 'Either a file or content field is required for valid file upload. You must supply one', ); }), ); @@ -170,11 +165,8 @@ export async function getFileData(options: FilesUploadV2Arguments | FileUploadV2 const dataBuffer = readFileSync(file); return dataBuffer; } catch (_err) { - throw errorWithCode( - new Error( - `Unable to resolve file data for ${file}. Please supply a filepath string, or binary data Buffer or String directly.`, - ), - ErrorCode.FileUploadInvalidArgumentsError, + throw new WebAPIFileUploadInvalidArgumentsError( + `Unable to resolve file data for ${file}. Please supply a filepath string, or binary data Buffer or String directly.`, ); } } @@ -186,9 +178,8 @@ export async function getFileData(options: FilesUploadV2Arguments | FileUploadV2 if ('content' in options) return Buffer.from(options.content); // general catch-all error - throw errorWithCode( - new Error('There was an issue getting the file data for the file or content supplied'), - ErrorCode.FileUploadReadFileDataError, + throw new WebAPIFileUploadReadFileDataError( + 'There was an issue getting the file data for the file or content supplied', ); } @@ -196,7 +187,7 @@ export function getFileDataLength(data: Buffer): number { if (data) { return Buffer.byteLength(data, 'utf8'); } - throw errorWithCode(new Error(buildFileSizeErrorMsg()), ErrorCode.FileUploadReadFileDataError); + throw new WebAPIFileUploadReadFileDataError(buildFileSizeErrorMsg()); } export async function getFileDataAsStream(readable: Readable): Promise { @@ -274,19 +265,6 @@ export function getAllFileUploadsToComplete( } // Validation -/** - * Advise to use the files.uploadV2 method over legacy files.upload method and over - * lower-level utilities. - * @param method - * @param logger - */ -export function warnIfNotUsingFilesUploadV2(method: string, logger: Logger): void { - const targetMethods = ['files.upload']; - const isTargetMethod = targetMethods.includes(method); - if (method === 'files.upload') logger.warn(buildLegacyMethodWarning(method)); - if (isTargetMethod) logger.info(buildGeneralFilesUploadWarning()); -} - /** * `channels` param is supported but only when a single channel is specified. * @param options @@ -306,7 +284,7 @@ export function warnIfChannels(options: FilesUploadV2Arguments | FileUploadV2, l export function errorIfChannelsCsv(options: FilesUploadV2Arguments | FileUploadV2): void { const channels = options.channels ? options.channels.split(',') : []; if (channels.length > 1) { - throw errorWithCode(new Error(buildMultipleChannelsErrorMsg()), ErrorCode.FileUploadInvalidArgumentsError); + throw new WebAPIFileUploadInvalidArgumentsError(buildMultipleChannelsErrorMsg()); } } @@ -319,22 +297,18 @@ export function errorIfInvalidOrMissingFileData(options: FilesUploadV2Arguments const hasContent = 'content' in options; if (!(hasFile || hasContent) || (hasFile && hasContent)) { - throw errorWithCode( - new Error('Either a file or content field is required for valid file upload. You cannot supply both'), - ErrorCode.FileUploadInvalidArgumentsError, + throw new WebAPIFileUploadInvalidArgumentsError( + 'Either a file or content field is required for valid file upload. You cannot supply both', ); } if ('file' in options) { const { file } = options; if (file && !(typeof file === 'string' || Buffer.isBuffer(file) || file instanceof Readable)) { - throw errorWithCode( - new Error('file must be a valid string path, buffer or Readable'), - ErrorCode.FileUploadInvalidArgumentsError, - ); + throw new WebAPIFileUploadInvalidArgumentsError('file must be a valid string path, buffer or Readable'); } } if ('content' in options && options.content && typeof options.content !== 'string') { - throw errorWithCode(new Error('content must be a string'), ErrorCode.FileUploadInvalidArgumentsError); + throw new WebAPIFileUploadInvalidArgumentsError('content must be a string'); } } @@ -403,17 +377,6 @@ export function buildMissingExtensionWarning(filename: string): string { return `filename supplied '${filename}' may be missing a proper extension. Missing extenions may result in unexpected unfurl behavior when shared`; } -export function buildLegacyMethodWarning(method: string): string { - return `${method} may cause some issues like timeouts for relatively large files.`; -} - -export function buildGeneralFilesUploadWarning(): string { - return ( - 'Our latest recommendation is to use client.files.uploadV2() method, ' + - 'which is mostly compatible and much stabler, instead.' - ); -} - export function buildFilesUploadMissingMessage(): string { return 'Something went wrong with processing file_uploads'; } diff --git a/packages/web-api/src/index.ts b/packages/web-api/src/index.ts index 534f0e34d..c1dbee85b 100644 --- a/packages/web-api/src/index.ts +++ b/packages/web-api/src/index.ts @@ -3,7 +3,11 @@ export { CodedError, ErrorCode, + SlackError, WebAPICallError, + WebAPIFileUploadInvalidArgumentsError, + WebAPIFileUploadReadFileDataError, + WebAPIFilesUploadError, WebAPIHTTPError, WebAPIPlatformError, WebAPIRateLimitedError, @@ -22,10 +26,10 @@ export * from './types/response/index'; export { ChatStreamer, ChatStreamerOptions } from './chat-stream'; export { + FetchFunction, PageAccumulator, PageReducer, PaginatePredicate, - TLSOptions, WebAPICallResult, WebClient, WebClientEvent, diff --git a/packages/web-api/src/methods.ts b/packages/web-api/src/methods.ts index d6e584d9e..462060320 100644 --- a/packages/web-api/src/methods.ts +++ b/packages/web-api/src/methods.ts @@ -193,7 +193,6 @@ import type { FilesRemoteUpdateArguments, FilesRevokePublicURLArguments, FilesSharedPublicURLArguments, - FilesUploadArguments, FilesUploadV2Arguments, FunctionsCompleteErrorArguments, FunctionsCompleteSuccessArguments, @@ -216,7 +215,6 @@ import type { RemindersInfoArguments, RemindersListArguments, RTMConnectArguments, - RTMStartArguments, SearchAllArguments, SearchFilesArguments, SearchMessagesArguments, @@ -271,9 +269,6 @@ import type { WorkflowsFeaturedListArguments, WorkflowsFeaturedRemoveArguments, WorkflowsFeaturedSetArguments, - WorkflowsStepCompletedArguments, - WorkflowsStepFailedArguments, - WorkflowsUpdateStepArguments, } from './types/request/index'; import type { AdminAnalyticsGetFileResponse, @@ -467,7 +462,6 @@ import type { FilesRemoteUpdateResponse, FilesRevokePublicURLResponse, FilesSharedPublicURLResponse, - FilesUploadResponse, FunctionsCompleteErrorResponse, FunctionsCompleteSuccessResponse, MigrationExchangeResponse, @@ -489,7 +483,6 @@ import type { RemindersInfoResponse, RemindersListResponse, RtmConnectResponse, - RtmStartResponse, SearchAllResponse, SearchFilesResponse, SearchMessagesResponse, @@ -545,9 +538,6 @@ import type { WorkflowsFeaturedListResponse, WorkflowsFeaturedRemoveResponse, WorkflowsFeaturedSetResponse, - WorkflowsStepCompletedResponse, - WorkflowsStepFailedResponse, - WorkflowsUpdateStepResponse, } from './types/response/index'; import { type WebAPICallResult, WebClient, type WebClientEvent } from './WebClient'; @@ -1981,12 +1971,6 @@ export abstract class Methods extends EventEmitter { this, 'files.sharedPublicURL', ), - /** - * @description Uploads or creates a file. - * @deprecated Use `uploadV2` instead. See {@link https://docs.slack.dev/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay our post on retiring `files.upload`}. - * @see {@link https://docs.slack.dev/reference/methods/files.upload `files.upload` API reference}. - */ - upload: bindApiCall(this, 'files.upload'), /** * @description Custom method to support a new way of uploading files to Slack. * Supports a single file upload @@ -2185,12 +2169,6 @@ export abstract class Methods extends EventEmitter { * @see {@link https://docs.slack.dev/reference/methods/rtm.connect `rtm.connect` API reference}. */ connect: bindApiCallWithOptionalArgument(this, 'rtm.connect'), - /** - * @description Starts a Real Time Messaging session. - * @deprecated Use `rtm.connect` instead. See {@link https://docs.slack.dev/changelog/2021-10-rtm-start-to-stop our post on retiring `rtm.start`}. - * @see {@link https://docs.slack.dev/reference/methods/rtm.start `rtm.start` API reference}. - */ - start: bindApiCallWithOptionalArgument(this, 'rtm.start'), }; public readonly search = { @@ -2520,7 +2498,6 @@ export abstract class Methods extends EventEmitter { // ------------------ // TODO: breaking changes for future majors: // - stars.* methods are marked as deprecated; once Later has APIs, these will see an official sunsetting timeline - // - workflows.* methods, Sep 12 2024: https://docs.slack.dev/changelog/2023-08-workflow-steps-from-apps-step-back public readonly stars = { /** @@ -2572,33 +2549,6 @@ export abstract class Methods extends EventEmitter { */ set: bindApiCall(this, 'workflows.featured.set'), }, - /** - * @description Indicate that an app's step in a workflow completed execution. - * @deprecated Steps from Apps is deprecated. - * We're retiring all Slack app functionality around Steps from Apps in September 2024. - * See {@link https://docs.slack.dev/changelog/2023-08-workflow-steps-from-apps-step-back our post on deprecating Steps from Apps}. - * @see {@link https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object `workflows.stepCompleted` API reference}. - */ - stepCompleted: bindApiCall( - this, - 'workflows.stepCompleted', - ), - /** - * @description Indicate that an app's step in a workflow failed to execute. - * @deprecated Steps from Apps is deprecated. - * We're retiring all Slack app functionality around Steps from Apps in September 2024. - * See {@link https://docs.slack.dev/changelog/2023-08-workflow-steps-from-apps-step-back our post on deprecating Steps from Apps}. - * @see {@link https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object `workflows.stepFailed` API reference}. - */ - stepFailed: bindApiCall(this, 'workflows.stepFailed'), - /** - * @description Update the configuration for a workflow step. - * @deprecated Steps from Apps is deprecated. - * We're retiring all Slack app functionality around Steps from Apps in September 2024. - * See {@link https://docs.slack.dev/changelog/2023-08-workflow-steps-from-apps-step-back our post on deprecating Steps from Apps}. - * @see {@link https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object `workflows.updateStep` API reference}. - */ - updateStep: bindApiCall(this, 'workflows.updateStep'), }; } diff --git a/packages/web-api/src/types/request/files.ts b/packages/web-api/src/types/request/files.ts index 3d05ddd7f..af0794c94 100644 --- a/packages/web-api/src/types/request/files.ts +++ b/packages/web-api/src/types/request/files.ts @@ -157,9 +157,6 @@ type FileUpload = FileUploadContents & /** @description File title. */ title?: string; }; -// https://docs.slack.dev/reference/methods/files.upload -export type FilesUploadArguments = FileUpload & TokenOverridable; - export type FileUploadV2 = FileUpload & { /** @description Description of image for screen-reader. */ alt_text?: string; diff --git a/packages/web-api/src/types/request/index.ts b/packages/web-api/src/types/request/index.ts index fb387f1d4..112bfa46e 100644 --- a/packages/web-api/src/types/request/index.ts +++ b/packages/web-api/src/types/request/index.ts @@ -232,7 +232,6 @@ export type { FilesRemoteUpdateArguments, FilesRevokePublicURLArguments, FilesSharedPublicURLArguments, - FilesUploadArguments, FilesUploadV2Arguments, } from './files'; export type { @@ -267,10 +266,7 @@ export type { RemindersInfoArguments, RemindersListArguments, } from './reminders'; -export type { - RTMConnectArguments, - RTMStartArguments, -} from './rtm'; +export type { RTMConnectArguments } from './rtm'; export type { SearchAllArguments, SearchFilesArguments, @@ -340,7 +336,4 @@ export type { WorkflowsFeaturedListArguments, WorkflowsFeaturedRemoveArguments, WorkflowsFeaturedSetArguments, - WorkflowsStepCompletedArguments, - WorkflowsStepFailedArguments, - WorkflowsUpdateStepArguments, } from './workflows'; diff --git a/packages/web-api/src/types/request/rtm.ts b/packages/web-api/src/types/request/rtm.ts index 9d8ca0323..a824fdbe9 100644 --- a/packages/web-api/src/types/request/rtm.ts +++ b/packages/web-api/src/types/request/rtm.ts @@ -1,5 +1,5 @@ import type { OptionalArgument } from '../helpers'; -import type { LocaleAware, TokenOverridable } from './common'; +import type { TokenOverridable } from './common'; // https://docs.slack.dev/reference/methods/rtm.connect export type RTMConnectArguments = OptionalArgument< @@ -16,20 +16,3 @@ export type RTMConnectArguments = OptionalArgument< presence_sub?: boolean; } >; -// https://docs.slack.dev/reference/methods/rtm.start -export type RTMStartArguments = OptionalArgument< - RTMConnectArguments & - LocaleAware & { - /** @description Returns MPIMs to the client in the API response. */ - mpim_aware?: boolean; - /** - * @description Exclude latest timestamps for channels, groups, mpims, and ims. - * Automatically sets `no_unreads` to `true`. - */ - no_latest?: boolean; - /** @description Skip unread counts for each channel (improves performance). */ - no_unreads?: boolean; - /** @description Return timestamp only for latest message object of each channel (improves performance). */ - simple_latest?: boolean; - } ->; diff --git a/packages/web-api/src/types/request/workflows.ts b/packages/web-api/src/types/request/workflows.ts index 4173caf94..714f4a39a 100644 --- a/packages/web-api/src/types/request/workflows.ts +++ b/packages/web-api/src/types/request/workflows.ts @@ -47,41 +47,3 @@ export interface WorkflowsFeaturedSetArguments extends TokenOverridable { */ trigger_ids: string[]; } - -// TODO: breaking change: to be removed after Sep 12 2024 -// https://docs.slack.dev/changelog/2023-08-workflow-steps-from-apps-step-back - -// https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object -export interface WorkflowsStepCompletedArguments extends TokenOverridable { - workflow_step_execute_id: string; - outputs?: Record; -} -// https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object -export interface WorkflowsStepFailedArguments extends TokenOverridable { - workflow_step_execute_id: string; - error: { - message: string; - }; -} -// https://docs.slack.dev/legacy/legacy-steps-from-apps/legacy-steps-from-apps-workflow_step-object -export interface WorkflowsUpdateStepArguments extends TokenOverridable { - workflow_step_edit_id: string; - step_image_url?: string; - step_name?: string; - inputs?: { - [name: string]: { - // biome-ignore lint/suspicious/noExplicitAny: steps from apps inputs are untyped - value: any; - skip_variable_replacement?: boolean; - variables?: { - // biome-ignore lint/suspicious/noExplicitAny: steps from apps inputs are untyped - [key: string]: any; - }; - }; - }; - outputs?: { - type: string; - name: string; - label: string; - }[]; -} 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/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 b9f8f6fb8..454f83b93 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", @@ -18,8 +18,8 @@ "dist/**/*" ], "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">=9.6.4" }, "repository": { "type": "git", @@ -41,9 +41,8 @@ "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/IncomingWebhook.test.ts" }, "dependencies": { - "@slack/types": "^2.20.1", - "@types/node": ">=18", - "axios": "^1.16.0" + "@slack/types": "^3.0.0-rc.1", + "@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 36db5946b..2ae75aab4 100644 --- a/packages/webhook/src/IncomingWebhook.test.ts +++ b/packages/webhook/src/IncomingWebhook.test.ts @@ -2,9 +2,9 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it } from 'node:test'; import nock from 'nock'; -import type { CodedError } from './errors'; -import { ErrorCode } from './errors'; -import { IncomingWebhook } from './IncomingWebhook'; +import { ErrorCode, IncomingWebhookHTTPError, IncomingWebhookRequestError, SlackWebhookError } from './errors'; +import { type FetchFunction, IncomingWebhook } from './IncomingWebhook'; +import { getUserAgent } from './instrument'; const url = 'https://hooks.slack.com/services/FAKEWEBHOOK'; @@ -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: FetchFunction = async () => { + fetchCalled = true; + return new Response('ok', { status: 200 }); + }; + const webhook = new IncomingWebhook(url, { fetch: customFetch }); + await webhook.send('Hello'); + assert.ok(fetchCalled); }); }); @@ -81,9 +92,11 @@ describe('IncomingWebhook', () => { await webhook.send('Hello'); assert.fail('expected rejection'); } catch (error) { - assert.ok(error); - assert.ok(error instanceof Error); - assert.match((error as Error).message, new RegExp(String(statusCode))); + assert.ok(error instanceof IncomingWebhookHTTPError); + assert.ok(error instanceof SlackWebhookError); + assert.strictEqual(error.code, ErrorCode.HTTPError); + assert.strictEqual(error.statusCode, statusCode); + assert.match(error.message, new RegExp(String(statusCode))); scope.done(); } }); @@ -96,8 +109,11 @@ describe('IncomingWebhook', () => { await webhook.send('Hello'); assert.fail('expected rejection'); } catch (error) { - assert.ok(error instanceof Error); - assert.strictEqual((error as CodedError).code, ErrorCode.RequestError); + assert.ok(error instanceof IncomingWebhookRequestError); + assert.ok(error instanceof SlackWebhookError); + assert.strictEqual(error.code, ErrorCode.RequestError); + assert.ok(error.original instanceof Error); + assert.strictEqual(error.cause, error.original); } }); }); @@ -122,7 +138,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; }, }, }) diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 8b5046121..26621419d 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -1,11 +1,34 @@ -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 { httpErrorWithOriginal, requestErrorWithOriginal } from './errors'; +import { IncomingWebhookHTTPError, IncomingWebhookRequestError, SlackWebhookError } from './errors'; import { getUserAgent } from './instrument'; +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; +} + +export type FetchFunction = (url: string | URL, init?: FetchRequestInit) => Promise; + /** * A client for Slack's Incoming Webhooks */ @@ -21,9 +44,19 @@ export class IncomingWebhook { private defaults: IncomingWebhookDefaultArguments; /** - * Axios HTTP client instance used by this client + * The fetch function used for HTTP requests + */ + private fetchFn: FetchFunction; + + /** + * Request timeout in milliseconds + */ + private timeout: number; + + /** + * Default headers sent with every request */ - private axios: AxiosInstance; + private headers: Record; public constructor( url: string, @@ -36,21 +69,15 @@ 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; } /** @@ -58,7 +85,6 @@ export class IncomingWebhook { * @param message - the message (a simple string, or an object describing the message) */ public async send(message: string | IncomingWebhookSendArguments): Promise { - // NOTE: no support for TLS config let payload: IncomingWebhookSendArguments = { ...this.defaults }; if (typeof message === 'string') { @@ -67,28 +93,44 @@ export class IncomingWebhook { payload = Object.assign(payload, message); } + const controller = new AbortController(); + const timer = this.timeout > 0 ? setTimeout(() => controller.abort(), this.timeout) : undefined; + const signal = 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 this.fetchFn(this.url, { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + redirect: 'error', + ...(signal ? { signal } : {}), + }); + + if (!response.ok) { + const body = await response.text(); + throw new IncomingWebhookHTTPError(response.status, response.statusText, body); } - if (error.request !== undefined) { - throw requestErrorWithOriginal(error); + + return await this.buildResult(response); + } catch (error) { + if (error instanceof SlackWebhookError) { + throw error; } - throw error; + throw new IncomingWebhookRequestError(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: FetchResponse): Promise { return { - text: response.data, + text: await response.text(), }; } } @@ -104,7 +146,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..07b0e9358 100644 --- a/packages/webhook/src/errors.ts +++ b/packages/webhook/src/errors.ts @@ -1,15 +1,10 @@ -import type { AxiosError, AxiosResponse } from 'axios'; - /** - * All errors produced by this package adhere to this interface + * @deprecated Use `instanceof` checks with specific error classes (e.g. `IncomingWebhookRequestError`) or the `SlackWebhookError` base class instead. */ export interface CodedError extends NodeJS.ErrnoException { code: ErrorCode; } -/** - * A dictionary of codes for errors produced by this package - */ export enum ErrorCode { RequestError = 'slack_webhook_request_error', HTTPError = 'slack_webhook_http_error', @@ -17,48 +12,36 @@ export enum ErrorCode { export type IncomingWebhookSendError = IncomingWebhookRequestError | IncomingWebhookHTTPError; -export interface IncomingWebhookRequestError extends CodedError { - code: ErrorCode.RequestError; - original: Error; -} +export abstract class SlackWebhookError extends Error { + abstract readonly code: ErrorCode; -export interface IncomingWebhookHTTPError extends CodedError { - code: ErrorCode.HTTPError; - original: Error; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = this.constructor.name; + Object.setPrototypeOf(this, new.target.prototype); + } } -/** - * Factory for producing a {@link CodedError} from a generic error - */ -function errorWithCode(error: Error, code: ErrorCode): CodedError { - // NOTE: might be able to return something more specific than a CodedError with conditional typing - const codedError = error as Partial; - codedError.code = code; - return codedError as CodedError; -} +export class IncomingWebhookRequestError extends SlackWebhookError { + readonly code = ErrorCode.RequestError; + readonly original: Error; -/** - * A factory to create IncomingWebhookRequestError objects - * @param original The original error - */ -export function requestErrorWithOriginal(original: AxiosError): IncomingWebhookRequestError { - const error = errorWithCode( - new Error(`A request error occurred: ${original.message}`), - ErrorCode.RequestError, - ) as Partial; - error.original = original; - return error as IncomingWebhookRequestError; + constructor(original: Error) { + super(`A request error occurred: ${original.message}`, { cause: original }); + this.original = original; + } } -/** - * A factory to create IncomingWebhookHTTPError objects - * @param original The original error - */ -export function httpErrorWithOriginal(original: AxiosError & { response: AxiosResponse }): IncomingWebhookHTTPError { - const error = errorWithCode( - new Error(`An HTTP protocol error occurred: statusCode = ${original.response.status}`), - ErrorCode.HTTPError, - ) as Partial; - error.original = original; - return error as IncomingWebhookHTTPError; +export class IncomingWebhookHTTPError extends SlackWebhookError { + readonly code = ErrorCode.HTTPError; + readonly statusCode: number; + readonly statusMessage: string; + readonly body: string; + + constructor(statusCode: number, statusMessage: string, body: string) { + super(`An HTTP protocol error occurred: statusCode = ${statusCode}`); + this.statusCode = statusCode; + this.statusMessage = statusMessage; + this.body = body; + } } diff --git a/packages/webhook/src/index.ts b/packages/webhook/src/index.ts index 74420ffba..67f56ae02 100644 --- a/packages/webhook/src/index.ts +++ b/packages/webhook/src/index.ts @@ -6,9 +6,11 @@ export { IncomingWebhookHTTPError, IncomingWebhookRequestError, IncomingWebhookSendError, + SlackWebhookError, } from './errors'; export { + FetchFunction, IncomingWebhook, IncomingWebhookDefaultArguments, IncomingWebhookResult, diff --git a/prod-server-integration-tests/test/admin-web-api-conversations-bulk.test.js b/prod-server-integration-tests/test/admin-web-api-conversations-bulk.test.js index 48c77407e..ee60863c8 100644 --- a/prod-server-integration-tests/test/admin-web-api-conversations-bulk.test.js +++ b/prod-server-integration-tests/test/admin-web-api-conversations-bulk.test.js @@ -29,10 +29,10 @@ describe('admin.* Web APIs', () => { .bulkArchive({ channel_ids: [channelId], }) - .catch((error) => { + .catch(async (error) => { if (error.data.error === 'action_already_in_progress') { isInProgress = true; - new Promise((r) => setTimeout(r, 3000)); + await new Promise((r) => setTimeout(r, 3000)); } else { throw error; } @@ -67,10 +67,10 @@ describe('admin.* Web APIs', () => { .bulkDelete({ channel_ids: [channelId], }) - .catch((error) => { + .catch(async (error) => { if (error.data.error === 'action_already_in_progress') { isInProgress = true; - new Promise((r) => setTimeout(r, 3000)); + await new Promise((r) => setTimeout(r, 3000)); } else { throw error; } @@ -100,10 +100,10 @@ describe('admin.* Web APIs', () => { channel_ids: [channelId], target_team_id: secondaryTeamId, }) - .catch((error) => { + .catch(async (error) => { if (error.data.error === 'action_already_in_progress') { isInProgress = true; - new Promise((r) => setTimeout(r, 3000)); + await new Promise((r) => setTimeout(r, 3000)); } else { throw error; } diff --git a/tsconfig.base.json b/tsconfig.base.json index 5bf2bd980..d9f74762c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "target": "es2017", + "target": "es2022", "module": "commonjs", "declaration": true, "declarationMap": true,