diff --git a/packages/next/README.md b/packages/next/README.md index fd20587..56c8ce1 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -68,7 +68,7 @@ Visit `http://localhost:3000/`. | Option | Type | Description | | ---------- | --------------------------- | ------------------------------------------------------ | -| `queues` | `Queue[]` | BullMQ `Queue` instances to display. Required. | +| `queues` | `Queue[] \| Promise` | 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. | diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts new file mode 100644 index 0000000..9168f87 --- /dev/null +++ b/packages/next/src/index.test.ts @@ -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"); + }); +}); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 03db6a0..9e909b0 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -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 { + /** BullMQ Queue instances to display, or a promise resolving to them */ + queues?: Queue[] | Promise; +} + /** * Mount the Workbench dashboard on a Next.js App Router catch-all route. * @@ -39,11 +49,22 @@ export interface WorkbenchHandlers { * }, * }); * ``` + * + * `queues` also accepts a `Promise` 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 + * 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, @@ -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 { + return ( + typeof value === "object" && + value !== null && + typeof (value as PromiseLike).then === "function" + ); +} + export type { WorkbenchOptions } from "@getworkbench/core";