Skip to content

Commit ad0fcc7

Browse files
authored
refactor: switch from express to srvx (#3193)
* refactor: switch from express to srvx * chore: update lockfile * Update .changeset/web-drop-express-for-srvx.md Signed-off-by: Rihan Arfan <me@file.properties> --------- Signed-off-by: Rihan Arfan <me@file.properties>
1 parent 35c5b32 commit ad0fcc7

11 files changed

Lines changed: 485 additions & 278 deletions

File tree

‎.changeset/cli-srvx-web-server.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@workflow/cli': patch
3+
---
4+
5+
Track the standalone web UI server as a srvx `Server`, following the `@workflow/web` change.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@workflow/web': major
3+
---
4+
5+
Replace Express with `srvx` in the standalone server, adding ETag/`304`, `Last-Modified`, byte ranges and on-the-fly compression.

‎packages/cli/src/lib/inspect/web.ts‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { Server } from 'node:http';
21
import chalk from 'chalk';
32
import open from 'open';
43
import { logger } from '../config/log.js';
@@ -8,7 +7,16 @@ import { getVercelDashboardUrl } from './vercel-api.js';
87

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

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

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

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

271-
// Keep the CLI process alive while the server is running
279+
// Keep the CLI process alive while the server is running. srvx has no
280+
// close event of its own, so we listen on the underlying Node server —
281+
// always present here, since the CLI only ever runs on Node.
282+
const nodeServer = webServer.node?.server;
272283
await new Promise<void>((resolve) => {
273-
if (httpServer) {
274-
httpServer.on('close', () => resolve());
284+
if (nodeServer) {
285+
nodeServer.on('close', () => resolve());
275286
} else {
276287
resolve();
277288
}

‎packages/web/handler.js‎

Lines changed: 13 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Framework-agnostic, in-process entry point for @workflow/web.
33
*
4-
* Unlike `server.js` (which starts a standalone Express HTTP server), this
4+
* Unlike `server.js` (which starts a standalone HTTP server of its own), this
55
* exports a single Web-standard fetch handler that another server can mount
66
* under an arbitrary base path — e.g. `@workflow/nitro` mounting the dashboard
77
* at `/_workflow` without spawning a second server/port.
@@ -17,41 +17,16 @@
1717
*/
1818

1919
import { existsSync } from 'node:fs';
20-
import { readFile, stat } from 'node:fs/promises';
2120
import path from 'node:path';
2221
import { fileURLToPath, pathToFileURL } from 'node:url';
2322
import { recordDashboard } from './registry.js';
23+
import { createStaticHandler } from './static.js';
2424

2525
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2626
const buildDir = path.resolve(__dirname, 'build');
2727
const clientDir = path.join(buildDir, 'client');
2828
const serverEntry = path.join(buildDir, 'server', 'index.js');
2929

30-
// Minimal extension -> MIME map for serving the static client build. A wrong
31-
// Content-Type on the entry module breaks the whole app, so be explicit.
32-
const MIME_TYPES = {
33-
'.js': 'text/javascript',
34-
'.mjs': 'text/javascript',
35-
'.css': 'text/css',
36-
'.json': 'application/json',
37-
'.map': 'application/json',
38-
'.html': 'text/html',
39-
'.ico': 'image/x-icon',
40-
'.svg': 'image/svg+xml',
41-
'.png': 'image/png',
42-
'.jpg': 'image/jpeg',
43-
'.jpeg': 'image/jpeg',
44-
'.gif': 'image/gif',
45-
'.webp': 'image/webp',
46-
'.avif': 'image/avif',
47-
'.woff': 'font/woff',
48-
'.woff2': 'font/woff2',
49-
'.ttf': 'font/ttf',
50-
'.otf': 'font/otf',
51-
'.txt': 'text/plain',
52-
'.wasm': 'application/wasm',
53-
};
54-
5530
/** Normalize a mount path: `/` or empty -> "" (root); otherwise strip trailing slash. */
5631
function normalizeBasename(basename) {
5732
if (!basename || basename === '/') return '';
@@ -94,6 +69,16 @@ async function buildHandler(basename) {
9469
);
9570
}
9671
const ssr = mod.createFetchHandler(basename || '/');
72+
const serveStatic = createStaticHandler({
73+
dir: clientDir,
74+
basename,
75+
// The host owns the connection and may compress the response itself. Even
76+
// where it doesn't, this runs inside someone else's process — spending its
77+
// CPU and libuv threads on brotli for what is almost always a localhost
78+
// dev server is a bad trade. `server.js` leaves compression on, since it
79+
// can be self-hosted over a real network.
80+
compress: false,
81+
});
9782

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

114-
const staticResponse = await tryServeStatic(request, basename);
99+
const staticResponse = await serveStatic(request);
115100
if (staticResponse) return staticResponse;
116101
return ssr(request);
117102
};
118103
}
119-
120-
/**
121-
* Resolve a request to a path-within-the-client-build (relative to `clientDir`),
122-
* stripping `basename` and guarding against traversal. Returns null when the
123-
* request can't map to a client file (so it should fall through to the SSR
124-
* handler) — e.g. the index, a directory, or an out-of-tree path.
125-
*/
126-
function resolveClientFile(request, basename) {
127-
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
128-
129-
let pathname;
130-
try {
131-
pathname = decodeURIComponent(new URL(request.url).pathname);
132-
} catch {
133-
return null;
134-
}
135-
136-
if (basename) {
137-
if (!pathname.startsWith(`${basename}/`)) return null; // index/other -> SSR
138-
pathname = pathname.slice(basename.length);
139-
}
140-
141-
const relative = pathname.replace(/^\/+/, '');
142-
if (!relative || relative.endsWith('/')) return null;
143-
144-
const filePath = path.join(clientDir, relative);
145-
// Guard against path traversal escaping the client build directory.
146-
if (filePath !== clientDir && !filePath.startsWith(clientDir + path.sep)) {
147-
return null;
148-
}
149-
return { filePath, relative };
150-
}
151-
152-
/**
153-
* Serve a file from the prebuilt client bundle if the request maps to one;
154-
* otherwise return null so the request falls through to the SSR handler.
155-
*/
156-
async function tryServeStatic(request, basename) {
157-
const resolved = resolveClientFile(request, basename);
158-
if (!resolved) return null;
159-
const { filePath, relative } = resolved;
160-
161-
let stats;
162-
try {
163-
stats = await stat(filePath);
164-
} catch {
165-
return null;
166-
}
167-
if (!stats.isFile()) return null;
168-
169-
const ext = path.extname(filePath).toLowerCase();
170-
const headers = new Headers({
171-
'Content-Type': MIME_TYPES[ext] ?? 'application/octet-stream',
172-
// Hashed assets are content-addressed and safe to cache forever; other
173-
// client files (e.g. favicon) get a short cache.
174-
'Cache-Control': relative.startsWith('assets/')
175-
? 'public, max-age=31536000, immutable'
176-
: 'public, max-age=3600',
177-
});
178-
179-
if (request.method === 'HEAD') {
180-
headers.set('Content-Length', String(stats.size));
181-
return new Response(null, { status: 200, headers });
182-
}
183-
184-
const body = await readFile(filePath);
185-
return new Response(body, { status: 200, headers });
186-
}

‎packages/web/package.json‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"handler.d.ts",
1313
"registry.js",
1414
"registry.d.ts",
15+
"static.js",
1516
"package.json"
1617
],
1718
"exports": {
@@ -49,7 +50,7 @@
4950
},
5051
"dependencies": {
5152
"@workflow/world-local": "workspace:*",
52-
"express": "^5.2.1",
53+
"srvx": "^0.12.4",
5354
"swr": "^2.3.6"
5455
},
5556
"devDependencies": {
@@ -65,11 +66,9 @@
6566
"@radix-ui/react-tabs": "^1.1.13",
6667
"@radix-ui/react-tooltip": "1.2.8",
6768
"@react-router/dev": "7.13.1",
68-
"@react-router/express": "7.13.1",
6969
"@react-router/node": "7.13.1",
7070
"@tailwindcss/vite": "4",
7171
"@testing-library/react": "^16.3.0",
72-
"@types/express": "^5",
7372
"@types/node": "catalog:",
7473
"@types/react": "19",
7574
"@types/react-dom": "19",

‎packages/web/server.d.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1-
import type { Server } from 'node:http';
1+
import type { Server } from 'srvx';
22

3+
/**
4+
* Start the standalone observability UI server on `port` (defaults to `PORT`, then 3000),
5+
* and resolve once it is listening.
6+
*/
37
export function startServer(port?: number): Promise<Server>;

‎packages/web/server.js‎

Lines changed: 32 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,57 +10,50 @@
1010

1111
import path from 'node:path';
1212
import { fileURLToPath, pathToFileURL } from 'node:url';
13-
import express from 'express';
13+
import { serve } from 'srvx';
14+
import { createStaticHandler } from './static.js';
1415

1516
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1617
const buildDir = path.resolve(__dirname, 'build');
17-
18-
async function createApp() {
19-
// Import the compiled server build, which exports { app } (an Express app
20-
// with the React Router request handler already mounted by server/app.ts)
21-
const { app } = await import(
22-
pathToFileURL(path.join(buildDir, 'server/index.js')).href
23-
);
24-
25-
// Add static file serving in front of the React Router handler.
26-
// We create a wrapper app so static middleware runs first.
27-
const server = express();
28-
29-
// Serve immutable assets with long-lived cache
30-
server.use(
31-
'/assets',
32-
express.static(path.join(buildDir, 'client/assets'), {
33-
immutable: true,
34-
maxAge: '1y',
35-
})
36-
);
37-
38-
// Serve static client files with short cache
39-
server.use(express.static(path.join(buildDir, 'client'), { maxAge: '1h' }));
40-
41-
// Mount the React Router app
42-
server.use(app);
43-
44-
return server;
45-
}
18+
const clientDir = path.join(buildDir, 'client');
19+
const serverEntry = path.join(buildDir, 'server', 'index.js');
4620

4721
/**
4822
* Start the production HTTP server.
4923
*
5024
* @param {number} [port] - Port to listen on. Defaults to PORT env or 3000.
51-
* @returns {Promise<import("http").Server>} The HTTP server instance.
25+
* @returns {Promise<import("srvx").Server>} The listening server. The
26+
* underlying Node `http.Server` remains reachable via `server.node.server`.
5227
*/
5328
export async function startServer(port) {
54-
const resolvedPort = port ?? parseInt(process.env.PORT || '3000', 10);
55-
const app = await createApp();
29+
// Import the compiled server build, which exports `createFetchHandler` (the
30+
// React Router request handler, as a Web `Request` -> `Response` function).
31+
const { createFetchHandler } = await import(pathToFileURL(serverEntry).href);
32+
33+
// Static assets are resolved before the SSR handler ever runs. Compression is
34+
// left on here (unlike the embedded handler): this server can be self-hosted
35+
// over a real network, where trading CPU for bytes is worth it.
36+
const serveStatic = createStaticHandler({ dir: clientDir });
37+
const ssr = createFetchHandler('/');
5638

57-
return new Promise((resolve, reject) => {
58-
const server = app.listen(resolvedPort, () => {
59-
console.log(`@workflow/web server listening on port ${resolvedPort}`);
60-
resolve(server);
61-
});
62-
server.on('error', reject);
39+
const server = serve({
40+
port: port ?? parseInt(process.env.PORT || '3000', 10),
41+
async fetch(request) {
42+
return (await serveStatic(request)) ?? ssr(request);
43+
},
44+
// Safety net — prevents an unhandled error from taking down the server when
45+
// the React Router error boundary cannot render (e.g. during SSR).
46+
error(error) {
47+
console.error('Unhandled request error:', error);
48+
return new Response('Internal Server Error', { status: 500 });
49+
},
50+
// We print our own listening line below.
51+
silent: true,
6352
});
53+
54+
await server.ready();
55+
console.log(`@workflow/web server listening on ${server.url}`);
56+
return server;
6457
}
6558

6659
// When run directly, start the server

0 commit comments

Comments
 (0)