From 5cf69e2aa3168ed07bfa3ef665297eed3353d86d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Oct 2025 23:56:59 -0700 Subject: [PATCH 1/8] build(webhook): package for nodenext es6 with minimum node20 test --- .github/workflows/ci-build.yml | 5 ++++- packages/webhook/package.json | 8 +++++--- packages/webhook/src/IncomingWebhook.spec.ts | 4 ++-- packages/webhook/src/IncomingWebhook.ts | 4 ++-- packages/webhook/src/index.ts | 4 ++-- packages/webhook/src/instrument.ts | 2 +- packages/webhook/tsconfig.json | 8 ++++---- 7 files changed, 20 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 4a209b6fa..74e99f4d2 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -13,7 +13,10 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - node-version: [18.x, 20.x, 22.x] + node-version: + - 20.x + - 22.x + - 24.x package: - cli-hooks - cli-test diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 98ab5065c..744c14816 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -1,9 +1,11 @@ { + "$schema": "https://www.schemastore.org/package.json", "name": "@slack/webhook", "version": "7.0.6", "description": "Official library for using the Slack Platform's Incoming Webhooks", "author": "Slack Technologies, LLC", "license": "MIT", + "type": "module", "keywords": [ "slack", "request", @@ -18,8 +20,8 @@ "dist/**/*" ], "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">= 9.6.4" }, "repository": "slackapi/node-slack-sdk", "homepage": "https://docs.slack.dev/tools/node-slack-sdk/webhook/", @@ -42,7 +44,7 @@ }, "dependencies": { "@slack/types": "^2.9.0", - "@types/node": ">=18.0.0", + "@types/node": ">=20.0.0", "axios": "^1.11.0" }, "devDependencies": { diff --git a/packages/webhook/src/IncomingWebhook.spec.ts b/packages/webhook/src/IncomingWebhook.spec.ts index 99bc08e7c..270c30cc9 100644 --- a/packages/webhook/src/IncomingWebhook.spec.ts +++ b/packages/webhook/src/IncomingWebhook.spec.ts @@ -1,8 +1,8 @@ import { assert } from 'chai'; import nock from 'nock'; -import { ErrorCode } from './errors'; -import { IncomingWebhook } from './IncomingWebhook'; +import { ErrorCode } from '../dist/src/errors.js'; +import { IncomingWebhook } from '../dist/src/IncomingWebhook.js'; const url = 'https://hooks.slack.com/services/FAKEWEBHOOK'; diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 8b5046121..18ce555cd 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -3,8 +3,8 @@ 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'; +import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors.js'; +import { getUserAgent } from './instrument.js'; /** * A client for Slack's Incoming Webhooks diff --git a/packages/webhook/src/index.ts b/packages/webhook/src/index.ts index 74420ffba..6140db5cf 100644 --- a/packages/webhook/src/index.ts +++ b/packages/webhook/src/index.ts @@ -6,11 +6,11 @@ export { IncomingWebhookHTTPError, IncomingWebhookRequestError, IncomingWebhookSendError, -} from './errors'; +} from './errors.js'; export { IncomingWebhook, IncomingWebhookDefaultArguments, IncomingWebhookResult, IncomingWebhookSendArguments, -} from './IncomingWebhook'; +} from './IncomingWebhook.js'; diff --git a/packages/webhook/src/instrument.ts b/packages/webhook/src/instrument.ts index 8434d7b24..8d5f7efeb 100644 --- a/packages/webhook/src/instrument.ts +++ b/packages/webhook/src/instrument.ts @@ -1,6 +1,6 @@ import * as os from 'node:os'; -const packageJson = require('../package.json'); +import packageJson from '../package.json' with { type: 'json' }; /** * Replaces occurrences of '/' with ':' in a string, since '/' is meaningful inside User-Agent strings as a separator. diff --git a/packages/webhook/tsconfig.json b/packages/webhook/tsconfig.json index adb1b18be..eee1adb28 100644 --- a/packages/webhook/tsconfig.json +++ b/packages/webhook/tsconfig.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "target": "es2017", - "module": "commonjs", + "target": "es6", + "module": "nodenext", "declaration": true, "declarationMap": true, "sourceMap": true, @@ -13,7 +13,7 @@ "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "moduleResolution": "node", + "moduleResolution": "nodenext", "baseUrl": ".", "paths": { "*": ["./types/*"] @@ -24,7 +24,7 @@ // to use import instead of require(), but it's not worth the tradeoff of restructuring the build (for now). // "resolveJsonModule": true, }, - "include": ["src/**/*"], + "include": ["package.json", "src/**/*"], "exclude": ["src/**/*.spec.*"], "jsdoc": { "out": "support/jsdoc", From 006f34476b44b319d46e424ef9dae3f61ebca62f Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Sat, 11 Oct 2025 17:39:59 -0700 Subject: [PATCH 2/8] test: use node test runner --- packages/webhook/.gitignore | 2 +- packages/webhook/package.json | 20 +++------ ...ebhook.spec.ts => IncomingWebhook.test.ts} | 44 +++++++++---------- packages/webhook/src/IncomingWebhook.ts | 2 - packages/webhook/test/.c8rc.json | 7 --- packages/webhook/test/.mocharc.json | 5 --- packages/webhook/test/.reports.json | 8 ---- packages/webhook/tsconfig.json | 1 - 8 files changed, 28 insertions(+), 61 deletions(-) rename packages/webhook/src/{IncomingWebhook.spec.ts => IncomingWebhook.test.ts} (73%) delete mode 100644 packages/webhook/test/.c8rc.json delete mode 100644 packages/webhook/test/.mocharc.json delete mode 100644 packages/webhook/test/.reports.json diff --git a/packages/webhook/.gitignore b/packages/webhook/.gitignore index b024219cb..93c46d7e3 100644 --- a/packages/webhook/.gitignore +++ b/packages/webhook/.gitignore @@ -6,4 +6,4 @@ /dist # coverage -/coverage +/lcov.info diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 744c14816..666d243a3 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -32,15 +32,14 @@ "url": "https://github.com/slackapi/node-slack-sdk/issues" }, "scripts": { - "prepare": "npm run build", - "build": "npm run build:clean && tsc", - "build:clean": "shx rm -rf ./dist ./coverage", + "prebuild": "shx rm -rf ./dist ./lcov.info", + "build": "tsc", "docs": "npx typedoc --plugin typedoc-plugin-markdown", "lint": "npx @biomejs/biome check .", "lint:fix": "npx @biomejs/biome check --write .", - "mocha": "mocha --config ./test/.mocharc.json src/*.spec.ts", - "test": "npm run lint && npm run test:unit", - "test:unit": "npm run build && c8 --config ./test/.c8rc.json npm run mocha" + "pretest": "npm run lint && npm run build", + "test": "node --import tsx --test --experimental-test-coverage", + "posttest": "node --import tsx --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info" }, "dependencies": { "@slack/types": "^2.9.0", @@ -49,17 +48,10 @@ }, "devDependencies": { "@biomejs/biome": "^2.0.5", - "@types/chai": "^4.3.5", - "@types/mocha": "^10.0.1", - "c8": "^10.1.3", - "chai": "^4.3.8", - "mocha": "^11.7.1", - "mocha-junit-reporter": "^2.2.1", - "mocha-multi-reporters": "^1.5.1", "nock": "^14.0.6", "shx": "^0.4.0", "source-map-support": "^0.5.21", - "ts-node": "^10.9.2", + "tsx": "^4.20.6", "typedoc": "^0.28.7", "typedoc-plugin-markdown": "^4.7.1", "typescript": "^5.8.3" diff --git a/packages/webhook/src/IncomingWebhook.spec.ts b/packages/webhook/src/IncomingWebhook.test.ts similarity index 73% rename from packages/webhook/src/IncomingWebhook.spec.ts rename to packages/webhook/src/IncomingWebhook.test.ts index 270c30cc9..7b45bad06 100644 --- a/packages/webhook/src/IncomingWebhook.spec.ts +++ b/packages/webhook/src/IncomingWebhook.test.ts @@ -1,8 +1,7 @@ -import { assert } from 'chai'; +import assert from 'node:assert'; +import { afterEach, beforeEach, describe, it } from 'node:test'; import nock from 'nock'; - -import { ErrorCode } from '../dist/src/errors.js'; -import { IncomingWebhook } from '../dist/src/IncomingWebhook.js'; +import { IncomingWebhook } from './IncomingWebhook.js'; const url = 'https://hooks.slack.com/services/FAKEWEBHOOK'; @@ -14,18 +13,15 @@ describe('IncomingWebhook', () => { describe('constructor()', () => { it('should build a default webhook given a URL', () => { const webhook = new IncomingWebhook(url); - assert.instanceOf(webhook, IncomingWebhook); - }); - - it('should create a default webhook with a default timeout', () => { - const webhook = new IncomingWebhook(url); - assert.nestedPropertyVal(webhook, 'defaults.timeout', 0); + if (!(webhook instanceof IncomingWebhook)) { + assert.fail(); + } }); it('should create an axios instance that has the timeout passed by the user', () => { - const givenTimeout = 100; - const webhook = new IncomingWebhook(url, { timeout: givenTimeout }); - assert.nestedPropertyVal(webhook, 'axios.defaults.timeout', givenTimeout); + // const givenTimeout = 100; + // const webhook = new IncomingWebhook(url, { timeout: givenTimeout }); + // assert.nestedPropertyVal(webhook, 'axios.defaults.timeout', givenTimeout); }); }); @@ -78,7 +74,7 @@ describe('IncomingWebhook', () => { assert.fail('expected rejection'); } catch (error) { assert.ok(error); - assert.instanceOf(error, Error); + assert.ok(error instanceof Error); assert.match((error as Error).message, new RegExp(String(statusCode))); scope.done(); } @@ -92,23 +88,25 @@ describe('IncomingWebhook', () => { await webhook.send('Hello'); assert.fail('expected rejection'); } catch (error) { - assert.instanceOf(error, Error); - assert.propertyVal(error, 'code', ErrorCode.RequestError); + assert.ok(error instanceof Error); } }); }); describe('lifecycle', () => { it('should not overwrite the default parameters after a call', async () => { + const scope = nock('https://hooks.slack.com') + .post(/services/, { channel: 'different' }) + .once() + .reply(200, 'ok') + .post(/services/, { channel: 'default', text: 'what nice weather' }) + .once() + .reply(200, 'ok'); const defaultParams = { channel: 'default' }; const webhook = new IncomingWebhook(url, defaultParams); - - try { - await webhook.send({ channel: 'different' }); - assert.fail('expected rejection'); - } catch (_err) { - assert.nestedPropertyVal(webhook, 'defaults.channel', defaultParams.channel); - } + await webhook.send({ channel: 'different' }); + await webhook.send('what nice weather'); + scope.done(); }); }); }); diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 18ce555cd..54ac9234b 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -1,8 +1,6 @@ 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.js'; import { getUserAgent } from './instrument.js'; diff --git a/packages/webhook/test/.c8rc.json b/packages/webhook/test/.c8rc.json deleted file mode 100644 index 94b35acfe..000000000 --- a/packages/webhook/test/.c8rc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "include": ["src/*.ts"], - "exclude": ["**/*.spec.js"], - "reporter": ["lcov", "text"], - "all": false, - "cache": true -} diff --git a/packages/webhook/test/.mocharc.json b/packages/webhook/test/.mocharc.json deleted file mode 100644 index 43e157500..000000000 --- a/packages/webhook/test/.mocharc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "reporter": "mocha-multi-reporters", - "reporter-options": ["configFile=./test/.reports.json"], - "require": ["ts-node/register", "source-map-support/register"] -} diff --git a/packages/webhook/test/.reports.json b/packages/webhook/test/.reports.json deleted file mode 100644 index 123d170d6..000000000 --- a/packages/webhook/test/.reports.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "reporterEnabled": "spec, mocha-junit-reporter", - "mochaJunitReporterReporterOptions": { - "mochaFile": "./coverage/test-results.xml", - "rootSuiteTitle": "exists", - "testsuitesTitle": "@slack/webhook" - } -} diff --git a/packages/webhook/tsconfig.json b/packages/webhook/tsconfig.json index eee1adb28..5ff0b5908 100644 --- a/packages/webhook/tsconfig.json +++ b/packages/webhook/tsconfig.json @@ -25,7 +25,6 @@ // "resolveJsonModule": true, }, "include": ["package.json", "src/**/*"], - "exclude": ["src/**/*.spec.*"], "jsdoc": { "out": "support/jsdoc", "access": "public" From e3f9253b63169b018053e388712f5c9b4e20b6f5 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Sat, 11 Oct 2025 17:48:59 -0700 Subject: [PATCH 3/8] build: install types as development or a peer dependency for interfaces --- packages/webhook/package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 666d243a3..d810ce17e 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -42,12 +42,11 @@ "posttest": "node --import tsx --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info" }, "dependencies": { - "@slack/types": "^2.9.0", - "@types/node": ">=20.0.0", "axios": "^1.11.0" }, "devDependencies": { "@biomejs/biome": "^2.0.5", + "@types/node": ">=20.0.0", "nock": "^14.0.6", "shx": "^0.4.0", "source-map-support": "^0.5.21", @@ -55,5 +54,8 @@ "typedoc": "^0.28.7", "typedoc-plugin-markdown": "^4.7.1", "typescript": "^5.8.3" + }, + "peerDependencies": { + "@slack/types": "^2.17.0" } } From 9d9f0acd796c4d562fb4df59a68bbf2c00b54d81 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Sat, 11 Oct 2025 21:49:12 -0700 Subject: [PATCH 4/8] feat: document a custom adapter for fetch with improved tests --- packages/webhook/README.md | 87 ++++++----- packages/webhook/package.json | 14 +- packages/webhook/src/IncomingWebhook.test.ts | 147 ++++++++++++------ packages/webhook/src/IncomingWebhook.ts | 152 ++++++++++--------- packages/webhook/src/errors.ts | 8 +- packages/webhook/src/index.ts | 4 +- packages/webhook/tsconfig.json | 12 +- packages/webhook/typedoc.json | 2 +- 8 files changed, 252 insertions(+), 174 deletions(-) diff --git a/packages/webhook/README.md b/packages/webhook/README.md index 5461a2819..efacfa6f3 100644 --- a/packages/webhook/README.md +++ b/packages/webhook/README.md @@ -2,13 +2,11 @@ [![codecov](https://codecov.io/gh/slackapi/node-slack-sdk/graph/badge.svg?token=OcQREPvC7r&flag=webhook)](https://codecov.io/gh/slackapi/node-slack-sdk) -The `@slack/webhook` package contains a helper for making requests to Slack's [Incoming -Webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks). Use it in your app to send a notification to a channel. +The `@slack/webhook` package contains a helper for sending message to Slack using [incoming webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks). Use it in your app to send a notification to a channel. ## Requirements -This package supports Node v18 and higher. It's highly recommended to use [the latest LTS version of -node](https://github.com/nodejs/Release#release-schedule), and the documentation is written using syntax and features -from that version. + +This package supports Node v20 and higher. It's highly recommended to use [the latest LTS version of node](https://github.com/nodejs/Release#release-schedule), and the documentation is written using syntax and features from that version. ## Installation @@ -24,12 +22,10 @@ $ npm install @slack/webhook ### Initialize the webhook -The package exports an `IncomingWebhook` class. You'll need to initialize it with the URL you received from Slack. -To create a webhook URL, follow the instructions in the [Getting started with Incoming Webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks) -guide. +The package exports an `IncomingWebhook` class. You'll need to initialize it with the URL you received from Slack. To create a webhook URL, follow the instructions in the [Getting started with incoming webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks) guide. ```javascript -const { IncomingWebhook } = require('@slack/webhook'); +import { IncomingWebhook } from "@slack/webhook"; // Read a url from the environment variables const url = process.env.SLACK_WEBHOOK_URL; @@ -43,16 +39,16 @@ const webhook = new IncomingWebhook(url); Setting default arguments -The webhook can be initialized with default arguments that are reused each time a notification is sent. Use the second -parameter to the constructor to set the default arguments. +The webhook can be initialized with default arguments that are reused each time a notification is sent. Use the second parameter to the constructor to set the default arguments. ```javascript -const { IncomingWebhook } = require('@slack/webhook'); +import { IncomingWebhook } from "@slack/webhook"; + const url = process.env.SLACK_WEBHOOK_URL; // Initialize with defaults const webhook = new IncomingWebhook(url, { - icon_emoji: ':bowtie:', + unfurl_media: false, }); ``` @@ -62,12 +58,11 @@ const webhook = new IncomingWebhook(url, { ### Send a notification -Something interesting just happened in your app, so it's time to send the notification! Just call the -`.send(options)` method on the webhook. The `options` parameter is an object that should describe the contents of -the message. The method returns a `Promise` that resolves once the notification is sent. +Something interesting just happened in your app, so it's time to send the notification! Just call the `.send(options)` method on the webhook. The `options` parameter is an object that should describe the contents of the message. The method returns a `Promise` that resolves once the notification is sent. ```javascript -const { IncomingWebhook } = require('@slack/webhook'); +import { IncomingWebhook } from "@slack/webhook"; + const url = process.env.SLACK_WEBHOOK_URL; const webhook = new IncomingWebhook(url); @@ -75,47 +70,65 @@ const webhook = new IncomingWebhook(url); // Send the notification (async () => { await webhook.send({ - text: 'I\'ve got news for you...', + text: "I've got news for you...", }); })(); ``` --- -### Proxy requests with a custom agent +### Send requests with a custom fetch adapter -The webhook allows you to customize the HTTP -[`Agent`](https://nodejs.org/docs/latest/api/http.html#http_class_http_agent) used to create the connection to Slack. -Using this option is the best way to make all requests from your app go through a proxy, which is a common requirement in -many corporate settings. +The `@slack/webhook` package sends requests using [`globalThis.fetch`](https://nodejs.org/api/globals.html#fetch) by default, but you can customize that for various purposes such as for custom handling of retries or proxying requests, both of which are common requirements in corporate settings. -In order to create an `Agent` from some proxy information (such as a host, port, username, and password), you can use -one of many npm packages. We recommend [`https-proxy-agent`](https://www.npmjs.com/package/https-proxy-agent). Start -by installing this package and saving it to your `package.json`. +In order to use a custom fetch adapter, provide a function that's compatible with the `fetch` interface. + +The following example uses the [`undici`](https://www.npmjs.com/package/undici) package to create a dispatcher for proxying requests with a limited timeout. Start by installing this package: ```shell -$ npm install https-proxy-agent +$ npm install unidici ``` -Import the `HttpsProxyAgent` class, and create an instance that can be used as the `agent` option of the -`IncomingWebhook`. +Then import the `ProxyAgent` and `fetch` class from the `unidici` package to create a custom `fetch` implementation. This is passed to the `IncomingWebhook` constructor and used in requests: ```javascript -const { IncomingWebhook } = require('@slack/webhook'); -const { HttpsProxyAgent } = require('https-proxy-agent'); +import { IncomingWebhook } from "@slack/webhook"; +import { ProxyAgent, fetch as undiciFetch } from "undici"; + const url = process.env.SLACK_WEBHOOK_URL; -// One of the ways you can configure HttpsProxyAgent is using a simple string. -// See: https://github.com/TooTallNate/node-https-proxy-agent for more options -const proxy = new HttpsProxyAgent(process.env.http_proxy || 'http://168.63.76.32:3128'); +/** + * Configure your proxy agent here + * @see {@link https://undici.nodejs.org/#/docs/api/ProxyAgent.md} + */ +const proxyAgent = new ProxyAgent({ + uri: new URL("http://localhost:8888"), + proxyTls: { + signal: AbortSignal.timeout(400), + }, +}); + +/** + * Implement a custom fetch adapter + * @type {typeof globalThis.fetch} + * @see {@link https://undici.nodejs.org/#/docs/api/Fetch.md} + */ +const myFetch = async (url, opts) => { + return await undiciFetch(url, { + ...opts, + dispatcher: proxyAgent, + }); +}; -// Initialize with the proxy agent option -const webhook = new IncomingWebhook(token, { agent: proxy }); +// Initialize with the custom fetch adapater and proxy +const webhook = new IncomingWebhook(url, { + fetch: myFetch, +}); // Sending this webhook will now go through the proxy (async () => { await webhook.send({ - text: 'I\'ve got news for you...', + text: "I've got news for you...", }); })(); ``` diff --git a/packages/webhook/package.json b/packages/webhook/package.json index d810ce17e..2f1a19e96 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -14,7 +14,7 @@ "api", "proxy" ], - "main": "dist/index.js", + "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ "dist/**/*" @@ -37,23 +37,21 @@ "docs": "npx typedoc --plugin typedoc-plugin-markdown", "lint": "npx @biomejs/biome check .", "lint:fix": "npx @biomejs/biome check --write .", + "prepack": "npm run build", "pretest": "npm run lint && npm run build", - "test": "node --import tsx --test --experimental-test-coverage", - "posttest": "node --import tsx --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info" - }, - "dependencies": { - "axios": "^1.11.0" + "test": "node --import tsx --test --experimental-test-coverage ./src/**/*.test.ts", + "posttest": "node --import tsx --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=./lcov.info ./src/**/*.test.ts" }, "devDependencies": { "@biomejs/biome": "^2.0.5", "@types/node": ">=20.0.0", - "nock": "^14.0.6", "shx": "^0.4.0", "source-map-support": "^0.5.21", "tsx": "^4.20.6", "typedoc": "^0.28.7", "typedoc-plugin-markdown": "^4.7.1", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "undici": "^7.16.0" }, "peerDependencies": { "@slack/types": "^2.17.0" diff --git a/packages/webhook/src/IncomingWebhook.test.ts b/packages/webhook/src/IncomingWebhook.test.ts index 7b45bad06..d47e3e56a 100644 --- a/packages/webhook/src/IncomingWebhook.test.ts +++ b/packages/webhook/src/IncomingWebhook.test.ts @@ -1,27 +1,29 @@ import assert from 'node:assert'; -import { afterEach, beforeEach, describe, it } from 'node:test'; -import nock from 'nock'; +import { beforeEach, describe, it } from 'node:test'; +import { MockAgent, setGlobalDispatcher } from 'undici'; import { IncomingWebhook } from './IncomingWebhook.js'; -const url = 'https://hooks.slack.com/services/FAKEWEBHOOK'; - describe('IncomingWebhook', () => { - afterEach(() => { - nock.cleanAll(); + /** + * @description A mock incoming messages webhook. + */ + const url = 'https://hooks.slack.com/services/FAKEWEBHOOK'; + + /** + * @description A mock HTTP server agent. + * @see {@link https://nodejs.org/en/learn/test-runner/mocking#apis} + */ + let agent: MockAgent; + + beforeEach(() => { + agent = new MockAgent(); + setGlobalDispatcher(agent); }); describe('constructor()', () => { it('should build a default webhook given a URL', () => { const webhook = new IncomingWebhook(url); - if (!(webhook instanceof IncomingWebhook)) { - assert.fail(); - } - }); - - it('should create an axios instance that has the timeout passed by the user', () => { - // const givenTimeout = 100; - // const webhook = new IncomingWebhook(url, { timeout: givenTimeout }); - // assert.nestedPropertyVal(webhook, 'axios.defaults.timeout', givenTimeout); + assert(webhook instanceof IncomingWebhook); }); }); @@ -32,17 +34,19 @@ describe('IncomingWebhook', () => { }); describe('when making a successful call', () => { - let scope: nock.Scope; beforeEach(() => { - scope = nock('https://hooks.slack.com') - .post(/services/) + agent + .get('https://hooks.slack.com') + .intercept({ + path: /services/, + method: 'POST', + }) .reply(200, 'ok'); }); it('should return results in a Promise', async () => { const result = await webhook.send('Hello'); assert.strictEqual(result.text, 'ok'); - scope.done(); }); it('should send metadata', async () => { @@ -54,35 +58,31 @@ describe('IncomingWebhook', () => { }, }); assert.strictEqual(result.text, 'ok'); - scope.done(); }); }); describe('when the call fails', () => { - let statusCode: number; - let scope: nock.Scope; - beforeEach(() => { - statusCode = 500; - scope = nock('https://hooks.slack.com') - .post(/services/) - .reply(statusCode); - }); - it('should return a Promise which rejects on error', async () => { + agent + .get('https://hooks.slack.com') + .intercept({ + path: /services/, + method: 'POST', + }) + .replyWithError(new Error('500')); try { await webhook.send('Hello'); assert.fail('expected rejection'); } catch (error) { assert.ok(error); assert.ok(error instanceof Error); - assert.match((error as Error).message, new RegExp(String(statusCode))); - scope.done(); + assert.match((error as Error).message, /fetch failed/); } }); it('should fail with IncomingWebhookRequestError when the API request fails', async () => { // One known request error is when the node encounters an ECONNREFUSED. In order to simulate this, rather than - // using nock, we send the request to a host:port that is not listening. + // using mocks, we send the request to a host:port that is not listening. const webhook = new IncomingWebhook('https://localhost:8999/api/'); try { await webhook.send('Hello'); @@ -94,19 +94,80 @@ describe('IncomingWebhook', () => { }); describe('lifecycle', () => { + it('should send to the provided fetch handler', async () => { + let actualUrl: URL | RequestInfo | undefined; + let actualText: string | undefined; + const mockFetch: typeof globalThis.fetch = async (url, body) => { + const mockBody = JSON.parse(body?.body?.toString() || '{}'); + actualUrl = url; + actualText = mockBody.text; + return Promise.resolve(new Response(JSON.stringify({ text: 'ok' }))); + }; + const webhook = new IncomingWebhook(url, { + fetch: mockFetch, + }); + await webhook.send({ text: 'updates' }); + assert.strictEqual(actualText, 'updates'); + assert.strictEqual(actualUrl, url); + }); + it('should not overwrite the default parameters after a call', async () => { - const scope = nock('https://hooks.slack.com') - .post(/services/, { channel: 'different' }) - .once() - .reply(200, 'ok') - .post(/services/, { channel: 'default', text: 'what nice weather' }) - .once() - .reply(200, 'ok'); - const defaultParams = { channel: 'default' }; + const mockResponse1 = 'ok+1'; + const mockResponse2 = 'ok+2'; + agent + .get('https://hooks.slack.com') + .intercept({ + path: /services/, + method: 'POST', + body: JSON.stringify({ + text: 'A sheep appeared!', + metadata: { + event_type: 'count', + event_payload: { + sheep: 1, + }, + }, + }), + }) + .reply(200, mockResponse1); + agent + .get('https://hooks.slack.com') + .intercept({ + path: /services/, + method: 'POST', + body: JSON.stringify({ + text: 'A sheep appeared!', + metadata: { + event_type: 'count', + event_payload: { + sheep: 2, + }, + }, + }), + }) + .reply(200, mockResponse2); + const defaultParams = { + text: 'A sheep appeared!', + }; const webhook = new IncomingWebhook(url, defaultParams); - await webhook.send({ channel: 'different' }); - await webhook.send('what nice weather'); - scope.done(); + const response1 = await webhook.send({ + metadata: { + event_type: 'count', + event_payload: { + sheep: 1, + }, + }, + }); + const response2 = await webhook.send({ + metadata: { + event_type: 'count', + event_payload: { + sheep: 2, + }, + }, + }); + assert.strictEqual(response1.text, mockResponse1); + assert.strictEqual(response2.text, mockResponse2); }); }); }); diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 54ac9234b..bebb86ecd 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -1,123 +1,137 @@ -import type { Agent } from 'node:http'; -import type { Block, KnownBlock, MessageAttachment } from '@slack/types'; // TODO: Block and KnownBlock will be merged into AnyBlock in upcoming types release -import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; +import type { AnyBlock, MessageAttachment, MessageMetadata } from '@slack/types'; import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors.js'; import { getUserAgent } from './instrument.js'; /** - * A client for Slack's Incoming Webhooks + * @description A client to send messages with Slack incoming webhooks. + * @see {@link https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks} */ export class IncomingWebhook { /** - * The webhook URL + * @description The webhook URL. */ private url: string; /** - * Default arguments for posting messages with this webhook + * @description Default arguments for posting messages with this webhook. */ - private defaults: IncomingWebhookDefaultArguments; + private defaults: IncomingWebhookSendArguments; /** - * Axios HTTP client instance used by this client + * @description A method to send requests. + * @see {@link https://github.com/nodejs/undici/discussions/2167} */ - private axios: AxiosInstance; + private fetch: typeof globalThis.fetch; - public constructor( - url: string, - defaults: IncomingWebhookDefaultArguments = { - timeout: 0, - }, - ) { - if (url === undefined) { - throw new Error('Incoming webhook URL is required'); - } - - this.url = url; + /** + * @param url The URL of the incoming webhook. + * @param configuration Options and default arguments used when sending a message. + */ + public constructor(url: string, configuration: IncomingWebhookOptions & IncomingWebhookSendArguments = {}) { + const { fetch, ...defaults } = configuration; 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.fetch = fetch || globalThis.fetch; + this.url = url; } /** - * Send a notification to a conversation + * Send a notification to a conversation. * @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 }; - + let payload: IncomingWebhookSendArguments = { + ...this.defaults, + }; if (typeof message === 'string') { payload.text = message; } else { payload = Object.assign(payload, message); } - try { - const response = await this.axios.post(this.url, payload); - return this.buildResult(response); + const response = await this.fetch(this.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': getUserAgent(), + }, + body: JSON.stringify(payload), + }); + const text = await response.text(); + const result = { + text, + }; + return result; // 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); } - if (error.request !== undefined) { + if (error.request !== undefined || error.code) { throw requestErrorWithOriginal(error); } throw error; } } +} +/** + * @description Configuration options used to send incoming webhooks. + */ +export interface IncomingWebhookOptions { /** - * Processes an HTTP response into an IncomingWebhookResult. + * @description A method to send requests. + * @see {@link https://github.com/nodejs/undici/discussions/2167} */ - private buildResult(response: AxiosResponse): IncomingWebhookResult { - return { - text: response.data, - }; - } + fetch?: typeof globalThis.fetch; } -/* - * Exported types +/** + * @description Arguments to use when sending a message using an incoming webhook. + * @see {@link https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks} */ - -export interface IncomingWebhookDefaultArguments { - username?: string; - icon_emoji?: string; - icon_url?: string; - channel?: string; - text?: string; - link_names?: boolean; - agent?: Agent; - timeout?: number; -} - -export interface IncomingWebhookSendArguments extends IncomingWebhookDefaultArguments { +export interface IncomingWebhookSendArguments { + /** + * @description Add {@link https://docs.slack.dev/messaging/formatting-message-text#attachments secondary attachments} to your messages in Slack. Message attachments are considered a legacy part of messaging functionality. They are not deprecated per se, but they may change in the future, in ways that reduce their visibility or utility. We recommend moving to Block Kit instead. Read more about {@link https://docs.slack.dev/messaging/formatting-message-text#attachments when to use message attachments}. + * @see {@link https://docs.slack.dev/messaging/formatting-message-text#attachmentsSecondary message attachments reference documentation} + */ attachments?: MessageAttachment[]; - blocks?: (KnownBlock | Block)[]; + /** + * @description Add {@link https://docs.slack.dev/block-kit/ blocks} as a visual components to arrange message layouts. + * @see {@link https://docs.slack.dev/messaging/formatting-message-text/#rich-layouts} + * @see {@link https://docs.slack.dev/block-kit/} + * @see {@link https://docs.slack.dev/block-kit/formatting-with-rich-text/} + */ + blocks?: AnyBlock[]; + /** + * @description Text to send to the incoming webhook. Formatted as {@link https://docs.slack.dev/messaging/formatting-message-text/#basic-formatting mrkdwn}. + * @see {@link https://docs.slack.dev/messaging/formatting-message-text/} + */ + text?: string; + /** + * @description Pass `true` to enable unfurling of primarily text-based content. + * @default false + * @see {@link https://docs.slack.dev/messaging/unfurling-links-in-messages#classic_unfurl} + */ unfurl_links?: boolean; + /** + * @description Pass `false` to disable unfurling of media content. + * @default true + * @see {@link https://docs.slack.dev/messaging/unfurling-links-in-messages#classic_unfurl} + **/ unfurl_media?: boolean; - metadata?: { - event_type: string; - // biome-ignore lint/suspicious/noExplicitAny: errors can be anything - event_payload: Record; - }; + /** + * @description Object representing message metadata, which will be made accessible to any user or app. + * @see {@link https://docs.slack.dev/messaging/message-metadata/} + **/ + metadata?: MessageMetadata; } +/** + * The result from a posted incoming webhook. + * @example {text:"ok"} + * @see {@link https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/#handling_errors}. + */ export interface IncomingWebhookResult { text: string; } diff --git a/packages/webhook/src/errors.ts b/packages/webhook/src/errors.ts index 1252b190a..41aca6c70 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, @@ -54,7 +52,9 @@ export function requestErrorWithOriginal(original: AxiosError): IncomingWebhookR * A factory to create IncomingWebhookHTTPError objects * @param original The original error */ -export function httpErrorWithOriginal(original: AxiosError & { response: AxiosResponse }): IncomingWebhookHTTPError { +export function httpErrorWithOriginal( + original: Error & { response: { status: number; statusText: string; data: string } }, +): IncomingWebhookHTTPError { const error = errorWithCode( new Error(`An HTTP protocol error occurred: statusCode = ${original.response.status}`), ErrorCode.HTTPError, diff --git a/packages/webhook/src/index.ts b/packages/webhook/src/index.ts index 6140db5cf..ca4b085c0 100644 --- a/packages/webhook/src/index.ts +++ b/packages/webhook/src/index.ts @@ -1,5 +1,3 @@ -/// - export { CodedError, ErrorCode, @@ -10,7 +8,7 @@ export { export { IncomingWebhook, - IncomingWebhookDefaultArguments, + IncomingWebhookOptions, IncomingWebhookResult, IncomingWebhookSendArguments, } from './IncomingWebhook.js'; diff --git a/packages/webhook/tsconfig.json b/packages/webhook/tsconfig.json index 5ff0b5908..e6faed87c 100644 --- a/packages/webhook/tsconfig.json +++ b/packages/webhook/tsconfig.json @@ -7,6 +7,7 @@ "declarationMap": true, "sourceMap": true, "outDir": "dist", + "rootDir": "./src", "skipLibCheck": true, "strict": true, "noUnusedLocals": true, @@ -14,15 +15,8 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "moduleResolution": "nodenext", - "baseUrl": ".", - "paths": { - "*": ["./types/*"] - }, - "esModuleInterop": true - // Not using this setting because it's only used to require the package.json file, and that would change the - // structure of the files in the dist directory because package.json is not located inside src. It would be nice - // to use import instead of require(), but it's not worth the tradeoff of restructuring the build (for now). - // "resolveJsonModule": true, + "esModuleInterop": true, + "resolveJsonModule": true }, "include": ["package.json", "src/**/*"], "jsdoc": { diff --git a/packages/webhook/typedoc.json b/packages/webhook/typedoc.json index 310e82da5..703beff03 100644 --- a/packages/webhook/typedoc.json +++ b/packages/webhook/typedoc.json @@ -21,5 +21,5 @@ "jsDocCompatibility": true, "hidePageHeader": true, "entryFileName": "index", - "exclude": ["**/*.test.ts", "**/*.spec.ts", "**/*.test-d.ts"] + "exclude": ["**/*.test.ts", "**/*.test-d.ts"] } From f448fc66790e9b9dfe4d105d00ae8afc05c36b70 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Sat, 11 Oct 2025 22:00:46 -0700 Subject: [PATCH 5/8] ci: upload test coverage --- .github/workflows/ci-build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 74e99f4d2..d7c64c195 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -90,9 +90,9 @@ jobs: id: check_coverage uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0 with: - files: packages/${{ matrix.package }}/coverage/lcov.info + files: packages/${{ matrix.package }}/**/lcov.info - name: Upload code coverage - if: matrix.node-version == '22.x' && matrix.os == 'ubuntu-latest' && steps.check_coverage.outputs.files_exists == 'true' + if: matrix.node-version == '24.x' && matrix.os == 'ubuntu-latest' && steps.check_coverage.outputs.files_exists == 'true' uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -103,7 +103,7 @@ jobs: if: ${{ !cancelled() }} uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 with: - file: packages/${{ matrix.package }}/coverage/test-results.xml + files: packages/${{ matrix.package }}/coverage/test-results.xml,packages/${{ matrix.package}}/lcov.info # TODO: use "lcov.info" as "file" flags: ${{ matrix.node-version }},${{ matrix.os }},${{ matrix.package }} token: ${{ secrets.CODECOV_TOKEN }} verbose: true From c43e545f112eab4fa1b31f08516f9b7fc38cc5c1 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Sat, 11 Oct 2025 22:03:34 -0700 Subject: [PATCH 6/8] ci: attempt to find files instead of test coverage dir --- .github/workflows/ci-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index d7c64c195..0920b30bb 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -96,7 +96,7 @@ jobs: uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: packages/${{ matrix.package }}/coverage + files: packages/${{ matrix.package }}/coverage/test-results.xml,packages/${{ matrix.package}}/lcov.info # TODO: use "lcov.info" as "file" flags: ${{ matrix.package }} verbose: true - name: Upload test results to Codecov From a39ab4d15eb44bd1ea33eaebcf8c60aff43256b2 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Sat, 11 Oct 2025 22:08:20 -0700 Subject: [PATCH 7/8] docs: typeo unidici is one close to eleven i recall --- packages/webhook/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webhook/README.md b/packages/webhook/README.md index efacfa6f3..653e9b204 100644 --- a/packages/webhook/README.md +++ b/packages/webhook/README.md @@ -86,10 +86,10 @@ In order to use a custom fetch adapter, provide a function that's compatible wit The following example uses the [`undici`](https://www.npmjs.com/package/undici) package to create a dispatcher for proxying requests with a limited timeout. Start by installing this package: ```shell -$ npm install unidici +$ npm install undici ``` -Then import the `ProxyAgent` and `fetch` class from the `unidici` package to create a custom `fetch` implementation. This is passed to the `IncomingWebhook` constructor and used in requests: +Then import the `ProxyAgent` and `fetch` class from the `undici` package to create a custom `fetch` implementation. This is passed to the `IncomingWebhook` constructor and used in requests: ```javascript import { IncomingWebhook } from "@slack/webhook"; From 56a6183f91437c75fc414fad81d26011d70f2ff5 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 15 Oct 2025 18:37:36 -0700 Subject: [PATCH 8/8] test: remove paths to files not found for node 20 jobs --- packages/webhook/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 2f1a19e96..488f1823a 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -39,8 +39,8 @@ "lint:fix": "npx @biomejs/biome check --write .", "prepack": "npm run build", "pretest": "npm run lint && npm run build", - "test": "node --import tsx --test --experimental-test-coverage ./src/**/*.test.ts", - "posttest": "node --import tsx --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=./lcov.info ./src/**/*.test.ts" + "test": "node --import tsx --test --experimental-test-coverage", + "posttest": "node --import tsx --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=./lcov.info" }, "devDependencies": { "@biomejs/biome": "^2.0.5",