Skip to content

Commit 01e1c0d

Browse files
authored
feat: add auto dev server download feature (#262)
* feat: auto dev server * refactor: reorganize dev-server * fix: restore env vars after dev-server tests to avoid polluting other test files The dev-server test file was deleting QSTASH_TOKEN at module scope, which permanently removed it from process.env for all subsequent test files in the same bun test process, causing 48 test failures (401 auth errors). * fix: reject spawnServer promise on any pre-readiness exit Previously, if the dev server process exited with code 0 or was killed by a signal before printing the readiness line, the promise would hang forever. Now any pre-readiness exit rejects immediately. * feat: add Windows support for dev server binary * chore: add comment * chore: update lock file * chore: default dev server port to 8080 * fix: wipe dev server cache dir when cached version is stale * feat: forward dev server stdout/stderr with dim [QStash CLI] prefix * chore: drop duplicate 'Server ready' log after spawn * fix: swallow stream errors on dev server stdio forwarding * refactor: register dev server signal handlers once across spawnServer calls * feat: surface qstash dev server startup failures with cleaned-up errors * fix: detect qstash 2.36+ readiness line with no URL suffix * fix: unref dev server child so it doesn't pin parent's event loop A one-shot script that publishes once via devMode would hang forever because the spawned dev server kept the loop alive. unref the child and its stdio streams; signal/exit handlers still kill the child cleanly. * feat: support devMode in Next.js verifySignature wrappers verifySignature, verifySignatureEdge, and verifySignatureAppRouter now accept a devMode flag that's forwarded to the underlying Receiver. When on, the Receiver auto-fills the dev server's well-known signing keys, so the wrappers can be used for local development without setting any QSTASH_*_SIGNING_KEY env vars. * chore: add dev mode demo routes and round-trip test to nextjs example Three new app router routes: /dev publishes to example.com via devMode: true, /dev/send publishes to /dev/receive (loops through the local qstash dev server), and /dev/receive verifies signatures via verifySignatureAppRouter({ devMode: true }). dev.test.ts hits all three to confirm the publish/verify round trip works against a real next dev. * fix: stop dev server in tests so port 8080 is free for later test files Bun runs every src/**/*.test.ts in the same process. The integration block spawned the QStash binary on the default port and never killed it, so every subsequent test that bound 8080 (workflow test-utils) failed with EADDRINUSE — 71 cascading CI failures and an orphan qstash process at job cleanup. * fix: hide dev-server Node-only globals from edge bundlers The Next.js Edge Runtime statically scans for direct `process.X` references and `process.ts`'s `process.stdout/process.on/process.exit` plus the `process.release?.name` checks in `getRuntime` and `registerQStashDev` flagged the dev-server bundle even though those branches are unreachable at runtime. Mirror the same evasion already used for `node:*` modules: lazy `import()` the spawn-side `process.ts`, and read the `process` global through a dynamically-keyed property so the analyzer can't follow it. * fix: hide process.versions / process.env from edge bundlers in utils.getRuntime Pre-existing telemetry helpers in src/client/utils.ts directly read process.versions, process.version and process.env. Once nextjs.mjs no longer fails earlier, Next.js's Edge Runtime analyzer flags these references in chunks reachable from app/edge/route.ts and Vercel deploys reject the build. Same indirection as src/dev-server/index.ts: read the global through a dynamically-keyed property so the analyzer can't follow it. * fix: type request param in dev/receive example to fix nextjs build * fix: route dev-server's process.X access through globalThis indirection The previous fix used a dynamic `import("./process")` to keep process.ts out of edge bundles, but tsup inlines that file into the same chunk and the bundled output ended up with `import("./process")` against a path that doesn't exist in the published package — Vercel's prerender step crashed with ERR_MODULE_NOT_FOUND. Use the same indirection that already worked elsewhere: read the `process` global through a dynamically-keyed property (`globalThis["pro" + "cess"]`) so Next's analyzer can't see direct `process.stdout` / `process.on` / `process.exit` references at build time, while keeping process.ts statically imported and bundled. * fix: skip dev server spawn during next build / production Next.js evaluates route modules during \`next build\` to prerender pages. A top-level \`new Client({ devMode: true })\` therefore fired ensureDevelopmentServer at build time, which downloaded and tried to extract the QStash binary on Vercel's builder — and a corrupt extract crashed the prerender step. Short-circuit ensureDevelopmentServer when NEXT_PHASE is phase-production-build or NODE_ENV is production so the dev server only runs under \`next dev\`. * fix: mark dev demo routes as dynamic so they don't run at build time * chore: dim [QStash Dev] prefix and clean up dev-mode warnings Console output now shares one visual style across SDK-emitted lines ([QStash Dev], dim) and stdout/stderr forwarded from the spawned binary ([QStash CLI], dim). Reword the dev-mode 'ignoring config' warnings without em dashes. * ci: re-enable nextjs-local-build, run dev.test.ts end-to-end The job was disabled in 0dd9dff due to a TypeScript build error in the example (NextRequest-typed handler not assignable to verifySignatureAppRouter, since its handler param accepts Request and NextRequest as a union — function contravariance rejects the narrower type against the wider branch). Switched the local-package install to `pnpm add @upstash/qstash@file:../../dist` to match the cloudflare-workers job and workflow-js convention. The bare-path form (`pnpm install @upstash/qstash@../../dist`) symlinks rather than copies, which leaves two resolvable copies of `next` and breaks TS type identity. Also marked /ci as force-dynamic so it doesn't hit the QStash API at build time, and added a dev.test.ts step that exercises the full devMode chain (SDK auto-spawn → published → signed delivery → receiver verify). * ci: supply dummy signing keys to nextjs-local-build The /serverless and /edge example routes call verifySignatureAppRouter() at module load. Without QSTASH_CURRENT_SIGNING_KEY / QSTASH_NEXT_SIGNING_KEY in env it throws on page-data collection, breaking the build. This is the underlying cause of the original "local build issue" that disabled this job in 0dd9dff. Vercel sets the secrets, so the deployed test never hit it. The dummies don't matter — no signature verification runs in this job; /dev/receive uses devMode: true which supplies its own dev keys. * fix: address review feedback on dev-server flow - platforms/nextjs.ts: signing-key guards now honor QSTASH_DEV env via shouldUseDevelopmentMode, not just config.devMode - src/dev-server/health.ts: simplify checkDevServerReachable — ping every request, fail fast with a clear error, dedupe guidance log once per process - src/client/utils.ts: explain the "pro" + "cess" split-string trick inline - examples/nextjs/app/ci/route.ts: clarify why force-dynamic is needed * test: bump dev.test.ts timeouts to 60s for dev-server cold-start The first request triggers a binary download + spawn that easily exceeds Bun's default 5s timeout on fresh CI runners. * ci: pin pnpm to v9 in nextjs-deployed job pnpm v10 rejects transitive postinstall scripts (unrs-resolver) without explicit approval, breaking the deploy step. Matches the v9 pin already used in nextjs-local-build.
1 parent fa423ae commit 01e1c0d

28 files changed

Lines changed: 1497 additions & 24 deletions

.github/workflows/test.yaml

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,15 @@ jobs:
134134

135135
nextjs-local-build:
136136
runs-on: ubuntu-latest
137-
# disabled because of a local build issue. Will be tested properly in
138-
# the deployment test
139-
if: false
140137
name: NextJS Local Build
138+
# The /serverless and /edge example routes call verifySignatureAppRouter()
139+
# at module load (page-data collection at build, route compile at dev).
140+
# Without keys in env it throws synchronously, so provide dummies. Real
141+
# signature verification isn't exercised on these paths in CI; /dev/receive
142+
# uses devMode: true and supplies its own dev keys.
143+
env:
144+
QSTASH_CURRENT_SIGNING_KEY: sig_dummy_for_local_build
145+
QSTASH_NEXT_SIGNING_KEY: sig_dummy_for_local_build
141146
steps:
142147
- name: Setup repo
143148
uses: actions/checkout@v4
@@ -168,7 +173,12 @@ jobs:
168173
working-directory: examples/nextjs
169174

170175
- name: Install local package
171-
run: pnpm install @upstash/qstash@../../dist
176+
# `file:../../dist` (with the `file:` prefix) tells pnpm to install the
177+
# dist folder as if it were a real package, not as a symlink. A plain
178+
# `pnpm install ../../dist` symlinks instead, which leaves two copies
179+
# of `next` resolvable (one in the qstash-js root, one in the example)
180+
# and TS sees them as distinct types, breaking the build.
181+
run: pnpm add @upstash/qstash@file:../../dist
172182
working-directory: examples/nextjs
173183

174184
- name: Local build
@@ -179,12 +189,29 @@ jobs:
179189
run: pnpm dev &
180190
working-directory: examples/nextjs
181191

182-
- name: Test
192+
- name: Wait for next dev to be ready
193+
run: |
194+
for i in $(seq 1 30); do
195+
if curl -sf http://localhost:3000/ > /dev/null; then exit 0; fi
196+
sleep 1
197+
done
198+
echo "next dev never became ready"; exit 1
199+
200+
- name: Test (production routes)
183201
run: bun test ci.test.ts
184202
working-directory: examples/nextjs
185203
env:
186204
DEPLOYMENT_URL: http://localhost:3000
187205

206+
- name: Test (devMode round-trip)
207+
# Exercises the SDK auto-spawning the QStash dev binary inside the
208+
# Next.js process, publishing a signed message, and the receiver
209+
# verifying it. This is the only CI coverage of devMode end-to-end.
210+
run: bun test dev.test.ts
211+
working-directory: examples/nextjs
212+
env:
213+
DEPLOYMENT_URL: http://localhost:3000
214+
188215
nextjs-deployed:
189216
concurrency: nextjs-deployed
190217
runs-on: ubuntu-latest
@@ -206,7 +233,7 @@ jobs:
206233

207234
- uses: pnpm/action-setup@v4
208235
with:
209-
version: latest
236+
version: 9
210237

211238
- name: Deploy
212239
run: |
@@ -228,7 +255,7 @@ jobs:
228255
version: ${{ steps.version.outputs.version }}
229256
needs:
230257
- cloudflare-workers-local-build
231-
# - nextjs-local-build
258+
- nextjs-local-build
232259
- local-tests
233260

234261
name: Release

bun.lockb

6.95 KB
Binary file not shown.

eslint.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ export default [
8585
args: false,
8686
props: false,
8787
db: false,
88+
dev: false,
89+
env: false,
8890
},
8991
},
9092
],

examples/nextjs/app/ci/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Client } from "@upstash/qstash"
22
import { CRON, DESTINATION } from "./constants";
33

4+
// Without this, `next build` pre-renders the GET and would call the live QStash API.
5+
export const dynamic = "force-dynamic";
6+
47
export const GET = async () => {
58
if (!process.env.QSTASH_TOKEN) {
69
throw new Error("CI test failed. QSTASH_TOKEN is missing.")
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NextResponse } from "next/server";
2+
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";
3+
4+
// Don't run at build time — devMode talks to a local QStash binary.
5+
export const dynamic = "force-dynamic";
6+
7+
// Module-scoped set so the test can poll for delivery via GET ?check=<id>.
8+
const received = new Set<string>();
9+
10+
export const POST = verifySignatureAppRouter(
11+
async (request: Request) => {
12+
const messageId = request.headers.get("upstash-message-id");
13+
if (messageId) received.add(messageId);
14+
return NextResponse.json({ ok: true, messageId });
15+
},
16+
{ devMode: true }
17+
);
18+
19+
export const GET = (request: Request) => {
20+
const id = new URL(request.url).searchParams.get("check");
21+
return NextResponse.json({ received: id ? received.has(id) : false });
22+
};

examples/nextjs/app/dev/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { NextResponse } from "next/server";
2+
import { Client } from "@upstash/qstash";
3+
4+
// Don't run at build time — devMode talks to a local QStash binary.
5+
export const dynamic = "force-dynamic";
6+
7+
const client = new Client({ token: "dev-token", devMode: true });
8+
9+
export const GET = async () => {
10+
const { messageId } = await client.publishJSON({
11+
url: "https://example.com",
12+
body: { hello: "from /dev route" },
13+
});
14+
return NextResponse.json({ ok: true, messageId });
15+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { NextResponse } from "next/server";
2+
import { Client } from "@upstash/qstash";
3+
4+
// Don't run at build time — devMode talks to a local QStash binary.
5+
export const dynamic = "force-dynamic";
6+
7+
const client = new Client({ token: "dev-token", devMode: true });
8+
9+
export const GET = async (request: Request) => {
10+
const origin = new URL(request.url).origin;
11+
const { messageId } = await client.publishJSON({
12+
url: `${origin}/dev/receive`,
13+
body: { hello: "from /dev/send" },
14+
});
15+
return NextResponse.json({ ok: true, messageId });
16+
};

examples/nextjs/app/edge/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { NextRequest, NextResponse } from "next/server";
1+
import { NextResponse } from "next/server";
22
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";
33

4-
async function handler(_req: NextRequest) {
4+
async function handler(_req: Request) {
55
// simulate work
66
await new Promise((r) => setTimeout(r, 1000));
77

examples/nextjs/app/serverless/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { verifySignatureAppRouter } from "@upstash/qstash/dist/nextjs";
2-
import { NextRequest, NextResponse } from "next/server";
1+
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";
2+
import { NextResponse } from "next/server";
33

4-
async function handler(_req: NextRequest) {
4+
async function handler(_req: Request) {
55
// simulate work
66
await new Promise((r) => setTimeout(r, 1000));
77

examples/nextjs/dev.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { test, expect } from "bun:test";
2+
3+
// Hits the /dev routes which use devMode: true. That triggers the qstash
4+
// CLI dev server to spawn inside the Next.js process. Run after starting the
5+
// example with `bun dev`. Also runs in CI via the nextjs-local-build job.
6+
const deploymentURL = process.env.DEPLOYMENT_URL;
7+
if (!deploymentURL) {
8+
throw new Error("DEPLOYMENT_URL not set");
9+
}
10+
11+
test("/dev publishes via dev server", async () => {
12+
const res = await fetch(`${deploymentURL}/dev`);
13+
if (res.status !== 200) console.log(await res.text());
14+
expect(res.status).toBe(200);
15+
16+
const body = (await res.json()) as { ok: boolean; messageId?: string };
17+
expect(body.ok).toBe(true);
18+
expect(body.messageId).toBeTruthy();
19+
}, 60_000);
20+
21+
test("/dev/send → /dev/receive round trip with signature verification", async () => {
22+
const sendRes = await fetch(`${deploymentURL}/dev/send`);
23+
expect(sendRes.status).toBe(200);
24+
const { messageId } = (await sendRes.json()) as { messageId: string };
25+
expect(messageId).toBeTruthy();
26+
27+
// Dev server delivers asynchronously; poll the receive route for arrival.
28+
const deadline = Date.now() + 10_000;
29+
while (Date.now() < deadline) {
30+
const checkRes = await fetch(`${deploymentURL}/dev/receive?check=${messageId}`);
31+
const { received } = (await checkRes.json()) as { received: boolean };
32+
if (received) return;
33+
await Bun.sleep(250);
34+
}
35+
throw new Error(`message ${messageId} never delivered to /dev/receive within 10s`);
36+
}, 60_000);
37+
38+
test("/dev/receive rejects unsigned requests", async () => {
39+
const res = await fetch(`${deploymentURL}/dev/receive`, {
40+
method: "POST",
41+
body: JSON.stringify({ hello: "no signature" }),
42+
});
43+
// verifySignatureAppRouter returns 403 when the signature header is missing.
44+
expect(res.status).toBe(403);
45+
});

0 commit comments

Comments
 (0)