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

90 lines
3.0 KiB
JavaScript

/**
* 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
}
}