From 4ba2a5abc42bf699fbc26701b92129eef369c55f Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Sat, 20 Jun 2026 14:19:45 -0700 Subject: [PATCH] feat(nitro): embed observability dashboard in-process at /_workflow Serve the @workflow/web observability UI inside the Nitro process at a configurable route (default /_workflow) instead of spawning a separate web server and 302-redirecting to it. Enabled in dev, omitted from production builds by default (so prod bundles carry no @workflow/web import). Never mounted on Vercel deploys (use the hosted dashboard). - @workflow/web: add a framework-neutral `@workflow/web/handler` (createWorkflowWebHandler) that serves SSR + static client assets + RPC as one Web Request->Response handler under a runtime basename (asset manifest URLs + publicPath are reprefixed so the dashboard is self-contained under its mount). Add `@workflow/web/registry` for embedded-dashboard discovery; make the RPC/stream client basename-aware. - @workflow/nitro: mount the handler in-process (Nitro v2 h3 + v3 native paths), gated by a new `dashboard` option (default = dev). - @workflow/cli: `workflow web` / `inspect --web` defer to a running embedded dashboard instead of starting a redundant server; pass `--standalone` to force the standalone UI. Co-Authored-By: Claude Opus 4.8 --- .changeset/cli-defer-embedded-dashboard.md | 5 + .changeset/nitro-embedded-dashboard.md | 5 + .changeset/web-embeddable-handler.md | 5 + packages/cli/src/lib/inspect/flags.ts | 9 + packages/cli/src/lib/inspect/web.ts | 86 ++++++++ packages/nitro/src/index.ts | 100 ++++++---- packages/nitro/src/types.ts | 17 ++ packages/web/app/lib/api-base.ts | 26 +++ .../web/app/lib/client/workflow-streams.ts | 3 +- packages/web/app/lib/rpc-client.ts | 3 +- packages/web/app/root.tsx | 15 +- packages/web/handler.d.ts | 22 +++ packages/web/handler.js | 186 ++++++++++++++++++ packages/web/package.json | 12 ++ packages/web/registry.d.ts | 29 +++ packages/web/registry.js | 89 +++++++++ packages/web/server/app.ts | 103 +++++++++- 17 files changed, 671 insertions(+), 44 deletions(-) create mode 100644 .changeset/cli-defer-embedded-dashboard.md create mode 100644 .changeset/nitro-embedded-dashboard.md create mode 100644 .changeset/web-embeddable-handler.md create mode 100644 packages/web/app/lib/api-base.ts create mode 100644 packages/web/handler.d.ts create mode 100644 packages/web/handler.js create mode 100644 packages/web/registry.d.ts create mode 100644 packages/web/registry.js diff --git a/.changeset/cli-defer-embedded-dashboard.md b/.changeset/cli-defer-embedded-dashboard.md new file mode 100644 index 0000000000..34be61674b --- /dev/null +++ b/.changeset/cli-defer-embedded-dashboard.md @@ -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. diff --git a/.changeset/nitro-embedded-dashboard.md b/.changeset/nitro-embedded-dashboard.md new file mode 100644 index 0000000000..13cb6c08b6 --- /dev/null +++ b/.changeset/nitro-embedded-dashboard.md @@ -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. diff --git a/.changeset/web-embeddable-handler.md b/.changeset/web-embeddable-handler.md new file mode 100644 index 0000000000..cb3a20d716 --- /dev/null +++ b/.changeset/web-embeddable-handler.md @@ -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. diff --git a/packages/cli/src/lib/inspect/flags.ts b/packages/cli/src/lib/inspect/flags.ts index 44fa08c767..924bebbaaf 100644 --- a/packages/cli/src/lib/inspect/flags.ts +++ b/packages/cli/src/lib/inspect/flags.ts @@ -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, diff --git a/packages/cli/src/lib/inspect/web.ts b/packages/cli/src/lib/inspect/web.ts index 6311f2a3cf..a646073a54 100644 --- a/packages/cli/src/lib/inspect/web.ts +++ b/packages/cli/src/lib/inspect/web.ts @@ -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 { + 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. */ @@ -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); diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index 3d2989bf46..b8adcc9580 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -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'; @@ -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( @@ -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) { diff --git a/packages/nitro/src/types.ts b/packages/nitro/src/types.ts index 274d311a95..85b47e1371 100644 --- a/packages/nitro/src/types.ts +++ b/packages/nitro/src/types.ts @@ -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' { diff --git a/packages/web/app/lib/api-base.ts b/packages/web/app/lib/api-base.ts new file mode 100644 index 0000000000..86e3c9778e --- /dev/null +++ b/packages/web/app/lib/api-base.ts @@ -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__ ?? ''; +} diff --git a/packages/web/app/lib/client/workflow-streams.ts b/packages/web/app/lib/client/workflow-streams.ts index 6af07c23a8..77d0a072dd 100644 --- a/packages/web/app/lib/client/workflow-streams.ts +++ b/packages/web/app/lib/client/workflow-streams.ts @@ -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'; @@ -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); diff --git a/packages/web/app/lib/rpc-client.ts b/packages/web/app/lib/rpc-client.ts index 2357faff3a..a035f00e78 100644 --- a/packages/web/app/lib/rpc-client.ts +++ b/packages/web/app/lib/rpc-client.ts @@ -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, @@ -26,7 +27,7 @@ import type { } from '~/lib/types'; async function rpc(method: string, params?: any): Promise { - const res = await fetch('/api/rpc', { + const res = await fetch(`${apiBase()}/api/rpc`, { method: 'POST', headers: { 'Content-Type': 'application/cbor', diff --git a/packages/web/app/root.tsx b/packages/web/app/root.tsx index 80999a1d24..507d7240d4 100644 --- a/packages/web/app/root.tsx +++ b/packages/web/app/root.tsx @@ -2,13 +2,13 @@ import { TooltipProvider } from '@radix-ui/react-tooltip'; import { ThemeProvider, useTheme } from 'next-themes'; import { useEffect, useRef } from 'react'; import { + isRouteErrorResponse, Link, Links, Meta, Outlet, Scripts, ScrollRestoration, - isRouteErrorResponse, useNavigate, useRouteError, useSearchParams, @@ -25,10 +25,12 @@ import { getPublicServerConfig } from '~/server/workflow-server-actions.server'; import type { Route } from './+types/root'; import './globals.css'; -// Server-side loader: provides config data on initial render and navigation -export async function loader() { +// Server-side loader: provides config data on initial render and navigation. +// `basename` is the embed mount path (e.g. "/_workflow"), threaded through the +// load context by the embedded handler; "" when served standalone at the root. +export async function loader({ context }: Route.LoaderArgs) { const serverConfig = await getPublicServerConfig(); - return { serverConfig }; + return { serverConfig, basename: context?.basename ?? '' }; } // Catch-all action: handles stray POST requests (e.g. from Radix UI dialogs @@ -205,6 +207,11 @@ function LayoutContent({ children }: { children: React.ReactNode }) { } export default function App({ loaderData }: Route.ComponentProps) { + // Surface the embed mount path to client-only fetches (RPC + stream reads). + // Set during the first (hydration) render, before any post-mount data fetch. + if (typeof window !== 'undefined') { + window.__WORKFLOW_BASENAME__ = loaderData.basename ?? ''; + } return ( `Response` handler that serves + * the Workflow observability UI (static client assets + React Router SSR) + * in-process, suitable for mounting inside another server under `basename`. + * + * Requires `@workflow/web` to have been built (`build/` must exist); throws + * otherwise. + */ +export function createWorkflowWebHandler( + options?: CreateWorkflowWebHandlerOptions +): Promise<(request: Request) => Promise>; diff --git a/packages/web/handler.js b/packages/web/handler.js new file mode 100644 index 0000000000..219f0ac64a --- /dev/null +++ b/packages/web/handler.js @@ -0,0 +1,186 @@ +/** + * Framework-agnostic, in-process entry point for @workflow/web. + * + * Unlike `server.js` (which starts a standalone Express HTTP server), this + * exports a single Web-standard fetch handler that another server can mount + * under an arbitrary base path — e.g. `@workflow/nitro` mounting the dashboard + * at `/_workflow` without spawning a second server/port. + * + * import { createWorkflowWebHandler } from "@workflow/web/handler"; + * const handler = await createWorkflowWebHandler({ basename: "/_workflow" }); + * const response = await handler(request); // (request: Request) => Response + * + * The handler serves both the prebuilt static client assets (from `build/client`) + * and the React Router SSR app. It reads the prebuilt `build/` as-is in every + * mode (it never runs Vite), so consumers must have built @workflow/web first + * (the published package ships `build/`). + */ + +import { existsSync } from 'node:fs'; +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { recordDashboard } from './registry.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const buildDir = path.resolve(__dirname, 'build'); +const clientDir = path.join(buildDir, 'client'); +const serverEntry = path.join(buildDir, 'server', 'index.js'); + +// Minimal extension -> MIME map for serving the static client build. A wrong +// Content-Type on the entry module breaks the whole app, so be explicit. +const MIME_TYPES = { + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.map': 'application/json', + '.html': 'text/html', + '.ico': 'image/x-icon', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf', + '.txt': 'text/plain', + '.wasm': 'application/wasm', +}; + +/** Normalize a mount path: `/` or empty -> "" (root); otherwise strip trailing slash. */ +function normalizeBasename(basename) { + if (!basename || basename === '/') return ''; + return basename.endsWith('/') ? basename.slice(0, -1) : basename; +} + +// One handler per basename, lazily constructed and memoized so repeated +// requests reuse the same React Router handler and module import. +const handlerCache = new Map(); + +/** + * @param {{ basename?: string }} [options] + * @returns {Promise<(request: Request) => Promise>} + */ +export async function createWorkflowWebHandler(options = {}) { + const basename = normalizeBasename(options.basename ?? '/'); + let promise = handlerCache.get(basename); + if (!promise) { + promise = buildHandler(basename).catch((err) => { + handlerCache.delete(basename); + throw err; + }); + handlerCache.set(basename, promise); + } + return promise; +} + +async function buildHandler(basename) { + if (!existsSync(serverEntry) || !existsSync(clientDir)) { + throw new Error( + '@workflow/web has not been built (missing build/). ' + + 'Run `pnpm --filter @workflow/web build` before embedding the dashboard.' + ); + } + + const mod = await import(pathToFileURL(serverEntry).href); + if (typeof mod.createFetchHandler !== 'function') { + throw new Error( + '@workflow/web build does not export createFetchHandler; rebuild the package.' + ); + } + const ssr = mod.createFetchHandler(basename || '/'); + + return async (request) => { + // Advertise this dashboard so the CLI can defer to it (best-effort, once). + // Done on first request because that's when the public origin is known. + try { + const origin = new URL(request.url).origin; + recordDashboard({ + url: origin + basename, + basename, + world: + process.env.WORKFLOW_TARGET_WORLD || + (process.env.VERCEL_DEPLOYMENT_ID ? 'vercel' : 'local'), + }); + } catch { + // ignore — registration must never affect request handling + } + + const staticResponse = await tryServeStatic(request, basename); + if (staticResponse) return staticResponse; + return ssr(request); + }; +} + +/** + * Resolve a request to a path-within-the-client-build (relative to `clientDir`), + * stripping `basename` and guarding against traversal. Returns null when the + * request can't map to a client file (so it should fall through to the SSR + * handler) — e.g. the index, a directory, or an out-of-tree path. + */ +function resolveClientFile(request, basename) { + if (request.method !== 'GET' && request.method !== 'HEAD') return null; + + let pathname; + try { + pathname = decodeURIComponent(new URL(request.url).pathname); + } catch { + return null; + } + + if (basename) { + if (!pathname.startsWith(`${basename}/`)) return null; // index/other -> SSR + pathname = pathname.slice(basename.length); + } + + const relative = pathname.replace(/^\/+/, ''); + if (!relative || relative.endsWith('/')) return null; + + const filePath = path.join(clientDir, relative); + // Guard against path traversal escaping the client build directory. + if (filePath !== clientDir && !filePath.startsWith(clientDir + path.sep)) { + return null; + } + return { filePath, relative }; +} + +/** + * Serve a file from the prebuilt client bundle if the request maps to one; + * otherwise return null so the request falls through to the SSR handler. + */ +async function tryServeStatic(request, basename) { + const resolved = resolveClientFile(request, basename); + if (!resolved) return null; + const { filePath, relative } = resolved; + + let stats; + try { + stats = await stat(filePath); + } catch { + return null; + } + if (!stats.isFile()) return null; + + const ext = path.extname(filePath).toLowerCase(); + const headers = new Headers({ + 'Content-Type': MIME_TYPES[ext] ?? 'application/octet-stream', + // Hashed assets are content-addressed and safe to cache forever; other + // client files (e.g. favicon) get a short cache. + 'Cache-Control': relative.startsWith('assets/') + ? 'public, max-age=31536000, immutable' + : 'public, max-age=3600', + }); + + if (request.method === 'HEAD') { + headers.set('Content-Length', String(stats.size)); + return new Response(null, { status: 200, headers }); + } + + const body = await readFile(filePath); + return new Response(body, { status: 200, headers }); +} diff --git a/packages/web/package.json b/packages/web/package.json index 92cc1033a0..7ce157ad10 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -8,6 +8,10 @@ "build", "server.js", "server.d.ts", + "handler.js", + "handler.d.ts", + "registry.js", + "registry.d.ts", "package.json" ], "exports": { @@ -15,6 +19,14 @@ "types": "./server.d.ts", "default": "./server.js" }, + "./handler": { + "types": "./handler.d.ts", + "default": "./handler.js" + }, + "./registry": { + "types": "./registry.d.ts", + "default": "./registry.js" + }, "./package.json": "./package.json" }, "publishConfig": { diff --git a/packages/web/registry.d.ts b/packages/web/registry.d.ts new file mode 100644 index 0000000000..12e40ebfa2 --- /dev/null +++ b/packages/web/registry.d.ts @@ -0,0 +1,29 @@ +/** A recorded embedded-dashboard instance. */ +export interface DashboardRegistryEntry { + /** Public base URL the dashboard is reachable at (e.g. `http://localhost:3000/_workflow`). */ + url: string; + /** Mount path the dashboard is served under (e.g. `/_workflow`; `""` at root). */ + basename: string; + /** World backend identifier, for display (e.g. `local`, `@workflow/world-postgres`). */ + world: string; + /** PID of the process hosting the dashboard. */ + pid: number; + /** ISO timestamp the entry was recorded. */ + startedAt: string; +} + +/** Absolute path of the registry file for the current working directory. */ +export function dashboardRegistryPath(): string; + +/** Read the registry entries (always returns an array; never throws). */ +export function readDashboardRegistry(): DashboardRegistryEntry[]; + +/** + * Record this process's embedded dashboard (idempotent per process). Entirely + * best-effort — never throws. + */ +export function recordDashboard(entry: { + url: string; + basename?: string; + world?: string; +}): void; diff --git a/packages/web/registry.js b/packages/web/registry.js new file mode 100644 index 0000000000..e5a24acc9f --- /dev/null +++ b/packages/web/registry.js @@ -0,0 +1,89 @@ +/** + * Best-effort registry of live, embedded observability dashboards. + * + * When the dashboard is embedded in another server (e.g. `@workflow/nitro` at + * `/_workflow`), the running process records its public URL here. The CLI + * (`workflow web` / `inspect … --web`) reads this so it can point the user at + * an already-running dashboard instead of spawning a redundant standalone + * server on a fixed port. + * + * This is a coordination hint, never a source of truth: entries may be stale + * (a SIGKILL'd dev server can't clean up), so consumers MUST health-check a URL + * before trusting it. A missing or corrupt registry is treated as "none". + * + * The file is keyed by the current working directory so multiple projects don't + * collide, while multiple dev servers for the same project share one file + * (keyed by pid within it). + */ + +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** Absolute path of the registry file for the current working directory. */ +export function dashboardRegistryPath() { + const key = createHash('sha256') + .update(process.cwd()) + .digest('hex') + .slice(0, 16); + return path.join(os.tmpdir(), 'workflow-observability', `${key}.json`); +} + +/** Read the registry entries (always returns an array; never throws). */ +export function readDashboardRegistry() { + try { + const parsed = JSON.parse(readFileSync(dashboardRegistryPath(), 'utf8')); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function writeEntries(entries) { + const file = dashboardRegistryPath(); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(entries)); +} + +let recorded = false; + +/** + * Record this process's embedded dashboard in the registry (idempotent per + * process). Call once the public origin is known (e.g. from the first request). + * Entirely best-effort — never throws. + * + * @param {{ url: string, basename?: string, world?: string }} entry + */ +export function recordDashboard(entry) { + if (recorded) return; + recorded = true; + try { + const others = readDashboardRegistry().filter( + (e) => e && e.pid !== process.pid + ); + others.push({ + url: entry.url, + basename: entry.basename ?? '', + world: entry.world ?? '', + pid: process.pid, + startedAt: new Date().toISOString(), + }); + writeEntries(others); + // Best-effort cleanup on normal exit. Signal-terminated processes (Ctrl+C, + // SIGKILL) won't run this — consumers prune via health checks instead. We + // intentionally don't hook SIGINT/SIGTERM so we don't interfere with the + // host framework's own shutdown handling. + process.once('exit', () => { + try { + writeEntries( + readDashboardRegistry().filter((e) => e && e.pid !== process.pid) + ); + } catch { + // ignore + } + }); + } catch { + // best-effort: a failed write must never break request handling + } +} diff --git a/packages/web/server/app.ts b/packages/web/server/app.ts index 91aa9b2301..6664b4062c 100644 --- a/packages/web/server/app.ts +++ b/packages/web/server/app.ts @@ -1,6 +1,23 @@ import 'react-router'; -import { createRequestHandler } from '@react-router/express'; +// The React Router server build (virtual module). Imported as a namespace so we +// can both hand it to the Express handler (standalone server.js) and reprefix a +// copy of it for the framework-agnostic embedded handler below. +import * as serverBuild from 'virtual:react-router/server-build'; +import { createRequestHandler as createExpressRequestHandler } from '@react-router/express'; import express from 'express'; +import { + type AppLoadContext, + createRequestHandler as createReactRouterRequestHandler, + type ServerBuild, +} from 'react-router'; + +// Expose `basename` on the React Router load context so the root route can +// surface the embed mount path to the client (see app/root.tsx + lib/api-base). +declare module 'react-router' { + interface AppLoadContext { + basename?: string; + } +} export const app = express(); @@ -10,7 +27,7 @@ export const app = express(); // - server.js in production (before mounting this app) app.all( '/{*splat}', - createRequestHandler({ + createExpressRequestHandler({ build: () => import('virtual:react-router/server-build'), }) ); @@ -30,3 +47,85 @@ app.use( } } ); + +// --- Framework-agnostic fetch handler (for embedding under a base path) ------- +// +// `@workflow/web/handler` (a thin sibling file that ships as-is) imports this to +// mount the dashboard in-process inside another server — e.g. `@workflow/nitro` +// at `/_workflow` — without spawning a second HTTP server. React Router itself +// is bundled into this build, so the `createRequestHandler` wiring must live +// here rather than in the un-bundled `handler.js`. + +/** Normalize a mount path: `/` or empty -> "" (root); otherwise strip trailing slash. */ +function normalizeBasename(basename: string): string { + if (!basename || basename === '/') return ''; + return basename.endsWith('/') ? basename.slice(0, -1) : basename; +} + +/** + * Return a copy of the server build with every root-absolute asset URL (and + * `publicPath`) reprefixed by `basename`. The build is produced with Vite base + * "/", so assets are emitted at `/assets/...`; under a mount prefix both the SSR + * document and the client-serialized manifest must resolve them at + * `/assets/...`. Routing is handled separately via `basename`. + */ +function reprefixBuild(build: ServerBuild, basename: string): ServerBuild { + const pre = (url: T): T => + typeof url === 'string' && url.startsWith('/') && !url.startsWith('//') + ? ((basename + url) as T) + : url; + const preArr = (arr: string[] | undefined) => + Array.isArray(arr) ? arr.map((u) => pre(u)) : arr; + + const { assets } = build; + const routes: typeof assets.routes = {}; + for (const [id, route] of Object.entries(assets.routes)) { + routes[id] = { + ...route, + module: pre(route.module), + imports: preArr(route.imports), + css: preArr(route.css), + clientActionModule: pre(route.clientActionModule), + clientLoaderModule: pre(route.clientLoaderModule), + clientMiddlewareModule: pre(route.clientMiddlewareModule), + hydrateFallbackModule: pre(route.hydrateFallbackModule), + }; + } + + return { + ...build, + basename, + publicPath: pre(build.publicPath) || build.publicPath, + assets: { + ...assets, + url: pre(assets.url), + entry: { + ...assets.entry, + module: pre(assets.entry.module), + imports: preArr(assets.entry.imports) ?? assets.entry.imports, + }, + routes, + }, + }; +} + +/** + * Build a framework-agnostic Web `Request` -> `Response` handler for the + * observability UI, suitable for mounting inside another server under an + * arbitrary base path. Pass `basename` as the mount prefix (e.g. `/_workflow`); + * `/` (the default) mounts at the root. + * + * The mount prefix is threaded into the React Router load context so the root + * route can expose it to the client (RPC/stream fetches need the prefix). + */ +export function createFetchHandler( + basename = '/' +): (request: Request) => Promise { + const normalized = normalizeBasename(basename); + const build = normalized + ? reprefixBuild(serverBuild as unknown as ServerBuild, normalized) + : (serverBuild as unknown as ServerBuild); + const handler = createReactRouterRequestHandler(build, 'production'); + const loadContext: AppLoadContext = { basename: normalized }; + return (request: Request) => handler(request, loadContext); +}