Files
vercel__workflow/packages/web/app/lib/config-world.server.ts
Nathan Rajlich 7653e6bfdb Migrate @workflow/web from Next.js to React Router v7 (#1005)
## Summary

- Replace Next.js App Router with React Router v7.13.0 framework mode (Vite-based), eliminating the large `next` dependency from the web, CLI, and workflow metapackages
- Serve the web UI in-process from the CLI via Express instead of spawning `next start` as a child process
- Switch RPC transport from JSON to CBOR to preserve binary data types across the wire
- Replace `nuqs` URL state management with React Router's `useSearchParams`
- Replace Next.js server actions with an RPC resource route (`/api/rpc`) and a thin CBOR-based client

## Motivation

The `next` package is ~300MB installed and was the single largest dependency in the monorepo. It also required spawning a separate child process from the CLI to run the o11y web server, adding complexity around process lifecycle management, port readiness polling, and environment variable forwarding.

With React Router framework mode, the web package builds to a standard Express-compatible server bundle that the CLI can import and serve directly in its own process.

## What changed

**Framework swap (`@workflow/web`):**
- `next.config.ts` / `postcss.config.mjs` → `react-router.config.ts` / `vite.config.ts`
- `src/` directory → `app/` directory (React Router convention)
- `src/app/layout.tsx` + `layout-client.tsx` → `app/root.tsx`
- `src/app/page.tsx` → `app/routes/home.tsx`
- `src/app/run/[runId]/page.tsx` → `app/routes/run-detail.tsx`
- Path alias `@/` → `~/`
- Removed all `'use client'` / `'use server'` directives

**Data transport:**
- Server actions → RPC resource route at `/api/rpc` with CBOR encoding
- CBOR preserves `Uint8Array` and other binary types natively (no base64 overhead)
- Stream reading → dedicated `/api/stream/:streamId` resource route

**URL state:**
- `nuqs` (`useQueryState`) → `useSearchParams` from `react-router`

**Fonts:**
- `next/font/google` → Geist `.woff2` files referenced directly from `node_modules/geist` via `@font-face` in CSS

**CLI integration (`@workflow/cli`):**
- `import('@workflow/web/server').then(m => m.startServer(port))`
- No child process, no readiness polling, no cleanup handlers

**Radix UI compatibility:**
- `onSubmit` preventDefault on `AlertDialogContent` and `SheetContent` to prevent Radix's internal `<form method="dialog">` from triggering React Router route actions
- Catch-all action on root route for any stray POSTs

## Dependencies removed
- `next`, `swr`, `nuqs`, `@tailwindcss/postcss`

## Dependencies added
- `react-router` / `@react-router/dev` / `@react-router/node` / `@react-router/express` (all `7.13.0`)
- `express`, `vite`, `@tailwindcss/vite`, `cbor-x`, `isbot`, `cross-env`
- `geist` (devDep)
2026-02-12 16:30:51 -08:00

158 lines
4.3 KiB
TypeScript

/**
* Server-only module for world configuration.
* The `.server.ts` suffix ensures Vite excludes this from client bundles.
*/
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, resolve } from 'node:path';
import {
findWorkflowDataDir,
type WorkflowDataDirInfo,
} from '@workflow/utils/check-data-dir';
import { KNOWN_WORLDS, type KnownWorld } from './known-worlds';
export type { WorkflowDataDirInfo };
const require = createRequire(join(process.cwd(), 'index.js'));
export interface WorldConfig {
backend?: string;
env?: string;
authToken?: string;
project?: string;
team?: string;
port?: string;
// Will always be defined (""./"" if not set by user), but only used for local world
dataDir: string;
// Path to the workflow manifest file (defaults to app/.well-known/workflow/v1/manifest.json)
manifestPath?: string;
// Postgres fields
postgresUrl?: string;
}
export interface ValidationError {
field: string;
message: string;
}
export interface WorldAvailability {
id: string;
displayName: string;
packageName: string | null;
description: string;
isBuiltIn: boolean;
isInstalled: boolean;
installCommand?: string;
}
/**
* Check which world packages are installed.
*
* Built-in worlds (local, vercel) are always available.
* Third-party worlds are checked by attempting to resolve their package.
*/
export async function checkWorldsAvailability(): Promise<WorldAvailability[]> {
return KNOWN_WORLDS.map((world: KnownWorld) => {
const availability: WorldAvailability = {
id: world.id,
displayName: world.displayName,
packageName: world.packageName,
description: world.description,
isBuiltIn: world.isBuiltIn,
isInstalled: world.isBuiltIn, // Built-in worlds are always installed
};
// For non-built-in worlds, try to resolve the package
if (!world.isBuiltIn && world.packageName) {
try {
require.resolve(world.packageName);
availability.isInstalled = true;
} catch {
availability.isInstalled = false;
availability.installCommand = `npm install ${world.packageName}`;
}
}
return availability;
});
}
export interface ValidationWarning {
field: string;
message: string;
}
export interface ValidationResult {
errors: ValidationError[];
warnings: ValidationWarning[];
}
// Validate configuration and return errors/warnings
export async function validateWorldConfig(
config: WorldConfig
): Promise<ValidationError[]> {
const errors: ValidationError[] = [];
const backend = config.backend || 'local';
if (backend === 'local') {
// Check if data directory exists
const resolvedPath = resolve(config.dataDir);
if (!existsSync(resolvedPath)) {
errors.push({
field: 'dataDir',
message: `Data directory does not exist: ${resolvedPath}`,
});
}
// Validate port if provided
if (config.port) {
const portNum = Number.parseInt(config.port, 10);
if (Number.isNaN(portNum) || portNum < 1 || portNum > 65535) {
errors.push({
field: 'port',
message: 'Port must be a number between 1 and 65535',
});
}
}
}
if (backend === 'postgres') {
// Validate postgres connection string
if (!config.postgresUrl) {
errors.push({
field: 'postgresUrl',
message: 'PostgreSQL connection string is required',
});
} else if (
!config.postgresUrl.startsWith('postgres://') &&
!config.postgresUrl.startsWith('postgresql://')
) {
errors.push({
field: 'postgresUrl',
message:
'Invalid PostgreSQL connection string format (must start with postgres:// or postgresql://)',
});
}
}
return errors;
}
/**
* Resolves a dataDir path to full WorkflowDataDirInfo.
*
* This function takes a dataDir path (which may be relative, absolute, or use ~)
* and resolves it to get the projectDir and shortName for display purposes.
*
* @param dataDir - The data directory path to resolve
* @returns WorkflowDataDirInfo if found, null otherwise
*/
export async function resolveDataDirInfo(
dataDir: string
): Promise<WorkflowDataDirInfo> {
// If no dataDir provided, try to find one from cwd
const searchPath = dataDir;
return findWorkflowDataDir(searchPath);
}