From cd68c2be873b785636563c885d73ab560a34aeac Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 10 Aug 2026 14:37:26 -0400 Subject: [PATCH 01/12] feat(socket-mode): support undici v8 as a peer dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the `undici` peer dependency range to `^7.0.0 || ^8.0.0` so consumers can bring either major. undici v8 requires Node >=22.19, while v7 continues to support Node >=20 — the source needs no changes, as v8 preserves every API socket-mode uses. CI now pairs each Node version with an undici major via a `versions` matrix and forces the socket-mode workspace onto that major before running the suite, so v8 support is actually exercised on capable Node versions. A new guardrail test asserts the Node/undici pairing (and never allows v8 on Node <22.19). Co-Authored-By: Claude --- .changeset/socket-mode-undici-v8.md | 5 ++ .github/workflows/ci-build.yml | 37 ++++++---- packages/socket-mode/README.md | 6 +- packages/socket-mode/package.json | 2 +- .../socket-mode/src/undiciVersion.test.ts | 67 +++++++++++++++++++ 5 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 .changeset/socket-mode-undici-v8.md create mode 100644 packages/socket-mode/src/undiciVersion.test.ts diff --git a/.changeset/socket-mode-undici-v8.md b/.changeset/socket-mode-undici-v8.md new file mode 100644 index 000000000..d2ff20232 --- /dev/null +++ b/.changeset/socket-mode-undici-v8.md @@ -0,0 +1,5 @@ +--- +"@slack/socket-mode": minor +--- + +Support `undici@^8` as a peer dependency in addition to `undici@^7`. `undici@^8` requires Node.js >=22.19; `undici@^7` continues to support Node.js >=20. diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index d5afe57af..68fbdda17 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -18,14 +18,20 @@ jobs: os: - "ubuntu-latest" - "windows-latest" - node-version: - - "20.x" - - "22.x" - - "24.x" - - "26.x" + versions: + - node: "20.x" + undici: "7" # undici v8 requires Node >=22.19, so 20.x tests against v7 + - node: "22.x" + undici: "8" + - node: "24.x" + undici: "8" + - node: "26.x" + undici: "8" runs-on: ${{ matrix.os }} permissions: contents: read + env: + SOCKET_MODE_EXPECT_UNDICI_MAJOR: ${{ matrix.versions.undici }} steps: - name: Configure git settings (Windows) if: matrix.os == 'windows-latest' @@ -35,31 +41,38 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js ${{ matrix.versions.node }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ${{ matrix.node-version }} + node-version: ${{ matrix.versions.node }} - name: Check versions run: | node --version npm --version - name: Install dependencies run: npm ci --verbose + # Force the socket-mode workspace onto the undici major paired with this Node + # version in the matrix above. A plain workspace `npm install` (no --no-save / + # --no-package-lock) is required: those flags make npm ignore the requested + # version and keep whatever the committed devDependency range already allows. + # The mutation is ephemeral — CI checks out fresh and never commits it. + - name: Install undici v${{ matrix.versions.undici }} for socket-mode + run: npm install undici@^${{ matrix.versions.undici }} --workspace=packages/socket-mode - name: Build packages run: npm run build - name: Lint run: npm run lint - name: Build docs - if: matrix.node-version == env.LATEST_SUPPORTED_NODE + if: matrix.versions.node == env.LATEST_SUPPORTED_NODE run: npm run docs - name: Run tests - if: matrix.node-version != env.LATEST_SUPPORTED_NODE + if: matrix.versions.node != env.LATEST_SUPPORTED_NODE run: npm test - name: Run test coverage - if: matrix.node-version == env.LATEST_SUPPORTED_NODE + if: matrix.versions.node == env.LATEST_SUPPORTED_NODE run: npm run test:coverage - name: Upload code coverage - if: matrix.node-version == env.LATEST_SUPPORTED_NODE && matrix.os == 'ubuntu-latest' + if: matrix.versions.node == env.LATEST_SUPPORTED_NODE && matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: fail_ci_if_error: true @@ -74,7 +87,7 @@ jobs: with: fail_ci_if_error: true files: packages/cli-hooks/test-results.xml,packages/cli-test/test-results.xml,packages/logger/test-results.xml,packages/oauth/test-results.xml,packages/socket-mode/test-results.xml,packages/web-api/test-results.xml,packages/webhook/test-results.xml - flags: ${{ matrix.node-version }},${{ matrix.os }} + flags: ${{ matrix.versions.node }},${{ matrix.os }} report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} verbose: true diff --git a/packages/socket-mode/README.md b/packages/socket-mode/README.md index aad663361..e2c3cfcaa 100644 --- a/packages/socket-mode/README.md +++ b/packages/socket-mode/README.md @@ -9,10 +9,14 @@ This package is designed to support [**Socket Mode**][socket-mode], which allows 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. +[`undici`](https://www.npmjs.com/package/undici) is a required peer dependency and must be installed alongside this +package. Both `undici@^7` and `undici@^8` are supported. Note that `undici@^8` requires Node v22.19 or higher; if you're +on Node v20, use `undici@^7`. + ## Installation ```shell -$ npm install @slack/socket-mode +$ npm install @slack/socket-mode undici ``` ## Usage diff --git a/packages/socket-mode/package.json b/packages/socket-mode/package.json index 43222aad7..0d606f580 100644 --- a/packages/socket-mode/package.json +++ b/packages/socket-mode/package.json @@ -57,7 +57,7 @@ "eventemitter3": "^5" }, "peerDependencies": { - "undici": "^7.0.0" + "undici": "^7.0.0 || ^8.0.0" }, "devDependencies": { "@types/proxyquire": "^1.3.31", diff --git a/packages/socket-mode/src/undiciVersion.test.ts b/packages/socket-mode/src/undiciVersion.test.ts new file mode 100644 index 000000000..19665094f --- /dev/null +++ b/packages/socket-mode/src/undiciVersion.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { describe, it } from 'node:test'; + +// This is a guardrail, not a behavioral test. `@slack/socket-mode` supports undici +// as a peer dependency across two majors (`^7.0.0 || ^8.0.0`), but the two majors do +// not run on the same Node.js versions: undici v8 requires Node >=22.19.0, while +// undici v7 runs on Node >=20. CI pairs each Node row with a specific undici major +// (see the `versions` matrix in .github/workflows/ci-build.yml) and exposes the +// expected major via SOCKET_MODE_EXPECT_UNDICI_MAJOR. This test verifies that the +// undici actually resolved at runtime matches that pairing, so we never ship +// "supports v8" without having run the suite against v8 on a capable Node. + +const require = createRequire(import.meta.url); + +// The lowest Node.js version undici v8 supports (its own `engines.node`). +const UNDICI_V8_MIN_NODE = '22.19.0'; + +// Read the major of the undici that is actually installed and resolvable from this +// package, rather than trusting the declared range — this reflects what the source, +// integration tests, and WHATWG event classes import via the bare 'undici' specifier. +const undiciVersion: string = require('undici/package.json').version; +const undiciMajor = Number.parseInt(undiciVersion.split('.')[0], 10); + +// Dependency-free numeric version comparison. `semver` is only transitively available, +// so we avoid importing it. `process.versions.node` is "MAJOR.MINOR.PATCH"; any +// pre-release suffix on the patch segment parses down to its leading integer. +function nodeAtLeast(target: string): boolean { + const actual = process.versions.node.split('.').map((part) => Number.parseInt(part, 10)); + const min = target.split('.').map((part) => Number.parseInt(part, 10)); + for (let i = 0; i < min.length; i++) { + const a = actual[i] ?? 0; + if (a !== min[i]) { + return a > min[i]; + } + } + return true; +} + +describe('undici peer dependency', () => { + // Safety invariant — runs in every environment (local and CI). It can only fail on + // the genuinely broken combination of undici v8+ on a Node older than 22.19.0. + it('never pairs undici v8+ with a Node.js version older than 22.19.0', () => { + const brokenCombo = undiciMajor >= 8 && !nodeAtLeast(UNDICI_V8_MIN_NODE); + assert.ok( + !brokenCombo, + `undici@${undiciVersion} requires Node >=${UNDICI_V8_MIN_NODE}, but the tests are running on Node ${process.versions.node}`, + ); + }); + + // Strict pairing — enforced only when CI pins the expected major via the matrix. + // This is the "recent Node runs the latest undici" guarantee: it proves Node 20 ran + // on v7 and Node 22/24/26 ran on v8. Skipped locally so a plain `npm test` after + // `npm ci` (which installs the v7 dev default) never fails. + const expectedMajor = process.env.SOCKET_MODE_EXPECT_UNDICI_MAJOR; + if (expectedMajor) { + it(`runs against the undici major pinned by CI (undici v${expectedMajor})`, () => { + assert.strictEqual( + undiciMajor, + Number.parseInt(expectedMajor, 10), + `Expected socket-mode to run against undici v${expectedMajor} (from SOCKET_MODE_EXPECT_UNDICI_MAJOR), but undici@${undiciVersion} is installed`, + ); + }); + } else { + it.skip('runs against the undici major pinned by CI (only enforced when SOCKET_MODE_EXPECT_UNDICI_MAJOR is set)'); + } +}); From 7edc77682915e1f06c81d0c6b9f3bde2e3c8f6ae Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 10 Aug 2026 14:38:30 -0400 Subject: [PATCH 02/12] docs(ci): condense undici install step comment to one line Co-Authored-By: Claude --- .github/workflows/ci-build.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 68fbdda17..54e29676e 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -51,11 +51,7 @@ jobs: npm --version - name: Install dependencies run: npm ci --verbose - # Force the socket-mode workspace onto the undici major paired with this Node - # version in the matrix above. A plain workspace `npm install` (no --no-save / - # --no-package-lock) is required: those flags make npm ignore the requested - # version and keep whatever the committed devDependency range already allows. - # The mutation is ephemeral — CI checks out fresh and never commits it. + # Force socket-mode onto the matrix's undici major (plain install — --no-save/--no-package-lock make npm ignore the requested version). - name: Install undici v${{ matrix.versions.undici }} for socket-mode run: npm install undici@^${{ matrix.versions.undici }} --workspace=packages/socket-mode - name: Build packages From 4cabd4a4c0ce4447a09d430163cc78c194a7d17f Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 10 Aug 2026 14:39:28 -0400 Subject: [PATCH 03/12] docs(ci): trim undici install step comment Co-Authored-By: Claude --- .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 54e29676e..3610da707 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -51,7 +51,7 @@ jobs: npm --version - name: Install dependencies run: npm ci --verbose - # Force socket-mode onto the matrix's undici major (plain install — --no-save/--no-package-lock make npm ignore the requested version). + # Force socket-mode onto the matrix's undici major. - name: Install undici v${{ matrix.versions.undici }} for socket-mode run: npm install undici@^${{ matrix.versions.undici }} --workspace=packages/socket-mode - name: Build packages From 3e6270df011840863be4e758a5e97ff93498dfa1 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 10 Aug 2026 15:14:26 -0400 Subject: [PATCH 04/12] test(socket-mode): simplify undici version guardrail test Reduce the undici peer-dependency test to its two assertions, drop the comment essays and the general-purpose version-comparison helper in favor of a plain Node major check, and rename SOCKET_MODE_EXPECT_UNDICI_MAJOR to SOCKET_MODE_UNDICI_VERSION. Co-Authored-By: Claude --- .github/workflows/ci-build.yml | 2 +- packages/socket-mode/README.md | 6 +-- .../socket-mode/src/undiciVersion.test.ts | 54 ++++--------------- 3 files changed, 12 insertions(+), 50 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 3610da707..fffb8dd41 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -31,7 +31,7 @@ jobs: permissions: contents: read env: - SOCKET_MODE_EXPECT_UNDICI_MAJOR: ${{ matrix.versions.undici }} + SOCKET_MODE_UNDICI_VERSION: ${{ matrix.versions.undici }} steps: - name: Configure git settings (Windows) if: matrix.os == 'windows-latest' diff --git a/packages/socket-mode/README.md b/packages/socket-mode/README.md index e2c3cfcaa..aad663361 100644 --- a/packages/socket-mode/README.md +++ b/packages/socket-mode/README.md @@ -9,14 +9,10 @@ This package is designed to support [**Socket Mode**][socket-mode], which allows 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. -[`undici`](https://www.npmjs.com/package/undici) is a required peer dependency and must be installed alongside this -package. Both `undici@^7` and `undici@^8` are supported. Note that `undici@^8` requires Node v22.19 or higher; if you're -on Node v20, use `undici@^7`. - ## Installation ```shell -$ npm install @slack/socket-mode undici +$ npm install @slack/socket-mode ``` ## Usage diff --git a/packages/socket-mode/src/undiciVersion.test.ts b/packages/socket-mode/src/undiciVersion.test.ts index 19665094f..5ea57b350 100644 --- a/packages/socket-mode/src/undiciVersion.test.ts +++ b/packages/socket-mode/src/undiciVersion.test.ts @@ -2,66 +2,32 @@ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { describe, it } from 'node:test'; -// This is a guardrail, not a behavioral test. `@slack/socket-mode` supports undici -// as a peer dependency across two majors (`^7.0.0 || ^8.0.0`), but the two majors do -// not run on the same Node.js versions: undici v8 requires Node >=22.19.0, while -// undici v7 runs on Node >=20. CI pairs each Node row with a specific undici major -// (see the `versions` matrix in .github/workflows/ci-build.yml) and exposes the -// expected major via SOCKET_MODE_EXPECT_UNDICI_MAJOR. This test verifies that the -// undici actually resolved at runtime matches that pairing, so we never ship -// "supports v8" without having run the suite against v8 on a capable Node. +// Guardrail for the `undici` peer dependency. undici v8 needs +// Node >=22 and v7 needs Node >=20, so CI passes the expected major in via SOCKET_MODE_UNDICI_VERSION. const require = createRequire(import.meta.url); - -// The lowest Node.js version undici v8 supports (its own `engines.node`). -const UNDICI_V8_MIN_NODE = '22.19.0'; - -// Read the major of the undici that is actually installed and resolvable from this -// package, rather than trusting the declared range — this reflects what the source, -// integration tests, and WHATWG event classes import via the bare 'undici' specifier. const undiciVersion: string = require('undici/package.json').version; const undiciMajor = Number.parseInt(undiciVersion.split('.')[0], 10); - -// Dependency-free numeric version comparison. `semver` is only transitively available, -// so we avoid importing it. `process.versions.node` is "MAJOR.MINOR.PATCH"; any -// pre-release suffix on the patch segment parses down to its leading integer. -function nodeAtLeast(target: string): boolean { - const actual = process.versions.node.split('.').map((part) => Number.parseInt(part, 10)); - const min = target.split('.').map((part) => Number.parseInt(part, 10)); - for (let i = 0; i < min.length; i++) { - const a = actual[i] ?? 0; - if (a !== min[i]) { - return a > min[i]; - } - } - return true; -} +const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10); describe('undici peer dependency', () => { - // Safety invariant — runs in every environment (local and CI). It can only fail on - // the genuinely broken combination of undici v8+ on a Node older than 22.19.0. - it('never pairs undici v8+ with a Node.js version older than 22.19.0', () => { - const brokenCombo = undiciMajor >= 8 && !nodeAtLeast(UNDICI_V8_MIN_NODE); + it('never runs undici v8+ on Node older than 22', () => { assert.ok( - !brokenCombo, - `undici@${undiciVersion} requires Node >=${UNDICI_V8_MIN_NODE}, but the tests are running on Node ${process.versions.node}`, + undiciMajor < 8 || nodeMajor >= 22, + `undici@${undiciVersion} requires Node >=22, but tests are running on Node ${process.versions.node}`, ); }); - // Strict pairing — enforced only when CI pins the expected major via the matrix. - // This is the "recent Node runs the latest undici" guarantee: it proves Node 20 ran - // on v7 and Node 22/24/26 ran on v8. Skipped locally so a plain `npm test` after - // `npm ci` (which installs the v7 dev default) never fails. - const expectedMajor = process.env.SOCKET_MODE_EXPECT_UNDICI_MAJOR; + const expectedMajor = process.env.SOCKET_MODE_UNDICI_VERSION; if (expectedMajor) { - it(`runs against the undici major pinned by CI (undici v${expectedMajor})`, () => { + it(`runs against the undici major pinned by CI (v${expectedMajor})`, () => { assert.strictEqual( undiciMajor, Number.parseInt(expectedMajor, 10), - `Expected socket-mode to run against undici v${expectedMajor} (from SOCKET_MODE_EXPECT_UNDICI_MAJOR), but undici@${undiciVersion} is installed`, + `Expected undici v${expectedMajor} (SOCKET_MODE_UNDICI_VERSION), but undici@${undiciVersion} is installed`, ); }); } else { - it.skip('runs against the undici major pinned by CI (only enforced when SOCKET_MODE_EXPECT_UNDICI_MAJOR is set)'); + it.skip('runs against the undici major pinned by CI (only when SOCKET_MODE_UNDICI_VERSION is set)'); } }); From c0746b2f0ccf8b205a6166a7aa4ae1b4ceba93ee Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 31 Aug 2026 11:33:50 -0700 Subject: [PATCH 05/12] Apply suggestion from @zimeg Co-authored-by: Eden Zimbelman --- packages/socket-mode/src/undiciVersion.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/socket-mode/src/undiciVersion.test.ts b/packages/socket-mode/src/undiciVersion.test.ts index 5ea57b350..e9cb309d5 100644 --- a/packages/socket-mode/src/undiciVersion.test.ts +++ b/packages/socket-mode/src/undiciVersion.test.ts @@ -13,7 +13,7 @@ const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10); describe('undici peer dependency', () => { it('never runs undici v8+ on Node older than 22', () => { assert.ok( - undiciMajor < 8 || nodeMajor >= 22, + undiciMajor >= 8 && nodeMajor < 22, `undici@${undiciVersion} requires Node >=22, but tests are running on Node ${process.versions.node}`, ); }); From bb11653ec8387ed73d787244627975f73f5c7060 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 24 Sep 2026 11:55:58 -0400 Subject: [PATCH 06/12] fix(socket-mode): pin HTTP/1.1 on undici v8 and test v8 in its own CI job undici v8 flips buildConnector's allowH2 default to true, so the default dispatcher offered h2 over ALPN. Pin allowH2: false so the WebSocket stays on HTTP/1.1 (where the force-destroy fallback holds) on both majors. A test parses the ALPN list from the raw ClientHello (no certificate needed) and asserts the client only offers http/1.1; it fails on v8 without the fix. CI: restore the original matrix (undici v7 on every Node) and add a socket-mode-undici-v8 job on Node 22/24/26. Drop the guardrail unit test, whose inverted assertion failed every job; the job checks the installed major inline instead. Co-Authored-By: Claude --- .changeset/socket-mode-undici-v8.md | 2 + .github/workflows/ci-build.yml | 65 ++++++++++------ packages/socket-mode/src/SlackWebSocket.ts | 5 +- packages/socket-mode/src/SocketModeOptions.ts | 3 + .../socket-mode/src/undiciVersion.test.ts | 33 -------- .../test/integrations/alpn.test.js | 75 +++++++++++++++++++ 6 files changed, 128 insertions(+), 55 deletions(-) delete mode 100644 packages/socket-mode/src/undiciVersion.test.ts create mode 100644 packages/socket-mode/test/integrations/alpn.test.js diff --git a/.changeset/socket-mode-undici-v8.md b/.changeset/socket-mode-undici-v8.md index d2ff20232..38fd6e865 100644 --- a/.changeset/socket-mode-undici-v8.md +++ b/.changeset/socket-mode-undici-v8.md @@ -3,3 +3,5 @@ --- Support `undici@^8` as a peer dependency in addition to `undici@^7`. `undici@^8` requires Node.js >=22.19; `undici@^7` continues to support Node.js >=20. + +The default dispatcher now pins the WebSocket connection to HTTP/1.1, since `undici@^8` offers HTTP/2 by default. To do the same with a custom `ProxyAgent` dispatcher, pass `requestTls: { allowH2: false }`. diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index fffb8dd41..25f6d3eec 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -18,20 +18,14 @@ jobs: os: - "ubuntu-latest" - "windows-latest" - versions: - - node: "20.x" - undici: "7" # undici v8 requires Node >=22.19, so 20.x tests against v7 - - node: "22.x" - undici: "8" - - node: "24.x" - undici: "8" - - node: "26.x" - undici: "8" + node-version: + - "20.x" + - "22.x" + - "24.x" + - "26.x" runs-on: ${{ matrix.os }} permissions: contents: read - env: - SOCKET_MODE_UNDICI_VERSION: ${{ matrix.versions.undici }} steps: - name: Configure git settings (Windows) if: matrix.os == 'windows-latest' @@ -41,34 +35,31 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Use Node.js ${{ matrix.versions.node }} + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ${{ matrix.versions.node }} + node-version: ${{ matrix.node-version }} - name: Check versions run: | node --version npm --version - name: Install dependencies run: npm ci --verbose - # Force socket-mode onto the matrix's undici major. - - name: Install undici v${{ matrix.versions.undici }} for socket-mode - run: npm install undici@^${{ matrix.versions.undici }} --workspace=packages/socket-mode - name: Build packages run: npm run build - name: Lint run: npm run lint - name: Build docs - if: matrix.versions.node == env.LATEST_SUPPORTED_NODE + if: matrix.node-version == env.LATEST_SUPPORTED_NODE run: npm run docs - name: Run tests - if: matrix.versions.node != env.LATEST_SUPPORTED_NODE + if: matrix.node-version != env.LATEST_SUPPORTED_NODE run: npm test - name: Run test coverage - if: matrix.versions.node == env.LATEST_SUPPORTED_NODE + if: matrix.node-version == env.LATEST_SUPPORTED_NODE run: npm run test:coverage - name: Upload code coverage - if: matrix.versions.node == env.LATEST_SUPPORTED_NODE && matrix.os == 'ubuntu-latest' + if: matrix.node-version == env.LATEST_SUPPORTED_NODE && matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: fail_ci_if_error: true @@ -83,7 +74,39 @@ jobs: with: fail_ci_if_error: true files: packages/cli-hooks/test-results.xml,packages/cli-test/test-results.xml,packages/logger/test-results.xml,packages/oauth/test-results.xml,packages/socket-mode/test-results.xml,packages/web-api/test-results.xml,packages/webhook/test-results.xml - flags: ${{ matrix.versions.node }},${{ matrix.os }} + flags: ${{ matrix.node-version }},${{ matrix.os }} report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} verbose: true + socket-mode-undici-v8: + # The lockfile pins undici v7, which `test` covers on every Node; this covers the v8 peer range (Node >=22.19). + timeout-minutes: 6 + strategy: + fail-fast: false + matrix: + node-version: + - "22.x" + - "24.x" + - "26.x" + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node-version }} + - name: Install dependencies + run: npm ci --verbose + - name: Install undici v8 for socket-mode + run: npm install undici@^8 --workspace=packages/socket-mode --no-save + - name: Check undici version + working-directory: packages/socket-mode + run: node -e "const v = require('undici/package.json').version; console.log('undici', v); if (!v.startsWith('8.')) process.exit(1);" + - name: Build packages + run: npm run build + - name: Run socket-mode tests + run: npm test --workspace=packages/socket-mode diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index 4340db39b..4b0ea4bce 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -227,9 +227,12 @@ export class SlackWebSocket { * The `connect` hook captures the underlying socket into `this.defaultSocket` so `cleanup()` can * force-destroy it: undici's `WebSocket` hides its socket and detaches it from the pool at upgrade, * leaving no other way to close a stalled peer. + * + * `allowH2: false` pins HTTP/1.1: undici v8 defaults it to `true`, which would offer h2 over ALPN and + * could move the WebSocket onto an HTTP/2 stream, where there is no dedicated socket to destroy. */ private buildDefaultDispatcher(): Dispatcher { - const baseConnect = buildConnector({}); + const baseConnect = buildConnector({ allowH2: false }); return new Agent({ connect: (opts, callback) => { baseConnect(opts, (err, socket) => { diff --git a/packages/socket-mode/src/SocketModeOptions.ts b/packages/socket-mode/src/SocketModeOptions.ts index 57da6f4db..22f038b7e 100644 --- a/packages/socket-mode/src/SocketModeOptions.ts +++ b/packages/socket-mode/src/SocketModeOptions.ts @@ -68,6 +68,9 @@ export interface SocketModeOptions { * * Use this to configure proxies or custom TLS behavior. * + * On `undici@^8`, dispatchers offer HTTP/2 by default. The default dispatcher pins HTTP/1.1; to do the same + * with a `ProxyAgent`, pass `requestTls: { allowH2: false }`. + * * @example * ```js * // Using undici's ProxyAgent as the dispatcher diff --git a/packages/socket-mode/src/undiciVersion.test.ts b/packages/socket-mode/src/undiciVersion.test.ts deleted file mode 100644 index e9cb309d5..000000000 --- a/packages/socket-mode/src/undiciVersion.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import assert from 'node:assert/strict'; -import { createRequire } from 'node:module'; -import { describe, it } from 'node:test'; - -// Guardrail for the `undici` peer dependency. undici v8 needs -// Node >=22 and v7 needs Node >=20, so CI passes the expected major in via SOCKET_MODE_UNDICI_VERSION. - -const require = createRequire(import.meta.url); -const undiciVersion: string = require('undici/package.json').version; -const undiciMajor = Number.parseInt(undiciVersion.split('.')[0], 10); -const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10); - -describe('undici peer dependency', () => { - it('never runs undici v8+ on Node older than 22', () => { - assert.ok( - undiciMajor >= 8 && nodeMajor < 22, - `undici@${undiciVersion} requires Node >=22, but tests are running on Node ${process.versions.node}`, - ); - }); - - const expectedMajor = process.env.SOCKET_MODE_UNDICI_VERSION; - if (expectedMajor) { - it(`runs against the undici major pinned by CI (v${expectedMajor})`, () => { - assert.strictEqual( - undiciMajor, - Number.parseInt(expectedMajor, 10), - `Expected undici v${expectedMajor} (SOCKET_MODE_UNDICI_VERSION), but undici@${undiciVersion} is installed`, - ); - }); - } else { - it.skip('runs against the undici major pinned by CI (only when SOCKET_MODE_UNDICI_VERSION is set)'); - } -}); diff --git a/packages/socket-mode/test/integrations/alpn.test.js b/packages/socket-mode/test/integrations/alpn.test.js new file mode 100644 index 000000000..80927dd83 --- /dev/null +++ b/packages/socket-mode/test/integrations/alpn.test.js @@ -0,0 +1,75 @@ +const assert = require('node:assert/strict'); +const { createServer } = require('node:net'); +const { describe, it, afterEach } = require('node:test'); +const { EventEmitter } = require('eventemitter3'); +const { SlackWebSocket } = require('../../src/SlackWebSocket'); +const { LogLevel } = require('../../src/logger'); + +const ALPN_EXTENSION_TYPE = 0x0010; + +/** + * Reads the ALPN protocols a client offers from a raw TLS ClientHello (RFC 8446 section 4.1.2). + * Parsing the hello directly means the test needs no certificate: the handshake never completes. + */ +function offeredAlpnProtocols(hello) { + let offset = 5 + 4 + 2 + 32; // record header, handshake header, legacy_version, random + offset += 1 + hello.readUInt8(offset); // legacy_session_id + offset += 2 + hello.readUInt16BE(offset); // cipher_suites + offset += 1 + hello.readUInt8(offset); // legacy_compression_methods + const extensionsEnd = offset + 2 + hello.readUInt16BE(offset); + offset += 2; + while (offset < extensionsEnd) { + const type = hello.readUInt16BE(offset); + const length = hello.readUInt16BE(offset + 2); + offset += 4; + if (type === ALPN_EXTENSION_TYPE) { + const protocols = []; + const listEnd = offset + 2 + hello.readUInt16BE(offset); + for (let p = offset + 2; p < listEnd; p += 1 + hello.readUInt8(p)) { + protocols.push(hello.toString('ascii', p + 1, p + 1 + hello.readUInt8(p))); + } + return protocols; + } + offset += length; + } + return []; +} + +// undici v8 flipped buildConnector's allowH2 default to true, so the default dispatcher would offer h2 +// over ALPN and could negotiate a WebSocket over HTTP/2. Slack's WebSocket endpoints expect HTTP/1.1. +describe('Default dispatcher ALPN', { timeout: 10000 }, () => { + const TLS_PORT = 23470; + + let server = null; + let sws = null; + + afterEach(() => { + if (sws) sws.disconnect(); + sws = null; + if (server) server.close(); + server = null; + }); + + it('offers only http/1.1', async () => { + const offered = new Promise((resolve) => { + server = createServer((socket) => { + socket.once('data', (hello) => { + resolve(offeredAlpnProtocols(hello)); + socket.destroy(); + }); + }); + server.listen(TLS_PORT); + }); + + sws = new SlackWebSocket({ + url: `wss://localhost:${TLS_PORT}/`, + client: new EventEmitter(), + logLevel: LogLevel.ERROR, + serverPingTimeoutMS: 30000, + clientPingTimeoutMS: 5000, + }); + sws.connect(); + + assert.deepStrictEqual(await offered, ['http/1.1']); + }); +}); From 4d3c1d22a6833d42340b0f8ae168c01ecd9d8d69 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 24 Sep 2026 12:19:46 -0400 Subject: [PATCH 07/12] ci: run socket-mode's undici v8 tests as steps in the existing test job Replaces the separate socket-mode-undici-v8 job. The steps run on every Node except 20.x (undici v8 needs >=22.19), on both ubuntu and windows, after the codecov uploads so socket-mode's v7 test results aren't overwritten. Co-Authored-By: Claude --- .github/workflows/ci-build.yml | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 25f6d3eec..eab0c61a8 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -78,35 +78,15 @@ jobs: report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} verbose: true - socket-mode-undici-v8: - # The lockfile pins undici v7, which `test` covers on every Node; this covers the v8 peer range (Node >=22.19). - timeout-minutes: 6 - strategy: - fail-fast: false - matrix: - node-version: - - "22.x" - - "24.x" - - "26.x" - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: ${{ matrix.node-version }} - - name: Install dependencies - run: npm ci --verbose + # The lockfile pins undici v7; re-run socket-mode against the v8 peer range, which needs Node >=22.19. + # These run after the uploads so they don't overwrite socket-mode's v7 test-results.xml. - name: Install undici v8 for socket-mode + if: matrix.node-version != '20.x' run: npm install undici@^8 --workspace=packages/socket-mode --no-save - name: Check undici version + if: matrix.node-version != '20.x' working-directory: packages/socket-mode run: node -e "const v = require('undici/package.json').version; console.log('undici', v); if (!v.startsWith('8.')) process.exit(1);" - - name: Build packages - run: npm run build - - name: Run socket-mode tests + - name: Run socket-mode tests (undici v8) + if: matrix.node-version != '20.x' run: npm test --workspace=packages/socket-mode From d4e6378c6dce7e3bafec96ad9e25885b8d51dffb Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 24 Sep 2026 12:28:15 -0400 Subject: [PATCH 08/12] ci: test undici v7 and v8 through the existing matrix Add an undici dimension (excluding Node 20 + v8, since v8 needs >=22.19) in place of the extra steps. Job names gain the undici version, so the required status checks on main need updating to match. Co-Authored-By: Claude --- .github/workflows/ci-build.yml | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index eab0c61a8..220fd9604 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -23,6 +23,12 @@ jobs: - "22.x" - "24.x" - "26.x" + undici: + - "7" + - "8" + exclude: + - node-version: "20.x" + undici: "8" runs-on: ${{ matrix.os }} permissions: contents: read @@ -45,6 +51,9 @@ jobs: npm --version - name: Install dependencies run: npm ci --verbose + - name: Install undici v8 for socket-mode + if: matrix.undici == '8' + run: npm install undici@^8 --workspace=packages/socket-mode --no-save - name: Build packages run: npm run build - name: Lint @@ -78,15 +87,3 @@ jobs: report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} verbose: true - # The lockfile pins undici v7; re-run socket-mode against the v8 peer range, which needs Node >=22.19. - # These run after the uploads so they don't overwrite socket-mode's v7 test-results.xml. - - name: Install undici v8 for socket-mode - if: matrix.node-version != '20.x' - run: npm install undici@^8 --workspace=packages/socket-mode --no-save - - name: Check undici version - if: matrix.node-version != '20.x' - working-directory: packages/socket-mode - run: node -e "const v = require('undici/package.json').version; console.log('undici', v); if (!v.startsWith('8.')) process.exit(1);" - - name: Run socket-mode tests (undici v8) - if: matrix.node-version != '20.x' - run: npm test --workspace=packages/socket-mode From 3ebf00041dcf3adaeb27912e8d94da0db0d54e79 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 24 Sep 2026 12:32:36 -0400 Subject: [PATCH 09/12] ci: install the matrix's undici major on every job Co-Authored-By: Claude --- .github/workflows/ci-build.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 220fd9604..942c64d01 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -51,9 +51,8 @@ jobs: npm --version - name: Install dependencies run: npm ci --verbose - - name: Install undici v8 for socket-mode - if: matrix.undici == '8' - run: npm install undici@^8 --workspace=packages/socket-mode --no-save + - name: Install undici + run: npm install undici@^${{ matrix.undici }} --workspace=packages/socket-mode --no-save - name: Build packages run: npm run build - name: Lint From acb1c81108c5be7acfe3449a8cc463781fa9bd93 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 24 Sep 2026 12:36:49 -0400 Subject: [PATCH 10/12] ci: name the undici matrix entries by their install spec Co-Authored-By: Claude --- .github/workflows/ci-build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 942c64d01..fb35e3acc 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -24,11 +24,11 @@ jobs: - "24.x" - "26.x" undici: - - "7" - - "8" + - "undici@^7" + - "undici@^8" exclude: - node-version: "20.x" - undici: "8" + undici: "undici@^8" runs-on: ${{ matrix.os }} permissions: contents: read @@ -52,7 +52,7 @@ jobs: - name: Install dependencies run: npm ci --verbose - name: Install undici - run: npm install undici@^${{ matrix.undici }} --workspace=packages/socket-mode --no-save + run: npm install ${{ matrix.undici }} --workspace=packages/socket-mode --no-save - name: Build packages run: npm run build - name: Lint From 5108e95272e4302802a5af11704b34bf162d6022 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 24 Sep 2026 12:41:05 -0400 Subject: [PATCH 11/12] test(socket-mode): drop the ALPN integration test Slack's WebSocket hosts negotiate HTTP/1.1 today, so the allowH2: false pin stays as cheap insurance without a dedicated test. Co-Authored-By: Claude --- .../test/integrations/alpn.test.js | 75 ------------------- 1 file changed, 75 deletions(-) delete mode 100644 packages/socket-mode/test/integrations/alpn.test.js diff --git a/packages/socket-mode/test/integrations/alpn.test.js b/packages/socket-mode/test/integrations/alpn.test.js deleted file mode 100644 index 80927dd83..000000000 --- a/packages/socket-mode/test/integrations/alpn.test.js +++ /dev/null @@ -1,75 +0,0 @@ -const assert = require('node:assert/strict'); -const { createServer } = require('node:net'); -const { describe, it, afterEach } = require('node:test'); -const { EventEmitter } = require('eventemitter3'); -const { SlackWebSocket } = require('../../src/SlackWebSocket'); -const { LogLevel } = require('../../src/logger'); - -const ALPN_EXTENSION_TYPE = 0x0010; - -/** - * Reads the ALPN protocols a client offers from a raw TLS ClientHello (RFC 8446 section 4.1.2). - * Parsing the hello directly means the test needs no certificate: the handshake never completes. - */ -function offeredAlpnProtocols(hello) { - let offset = 5 + 4 + 2 + 32; // record header, handshake header, legacy_version, random - offset += 1 + hello.readUInt8(offset); // legacy_session_id - offset += 2 + hello.readUInt16BE(offset); // cipher_suites - offset += 1 + hello.readUInt8(offset); // legacy_compression_methods - const extensionsEnd = offset + 2 + hello.readUInt16BE(offset); - offset += 2; - while (offset < extensionsEnd) { - const type = hello.readUInt16BE(offset); - const length = hello.readUInt16BE(offset + 2); - offset += 4; - if (type === ALPN_EXTENSION_TYPE) { - const protocols = []; - const listEnd = offset + 2 + hello.readUInt16BE(offset); - for (let p = offset + 2; p < listEnd; p += 1 + hello.readUInt8(p)) { - protocols.push(hello.toString('ascii', p + 1, p + 1 + hello.readUInt8(p))); - } - return protocols; - } - offset += length; - } - return []; -} - -// undici v8 flipped buildConnector's allowH2 default to true, so the default dispatcher would offer h2 -// over ALPN and could negotiate a WebSocket over HTTP/2. Slack's WebSocket endpoints expect HTTP/1.1. -describe('Default dispatcher ALPN', { timeout: 10000 }, () => { - const TLS_PORT = 23470; - - let server = null; - let sws = null; - - afterEach(() => { - if (sws) sws.disconnect(); - sws = null; - if (server) server.close(); - server = null; - }); - - it('offers only http/1.1', async () => { - const offered = new Promise((resolve) => { - server = createServer((socket) => { - socket.once('data', (hello) => { - resolve(offeredAlpnProtocols(hello)); - socket.destroy(); - }); - }); - server.listen(TLS_PORT); - }); - - sws = new SlackWebSocket({ - url: `wss://localhost:${TLS_PORT}/`, - client: new EventEmitter(), - logLevel: LogLevel.ERROR, - serverPingTimeoutMS: 30000, - clientPingTimeoutMS: 5000, - }); - sws.connect(); - - assert.deepStrictEqual(await offered, ['http/1.1']); - }); -}); From 7e1e6857ba6d1a56de1f2c7d8489d944c967bd37 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 24 Sep 2026 12:50:06 -0400 Subject: [PATCH 12/12] docs(socket-mode): trim the allowH2 comments Co-Authored-By: Claude --- packages/socket-mode/src/SlackWebSocket.ts | 4 +--- packages/socket-mode/src/SocketModeOptions.ts | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index 4b0ea4bce..62f90ce82 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -227,11 +227,9 @@ export class SlackWebSocket { * The `connect` hook captures the underlying socket into `this.defaultSocket` so `cleanup()` can * force-destroy it: undici's `WebSocket` hides its socket and detaches it from the pool at upgrade, * leaving no other way to close a stalled peer. - * - * `allowH2: false` pins HTTP/1.1: undici v8 defaults it to `true`, which would offer h2 over ALPN and - * could move the WebSocket onto an HTTP/2 stream, where there is no dedicated socket to destroy. */ private buildDefaultDispatcher(): Dispatcher { + // undici v8 defaults allowH2 to true; pin HTTP/1.1 so the WebSocket keeps a dedicated socket. const baseConnect = buildConnector({ allowH2: false }); return new Agent({ connect: (opts, callback) => { diff --git a/packages/socket-mode/src/SocketModeOptions.ts b/packages/socket-mode/src/SocketModeOptions.ts index 22f038b7e..57da6f4db 100644 --- a/packages/socket-mode/src/SocketModeOptions.ts +++ b/packages/socket-mode/src/SocketModeOptions.ts @@ -68,9 +68,6 @@ export interface SocketModeOptions { * * Use this to configure proxies or custom TLS behavior. * - * On `undici@^8`, dispatchers offer HTTP/2 by default. The default dispatcher pins HTTP/1.1; to do the same - * with a `ProxyAgent`, pass `requestTls: { allowH2: false }`. - * * @example * ```js * // Using undici's ProxyAgent as the dispatcher