Skip to content
Closed
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: 2 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ export default defineConfig(
flagWords: ["cancellation", "cancelled"], // prefer en-US spelling for consistency
ignoreWords: [
"arktype",
"healthz",
"heartbeating",
"idempotently",
"openworkflow",
"readyz",
"sonarjs",
"timestamptz",
],
Expand Down
3 changes: 2 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"**/openworkflow.config.*",
"openworkflow/**/*.ts",
"packages/dashboard/src/components/ui/*.tsx",
"packages/docs/style.css"
"packages/docs/style.css",
"packages/openworkflow/http.ts"
],
"ignoreIssues": {
"packages/dashboard/src/components/ui/*.tsx": ["exports"],
Expand Down
35 changes: 29 additions & 6 deletions package-lock.json

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

14 changes: 14 additions & 0 deletions packages/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
doctor,
getVersion,
init,
serverStart,
workerStart,
} from "./commands.js";
import { withErrorHandling } from "./errors.js";
Expand Down Expand Up @@ -59,4 +60,17 @@ program
.option("--config <path>", "path to OpenWorkflow config file")
.action(withErrorHandling(dashboard));

// server
const serverCmd = program
.command("server")
.description("manage the API server");

// server start
serverCmd
.command("start")
.description("start the HTTP API server")
.option("-p, --port <number>", "port to listen on", Number.parseInt)
.option("--config <path>", "path to OpenWorkflow config file")
.action(withErrorHandling(serverStart));

await program.parseAsync(process.argv);
18 changes: 10 additions & 8 deletions packages/cli/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
getConfigFileName,
getExampleWorkflowFileName,
getRunFileName,
validateDashboardPort,
validatePort,
} from "./commands.js";
import fs from "node:fs";
import os from "node:os";
Expand Down Expand Up @@ -164,27 +164,29 @@ describe("getDashboardSpawnOptions", () => {
});
});

describe("validateDashboardPort", () => {
describe("validatePort", () => {
test("returns undefined when no custom port is provided", () => {
expect(validateDashboardPort()).toBeUndefined();
expect(validatePort(undefined, "dashboard")).toBeUndefined();
});

test("returns the port when it is within range", () => {
expect(validateDashboardPort(3001)).toBe(3001);
expect(validatePort(3001, "dashboard")).toBe(3001);
});

test("throws for non-integer ports", () => {
expect(() => validateDashboardPort(Number.NaN)).toThrow(
expect(() => validatePort(Number.NaN, "dashboard")).toThrow(
"Invalid dashboard port.",
);
expect(() => validateDashboardPort(3000.5)).toThrow(
expect(() => validatePort(3000.5, "dashboard")).toThrow(
"Invalid dashboard port.",
);
});

test("throws for out-of-range ports", () => {
expect(() => validateDashboardPort(0)).toThrow("Invalid dashboard port.");
expect(() => validateDashboardPort(65_536)).toThrow(
expect(() => validatePort(0, "dashboard")).toThrow(
"Invalid dashboard port.",
);
expect(() => validatePort(65_536, "dashboard")).toThrow(
"Invalid dashboard port.",
);
});
Expand Down
148 changes: 117 additions & 31 deletions packages/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ interface CommandOptions {
config?: string;
}

interface DashboardOptions extends CommandOptions {
interface PortedOptions extends CommandOptions {
port?: number;
}

Expand Down Expand Up @@ -283,21 +283,13 @@ export async function workerStart(
const ow = new OpenWorkflow({ backend });

let worker: ReturnType<typeof ow.newWorker> | null = null;
let shuttingDown = false;

/** Stop the worker on process shutdown. */
async function gracefulShutdown(): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;

consola.warn("Shutting down worker...");
try {
const gracefulShutdown = registerGracefulShutdown({
noun: "worker",
stopApp: async () => {
await worker?.stop();
} finally {
await backend.stop();
}
consola.success("Worker stopped");
}
},
backend,
});

try {
// discover and import workflows
Expand Down Expand Up @@ -330,9 +322,6 @@ export async function workerStart(

worker = ow.newWorker(workerOptions);

process.on("SIGINT", () => void gracefulShutdown());
process.on("SIGTERM", () => void gracefulShutdown());

await worker.start();
consola.success("Worker started.");
} catch (error) {
Expand Down Expand Up @@ -369,19 +358,23 @@ export function getDashboardSpawnOptions(port?: number): {
}

/**
* Validate dashboard port option.
* @param port - Optional dashboard port.
* @returns Validated dashboard port.
* @throws {CLIError} If the provided port is not an integer in the 1-65535 range.
* Validate a port option.
* @param port - Optional port number.
* @param label - Label used in the error message (e.g. "dashboard", "server").
* @returns Validated port, or undefined if not provided.
* @throws {CLIError} If the port is not an integer in the 1-65535 range.
*/
export function validateDashboardPort(port?: number): number | undefined {
export function validatePort(
port: number | undefined,
label: string,
): number | undefined {
if (port === undefined) {
return undefined;
}

if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new CLIError(
"Invalid dashboard port.",
`Invalid ${label} port.`,
"Use an integer between 1 and 65535, for example `--port 3001`.",
);
}
Expand All @@ -394,9 +387,9 @@ export function validateDashboardPort(port?: number): number | undefined {
* @param options - Dashboard command options.
* @returns Resolves when the dashboard process exits.
*/
export async function dashboard(options: DashboardOptions = {}): Promise<void> {
export async function dashboard(options: PortedOptions = {}): Promise<void> {
const configPath = options.config;
const port = validateDashboardPort(options.port);
const port = validatePort(options.port, "dashboard");
consola.start("Starting dashboard...");

const { configFile } = await loadConfigWithEnv(configPath);
Expand Down Expand Up @@ -458,8 +451,106 @@ export async function dashboard(options: DashboardOptions = {}): Promise<void> {
});
}

/**
* openworkflow server start
* @param options - Server start options.
*/
export async function serverStart(options: PortedOptions = {}): Promise<void> {
const port = validatePort(options.port, "server") ?? 3000;
consola.start("Starting server...");

const { configFile, config } = await loadConfigWithEnv(options.config);
if (!configFile) {
throw new CLIError(
"No config file found.",
"Run `npx @openworkflow/cli init` to create a config file.",
);
}
consola.info(`Using config: ${configFile}`);

const backend = config.backend;
let handle: { close(): Promise<void> } | null = null;
const gracefulShutdown = registerGracefulShutdown({
noun: "server",
stopApp: async () => {
await handle?.close();
},
backend,
});

try {
// dynamic to defer hono's ~150KB until `server start` is invoked
// this is not ideal and will be addressed before release
let serverModule: typeof import("@openworkflow/server");
try {
serverModule = await import("@openworkflow/server");
} catch {
throw new CLIError(
"@openworkflow/server is not installed.",
'Install it to enable the "server start" command: `npm install @openworkflow/server`.',
);
}
const { createServer, serve } = serverModule;

const server = createServer(backend, {
logRequests: true,
onError: (error, ctx) => {
consola.error(`[${ctx.method} ${ctx.path}]`, error);
},
});
handle = serve(server, { port });
consola.success(`Server listening on http://localhost:${String(port)}`);
} catch (error) {
await gracefulShutdown();
throw error;
}
}

// -----------------------------------------------------------------------------

interface ShutdownOptions {
/** Lower-case name of the thing being stopped (e.g. "worker", "server"). */
noun: string;
/** App-level stop — `worker.stop` or `handle.close`. */
stopApp: () => Promise<void>;
/** Backend whose `stop()` runs last, even if `stopApp` throws. */
backend: { stop: () => Promise<void> };
}

/**
* Wire SIGINT/SIGTERM to a graceful shutdown. `stopApp` runs first; the
* backend is stopped even if `stopApp` throws.
* @param options - What to stop on shutdown
* @returns The shutdown function
*/
function registerGracefulShutdown(
options: ShutdownOptions,
): () => Promise<void> {
let shuttingDown = false;
/**
* Stop the app and backend; idempotent against repeat signals.
* @returns Resolves when both are stopped
*/
async function shutdown(): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
consola.warn(`Shutting down ${options.noun}...`);
try {
await options.stopApp();
} finally {
await options.backend.stop();
}
const capitalized =
options.noun.charAt(0).toUpperCase() + options.noun.slice(1);
consola.success(`${capitalized} stopped`);
}
// `once` so repeated worker/serverStart invocations (e.g. programmatic use,
// tests) don't accumulate listeners and trigger MaxListenersExceededWarning.
process.once("SIGINT", () => void shutdown());
process.once("SIGTERM", () => void shutdown());
return shutdown;
}

/**
* Get workflow directories from config.
* @param config - The loaded config
Expand Down Expand Up @@ -827,11 +918,6 @@ async function discoverWorkflowsInDirs(
return { files, workflows };
}

/**
* Get the config template for a backend choice.
* @param backendChoice - The selected backend choice
* @returns The config template string
*/
/**
* Get the client template for a backend choice.
* @param backendChoice - The selected backend choice
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"nypm": "^0.6.5"
},
"devDependencies": {
"@openworkflow/server": "*",
"openworkflow": "*",
"vitest": "^4.0.18"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"compilerOptions": {
"outDir": "dist"
},
"references": [{ "path": "../openworkflow" }],
"references": [{ "path": "../openworkflow" }, { "path": "../server" }],
"include": ["**/*.ts"],
"exclude": ["dist"]
}
Loading
Loading