Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 83 additions & 4 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -30,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
Expand Down Expand Up @@ -114,6 +169,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
Expand All @@ -132,9 +189,22 @@ 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
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
Expand All @@ -158,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
Expand Down Expand Up @@ -238,7 +308,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}}
Expand All @@ -249,6 +322,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:
Expand All @@ -257,6 +334,8 @@ jobs:
- cloudflare-workers-local-build
- nextjs-local-build
- local-tests
- receiver-node-versions
- receiver-bun-runtime

name: Release
runs-on: ubuntu-latest
Expand Down
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 76 additions & 21 deletions examples/cloudflare-workers/src/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Response> {
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<Response> {
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: 403 });
}

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: 403 });
}

return new Response("OK", { status: 200 });
}

async function handleSchedule(env: Env): Promise<Response> {
// 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 });
}
3 changes: 3 additions & 0 deletions examples/cloudflare-workers/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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" }
67 changes: 67 additions & 0 deletions examples/cloudflare-workers/verify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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("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 () => {
// 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
);
21 changes: 21 additions & 0 deletions examples/nextjs/app/roundtrip/publish/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
};
12 changes: 12 additions & 0 deletions examples/nextjs/app/roundtrip/verify/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading
Loading