From 7ce2c4aaade76f0b95cf45bb567a5a1e662d499d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 2 Jun 2026 14:17:00 -0700 Subject: [PATCH 1/3] feat(webhook): add WebhookTrigger class for Workflow Builder triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new WebhookTrigger class that mirrors IncomingWebhook but handles Workflow Builder webhook triggers which return JSON responses with arbitrary payloads (vs plain text "ok" from incoming webhooks). - Constructor takes URL + defaults (timeout, agent) — same pattern - send() accepts arbitrary key-value payload, returns { ok, body } - Reuses existing error infrastructure and User-Agent instrumentation - Enables consumers like slack-github-action to use the SDK instead of raw fetch for WFB triggers Co-Authored-By: Claude --- packages/webhook/package.json | 4 +- packages/webhook/src/WebhookTrigger.test.ts | 128 ++++++++++++++++++++ packages/webhook/src/WebhookTrigger.ts | 106 ++++++++++++++++ packages/webhook/src/index.ts | 7 ++ 4 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 packages/webhook/src/WebhookTrigger.test.ts create mode 100644 packages/webhook/src/WebhookTrigger.ts diff --git a/packages/webhook/package.json b/packages/webhook/package.json index b9f8f6fb8..8349aa6ed 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -37,8 +37,8 @@ "build:clean": "shx rm -rf ./dist", "docs": "npx typedoc --plugin typedoc-plugin-markdown", "prepack": "npm run build", - "test": "npm run build && node --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/IncomingWebhook.test.ts", - "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" + "test": "npm run build && node --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/IncomingWebhook.test.ts src/WebhookTrigger.test.ts", + "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 src/WebhookTrigger.test.ts" }, "dependencies": { "@slack/types": "^2.20.1", diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts new file mode 100644 index 000000000..aed8706d1 --- /dev/null +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -0,0 +1,128 @@ +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 { WebhookTrigger } from './WebhookTrigger'; + +const url = 'https://hooks.slack.com/triggers/FAKETRIGGER'; + +describe('WebhookTrigger', () => { + afterEach(() => { + nock.cleanAll(); + }); + + describe('constructor()', () => { + it('should build a default webhook trigger given a URL', () => { + const trigger = new WebhookTrigger(url); + assert.ok(trigger instanceof WebhookTrigger); + }); + + it('should create a default webhook trigger with a default timeout', () => { + const trigger = new WebhookTrigger(url); + // biome-ignore lint/suspicious/noExplicitAny: accessing private property for test assertion + assert.strictEqual((trigger as any).defaults.timeout, 0); + }); + + it('should create an axios instance that has the timeout passed by the user', () => { + const givenTimeout = 100; + const trigger = new WebhookTrigger(url, { timeout: givenTimeout }); + // biome-ignore lint/suspicious/noExplicitAny: accessing private property for test assertion + assert.strictEqual((trigger as any).axios.defaults.timeout, givenTimeout); + }); + }); + + describe('send()', () => { + let trigger: WebhookTrigger; + beforeEach(() => { + trigger = new WebhookTrigger(url); + }); + + describe('when making a successful call', () => { + let scope: nock.Scope; + beforeEach(() => { + scope = nock('https://hooks.slack.com') + .post(/triggers/) + .reply(200, { ok: true }); + }); + + it('should return results in a Promise', async () => { + const result = await trigger.send({ key: 'value' }); + assert.strictEqual(result.ok, true); + assert.deepStrictEqual(result.body, { ok: true }); + scope.done(); + }); + }); + + describe('when the response contains additional data', () => { + let scope: nock.Scope; + beforeEach(() => { + scope = nock('https://hooks.slack.com') + .post(/triggers/) + .reply(200, { ok: true, workflow_run_id: 'WFR123' }); + }); + + it('should include the full response body', async () => { + const result = await trigger.send({ input: 'data' }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.body.workflow_run_id, 'WFR123'); + scope.done(); + }); + }); + + describe('when the call fails', () => { + let statusCode: number; + let scope: nock.Scope; + beforeEach(() => { + statusCode = 500; + scope = nock('https://hooks.slack.com') + .post(/triggers/) + .reply(statusCode); + }); + + it('should return a Promise which rejects on error', async () => { + try { + await trigger.send({ key: 'value' }); + assert.fail('expected rejection'); + } catch (error) { + assert.ok(error); + assert.ok(error instanceof Error); + assert.match((error as Error).message, new RegExp(String(statusCode))); + scope.done(); + } + }); + + it('should fail with RequestError when the API request fails', async () => { + const trigger = new WebhookTrigger('https://localhost:8999/api/'); + try { + await trigger.send({ key: 'value' }); + assert.fail('expected rejection'); + } catch (error) { + assert.ok(error instanceof Error); + assert.strictEqual((error as CodedError).code, ErrorCode.RequestError); + } + }); + }); + + describe('User-Agent header', () => { + it('should send the User-Agent header with every request', async () => { + const scope = nock('https://hooks.slack.com', { + reqheaders: { + 'User-Agent': (value) => { + return /@slack:webhook/.test(value); + }, + }, + }) + .post(/triggers/) + .reply(200, { ok: true }); + try { + const trigger = new WebhookTrigger(url); + await trigger.send({ key: 'value' }); + } finally { + scope.done(); + } + }); + }); + }); +}); diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts new file mode 100644 index 000000000..ad3d98563 --- /dev/null +++ b/packages/webhook/src/WebhookTrigger.ts @@ -0,0 +1,106 @@ +import type { Agent } from 'node:http'; + +import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; + +import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors'; +import { getUserAgent } from './instrument'; + +/** + * A client for Slack's Workflow Builder webhook triggers + * @see {@link https://docs.slack.dev/workflows/triggers/webhook} + */ +export class WebhookTrigger { + /** + * The webhook trigger URL + */ + private url: string; + + /** + * Default arguments for sending to this webhook trigger + */ + private defaults: WebhookTriggerDefaultArguments; + + /** + * Axios HTTP client instance used by this client + */ + private axios: AxiosInstance; + + public constructor( + url: string, + defaults: WebhookTriggerDefaultArguments = { + timeout: 0, + }, + ) { + if (url === undefined) { + throw new Error('Webhook trigger URL is required'); + } + + 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: { + 'Content-Type': 'application/json', + 'User-Agent': getUserAgent(), + }, + }); + + this.defaults.agent = undefined; + } + + /** + * Send a payload to the webhook trigger + * @param payload - arbitrary key-value data to send to the trigger + */ + public async send(payload: WebhookTriggerSendArguments): Promise { + 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) { + if (error.response !== undefined) { + throw httpErrorWithOriginal(error); + } + if (error.request !== undefined) { + throw requestErrorWithOriginal(error); + } + throw error; + } + } + + /** + * Processes an HTTP response into a WebhookTriggerResult. + */ + private buildResult(response: AxiosResponse): WebhookTriggerResult { + const body = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + return { + ok: body.ok ?? true, + body, + }; + } +} + +/* + * Exported types + */ + +export interface WebhookTriggerDefaultArguments { + agent?: Agent; + timeout?: number; +} + +export interface WebhookTriggerSendArguments { + [key: string]: unknown; +} + +export interface WebhookTriggerResult { + ok: boolean; + // biome-ignore lint/suspicious/noExplicitAny: webhook trigger responses are untyped + body: Record; +} diff --git a/packages/webhook/src/index.ts b/packages/webhook/src/index.ts index 74420ffba..7517027ba 100644 --- a/packages/webhook/src/index.ts +++ b/packages/webhook/src/index.ts @@ -14,3 +14,10 @@ export { IncomingWebhookResult, IncomingWebhookSendArguments, } from './IncomingWebhook'; + +export { + WebhookTrigger, + WebhookTriggerDefaultArguments, + WebhookTriggerResult, + WebhookTriggerSendArguments, +} from './WebhookTrigger'; From 89c7af2b1fb3de659da3cec1ff72365bf97ce00c Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 2 Jun 2026 14:20:02 -0700 Subject: [PATCH 2/3] docs(webhook): update WebhookTrigger @see link to JSODC article Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index ad3d98563..8c75444e7 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -7,7 +7,7 @@ import { getUserAgent } from './instrument'; /** * A client for Slack's Workflow Builder webhook triggers - * @see {@link https://docs.slack.dev/workflows/triggers/webhook} + * @see {@link https://slack.com/help/articles/360041352714-Build-a-workflow--Create-a-workflow-that-starts-outside-of-Slack} */ export class WebhookTrigger { /** From 0aab45b61d68b73e156bd4687cfc814b3ce6b850 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 2 Jun 2026 14:40:12 -0700 Subject: [PATCH 3/3] fix(webhook): constrain WebhookTriggerSendArguments values to string Workflow Builder webhook trigger inputs are always string values. Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index 8c75444e7..30929fe45 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -96,7 +96,7 @@ export interface WebhookTriggerDefaultArguments { } export interface WebhookTriggerSendArguments { - [key: string]: unknown; + [key: string]: string; } export interface WebhookTriggerResult {