Files
Pranay Prakash 25715d4521 [RFC] feat(nitro): embed observability dashboard in-process at /_workflow (#2548)
* 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 <noreply@anthropic.com>

* refactor(nitro): normalize dashboard path once, use isNitroV2() helper

Address review feedback on the embedded dashboard:

- Normalize the dashboard mount path in one place before it feeds both
  the Nitro route registration (`[path, path + '/**']`) and the handler
  `basename`. Force a single leading slash, strip trailing slashes, and
  reject the root mount, so a custom `path` can't make the route and the
  handler's internal `normalizeBasename` disagree.
- Replace the handler-level `!nitro.routing` v2 checks with the existing
  `isNitroV2()` helper for consistent v2/v3 detection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-07-29 13:58:59 -07:00

132 lines
4.7 KiB
TypeScript

import 'react-router';
// 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();
// Handle all requests with React Router.
// Static file serving is handled by:
// - Vite's dev server in development
// - server.js in production (before mounting this app)
app.all(
'/{*splat}',
createExpressRequestHandler({
build: () => import('virtual:react-router/server-build'),
})
);
// Safety-net error handler — prevents unhandled errors from crashing the
// server when the React Router error boundary cannot render (e.g. during SSR).
app.use(
(
err: unknown,
_req: express.Request,
res: express.Response,
_next: express.NextFunction
) => {
console.error('Unhandled request error:', err);
if (!res.headersSent) {
res.status(500).send('Internal Server Error');
}
}
);
// --- 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
* `<basename>/assets/...`. Routing is handled separately via `basename`.
*/
function reprefixBuild(build: ServerBuild, basename: string): ServerBuild {
const pre = <T extends string | undefined>(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<Response> {
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);
}