diff --git a/CLAUDE.md b/CLAUDE.md index ca568f1..d34bba3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Monorepo on **Yarn 4 (Berry, via Corepack) + Turbo**. Workspaces declared in roo | `packages/sidequest` | `sidequest` | Umbrella package end users install. Exposes `Sidequest`, re-exports the rest. Source is intentionally thin — mostly the `Sidequest` static class and operations facade. | | `packages/engine` | `@sidequest/engine` | Orchestration. Owns the `Engine`, `Dispatcher`, `QueueManager`, `ExecutorManager`, `JobBuilder`, `JobTransitioner`, cron registry, routines (cleanup, stale recovery), shared runner pool. | | `packages/core` | `@sidequest/core` | Shared primitives: `Job` base class, schema/types (`JobData`, `QueueConfig`, etc.), state transitions, logger (Winston), uniqueness, tools. | -| `packages/web` | `@sidequest/web` | Web layer (v2 rewrite, React + Vite). Currently ships the `/ui` design-system component library; the OSS dashboard app, the Hono management API, and a boot façade are being added. | +| `packages/web` | `@sidequest/web` | Web layer (v2 rewrite, React + Vite). Ships `/ui` (design-system components), `/api` (Hono management API), and `/server` (`serveDashboard()` façade serving the SPA + API). Booted by `Sidequest.start({ dashboard })`. | | `packages/cli` | `@sidequest/cli` | `sidequest` / `sq` CLI for `config`, `migrate`, `rollback`. | | `packages/docs` | (private) | VitePress site → docs.sidequestjs.com. | | `packages/backends/backend` | `@sidequest/backend` | Backend interface + `SQLBackend` base (Knex-based). | @@ -96,7 +96,7 @@ Node ≥ 22.6.0 required. TypeScript jobs run natively on Node ≥ 23.6.0. - `Sidequest.job` — `.get`, `.list`, `.count`, `.cancel`, `.run`, `.snooze`, `.findStale`, `.deleteFinished`. - `Sidequest.queue` — `.get`, `.list`, `.create`, `.pause`, `.activate`, `.toggle`, `.setConcurrency`, `.setPriority`. - `Job` class (`@sidequest/core`) with `async run(...args)`. Runtime metadata (`this.id`, `this.attempt`, etc.) is injected **after construction**, only available inside `run`. Convenience methods inside `run`: `return this.complete(result)` / `this.fail(reason)` / `this.retry(reason, delay?)` / `this.snooze(delay)`. **You must `return` them** — calling without returning is a no-op. -- Dashboard: under v2 rewrite. The old Express/EJS `SidequestDashboard` and the `Sidequest.start({ dashboard })` option have been removed; `@sidequest/web` currently exposes only the `/ui` component library. Booting a dashboard will return via the `@sidequest/web` façade. +- **Dashboard boots via the `@sidequest/web` façade.** `Sidequest.start({ dashboard: { enabled, port, basePath, auth } })` dynamically imports `@sidequest/web/server` and calls `serveDashboard()`, which serves the built SPA (`dist/app`) plus the Hono management API on one port. The client↔API base is reconciled by injecting `window.__SQ_DASHBOARD_BASE__` into the served `index.html` (the SPA uses hash routing + relative assets, so one build serves any `basePath`). Without `auth` the dashboard is wide open (dev-only). ## Behavioral nuances that bite diff --git a/packages/sidequest/package.json b/packages/sidequest/package.json index 0da1451..80a7dbe 100644 --- a/packages/sidequest/package.json +++ b/packages/sidequest/package.json @@ -61,6 +61,7 @@ "dependencies": { "@sidequest/backend": "workspace:*", "@sidequest/core": "workspace:*", - "@sidequest/engine": "workspace:*" + "@sidequest/engine": "workspace:*", + "@sidequest/web": "workspace:*" } } diff --git a/packages/sidequest/src/operations/sidequest.ts b/packages/sidequest/src/operations/sidequest.ts index 1c6834c..f5ff3a2 100644 --- a/packages/sidequest/src/operations/sidequest.ts +++ b/packages/sidequest/src/operations/sidequest.ts @@ -1,8 +1,9 @@ +import type { Backend } from "@sidequest/backend"; import { JobClassType, logger } from "@sidequest/core"; import { Engine } from "@sidequest/engine"; import { JobOperations } from "./job"; import { QueueOperations } from "./queue"; -import { KnownDrivers, SidequestConfig, SidequestEngineConfig } from "./types"; +import { DashboardConfig, KnownDrivers, SidequestConfig, SidequestEngineConfig } from "./types"; /** * Main entry point for the Sidequest job processing system. @@ -29,6 +30,9 @@ export class Sidequest { */ private static engine = new Engine(); + /** Handle to the running dashboard server, when booted via `start({ dashboard })`. */ + private static dashboardServer?: { close: () => Promise }; + /** * Provides access to the singleton QueueOperations instance for managing queues. * @@ -91,6 +95,11 @@ export class Sidequest { const engineConfig = await this.configure(config); await this.engine.start(engineConfig); + + if (config?.dashboard?.enabled) { + const driver = (engineConfig as { backend?: { driver?: string } }).backend?.driver; + await this.startDashboard(config.dashboard, driver); + } } catch (error) { logger().error("Failed to start Sidequest:", error); await this.stop(); // Ensure cleanup on error @@ -109,11 +118,51 @@ export class Sidequest { * @returns A promise that resolves when all cleanup operations are complete */ static async stop() { + if (this.dashboardServer) { + await this.dashboardServer.close(); + this.dashboardServer = undefined; + } await this.engine.close(); this.job.setBackend(undefined); this.queue.setBackend(undefined); } + /** + * Boots the `@sidequest/web` dashboard façade alongside the engine, serving the SPA and + * the management API on `dashboard.port`. Loaded lazily via a dynamic import so + * `@sidequest/web` is only touched when the dashboard is enabled. + */ + private static async startDashboard(dashboard: DashboardConfig, driver?: string): Promise { + interface WebServer { + serveDashboard(options: { + backend: Backend; + driver?: string; + port?: number; + basePath?: string; + auth?: { user: string; password: string }; + }): { close: () => Promise }; + } + const backend = this.getBackend(); + if (!backend) { + throw new Error("Cannot start the dashboard before the engine backend is ready."); + } + try { + const specifier = "@sidequest/web/server"; + const web = (await import(specifier)) as WebServer; + this.dashboardServer = web.serveDashboard({ + backend, + driver, + port: dashboard.port, + basePath: dashboard.basePath, + auth: dashboard.auth, + }); + logger().info(`Sidequest dashboard listening on port ${dashboard.port ?? 8678}`); + } catch (error) { + logger().error("Failed to start the Sidequest dashboard. Is @sidequest/web installed?", error); + throw error; + } + } + /** * Builds a job class using a JobBuilder. * diff --git a/packages/sidequest/src/operations/types.ts b/packages/sidequest/src/operations/types.ts index af54eee..a2dd914 100644 --- a/packages/sidequest/src/operations/types.ts +++ b/packages/sidequest/src/operations/types.ts @@ -49,10 +49,25 @@ export type SidequestEngineConfig = Omit< }; /** - * Complete Sidequest configuration. - * - * Currently an alias of {@link SidequestEngineConfig}. The dashboard/web layer is - * being rewritten and no longer wired into `Sidequest.start`; it will be reintroduced - * here once the `@sidequest/web` façade lands. + * Options for the optional dashboard served alongside the engine by `@sidequest/web`. + * Leaving `auth` unset serves the dashboard wide open (dev-only). */ -export type SidequestConfig = SidequestEngineConfig; +export interface DashboardConfig { + /** Boot the dashboard when the engine starts. @default false */ + enabled?: boolean; + /** Listen port. @default 8678 */ + port?: number; + /** Reverse-proxy prefix the dashboard is mounted under, e.g. "/admin". @default "" */ + basePath?: string; + /** Basic-auth credentials. Omit for a wide-open (dev-only) dashboard. */ + auth?: { user: string; password: string }; +} + +/** + * Complete Sidequest configuration: the engine config plus the optional `dashboard` + * served by the `@sidequest/web` façade. + */ +export type SidequestConfig = SidequestEngineConfig & { + /** Optional dashboard served alongside the engine. Requires `@sidequest/web`. */ + dashboard?: DashboardConfig; +}; diff --git a/packages/web/package.json b/packages/web/package.json index 65122ff..c313e00 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -34,13 +34,18 @@ "types": "./dist/api/index.d.ts", "import": "./dist/api/index.js", "require": "./dist/api/index.cjs" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js", + "require": "./dist/server/index.cjs" } }, "files": [ "dist" ], "scripts": { - "build": "npx rollup -c && npx vite build --config vite.lib.config.ts && npx @tailwindcss/cli -i ./src/ui/styles.css -o ./dist/ui/styles.css --minify", + "build": "npx rollup -c && npx vite build --config vite.lib.config.ts && npx @tailwindcss/cli -i ./src/ui/styles.css -o ./dist/ui/styles.css --minify && npx vite build --config vite.app.config.ts", "dev": "npx rollup -c -w", "dev:app": "npx vite --config vite.app.config.ts", "test": "yarn vitest run", @@ -49,6 +54,7 @@ }, "license": "LGPL-3.0-or-later", "dependencies": { + "@hono/node-server": "^1.13.0", "@sidequest/backend": "workspace:*", "@sidequest/core": "workspace:*", "@sidequest/engine": "workspace:*", diff --git a/packages/web/rollup.config.js b/packages/web/rollup.config.js index 79443d7..eb28a28 100644 --- a/packages/web/rollup.config.js +++ b/packages/web/rollup.config.js @@ -1,6 +1,8 @@ import createConfig from "../../rollup.config.base.js"; import pkg from "./package.json" with { type: "json" }; -// Node build for the management API (`@sidequest/web/api`). The React UI library keeps -// its own Vite build (see vite.lib.config.ts); both write into dist/ without clobbering. -export default createConfig(pkg, "src/api/index.ts"); +// Node builds for the management API (`@sidequest/web/api`) and the dashboard façade +// (`@sidequest/web/server`). The React UI library keeps its own Vite build (see +// vite.lib.config.ts) and the SPA its own (vite.app.config.ts); all write into dist/ +// without clobbering (preserveModules mirrors src/ → dist/api, dist/server, …). +export default createConfig(pkg, ["src/api/index.ts", "src/server/index.ts"]); diff --git a/packages/web/src/api/index.ts b/packages/web/src/api/index.ts index 19b377a..5ae932e 100644 --- a/packages/web/src/api/index.ts +++ b/packages/web/src/api/index.ts @@ -4,6 +4,8 @@ export * from "./filters"; export * from "./routes/jobs"; export * from "./routes/overview"; export * from "./routes/queues"; +export * from "./routes/system"; export * from "./services/job-service"; export * from "./services/overview-service"; export * from "./services/queue-service"; +export * from "./services/system-service"; diff --git a/packages/web/src/dashboard/app/global.d.ts b/packages/web/src/dashboard/app/global.d.ts new file mode 100644 index 0000000..6206f35 --- /dev/null +++ b/packages/web/src/dashboard/app/global.d.ts @@ -0,0 +1,8 @@ +export {}; + +declare global { + interface Window { + /** Base path the dashboard SPA is served under, injected by the `@sidequest/web` façade. */ + __SQ_DASHBOARD_BASE__?: string; + } +} diff --git a/packages/web/src/dashboard/app/main.tsx b/packages/web/src/dashboard/app/main.tsx index e6ae7f8..422f632 100644 --- a/packages/web/src/dashboard/app/main.tsx +++ b/packages/web/src/dashboard/app/main.tsx @@ -1,7 +1,15 @@ import { createRoot } from "react-dom/client"; +import { createApiClient } from "../client"; import "../../ui/styles.css"; import "../dashboard.css"; import { DashboardApp } from "./app"; +// The façade injects the base path the dashboard is served under (default "/"); the +// management API lives at `/api`, so point the client there. +const base = window.__SQ_DASHBOARD_BASE__ ?? "/"; +const apiBase = `${base.replace(/\/$/, "")}/api`; + const root = document.getElementById("root"); -if (root) createRoot(root).render(); +if (root) { + createRoot(root).render(); +} diff --git a/packages/web/src/server/__fixtures__/app/assets/app.js b/packages/web/src/server/__fixtures__/app/assets/app.js new file mode 100644 index 0000000..9e041e6 --- /dev/null +++ b/packages/web/src/server/__fixtures__/app/assets/app.js @@ -0,0 +1 @@ +// Dashboard SPA fixture asset — served as a static file by serve.test.ts. diff --git a/packages/web/src/server/__fixtures__/app/index.html b/packages/web/src/server/__fixtures__/app/index.html new file mode 100644 index 0000000..45cd829 --- /dev/null +++ b/packages/web/src/server/__fixtures__/app/index.html @@ -0,0 +1,11 @@ + + + + + Sidequest + + +
+ + + diff --git a/packages/web/src/server/index.ts b/packages/web/src/server/index.ts new file mode 100644 index 0000000..d71b706 --- /dev/null +++ b/packages/web/src/server/index.ts @@ -0,0 +1,2 @@ +export { createDashboardApp, normalizeBase, serveDashboard } from "./serve"; +export type { DashboardAuth, DashboardOptions, DashboardServer } from "./serve"; diff --git a/packages/web/src/server/serve.test.ts b/packages/web/src/server/serve.test.ts new file mode 100644 index 0000000..4c92592 --- /dev/null +++ b/packages/web/src/server/serve.test.ts @@ -0,0 +1,66 @@ +import { resolve } from "node:path"; +import { mockBackend } from "../api/testing/mock-backend"; +import { createDashboardApp, normalizeBase } from "./serve"; + +const STATIC_DIR = resolve(import.meta.dirname, "__fixtures__/app"); + +describe("normalizeBase", () => { + it("normalizes to '' (root) or '/prefix'", () => { + expect(normalizeBase()).toBe(""); + expect(normalizeBase("")).toBe(""); + expect(normalizeBase("/")).toBe(""); + expect(normalizeBase("admin")).toBe("/admin"); + expect(normalizeBase("/admin/")).toBe("/admin"); + }); +}); + +describe("createDashboardApp", () => { + const backend = mockBackend(); + const app = () => + createDashboardApp({ backend, staticDir: STATIC_DIR, version: "9.9.9", driver: "@sidequest/sqlite-backend" }); + + it("routes the management API under /api", async () => { + const res = await app().request("/api/overview"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ total: 0 }); + }); + + it("serves the SPA shell with the injected base at root", async () => { + const res = await app().request("/"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toContain('window.__SQ_DASHBOARD_BASE__="/"'); + }); + + it("serves static assets with a content type", async () => { + const res = await app().request("/assets/app.js"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("javascript"); + }); + + it("falls back to the shell for unknown SPA paths", async () => { + const res = await app().request("/some/deep/route"); + expect(res.status).toBe(200); + expect(await res.text()).toContain("__SQ_DASHBOARD_BASE__"); + }); + + it("serves under a base path and ignores paths outside it", async () => { + const scoped = createDashboardApp({ backend, staticDir: STATIC_DIR, basePath: "/dash" }); + expect((await scoped.request("/dash/api/overview")).status).toBe(200); + expect(await (await scoped.request("/dash/")).text()).toContain('window.__SQ_DASHBOARD_BASE__="/dash/"'); + expect((await scoped.request("/api/overview")).status).toBe(404); + }); + + it("enforces basic auth when configured", async () => { + const secured = createDashboardApp({ backend, staticDir: STATIC_DIR, auth: { user: "admin", password: "s3cret" } }); + expect((await secured.request("/api/overview")).status).toBe(401); + const ok = await secured.request("/api/overview", { + headers: { Authorization: `Basic ${Buffer.from("admin:s3cret").toString("base64")}` }, + }); + expect(ok.status).toBe(200); + }); + + it("throws without a backend or backend config", () => { + expect(() => createDashboardApp({ staticDir: STATIC_DIR })).toThrow(/backend/); + }); +}); diff --git a/packages/web/src/server/serve.ts b/packages/web/src/server/serve.ts new file mode 100644 index 0000000..8cb4573 --- /dev/null +++ b/packages/web/src/server/serve.ts @@ -0,0 +1,155 @@ +import { serve } from "@hono/node-server"; +import { type Backend, type BackendConfig, LazyBackend } from "@sidequest/backend"; +import { readFileSync } from "node:fs"; +import { extname, join, resolve } from "node:path"; +import { type Context, Hono, type Next } from "hono"; +import { createApiApp } from "../api/app"; + +/** Basic-auth credentials for the dashboard. Leaving `auth` unset serves it wide open. */ +export interface DashboardAuth { + user: string; + password: string; +} + +/** Options for {@link serveDashboard} / {@link createDashboardApp}. */ +export interface DashboardOptions { + /** A live backend to serve (preferred when booting alongside the engine). */ + backend?: Backend; + /** A backend config to lazily build one from, when no `backend` is given. */ + backendConfig?: BackendConfig; + /** Listen port. @default 8678 */ + port?: number; + /** Reverse-proxy prefix the dashboard is mounted under, e.g. "/admin". @default "" */ + basePath?: string; + /** Basic-auth credentials. Omit for a wide-open (dev-only) dashboard. */ + auth?: DashboardAuth; + /** Sidequest version shown in the sidebar (defaults to this package's version). */ + version?: string; + /** Backend driver name shown in the sidebar (defaults to `backendConfig.driver`). */ + driver?: string; + /** Directory of the built SPA. @default the packaged `dist/app`. */ + staticDir?: string; +} + +/** A running dashboard server. */ +export interface DashboardServer { + port: number; + close: () => Promise; +} + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".woff2": "font/woff2", + ".map": "application/json; charset=utf-8", +}; + +/** A minimal HTTP basic-auth Hono middleware (avoids a `hono/basic-auth` subpath import + * that the shared rollup build can't externalize). */ +function basicAuth(user: string, password: string) { + const expected = `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`; + return async (c: Context, next: Next) => { + if (c.req.header("Authorization") !== expected) { + return c.body("Unauthorized", 401, { "WWW-Authenticate": 'Basic realm="Sidequest"' }); + } + await next(); + }; +} + +/** Normalizes a base path to "" (root) or "/prefix" (no trailing slash). */ +export function normalizeBase(basePath?: string): string { + if (!basePath) { + return ""; + } + const trimmed = `/${basePath.replace(/^\/+|\/+$/g, "")}`; + return trimmed === "/" ? "" : trimmed; +} + +/** Reads this package's version from its package.json (for the sidebar), best-effort. */ +function packageVersion(): string | undefined { + try { + const pkg = JSON.parse(readFileSync(resolve(import.meta.dirname, "../../package.json"), "utf8")) as { + version?: string; + }; + return pkg.version; + } catch { + return undefined; + } +} + +/** Injects the runtime base (for the API client + relative assets) into the SPA shell. */ +function injectBase(html: string, base: string): string { + const tag = ``; + return html.includes("") ? html.replace("", `${tag}`) : `${tag}${html}`; +} + +/** + * createDashboardApp — the Hono app serving the management API under `/api` and the + * built SPA for everything else, with optional basic auth. Split out from {@link serveDashboard} + * so it can be exercised with `app.request()` without opening a socket. + */ +export function createDashboardApp(options: DashboardOptions): Hono { + const backend = resolveBackend(options); + const base = normalizeBase(options.basePath); + const staticDir = options.staticDir ?? resolve(import.meta.dirname, "../app"); + const version = options.version ?? packageVersion(); + const driver = options.driver ?? options.backendConfig?.driver; + + const app = new Hono(); + if (options.auth) { + app.use(`${base}/*`, basicAuth(options.auth.user, options.auth.password)); + } + app.route(`${base}/api`, createApiApp({ backend, version, driver })); + app.get(`${base}/*`, (c) => { + const rel = new URL(c.req.url).pathname.slice(base.length).replace(/^\/+/, ""); + const indexHtml = () => c.html(injectBase(readFileSync(join(staticDir, "index.html"), "utf8"), base)); + if (rel === "" || rel === "index.html") { + return indexHtml(); + } + const filePath = join(staticDir, rel); + if (!filePath.startsWith(resolve(staticDir))) { + return c.notFound(); + } + try { + const body = readFileSync(filePath); + return c.body(body, 200, { "Content-Type": MIME[extname(filePath)] ?? "application/octet-stream" }); + } catch { + // Unknown path under the SPA → serve the shell (single-page fallback). + return indexHtml(); + } + }); + return app; +} + +function resolveBackend(options: DashboardOptions): Backend { + if (options.backend) { + return options.backend; + } + if (options.backendConfig) { + return new LazyBackend(options.backendConfig); + } + throw new Error("serveDashboard requires either a `backend` or a `backendConfig`."); +} + +/** + * serveDashboard — boots an HTTP server that serves the OSS dashboard SPA and the management + * API on one port. Pass a live `backend` (when booting with the engine) or a `backendConfig`. + * Returns a handle to close it. + */ +export function serveDashboard(options: DashboardOptions): DashboardServer { + const app = createDashboardApp(options); + const port = options.port ?? 8678; + const server = serve({ fetch: app.fetch, port }); + return { + port, + close: () => + new Promise((resolvePromise, reject) => { + server.close((err) => (err ? reject(err) : resolvePromise())); + }), + }; +} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index ce3ec64..a4fddc7 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "outDir": "dist" }, - "include": ["src/api/**/*.ts"] + "include": ["src/api/**/*.ts", "src/server/**/*.ts"] } diff --git a/packages/web/vite.app.config.ts b/packages/web/vite.app.config.ts index 623a98e..49295cf 100644 --- a/packages/web/vite.app.config.ts +++ b/packages/web/vite.app.config.ts @@ -7,6 +7,10 @@ import { defineConfig } from "vite"; // web façade will serve. export default defineConfig({ root: import.meta.dirname, + // Relative asset URLs so one build serves both the root and any reverse-proxy base path + // (the façade injects the runtime base; the app uses hash routing, so the document URL + // stays at "/" and "./assets/…" resolves under it). + base: "./", plugins: [react()], server: { port: 5199, diff --git a/yarn.lock b/yarn.lock index 7d7427d..019dcc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1651,6 +1651,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.13.0": + version: 1.19.14 + resolution: "@hono/node-server@npm:1.19.14" + peerDependencies: + hono: ^4 + checksum: 10c0/41a099bb3705d96aac44b7a8db8805f2a22ce8a0f767a27b6d10b74a9964925df01c5f35d3631e882f8bcdeee3518884c30f40588ac8c960d88bf71048ba0df3 + languageName: node + linkType: hard + "@humanfs/core@npm:^0.19.1": version: 0.19.1 resolution: "@humanfs/core@npm:0.19.1" @@ -4041,10 +4050,11 @@ __metadata: languageName: unknown linkType: soft -"@sidequest/web@workspace:packages/web": +"@sidequest/web@workspace:*, @sidequest/web@workspace:packages/web": version: 0.0.0-use.local resolution: "@sidequest/web@workspace:packages/web" dependencies: + "@hono/node-server": "npm:^1.13.0" "@sidequest/backend": "workspace:*" "@sidequest/core": "workspace:*" "@sidequest/engine": "workspace:*" @@ -15108,6 +15118,7 @@ __metadata: "@sidequest/backend": "workspace:*" "@sidequest/core": "workspace:*" "@sidequest/engine": "workspace:*" + "@sidequest/web": "workspace:*" languageName: unknown linkType: soft