diff --git a/docs/adapters/dev.md b/docs/adapters/dev.md index 12124dc..727509a 100644 --- a/docs/adapters/dev.md +++ b/docs/adapters/dev.md @@ -30,8 +30,34 @@ process.on('SIGINT', () => handle.close().then(() => process.exit(0))) | `basePath` | `resolveBasePath(def, 'standalone')` | Mount path override. | | `app` | fresh h3 app | Pre-configured h3 app to mount onto (custom middleware, auth, extra static assets). | | `openBrowser` | resolves from `flags.open` / `def.cli?.open` | Explicit on/off override. `false` disables; a string opens that relative path. | +| `ws` | `def.cli?.ws` | How the browser reaches the RPC WebSocket — see below. | | `onReady` | — | Callback when the WS server is bound. | +## WebSocket endpoint + +By default the RPC socket shares the HTTP server's port and binds to the `__devframe_ws` route next to `__connection.json`. The descriptor advertises a *relative* path, so the client connects to its own origin — the link follows the page through a reverse proxy that rewrites the domain, port, or subpath. Configure the three connection scenarios via `def.cli.ws` (or the `ws` call-site option): + +```ts +defineDevframe({ + // 1. Same server, a custom route (default route is `__devframe_ws`): + cli: { ws: { route: '__sockets' } }, + + // 2. A dedicated port on the same host: + cli: { ws: { port: 9788 } }, + + // 3. A remote, fully-qualified endpoint (e.g. a tunnel/relay): + cli: { ws: { url: 'wss://devtools.example.com/relay/__devframe_ws' } }, +}) +``` + +| Field | Scenario | Advertised `websocket` | +|-------|----------|------------------------| +| `route` | same server, different route | `{ path: }` (same origin) | +| `port` | different port | `{ port, path: }` (page host) | +| `url` | remote, different origin | the URL string, used verbatim | + +Precedence is `url` > `port` > `route`. In the remote case the dev server still hosts the socket locally on `route`; point your tunnel at it. + ## Port resolution `resolveDevServerPort(def, opts?)` resolves a port up-front (to print or log it) before the server starts: diff --git a/docs/guide/client.md b/docs/guide/client.md index d3f2cc0..facad41 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -183,16 +183,24 @@ With caching on, `query` / `static` function responses are memoized per argument ## Discovery (`__connection.json`) -Devframe writes a JSON descriptor at `/__connection.json` so the client knows where to connect: +Devframe writes a JSON descriptor at `/__connection.json` so the client knows where to connect. The dev server shares one port for HTTP and the WebSocket — the socket is bound to a route (`__devframe_ws`) next to the meta file — and advertises it as a relative path: ```json { "backend": "websocket", - "websocket": "ws://localhost:9999/__ws" + "websocket": { "path": "__devframe_ws" } } ``` -or for static mode: +The client resolves that path against the origin it loaded from, swapping `http`→`ws` / `https`→`wss`. It never trusts a host or port baked into the descriptor, so the connection follows the page through a reverse proxy that rewrites the domain, port, or subpath. + +The `websocket` field also accepts: + +- A `number` — a port on the page's hostname (`ws(s)://:`). +- A full `ws://`/`wss://` URL string — used verbatim for a fixed cross-origin endpoint. +- `{ port }` / `{ host }` — a cross-origin endpoint (e.g. a side-car server on its own port), rooted at that host/port rather than the page origin. + +For static mode: ```json { "backend": "static" } diff --git a/docs/helpers/vite-bridge.md b/docs/helpers/vite-bridge.md index 3a8bfb2..6eabe76 100644 --- a/docs/helpers/vite-bridge.md +++ b/docs/helpers/vite-bridge.md @@ -21,7 +21,9 @@ export default defineConfig({ ## Modes - **Static mount** (default) — mounts `def.cli.distDir` at `options.base` (`/__/` by default). No RPC server. Useful when you only need the SPA bundle served from a known path. -- **Bridge mode** (`devMiddleware: true | {…}`) — skips the static mount; the host app owns the SPA. Devframe spawns a separate RPC + WS server and registers Vite middleware at `__connection.json` so the host-served SPA can discover the WS endpoint. +- **Bridge mode** (`devMiddleware: true | {…}`) — skips the static mount; the host app owns the SPA. Devframe spawns a separate RPC + WS server and registers Vite middleware at `__connection.json` so the host-served SPA can discover the WS endpoint. The side-car listens on its own port, so the descriptor carries that port alongside the `/__devframe_ws` route. + +To mount the RPC socket onto the Vite server's own port instead of a side-car — so it shares the origin with the app and rides through a proxy — pass an existing HTTP server and a route to [`startHttpAndWs`](/adapters/dev) via its `server` and `path` options. Devframe routes only that upgrade path and leaves the rest (Vite's HMR socket included) untouched. ## Options diff --git a/examples/streaming-chat/src/client/app.tsx b/examples/streaming-chat/src/client/app.tsx index 18b1cba..7c5354f 100644 --- a/examples/streaming-chat/src/client/app.tsx +++ b/examples/streaming-chat/src/client/app.tsx @@ -264,7 +264,7 @@ export function App() { )} -
+
backend: {' '} {ctx.base.connectionMeta.backend} @@ -299,7 +299,7 @@ function Message({ msg, live }: { msg: ChatMessage, live: string | undefined }) ) return ( -
+
{displayed || (msg.streamId ? '' : '(empty)')} {/* Live "typing" indicator while the producer is still streaming tokens. */} {msg.streamId && } diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index 73d911e..879a5ea 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { getPort } from 'get-port-please' import { describe, expect, it } from 'vitest' +import { WebSocket } from 'ws' import { defineDevframe } from '../../types/devframe' import { createDevServer, resolveDevServerPort } from '../dev' @@ -41,7 +42,155 @@ describe('adapters/dev', () => { const res = await fetch(`http://${host}:${port}/__connection.json`) expect(res.ok).toBe(true) const meta = await res.json() - expect(meta).toEqual({ backend: 'websocket', websocket: port }) + // Proxy-safe: the WS endpoint is advertised as a same-origin route + // relative to `__connection.json`, never a baked-in host/port. + expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__devframe_ws' } }) + } + finally { + await handle.close() + } + }) + + it('createDevServer binds the WS endpoint to the advertised route', async () => { + const distDir = makeTmpDist() + const devframe = defineDevframe({ + id: 'devframe-test-ws', + name: 'Devframe WS', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + }) + + const host = '127.0.0.1' + const port = await getPort({ port: 19899, host }) + const handle = await createDevServer(devframe, { + host, + port, + distDir, + openBrowser: false, + }) + + try { + // Connects on the bound route. + const ok = new WebSocket(`ws://${host}:${port}/__devframe_ws`) + await expect(new Promise((resolve, reject) => { + ok.on('open', () => resolve('open')) + ok.on('error', reject) + })).resolves.toBe('open') + ok.close() + + // A connection off-route is left unhandled (no upgrade handler claims + // it) and the socket is closed without an open event. + const off = new WebSocket(`ws://${host}:${port}/not-the-ws-route`) + await expect(new Promise((resolve, reject) => { + off.on('open', () => reject(new Error('should not open off-route'))) + off.on('close', () => resolve('closed')) + off.on('error', () => resolve('closed')) + })).resolves.toBe('closed') + } + finally { + await handle.close() + } + }) + + it('ws config: custom route on the same server', async () => { + const devframe = defineDevframe({ + id: 'devframe-ws-route', + name: 'WS Route', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + cli: { ws: { route: '__sockets' } }, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19880, host }) + const handle = await createDevServer(devframe, { host, port, openBrowser: false }) + + try { + const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json() + expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__sockets' } }) + + const ok = new WebSocket(`ws://${host}:${port}/__sockets`) + await expect(new Promise((resolve, reject) => { + ok.on('open', () => resolve('open')) + ok.on('error', reject) + })).resolves.toBe('open') + ok.close() + } + finally { + await handle.close() + } + }) + + it('ws config: dedicated port binds a separate socket server', async () => { + const devframe = defineDevframe({ + id: 'devframe-ws-port', + name: 'WS Port', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19870, host }) + const wsPort = await getPort({ port: 19871, host }) + const handle = await createDevServer(devframe, { + host, + port, + openBrowser: false, + ws: { port: wsPort }, + }) + + try { + const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json() + expect(meta).toEqual({ + backend: 'websocket', + websocket: { port: wsPort, path: '__devframe_ws' }, + }) + + // The socket is reachable on its own port, rooted at `/`. + const ok = new WebSocket(`ws://${host}:${wsPort}/__devframe_ws`) + await expect(new Promise((resolve, reject) => { + ok.on('open', () => resolve('open')) + ok.on('error', reject) + })).resolves.toBe('open') + ok.close() + + // Nothing on the HTTP port handles upgrades in this mode. + const httpAddr = handle.port + expect(httpAddr).toBe(port) + } + finally { + await handle.close() + } + }) + + it('ws config: remote url is advertised verbatim', async () => { + const devframe = defineDevframe({ + id: 'devframe-ws-remote', + name: 'WS Remote', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + cli: { ws: { url: 'wss://devtools.example.com/relay/__devframe_ws' } }, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19860, host }) + const handle = await createDevServer(devframe, { host, port, openBrowser: false }) + + try { + const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json() + expect(meta).toEqual({ + backend: 'websocket', + websocket: 'wss://devtools.example.com/relay/__devframe_ws', + }) } finally { await handle.close() @@ -72,7 +221,7 @@ describe('adapters/dev', () => { const res = await fetch(`http://${host}:${port}/__connection.json`) expect(res.ok).toBe(true) const meta = await res.json() - expect(meta).toEqual({ backend: 'websocket', websocket: port }) + expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__devframe_ws' } }) // The SPA mount is absent — without a distDir, no static handler // is wired, so the basePath returns a 404 from h3 instead of an diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 1c0ec53..875bc39 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -1,12 +1,13 @@ import type { StartedServer } from '../node/server' -import type { DevframeDefinition, DevframeSetupInfo } from '../types/devframe' +import type { ConnectionMeta } from '../types/context' +import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions } from '../types/devframe' import process from 'node:process' import { open } from 'devframe/utils/open' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3 } from 'h3' import { resolve } from 'pathe' -import { DEVFRAME_CONNECTION_META_FILENAME } from '../constants' +import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants' import { createHostContext } from '../node/context' import { createH3DevframeHost } from '../node/host-h3' import { startHttpAndWs } from '../node/server' @@ -42,6 +43,12 @@ export interface CreateDevServerOptions { * `resolveBasePath(def, 'standalone')` (i.e. `def.basePath` or `/`). */ basePath?: string + /** + * Override how the browser reaches the RPC WebSocket (`def.cli?.ws`). + * See {@link DevframeWsOptions}: same-server route (default), a dedicated + * port, or a remote origin. + */ + ws?: DevframeWsOptions /** * h3 app to mount the SPA + connection-meta routes on. When omitted * a fresh app is created. Pass a pre-configured app to attach custom @@ -141,13 +148,17 @@ export async function createDevServer( const setupInfo: DevframeSetupInfo = { flags } await def.setup(ctx, setupInfo) - // Connection meta — the SPA fetches this to discover the RPC backend. - // In dev the WS endpoint shares the HTTP port, so the client only needs - // to know it's a websocket backend bound to that same port. The path - // sits at the SPA root (next to index.html) so the deployed SPA can - // discover it via a relative `./__connection.json` fetch. + // Connection meta — the SPA fetches this to discover the RPC backend. How + // the WS endpoint is bound and advertised follows the resolved ws config: + // a same-origin route (default, proxy-safe), a dedicated port, or a remote + // origin. Both files sit at the SPA root so the deployed SPA discovers them + // via relative `./__connection.json` / `./` fetches. + const { bindPath, wsPort, meta } = resolveWsConnection(def, options, basePath) const connectionMetaPath = `${basePath}${DEVFRAME_CONNECTION_META_FILENAME}` - app.use(connectionMetaPath, () => ({ backend: 'websocket', websocket: port })) + app.use(connectionMetaPath, () => ({ + backend: 'websocket', + websocket: meta, + })) if (distDir) mountStaticHandler(app, basePath, resolve(distDir)) @@ -157,6 +168,8 @@ export async function createDevServer( host, port, app, + path: bindPath, + wsPort, auth: def.cli?.auth, onReady: async (info) => { await options.onReady?.(info) @@ -165,6 +178,36 @@ export async function createDevServer( }) } +/** + * Resolve the three WS connection scenarios from the definition / call-site + * config into a concrete server bind path, optional dedicated port, and the + * `__connection.json` descriptor the browser resolves. + */ +function resolveWsConnection( + def: DevframeDefinition, + options: CreateDevServerOptions, + basePath: string, +): { bindPath: string, wsPort: number | undefined, meta: ConnectionMeta['websocket'] } { + const ws = options.ws ?? def.cli?.ws ?? {} + // Normalize the route to a bare segment; the meta carries it relative so the + // client resolves it against its own origin (proxy-safe). + const route = (ws.route ?? DEVFRAME_WS_ROUTE).replace(/^\/+/, '') + + // (3) Remote origin — host the socket locally on the shared route, but tell + // the browser to dial the fully-qualified endpoint (a tunnel/relay) verbatim. + if (ws.url) + return { bindPath: `${basePath}${route}`, wsPort: undefined, meta: ws.url } + + // (2) Different port — a standalone socket server on its own port, rooted at + // `/`. The client targets `ws(s)://:/`. + if (ws.port != null) + return { bindPath: `/${route}`, wsPort: ws.port, meta: { port: ws.port, path: route } } + + // (1) Same server, different route (default) — share the HTTP port; advertise + // a relative same-origin path. + return { bindPath: `${basePath}${route}`, wsPort: undefined, meta: { path: route } } +} + async function maybeOpenBrowser( def: DevframeDefinition, flags: Record, diff --git a/packages/devframe/src/client/rpc-ws.test.ts b/packages/devframe/src/client/rpc-ws.test.ts new file mode 100644 index 0000000..5f848f5 --- /dev/null +++ b/packages/devframe/src/client/rpc-ws.test.ts @@ -0,0 +1,76 @@ +import type { WsUrlLocation } from './rpc-ws' +import { describe, expect, it } from 'vitest' +import { resolveWsUrl } from './rpc-ws' + +const httpLoc: WsUrlLocation = { + protocol: 'http:', + host: 'localhost:5173', + hostname: 'localhost', + href: 'http://localhost:5173/__foo/index.html', +} + +const httpsProxyLoc: WsUrlLocation = { + // A reverse proxy serves the SPA over HTTPS on a rewritten host/subpath. + protocol: 'https:', + host: 'devtools.example.com', + hostname: 'devtools.example.com', + href: 'https://devtools.example.com/app/__foo/index.html', +} + +describe('resolveWsUrl', () => { + it('resolves a relative path against the meta base, same-origin', () => { + const url = resolveWsUrl( + { path: '__devframe_ws' }, + 'http://localhost:5173/__foo/__connection.json', + httpLoc, + ) + expect(url).toBe('ws://localhost:5173/__foo/__devframe_ws') + }) + + it('follows the page origin through a proxy (host + subpath + tls)', () => { + // The server has no idea about the proxy's host — the client reuses its own. + const url = resolveWsUrl( + { path: '__devframe_ws' }, + 'https://devtools.example.com/app/__foo/__connection.json', + httpsProxyLoc, + ) + expect(url).toBe('wss://devtools.example.com/app/__foo/__devframe_ws') + }) + + it('roots an explicit-port endpoint at the page hostname (side-car)', () => { + const url = resolveWsUrl( + { port: 9777, path: '/__devframe_ws' }, + 'http://localhost:5173/__hub/__connection.json', + httpLoc, + ) + expect(url).toBe('ws://localhost:9777/__devframe_ws') + }) + + it('honors an explicit host override', () => { + const url = resolveWsUrl( + { host: 'inner:1234', path: '/__devframe_ws' }, + 'http://localhost:5173/__connection.json', + httpLoc, + ) + expect(url).toBe('ws://inner:1234/__devframe_ws') + }) + + it('keeps the legacy numeric-port form (page hostname)', () => { + expect(resolveWsUrl(9999, './', httpLoc)).toBe('ws://localhost:9999') + expect(resolveWsUrl(9999, './', httpsProxyLoc)).toBe('wss://devtools.example.com:9999') + }) + + it('uses a full ws/wss URL verbatim', () => { + expect(resolveWsUrl('wss://example.com/socket', './', httpLoc)).toBe('wss://example.com/socket') + }) + + it('swaps protocol on an http(s) URL string', () => { + expect(resolveWsUrl('http://example.com:8080/x', './', httpLoc)).toBe('ws://example.com:8080/x') + expect(resolveWsUrl('https://example.com/x', './', httpLoc)).toBe('wss://example.com/x') + }) + + it('resolves a bare path string same-origin', () => { + expect(resolveWsUrl('/socket', 'http://localhost:5173/__connection.json', httpLoc)) + .toBe('ws://localhost:5173/socket') + }) +}) diff --git a/packages/devframe/src/client/rpc-ws.ts b/packages/devframe/src/client/rpc-ws.ts index 877427b..e1b94d2 100644 --- a/packages/devframe/src/client/rpc-ws.ts +++ b/packages/devframe/src/client/rpc-ws.ts @@ -8,16 +8,85 @@ import { parseUA } from 'ua-parser-modern' export interface CreateWsRpcClientModeOptions { authToken?: string connectionMeta: ConnectionMeta + /** + * Absolute URL of where `__connection.json` was loaded from. Relative WS + * paths in the connection meta are resolved against it so the endpoint + * lands on the same origin the SPA loaded from (proxy-safe). + */ + metaBaseUrl?: string events: EventEmitter clientRpc: DevframeClientRpcHost rpcOptions?: DevframeRpcClientOptions['rpcOptions'] wsOptions?: DevframeRpcClientOptions['wsOptions'] } -function isNumeric(str: string | number | undefined) { - if (str == null) - return false - return `${+str}` === `${str}` +/** Minimal subset of `window.location` needed to resolve a WS URL. */ +export interface WsUrlLocation { + protocol: string + host: string + hostname: string + href: string +} + +/** + * Resolve a {@link ConnectionMeta.websocket} descriptor into a concrete + * `ws(s)://` URL. + * + * The object / relative-path forms connect to the page's own origin (only the + * `http`→`ws` protocol swap is applied), resolving the path against where + * `__connection.json` was loaded. This is deliberately host-agnostic so the + * connection survives a reverse proxy that changes the domain or port — the + * client trusts its own location, never a server-baked hostname. An explicit + * `port`/`host` (or a full `ws(s)://` URL string) opts into a cross-origin + * endpoint, e.g. a side-car server on its own port. + */ +export function resolveWsUrl( + websocket: ConnectionMeta['websocket'], + metaBaseUrl: string, + loc: WsUrlLocation, +): string { + const wsProtocol = loc.protocol === 'https:' ? 'wss:' : 'ws:' + const base = (() => { + try { + return new URL(metaBaseUrl, loc.href) + } + catch { + return new URL(loc.href) + } + })() + + // Object form — the proxy-flexible default. + if (websocket && typeof websocket === 'object') { + // An explicit host/port marks a cross-origin endpoint (e.g. a side-car on + // its own port): root the path at that origin, independent of where the + // meta file sits. Otherwise stay same-origin and resolve the path relative + // to the meta base so a reverse-proxied subpath is honored. + if (websocket.host != null || websocket.port != null) { + const host = websocket.host ?? `${loc.hostname}:${websocket.port}` + const target = new URL(websocket.path ?? '/', `${wsProtocol}//${host}`) + target.protocol = wsProtocol + return target.href + } + const target = new URL(websocket.path ?? '', base) + target.protocol = wsProtocol + return target.href + } + + // Legacy numeric port — page hostname, explicit port. + if (typeof websocket === 'number') + return `${wsProtocol}//${loc.hostname}:${websocket}` + + const str = websocket ?? '' + // Full WS URL — used verbatim. + if (/^wss?:\/\//i.test(str)) + return str + // HTTP(S) URL — swap to the matching WS protocol. + if (/^https?:\/\//i.test(str)) + return str.replace(/^http/i, 'ws') + // Path string — resolve same-origin against the meta base. + const target = new URL(str, base) + target.protocol = wsProtocol + return target.href } export function createWsRpcClientMode( @@ -26,6 +95,7 @@ export function createWsRpcClientMode( const { authToken, connectionMeta, + metaBaseUrl, events, clientRpc, rpcOptions = {}, @@ -34,9 +104,11 @@ export function createWsRpcClientMode( let isTrusted = false const trustedPromise = promiseWithResolver() - const url = isNumeric(connectionMeta.websocket) - ? `${location.protocol.replace('http', 'ws')}//${location.hostname}:${connectionMeta.websocket}` - : connectionMeta.websocket as string + const url = resolveWsUrl( + connectionMeta.websocket, + metaBaseUrl ?? './', + location, + ) // Build a minimal `defs` map from the connection meta so the per-call // wire serializer dispatches outgoing requests with the correct diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 55aa1ab..1dfce6f 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -227,6 +227,19 @@ export async function getDevframeRpcClient( let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || findConnectionMetaFromWindows() let resolvedBaseURL = bases[0] ?? './' + // Absolute URL of where `__connection.json` lives, used to resolve a + // relative WS path against the SPA's own origin (proxy-safe). Falls back to + // the page location when running outside a browser document. + function resolveMetaBaseUrl(): string { + const metaPath = resolveBasePath(resolvedBaseURL, DEVFRAME_CONNECTION_META_FILENAME) + try { + return new URL(metaPath, globalThis.location?.href).href + } + catch { + return metaPath + } + } + function normalizeBase(base: string): string { return base.endsWith('/') ? base : `${base}/` } @@ -306,6 +319,7 @@ export async function getDevframeRpcClient( : createWsRpcClientMode({ authToken, connectionMeta, + metaBaseUrl: resolveMetaBaseUrl(), events, clientRpc, rpcOptions: { diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index dacf603..5363b6b 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -4,6 +4,15 @@ export const DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH = '/__devframe' export const DEVFRAME_DIRNAME = '__devframe' export const DEVFRAME_CONNECTION_META_FILENAME = '__connection.json' + +/** + * Route the WebSocket RPC endpoint is bound to, relative to a devframe's + * base path. Sits next to `__connection.json` so the deployed SPA can reach + * it on the same origin it loaded from — the dev server shares one port for + * both HTTP and WS, and a host server (Vite, etc.) can mount the WS upgrade + * handler here without colliding with its own routes (HMR, asset serving). + */ +export const DEVFRAME_WS_ROUTE = '__devframe_ws' export const DEVFRAME_RPC_DUMP_MANIFEST_FILENAME = '__rpc-dump/index.json' export const DEVFRAME_DOCK_IMPORTS_FILENAME = '__client-imports.js' export const DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID = '/__devframe-client-imports.js' diff --git a/packages/devframe/src/helpers/vite.ts b/packages/devframe/src/helpers/vite.ts index 647213c..b1077c7 100644 --- a/packages/devframe/src/helpers/vite.ts +++ b/packages/devframe/src/helpers/vite.ts @@ -3,7 +3,7 @@ import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' import { resolve } from 'pathe' import { resolveBasePath } from '../adapters/_shared' import { createDevServer, resolveDevServerPort } from '../adapters/dev' -import { DEVFRAME_CONNECTION_META_FILENAME } from '../constants' +import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants' import { diagnostics } from '../node/diagnostics' export interface ViteDevBridgeOptions { @@ -110,10 +110,17 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio return } + // The side-car listens on its own port, so the browser must target that + // port explicitly (it can't reach the WS on Vite's origin). The route is + // `/__devframe_ws` — the bridge `createDevServer` mounts the SPA at `/`, so its WS + // upgrade handler is bound there. const metaPath = `${base}${DEVFRAME_CONNECTION_META_FILENAME}` server.middlewares.use(metaPath, (_req: unknown, res: any) => { res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ backend: 'websocket', websocket: port })) + res.end(JSON.stringify({ + backend: 'websocket', + websocket: { port, path: `/${DEVFRAME_WS_ROUTE}` }, + })) }) server.httpServer?.once('close', () => { diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts index 5ac1729..5fb897a 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -1,5 +1,6 @@ import type { BirpcGroup } from 'birpc' import type { DevframeNodeContext, DevframeNodeRpcSession, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' +import type { Server as NodeHttpServer } from 'node:http' import type { WebSocketServer } from 'ws' import type { RpcFunctionsHost } from './host-functions' import { AsyncLocalStorage } from 'node:async_hooks' @@ -7,7 +8,6 @@ import { createServer } from 'node:http' import { createRpcServer } from 'devframe/rpc/server' import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' import { H3, toNodeHandler } from 'h3' -import { WebSocketServer as WSServer } from 'ws' import { getInternalContext } from './hub-internals/context' export interface StartHttpAndWsOptions { @@ -20,6 +20,31 @@ export interface StartHttpAndWsOptions { * auth middleware, etc.) first. */ app?: H3 + /** + * Bind the WS endpoint to a single upgrade route (e.g. `/__devframe_ws`) instead of + * claiming every upgrade on the port. This lets the socket share a server + * with other upgrade handlers (Vite HMR, a host framework's own sockets) + * and is what the SPA's `__connection.json` points at. When omitted, the WS + * server handles every upgrade on the port (legacy behaviour). + */ + path?: string + /** + * Bind the WS endpoint on its own port instead of sharing the HTTP server's. + * The HTTP/SPA server still listens on `port`; the socket gets a dedicated + * `ws` server on `wsPort` (same `host`). Use this for the "different port" + * connection scenario. Ignored when a `server` is supplied. + */ + wsPort?: number + /** + * Mount the WS endpoint onto an existing HTTP server, sharing its port, + * rather than creating and listening on a fresh one. Use this to embed + * devframe's RPC socket inside a host server (e.g. a Vite dev server) — pair + * it with `path` so it coexists with the host's routes. The caller owns the + * server's lifecycle: {@link StartedServer.close} detaches devframe's upgrade + * listener but leaves the host server running. When set, `host`/`port` are + * only used to report the resolved origin. + */ + server?: NodeHttpServer /** * When `false`, the RPC server is started without a trust handshake. * Intended for single-user localhost tools where an auth round-trip @@ -58,8 +83,10 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise() @@ -92,8 +119,22 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise { rpcHost._emitSessionDisconnected(meta) }, @@ -122,15 +163,23 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise((resolveListen) => { - httpServer.listen(port, bindHost, () => resolveListen()) - }) + // Only start listening on a server we created. A shared server is already + // (or about to be) listening under the caller's control. + if (ownsHttpServer) { + await new Promise((resolveListen) => { + httpServer.listen(port, bindHost, () => resolveListen()) + }) + } const address = httpServer.address() const resolvedPort = typeof address === 'object' && address ? address.port : port const origin = `http://${bindHost}:${resolvedPort}` const internal = getInternalContext(context) - const wsUrl = origin.replace(/^http/, 'ws') + // Record the full WS URL (including the bound route) so consumers like the + // hub docks host can hand remote iframes a complete endpoint. A dedicated WS + // port is reflected here so the URL stays dialable. + const wsPortForUrl = separateWsPort ?? resolvedPort + const wsUrl = `ws://${bindHost}:${wsPortForUrl}${options.path ?? ''}` internal.wsEndpoint = { url: wsUrl, } @@ -145,13 +194,19 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise(r => wss.close(() => r())) - await new Promise(r => httpServer.close(() => r())) + // Leave a caller-owned server running — we only created (and listen on) + // our own. + if (ownsHttpServer) + await new Promise(r => httpServer.close(() => r())) if (getInternalContext(context).wsEndpoint?.url === wsUrl) getInternalContext(context).wsEndpoint = undefined }, diff --git a/packages/devframe/src/rpc/transports/ws-server.ts b/packages/devframe/src/rpc/transports/ws-server.ts index 833fcc2..79570ba 100644 --- a/packages/devframe/src/rpc/transports/ws-server.ts +++ b/packages/devframe/src/rpc/transports/ws-server.ts @@ -1,6 +1,8 @@ import type { BirpcGroup, ChannelOptions } from 'birpc' -import type { IncomingMessage } from 'node:http' -import type { ServerOptions as HttpsServerOptions } from 'node:https' +import type { Buffer } from 'node:buffer' +import type { Server as HttpServer, IncomingMessage } from 'node:http' +import type { Server as HttpsServer, ServerOptions as HttpsServerOptions } from 'node:https' +import type { Duplex } from 'node:stream' import type { WebSocket } from 'ws' import type { RpcFunctionDefinitionAny } from '../types' import { createServer as createHttpsServer } from 'node:https' @@ -29,12 +31,34 @@ export interface DevframeNodeRpcSessionMeta { } export interface WsRpcTransportOptions { - /** Attach to an existing WebSocketServer. When provided, `port`, `host`, and `https` are ignored. */ + /** Attach to an existing WebSocketServer. When provided, `port`, `host`, `https`, and `server` are ignored. */ wss?: WebSocketServer + /** + * Attach to an existing HTTP(S) server, sharing its port. Combine with + * `path` to bind the WS endpoint to a single route so it coexists with + * other upgrade handlers on the same server (e.g. a Vite dev server's HMR + * socket). The shared server's lifecycle is owned by the caller — closing + * this transport detaches the upgrade listener without closing the server. + */ + server?: HttpServer | HttpsServer /** Port for a newly-created WebSocketServer. */ port?: number /** Host for a newly-created WebSocketServer. Defaults to `localhost`. */ host?: string + /** + * Restrict the WS endpoint to a single upgrade route (e.g. `/__devframe_ws`). When + * sharing a server (`server` / `wss` bound to one, or `https`), non-matching + * upgrade requests are left untouched for other listeners to handle, so + * devframe's socket can sit alongside framework sockets (Vite HMR, etc.). + */ + path?: string + /** + * Destroy upgrade requests that don't match `path` instead of leaving them + * for other listeners. Enable this when devframe owns the server outright + * (nothing else handles its upgrades), so an off-route client is rejected + * promptly rather than left hanging. Default: `false` (coexist-friendly). + */ + destroyUnmatched?: boolean /** When set, a new https.Server is created and the WebSocketServer is attached to it. */ https?: HttpsServerOptions /** @@ -60,10 +84,53 @@ const EMPTY_DEFS: ReadonlyMap (p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p) + return strip(a) === strip(b) +} + +/** + * Route `upgrade` events on a shared server to `wss`, optionally filtered to a + * single `path`. Non-matching requests are left untouched so other upgrade + * listeners (e.g. a Vite dev server's HMR socket) can claim them. Returns a + * detach function that removes the listener. + */ +function routeUpgrades( + server: HttpServer | HttpsServer, + wss: WebSocketServer, + path: string | undefined, + destroyUnmatched: boolean, +): () => void { + const listener = (req: IncomingMessage, socket: Duplex, head: Buffer) => { + if (path) { + let pathname = req.url ?? '/' + try { + pathname = new URL(req.url ?? '/', 'http://localhost').pathname + } + catch {} + if (!pathMatches(pathname, path)) { + if (destroyUnmatched) + socket.destroy() + return + } + } + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req) + }) + } + server.on('upgrade', listener) + return () => server.off('upgrade', listener) +} + /** * Attach a WebSocket transport to an existing RPC group. Either pass an - * existing `WebSocketServer` via `wss`, or let this helper create one from - * `port` / `host` / `https`. + * existing `WebSocketServer` via `wss`, attach to an existing HTTP(S) `server` + * (sharing its port, optionally scoped to a `path`), or let this helper create + * a standalone server from `port` / `host` / `https`. + * + * Returns the `WebSocketServer` plus a `detach` function that removes any + * upgrade listener registered on a shared `server` (a no-op otherwise). */ export function attachWsRpcTransport< ClientFunctions extends object, @@ -71,11 +138,14 @@ export function attachWsRpcTransport< >( rpcGroup: BirpcGroup, options: WsRpcTransportOptions = {}, -): { wss: WebSocketServer } { +): { wss: WebSocketServer, detach: () => void } { const { wss: externalWss, + server, port, host = 'localhost', + path, + destroyUnmatched = false, https, onConnected = NOOP, onDisconnected = NOOP, @@ -85,16 +155,31 @@ export function attachWsRpcTransport< } = options let wss: WebSocketServer + let detach = NOOP if (externalWss) { wss = externalWss } + else if (server) { + // Share an existing HTTP(S) server's port. Route upgrades ourselves so we + // can coexist with the host's own upgrade handlers. + wss = new WebSocketServer({ noServer: true }) + detach = routeUpgrades(server, wss, path, destroyUnmatched) + } else if (https) { const httpsServer = createHttpsServer(https) - wss = new WebSocketServer({ server: httpsServer }) + if (path) { + wss = new WebSocketServer({ noServer: true }) + detach = routeUpgrades(httpsServer, wss, path, destroyUnmatched) + } + else { + wss = new WebSocketServer({ server: httpsServer }) + } httpsServer.listen(port, host) } else { - wss = new WebSocketServer({ port, host }) + // Standalone server on its own port — `ws` enforces `path` itself since + // nothing else shares this port. + wss = new WebSocketServer(path ? { port, host, path } : { port, host }) } wss.on('connection', (ws, req) => { @@ -163,5 +248,5 @@ export function attachWsRpcTransport< onConnected(ws, req, meta) }) - return { wss } + return { wss, detach } } diff --git a/packages/devframe/src/rpc/transports/ws.test.ts b/packages/devframe/src/rpc/transports/ws.test.ts index 9163a9a..65efda1 100644 --- a/packages/devframe/src/rpc/transports/ws.test.ts +++ b/packages/devframe/src/rpc/transports/ws.test.ts @@ -1,6 +1,7 @@ +import { createServer } from 'node:http' import { getPort } from 'get-port-please' import { describe, expect, it, vi } from 'vitest' -import { WebSocket } from 'ws' +import { WebSocket, WebSocketServer } from 'ws' import { createRpcClient } from '../client' import { createRpcServer } from '../server' import { createWsRpcChannel } from './ws-client' @@ -83,6 +84,53 @@ describe('devframe rpc', () => { expect(await server.broadcast.$call('hey', 'server')).toEqual(expect.arrayContaining(['hey server, I\'m client 1', 'hey server, I\'m client 2'])) }) + it('shares a server with another WS handler, scoped to its own path', async () => { + const HOST = '127.0.0.1' + const PORT = await getPort({ host: HOST, random: true }) + const httpServer = createServer() + await new Promise(r => httpServer.listen(PORT, HOST, () => r())) + + // A second WS server on the same http server, simulating a framework's own + // socket (e.g. Vite HMR). It owns a different route and must keep working. + const other = new WebSocketServer({ noServer: true }) + const otherConnections: string[] = [] + httpServer.on('upgrade', (req, socket, head) => { + const { pathname } = new URL(req.url ?? '/', 'http://localhost') + if (pathname !== '/__hmr') + return + other.handleUpgrade(req, socket, head, (ws) => { + otherConnections.push('hmr') + ws.close() + }) + }) + + const serverFunctions = { ping: () => 'pong' } + const server = createRpcServer, typeof serverFunctions>(serverFunctions) + const { wss, detach } = attachWsRpcTransport(server, { server: httpServer, path: '/__devframe_ws' }) + + try { + const client = createRpcClient>({}, { + channel: createWsRpcChannel({ url: `ws://${HOST}:${PORT}/__devframe_ws` }), + }) + expect(await client.$call('ping')).toBe('pong') + + // The co-located socket still receives its own-route connections. + const hmr = new WebSocket(`ws://${HOST}:${PORT}/__hmr`) + await new Promise((resolve, reject) => { + hmr.on('close', () => resolve()) + hmr.on('error', reject) + }) + expect(otherConnections).toEqual(['hmr']) + } + finally { + detach() + for (const c of wss.clients) c.terminate() + await new Promise(r => wss.close(() => r())) + other.close() + await new Promise(r => httpServer.close(() => r())) + } + }) + // Regression: a `jsonSerializable: true` RPC that throws used to crash the // WS serializer with DF0020 because the error envelope was strict-JSON-encoded // alongside the result path. diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index 2d1bee2..50caf5c 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -67,9 +67,42 @@ export interface DevframeNodeContext { } } +/** + * Describes where the browser client should open its RPC WebSocket. The + * object form is the proxy-flexible default: `path` is resolved relative to + * where `__connection.json` was loaded, and the connection is made to the + * page's own origin (only the `http`→`ws` / `https`→`wss` protocol swap is + * applied). This survives reverse proxies that change the host/port, because + * the client never trusts a server-baked hostname — it reuses its own. + * + * Set `port` (and/or `host`) only when the WS endpoint genuinely lives on a + * different origin than the page, e.g. a side-car server on its own port. + */ +export interface ConnectionMetaWebsocket { + /** + * Path to the WS endpoint. Relative paths (the default, e.g. `__devframe_ws`) are + * resolved against `__connection.json`'s location; absolute paths (`/__devframe_ws`) + * resolve against the page origin. + */ + path?: string + /** Override the port. Combined with the page hostname unless `host` is set. */ + port?: number + /** Override the host (`hostname[:port]`). Use for a fully cross-origin endpoint. */ + host?: string +} + export interface ConnectionMeta { backend: 'websocket' | 'static' - websocket?: number | string + /** + * WebSocket endpoint, resolved by the client into a `ws(s)://` URL: + * + * - {@link ConnectionMetaWebsocket} — the proxy-flexible default; a + * same-origin path relative to `__connection.json`. + * - `number` — a port on the page's hostname (`ws(s)://:`). + * - `string` — a full `ws://`/`wss://` URL used verbatim, an `http(s)://` + * URL with its protocol swapped, or a path resolved same-origin. + */ + websocket?: number | string | ConnectionMetaWebsocket /** * Names of RPC functions that have declared `jsonSerializable: true`. * Used by the WS / static client to dispatch the per-call wire diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 34753ec..7bceb60 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -25,6 +25,40 @@ export type DevframeDeploymentKind = 'standalone' | 'hosted' */ export type DevframeDuplicationStrategy = 'warn' | 'silent' | 'throw' | 'duplicate' +/** + * Controls where the browser opens the RPC WebSocket — advertised in + * `__connection.json` and used to bind the dev server. The three shapes map + * to the three connection scenarios; precedence is `url` > `port` > `route`: + * + * 1. **Same server, different route** (default) — leave `port`/`url` unset. + * The socket shares the HTTP server's port and binds to `route` + * (`__devframe_ws`). The client connects to its own origin, so the link + * survives a reverse proxy that rewrites the host/port/subpath. + * + * 2. **Different port** — set `port`. The socket binds on its own port on the + * same host; the client targets `ws(s)://:/`. + * + * 3. **Remote, different origin** — set `url` to a full `ws://`/`wss://` + * endpoint (e.g. a public tunnel or relay). The client uses it verbatim. + */ +export interface DevframeWsOptions { + /** + * Upgrade route segment the socket binds to and is advertised at, relative + * to the SPA base. Default: `__devframe_ws`. + */ + route?: string + /** + * Bind the socket on its own port instead of sharing the HTTP port. The + * browser connects to this port on the page's hostname. + */ + port?: number + /** + * Advertise a fixed, fully-qualified endpoint on another origin (a full + * `ws://`/`wss://` URL). Takes precedence over `port`/`route` in the meta. + */ + url?: string +} + export interface DevframeCliOptions { /** Binary name; default: the devframe's `id`. */ command?: string @@ -53,6 +87,12 @@ export interface DevframeCliOptions { auth?: boolean /** Author's SPA dist directory (served as the devframe's UI). */ distDir?: string + /** + * How the browser reaches the RPC WebSocket. Defaults to sharing the HTTP + * port on the `__devframe_ws` route. See {@link DevframeWsOptions} for the + * different-port and remote-origin variants. + */ + ws?: DevframeWsOptions /** * Capability-side CAC hook. Called with the CAC instance after the * adapter registers its built-in commands (`build` / `spa` / `mcp`) diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts index 623185f..60ed9a7 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts @@ -8,6 +8,7 @@ export interface CreateDevServerOptions { flags?: Record; distDir?: string; basePath?: string; + ws?: DevframeWsOptions; app?: H3; openBrowser?: boolean | string; onReady?: (_: { diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index b6cf894..c97c647 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -11,5 +11,6 @@ export declare const DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH: string; export declare const DEVFRAME_OTP_URL_PARAM: string; export declare const DEVFRAME_RPC_DUMP_DIRNAME: string; export declare const DEVFRAME_RPC_DUMP_MANIFEST_FILENAME: string; +export declare const DEVFRAME_WS_ROUTE: string; export declare const REMOTE_CONNECTION_KEY: string; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js index 59c320f..536a97a 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js @@ -11,5 +11,6 @@ export var DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH /* const */ export var DEVFRAME_OTP_URL_PARAM /* const */ export var DEVFRAME_RPC_DUMP_DIRNAME /* const */ export var DEVFRAME_RPC_DUMP_MANIFEST_FILENAME /* const */ +export var DEVFRAME_WS_ROUTE /* const */ export var REMOTE_CONNECTION_KEY /* const */ // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 3899ed1..de33719 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -14,6 +14,7 @@ export { AgentResourceInput } export { AgentTool } export { AgentToolInput } export { ConnectionMeta } +export { ConnectionMetaWebsocket } export { defineDevframe } export { DevframeAgentHost } export { DevframeAgentHostEvents } @@ -44,6 +45,7 @@ export { DevframeSettingsStore } export { DevframeSetupInfo } export { DevframeSpaOptions } export { DevframeViewHost } +export { DevframeWsOptions } export { EntriesToObject } export { EventEmitter } export { EventsMap } diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index e26629e..b484092 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -10,6 +10,7 @@ export { AgentResourceInput } export { AgentTool } export { AgentToolInput } export { ConnectionMeta } +export { ConnectionMetaWebsocket } export { defineDevframe } export { DevframeAgentHost } export { DevframeAgentHostEvents } @@ -40,6 +41,7 @@ export { DevframeSettingsStore } export { DevframeSetupInfo } export { DevframeSpaOptions } export { DevframeViewHost } +export { DevframeWsOptions } export { EntriesToObject } export { EventEmitter } export { EventsMap } diff --git a/tests/e2e/files-inspector-dev.spec.ts b/tests/e2e/files-inspector-dev.spec.ts index 626ffa1..197fd0f 100644 --- a/tests/e2e/files-inspector-dev.spec.ts +++ b/tests/e2e/files-inspector-dev.spec.ts @@ -5,12 +5,12 @@ const BASE = 'http://localhost:9876/__devframe-files-inspector/' test.describe('files-inspector (dev)', () => { test.beforeEach(async ({ page }) => { await page.goto(BASE) - await expect(page.locator('h1')).toHaveText('Files Inspector') + await expect(page.locator('.df-nav-brand')).toHaveText('Files Inspector') }) test('lists fixture files on home', async ({ page }) => { await expect(page.locator('section h2')).toContainText('Files') - await expect(page.locator('section h2 small')).toHaveText('(3)') + await expect(page.locator('section span[class*="df-badge-"]')).toHaveText('3') await expect(page.locator('section ul li')).toHaveText([ 'README.md', 'package.json', @@ -19,10 +19,10 @@ test.describe('files-inspector (dev)', () => { }) test('navigates to about and shows cwd', async ({ page }) => { - await page.click('a:has-text("About")') + await page.click('button:has-text("About")') await expect(page.locator('section h2')).toHaveText('About') - const cwdValue = page.locator('dt:has-text("Server cwd") + dd code') + const cwdValue = page.locator('dt:has-text("Server cwd") + dd') await expect(cwdValue).toContainText(/fixtures$/) }) }) diff --git a/tests/e2e/files-inspector-static.spec.ts b/tests/e2e/files-inspector-static.spec.ts index 2c1e24a..f0c373b 100644 --- a/tests/e2e/files-inspector-static.spec.ts +++ b/tests/e2e/files-inspector-static.spec.ts @@ -5,11 +5,11 @@ const BASE = 'http://127.0.0.1:9886/' test.describe('files-inspector (static build)', () => { test.beforeEach(async ({ page }) => { await page.goto(BASE) - await expect(page.locator('h1')).toHaveText('Files Inspector') + await expect(page.locator('.df-nav-brand')).toHaveText('Files Inspector') }) test('renders the file list from the static RPC dump', async ({ page }) => { - await expect(page.locator('section h2 small')).toHaveText('(3)') + await expect(page.locator('section span[class*="df-badge-"]')).toHaveText('3') await expect(page.locator('section ul li')).toHaveText([ 'README.md', 'package.json', @@ -18,14 +18,14 @@ test.describe('files-inspector (static build)', () => { }) test('reports static backend on the About page', async ({ page }) => { - await page.click('a:has-text("About")') + await page.click('button:has-text("About")') await expect(page.locator('section h2')).toHaveText('About') await expect( - page.locator('dt:has-text("RPC backend") + dd code'), + page.locator('dt:has-text("RPC backend") + dd'), ).toHaveText('static') await expect( - page.locator('dt:has-text("Server cwd") + dd code'), + page.locator('dt:has-text("Server cwd") + dd'), ).toContainText(/fixtures$/) }) }) diff --git a/tests/e2e/next-runtime-snapshot-dev.spec.ts b/tests/e2e/next-runtime-snapshot-dev.spec.ts index f88884b..00183ab 100644 --- a/tests/e2e/next-runtime-snapshot-dev.spec.ts +++ b/tests/e2e/next-runtime-snapshot-dev.spec.ts @@ -5,40 +5,40 @@ const BASE = 'http://localhost:9899/__next-runtime-snapshot/' test.describe('next-runtime-snapshot (dev)', () => { test.beforeEach(async ({ page }) => { await page.goto(BASE) - await expect(page.locator('h1')).toHaveText('Next Runtime Snapshot') + await expect(page.locator('.df-nav-brand')).toHaveText('Runtime Snapshot') }) test('system card populates with node + platform info', async ({ page }) => { - const systemCard = page.locator('.card').filter({ hasText: 'System' }) - await expect(systemCard.locator('.kv .v').first()).toContainText(/v\d+\.\d+/, { timeout: 10_000 }) + const systemCard = page.locator('.df-card').filter({ hasText: 'System' }) + await expect(systemCard.locator('span.font-mono').first()).toContainText(/v\d+\.\d+/, { timeout: 10_000 }) await expect(systemCard).toContainText(/pid/) await expect(systemCard).toContainText(/cwd/) }) test('memory card populates and refresh re-invokes the RPC', async ({ page }) => { - const memCard = page.locator('.card').filter({ hasText: 'Memory & Uptime' }) + const memCard = page.locator('.df-card').filter({ hasText: 'Memory & Uptime' }) await expect(memCard).toContainText(/heap used/i, { timeout: 10_000 }) - const initialRss = (await memCard.locator('.kv .v').nth(1).textContent()) ?? '' + const initialRss = (await memCard.locator('span.font-mono').nth(1).textContent()) ?? '' expect(initialRss).toMatch(/\d+(?:\.\d+)?\s*MB/) await memCard.locator('button:has-text("Refresh")').click() // After refresh the uptime row should still render — the call resolved. - await expect(memCard.locator('.kv .k').first()).toHaveText('uptime') + await expect(memCard.locator('span.text-muted-foreground').first()).toHaveText('uptime') }) test('env filter triggers a query call', async ({ page }) => { - const envCard = page.locator('.card').filter({ hasText: 'Environment' }) + const envCard = page.locator('.df-card').filter({ hasText: 'Environment' }) // Default pattern "NODE" should yield at least one row on most systems. - await expect(envCard.locator('.env-list')).toBeVisible({ timeout: 10_000 }) + await expect(envCard.locator('div.scrollbar-slim')).toBeVisible({ timeout: 10_000 }) const input = envCard.locator('input') await input.fill('___definitely_not_a_real_env_var___') - await expect(envCard.locator('.empty')).toBeVisible({ timeout: 5_000 }) - await expect(envCard.locator('.empty')).toContainText('No environment variables match') + await expect(envCard.locator('p.text-muted-foreground')).toBeVisible({ timeout: 5_000 }) + await expect(envCard.locator('p.text-muted-foreground')).toContainText('No environment variables match') }) test('status bar reports websocket backend', async ({ page }) => { - await expect(page.locator('.status code').first()).toHaveText('websocket', { timeout: 10_000 }) + await expect(page.locator('header code').first()).toHaveText('websocket', { timeout: 10_000 }) }) }) diff --git a/tests/e2e/next-runtime-snapshot-static.spec.ts b/tests/e2e/next-runtime-snapshot-static.spec.ts index a1eba1b..2160579 100644 --- a/tests/e2e/next-runtime-snapshot-static.spec.ts +++ b/tests/e2e/next-runtime-snapshot-static.spec.ts @@ -11,16 +11,16 @@ const BASE = 'http://127.0.0.1:9889/' test.describe('next-runtime-snapshot (static build)', () => { test.beforeEach(async ({ page }) => { await page.goto(BASE) - await expect(page.locator('h1')).toHaveText('Next Runtime Snapshot') + await expect(page.locator('.df-nav-brand')).toHaveText('Runtime Snapshot') }) test('renders system info from the static RPC dump', async ({ page }) => { - const systemCard = page.locator('.card').filter({ hasText: 'System' }) - await expect(systemCard.locator('.kv .v').first()).toContainText(/v\d+\.\d+/, { timeout: 10_000 }) + const systemCard = page.locator('.df-card').filter({ hasText: 'System' }) + await expect(systemCard.locator('span.font-mono').first()).toContainText(/v\d+\.\d+/, { timeout: 10_000 }) await expect(systemCard).toContainText(/cwd/) }) test('reports static backend in the status bar', async ({ page }) => { - await expect(page.locator('.status code').first()).toHaveText('static', { timeout: 10_000 }) + await expect(page.locator('header code').first()).toHaveText('static', { timeout: 10_000 }) }) }) diff --git a/tests/e2e/streaming-chat-dev.spec.ts b/tests/e2e/streaming-chat-dev.spec.ts index d7e204d..33295dc 100644 --- a/tests/e2e/streaming-chat-dev.spec.ts +++ b/tests/e2e/streaming-chat-dev.spec.ts @@ -10,38 +10,38 @@ test.describe.configure({ mode: 'serial' }) test.describe('streaming-chat (dev)', () => { test.beforeEach(async ({ page }) => { await page.goto(BASE) - await expect(page.locator('h1')).toHaveText('Streaming Chat') - await expect(page.locator('.demo-prompts button').first()).toBeVisible() + await expect(page.locator('.df-nav-brand')).toHaveText('Streaming Chat') + await expect(page.locator('div.flex-wrap button').first()).toBeVisible() - const clearBtn = page.locator('.toolbar button:has-text("Clear")') + const clearBtn = page.locator('header button:has-text("Clear")') if (await clearBtn.isEnabled()) await clearBtn.click() - await expect(page.locator('.msg')).toHaveCount(0) + await expect(page.locator('div[data-role]')).toHaveCount(0) }) test('demo prompt streams tokens into a message', async ({ page }) => { - await page.click('.demo-prompts button:has-text("Write a haiku about RPC.")') + await page.click('div.flex-wrap button:has-text("Write a haiku about RPC.")') - await expect(page.locator('.msg-user').last()) + await expect(page.locator('div[data-role="user"]').last()) .toHaveText('Write a haiku about RPC.') - await expect(page.locator('.msg-assistant').last()) + await expect(page.locator('div[data-role="assistant"]').last()) .toContainText('Tiny chunks arrive', { timeout: 10_000 }) - await expect(page.locator('.msg-assistant.streaming')).toHaveCount(0, { + await expect(page.locator('div[data-role="assistant"][data-streaming="true"]')).toHaveCount(0, { timeout: 10_000, }) }) test('clear button resets history', async ({ page }) => { - await page.click('.demo-prompts button:has-text("Write a haiku about RPC.")') - await expect(page.locator('.msg-assistant').last()) + await page.click('div.flex-wrap button:has-text("Write a haiku about RPC.")') + await expect(page.locator('div[data-role="assistant"]').last()) .toContainText('Tiny chunks arrive', { timeout: 10_000 }) - await expect(page.locator('.msg-assistant.streaming')).toHaveCount(0) + await expect(page.locator('div[data-role="assistant"][data-streaming="true"]')).toHaveCount(0) - await page.locator('.toolbar button:has-text("Clear")').click() + await page.locator('header button:has-text("Clear")').click() - await expect(page.locator('.msg')).toHaveCount(0) - await expect(page.locator('.status')).toContainText('0 messages') + await expect(page.locator('div[data-role]')).toHaveCount(0) + await expect(page.locator('[data-testid="status"]')).toContainText('0 messages') }) }) diff --git a/tests/e2e/streaming-chat-static.spec.ts b/tests/e2e/streaming-chat-static.spec.ts index 1867c8f..946cfdf 100644 --- a/tests/e2e/streaming-chat-static.spec.ts +++ b/tests/e2e/streaming-chat-static.spec.ts @@ -10,11 +10,11 @@ const BASE = 'http://127.0.0.1:9898/' test.describe('streaming-chat (static build)', () => { test.beforeEach(async ({ page }) => { await page.goto(BASE) - await expect(page.locator('h1')).toHaveText('Streaming Chat') + await expect(page.locator('.df-nav-brand')).toHaveText('Streaming Chat') }) test('renders demo prompts from the static RPC dump', async ({ page }) => { - const prompts = page.locator('.demo-prompts button') + const prompts = page.locator('div.flex-wrap button') await expect(prompts).toHaveCount(3) await expect(prompts).toHaveText([ 'Tell me about devframe.', @@ -24,7 +24,7 @@ test.describe('streaming-chat (static build)', () => { }) test('reports static backend in the status bar', async ({ page }) => { - await expect(page.locator('.status code').first()).toHaveText('static') - await expect(page.locator('.status')).toContainText('0 messages') + await expect(page.locator('[data-testid="status"] code').first()).toHaveText('static') + await expect(page.locator('[data-testid="status"]')).toContainText('0 messages') }) })