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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion packages/sidequest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"dependencies": {
"@sidequest/backend": "workspace:*",
"@sidequest/core": "workspace:*",
"@sidequest/engine": "workspace:*"
"@sidequest/engine": "workspace:*",
"@sidequest/web": "workspace:*"
}
}
51 changes: 50 additions & 1 deletion packages/sidequest/src/operations/sidequest.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<void> };

/**
* Provides access to the singleton QueueOperations instance for managing queues.
*
Expand Down Expand Up @@ -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
Expand All @@ -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<void> {
interface WebServer {
serveDashboard(options: {
backend: Backend;
driver?: string;
port?: number;
basePath?: string;
auth?: { user: string; password: string };
}): { close: () => Promise<void> };
}
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;
Comment thread
GiovaniGuizzo marked this conversation as resolved.
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.
*
Expand Down
27 changes: 21 additions & 6 deletions packages/sidequest/src/operations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,25 @@ export type SidequestEngineConfig<TDriver extends string = KnownDrivers> = 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<TDriver extends string = KnownDrivers> = SidequestEngineConfig<TDriver>;
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<TDriver extends string = KnownDrivers> = SidequestEngineConfig<TDriver> & {
/** Optional dashboard served alongside the engine. Requires `@sidequest/web`. */
dashboard?: DashboardConfig;
};
8 changes: 7 additions & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:*",
Expand Down
8 changes: 5 additions & 3 deletions packages/web/rollup.config.js
Original file line number Diff line number Diff line change
@@ -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"]);
2 changes: 2 additions & 0 deletions packages/web/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
8 changes: 8 additions & 0 deletions packages/web/src/dashboard/app/global.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
10 changes: 9 additions & 1 deletion packages/web/src/dashboard/app/main.tsx
Original file line number Diff line number Diff line change
@@ -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 `<base>/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(<DashboardApp />);
if (root) {
createRoot(root).render(<DashboardApp client={createApiClient(apiBase)} />);
}
1 change: 1 addition & 0 deletions packages/web/src/server/__fixtures__/app/assets/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Dashboard SPA fixture asset — served as a static file by serve.test.ts.
11 changes: 11 additions & 0 deletions packages/web/src/server/__fixtures__/app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Sidequest</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./assets/app.js"></script>
</body>
</html>
2 changes: 2 additions & 0 deletions packages/web/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createDashboardApp, normalizeBase, serveDashboard } from "./serve";
export type { DashboardAuth, DashboardOptions, DashboardServer } from "./serve";
66 changes: 66 additions & 0 deletions packages/web/src/server/serve.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading