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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions docs/guide/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,24 @@ With caching on, `query` / `static` function responses are memoized per argument

## Discovery (`__connection.json`)

Devframe writes a JSON descriptor at `<base>/__connection.json` so the client knows where to connect:
Devframe writes a JSON descriptor at `<base>/__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 (`<base>__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)://<host>:<port>`).
- 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" }
Expand Down
4 changes: 3 additions & 1 deletion docs/helpers/vite-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export default defineConfig({
## Modes

- **Static mount** (default) — mounts `def.cli.distDir` at `options.base` (`/__<id>/` 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 `<base>__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 `<base>__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

Expand Down
51 changes: 49 additions & 2 deletions packages/devframe/src/adapters/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -41,7 +42,53 @@ 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()
Expand Down Expand Up @@ -72,7 +119,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
Expand Down
19 changes: 13 additions & 6 deletions packages/devframe/src/adapters/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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'
Expand Down Expand Up @@ -142,12 +142,18 @@ export async function createDevServer(
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.
// In dev the WS endpoint shares the HTTP port and is bound to a route next
// to `__connection.json` (`<basePath>__devframe_ws`). The meta points at it with a
// *relative* path so the client connects to its own origin — surviving a
// reverse proxy that rewrites the host/port. Both files sit at the SPA root
// so the deployed SPA discovers them via relative `./__connection.json` /
// `./__devframe_ws` fetches.
const connectionMetaPath = `${basePath}${DEVFRAME_CONNECTION_META_FILENAME}`
app.use(connectionMetaPath, () => ({ backend: 'websocket', websocket: port }))
const wsRoute = `${basePath}${DEVFRAME_WS_ROUTE}`
app.use(connectionMetaPath, () => ({
backend: 'websocket',
websocket: { path: DEVFRAME_WS_ROUTE },
}))

if (distDir)
mountStaticHandler(app, basePath, resolve(distDir))
Expand All @@ -157,6 +163,7 @@ export async function createDevServer(
host,
port,
app,
path: wsRoute,
auth: def.cli?.auth,
onReady: async (info) => {
await options.onReady?.(info)
Expand Down
76 changes: 76 additions & 0 deletions packages/devframe/src/client/rpc-ws.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
86 changes: 79 additions & 7 deletions packages/devframe/src/client/rpc-ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RpcClientEvents>
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(
Expand All @@ -26,6 +95,7 @@ export function createWsRpcClientMode(
const {
authToken,
connectionMeta,
metaBaseUrl,
events,
clientRpc,
rpcOptions = {},
Expand All @@ -34,9 +104,11 @@ export function createWsRpcClientMode(

let isTrusted = false
const trustedPromise = promiseWithResolver<boolean>()
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
Expand Down
14 changes: 14 additions & 0 deletions packages/devframe/src/client/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}/`
}
Expand Down Expand Up @@ -306,6 +319,7 @@ export async function getDevframeRpcClient(
: createWsRpcClientMode({
authToken,
connectionMeta,
metaBaseUrl: resolveMetaBaseUrl(),
events,
clientRpc,
rpcOptions: {
Expand Down
9 changes: 9 additions & 0 deletions packages/devframe/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading