Skip to content
Draft
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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
"typescript-eslint": "^8.18.0"
},
"dependencies": {
"@upstash/qstash": "^2.11.0"
"@upstash/qstash": "0.0.0-ci.8c658c13532090cf00f778d2216eacbad68f85f9-20260618065901"
},
"directories": {
"example": "examples"
Expand Down
3 changes: 3 additions & 0 deletions platforms/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { InvokableWorkflow, WorkflowServeOptions, Telemetry, WorkflowContext } f
import { serveBase } from "../src/serve";
import { SDK_TELEMETRY } from "../src/constants";
import { serveManyBase } from "../src/serve/serve-many";
import { startDevServer } from "@upstash/qstash";

const telemetry: Telemetry = {
sdk: SDK_TELEMETRY,
Expand All @@ -20,6 +21,8 @@ export function serve<TInitialPayload = unknown, TResult = unknown>(
) => Promise<TResult>,
options?: WorkflowServeOptions<TInitialPayload, TResult>
) {
void startDevServer();

const POST: APIRoute = (apiContext) => {
const { handler } = serveBase<TInitialPayload, Request, Response, TResult>(
(workflowContext) => routeFunction(workflowContext, apiContext),
Expand Down
3 changes: 3 additions & 0 deletions platforms/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { InvokableWorkflow, WorkflowServeOptions, RouteFunction, Telemetry
import { SDK_TELEMETRY } from "../src/constants";
import { serveBase } from "../src/serve";
import { serveManyBase } from "../src/serve/serve-many";
import { startDevServer } from "@upstash/qstash";

export type WorkflowBindings = {
QSTASH_TOKEN: string;
Expand Down Expand Up @@ -69,6 +70,8 @@ export const serve = <TInitialPayload = unknown, TResult = unknown>(
): {
fetch: (...args: PagesHandlerArgs | WorkersHandlerArgs) => Promise<Response>;
} => {
void startDevServer();

const fetch = async (...args: PagesHandlerArgs | WorkersHandlerArgs) => {
const { request, env } = getArgs(args);
const { handler: serveHandler } = serveBase(routeFunction, telemetry, {
Expand Down
7 changes: 7 additions & 0 deletions platforms/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
RequestHandler,
} from "express";
import { serveManyBase } from "../src/serve/serve-many";
import { startDevServer } from "@upstash/qstash";

const isEmptyRequest = (req: ExpressRequest) => {
return (
Expand Down Expand Up @@ -83,6 +84,8 @@ export function serve<TInitialPayload = unknown, TResult = unknown>(
routeFunction: RouteFunction<TInitialPayload, TResult>,
options?: WorkflowServeOptions<TInitialPayload, TResult>
): Router {
void startDevServer();

const router = Router();

const handler: RequestHandler = createExpressHandler([routeFunction, options]);
Expand All @@ -108,6 +111,10 @@ export const serveMany = (
workflows: Parameters<typeof serveManyBase>[0]["workflows"],
options?: Parameters<typeof serveManyBase>[0]["options"]
) => {
// serveMany's serveMethod uses createExpressHandler (not serve), so start the
// dev server here too. No-op unless QSTASH_DEV is set.
void startDevServer();

const router = Router();

const { handler } = serveManyBase<ReturnType<typeof createExpressHandler>>({
Expand Down
3 changes: 3 additions & 0 deletions platforms/h3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { serveBase } from "../src/serve";
import type { IncomingHttpHeaders } from "node:http";
import { SDK_TELEMETRY } from "../src/constants";
import { serveManyBase } from "../src/serve/serve-many";
import { startDevServer } from "@upstash/qstash";

function transformHeaders(headers: IncomingHttpHeaders): [string, string][] {
const formattedHeaders = Object.entries(headers).map(([key, value]) => [
Expand Down Expand Up @@ -34,6 +35,8 @@ export const serve = <TInitialPayload = unknown, TResult = unknown>(
routeFunction: RouteFunction<TInitialPayload, TResult>,
options?: WorkflowServeOptions<TInitialPayload, TResult>
) => {
void startDevServer();

const handler = defineEventHandler(async (event) => {
const method = event.node.req.method;
if (method?.toUpperCase() !== "POST") {
Expand Down
3 changes: 3 additions & 0 deletions platforms/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { serveBase } from "../src/serve";
import { Variables } from "hono/types";
import { SDK_TELEMETRY } from "../src/constants";
import { serveManyBase } from "../src/serve/serve-many";
import { startDevServer } from "@upstash/qstash";

export type WorkflowBindings = {
QSTASH_TOKEN: string;
Expand Down Expand Up @@ -36,6 +37,8 @@ export const serve = <
routeFunction: RouteFunction<TInitialPayload, TResult>,
options?: WorkflowServeOptions<TInitialPayload, TResult>
): ((context: Context<{ Bindings: TBindings; Variables: TVariables }>) => Promise<Response>) => {
void startDevServer();

const handler = async (context: Context<{ Bindings: TBindings; Variables: TVariables }>) => {
const environment = context.env
? context.env
Expand Down
5 changes: 5 additions & 0 deletions platforms/nextjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { RouteFunction, WorkflowServeOptions, Telemetry, InvokableWorkflow
import { serveBase } from "../src/serve";
import { SDK_TELEMETRY } from "../src/constants";
import { serveManyBase } from "../src/serve/serve-many";
import { startDevServer } from "@upstash/qstash";

const appTelemetry: Telemetry = {
sdk: SDK_TELEMETRY,
Expand Down Expand Up @@ -34,6 +35,8 @@ export const serve = <TInitialPayload = unknown, TResult = unknown>(
routeFunction: RouteFunction<TInitialPayload, TResult>,
options?: WorkflowServeOptions<TInitialPayload, TResult>
) => {
void startDevServer();

const { handler: serveHandler } = serveBase<TInitialPayload, Request, Response, TResult>(
routeFunction,
appTelemetry,
Expand Down Expand Up @@ -80,6 +83,8 @@ export const servePagesRouter = <TInitialPayload = unknown, TResult = unknown>(
): {
handler: NextApiHandler;
} => {
void startDevServer();

const { handler: serveHandler } = serveBase(routeFunction, pagesTelemetry, options, undefined);

const handler = async (request_: NextApiRequest, res: NextApiResponse) => {
Expand Down
3 changes: 3 additions & 0 deletions platforms/react-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { WorkflowServeOptions, RouteFunction, Telemetry, InvokableWorkflow
import { SDK_TELEMETRY } from "../src/constants";
import { serveBase } from "../src/serve";
import { serveManyBase } from "../src/serve/serve-many";
import { startDevServer } from "@upstash/qstash";

const telemetry: Telemetry = {
sdk: SDK_TELEMETRY,
Expand Down Expand Up @@ -39,6 +40,8 @@ export const serve = <TInitialPayload = unknown, TResult = unknown>(
routeFunction: RouteFunction<TInitialPayload, TResult>,
options?: WorkflowServeOptions<TInitialPayload, TResult>
) => {
void startDevServer();

const { handler: serveHandler } = serveBase<TInitialPayload, Request, Response, TResult>(
routeFunction,
telemetry,
Expand Down
3 changes: 3 additions & 0 deletions platforms/solidjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { APIEvent } from "@solidjs/start/server";
import type { WorkflowServeOptions, RouteFunction, Telemetry } from "../src";
import { serveBase } from "../src/serve";
import { SDK_TELEMETRY } from "../src/constants";
import { startDevServer } from "@upstash/qstash";

/**
* Serve method to serve a Upstash Workflow in a SolidJS project
Expand All @@ -17,6 +18,8 @@ export const serve = <TInitialPayload = unknown, TResult = unknown>(
routeFunction: RouteFunction<TInitialPayload, TResult>,
options?: WorkflowServeOptions<TInitialPayload, TResult>
) => {
void startDevServer();

const telemetry: Telemetry = {
sdk: SDK_TELEMETRY,
framework: "solidjs",
Expand Down
3 changes: 3 additions & 0 deletions platforms/svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { InvokableWorkflow, WorkflowServeOptions, RouteFunction, Telemetry
import { serveBase } from "../src/serve";
import { SDK_TELEMETRY } from "../src/constants";
import { OmitOptionsInServeMany, serveManyBase } from "../src/serve/serve-many";
import { startDevServer } from "@upstash/qstash";

const telemetry: Telemetry = {
sdk: SDK_TELEMETRY,
Expand Down Expand Up @@ -31,6 +32,8 @@ export const serve = <TInitialPayload = unknown, TResult = unknown>(
): {
POST: RequestHandler;
} => {
void startDevServer();

const handler: RequestHandler = async ({ request }) => {
const { handler: serveHandler } = serveBase<TInitialPayload, Request, Response, TResult>(
routeFunction,
Expand Down
3 changes: 3 additions & 0 deletions platforms/tanstack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
import { serveBase } from "../src/serve";
import { SDK_TELEMETRY } from "../src/constants";
import { serveManyBase } from "../src/serve/serve-many";
import { startDevServer } from "@upstash/qstash";

const telemetry: Telemetry = {
sdk: SDK_TELEMETRY,
Expand All @@ -32,6 +33,8 @@ export function serve<TInitialPayload = unknown, TResult = unknown>(
> &
ExclusiveValidationOptions<TInitialPayload>
) {
void startDevServer();

const POST = (tanstackContext: { request: Request }) => {
// Create a Next.js compatible handler that passes the route context
const { handler } = serveBase<TInitialPayload, Request, Response, TResult>(
Expand Down
112 changes: 112 additions & 0 deletions src/serve/dev-server-eager-start.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Pins the eager-start regression (DX-2705): defining a workflow route by
* calling an adapter's `serve()` / `serveMany()` factory must start the QStash
* dev server *synchronously at factory-call time* — NOT deferred to the first
* inbound request, which is how every lazy adapter behaved before this change.
*
* We assert at the `@upstash/qstash` boundary by mocking `startDevServer`, the
* single call each adapter makes. (Prior art for the "drive the public entry,
* assert at the qstash boundary" approach: multi-region/multi-region.test.ts.)
*/
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import * as realQstash from "@upstash/qstash";

const startDevServer = mock(() => Promise.resolve());

// Keep the real Client/Receiver/etc.; only intercept startDevServer. Registered
// before the adapters are imported (below) so they pick up the mocked export.
void mock.module("@upstash/qstash", () => ({
...realQstash,
startDevServer,
}));

// Import the adapters dynamically AFTER the mock is registered.
const hono = await import("../../platforms/hono");
const cloudflare = await import("../../platforms/cloudflare");
const express = await import("../../platforms/express");
const svelte = await import("../../platforms/svelte");
const h3 = await import("../../platforms/h3");
const solidjs = await import("../../platforms/solidjs");
const astro = await import("../../platforms/astro");
const tanstack = await import("../../platforms/tanstack");
const nextjs = await import("../../platforms/nextjs");
const reactRouter = await import("../../platforms/react-router");
const baseServe = await import("./index");

const routeFunction = async () => {};

// Every public serve-like factory should start the dev server when the route is
// defined. `defineRoute` calls the factory but never invokes the returned
// request handler — so a passing assertion proves the start was NOT deferred.
const factories: { name: string; defineRoute: () => unknown }[] = [
{ name: "hono.serve", defineRoute: () => hono.serve(routeFunction) },
{ name: "cloudflare.serve", defineRoute: () => cloudflare.serve(routeFunction) },
{ name: "express.serve", defineRoute: () => express.serve(routeFunction) },
{ name: "svelte.serve", defineRoute: () => svelte.serve(routeFunction, { env: {} }) },
{ name: "h3.serve", defineRoute: () => h3.serve(routeFunction) },
{ name: "solidjs.serve", defineRoute: () => solidjs.serve(routeFunction) },
{ name: "astro.serve", defineRoute: () => astro.serve(routeFunction) },
{ name: "tanstack.serve", defineRoute: () => tanstack.serve(routeFunction) },
{ name: "nextjs.serve", defineRoute: () => nextjs.serve(routeFunction) },
{ name: "nextjs.servePagesRouter", defineRoute: () => nextjs.servePagesRouter(routeFunction) },
{ name: "reactRouter.serve", defineRoute: () => reactRouter.serve(routeFunction) },
{ name: "serve (framework-agnostic root)", defineRoute: () => baseServe.serve(routeFunction) },
];

// serveMany factories. For most adapters serveMethod is the adapter `serve`, so
// they are covered transitively; express is the exception (its serveMethod is an
// internal handler, not `serve`) and so starts the dev server in serveMany itself.
const serveManyFactories: { name: string; defineRoutes: () => unknown }[] = [
{
name: "hono.serveMany",
defineRoutes: () => hono.serveMany({ wf: hono.createWorkflow(routeFunction) }),
},
{
name: "express.serveMany",
defineRoutes: () => express.serveMany({ wf: express.createWorkflow(routeFunction) }),
},
{
name: "nextjs.serveMany",
defineRoutes: () => nextjs.serveMany({ wf: nextjs.createWorkflow(routeFunction) }),
},
{
name: "nextjs.serveManyPagesRouter",
defineRoutes: () =>
nextjs.serveManyPagesRouter({ wf: nextjs.createWorkflowPagesRouter(routeFunction) }),
},
];

describe("adapters eagerly start the QStash dev server on serve()", () => {
let prevQstashDev: string | undefined;

beforeEach(() => {
// Eager adapters (nextjs, react-router) construct a real Client in serve();
// make sure a machine-level QSTASH_DEV=true doesn't make that real Client
// spawn an actual dev server during this unit test.
prevQstashDev = process.env.QSTASH_DEV;
delete process.env.QSTASH_DEV;
startDevServer.mockClear();
});

afterEach(() => {
if (prevQstashDev === undefined) delete process.env.QSTASH_DEV;
else process.env.QSTASH_DEV = prevQstashDev;
});

for (const { name, defineRoute } of factories) {
test(`${name} starts the dev server synchronously at factory-call time`, () => {
expect(startDevServer).not.toHaveBeenCalled();
defineRoute();
// Synchronously after defining the route and before any inbound request.
expect(startDevServer).toHaveBeenCalledTimes(1);
});
}

for (const { name, defineRoutes } of serveManyFactories) {
test(`${name} starts the dev server at setup time`, () => {
expect(startDevServer).not.toHaveBeenCalled();
defineRoutes();
expect(startDevServer).toHaveBeenCalled();
});
}
});
2 changes: 2 additions & 0 deletions src/serve/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { startDevServer } from "@upstash/qstash";
import { makeCancelRequest } from "../client/utils";
import {
SDK_TELEMETRY,
Expand Down Expand Up @@ -375,6 +376,7 @@ export const serve = <
routeFunction: RouteFunction<TInitialPayload, TResult>,
options?: WorkflowServeOptions<TInitialPayload, TResult>
): ReturnType<typeof serveBase<TInitialPayload, TRequest, TResponse, TResult>> => {
void startDevServer();
return serveBase(
routeFunction,
{
Expand Down
29 changes: 27 additions & 2 deletions src/serve/multi-region/dev-server-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ describe("workflow dev server integration", () => {
},
{ url: workflowUrl }
).handler;

client = new Client({ devMode: true });
});

afterAll(() => {
Expand All @@ -59,10 +57,37 @@ describe("workflow dev server integration", () => {
while (Date.now() < deadline && !predicate()) await Bun.sleep(100);
};

const isDevServerReachable = async () => {
try {
// Any HTTP response (even an error status) means the dev server is up.
await fetch(`http://127.0.0.1:${INTEGRATION_PORT}`);
return true;
} catch {
return false;
}
};

const waitForAsync = async (predicate: () => Promise<boolean>, timeoutMs = 60_000) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline && !(await predicate())) await Bun.sleep(100);
};

test(
"dev server is reachable after serve() and before any inbound request",
async () => {
// The only thing that has run is serve() in beforeAll — no workflow request
// has been published yet. This is the new eager-start guarantee.
await waitForAsync(isDevServerReachable);
expect(await isDevServerReachable()).toBe(true);
},
{ timeout: 60_000 }
);

test(
"multi-step workflow: every dev-server delivery verifies and steps run in order",
async () => {
ranSteps.length = 0;
client = new Client({ devMode: true });
const { messageId } = await client.publishJSON({ url: workflowUrl, body: "world" });
expect(messageId).toBeTruthy();

Expand Down
Loading
Loading