Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-srvx-web-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/cli': patch
---

Track the standalone web UI server as a srvx `Server`, following the `@workflow/web` change.
5 changes: 5 additions & 0 deletions .changeset/web-drop-express-for-srvx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/web': major
---

Replace Express with `srvx` in the standalone server, adding ETag/`304`, `Last-Modified`, byte ranges and on-the-fly compression.
25 changes: 18 additions & 7 deletions packages/cli/src/lib/inspect/web.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { Server } from 'node:http';
import chalk from 'chalk';
import open from 'open';
import { logger } from '../config/log.js';
Expand All @@ -8,7 +7,16 @@ import { getVercelDashboardUrl } from './vercel-api.js';

export const getHostUrl = (webPort: number) => `http://localhost:${webPort}`;

let httpServer: Server | null = null;
/**
* The standalone web UI server, as returned by `@workflow/web/server` (a srvx
* `Server`). Derived from the export rather than imported by name so the CLI
* doesn't need its own `srvx` dependency just to spell the type.
*/
type WebServer = Awaited<
ReturnType<typeof import('@workflow/web/server').startServer>
>;

let webServer: WebServer | null = null;

interface DashboardRegistryEntry {
url: string;
Expand Down Expand Up @@ -84,7 +92,7 @@ async function startWebServer(webPort: number): Promise<boolean> {
try {
logger.info('Starting web UI server...');
const { startServer } = await import('@workflow/web/server');
httpServer = await startServer(webPort);
webServer = await startServer(webPort);
logger.success(chalk.green(`Web UI server started on port ${webPort}`));
return true;
} catch (error) {
Expand Down Expand Up @@ -265,13 +273,16 @@ export async function launchWebUI(
}

// If we started the server, keep the process running
if (!alreadyRunning && httpServer) {
if (!alreadyRunning && webServer) {
logger.info(chalk.cyan('Press Ctrl+C to stop the web UI server and exit'));

// Keep the CLI process alive while the server is running
// Keep the CLI process alive while the server is running. srvx has no
// close event of its own, so we listen on the underlying Node server —
// always present here, since the CLI only ever runs on Node.
const nodeServer = webServer.node?.server;
await new Promise<void>((resolve) => {
if (httpServer) {
httpServer.on('close', () => resolve());
if (nodeServer) {
nodeServer.on('close', () => resolve());
} else {
resolve();
}
Expand Down
109 changes: 13 additions & 96 deletions packages/web/handler.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Framework-agnostic, in-process entry point for @workflow/web.
*
* Unlike `server.js` (which starts a standalone Express HTTP server), this
* Unlike `server.js` (which starts a standalone HTTP server of its own), 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.
Expand All @@ -17,41 +17,16 @@
*/

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';
import { createStaticHandler } from './static.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 '';
Expand Down Expand Up @@ -94,6 +69,16 @@ async function buildHandler(basename) {
);
}
const ssr = mod.createFetchHandler(basename || '/');
const serveStatic = createStaticHandler({
dir: clientDir,
basename,
// The host owns the connection and may compress the response itself. Even
// where it doesn't, this runs inside someone else's process — spending its
// CPU and libuv threads on brotli for what is almost always a localhost
// dev server is a bad trade. `server.js` leaves compression on, since it
// can be self-hosted over a real network.
compress: false,
});

return async (request) => {
// Advertise this dashboard so the CLI can defer to it (best-effort, once).
Expand All @@ -111,76 +96,8 @@ async function buildHandler(basename) {
// ignore — registration must never affect request handling
}

const staticResponse = await tryServeStatic(request, basename);
const staticResponse = await serveStatic(request);
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 });
}
5 changes: 2 additions & 3 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"handler.d.ts",
"registry.js",
"registry.d.ts",
"static.js",
"package.json"
],
"exports": {
Expand Down Expand Up @@ -49,7 +50,7 @@
},
"dependencies": {
"@workflow/world-local": "workspace:*",
"express": "^5.2.1",
"srvx": "^0.12.4",
"swr": "^2.3.6"
},
"devDependencies": {
Expand All @@ -65,11 +66,9 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "1.2.8",
"@react-router/dev": "7.13.1",
"@react-router/express": "7.13.1",
"@react-router/node": "7.13.1",
"@tailwindcss/vite": "4",
"@testing-library/react": "^16.3.0",
"@types/express": "^5",
"@types/node": "catalog:",
"@types/react": "19",
"@types/react-dom": "19",
Expand Down
6 changes: 5 additions & 1 deletion packages/web/server.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type { Server } from 'node:http';
import type { Server } from 'srvx';

/**
* Start the standalone observability UI server on `port` (defaults to `PORT`, then 3000),
* and resolve once it is listening.
*/
export function startServer(port?: number): Promise<Server>;
71 changes: 32 additions & 39 deletions packages/web/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,57 +10,50 @@

import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import express from 'express';
import { serve } from 'srvx';
import { createStaticHandler } from './static.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const buildDir = path.resolve(__dirname, 'build');

async function createApp() {
// Import the compiled server build, which exports { app } (an Express app
// with the React Router request handler already mounted by server/app.ts)
const { app } = await import(
pathToFileURL(path.join(buildDir, 'server/index.js')).href
);

// Add static file serving in front of the React Router handler.
// We create a wrapper app so static middleware runs first.
const server = express();

// Serve immutable assets with long-lived cache
server.use(
'/assets',
express.static(path.join(buildDir, 'client/assets'), {
immutable: true,
maxAge: '1y',
})
);

// Serve static client files with short cache
server.use(express.static(path.join(buildDir, 'client'), { maxAge: '1h' }));

// Mount the React Router app
server.use(app);

return server;
}
const clientDir = path.join(buildDir, 'client');
const serverEntry = path.join(buildDir, 'server', 'index.js');

/**
* Start the production HTTP server.
*
* @param {number} [port] - Port to listen on. Defaults to PORT env or 3000.
* @returns {Promise<import("http").Server>} The HTTP server instance.
* @returns {Promise<import("srvx").Server>} The listening server. The
* underlying Node `http.Server` remains reachable via `server.node.server`.
*/
export async function startServer(port) {
const resolvedPort = port ?? parseInt(process.env.PORT || '3000', 10);
const app = await createApp();
// Import the compiled server build, which exports `createFetchHandler` (the
// React Router request handler, as a Web `Request` -> `Response` function).
const { createFetchHandler } = await import(pathToFileURL(serverEntry).href);

// Static assets are resolved before the SSR handler ever runs. Compression is
// left on here (unlike the embedded handler): this server can be self-hosted
// over a real network, where trading CPU for bytes is worth it.
const serveStatic = createStaticHandler({ dir: clientDir });
const ssr = createFetchHandler('/');

return new Promise((resolve, reject) => {
const server = app.listen(resolvedPort, () => {
console.log(`@workflow/web server listening on port ${resolvedPort}`);
resolve(server);
});
server.on('error', reject);
const server = serve({
port: port ?? parseInt(process.env.PORT || '3000', 10),
async fetch(request) {
return (await serveStatic(request)) ?? ssr(request);
},
// Safety net — prevents an unhandled error from taking down the server when
// the React Router error boundary cannot render (e.g. during SSR).
error(error) {
console.error('Unhandled request error:', error);
return new Response('Internal Server Error', { status: 500 });
},
// We print our own listening line below.
silent: true,
});

await server.ready();
console.log(`@workflow/web server listening on ${server.url}`);
return server;
}

// When run directly, start the server
Expand Down
Loading
Loading