diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55b14aee7..4ec0571bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,9 @@ jobs: strategy: fail-fast: false matrix: - node-version: ["22.19.0", "24"] + # Node 24.19.0 regressed native ObjectWrap teardown. Restore the + # floating "24" lane after https://github.com/nodejs/node/pull/65042 ships. + node-version: ["22.19.0", "24.18.0"] steps: - name: Checkout diff --git a/CHANGELOG.md b/CHANGELOG.md index bd9683e17..151e166a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Fixed Web Push delivery on supported Node releases by honoring the + all-address DNS lookup callback contract while still pinning each request to + one previously validated public address. + ## 0.19.0 — Web Push, console discovery, and host themes (2026-08-13) ### Reliable Web Push diff --git a/packages/web/src/__tests__/push.test.ts b/packages/web/src/__tests__/push.test.ts index a262806b6..5d29d441d 100644 --- a/packages/web/src/__tests__/push.test.ts +++ b/packages/web/src/__tests__/push.test.ts @@ -1,5 +1,7 @@ import { createECDH } from "node:crypto"; +import { once } from "node:events"; import { rm } from "node:fs/promises"; +import { connect, createServer } from "node:net"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; @@ -9,6 +11,7 @@ import webPush from "web-push"; import type { WebAgentSummary } from "../contracts.js"; import { webPushPreview } from "../push-preview.js"; import { + createWebPushPinnedLookup, generateWebPushIdentity, validateWebPushEndpoint, validateWebPushKeys, @@ -92,6 +95,29 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + it("returns an address array when Node auto-family lookup requests all pinned addresses", async () => { + const server = createServer((socket) => socket.end()); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("Expected a TCP test address."); + + const socket = connect({ + host: "push.example.test", + port: address.port, + autoSelectFamily: true, + lookup: createWebPushPinnedLookup({ address: "127.0.0.1", family: 4 }), + }); + try { + await once(socket, "connect"); + } finally { + socket.destroy(); + await new Promise((resolvePromise, reject) => { + server.close((error) => error === undefined ? resolvePromise() : reject(error)); + }); + } + }); + it("persists one VAPID identity and fails closed on partial state", async () => { const now = () => new Date("2026-08-13T08:00:00.000Z"); const store = await storeAt(now); diff --git a/packages/web/src/push.ts b/packages/web/src/push.ts index d41ebb49b..d750ad2c9 100644 --- a/packages/web/src/push.ts +++ b/packages/web/src/push.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import type { LookupAddress } from "node:dns"; import { lookup as dnsLookup } from "node:dns/promises"; import { request as httpsRequest } from "node:https"; -import { BlockList, isIP } from "node:net"; +import { BlockList, isIP, type LookupFunction } from "node:net"; import webPush, { type Headers as WebPushHeaders, @@ -550,9 +550,7 @@ function performPinnedRequest( signal, // Keep TLS hostname verification on the original host while pinning the // validated address. Node's HTTPS client does not follow redirects. - lookup: ((_hostname: string, _options: unknown, callback: (error: Error | null, address: string, family: number) => void) => { - callback(null, pinned.address, pinned.family); - }) as never, + lookup: createWebPushPinnedLookup(pinned), }, (response) => { const chunks: Buffer[] = []; let captured = 0; @@ -576,6 +574,17 @@ function performPinnedRequest( }); } +/** Preserve one validated address while honoring both Node lookup callback modes. */ +export function createWebPushPinnedLookup(pinned: LookupAddress): LookupFunction { + return (_hostname, options, callback) => { + if (options.all === true) { + callback(null, [pinned]); + return; + } + callback(null, pinned.address, pinned.family); + }; +} + function retryAfterMs( headers: Readonly>, now: Date, diff --git a/scripts/__tests__/verify-all.test.mjs b/scripts/__tests__/verify-all.test.mjs index 1c7acaa9b..d3f8fc9cc 100644 --- a/scripts/__tests__/verify-all.test.mjs +++ b/scripts/__tests__/verify-all.test.mjs @@ -12,7 +12,7 @@ import { runVerifyAll, } from "../verify-all.mjs"; -const EXPECTED_CI_NODE_MATRIX = Object.freeze([MINIMUM_NODE_VERSION, "24"]); +const EXPECTED_CI_NODE_MATRIX = Object.freeze([MINIMUM_NODE_VERSION, "24.18.0"]); const CI_CHECKOUT_STEP = [ " - name: Checkout", " uses: actions/checkout@v4", @@ -293,7 +293,7 @@ describe("verify-all", () => { ].join("\n"); const mutated = [ " - name: Install packed consumer at the minimum Node version", - " if: ${{ matrix.node-version == '24' }}", + " if: ${{ matrix.node-version == '24.18.0' }}", ].join("\n"); const mutatedSource = replaceExactly(source, original, mutated); @@ -754,7 +754,7 @@ describe("verify-all", () => { it("rejects every noncanonical CI Node matrix value", () => { const source = readCiWorkflow(); - const matrix = " node-version: [\"22.19.0\", \"24\"]"; + const matrix = " node-version: [\"22.19.0\", \"24.18.0\"]"; for (const replacement of ["20", "99", "future"]) { const mutation = replaceExactly(