Files
vercel__workflow/packages/web/handler.js
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

187 lines
6.3 KiB
JavaScript

/**
* 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<Response>>}
*/
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 });
}