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
2 changes: 1 addition & 1 deletion packages/next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Visit `http://localhost:3000/<mount>`.

| Option | Type | Description |
| ---------- | --------------------------- | ------------------------------------------------------ |
| `queues` | `Queue[]` | BullMQ `Queue` instances to display. Required. |
| `queues` | `Queue[] \| Promise<Queue[]>` | BullMQ `Queue` instances to display, or a promise resolving to them. Required. |
| `auth` | `{ username, password }` | Basic auth credentials. Strongly recommended in prod. |
| `title` | `string` | Dashboard title. Default: `"Workbench"`. |
| `logo` | `string` | Logo URL to display in the nav. |
Expand Down
72 changes: 72 additions & 0 deletions packages/next/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import type { Queue } from "bullmq";
import { workbench } from "./index";

// A queue stub is enough here: `/config` only reads queue names from the
// QueueManager map and never touches Redis.
function fakeQueue(name: string): Queue {
return { name, opts: {} } as unknown as Queue;
}

const CONFIG_URL = "http://localhost/admin/jobs/config";

describe("workbench (Next.js adapter)", () => {
test("accepts queues synchronously", async () => {
const { GET } = workbench({
queues: [fakeQueue("emails")],
basePath: "/admin/jobs",
alerts: { enabled: false },
});

const res = await GET(new Request(CONFIG_URL));
expect(res.status).toBe(200);
const config = (await res.json()) as { queues: string[] };
expect(config.queues).toEqual(["emails"]);
});

test("accepts queues as a promise", async () => {
const { GET } = workbench({
queues: Promise.resolve([fakeQueue("emails"), fakeQueue("webhooks")]),
basePath: "/admin/jobs",
alerts: { enabled: false },
});

const res = await GET(new Request(CONFIG_URL));
expect(res.status).toBe(200);
const config = (await res.json()) as { queues: string[] };
expect(config.queues).toEqual(["emails", "webhooks"]);
});

test("resolves the queues promise once and reuses the handler", async () => {
let resolveCount = 0;
const queues = Promise.resolve().then(() => {
resolveCount++;
return [fakeQueue("emails")];
});

const { GET, POST } = workbench({
queues,
basePath: "/admin/jobs",
alerts: { enabled: false },
});

const [a, b] = await Promise.all([
GET(new Request(CONFIG_URL)),
POST(new Request(CONFIG_URL, { method: "POST" })),
]);
expect(a.status).toBe(200);
expect(b.status).toBe(404); // POST /config is not a route, but it routed
expect(resolveCount).toBe(1);
});

test("surfaces a rejected queues promise on each request", async () => {
const { GET } = workbench({
queues: Promise.reject(new Error("redis unavailable")),
basePath: "/admin/jobs",
alerts: { enabled: false },
});

expect(GET(new Request(CONFIG_URL))).rejects.toThrow("redis unavailable");
expect(GET(new Request(CONFIG_URL))).rejects.toThrow("redis unavailable");
});
});
52 changes: 50 additions & 2 deletions packages/next/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ export interface WorkbenchHandlers {
DELETE: NextRouteHandler;
}

/**
* Same as {@link WorkbenchOptions}, but `queues` may also be a promise so
* apps that collect their queues asynchronously can pass the promise
* directly instead of blocking module evaluation.
*/
export interface NextWorkbenchOptions extends Omit<WorkbenchOptions, "queues"> {
/** BullMQ Queue instances to display, or a promise resolving to them */
queues?: Queue[] | Promise<Queue[]>;
}

/**
* Mount the Workbench dashboard on a Next.js App Router catch-all route.
*
Expand Down Expand Up @@ -39,11 +49,22 @@ export interface WorkbenchHandlers {
* },
* });
* ```
*
* `queues` also accepts a `Promise<Queue[]>` for apps that discover their
* queues asynchronously — pass the promise as-is and requests wait for it:
*
* @example
* ```ts
* export const { GET, POST, PUT, PATCH, DELETE } = workbench({
* queues: loadQueues(), // () => Promise<Queue[]>
* basePath: "/admin/jobs",
* });
* ```
*/
export function workbench(
options: WorkbenchOptions | Queue[],
options: NextWorkbenchOptions | Queue[],
): WorkbenchHandlers {
const { fetch } = createFetchHandler(options);
const fetch = createHandler(options);
return {
GET: fetch,
POST: fetch,
Expand All @@ -53,4 +74,31 @@ export function workbench(
};
}

function createHandler(
options: NextWorkbenchOptions | Queue[],
): NextRouteHandler {
// Synchronous queues: build the handler eagerly, exactly as before.
if (Array.isArray(options) || !isThenable(options.queues)) {
return createFetchHandler(options as WorkbenchOptions | Queue[]).fetch;
}

const handlerPromise = options.queues.then(
(queues) => createFetchHandler({ ...options, queues }).fetch,
);
// Keep a rejected queues promise from becoming an unhandled rejection that
// can take down the server before any request lands — every request below
// still surfaces the original error.
handlerPromise.catch(() => {});

return async (req) => (await handlerPromise)(req);
}

function isThenable(value: unknown): value is Promise<Queue[]> {
return (
typeof value === "object" &&
value !== null &&
typeof (value as PromiseLike<unknown>).then === "function"
);
}

export type { WorkbenchOptions } from "@getworkbench/core";