Skip to content
Merged
2 changes: 2 additions & 0 deletions apps/testing/e2e-web/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import hello from './src/agent/hello/agent';
const app = await createApp({
router: { path: '/api', router },
agents: [counter, hello],
analytics: true,
workbench: '/workbench',
});

export default app;
27 changes: 27 additions & 0 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,33 @@ async function globalSetup(): Promise<void> {
}
console.log('[Global Setup] ✓ Client-side routing is working');

// Step 5: Verify Bun backend is responding (proxied through Vite/front-door)
// The analytics beacon JS is served by the Bun backend at /_agentuity/webanalytics/analytics.js
const backendRes = await fetch(`${baseURL}/_agentuity/webanalytics/analytics.js`, {
signal: AbortSignal.timeout(5000),
});
if (!backendRes.ok) {
console.log(
`[Global Setup] Backend not ready yet (/_agentuity/webanalytics/analytics.js returned ${backendRes.status})`
);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
continue;
}
console.log('[Global Setup] ✓ Bun backend is ready');

// Step 6: Verify workbench metadata is generated (dev server creates it asynchronously)
const metadataRes = await fetch(`${baseURL}/_agentuity/workbench/metadata.json`, {
signal: AbortSignal.timeout(5000),
});
if (!metadataRes.ok) {
console.log(
`[Global Setup] Metadata not ready yet (/_agentuity/workbench/metadata.json returned ${metadataRes.status})`
);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
continue;
}
console.log('[Global Setup] ✓ Workbench metadata is ready');

console.log('[Global Setup] ✓ All readiness checks passed!');
return;
} catch (err) {
Expand Down
10 changes: 9 additions & 1 deletion e2e/workbench.pw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ test.describe('Workbench Dev Mode', () => {
);
await expect(agentSelector).toBeVisible({ timeout: 10000 });

// Verify the metadata endpoint returns agents before interacting with the selector.
// The metadata file is generated by the dev server after startup, so the endpoint
// may return 500 briefly until it's ready — retry until it succeeds.
await expect(async () => {
const res = await page.request.get('/_agentuity/workbench/metadata.json');
expect(res.ok()).toBe(true);
}).toPass({ timeout: 10000 });

await agentSelector.click();

// Wait for the dropdown to be fully opened - cmdk uses data-state attribute
Expand All @@ -58,7 +66,7 @@ test.describe('Workbench Dev Mode', () => {

// Click with force to bypass animation stability checks
const helloAgentOption = page.locator('[role="option"]:has-text("hello")');
await expect(helloAgentOption).toBeVisible({ timeout: 5000 });
await expect(helloAgentOption).toBeVisible({ timeout: 10000 });
await helloAgentOption.click({ force: true });

await expect(page.locator('button:has-text("hello")')).toBeVisible();
Expand Down
62 changes: 56 additions & 6 deletions packages/cli/src/cmd/build/vite/vite-asset-server-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,47 @@ export interface GenerateAssetServerConfigOptions {
routePaths?: string[];
}

/**
* Vite plugin that injects analytics scripts in dev mode.
*
* In production the beacon plugin handles this at build time. In dev mode
* the analytics config + session + beacon scripts are served by the Bun
* backend at /_agentuity/webanalytics/* routes, but we need to inject the
* `<script>` tags into the HTML so the browser loads them.
*/
function devAnalyticsPlugin(): Plugin {
return {
name: 'agentuity:dev-analytics',
transformIndexHtml: {
order: 'pre',
handler(html) {
// Default analytics config — matches resolveAnalyticsConfig(undefined) in runtime
const config = {
enabled: true,
trackClicks: true,
trackScroll: true,
trackOutboundLinks: true,
trackForms: false,
trackWebVitals: true,
trackErrors: true,
trackSPANavigation: true,
isDevmode: true,
};

const injection =
`<script>window.__AGENTUITY_ANALYTICS__=${JSON.stringify(config)};</script>` +
'<script src="/_agentuity/webanalytics/session.js" async></script>' +
'<script src="/_agentuity/webanalytics/analytics.js"></script>';

if (html.includes('</head>')) {
return html.replace('</head>', `${injection}</head>`);
}
return html;
},
},
};
}

/**
* Vite plugin that serves src/web/index.html as the SPA fallback.
*
Expand All @@ -29,7 +70,7 @@ export interface GenerateAssetServerConfigOptions {
* this plugin to rewrite the URL so Vite's built-in transform pipeline
* (including React Fast Refresh injection) processes it correctly.
*/
function spaFallbackPlugin(rootDir: string, routePaths: string[]): Plugin {
function spaFallbackPlugin(rootDir: string, routePaths: string[], workbenchPath?: string): Plugin {
const htmlPath = join(rootDir, 'src', 'web', 'index.html');
const hasHtml = existsSync(htmlPath);

Expand Down Expand Up @@ -82,6 +123,13 @@ function spaFallbackPlugin(rootDir: string, routePaths: string[]): Plugin {
) {
return next();
}
// Skip workbench path (served by Bun)
if (
workbenchPath &&
(pathname === workbenchPath || pathname.startsWith(workbenchPath + '/'))
) {
return next();
}
for (const rp of routePaths) {
if (pathname === rp || pathname.startsWith(rp + '/')) return next();
}
Expand Down Expand Up @@ -190,8 +238,10 @@ export async function generateAssetServerConfig(
strictPort: true, // Port is pre-verified as available by findAvailablePort()
host: '127.0.0.1',

// Proxy backend routes to Bun server
// WebSocket upgrade requests are automatically proxied when ws: true
// Proxy backend routes to Bun server (HTTP only).
// WebSocket upgrades are handled by the front-door TCP proxy (ws-proxy.ts)
// which routes them directly to the Bun backend, bypassing Vite entirely.
// This avoids Bun's broken node:http upgrade socket implementation.
proxy: {
// User-defined route mounts (from createApp({ router }))
...Object.fromEntries(
Expand All @@ -200,15 +250,13 @@ export async function generateAssetServerConfig(
{
target: `http://127.0.0.1:${backendPort}`,
changeOrigin: true,
ws: true,
},
])
),
// Agentuity system routes (workbench API, health, analytics, etc.)
'/_agentuity': {
target: `http://127.0.0.1:${backendPort}`,
changeOrigin: true,
ws: true,
},
// Workbench UI route (served by Bun, references /@fs/* paths handled by Vite)
...(workbenchPath
Expand Down Expand Up @@ -283,8 +331,10 @@ export async function generateAssetServerConfig(
browserEnvPlugin(),
// Warn about incorrect public asset paths in dev mode
publicAssetPathPlugin({ warnInDev: true }),
// Inject analytics scripts in dev HTML
devAnalyticsPlugin(),
// SPA fallback: serve src/web/index.html for navigation requests
spaFallbackPlugin(rootDir, routePaths),
spaFallbackPlugin(rootDir, routePaths, workbenchPath),
];
})(),

Expand Down
126 changes: 126 additions & 0 deletions packages/cli/src/cmd/build/vite/ws-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* WebSocket-aware front-door TCP proxy for dev mode.
*
* Bun's node:http has several bugs that prevent Vite's built-in http-proxy
* from proxying WebSocket upgrades (see linked PRs). Rather than polyfilling
* those bugs, this module places a lightweight `net.createServer` on the
* user-facing port. It inspects the first bytes of each TCP connection and
* routes accordingly:
*
* - **WebSocket upgrades to backend paths** → piped directly to Bun backend
* (Bun's native `server.upgrade()` works perfectly over raw TCP)
* - **Everything else** (HTTP requests, Vite HMR WebSocket) → piped to Vite
*
* From the browser's perspective there is only one port. Vite and Bun both
* listen on internal ports that are never exposed.
*
* Bun bugs this works around:
* - https://github.com/oven-sh/bun/pull/27237 (socket.write drops data)
* - https://github.com/oven-sh/bun/pull/26264 (missing destroySoon)
* - https://github.com/oven-sh/bun/pull/27859 (http.request upgrade event)
* - Server-side upgrade socket read broken (HTTP parser doesn't hand off)
*
* This entire module can be removed once those Bun PRs are merged and the
* Vite `ws: true` proxy works natively under Bun.
*
* ```
* Browser ──TCP──▶ net.Server (:3500, user-facing)
* │
* ┌───────────┴───────────┐
* ▼ (WS upgrade to ▼ (everything else)
* backend paths)
* Bun backend (:3501) Vite server (:3502)
* ```
*/

import { createServer, connect, type Server } from 'node:net';
import type { Logger } from '../../../types';

export interface WsProxyOptions {
/** Port the front-door proxy listens on (user-facing) */
port: number;
/** Port of the Vite dev server (internal) */
vitePort: number;
/** Port of the Bun backend server (internal) */
backendPort: number;
/** Route path prefixes that should be proxied to the backend */
routePaths: string[];
logger: Logger;
}

/**
* Start a front-door TCP proxy that routes WebSocket upgrades to the Bun
* backend and everything else to Vite. Returns the `net.Server` instance.
*/
export function startWsProxy(options: WsProxyOptions): Promise<Server> {
const { port, vitePort, backendPort, routePaths, logger } = options;

// Prefixes whose WebSocket upgrades go to Bun instead of Vite
const wsPathPrefixes = ['/_agentuity', ...routePaths];

return new Promise((resolve, reject) => {
const server = createServer((socket) => {
let handled = false;

// Peek at the first chunk to decide where to route
socket.once('data', (firstChunk) => {
handled = true;

const header = firstChunk.toString('utf8', 0, Math.min(firstChunk.length, 4096));

// Detect: is this a WebSocket upgrade for a backend path?
const isUpgrade = /upgrade:\s*websocket/i.test(header);
let targetPort = vitePort;

if (isUpgrade) {
const pathMatch = header.match(/^(?:GET|POST)\s+(\S+)/);
const pathname = (pathMatch?.[1] ?? '/').split('?')[0] ?? '/';

const isBackendPath = wsPathPrefixes.some(
(prefix) => pathname === prefix || pathname.startsWith(prefix + '/')
);

if (isBackendPath) {
targetPort = backendPort;
logger.debug('WS upgrade %s → Bun :%d', pathname, backendPort);
}
}

const target = connect(targetPort, '127.0.0.1');

target.on('connect', () => {
target.write(firstChunk);
socket.pipe(target);
target.pipe(socket);
});

target.on('error', () => {
if (!socket.destroyed) socket.destroy();
});
socket.on('error', () => {
if (!target.destroyed) target.destroy();
});
});

// Client disconnected before sending anything
socket.on('close', () => {
if (!handled) socket.destroy();
});
socket.on('error', () => {
if (!handled) socket.destroy();
});
});

server.on('error', reject);

server.listen(port, '127.0.0.1', () => {
logger.debug(
'WS front-door proxy on :%d (Vite :%d, Bun :%d)',
port,
vitePort,
backendPort
);
resolve(server);
});
});
}
46 changes: 39 additions & 7 deletions packages/cli/src/cmd/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,8 +562,9 @@ export const command = createCommand({
logger.debug('Route detection failed, using default /api: %s', err);
}

// Pick internal port for Bun backend (not user-facing)
// Pick internal ports (neither is user-facing — the front-door proxy is)
const bunBackendPort = opts.port + 1;
const viteInternalPort = opts.port + 2;

// No-bundle dev mode guard: ensure stale bundled app artifact cannot be executed.
// We keep other .agentuity artifacts (metadata/workbench files) intact.
Expand Down Expand Up @@ -596,19 +597,19 @@ export const command = createCommand({
};
}

// Start Vite dev server ONCE before restart loop
// Vite is the primary (user-facing) server — serves frontend natively
// and proxies API/WS requests to Bun backend
// Start Vite dev server on an internal port.
// The user-facing port is handled by the front-door TCP proxy (ws-proxy)
// which routes WS upgrades to Bun and everything else to Vite.
let viteServer: ServerLike | null = null;
let vitePort: number;

try {
logger.debug('Starting Vite dev server (primary)...');
logger.debug('Starting Vite dev server (internal port %d)...', viteInternalPort);
const viteResult = await startViteAssetServer({
rootDir,
logger,
workbenchPath: workbench.config?.route,
port: opts.port,
port: viteInternalPort,
backendPort: bunBackendPort,
routePaths,
});
Expand All @@ -619,7 +620,7 @@ export const command = createCommand({
await devLock.updatePorts({ vite: vitePort });

logger.debug(
`Vite dev server running on port ${vitePort} (primary, proxying backend on port ${bunBackendPort})`
`Vite dev server running on port ${vitePort} (internal, proxying backend on port ${bunBackendPort})`
);
} catch (error) {
tui.error(`Failed to start Vite dev server: ${error}`);
Expand All @@ -628,6 +629,30 @@ export const command = createCommand({
return;
}

// Start the front-door TCP proxy on the user-facing port.
// Routes WebSocket upgrades (for /api/*, /_agentuity/*) directly to Bun
// and everything else (HTTP, HMR WebSocket) to Vite.
// This works around Bun's broken node:http upgrade socket implementation.
let frontDoorServer: import('node:net').Server | null = null;
try {
const { startWsProxy } = await import('../build/vite/ws-proxy');
frontDoorServer = await startWsProxy({
port: opts.port,
vitePort,
backendPort: bunBackendPort,
routePaths,
logger,
});
logger.debug(
`Front-door proxy on port ${opts.port} (Vite:${vitePort}, Bun:${bunBackendPort})`
);
} catch (error) {
tui.error(`Failed to start front-door proxy: ${error}`);
await devLock.release();
originalExit(1);
return;
}

// Restart loop - allows BACKEND server to restart on file changes
// Vite stays running and handles frontend changes via HMR
let shouldRestart = false;
Expand Down Expand Up @@ -682,6 +707,13 @@ export const command = createCommand({
logger.debug('Error stopping file watcher: %s', err);
}

// Stop front-door proxy
try {
frontDoorServer?.close();
} catch (err) {
logger.debug('Error stopping front-door proxy during cleanup: %s', err);
}

// Stop Bun server
try {
await stopBunServer(bunBackendPort, logger);
Expand Down
Loading
Loading