-
Notifications
You must be signed in to change notification settings - Fork 287
[RFC] feat(nitro): embed observability dashboard in-process at /_workflow #2548
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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. |
| 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. |
| 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'; | ||
|
|
@@ -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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have |
||
| // 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) { | ||
|
|
||
| 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__ ?? ''; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
dashboardPathis used raw both for Nitro route registration ([basename, basename + '/**']) and passed through as the handlerbasename, but normalization (normalizeBasename) only happens inside the handler. So a custompathcan make the two disagree:'/_workflow/'(trailing slash) -> routes/_workflow/and/_workflow//**, handler normalizes to/_workflow'admin/wf'(no leading slash) -> Nitro routes don't start with/, andreprefixBuildemits non-root-absolute asset URLs'/'(root) -> routes/and//**; the/catch-all would swallow the whole host appDefault is fine, but since
pathis documented as configurable, suggest normalizing once here (force leading slash, strip trailing, disallow root) before both the route registration and the handler get it.