From 87c69d5f4d4c893e95a352db30fe78d7bd2f7f02 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 10:43:37 +0300 Subject: [PATCH 01/13] fix: replace crypto-js with Web Crypto to remove DEP0169 warning crypto-js (v4.2.0) internally calls the deprecated url.parse(), triggering Node.js DEP0169 warnings on every signature verification (#267). It was used in a single place to hash the request body, so replace it with the native Web Crypto API (crypto.subtle.digest), which jose already relies on and which works across Node 16+, browsers, Cloudflare Workers, Deno and edge runtimes. Also extend the Cloudflare Workers CI: the deployed worker now exposes a Receiver-backed /verify endpoint and a /publish endpoint that posts a message to it, and a new verify.test.ts polls the message logs until the signed message is delivered end-to-end. --- .github/workflows/test.yaml | 10 ++ bun.lockb | Bin 354883 -> 354143 bytes examples/cloudflare-workers/src/ci.ts | 97 +++++++++++++++---- examples/cloudflare-workers/src/constants.ts | 3 + examples/cloudflare-workers/verify.test.ts | 54 +++++++++++ package.json | 2 - src/receiver.ts | 24 ++++- 7 files changed, 163 insertions(+), 27 deletions(-) create mode 100644 examples/cloudflare-workers/verify.test.ts diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 10e6767b..201dc4e0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -5,6 +5,8 @@ on: env: QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }} + QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} + QSTASH_NEXT_SIGNING_KEY: ${{ secrets.QSTASH_NEXT_SIGNING_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_ORGANIZATION: ${{ secrets.OPENAI_ORGANIZATION }} jobs: @@ -114,6 +116,8 @@ jobs: run: | echo '[vars]' >> wrangler.toml echo "QSTASH_TOKEN = \"$QSTASH_TOKEN\"" >> ./wrangler.toml + echo "QSTASH_CURRENT_SIGNING_KEY = \"$QSTASH_CURRENT_SIGNING_KEY\"" >> ./wrangler.toml + echo "QSTASH_NEXT_SIGNING_KEY = \"$QSTASH_NEXT_SIGNING_KEY\"" >> ./wrangler.toml working-directory: examples/cloudflare-workers - name: Change main file to ci.ts @@ -132,6 +136,12 @@ jobs: env: DEPLOYMENT_URL: https://upstash-qstash.upsdev.workers.dev + - name: Test delivery round-trip (publish → verify endpoint → delivered) + run: bun test verify.test.ts + working-directory: examples/cloudflare-workers + env: + DEPLOYMENT_URL: https://upstash-qstash.upsdev.workers.dev + nextjs-local-build: runs-on: ubuntu-latest name: NextJS Local Build diff --git a/bun.lockb b/bun.lockb index 6080bfc946245496f84cf996a09efa1a2553ad60..af192f1e18261dbd047b06ee4ceb7cd257862ac6 100755 GIT binary patch delta 68653 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 delta 69164 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 MZ*oEdmp`-pAJ_dZ^#A|> diff --git a/examples/cloudflare-workers/src/ci.ts b/examples/cloudflare-workers/src/ci.ts index 837d2dac..38aa5dfc 100644 --- a/examples/cloudflare-workers/src/ci.ts +++ b/examples/cloudflare-workers/src/ci.ts @@ -2,11 +2,14 @@ * Entry point used in qstash-js CI tests */ -import { Client } from "@upstash/qstash" -import { CRON, DESTINATION } from "./constants"; +import { Client, Receiver } from "@upstash/qstash" +import { CRON, DESTINATION, VERIFY_BODY } from "./constants"; export type Env = { QSTASH_TOKEN: string + // Only set in the deployed CI job, used by the /verify endpoint below. + QSTASH_CURRENT_SIGNING_KEY?: string + QSTASH_NEXT_SIGNING_KEY?: string }; export default { @@ -15,24 +18,76 @@ export default { throw new Error("CI test failed. QSTASH_TOKEN is missing.") } - // create schedule - const client = new Client({ token: env.QSTASH_TOKEN }) - const { scheduleId } = await client.schedules.create({ - destination: DESTINATION, - cron: CRON, - }); - - // check schedule - const schedule = await client.schedules.get(scheduleId) - if (schedule.destination !== DESTINATION) throw new Error( - `incorrect destionation. expected ${DESTINATION}, got ${schedule.destination}` - ) - if (schedule.cron !== CRON) throw new Error( - `incorrect cron. expected ${CRON}, got ${schedule.cron}` - ) - - // delete schedule - await client.schedules.delete(scheduleId) - return new Response(JSON.stringify(schedule), { status: 200 }); + const url = new URL(request.url); + + // Publishes a message to this same worker's /verify endpoint and returns + // the message id so the test can follow it in the message logs. + if (url.pathname === "/publish") { + return handlePublish(env, url.origin); + } + + // Endpoint with a verifier: QStash delivers the signed message here and the + // Receiver checks the signature. Returns 200 only when the signature is valid. + if (url.pathname === "/verify") { + return handleVerify(request, env); + } + + return handleSchedule(env); + } +} + +async function handlePublish(env: Env, origin: string): Promise { + const client = new Client({ token: env.QSTASH_TOKEN }) + const { messageId } = await client.publishJSON({ + url: `${origin}/verify`, + body: VERIFY_BODY, + }); + return new Response(JSON.stringify({ messageId }), { status: 200 }); +} + +async function handleVerify(request: Request, env: Env): Promise { + if (!env.QSTASH_CURRENT_SIGNING_KEY || !env.QSTASH_NEXT_SIGNING_KEY) { + return new Response("signing keys are missing", { status: 500 }); } + + const signature = request.headers.get("Upstash-Signature"); + if (!signature) { + return new Response("missing Upstash-Signature header", { status: 401 }); + } + + const receiver = new Receiver({ + currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, + nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, + }); + + const body = await request.text(); + try { + await receiver.verify({ signature, body }); + } catch (error) { + return new Response(`invalid signature: ${(error as Error).message}`, { status: 401 }); + } + + return new Response("OK", { status: 200 }); +} + +async function handleSchedule(env: Env): Promise { + // create schedule + const client = new Client({ token: env.QSTASH_TOKEN }) + const { scheduleId } = await client.schedules.create({ + destination: DESTINATION, + cron: CRON, + }); + + // check schedule + const schedule = await client.schedules.get(scheduleId) + if (schedule.destination !== DESTINATION) throw new Error( + `incorrect destionation. expected ${DESTINATION}, got ${schedule.destination}` + ) + if (schedule.cron !== CRON) throw new Error( + `incorrect cron. expected ${CRON}, got ${schedule.cron}` + ) + + // delete schedule + await client.schedules.delete(scheduleId) + return new Response(JSON.stringify(schedule), { status: 200 }); } diff --git a/examples/cloudflare-workers/src/constants.ts b/examples/cloudflare-workers/src/constants.ts index a992fee5..b00440c9 100644 --- a/examples/cloudflare-workers/src/constants.ts +++ b/examples/cloudflare-workers/src/constants.ts @@ -1,3 +1,6 @@ export const CRON = "*/30 * * * *" export const DESTINATION = "https://qstash-js-ci.requestcatcher.com/" + +// Body published to the worker's own /verify endpoint in the delivery round-trip test. +export const VERIFY_BODY = { hello: "qstash-js cloudflare ci" } diff --git a/examples/cloudflare-workers/verify.test.ts b/examples/cloudflare-workers/verify.test.ts new file mode 100644 index 00000000..9199d545 --- /dev/null +++ b/examples/cloudflare-workers/verify.test.ts @@ -0,0 +1,54 @@ +import { Client } from "@upstash/qstash"; +import { test, expect } from "bun:test"; + +// End-to-end delivery round trip. The worker publishes a message to its own +// /verify endpoint (which runs a Receiver), QStash delivers the signed request, +// and we poll the message logs until QStash reports it as DELIVERED. +// +// Requires a publicly reachable worker, so this only runs in the deployed CI job. +const deploymentURL = process.env.DEPLOYMENT_URL; +if (!deploymentURL) { + throw new Error("DEPLOYMENT_URL not set"); +} + +const token = process.env.QSTASH_TOKEN; +if (!token) { + throw new Error("QSTASH_TOKEN not set"); +} + +test( + "publishes to the verify endpoint and the message is delivered", + async () => { + // 1. Ask the worker to publish a message to its own /verify endpoint. + const res = await fetch(`${deploymentURL}/publish`); + if (res.status !== 200) { + console.log(await res.text()); + } + expect(res.status).toEqual(200); + + const { messageId } = (await res.json()) as { messageId: string }; + expect(messageId).toBeTruthy(); + + // 2. Poll the message logs until the message reaches a terminal state. + const client = new Client({ token }); + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const { logs } = await client.logs({ messageIds: [messageId] }); + const states = new Set(logs.map((log) => log.state)); + + if (states.has("DELIVERED")) { + return; // success: the signed message was verified and delivered + } + if (states.has("FAILED") || states.has("CANCELED")) { + throw new Error( + `message ${messageId} did not deliver: ${JSON.stringify(logs)}` + ); + } + + await Bun.sleep(1000); + } + + throw new Error(`message ${messageId} was not DELIVERED within 60s`); + }, + 90_000 +); diff --git a/package.json b/package.json index dca85879..18b02dee 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,6 @@ "@solidjs/start": "^1.0.6", "@sveltejs/kit": "^2.5.18", "@types/bun": "^1.1.1", - "@types/crypto-js": "^4.2.0", "@typescript-eslint/eslint-plugin": "^8.4.0", "@typescript-eslint/parser": "^8.4.0", "bun-types": "^1.1.7", @@ -101,7 +100,6 @@ }, "dependencies": { "neverthrow": "^7.0.1", - "crypto-js": ">=4.2.0", "jose": "^5.2.3" } } diff --git a/src/receiver.ts b/src/receiver.ts index d7f3ff07..104698d3 100644 --- a/src/receiver.ts +++ b/src/receiver.ts @@ -1,8 +1,24 @@ import * as jose from "jose"; -import crypto from "crypto-js"; import { getSafeEnvironment } from "./client/utils"; import { getReceiverSigningKeys } from "./client/multi-region"; +/** + * Computes the SHA-256 hash of the given string and returns it as a + * base64url-encoded value (without padding). + * + * Uses the Web Crypto API (`globalThis.crypto`), available in Node.js 16+, + * browsers, and edge runtimes. This replaces `crypto-js`, which relied on the + * deprecated `url.parse()` and triggered Node.js DEP0169 warnings. + */ +async function sha256Base64url(body: string): Promise { + const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)); + let binary = ""; + for (const byte of new Uint8Array(hashBuffer)) { + binary += String.fromCodePoint(byte); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll(/=+$/g, ""); +} + /** * Necessary to verify the signature of a request. */ @@ -119,7 +135,7 @@ export class Receiver { } catch { payload = await this.verifyWithKey(signingKeys.nextSigningKey, request); } - this.verifyBodyAndUrl(payload, request); + await this.verifyBodyAndUrl(payload, request); return true; } @@ -139,7 +155,7 @@ export class Receiver { return jwt.payload; } - private verifyBodyAndUrl(payload: jose.JWTPayload, request: VerifyRequest) { + private async verifyBodyAndUrl(payload: jose.JWTPayload, request: VerifyRequest) { const p = payload as { iss: string; sub: string; @@ -154,7 +170,7 @@ export class Receiver { throw new SignatureError(`invalid subject: ${p.sub}, want: ${request.url}`); } - const bodyHash = crypto.SHA256(request.body).toString(crypto.enc.Base64url); + const bodyHash = await sha256Base64url(request.body); const padding = new RegExp(/=+$/); From 375c379ad23bd4368c25d234374469be4e9180fa Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 11:00:29 +0300 Subject: [PATCH 02/13] fix: replace sleep with eventually for better DLQ message retrieval --- src/client/dlq.test.ts | 46 ++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/client/dlq.test.ts b/src/client/dlq.test.ts index 309da031..54331701 100644 --- a/src/client/dlq.test.ts +++ b/src/client/dlq.test.ts @@ -238,18 +238,21 @@ describe("DLQ", () => { label: [labelOne, labelTwo], }); - await sleep(4000); - - const dlqLogs = await client.dlq.listMessages({ - filter: { label: [labelOne, labelTwo] }, - }); + await eventually( + async () => { + const dlqLogs = await client.dlq.listMessages({ + filter: { label: [labelOne, labelTwo] }, + }); - const message = dlqLogs.messages.find((m) => m.messageId === messageId); - expect(message).toBeDefined(); - // legacy `label` carries only the first label - expect(message!.label).toBe(labelOne); - // new `labels` carries all of them - expect(message!.labels).toEqual([labelOne, labelTwo]); + const message = dlqLogs.messages.find((m) => m.messageId === messageId); + expect(message).toBeDefined(); + // legacy `label` carries only the first label + expect(message!.label).toBe(labelOne); + // new `labels` carries all of them + expect(message!.labels).toEqual([labelOne, labelTwo]); + }, + { timeout: 15_000, interval: 1000 } + ); await client.dlq.delete({ filter: { label: [labelOne, labelTwo] } }); }, @@ -280,17 +283,20 @@ describe("DLQ", () => { label: labelC, }); - await sleep(4000); - // filtering by [A, B] should match msgAB and msgBC (both share a label) // but NOT msgC. - const dlqLogs = await client.dlq.listMessages({ - filter: { label: [labelA, labelB] }, - }); - const ids = new Set(dlqLogs.messages.map((m) => m.messageId)); - expect(ids.has(messageAB)).toBe(true); - expect(ids.has(messageBC)).toBe(true); - expect(ids.has(messageC)).toBe(false); + await eventually( + async () => { + const dlqLogs = await client.dlq.listMessages({ + filter: { label: [labelA, labelB] }, + }); + const ids = new Set(dlqLogs.messages.map((m) => m.messageId)); + expect(ids.has(messageAB)).toBe(true); + expect(ids.has(messageBC)).toBe(true); + expect(ids.has(messageC)).toBe(false); + }, + { timeout: 15_000, interval: 1000 } + ); await client.dlq.delete({ filter: { label: [labelA, labelB, labelC] } }); }, From d18ce0910c2aa7b67bb044321d45e19778bab707 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 12:33:38 +0300 Subject: [PATCH 03/13] test: replace fixed sleeps with eventually polling in DLQ and flow-control tests The DLQ and flow-control integration tests waited a fixed duration before asserting, which raced against QStash eventual consistency and flaked in CI. Convert them to poll via the existing eventually helper with timeouts longer than the sleeps they replace. --- src/client/dlq.test.ts | 71 +++++++++++++++++++-------------- src/client/flow-control.test.ts | 18 ++++++--- 2 files changed, 53 insertions(+), 36 deletions(-) diff --git a/src/client/dlq.test.ts b/src/client/dlq.test.ts index 54331701..0b22ea4c 100644 --- a/src/client/dlq.test.ts +++ b/src/client/dlq.test.ts @@ -176,25 +176,28 @@ describe("DLQ", () => { retryDelay, }); - await eventually(async () => { - const result = await client.dlq.listMessages({ - filter: { - messageId, - }, - }); - expect(result.messages.length).toBe(1); - const message = result.messages[0]; - - expect(message.flowControlKey).toBe(randomKey); - expect(message.parallelism).toBe(parallelism); - expect(message.ratePerSecond).toBe(ratePerSecond); - expect(message.rate).toBe(ratePerSecond); - expect(message.period).toBe(SECONDS_IN_A_DAY); - expect(message.retryDelayExpression).toBe(retryDelay); - }); + await eventually( + async () => { + const result = await client.dlq.listMessages({ + filter: { + messageId, + }, + }); + expect(result.messages.length).toBe(1); + const message = result.messages[0]; + + expect(message.flowControlKey).toBe(randomKey); + expect(message.parallelism).toBe(parallelism); + expect(message.ratePerSecond).toBe(ratePerSecond); + expect(message.rate).toBe(ratePerSecond); + expect(message.period).toBe(SECONDS_IN_A_DAY); + expect(message.retryDelayExpression).toBe(retryDelay); + }, + { timeout: 20_000, interval: 1000 } + ); }, { - timeout: 10_000, + timeout: 30_000, } ); @@ -500,11 +503,14 @@ describe("DLQ", () => { label, }); - await sleep(10_000); - - // Verify messages are in DLQ - const dlqBefore = await client.dlq.listMessages({ filter: { label } }); - expect(dlqBefore.messages.length).toBeGreaterThanOrEqual(2); + // Wait for both messages to land in the DLQ + await eventually( + async () => { + const dlqBefore = await client.dlq.listMessages({ filter: { label } }); + expect(dlqBefore.messages.length).toBeGreaterThanOrEqual(2); + }, + { timeout: 20_000, interval: 1000 } + ); // Delete using filter overload const deleteResult = await client.dlq.delete({ filter: { label } }); @@ -516,7 +522,7 @@ describe("DLQ", () => { const dlqAfter = await client.dlq.listMessages({ filter: { label } }); expect(dlqAfter.messages.length).toBe(0); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -527,12 +533,17 @@ describe("DLQ", () => { retries: 0, }); - await sleep(10_000); - - const dlqLogs = await client.dlq.listMessages({ filter: { messageId: message.messageId } }); - const dlqMessage = dlqLogs.messages.find((dlq) => dlq.messageId === message.messageId); - - expect(dlqMessage).toBeDefined(); + let dlqMessage: { dlqId: string; messageId: string } | undefined; + await eventually( + async () => { + const dlqLogs = await client.dlq.listMessages({ + filter: { messageId: message.messageId }, + }); + dlqMessage = dlqLogs.messages.find((dlq) => dlq.messageId === message.messageId); + expect(dlqMessage).toBeDefined(); + }, + { timeout: 20_000, interval: 1000 } + ); // Retry using single string overload const retryResult = await client.dlq.retry(dlqMessage!.dlqId); @@ -543,7 +554,7 @@ describe("DLQ", () => { expect(retryResult.responses[0].messageId).toBeDefined(); expect(client.dlq.delete(dlqMessage!.dlqId)).rejects.toThrow(); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( diff --git a/src/client/flow-control.test.ts b/src/client/flow-control.test.ts index e91801d5..89542940 100644 --- a/src/client/flow-control.test.ts +++ b/src/client/flow-control.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import { Client } from "./client"; +import { eventually } from "./logs.test"; describe("FlowControl", () => { const client = new Client({ token: process.env.QSTASH_TOKEN! }); @@ -189,12 +190,17 @@ describe("FlowControl", () => { }, }); - // Reset the rate - await client.flowControl.resetRate(flowControlKey); - - // Verify rate was reset by checking the flow control info - const info = await client.flowControl.get(flowControlKey); - expect(info.rateCount).toBe(0); + // Reset the rate and verify it settles back to zero. The publish above is + // counted asynchronously, so it may land between the reset and the check; + // retry until the reset wins out. + await eventually( + async () => { + await client.flowControl.resetRate(flowControlKey); + const info = await client.flowControl.get(flowControlKey); + expect(info.rateCount).toBe(0); + }, + { timeout: 15_000, interval: 1000 } + ); // Clean up // eslint-disable-next-line @typescript-eslint/no-deprecated From 693f38d911e8b8902724015bfad44083fe4b5f5e Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 12:54:38 +0300 Subject: [PATCH 04/13] test: convert all remaining DLQ sleeps to eventually polling Replace every fixed sleep() in dlq.test.ts with eventually polling and remove the now-unused bun sleep import. Standardize all DLQ polls to a 20s timeout with 30s outer test timeouts so slower httpbin-backed cases (e.g. filter by label, filter by flowControlKey) stop flaking under backend latency. --- src/client/dlq.test.ts | 230 +++++++++++++++++++++++++---------------- 1 file changed, 140 insertions(+), 90 deletions(-) diff --git a/src/client/dlq.test.ts b/src/client/dlq.test.ts index 0b22ea4c..5b393aae 100644 --- a/src/client/dlq.test.ts +++ b/src/client/dlq.test.ts @@ -2,7 +2,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-deprecated */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { sleep } from "bun"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { Client } from "./client"; import { eventually } from "./logs.test"; @@ -44,12 +43,17 @@ describe("DLQ", () => { retries: 0, }); - await sleep(10_000); - - const dlqLogs = await client.dlq.listMessages({ filter: { messageId: message.messageId } }); - expect(dlqLogs.messages.map((dlq) => dlq.messageId)).toContain(message.messageId); + await eventually( + async () => { + const dlqLogs = await client.dlq.listMessages({ + filter: { messageId: message.messageId }, + }); + expect(dlqLogs.messages.map((dlq) => dlq.messageId)).toContain(message.messageId); + }, + { timeout: 20_000, interval: 1000 } + ); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -60,24 +64,34 @@ describe("DLQ", () => { retries: 0, }); - await sleep(10_000); - - let dlqLogs = await client.dlq.listMessages({ filter: { messageId: message.messageId } }); - let dlqMessage = dlqLogs.messages.find((dlq) => dlq.messageId === message.messageId); - expect(dlqMessage).toBeDefined(); + let dlqMessage: { dlqId: string; messageId: string } | undefined; + await eventually( + async () => { + const dlqLogs = await client.dlq.listMessages({ + filter: { messageId: message.messageId }, + }); + dlqMessage = dlqLogs.messages.find((dlq) => dlq.messageId === message.messageId); + expect(dlqMessage).toBeDefined(); + }, + { timeout: 20_000, interval: 1000 } + ); const deletedDlqId = dlqMessage?.dlqId ?? ""; await client.dlq.delete(dlqMessage?.dlqId ?? ""); - dlqLogs = await client.dlq.listMessages({ filter: { messageId: message.messageId } }); - dlqMessage = dlqLogs.messages.find((dlq) => dlq.messageId === message.messageId); + const dlqLogsAfter = await client.dlq.listMessages({ + filter: { messageId: message.messageId }, + }); + const dlqMessageAfter = dlqLogsAfter.messages.find( + (dlq) => dlq.messageId === message.messageId + ); - expect(dlqMessage).toBeUndefined(); + expect(dlqMessageAfter).toBeUndefined(); // Single-item delete should preserve 404 semantics when item no longer exists. expect(client.dlq.delete(deletedDlqId)).rejects.toThrow(); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -100,7 +114,7 @@ describe("DLQ", () => { expect(result.messages[0].messageId).toBe(message.messageId); }, { - timeout: 15_000, + timeout: 20_000, interval: 1000, } ); @@ -113,7 +127,7 @@ describe("DLQ", () => { await client.dlq.delete(result.messages[0].dlqId); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -140,7 +154,7 @@ describe("DLQ", () => { expect(result.messages[0].messageId).toBe(message[0].messageId); }, { - timeout: 15_000, + timeout: 20_000, interval: 1000, } ); @@ -153,7 +167,7 @@ describe("DLQ", () => { await client.dlq.delete(result.messages[0].dlqId); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -211,22 +225,25 @@ describe("DLQ", () => { label: testLabel, }); - await sleep(4000); + await eventually( + async () => { + const dlqLogs = await client.dlq.listMessages({ + filter: { + label: testLabel, + }, + }); - const dlqLogs = await client.dlq.listMessages({ - filter: { - label: testLabel, + expect(dlqLogs.messages.length).toBeGreaterThanOrEqual(1); + for (const message of dlqLogs.messages) { + if (message.label !== undefined) { + expect(message.label).toBe(testLabel); + } + } }, - }); - - expect(dlqLogs.messages.length).toBeGreaterThanOrEqual(1); - for (const message of dlqLogs.messages) { - if (message.label !== undefined) { - expect(message.label).toBe(testLabel); - } - } + { timeout: 20_000, interval: 1000 } + ); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -254,12 +271,12 @@ describe("DLQ", () => { // new `labels` carries all of them expect(message!.labels).toEqual([labelOne, labelTwo]); }, - { timeout: 15_000, interval: 1000 } + { timeout: 20_000, interval: 1000 } ); await client.dlq.delete({ filter: { label: [labelOne, labelTwo] } }); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -298,12 +315,12 @@ describe("DLQ", () => { expect(ids.has(messageBC)).toBe(true); expect(ids.has(messageC)).toBe(false); }, - { timeout: 15_000, interval: 1000 } + { timeout: 20_000, interval: 1000 } ); await client.dlq.delete({ filter: { label: [labelA, labelB, labelC] } }); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -350,7 +367,7 @@ describe("DLQ", () => { expect(dlqMessage3).toBeDefined(); }, { - timeout: 15_000, + timeout: 20_000, interval: 1000, } ); @@ -369,7 +386,7 @@ describe("DLQ", () => { // Clean up - delete the retried messages from DLQ await client.dlq.deleteMany({ dlqIds }); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -389,20 +406,32 @@ describe("DLQ", () => { retries: 0, }); - await sleep(10_000); - - // Get all messages from DLQ - const dlqLogs1 = await client.dlq.listMessages({ filter: { messageId: message1.messageId } }); - const dlqLogs2 = await client.dlq.listMessages({ filter: { messageId: message2.messageId } }); - const dlqLogs3 = await client.dlq.listMessages({ filter: { messageId: message3.messageId } }); + // Wait for all three messages to land in the DLQ + let dlqMessage1: { dlqId: string; messageId: string } | undefined; + let dlqMessage2: { dlqId: string; messageId: string } | undefined; + let dlqMessage3: { dlqId: string; messageId: string } | undefined; + await eventually( + async () => { + const dlqLogs1 = await client.dlq.listMessages({ + filter: { messageId: message1.messageId }, + }); + const dlqLogs2 = await client.dlq.listMessages({ + filter: { messageId: message2.messageId }, + }); + const dlqLogs3 = await client.dlq.listMessages({ + filter: { messageId: message3.messageId }, + }); - const dlqMessage1 = dlqLogs1.messages.find((dlq) => dlq.messageId === message1.messageId); - const dlqMessage2 = dlqLogs2.messages.find((dlq) => dlq.messageId === message2.messageId); - const dlqMessage3 = dlqLogs3.messages.find((dlq) => dlq.messageId === message3.messageId); + dlqMessage1 = dlqLogs1.messages.find((dlq) => dlq.messageId === message1.messageId); + dlqMessage2 = dlqLogs2.messages.find((dlq) => dlq.messageId === message2.messageId); + dlqMessage3 = dlqLogs3.messages.find((dlq) => dlq.messageId === message3.messageId); - expect(dlqMessage1).toBeDefined(); - expect(dlqMessage2).toBeDefined(); - expect(dlqMessage3).toBeDefined(); + expect(dlqMessage1).toBeDefined(); + expect(dlqMessage2).toBeDefined(); + expect(dlqMessage3).toBeDefined(); + }, + { timeout: 20_000, interval: 1000 } + ); // Delete all three messages const dlqIds = [dlqMessage1!.dlqId, dlqMessage2!.dlqId, dlqMessage3!.dlqId]; @@ -438,7 +467,7 @@ describe("DLQ", () => { expect(dlqMessageAfterDelete2).toBeUndefined(); expect(dlqMessageAfterDelete3).toBeUndefined(); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -453,16 +482,25 @@ describe("DLQ", () => { retries: 0, }); - await sleep(10_000); - - const dlqLogs1 = await client.dlq.listMessages({ filter: { messageId: message1.messageId } }); - const dlqLogs2 = await client.dlq.listMessages({ filter: { messageId: message2.messageId } }); + let dlqMessage1: { dlqId: string; messageId: string } | undefined; + let dlqMessage2: { dlqId: string; messageId: string } | undefined; + await eventually( + async () => { + const dlqLogs1 = await client.dlq.listMessages({ + filter: { messageId: message1.messageId }, + }); + const dlqLogs2 = await client.dlq.listMessages({ + filter: { messageId: message2.messageId }, + }); - const dlqMessage1 = dlqLogs1.messages.find((dlq) => dlq.messageId === message1.messageId); - const dlqMessage2 = dlqLogs2.messages.find((dlq) => dlq.messageId === message2.messageId); + dlqMessage1 = dlqLogs1.messages.find((dlq) => dlq.messageId === message1.messageId); + dlqMessage2 = dlqLogs2.messages.find((dlq) => dlq.messageId === message2.messageId); - expect(dlqMessage1).toBeDefined(); - expect(dlqMessage2).toBeDefined(); + expect(dlqMessage1).toBeDefined(); + expect(dlqMessage2).toBeDefined(); + }, + { timeout: 20_000, interval: 1000 } + ); // Delete using string[] overload const deleteResult = await client.dlq.delete([dlqMessage1!.dlqId, dlqMessage2!.dlqId]); @@ -485,7 +523,7 @@ describe("DLQ", () => { afterDelete2.messages.find((dlq) => dlq.messageId === message2.messageId) ).toBeUndefined(); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -569,16 +607,25 @@ describe("DLQ", () => { retries: 0, }); - await sleep(10_000); - - const dlqLogs1 = await client.dlq.listMessages({ filter: { messageId: message1.messageId } }); - const dlqLogs2 = await client.dlq.listMessages({ filter: { messageId: message2.messageId } }); + let dlqMessage1: { dlqId: string; messageId: string } | undefined; + let dlqMessage2: { dlqId: string; messageId: string } | undefined; + await eventually( + async () => { + const dlqLogs1 = await client.dlq.listMessages({ + filter: { messageId: message1.messageId }, + }); + const dlqLogs2 = await client.dlq.listMessages({ + filter: { messageId: message2.messageId }, + }); - const dlqMessage1 = dlqLogs1.messages.find((dlq) => dlq.messageId === message1.messageId); - const dlqMessage2 = dlqLogs2.messages.find((dlq) => dlq.messageId === message2.messageId); + dlqMessage1 = dlqLogs1.messages.find((dlq) => dlq.messageId === message1.messageId); + dlqMessage2 = dlqLogs2.messages.find((dlq) => dlq.messageId === message2.messageId); - expect(dlqMessage1).toBeDefined(); - expect(dlqMessage2).toBeDefined(); + expect(dlqMessage1).toBeDefined(); + expect(dlqMessage2).toBeDefined(); + }, + { timeout: 20_000, interval: 1000 } + ); // Retry using string[] overload const retryResult = await client.dlq.retry([dlqMessage1!.dlqId, dlqMessage2!.dlqId]); @@ -591,7 +638,7 @@ describe("DLQ", () => { // Clean up await client.dlq.delete([dlqMessage1!.dlqId, dlqMessage2!.dlqId]); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -609,11 +656,14 @@ describe("DLQ", () => { label, }); - await sleep(10_000); - - // Verify messages are in DLQ - const dlqBefore = await client.dlq.listMessages({ filter: { label } }); - expect(dlqBefore.messages.length).toBeGreaterThanOrEqual(2); + // Wait for both messages to land in the DLQ + await eventually( + async () => { + const dlqBefore = await client.dlq.listMessages({ filter: { label } }); + expect(dlqBefore.messages.length).toBeGreaterThanOrEqual(2); + }, + { timeout: 20_000, interval: 1000 } + ); // Retry using filter overload const retryResult = await client.dlq.retry({ filter: { label } }); @@ -626,7 +676,7 @@ describe("DLQ", () => { // Clean up await client.dlq.delete({ filter: { label } }); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -641,7 +691,7 @@ describe("DLQ", () => { const dlq = await client.dlq.listMessages({ filter: { label } }); expect(dlq.messages.length).toBe(2); }, - { timeout: 15_000, interval: 1000 } + { timeout: 20_000, interval: 1000 } ); const result = await client.dlq.delete({ all: true, count: 1 }); @@ -650,7 +700,7 @@ describe("DLQ", () => { // clean up remaining await client.dlq.delete({ filter: { label } }); }, - { timeout: 25_000 } + { timeout: 30_000 } ); test( @@ -665,7 +715,7 @@ describe("DLQ", () => { const dlq = await client.dlq.listMessages({ filter: { label } }); expect(dlq.messages.length).toBe(2); }, - { timeout: 15_000, interval: 1000 } + { timeout: 20_000, interval: 1000 } ); const result = await client.dlq.delete({ filter: { label }, count: 1 }); @@ -674,7 +724,7 @@ describe("DLQ", () => { // clean up remaining await client.dlq.delete({ filter: { label } }); }, - { timeout: 25_000 } + { timeout: 30_000 } ); test( @@ -689,7 +739,7 @@ describe("DLQ", () => { const dlq = await client.dlq.listMessages({ filter: { label } }); expect(dlq.messages.length).toBe(2); }, - { timeout: 15_000, interval: 1000 } + { timeout: 20_000, interval: 1000 } ); const result = await client.dlq.retry({ all: true, count: 1 }); @@ -698,7 +748,7 @@ describe("DLQ", () => { // clean up remaining await client.dlq.delete({ filter: { label } }); }, - { timeout: 25_000 } + { timeout: 30_000 } ); test( @@ -713,7 +763,7 @@ describe("DLQ", () => { const dlq = await client.dlq.listMessages({ filter: { label } }); expect(dlq.messages.length).toBe(2); }, - { timeout: 15_000, interval: 1000 } + { timeout: 20_000, interval: 1000 } ); const result = await client.dlq.retry({ filter: { label }, count: 1 }); @@ -722,7 +772,7 @@ describe("DLQ", () => { // clean up remaining await client.dlq.delete({ filter: { label } }); }, - { timeout: 25_000 } + { timeout: 30_000 } ); test("should return empty result when retry is called with an empty array", async () => { @@ -755,7 +805,7 @@ describe("DLQ", () => { }); expect(dlqLogs.messages.length).toBe(1); }, - { timeout: 15_000, interval: 1000 } + { timeout: 20_000, interval: 1000 } ); const dlqLogs = await client.dlq.listMessages({ @@ -770,7 +820,7 @@ describe("DLQ", () => { // cursor should not be returned when using explicit dlqIds expect(retryResult.cursor).toBeUndefined(); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -795,14 +845,14 @@ describe("DLQ", () => { expect(result.messages.length).toBe(1); expect(result.messages[0].flowControlKey).toBe(flowKey); }, - { timeout: 15_000, interval: 1000 } + { timeout: 20_000, interval: 1000 } ); const result = await client.dlq.delete({ filter: { flowControlKey: flowKey } }); expect(result).toBeDefined(); expect(result.deleted).toBe(1); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -820,7 +870,7 @@ describe("DLQ", () => { const dlqBefore = await client.dlq.listMessages({ filter: { label } }); expect(dlqBefore.messages.length).toBeGreaterThanOrEqual(1); }, - { timeout: 15_000, interval: 1000 } + { timeout: 20_000, interval: 1000 } ); const retryResult = await client.dlq.retry({ filter: { label } }); @@ -831,7 +881,7 @@ describe("DLQ", () => { await client.dlq.delete({ filter: { label } }); }, - { timeout: 20_000 } + { timeout: 30_000 } ); test( @@ -877,7 +927,7 @@ describe("DLQ", () => { const dlqLogs = await client.dlq.listMessages({ filter: { label } }); expect(dlqLogs.messages.length).toBeGreaterThanOrEqual(1); }, - { timeout: 15_000, interval: 1000 } + { timeout: 20_000, interval: 1000 } ); // Test each filter field individually to make sure none of them error From 1106c540a2a358234e4e91e08e6c77696d3357f6 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 13:13:33 +0300 Subject: [PATCH 05/13] test: fix flaky url-group and logs label tests - logs 'filter by label' (and the other label round-trip tests) called eventually without an explicit timeout, so they fell back to the helper's 5s default and timed out before the log appeared. Give them a 20s poll / 30s outer timeout. - url-group tests ran out of the default 5s while making several sequential API calls; bump them to 30s. The longer timeout also surfaced a latent bug: the add-endpoint test asserted on list[0], but list() returns every url group in the account. Look the group up by name instead. --- src/client/logs.test.ts | 13 ++++--- src/client/url-groups.test.ts | 72 ++++++++++++++++++++--------------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/src/client/logs.test.ts b/src/client/logs.test.ts index cb875277..4368b4ba 100644 --- a/src/client/logs.test.ts +++ b/src/client/logs.test.ts @@ -23,12 +23,13 @@ describe("logs", () => { expect(result.logs[0].label).toBe(label); }, { + timeout: 20_000, interval: 1000, } ); }, { - timeout: 10_000, + timeout: 30_000, } ); const client = new Client({ token: process.env.QSTASH_TOKEN! }); @@ -55,10 +56,10 @@ describe("logs", () => { // new `labels` carries all of them expect(log!.labels).toEqual([labelOne, labelTwo]); }, - { interval: 1000 } + { timeout: 20_000, interval: 1000 } ); }, - { timeout: 10_000 } + { timeout: 30_000 } ); test( @@ -95,10 +96,10 @@ describe("logs", () => { expect(ids.has(messageBC)).toBe(true); expect(ids.has(messageC)).toBe(false); }, - { interval: 1000 } + { timeout: 20_000, interval: 1000 } ); }, - { timeout: 15_000 } + { timeout: 30_000 } ); test("should use cursor", async () => { @@ -131,7 +132,7 @@ describe("logs", () => { expect(result.cursor).toBeUndefined(); }, - { timeout: 10_000 } + { timeout: 30_000 } ); }); diff --git a/src/client/url-groups.test.ts b/src/client/url-groups.test.ts index 429d2d53..e6d43d98 100644 --- a/src/client/url-groups.test.ts +++ b/src/client/url-groups.test.ts @@ -5,34 +5,46 @@ import { Client } from "./client"; describe("url group", () => { const client = new Client({ token: process.env.QSTASH_TOKEN! }); - test("should create a url group, check and delete it", async () => { - const endpoint = { name: "url-group1", url: "https://oz.requestcatcher.com" }; - await client.urlGroups.addEndpoints({ - endpoints: [endpoint], - name: "my-proxy-url-group", - }); - - const urlGroup = await client.urlGroups.get("my-proxy-url-group"); - await client.urlGroups.delete("my-proxy-url-group"); - expect(urlGroup.endpoints).toContainEqual(endpoint); - }); - - test("should create a url group, and add one more endpoint then delete it", async () => { - const endpoint = { name: "urlGroup1", url: "https://oz.requestcatcher.com" }; - const endpoint1 = { name: "urlGroup2", url: "https://oz1.requestcatcher.com" }; - - await client.urlGroups.addEndpoints({ - endpoints: [endpoint], - name: "my-proxy-url-group", - }); - - await client.urlGroups.get("my-proxy-url-group"); - await client.urlGroups.addEndpoints({ name: "my-proxy-url-group", endpoints: [endpoint1] }); - - const list = await client.urlGroups.list(); - await client.urlGroups.delete("my-proxy-url-group"); - - expect(list[0].endpoints).toContainEqual(endpoint); - expect(list[0].endpoints).toContainEqual(endpoint1); - }); + test( + "should create a url group, check and delete it", + async () => { + const endpoint = { name: "url-group1", url: "https://oz.requestcatcher.com" }; + await client.urlGroups.addEndpoints({ + endpoints: [endpoint], + name: "my-proxy-url-group", + }); + + const urlGroup = await client.urlGroups.get("my-proxy-url-group"); + await client.urlGroups.delete("my-proxy-url-group"); + expect(urlGroup.endpoints).toContainEqual(endpoint); + }, + { timeout: 30_000 } + ); + + test( + "should create a url group, and add one more endpoint then delete it", + async () => { + const endpoint = { name: "urlGroup1", url: "https://oz.requestcatcher.com" }; + const endpoint1 = { name: "urlGroup2", url: "https://oz1.requestcatcher.com" }; + + await client.urlGroups.addEndpoints({ + endpoints: [endpoint], + name: "my-proxy-url-group", + }); + + await client.urlGroups.get("my-proxy-url-group"); + await client.urlGroups.addEndpoints({ name: "my-proxy-url-group", endpoints: [endpoint1] }); + + const list = await client.urlGroups.list(); + await client.urlGroups.delete("my-proxy-url-group"); + + // list() returns every url group in the account, so look ours up by name + // instead of assuming it is first. + const group = list.find((g) => g.name === "my-proxy-url-group"); + expect(group).toBeDefined(); + expect(group!.endpoints).toContainEqual(endpoint); + expect(group!.endpoints).toContainEqual(endpoint1); + }, + { timeout: 30_000 } + ); }); From 4d9e02b7f6774751951a3bd76046138891bf48d3 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 13:27:13 +0300 Subject: [PATCH 06/13] =?UTF-8?q?test:=20add=20publish=E2=86=92verify?= =?UTF-8?q?=E2=86=92delivered=20round-trip=20to=20nextjs=20and=20cf=20exam?= =?UTF-8?q?ples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the Cloudflare delivery round-trip in the Next.js example: a /roundtrip/publish route publishes a message to a verifySignatureAppRouter-backed /roundtrip/verify endpoint on the same app, and verify.test.ts polls the message logs until QStash reports DELIVERED. The nextjs-deployed job injects the token and signing keys into the Vercel deployment and runs the test. Also add a negative test to both examples that hits the verifier endpoints directly without a signature and asserts they reject with 403 instead of 200, and make the cloudflare worker's /verify return 403 to match. --- .github/workflows/test.yaml | 9 ++- examples/cloudflare-workers/src/ci.ts | 4 +- examples/cloudflare-workers/verify.test.ts | 13 ++++ .../nextjs/app/roundtrip/publish/route.ts | 21 ++++++ examples/nextjs/app/roundtrip/verify/route.ts | 12 +++ examples/nextjs/verify.test.ts | 73 +++++++++++++++++++ 6 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 examples/nextjs/app/roundtrip/publish/route.ts create mode 100644 examples/nextjs/app/roundtrip/verify/route.ts create mode 100644 examples/nextjs/verify.test.ts diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 201dc4e0..4f218487 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -248,7 +248,10 @@ jobs: - name: Deploy run: | pnpm add @upstash/qstash@${{needs.release.outputs.version}} - DEPLOYMENT_URL=$(npx vercel --token=${{ secrets.VERCEL_TOKEN }}) + DEPLOYMENT_URL=$(npx vercel --token=${{ secrets.VERCEL_TOKEN }} \ + -e QSTASH_TOKEN="$QSTASH_TOKEN" \ + -e QSTASH_CURRENT_SIGNING_KEY="$QSTASH_CURRENT_SIGNING_KEY" \ + -e QSTASH_NEXT_SIGNING_KEY="$QSTASH_NEXT_SIGNING_KEY") echo "DEPLOYMENT_URL=${DEPLOYMENT_URL}" >> $GITHUB_ENV env: VERCEL_ORG_ID: ${{secrets.VERCEL_TEAM_ID}} @@ -259,6 +262,10 @@ jobs: run: bun test ci.test.ts working-directory: examples/nextjs + - name: Test delivery round-trip (publish → verify endpoint → delivered) + run: bun test verify.test.ts + working-directory: examples/nextjs + release: concurrency: release outputs: diff --git a/examples/cloudflare-workers/src/ci.ts b/examples/cloudflare-workers/src/ci.ts index 38aa5dfc..605e3fee 100644 --- a/examples/cloudflare-workers/src/ci.ts +++ b/examples/cloudflare-workers/src/ci.ts @@ -52,7 +52,7 @@ async function handleVerify(request: Request, env: Env): Promise { const signature = request.headers.get("Upstash-Signature"); if (!signature) { - return new Response("missing Upstash-Signature header", { status: 401 }); + return new Response("missing Upstash-Signature header", { status: 403 }); } const receiver = new Receiver({ @@ -64,7 +64,7 @@ async function handleVerify(request: Request, env: Env): Promise { try { await receiver.verify({ signature, body }); } catch (error) { - return new Response(`invalid signature: ${(error as Error).message}`, { status: 401 }); + return new Response(`invalid signature: ${(error as Error).message}`, { status: 403 }); } return new Response("OK", { status: 200 }); diff --git a/examples/cloudflare-workers/verify.test.ts b/examples/cloudflare-workers/verify.test.ts index 9199d545..a51ce248 100644 --- a/examples/cloudflare-workers/verify.test.ts +++ b/examples/cloudflare-workers/verify.test.ts @@ -16,6 +16,19 @@ if (!token) { throw new Error("QSTASH_TOKEN not set"); } +test("verify endpoint rejects unsigned requests", async () => { + // Hitting the verifier directly without a valid Upstash-Signature header must + // be rejected, never answered with 200. + const res = await fetch(`${deploymentURL}/verify`, { + method: "POST", + body: JSON.stringify({ hello: "no signature" }), + }); + + // The worker returns 403 when the Upstash-Signature header is missing. + expect(res.status).not.toBe(200); + expect(res.status).toBe(403); +}); + test( "publishes to the verify endpoint and the message is delivered", async () => { diff --git a/examples/nextjs/app/roundtrip/publish/route.ts b/examples/nextjs/app/roundtrip/publish/route.ts new file mode 100644 index 00000000..c91168b2 --- /dev/null +++ b/examples/nextjs/app/roundtrip/publish/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { Client } from "@upstash/qstash"; + +// Publishes a message to this same app's /roundtrip/verify endpoint and returns +// the message id so the test can follow it in the message logs. +export const dynamic = "force-dynamic"; + +export const GET = async (request: Request) => { + if (!process.env.QSTASH_TOKEN) { + throw new Error("CI test failed. QSTASH_TOKEN is missing."); + } + + const client = new Client({ token: process.env.QSTASH_TOKEN }); + const origin = new URL(request.url).origin; + const { messageId } = await client.publishJSON({ + url: `${origin}/roundtrip/verify`, + body: { hello: "qstash-js nextjs ci" }, + }); + + return NextResponse.json({ messageId }); +}; diff --git a/examples/nextjs/app/roundtrip/verify/route.ts b/examples/nextjs/app/roundtrip/verify/route.ts new file mode 100644 index 00000000..0e4e37aa --- /dev/null +++ b/examples/nextjs/app/roundtrip/verify/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { verifySignatureAppRouter } from "@upstash/qstash/nextjs"; + +// Publicly reachable endpoint with a verifier. QStash delivers the signed +// message here and verifySignatureAppRouter validates the signature using the +// QSTASH_CURRENT_SIGNING_KEY / QSTASH_NEXT_SIGNING_KEY env vars. Returning 200 +// is what makes QStash mark the message as DELIVERED. +export const dynamic = "force-dynamic"; + +export const POST = verifySignatureAppRouter(async () => { + return NextResponse.json({ ok: true }); +}); diff --git a/examples/nextjs/verify.test.ts b/examples/nextjs/verify.test.ts new file mode 100644 index 00000000..464bcb9e --- /dev/null +++ b/examples/nextjs/verify.test.ts @@ -0,0 +1,73 @@ +import { Client } from "@upstash/qstash"; +import { test, expect } from "bun:test"; + +// End-to-end delivery round trip. The app publishes a message to its own +// /roundtrip/verify endpoint (which runs verifySignatureAppRouter), QStash +// delivers the signed request, and we poll the message logs until QStash +// reports it as DELIVERED. +// +// Requires a publicly reachable deployment, so this only runs in the deployed +// CI job. +const deploymentURL = process.env.DEPLOYMENT_URL; +if (!deploymentURL) { + throw new Error("DEPLOYMENT_URL not set"); +} + +const token = process.env.QSTASH_TOKEN; +if (!token) { + throw new Error("QSTASH_TOKEN not set"); +} + +// Endpoints that verify the QStash signature. Hitting them directly (without a +// valid Upstash-Signature header) must be rejected, never answered with 200. +const VERIFIED_ENDPOINTS = ["/roundtrip/verify", "/serverless", "/edge"]; + +test("verified endpoints reject unsigned requests", async () => { + for (const path of VERIFIED_ENDPOINTS) { + const res = await fetch(`${deploymentURL}${path}`, { + method: "POST", + body: JSON.stringify({ hello: "no signature" }), + }); + + // verifySignatureAppRouter returns 403 when the signature header is missing. + expect(res.status).not.toBe(200); + expect(res.status).toBe(403); + } +}); + +test( + "publishes to the verify endpoint and the message is delivered", + async () => { + // 1. Ask the app to publish a message to its own /roundtrip/verify endpoint. + const res = await fetch(`${deploymentURL}/roundtrip/publish`); + if (res.status !== 200) { + console.log(await res.text()); + } + expect(res.status).toEqual(200); + + const { messageId } = (await res.json()) as { messageId: string }; + expect(messageId).toBeTruthy(); + + // 2. Poll the message logs until the message reaches a terminal state. + const client = new Client({ token }); + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const { logs } = await client.logs({ messageIds: [messageId] }); + const states = new Set(logs.map((log) => log.state)); + + if (states.has("DELIVERED")) { + return; // success: the signed message was verified and delivered + } + if (states.has("FAILED") || states.has("CANCELED")) { + throw new Error( + `message ${messageId} did not deliver: ${JSON.stringify(logs)}` + ); + } + + await Bun.sleep(1000); + } + + throw new Error(`message ${messageId} was not DELIVERED within 60s`); + }, + 90_000 +); From 146a72f1d3b1f470b5ec5b1389c2ce59fc8df3f8 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 13:31:51 +0300 Subject: [PATCH 07/13] refactor: address review on receiver hash and eventually helper - sha256Base64url now reads globalThis.crypto explicitly and throws a clear '[Upstash QStash] Web Crypto API is not available' error when it's missing, and uses jose.base64url.encode (already a dependency) instead of a manual btoa+replace base64url transform. - Move the eventually test helper out of logs.test.ts into test-utils/eventually.ts so importing it no longer loads the entire logs suite as a side effect. Update all importers. --- src/client/client.test.ts | 2 +- src/client/dlq.test.ts | 2 +- src/client/flow-control.test.ts | 2 +- src/client/logs.test.ts | 30 +----------------------- src/client/test-utils/eventually.ts | 36 +++++++++++++++++++++++++++++ src/receiver.ts | 13 +++++++---- 6 files changed, 48 insertions(+), 37 deletions(-) create mode 100644 src/client/test-utils/eventually.ts diff --git a/src/client/client.test.ts b/src/client/client.test.ts index eb97d38c..bcc29f75 100644 --- a/src/client/client.test.ts +++ b/src/client/client.test.ts @@ -7,7 +7,7 @@ import { Client } from "./client"; import type { PublishToUrlResponse } from "../../dist"; import { MOCK_QSTASH_SERVER_URL, mockQStashServer } from "./workflow/test-utils"; import type { HttpClient } from "./http"; -import { eventually } from "./logs.test"; +import { eventually } from "./test-utils/eventually"; export const clearQueues = async (client: Client) => { const queueDetails = await client.queue().list(); diff --git a/src/client/dlq.test.ts b/src/client/dlq.test.ts index 5b393aae..e1c17e71 100644 --- a/src/client/dlq.test.ts +++ b/src/client/dlq.test.ts @@ -4,7 +4,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { Client } from "./client"; -import { eventually } from "./logs.test"; +import { eventually } from "./test-utils/eventually"; import { MOCK_QSTASH_SERVER_URL, mockQStashServer } from "./workflow/test-utils"; import type { HttpClient } from "./http"; diff --git a/src/client/flow-control.test.ts b/src/client/flow-control.test.ts index 89542940..bc5cddfb 100644 --- a/src/client/flow-control.test.ts +++ b/src/client/flow-control.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"; import { Client } from "./client"; -import { eventually } from "./logs.test"; +import { eventually } from "./test-utils/eventually"; describe("FlowControl", () => { const client = new Client({ token: process.env.QSTASH_TOKEN! }); diff --git a/src/client/logs.test.ts b/src/client/logs.test.ts index 4368b4ba..734157c8 100644 --- a/src/client/logs.test.ts +++ b/src/client/logs.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import { Client } from "./client"; import { MOCK_QSTASH_SERVER_URL, mockQStashServer } from "./workflow/test-utils"; +import { eventually } from "./test-utils/eventually"; describe("logs", () => { test( @@ -198,32 +199,3 @@ describe("events (deprecated)", () => { expect(Array.isArray(result.events)).toBe(true); }); }); - -const EVENTUALLY_TIMEOUT = 5000; - -export const eventually = async function ( - function_: () => Promise | void, - options: { - timeout?: number; - interval?: number; - } = {} -): Promise { - const { timeout = EVENTUALLY_TIMEOUT, interval = 100 } = options; - - const startTime = Date.now(); - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - try { - await function_(); - // Success case - all assertions passed - return; - } catch (error) { - const lastError = error as Error; - if (Date.now() - startTime >= timeout) { - throw new Error(`Assertions not satisfied within timeout: ${lastError.message}`); - } - await new Promise((resolve) => setTimeout(resolve, interval)); - } - } -}; diff --git a/src/client/test-utils/eventually.ts b/src/client/test-utils/eventually.ts new file mode 100644 index 00000000..5d1f36aa --- /dev/null +++ b/src/client/test-utils/eventually.ts @@ -0,0 +1,36 @@ +const EVENTUALLY_TIMEOUT = 5000; + +/** + * Retries `function_` until it stops throwing or the timeout elapses. + * + * Useful in integration tests for asserting on eventually-consistent state + * (e.g. a message landing in the DLQ or appearing in the logs) without a fixed + * sleep. The callback should perform its assertions; a throw means "not ready + * yet" and triggers another attempt after `interval` ms. + */ +export const eventually = async function ( + function_: () => Promise | void, + options: { + timeout?: number; + interval?: number; + } = {} +): Promise { + const { timeout = EVENTUALLY_TIMEOUT, interval = 100 } = options; + + const startTime = Date.now(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + try { + await function_(); + // Success case - all assertions passed + return; + } catch (error) { + const lastError = error as Error; + if (Date.now() - startTime >= timeout) { + throw new Error(`Assertions not satisfied within timeout: ${lastError.message}`); + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + } +}; diff --git a/src/receiver.ts b/src/receiver.ts index 104698d3..fbef8c07 100644 --- a/src/receiver.ts +++ b/src/receiver.ts @@ -11,12 +11,15 @@ import { getReceiverSigningKeys } from "./client/multi-region"; * deprecated `url.parse()` and triggered Node.js DEP0169 warnings. */ async function sha256Base64url(body: string): Promise { - const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)); - let binary = ""; - for (const byte of new Uint8Array(hashBuffer)) { - binary += String.fromCodePoint(byte); + const webCrypto = globalThis.crypto; + // The types claim `crypto.subtle` is always present, but it can be missing at + // runtime (older/edge runtimes), so keep the guard despite the lint rule. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!webCrypto?.subtle) { + throw new Error("[Upstash QStash] Web Crypto API is not available in this runtime."); } - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll(/=+$/g, ""); + const hashBuffer = await webCrypto.subtle.digest("SHA-256", new TextEncoder().encode(body)); + return jose.base64url.encode(new Uint8Array(hashBuffer)); } /** From 8f96b7fcba9fd6655ec2d7166b66316081068537 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 13:57:26 +0300 Subject: [PATCH 08/13] ci: verify receiver SHA-256 hashing across node versions and bun Add scripts/check-webcrypto-node.mjs, which signs a request and verifies it through both the global Web Crypto path and a forced node:crypto fallback, and run it in CI on Node 18/20/22/24 and Bun. release depends on these jobs. This is committed before the receiver fix on purpose, to confirm the new jobs catch the older-Node regression (they should fail until the fallback lands). --- .github/workflows/test.yaml | 55 +++++++++++++++++++++++++ eslint.config.mjs | 2 +- scripts/check-webcrypto-node.mjs | 70 ++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 scripts/check-webcrypto-node.mjs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4f218487..15b20feb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -32,6 +32,59 @@ jobs: - name: Build run: bun run build + receiver-node-versions: + name: Receiver Web Crypto (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 18 = oldest supported (no global Web Crypto, exercises node:crypto + # fallback); 20/22/24 = newer with native global Web Crypto. + node: [18, 20, 22, 24] + steps: + - name: Setup repo + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install Dependencies + run: bun install + + - name: Build + run: bun run build + + - name: Setup Node.js ${{ matrix.node }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: Verify SHA-256 hashing under Node ${{ matrix.node }} + run: node scripts/check-webcrypto-node.mjs + + receiver-bun-runtime: + name: Receiver Web Crypto (Bun) + runs-on: ubuntu-latest + steps: + - name: Setup repo + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install Dependencies + run: bun install + + - name: Build + run: bun run build + + - name: Verify SHA-256 hashing under Bun + run: bun scripts/check-webcrypto-node.mjs + cloudflare-workers-local-build: runs-on: ubuntu-latest name: CF Workers Local Build @@ -274,6 +327,8 @@ jobs: - cloudflare-workers-local-build - nextjs-local-build - local-tests + - receiver-node-versions + - receiver-bun-runtime name: Release runs-on: ubuntu-latest diff --git a/eslint.config.mjs b/eslint.config.mjs index 7099a9d0..42f70260 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,7 +15,7 @@ const compat = new FlatCompat({ export default [ { - ignores: ["**/*.config.*", "src/encoding/**.*", "**/examples"], + ignores: ["**/*.config.*", "src/encoding/**.*", "**/examples", "scripts/**"], }, ...compat.extends( "eslint:recommended", diff --git a/scripts/check-webcrypto-node.mjs b/scripts/check-webcrypto-node.mjs new file mode 100644 index 00000000..c52958b0 --- /dev/null +++ b/scripts/check-webcrypto-node.mjs @@ -0,0 +1,70 @@ +// Runtime check for the receiver's SHA-256 hashing across Node.js versions. +// +// `bun test` can't cover this: Bun always has globalThis.crypto, so the +// node:crypto fallback in sha256Base64url never runs there. This script runs +// under plain `node` (see the node-versions CI job) and exercises BOTH paths: +// 1. the global Web Crypto path (Node >= 19) +// 2. the node:crypto fallback (Node < 19, and forced here on newer Node) +// +// It builds a valid Upstash signature with jose + node:crypto and asserts that +// Receiver.verify() accepts it. Run after `bun run build` (imports ../dist). +import assert from "node:assert"; +import { createHash } from "node:crypto"; +import { SignJWT } from "jose"; +import { Receiver } from "../dist/index.mjs"; + +const KEY = "test-signing-key"; +const BODY = JSON.stringify({ hello: "web crypto runtime check" }); + +async function makeSignature(body) { + const now = Math.floor(Date.now() / 1000); + const payload = { + iss: "Upstash", + sub: "", + exp: now + 300, + nbf: now, + iat: now, + jti: `jwt-${now}`, + body: createHash("sha256").update(body).digest("base64url"), + }; + return new SignJWT(payload) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .sign(new TextEncoder().encode(KEY)); +} + +const receiver = new Receiver({ currentSigningKey: KEY, nextSigningKey: KEY }); + +async function verifyRoundTrip(label) { + const signature = await makeSignature(BODY); + const ok = await receiver.verify({ signature, body: BODY }); + assert.strictEqual(ok, true, `${label}: verify() should return true`); + console.log(`✓ ${label} (node ${process.version}, globalThis.crypto: ${typeof globalThis.crypto})`); +} + +// 1. Whatever this Node version offers natively (global on >=19, fallback on <19). +await verifyRoundTrip("native path"); + +// 2. Force the node:crypto fallback even on a runtime that has the global, so +// the fallback branch is covered regardless of version. Best-effort: some +// runtimes make globalThis.crypto non-configurable, in which case we skip +// (the Node 18 matrix entry still exercises the fallback for real). +const savedCrypto = Object.getOwnPropertyDescriptor(globalThis, "crypto"); +let unset = false; +try { + Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true }); + unset = globalThis.crypto === undefined; +} catch { + unset = false; +} + +if (unset) { + try { + await verifyRoundTrip("forced node:crypto fallback"); + } finally { + if (savedCrypto) Object.defineProperty(globalThis, "crypto", savedCrypto); + } +} else { + console.log("• skipped forced fallback (globalThis.crypto is not configurable here)"); +} + +console.log("All Web Crypto runtime checks passed."); From b2b916ee5d66fec8250c4591dbd0419dfd1b9ee9 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 13:59:11 +0300 Subject: [PATCH 09/13] test: poll messages.get in label publish tests to fix flakiness A published message isn't immediately retrievable via messages.get, so the label/multiple-labels E2E tests 404'd intermittently. Wrap the lookups in eventually polling instead of a single immediate get. --- scripts/check-webcrypto-node.mjs | 4 +- src/client/client.test.ts | 66 +++++++++++++++++++++----------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/scripts/check-webcrypto-node.mjs b/scripts/check-webcrypto-node.mjs index c52958b0..6d1d86c4 100644 --- a/scripts/check-webcrypto-node.mjs +++ b/scripts/check-webcrypto-node.mjs @@ -38,7 +38,9 @@ async function verifyRoundTrip(label) { const signature = await makeSignature(BODY); const ok = await receiver.verify({ signature, body: BODY }); assert.strictEqual(ok, true, `${label}: verify() should return true`); - console.log(`✓ ${label} (node ${process.version}, globalThis.crypto: ${typeof globalThis.crypto})`); + console.log( + `✓ ${label} (node ${process.version}, globalThis.crypto: ${typeof globalThis.crypto})` + ); } // 1. Whatever this Node version offers natively (global on >=19, fallback on <19). diff --git a/src/client/client.test.ts b/src/client/client.test.ts index bcc29f75..5f5d7aeb 100644 --- a/src/client/client.test.ts +++ b/src/client/client.test.ts @@ -19,30 +19,50 @@ export const clearQueues = async (client: Client) => { }; describe("E2E Publish", () => { - test("should publish a message with a label", async () => { - const result = await client.publish({ - url: "https://example.com/", - body: "test-body", - label: "test-label", - }); - const verifiedMessage = await client.messages.get(result.messageId); - expect(verifiedMessage.label).toBe("test-label"); - }); + test( + "should publish a message with a label", + async () => { + const result = await client.publish({ + url: "https://example.com/", + body: "test-body", + label: "test-label", + }); + // The message isn't immediately retrievable after publish, so poll. + await eventually( + async () => { + const verifiedMessage = await client.messages.get(result.messageId); + expect(verifiedMessage.label).toBe("test-label"); + }, + { timeout: 20_000, interval: 1000 } + ); + }, + { timeout: 30_000 } + ); - test("should publish a message with multiple labels", async () => { - const labelOne = `multi-a-${Date.now()}`; - const labelTwo = `multi-b-${Date.now()}`; - const result = await client.publish({ - url: "https://example.com/", - body: "test-body", - label: [labelOne, labelTwo], - }); - const verifiedMessage = await client.messages.get(result.messageId); - // legacy `label` carries only the first label - expect(verifiedMessage.label).toBe(labelOne); - // new `labels` carries all of them - expect(verifiedMessage.labels).toEqual([labelOne, labelTwo]); - }); + test( + "should publish a message with multiple labels", + async () => { + const labelOne = `multi-a-${Date.now()}`; + const labelTwo = `multi-b-${Date.now()}`; + const result = await client.publish({ + url: "https://example.com/", + body: "test-body", + label: [labelOne, labelTwo], + }); + // The message isn't immediately retrievable after publish, so poll. + await eventually( + async () => { + const verifiedMessage = await client.messages.get(result.messageId); + // legacy `label` carries only the first label + expect(verifiedMessage.label).toBe(labelOne); + // new `labels` carries all of them + expect(verifiedMessage.labels).toEqual([labelOne, labelTwo]); + }, + { timeout: 20_000, interval: 1000 } + ); + }, + { timeout: 30_000 } + ); const client = new Client({ token: process.env.QSTASH_TOKEN! }); afterAll(async () => { From 5a366c8d02b1de403a1cedfb6426457d5e2b01af Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 14:02:19 +0300 Subject: [PATCH 10/13] ci: drop forced-fallback phase from web crypto runtime check The forced phase removed globalThis.crypto on every runtime, so a missing fallback failed all versions and obscured which ones break in practice. Keep only the native-path check so the matrix reflects real behavior: Node < 19 (no global Web Crypto) is the version that actually needs the node:crypto fallback. --- scripts/check-webcrypto-node.mjs | 36 +++++++------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/scripts/check-webcrypto-node.mjs b/scripts/check-webcrypto-node.mjs index 6d1d86c4..05cea551 100644 --- a/scripts/check-webcrypto-node.mjs +++ b/scripts/check-webcrypto-node.mjs @@ -1,10 +1,10 @@ // Runtime check for the receiver's SHA-256 hashing across Node.js versions. // -// `bun test` can't cover this: Bun always has globalThis.crypto, so the -// node:crypto fallback in sha256Base64url never runs there. This script runs -// under plain `node` (see the node-versions CI job) and exercises BOTH paths: -// 1. the global Web Crypto path (Node >= 19) -// 2. the node:crypto fallback (Node < 19, and forced here on newer Node) +// `bun test` can't cover this: it runs on Bun, which always has +// globalThis.crypto. Running this under plain `node` across the version matrix +// (see the node-versions CI job) exercises the real runtime behavior — most +// importantly Node < 19, where Web Crypto isn't a global and the receiver must +// fall back to node:crypto. // // It builds a valid Upstash signature with jose + node:crypto and asserts that // Receiver.verify() accepts it. Run after `bun run build` (imports ../dist). @@ -43,30 +43,8 @@ async function verifyRoundTrip(label) { ); } -// 1. Whatever this Node version offers natively (global on >=19, fallback on <19). +// Verify using whatever this Node version offers: the global Web Crypto on +// Node >= 19, or the node:crypto fallback on Node < 19. await verifyRoundTrip("native path"); -// 2. Force the node:crypto fallback even on a runtime that has the global, so -// the fallback branch is covered regardless of version. Best-effort: some -// runtimes make globalThis.crypto non-configurable, in which case we skip -// (the Node 18 matrix entry still exercises the fallback for real). -const savedCrypto = Object.getOwnPropertyDescriptor(globalThis, "crypto"); -let unset = false; -try { - Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true }); - unset = globalThis.crypto === undefined; -} catch { - unset = false; -} - -if (unset) { - try { - await verifyRoundTrip("forced node:crypto fallback"); - } finally { - if (savedCrypto) Object.defineProperty(globalThis, "crypto", savedCrypto); - } -} else { - console.log("• skipped forced fallback (globalThis.crypto is not configurable here)"); -} - console.log("All Web Crypto runtime checks passed."); From 0450fe551bef6250f2f1ac85cc7127c40fe58019 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 14:06:35 +0300 Subject: [PATCH 11/13] fix: fall back to node:crypto for SHA-256 on Node < 19 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit globalThis.crypto is only a default global on Node >= 19, so the Web-Crypto-only hashing threw 'Web Crypto API is not available' on Node 16/17/18 (where crypto-js used to work). sha256Base64url now prefers the global and falls back to node:crypto's webcrypto on older Node — the same approach jose uses — so signature verification keeps working on every runtime. Confirmed by the receiver-node-versions CI matrix, which failed on Node 18 until this change. --- src/receiver.ts | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/receiver.ts b/src/receiver.ts index fbef8c07..85c81954 100644 --- a/src/receiver.ts +++ b/src/receiver.ts @@ -6,22 +6,31 @@ import { getReceiverSigningKeys } from "./client/multi-region"; * Computes the SHA-256 hash of the given string and returns it as a * base64url-encoded value (without padding). * - * Uses the Web Crypto API (`globalThis.crypto`), available in Node.js 16+, - * browsers, and edge runtimes. This replaces `crypto-js`, which relied on the - * deprecated `url.parse()` and triggered Node.js DEP0169 warnings. + * Prefers the Web Crypto API (`globalThis.crypto.subtle`), which is native on + * browsers, Cloudflare Workers, Bun, Deno, edge runtimes and Node.js >= 19. On + * older Node.js (16/17/18), where Web Crypto is not exposed as a global, it + * falls back to `node:crypto`'s `webcrypto` — the same approach `jose` uses — so + * verification keeps working on every runtime. This replaces `crypto-js`, which + * relied on the deprecated `url.parse()` and triggered Node.js DEP0169 warnings. */ async function sha256Base64url(body: string): Promise { - const webCrypto = globalThis.crypto; - // The types claim `crypto.subtle` is always present, but it can be missing at - // runtime (older/edge runtimes), so keep the guard despite the lint rule. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!webCrypto?.subtle) { - throw new Error("[Upstash QStash] Web Crypto API is not available in this runtime."); - } - const hashBuffer = await webCrypto.subtle.digest("SHA-256", new TextEncoder().encode(body)); + const hashBuffer = await digestSha256(new TextEncoder().encode(body)); return jose.base64url.encode(new Uint8Array(hashBuffer)); } +async function digestSha256(data: Uint8Array): Promise { + // The static types claim Web Crypto is always on the global, but on Node.js + // < 19 it isn't exposed there. + const globalCrypto = globalThis.crypto as typeof globalThis.crypto | undefined; + if (globalCrypto) { + return globalCrypto.subtle.digest("SHA-256", data); + } + // Fallback for older Node.js (< 19): use node:crypto's webcrypto, the same + // approach jose uses, so verification keeps working on every runtime. + const nodeCrypto = await import("node:crypto"); + return nodeCrypto.webcrypto.subtle.digest("SHA-256", data); +} + /** * Necessary to verify the signature of a request. */ From 5901b7d65284e6ee3ef10b91315ad4f48d03b96c Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 14:18:13 +0300 Subject: [PATCH 12/13] fix: make node:crypto fallback import bundler-safe The static import("node:crypto") broke Next.js's webpack build (UnhandledScheme error), including the edge route, even though that branch never runs on edge. Hold the specifier in a variable and mark the dynamic import webpackIgnore / @vite-ignore so webpack and Vite leave it as a runtime import. Verified the Next.js example (edge + serverless routes) now builds, and the node:crypto fallback still works when globalThis.crypto is absent. --- src/receiver.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/receiver.ts b/src/receiver.ts index 85c81954..3ff7f3d4 100644 --- a/src/receiver.ts +++ b/src/receiver.ts @@ -27,7 +27,15 @@ async function digestSha256(data: Uint8Array): Promise { } // Fallback for older Node.js (< 19): use node:crypto's webcrypto, the same // approach jose uses, so verification keeps working on every runtime. - const nodeCrypto = await import("node:crypto"); + // + // This branch never runs on edge/workers/browsers/Bun/Deno/Node >= 19 (they + // all have the global above). The specifier is held in a variable and marked + // webpackIgnore/@vite-ignore so bundlers (e.g. Next.js webpack, including the + // edge runtime, and Vite) don't try to resolve the node: builtin at build time. + const moduleName = "node:crypto"; + const nodeCrypto = (await import(/* webpackIgnore: true */ /* @vite-ignore */ moduleName)) as { + webcrypto: { subtle: { digest(algorithm: string, data: Uint8Array): Promise } }; + }; return nodeCrypto.webcrypto.subtle.digest("SHA-256", data); } From 247faaed15d124ec7303c4ddcf2789bd289ff48c Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 19 Jun 2026 14:19:39 +0300 Subject: [PATCH 13/13] ci: run nextjs local build across a node version matrix Build and run the Next.js example on Node 18/20/22/24 so bundler/runtime regressions (like the node:crypto import that broke the webpack build) are caught on every supported Node version, not just the latest. --- .github/workflows/test.yaml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 15b20feb..6ec322d1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -197,7 +197,14 @@ jobs: nextjs-local-build: runs-on: ubuntu-latest - name: NextJS Local Build + name: NextJS Local Build (Node ${{ matrix.node }}) + strategy: + fail-fast: false + matrix: + # Build + run the example across supported Node versions so bundler and + # runtime regressions (e.g. the node:crypto import in the receiver) are + # caught everywhere, not just on the latest Node. + node: [18, 20, 22, 24] # The /serverless and /edge example routes call verifySignatureAppRouter() # at module load (page-data collection at build, route compile at dev). # Without keys in env it throws synchronously, so provide dummies. Real @@ -221,10 +228,10 @@ jobs: - name: Build run: bun run build - - name: Install Node.js + - name: Install Node.js ${{ matrix.node }} uses: actions/setup-node@v4 with: - node-version: 24 + node-version: ${{ matrix.node }} - uses: pnpm/action-setup@v4 name: Install pnpm