From c5be20337781f1bda03d26c85a99dd0faa71590f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Linus=20Unneb=C3=A4ck?= Date: Tue, 19 May 2026 12:01:29 +0200 Subject: [PATCH] feat(web-api): add support for AbortSignal --- .changeset/web-api-abort-signal.md | 5 ++ packages/web-api/src/WebClient.test.ts | 72 ++++++++++++++++++++++++++ packages/web-api/src/WebClient.ts | 17 +++++- packages/web-api/src/methods.ts | 8 ++- 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 .changeset/web-api-abort-signal.md diff --git a/.changeset/web-api-abort-signal.md b/.changeset/web-api-abort-signal.md new file mode 100644 index 000000000..859457b50 --- /dev/null +++ b/.changeset/web-api-abort-signal.md @@ -0,0 +1,5 @@ +--- +"@slack/web-api": minor +--- + +Add support for [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). diff --git a/packages/web-api/src/WebClient.test.ts b/packages/web-api/src/WebClient.test.ts index cd4ce0259..bcce2cbcb 100644 --- a/packages/web-api/src/WebClient.test.ts +++ b/packages/web-api/src/WebClient.test.ts @@ -1963,6 +1963,78 @@ describe('WebClient', () => { } }); }); + + describe('accepts an AbortSignal to cancel requests', () => { + // fixme: use nock when https://github.com/nock/nock/issues/2949 is resolved + const slowFetch: FetchFunction = (_input, init) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => + resolve({ + ok: true, + status: 200, + statusText: 'OK', + url: 'https://slack.com/api/conversations.list', + headers: { get: () => null, entries: () => [][Symbol.iterator]() }, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + json: () => Promise.resolve({ ok: true }), + text: () => Promise.resolve('{"ok":true}'), + }), + 50, + ); + init?.signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(init.signal?.reason); + }); + }); + + it('cancels the request when the signal is aborted', async () => { + const client = new WebClient(token, { fetch: slowFetch }); + const controller = new AbortController(); + const { signal } = controller; + + setTimeout(() => { + controller.abort(); + }, 1); + + try { + await client.conversations.list({}, { signal }); + assert.fail('Expected error to be thrown'); + } catch (error) { + assert.ok(error instanceof Error); + } + }); + + it('completes the request when the signal is not aborted', async () => { + const client = new WebClient(token, { fetch: slowFetch }); + const controller = new AbortController(); + const { signal } = controller; + + try { + const response = await client.conversations.list({}, { signal }); + assert.equal(response.ok, true); + } catch (error) { + assert.fail(`Did not expect an error to be thrown: ${(error as Error).message}`); + } + }); + + it('uses the AbortSignal reason', async () => { + const client = new WebClient(token, { fetch: slowFetch }); + const controller = new AbortController(); + const { signal } = controller; + const abortReason = new Error('Abort reason'); + setTimeout(() => { + controller.abort(abortReason); + }, 1); + + try { + await client.conversations.list({}, { signal }); + assert.fail('Expected error to be thrown'); + } catch (error) { + assert.equal(error, abortReason); + } + }); + }); }); // Helpers diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index 674582c06..0a72ae2eb 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -254,7 +254,11 @@ export class WebClient extends Methods { * @param method - the Web API method to call {@link https://docs.slack.dev/reference/methods} * @param options - arguments for the Web API method */ - public async apiCall(method: string, options: Record = {}): Promise { + public async apiCall( + method: string, + options: Record = {}, + config?: { signal?: AbortSignal }, + ): Promise { this.logger.debug(`apiCall('${method}') start`); warnDeprecations(method, this.logger); @@ -279,6 +283,7 @@ export class WebClient extends Methods { ...options, }, headers, + config, ); const result = await this.buildResult(response); this.logger.debug(`http request result: ${JSON.stringify(result)}`); @@ -589,6 +594,7 @@ export class WebClient extends Methods { url: string, body: Record, headers: Record = {}, + options?: { signal?: AbortSignal }, ): Promise { const task = () => this.requestQueue.add(async () => { @@ -607,7 +613,11 @@ export class WebClient extends Methods { const controller = new AbortController(); const timer = this.timeout > 0 ? setTimeout(() => controller.abort(), this.timeout) : undefined; - const signal = timer ? controller.signal : undefined; + const userSignal = options?.signal; + const signals: AbortSignal[] = []; + if (timer) signals.push(controller.signal); + if (userSignal) signals.push(userSignal); + const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined; try { const response = await this.fetchFn(url, { @@ -668,6 +678,9 @@ export class WebClient extends Methods { if (error instanceof SlackError) { throw error; } + if (userSignal?.aborted && error === userSignal.reason) { + throw new AbortError(userSignal.reason); + } 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))); diff --git a/packages/web-api/src/methods.ts b/packages/web-api/src/methods.ts index 462060320..cfca6815d 100644 --- a/packages/web-api/src/methods.ts +++ b/packages/web-api/src/methods.ts @@ -547,9 +547,11 @@ import { type WebAPICallResult, WebClient, type WebClientEvent } from './WebClie */ type MethodWithRequiredArgument = ( options: MethodArguments, + config?: { signal?: AbortSignal }, ) => Promise; type MethodWithOptionalArgument = ( options?: MethodArguments, + config?: { signal?: AbortSignal }, ) => Promise; export default MethodWithOptionalArgument; @@ -596,7 +598,11 @@ export abstract class Methods extends EventEmitter { } } - public abstract apiCall(method: string, options?: Record): Promise; + public abstract apiCall( + method: string, + options?: Record, + config?: { signal?: AbortSignal }, + ): Promise; public abstract filesUploadV2(options: FilesUploadV2Arguments): Promise; public readonly admin = {