diff --git a/package-lock.json b/package-lock.json index 294ddaaf8..b0a31d67f 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": "*" @@ -6359,7 +6360,7 @@ }, "packages/logger": { "name": "@slack/logger", - "version": "4.0.1", + "version": "5.0.0-rc.1", "license": "MIT", "dependencies": { "@types/node": ">=20" @@ -6390,7 +6391,7 @@ "license": "MIT", "dependencies": { "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", + "@slack/web-api": "^8.0.0-rc.1", "@types/jsonwebtoken": "^9", "@types/node": ">=20", "jsonwebtoken": "^9" @@ -6405,6 +6406,19 @@ "npm": ">=9.6.4" } }, + "packages/oauth/node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, "packages/oauth/node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -6425,8 +6439,8 @@ "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", @@ -6444,6 +6458,43 @@ "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", @@ -6461,6 +6512,18 @@ "@types/sinonjs__fake-timers": "*" } }, + "packages/rtm-api/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/rtm-api/node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -6473,22 +6536,39 @@ "license": "MIT", "dependencies": { "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", + "@slack/web-api": "^8.0.0-rc.1", "@types/node": ">=20", - "@types/ws": "^8", - "eventemitter3": "^5", - "ws": "^8" + "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": ">=20", "npm": ">=9.6.4" + }, + "peerDependencies": { + "undici": "^7.0.0" + } + }, + "packages/socket-mode/node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" } }, "packages/socket-mode/node_modules/@types/node": { @@ -6500,6 +6580,16 @@ "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", @@ -6520,18 +6610,14 @@ }, "packages/web-api": { "name": "@slack/web-api", - "version": "7.15.2", + "version": "8.0.0-rc.1", "license": "MIT", "dependencies": { "@slack/logger": "^4.0.1", "@slack/types": "^2.21.0", "@types/node": ">=20", "@types/retry": "0.12.0", - "axios": "^1.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" @@ -6549,6 +6635,19 @@ "npm": ">=9.6.4" } }, + "packages/web-api/node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, "packages/web-api/node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -6558,16 +6657,6 @@ "undici-types": "~7.19.0" } }, - "packages/web-api/node_modules/is-stream": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/web-api/node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -6580,8 +6669,7 @@ "license": "MIT", "dependencies": { "@slack/types": "^2.20.1", - "@types/node": ">=20", - "axios": "^1.16.0" + "@types/node": ">=20" }, "devDependencies": { "nock": "^14.0.6" diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 05404c8ee..67266b966 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -41,7 +41,7 @@ }, "dependencies": { "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", + "@slack/web-api": "^8.0.0-rc.1", "@types/jsonwebtoken": "^9", "@types/node": ">=20", "jsonwebtoken": "^9" diff --git a/packages/socket-mode/examples/proxy.js b/packages/socket-mode/examples/proxy.js index e2886fb22..bfef8bd3d 100644 --- a/packages/socket-mode/examples/proxy.js +++ b/packages/socket-mode/examples/proxy.js @@ -1,17 +1,17 @@ const { SocketModeClient, LogLevel } = require('@slack/socket-mode'); -const HttpsProxyAgent = require('https-proxy-agent'); -const clientOptions = { agent: new HttpsProxyAgent('http://localhost:9001') }; +const { ProxyAgent } = require('undici'); +const dispatcher = new ProxyAgent('http://localhost:9001'); const socketModeClient = new SocketModeClient({ appToken: process.env.SLACK_APP_TOKEN, logLevel: LogLevel.DEBUG, - clientOptions, + dispatcher, }); // const { WebClient } = require('@slack/web-api'); // const webClient = new WebClient(process.env.SLACK_BOT_TOKEN, { // logLevel: LogLevel.DEBUG, -// clientOptions, +// fetch: (url, options) => fetch(url, { ...options, dispatcher }) // }); socketModeClient.on('slack_event', async ({ ack, body }) => { diff --git a/packages/socket-mode/package.json b/packages/socket-mode/package.json index c808e8e23..c0b816af6 100644 --- a/packages/socket-mode/package.json +++ b/packages/socket-mode/package.json @@ -46,22 +46,30 @@ "test": "npm run test:unit && npm run test:integration", "test:coverage": "npm run build && node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/*.test.ts", "test:integration": "npm run build && node --import tsx --test test/integration.test.js", + "test:types": "tsd", "test:unit": "npm run build && bash -c 'node --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/*.test.ts'", "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm test" }, "dependencies": { "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", + "@slack/web-api": "^8.0.0-rc.1", "@types/node": ">=20", - "@types/ws": "^8", - "eventemitter3": "^5", - "ws": "^8" + "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..ee9869219 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 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', websocketErrorWithOriginal(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..4061e8110 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -8,13 +8,13 @@ import { } from '@slack/web-api'; import { EventEmitter } from 'eventemitter3'; -import type WebSocket from 'ws'; +import { type Dispatcher, type RequestInit, fetch as undiciFetch } from 'undici'; import packageJson from '../package.json'; import { sendWhileDisconnectedError, sendWhileNotReadyError, websocketErrorWithOriginal } from './errors'; import log, { type Logger, LogLevel } from './logger'; import { SlackWebSocket, WS_READY_STATES } from './SlackWebSocket'; -import type { SocketModeOptions } from './SocketModeOptions'; +import type { SocketModeDispatcher, SocketModeOptions } from './SocketModeOptions'; import { UnrecoverableSocketModeStartError } from './UnrecoverableSocketModeStartError'; // Lifecycle events as described in the README @@ -55,14 +55,18 @@ 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; + + private connectionResponse?: AppsConnectionsOpenResponse; + public websocket?: SlackWebSocket; /** @@ -103,6 +107,7 @@ export class SocketModeClient extends EventEmitter { serverPingTimeout = 30000, appToken = '', clientOptions = {}, + dispatcher = undefined, }: SocketModeOptions = { appToken: '' }, ) { super(); @@ -112,6 +117,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 +129,10 @@ 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 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 +141,7 @@ export class SocketModeClient extends EventEmitter { logger, logLevel: this.logger.getLevel(), headers: { Authorization: `Bearer ${appToken}` }, - ...clientOptions, + ...this.webClientOptions, }); this.autoReconnectEnabled = autoReconnectEnabled; @@ -171,7 +181,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, @@ -255,6 +265,7 @@ export class SocketModeClient extends EventEmitter { throw new Error(msg); } this.numOfConsecutiveReconnectionFailures = 0; + this.connectionResponse = resp; this.emit(State.Authenticated, resp); return resp.url; } catch (error) { @@ -286,12 +297,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 +328,7 @@ export class SocketModeClient extends EventEmitter { // Slack has finalized the handshake with a hello message; we are good to go. if (event.type === 'hello') { - this.emit(State.Connected); + this.emit(State.Connected, this.connectionResponse); return; } diff --git a/packages/socket-mode/src/SocketModeOptions.ts b/packages/socket-mode/src/SocketModeOptions.ts index 7b2c553de..018f91a03 100644 --- a/packages/socket-mode/src/SocketModeOptions.ts +++ b/packages/socket-mode/src/SocketModeOptions.ts @@ -1,6 +1,18 @@ 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 { + // 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 +53,17 @@ export interface SocketModeOptions { pingPongLoggingEnabled?: boolean; /** * The `@slack/web-api` `WebClientOptions` to provide to the HTTP client interacting with Slack's HTTP API. - * Useful for setting retry configurations, TLS and HTTP Agent options. + * Useful for setting retry configurations and custom fetch implementations. */ clientOptions?: Omit; + /** + * An undici `Dispatcher` used for the WebSocket connection and, if no custom `fetch` is provided + * via `clientOptions`, also wrapped into a custom fetch for HTTP API calls. + * If `clientOptions.fetch` is already defined, the dispatcher is only used for the WebSocket connection. + * + * Use this to configure proxies (e.g. `new ProxyAgent('http://proxy:3128')`) or custom TLS behavior. + * + * @see https://undici.nodejs.org/#/docs/api/ProxyAgent + */ + dispatcher?: SocketModeDispatcher; } 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/integration.test.js index 03eccbcef..8635a2445 100644 --- a/packages/socket-mode/test/integration.test.js +++ b/packages/socket-mode/test/integration.test.js @@ -69,6 +69,12 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => await client.start(); await client.disconnect(); }); + it('start() resolves with the apps.connections.open API response', async () => { + const result = await client.start(); + assert.equal(result.ok, true); + assert.equal(result.url, `ws://localhost:${WSS_PORT}/`); + await client.disconnect(); + }); it('can call `disconnect()` even if already disconnected without issue', async () => { await client.disconnect(); }); 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/web-api/package.json b/packages/web-api/package.json index de12b5f1c..46a734e09 100644 --- a/packages/web-api/package.json +++ b/packages/web-api/package.json @@ -52,11 +52,7 @@ "@slack/types": "^2.21.0", "@types/node": ">=20", "@types/retry": "0.12.0", - "axios": "^1.16.0", "eventemitter3": "^5.0.1", - "form-data": "^4.0.4", - "is-electron": "2.2.2", - "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" diff --git a/packages/web-api/src/WebClient.test.ts b/packages/web-api/src/WebClient.test.ts index 35925358d..f0d075183 100644 --- a/packages/web-api/src/WebClient.test.ts +++ b/packages/web-api/src/WebClient.test.ts @@ -3,7 +3,6 @@ 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 { @@ -23,7 +22,7 @@ import { type Logger, LogLevel } from './logger'; import { rapidRetryPolicy } from './retry-policies'; import { buildThreadTsWarningMessage, - type RequestConfig, + type FetchFunction, type WebAPICallResult, WebClient, WebClientEvent, @@ -120,16 +119,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 +145,32 @@ 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) { + const e = error as WebAPIRequestError; + assert.strictEqual(e.code, ErrorCode.RequestError); + assert.ok(e.original instanceof Error); + } + }); }); describe('apiCall()', () => { @@ -664,6 +696,32 @@ describe('WebClient', () => { }); }); + describe('apiCall() - default Accept header', () => { + it('should include Accept: application/json header by default', async () => { + const scope = nock('https://slack.com', { + reqheaders: { + Accept: 'application/json', + }, + }) + .post(/api/) + .reply(200, { ok: true }); + await client.apiCall('method'); + scope.done(); + }); + it('should allow overriding Accept header via constructor options', async () => { + const customClient = new WebClient(token, { headers: { Accept: 'text/plain' } }); + const scope = nock('https://slack.com', { + reqheaders: { + Accept: 'text/plain', + }, + }) + .post(/api/) + .reply(200, { ok: true }); + await customClient.apiCall('method'); + scope.done(); + }); + }); + describe('named method aliases (facets)', () => { beforeEach(() => { client = new WebClient(token, { retryConfig: rapidRetryPolicy }); @@ -1089,116 +1147,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 +1962,21 @@ describe('WebClient', () => { }); }); - describe('has an option to suppress request error from Axios', () => { - let scope: nock.Scope; - beforeEach(() => { - scope = nock('https://slack.com').post(/api/).replyWithError('Request failed!!'); - }); - - it("the 'original' property is attached when the option, attachOriginalToWebAPIRequestError is absent", async () => { - const client = new WebClient(token, { - retryConfig: { retries: 0 }, - }); - - try { - await client.apiCall('conversations/list'); - } catch (error) { - assert.ok(Object.hasOwn(error, 'original')); - scope.done(); - } - }); - - it("the 'original' property is attached when the option, attachOriginalToWebAPIRequestError is set to true", async () => { + describe('request errors always attach original', () => { + it("the 'original' property is always attached to request errors", async () => { + const scope = nock('https://slack.com').post(/api/).replyWithError('Request failed!!'); const client = new WebClient(token, { - attachOriginalToWebAPIRequestError: true, retryConfig: { retries: 0 }, }); try { await client.apiCall('conversations/list'); + assert.fail('Should have thrown'); } catch (error) { + const e = error as WebAPIRequestError; + assert.strictEqual(e.code, ErrorCode.RequestError); assert.ok(Object.hasOwn(error, 'original')); - scope.done(); - } - }); - - it("the 'original' property is not attached when the option, attachOriginalToWebAPIRequestError is set to false", async () => { - const client = new WebClient(token, { - attachOriginalToWebAPIRequestError: false, - retryConfig: { retries: 0 }, - }); - - try { - await client.apiCall('conversations/list'); - } catch (error) { - assert.ok(!Object.hasOwn(error, 'original')); + assert.ok(e.original instanceof Error); scope.done(); } }); diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index 3a5185a33..9b8c63e6a 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -1,20 +1,8 @@ -import type { Agent } from 'node:http'; import { basename } from 'node:path'; import { stringify as qsStringify } from 'node:querystring'; -import type { SecureContextOptions } from 'node:tls'; import { TextDecoder } from 'node:util'; import zlib from 'node:zlib'; -import axios, { - type AxiosAdapter, - type AxiosHeaderValue, - type AxiosInstance, - type AxiosResponse, - type InternalAxiosRequestConfig, -} from 'axios'; -import FormData from 'form-data'; -import isElectron from 'is-electron'; -import isStream from 'is-stream'; import pQueue from 'p-queue'; import pRetry, { AbortError } from 'p-retry'; import { ChatStreamer, type ChatStreamerOptions } from './chat-stream'; @@ -54,20 +42,6 @@ import type { /* * Helpers */ -// Props on axios default headers object to ignore when retrieving full list of actual headers sent in any HTTP requests -const axiosHeaderPropsToIgnore = [ - 'delete', - 'common', - 'get', - 'put', - 'head', - 'post', - 'link', - 'patch', - 'purge', - 'unlink', - 'options', -]; const defaultFilename = 'Untitled'; const defaultPageSize = 200; const noopPageReducer: PageReducer = () => undefined; @@ -88,8 +62,11 @@ export interface WebClientOptions { logLevel?: LogLevel; maxRequestConcurrency?: number; retryConfig?: RetryOptions; - agent?: Agent; - tls?: TLSOptions; + /** + * A custom `fetch` implementation conforming to the WHATWG Fetch standard. + * Defaults to `globalThis.fetch`. Use this to configure proxies, TLS, or other transport-level behavior. + */ + fetch?: FetchFunction; timeout?: number; rejectRateLimitedCalls?: boolean; headers?: Record; @@ -99,35 +76,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', @@ -163,23 +116,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 | null; +} + +export type FetchFunction = (url: string | URL, init?: FetchRequestInit) => Promise; /** * A client for Slack's Web API @@ -210,14 +171,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 +205,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 +219,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 +237,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 +253,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 +263,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`); @@ -401,7 +323,7 @@ export class WebClient extends Methods { // If result's content is gzip, "ok" property is not returned with successful response // TODO: look into simplifying this code block to only check for the second condition // if an { ok: false } body applies for all API errors - if (!result.ok && response.headers['content-type'] !== 'application/gzip') { + if (!result.ok && response.headers.get('content-type') !== 'application/gzip') { throw platformErrorFromResult(result as WebAPICallResult & { error: string }); } if ('ok' in result && result.ok === false) { @@ -644,7 +566,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 +601,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; + } + + 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; - allHeaders = { - ...this.axios.defaults.headers.common, - ...allHeaders, - ...headers, - }; - this.logger.debug(`http request headers: ${JSON.stringify(redact(allHeaders))}`); - const response = await this.axios.post(url, body, config); + try { + const response = await this.fetchFn(url, { + method: 'POST', + headers: allHeaders, + body: serializedBody, + redirect: 'error', + ...(signal ? { signal } : {}), + }); this.logger.debug('http response received'); if (response.status === 429) { @@ -733,10 +642,10 @@ export class WebClient extends Methods { // 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 +656,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 httpErrorFromResponse( + response.status, + response.statusText, + Object.fromEntries(response.headers.entries()), + responseBody, + ); } return response; } catch (error) { - // To make this compatible with tsd, casting here instead of `catch (error: any)` - // biome-ignore lint/suspicious/noExplicitAny: errors can be anything - const e = error as any; - this.logger.warn('http request failed', e.message); - if (e.request) { - throw requestErrorWithOriginal(e, this.attachOriginalToWebAPIRequestError); + if (error instanceof AbortError) { + throw error; + } + if (error instanceof Error && 'code' in error && typeof error.code === 'string') { + throw error; } - throw error; + const message = error instanceof Error ? error.message : String(error); + this.logger.warn('http request failed', message); + throw requestErrorWithOriginal(error instanceof Error ? error : new Error(String(error))); + } finally { + if (timer) clearTimeout(timer); } }); - // 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 +699,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 +721,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 +735,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 +766,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 +777,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'; + + // biome-ignore lint/suspicious/noExplicitAny: HTTP response data can be anything + let data: any; - // Check for GZIP response - if so, it is a successful response from admin.analytics.getFile + // admin.analytics.getFile returns a gzip binary response that can be unzipped if (isGzipResponse) { - // 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 +810,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 +830,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 +878,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; @@ -1088,7 +987,7 @@ function redact(body: Record): Record { } // when value is buffer or stream we can avoid logging it - if (Buffer.isBuffer(value) || isStream(value)) { + if (Buffer.isBuffer(value)) { serializedValue = '[[BINARY VALUE OMITTED]]'; } else if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { serializedValue = JSON.stringify(value); diff --git a/packages/web-api/src/errors.ts b/packages/web-api/src/errors.ts index c6698acf6..b90865f45 100644 --- a/packages/web-api/src/errors.ts +++ b/packages/web-api/src/errors.ts @@ -1,7 +1,3 @@ -import type { IncomingHttpHeaders } from 'node:http'; - -import type { AxiosResponse } from 'axios'; - import type { WebAPICallResult } from './WebClient'; /** @@ -52,7 +48,7 @@ export interface WebAPIHTTPError extends CodedError { code: ErrorCode.HTTPError; statusCode: number; statusMessage: string; - headers: IncomingHttpHeaders; + headers: Record; // biome-ignore lint/suspicious/noExplicitAny: HTTP response bodies might be anything body?: any; } @@ -75,38 +71,46 @@ export function errorWithCode(error: Error, code: ErrorCode): CodedError { /** * A factory to create WebAPIRequestError objects * @param original - original error - * @param attachOriginal - config indicating if 'original' property should be added on the error object */ -export function requestErrorWithOriginal(original: Error, attachOriginal: boolean): WebAPIRequestError { +export function requestErrorWithOriginal(original: Error): WebAPIRequestError { const error = errorWithCode( new Error(`A request error occurred: ${original.message}`), ErrorCode.RequestError, ) as Partial; - if (attachOriginal) { - error.original = original; - } + error.original = original; return error as WebAPIRequestError; } /** * A factory to create WebAPIHTTPError objects - * @param response - original error + * @param status - HTTP status code + * @param statusText - HTTP status text + * @param headers - response headers + * @param body - response body */ -export function httpErrorFromResponse(response: AxiosResponse): WebAPIHTTPError { +export function httpErrorFromResponse( + status: number, + statusText: string, + headers: Record, + // biome-ignore lint/suspicious/noExplicitAny: HTTP response bodies might be anything + body?: any, +): WebAPIHTTPError { const error = errorWithCode( - new Error(`An HTTP protocol error occurred: statusCode = ${response.status}`), + new Error(`An HTTP protocol error occurred: statusCode = ${status}`), ErrorCode.HTTPError, ) as Partial; - error.statusCode = response.status; - error.statusMessage = response.statusText; - const nonNullHeaders: Record = {}; - for (const k of Object.keys(response.headers)) { - if (k && response.headers[k]) { - nonNullHeaders[k] = response.headers[k]; + error.statusCode = status; + error.statusMessage = statusText; + error.headers = headers; + if (typeof body === 'string') { + try { + error.body = JSON.parse(body); + } catch { + error.body = body; } + } else { + error.body = body; } - error.headers = nonNullHeaders; - error.body = response.data; return error as WebAPIHTTPError; } diff --git a/packages/web-api/src/index.ts b/packages/web-api/src/index.ts index 534f0e34d..d7dd01551 100644 --- a/packages/web-api/src/index.ts +++ b/packages/web-api/src/index.ts @@ -22,10 +22,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/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/web-api/test/types/methods/chat.test-d.ts b/packages/web-api/test/types/methods/chat.test-d.ts index 867758917..3980bf0ed 100644 --- a/packages/web-api/test/types/methods/chat.test-d.ts +++ b/packages/web-api/test/types/methods/chat.test-d.ts @@ -1,6 +1,6 @@ import { CustomFieldType, type EntityMetadata } from '@slack/types'; import { expectAssignable, expectError } from 'tsd'; -import { WebClient } from '../../../src/WebClient'; +import { type RequestOptions, WebClient } from '../../../src/WebClient'; const web = new WebClient('TOKEN'); diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 251f4b053..460e64db3 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -42,8 +42,7 @@ }, "dependencies": { "@slack/types": "^2.20.1", - "@types/node": ">=20", - "axios": "^1.16.0" + "@types/node": ">=20" }, "devDependencies": { "nock": "^14.0.6" diff --git a/packages/webhook/src/IncomingWebhook.test.ts b/packages/webhook/src/IncomingWebhook.test.ts index 36db5946b..7c8ab751e 100644 --- a/packages/webhook/src/IncomingWebhook.test.ts +++ b/packages/webhook/src/IncomingWebhook.test.ts @@ -4,7 +4,8 @@ import nock from 'nock'; import type { CodedError } from './errors'; import { ErrorCode } from './errors'; -import { IncomingWebhook } from './IncomingWebhook'; +import { type FetchFunction, IncomingWebhook } from './IncomingWebhook'; +import { getUserAgent } from './instrument'; const url = 'https://hooks.slack.com/services/FAKEWEBHOOK'; @@ -22,14 +23,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); }); }); @@ -122,7 +134,8 @@ describe('IncomingWebhook', () => { const scope = nock('https://hooks.slack.com', { reqheaders: { 'User-Agent': (value) => { - return /@slack:webhook/.test(value); + assert.strictEqual(value, getUserAgent()); + return true; }, }, }) diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 8b5046121..1d508f503 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 { 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 | null; +} + +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 httpErrorWithOriginal(response.status, body); } - if (error.request !== undefined) { - throw requestErrorWithOriginal(error); + + return await this.buildResult(response); + } catch (error) { + if (error instanceof Error && 'code' in error && typeof error.code === 'string') { + throw error; } - throw error; + throw requestErrorWithOriginal(error instanceof Error ? error : new Error(String(error))); + } finally { + if (timer) clearTimeout(timer); } } /** * Processes an HTTP response into an IncomingWebhookResult. */ - private buildResult(response: AxiosResponse): IncomingWebhookResult { + private async buildResult(response: 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..2cf97cd95 100644 --- a/packages/webhook/src/errors.ts +++ b/packages/webhook/src/errors.ts @@ -1,5 +1,3 @@ -import type { AxiosError, AxiosResponse } from 'axios'; - /** * All errors produced by this package adhere to this interface */ @@ -41,7 +39,7 @@ function errorWithCode(error: Error, code: ErrorCode): CodedError { * A factory to create IncomingWebhookRequestError objects * @param original The original error */ -export function requestErrorWithOriginal(original: AxiosError): IncomingWebhookRequestError { +export function requestErrorWithOriginal(original: Error): IncomingWebhookRequestError { const error = errorWithCode( new Error(`A request error occurred: ${original.message}`), ErrorCode.RequestError, @@ -52,13 +50,14 @@ export function requestErrorWithOriginal(original: AxiosError): IncomingWebhookR /** * A factory to create IncomingWebhookHTTPError objects - * @param original The original error + * @param status The HTTP status code + * @param body The response body text */ -export function httpErrorWithOriginal(original: AxiosError & { response: AxiosResponse }): IncomingWebhookHTTPError { +export function httpErrorWithOriginal(status: number, body: string): IncomingWebhookHTTPError { const error = errorWithCode( - new Error(`An HTTP protocol error occurred: statusCode = ${original.response.status}`), + new Error(`An HTTP protocol error occurred: statusCode = ${status}`), ErrorCode.HTTPError, ) as Partial; - error.original = original; + error.original = new Error(`An HTTP protocol error occurred: statusCode = ${status}, body = ${body}`); return error as IncomingWebhookHTTPError; } diff --git a/packages/webhook/src/index.ts b/packages/webhook/src/index.ts index 74420ffba..331d6f8ca 100644 --- a/packages/webhook/src/index.ts +++ b/packages/webhook/src/index.ts @@ -9,6 +9,7 @@ export { } from './errors'; export { + FetchFunction, IncomingWebhook, IncomingWebhookDefaultArguments, IncomingWebhookResult,