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
5 changes: 5 additions & 0 deletions .changeset/cli-defer-embedded-dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/cli': minor
---

`workflow web` / `inspect --web` now detect an already-running embedded dashboard and open it instead of starting a redundant server. Pass `--standalone` to force the standalone web UI.
5 changes: 5 additions & 0 deletions .changeset/nitro-embedded-dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/nitro': minor
---

Embed the observability dashboard in-process at `/_workflow` (configurable via the `dashboard` option) instead of spawning a separate web server. Enabled in dev and omitted from production builds by default.
5 changes: 5 additions & 0 deletions .changeset/web-embeddable-handler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/web': minor
---

Add a framework-agnostic `@workflow/web/handler` export (`createWorkflowWebHandler`) that serves the observability UI as a single Web `Request``Response` handler under a configurable base path, plus a `@workflow/web/registry` for embedded-dashboard discovery.
9 changes: 9 additions & 0 deletions packages/cli/src/lib/inspect/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ export const cliFlags = {
helpGroup: 'Output',
helpLabel: '--localUi',
}),
standalone: Flags.boolean({
description:
'Always start the standalone web UI, even if an embedded dashboard (e.g. /_workflow) is already running',
required: false,
default: false,
env: 'WORKFLOW_STANDALONE_UI',
helpGroup: 'Output',
helpLabel: '--standalone',
}),
sort: Flags.string({
description: 'sort order for list commands',
required: false,
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/src/lib/inspect/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,55 @@ export const getHostUrl = (webPort: number) => `http://localhost:${webPort}`;

let httpServer: Server | null = null;

interface DashboardRegistryEntry {
url: string;
basename: string;
world: string;
pid: number;
startedAt: string;
}

/**
* Find embedded dashboards (e.g. a framework integration serving `/_workflow`)
* that are currently live for this project. Reads the best-effort registry and
* health-checks each entry, since stale entries are expected (a SIGKILL'd dev
* server can't clean up). Returns [] on any error — coordination is optional.
*/
async function findLiveEmbeddedDashboards(): Promise<DashboardRegistryEntry[]> {
let entries: DashboardRegistryEntry[];
try {
const { readDashboardRegistry } = await import('@workflow/web/registry');
entries = readDashboardRegistry() as DashboardRegistryEntry[];
} catch {
return [];
}

const live: DashboardRegistryEntry[] = [];
for (const entry of entries) {
if (!entry?.url) continue;
// Best-effort PID liveness check (same-host dev servers).
if (typeof entry.pid === 'number') {
try {
process.kill(entry.pid, 0);
} catch {
continue; // process gone
}
}
try {
const res = await fetch(entry.url, {
method: 'HEAD',
redirect: 'manual',
signal: AbortSignal.timeout(1500),
});
// Any non-5xx response means something is serving that route.
if (res.status < 500) live.push(entry);
} catch {
// unreachable — treat as dead
}
}
return live;
}

/**
* Check if a server is already listening on the given URL.
*/
Expand Down Expand Up @@ -142,6 +191,43 @@ export async function launchWebUI(
}
}

// Defer to an already-running embedded dashboard (e.g. a framework
// integration serving `/_workflow`) unless the user forces a standalone
// server. Avoids spinning up a redundant UI on :3456 when one is already up.
if (!flags.standalone) {
const live = await findLiveEmbeddedDashboards();
if (live.length === 1) {
const target = buildWebUIUrl(live[0].url, resource, id, flags);
logger.info(
chalk.green(
`An embedded workflow dashboard is already running at ${live[0].url}`
)
);
if (disableBrowserOpen) {
logger.info(chalk.cyan(`Open it at: ${target}`));
return;
}
logger.info(chalk.cyan(`Opening ${target}`));
try {
await open(target);
} catch (error) {
logger.error(`Failed to open browser: ${error}`);
logger.info(`Please open the link manually: ${target}`);
}
return;
}
if (live.length > 1) {
logger.info('Embedded workflow dashboards are already running at:');
for (const entry of live) {
logger.info(chalk.cyan(` - ${entry.url}`));
}
logger.info(
'Open the one you want, or pass --standalone to start a separate web UI.'
);
return;
}
}

// Fall back to local web UI
const webPort = flags.webPort ?? 3456;
const hostUrl = getHostUrl(webPort);
Expand Down
100 changes: 64 additions & 36 deletions packages/nitro/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createRequire } from 'node:module';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders';
import { workflowTransformPlugin } from '@workflow/rollup';
Expand Down Expand Up @@ -210,8 +210,21 @@ export default {
});
}

if (nitro.options.dev) {
addDashboardHandler(nitro);
// Embedded observability dashboard. Defaults to on in dev / off in prod
// builds; when disabled nothing is registered, so prod bundles never
// import @workflow/web. Excluded on Vercel deploys (the on-disk build/
// and node_modules import don't survive a serverless bundle — users use
// the hosted Vercel dashboard there).
const dashboardOption = nitro.options.workflow?.dashboard;
const dashboardEnabled =
typeof dashboardOption === 'object'
? (dashboardOption.enabled ?? nitro.options.dev)
: (dashboardOption ?? nitro.options.dev);
const dashboardPath =
(typeof dashboardOption === 'object' && dashboardOption.path) ||
'/_workflow';
if (dashboardEnabled && !isVercelDeploy) {
addDashboardHandler(nitro, dashboardPath);
}

addVirtualHandler(
Expand Down Expand Up @@ -294,70 +307,85 @@ export default {

const DASHBOARD_VIRTUAL_ID = '#workflow/dashboard-handler';

function addDashboardHandler(nitro: Nitro) {
const route = '/_workflow';
nitro.options.handlers.push({ route, handler: DASHBOARD_VIRTUAL_ID });

// Resolve `@workflow/web/server` relative to this module so consumers don't
// need a direct dependency on `@workflow/web`. The path is inlined into the
// virtual handler as a file:// URL so Node can `import()` it at runtime
// regardless of where the generated Nitro bundle ends up.
/**
* Mount the observability dashboard in-process at `basename` (e.g. `/_workflow`).
*
* The entire `@workflow/web` UI (SSR + static client assets + RPC) is served by
* a single Web-standard fetch handler running inside this Nitro process — no
* second server, no separate port, no redirect. The handler is framework-
* neutral; this is just its first consumer.
*/
function addDashboardHandler(nitro: Nitro, basename: string) {
// Resolve `@workflow/web/handler` relative to this module so consumers don't
// need a direct dependency on `@workflow/web`. Inlined into the virtual
// handler as a file:// URL and imported with @vite-ignore/webpackIgnore so
// Node `import()`s it from node_modules at runtime — keeping @workflow/web's
// (large, React) dependency graph out of the Nitro server bundle.
const require_ = createRequire(import.meta.url);
let webServerUrl: string;
let webHandlerUrl: string;
try {
webServerUrl = pathToFileURL(require_.resolve('@workflow/web/server')).href;
webHandlerUrl = pathToFileURL(
require_.resolve('@workflow/web/handler')
).href;
} catch {
webServerUrl = '@workflow/web/server';
webHandlerUrl = '@workflow/web/handler';
}

const handlerSource = /* js */ `
const __workflowWebServerUrl = ${JSON.stringify(webServerUrl)};
let serverPromise = null;
async function getDashboardUrl() {
if (!serverPromise) {
serverPromise = (async () => {
const { startServer } = await import(/* @vite-ignore */ /* webpackIgnore: true */ __workflowWebServerUrl);
const server = await startServer(0);
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 3456;
return 'http://localhost:' + port;
const __workflowWebHandlerUrl = ${JSON.stringify(webHandlerUrl)};
const __workflowDashboardBasename = ${JSON.stringify(basename)};
let handlerPromise = null;
async function getDashboardHandler() {
if (!handlerPromise) {
handlerPromise = (async () => {
const mod = await import(/* @vite-ignore */ /* webpackIgnore: true */ __workflowWebHandlerUrl);
return mod.createWorkflowWebHandler({ basename: __workflowDashboardBasename });
})().catch((error) => {
serverPromise = null;
handlerPromise = null;
throw error;
});
}
return serverPromise;
return handlerPromise;
}
`;

if (!nitro.routing) {
// Nitro v2 (legacy h3)
nitro.options.virtual[DASHBOARD_VIRTUAL_ID] = /* js */ `
import { fromWebHandler } from "h3";
${handlerSource}
export default fromWebHandler(async () => {
export default fromWebHandler(async (request) => {
try {
const url = await getDashboardUrl();
return Response.redirect(url, 302);
const handler = await getDashboardHandler();
return await handler(request);
} catch (error) {
console.error('Failed to start workflow dashboard:', error);
return new Response('Failed to start workflow dashboard: ' + error.message, { status: 500 });
console.error('Failed to render workflow dashboard:', error);
return new Response('Failed to render workflow dashboard: ' + (error && error.message || error), { status: 500 });
}
});
`;
} else {
// Nitro v3+ (native web handlers)
nitro.options.virtual[DASHBOARD_VIRTUAL_ID] = /* js */ `
${handlerSource}
export default async () => {
export default async ({ req }) => {
try {
const url = await getDashboardUrl();
return Response.redirect(url, 302);
const handler = await getDashboardHandler();
return await handler(req);
} catch (error) {
console.error('Failed to start workflow dashboard:', error);
return new Response('Failed to start workflow dashboard: ' + error.message, { status: 500 });
console.error('Failed to render workflow dashboard:', error);
return new Response('Failed to render workflow dashboard: ' + (error && error.message || error), { status: 500 });
}
};
`;
}

// Register the exact mount path and a catch-all beneath it so the index,
// client routes, API routes (RPC/stream), and static assets all route to the
// embedded handler, which resolves them against `basename` internally.
for (const route of [basename, `${basename}/**`]) {
nitro.options.handlers.push({ route, handler: DASHBOARD_VIRTUAL_ID });
}
}

function addVirtualHandler(nitro: Nitro, route: string, buildPath: string) {
Expand Down
17 changes: 17 additions & 0 deletions packages/nitro/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ export interface ModuleOptions {
* Can also be set via the `WORKFLOW_SOURCEMAP` environment variable.
*/
sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both';

/**
* Embed the workflow observability dashboard in-process on this server,
* served at `/_workflow` (configurable). The UI runs inside the same Nitro
* process — no separate server or port.
*
* - `true` / `false` — force the dashboard on or off.
* - `{ enabled, path }` — toggle and/or change the mount path.
*
* Defaults to **on in dev, off in production builds**. When disabled the
* route is never registered, so production bundles carry no `@workflow/web`
* import and pay zero bundle-size or startup cost. Never mounted for Vercel
* deploys (use the hosted Vercel dashboard there).
*
* @default nitro.options.dev
*/
dashboard?: boolean | { enabled?: boolean; path?: string };
}

declare module 'nitro/types' {
Expand Down
26 changes: 26 additions & 0 deletions packages/web/app/lib/api-base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Base path the observability UI is mounted under.
*
* The standalone server mounts at the root (`""`), but when embedded in another
* server (e.g. `@workflow/nitro` at `/_workflow`) the API resource routes live
* under that prefix. The server threads the mount path through the React Router
* load context; `app/root.tsx` surfaces it to the browser via a global so that
* client-only `fetch` calls (RPC + stream reads) can target the right path
* regardless of the current route.
*/

declare global {
interface Window {
__WORKFLOW_BASENAME__?: string;
}
}

/**
* Returns the mount-path prefix for client-side API fetches (e.g. `""` at the
* root, or `"/_workflow"` when embedded). Always client-only — server code
* reaches the world directly rather than via these HTTP routes.
*/
export function apiBase(): string {
if (typeof window === 'undefined') return '';
return window.__WORKFLOW_BASENAME__ ?? '';
}
3 changes: 2 additions & 1 deletion packages/web/app/lib/client/workflow-streams.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { apiBase } from '~/lib/api-base';
import { fetchStreams } from '~/lib/rpc-client';
import type { EnvMap, ServerActionError } from '~/lib/types';
import { unwrapOrThrow, WorkflowWebAPIError } from './workflow-errors';
Expand Down Expand Up @@ -33,7 +34,7 @@ export async function readStream(
if (cursor) {
params.set('cursor', cursor);
}
const url = `/api/stream/${encodeURIComponent(streamId)}?${params.toString()}`;
const url = `${apiBase()}/api/stream/${encodeURIComponent(streamId)}?${params.toString()}`;
const response = await fetch(url, { signal });
if (!response.ok) {
const errorData = await response.json().catch(() => null);
Expand Down
3 changes: 2 additions & 1 deletion packages/web/app/lib/rpc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
WorkflowRunStatus,
} from '@workflow/world';
import { decode, encode } from 'cbor-x';
import { apiBase } from '~/lib/api-base';
import type {
EnvMap,
HealthCheckEndpoint,
Expand All @@ -26,7 +27,7 @@ import type {
} from '~/lib/types';

async function rpc<T>(method: string, params?: any): Promise<T> {
const res = await fetch('/api/rpc', {
const res = await fetch(`${apiBase()}/api/rpc`, {
method: 'POST',
headers: {
'Content-Type': 'application/cbor',
Expand Down
Loading
Loading