Skip to content

Commit 726bb43

Browse files
committed
feat(devframe): proxy-flexible, route-bound WebSocket endpoint
1 parent 6bbe9d2 commit 726bb43

17 files changed

Lines changed: 491 additions & 42 deletions

File tree

docs/guide/client.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,24 @@ With caching on, `query` / `static` function responses are memoized per argument
183183

184184
## Discovery (`__connection.json`)
185185

186-
Devframe writes a JSON descriptor at `<base>/__connection.json` so the client knows where to connect:
186+
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:
187187

188188
```json
189189
{
190190
"backend": "websocket",
191-
"websocket": "ws://localhost:9999/__ws"
191+
"websocket": { "path": "__devframe_ws" }
192192
}
193193
```
194194

195-
or for static mode:
195+
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.
196+
197+
The `websocket` field also accepts:
198+
199+
- A `number` — a port on the page's hostname (`ws(s)://<host>:<port>`).
200+
- A full `ws://`/`wss://` URL string — used verbatim for a fixed cross-origin endpoint.
201+
- `{ 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.
202+
203+
For static mode:
196204

197205
```json
198206
{ "backend": "static" }

docs/helpers/vite-bridge.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ export default defineConfig({
2121
## Modes
2222

2323
- **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.
24-
- **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.
24+
- **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.
25+
26+
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.
2527

2628
## Options
2729

packages/devframe/src/adapters/__tests__/dev.test.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
33
import { join } from 'node:path'
44
import { getPort } from 'get-port-please'
55
import { describe, expect, it } from 'vitest'
6+
import { WebSocket } from 'ws'
67
import { defineDevframe } from '../../types/devframe'
78
import { createDevServer, resolveDevServerPort } from '../dev'
89

@@ -41,7 +42,53 @@ describe('adapters/dev', () => {
4142
const res = await fetch(`http://${host}:${port}/__connection.json`)
4243
expect(res.ok).toBe(true)
4344
const meta = await res.json()
44-
expect(meta).toEqual({ backend: 'websocket', websocket: port })
45+
// Proxy-safe: the WS endpoint is advertised as a same-origin route
46+
// relative to `__connection.json`, never a baked-in host/port.
47+
expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__devframe_ws' } })
48+
}
49+
finally {
50+
await handle.close()
51+
}
52+
})
53+
54+
it('createDevServer binds the WS endpoint to the advertised route', async () => {
55+
const distDir = makeTmpDist()
56+
const devframe = defineDevframe({
57+
id: 'devframe-test-ws',
58+
name: 'Devframe WS',
59+
version: '0.0.0',
60+
packageName: 'devframe-test',
61+
homepage: 'https://example.test',
62+
description: 'Test devframe.',
63+
setup: () => {},
64+
})
65+
66+
const host = '127.0.0.1'
67+
const port = await getPort({ port: 19899, host })
68+
const handle = await createDevServer(devframe, {
69+
host,
70+
port,
71+
distDir,
72+
openBrowser: false,
73+
})
74+
75+
try {
76+
// Connects on the bound route.
77+
const ok = new WebSocket(`ws://${host}:${port}/__devframe_ws`)
78+
await expect(new Promise((resolve, reject) => {
79+
ok.on('open', () => resolve('open'))
80+
ok.on('error', reject)
81+
})).resolves.toBe('open')
82+
ok.close()
83+
84+
// A connection off-route is left unhandled (no upgrade handler claims
85+
// it) and the socket is closed without an open event.
86+
const off = new WebSocket(`ws://${host}:${port}/not-the-ws-route`)
87+
await expect(new Promise((resolve, reject) => {
88+
off.on('open', () => reject(new Error('should not open off-route')))
89+
off.on('close', () => resolve('closed'))
90+
off.on('error', () => resolve('closed'))
91+
})).resolves.toBe('closed')
4592
}
4693
finally {
4794
await handle.close()
@@ -72,7 +119,7 @@ describe('adapters/dev', () => {
72119
const res = await fetch(`http://${host}:${port}/__connection.json`)
73120
expect(res.ok).toBe(true)
74121
const meta = await res.json()
75-
expect(meta).toEqual({ backend: 'websocket', websocket: port })
122+
expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__devframe_ws' } })
76123

77124
// The SPA mount is absent — without a distDir, no static handler
78125
// is wired, so the basePath returns a 404 from h3 instead of an

packages/devframe/src/adapters/dev.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { mountStaticHandler } from 'devframe/utils/serve-static'
66
import { getPort } from 'get-port-please'
77
import { H3 } from 'h3'
88
import { resolve } from 'pathe'
9-
import { DEVFRAME_CONNECTION_META_FILENAME } from '../constants'
9+
import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants'
1010
import { createHostContext } from '../node/context'
1111
import { createH3DevframeHost } from '../node/host-h3'
1212
import { startHttpAndWs } from '../node/server'
@@ -142,12 +142,18 @@ export async function createDevServer(
142142
await def.setup(ctx, setupInfo)
143143

144144
// Connection meta — the SPA fetches this to discover the RPC backend.
145-
// In dev the WS endpoint shares the HTTP port, so the client only needs
146-
// to know it's a websocket backend bound to that same port. The path
147-
// sits at the SPA root (next to index.html) so the deployed SPA can
148-
// discover it via a relative `./__connection.json` fetch.
145+
// In dev the WS endpoint shares the HTTP port and is bound to a route next
146+
// to `__connection.json` (`<basePath>__devframe_ws`). The meta points at it with a
147+
// *relative* path so the client connects to its own origin — surviving a
148+
// reverse proxy that rewrites the host/port. Both files sit at the SPA root
149+
// so the deployed SPA discovers them via relative `./__connection.json` /
150+
// `./__devframe_ws` fetches.
149151
const connectionMetaPath = `${basePath}${DEVFRAME_CONNECTION_META_FILENAME}`
150-
app.use(connectionMetaPath, () => ({ backend: 'websocket', websocket: port }))
152+
const wsRoute = `${basePath}${DEVFRAME_WS_ROUTE}`
153+
app.use(connectionMetaPath, () => ({
154+
backend: 'websocket',
155+
websocket: { path: DEVFRAME_WS_ROUTE },
156+
}))
151157

152158
if (distDir)
153159
mountStaticHandler(app, basePath, resolve(distDir))
@@ -157,6 +163,7 @@ export async function createDevServer(
157163
host,
158164
port,
159165
app,
166+
path: wsRoute,
160167
auth: def.cli?.auth,
161168
onReady: async (info) => {
162169
await options.onReady?.(info)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { WsUrlLocation } from './rpc-ws'
2+
import { describe, expect, it } from 'vitest'
3+
import { resolveWsUrl } from './rpc-ws'
4+
5+
const httpLoc: WsUrlLocation = {
6+
protocol: 'http:',
7+
host: 'localhost:5173',
8+
hostname: 'localhost',
9+
href: 'http://localhost:5173/__foo/index.html',
10+
}
11+
12+
const httpsProxyLoc: WsUrlLocation = {
13+
// A reverse proxy serves the SPA over HTTPS on a rewritten host/subpath.
14+
protocol: 'https:',
15+
host: 'devtools.example.com',
16+
hostname: 'devtools.example.com',
17+
href: 'https://devtools.example.com/app/__foo/index.html',
18+
}
19+
20+
describe('resolveWsUrl', () => {
21+
it('resolves a relative path against the meta base, same-origin', () => {
22+
const url = resolveWsUrl(
23+
{ path: '__devframe_ws' },
24+
'http://localhost:5173/__foo/__connection.json',
25+
httpLoc,
26+
)
27+
expect(url).toBe('ws://localhost:5173/__foo/__devframe_ws')
28+
})
29+
30+
it('follows the page origin through a proxy (host + subpath + tls)', () => {
31+
// The server has no idea about the proxy's host — the client reuses its own.
32+
const url = resolveWsUrl(
33+
{ path: '__devframe_ws' },
34+
'https://devtools.example.com/app/__foo/__connection.json',
35+
httpsProxyLoc,
36+
)
37+
expect(url).toBe('wss://devtools.example.com/app/__foo/__devframe_ws')
38+
})
39+
40+
it('roots an explicit-port endpoint at the page hostname (side-car)', () => {
41+
const url = resolveWsUrl(
42+
{ port: 9777, path: '/__devframe_ws' },
43+
'http://localhost:5173/__hub/__connection.json',
44+
httpLoc,
45+
)
46+
expect(url).toBe('ws://localhost:9777/__devframe_ws')
47+
})
48+
49+
it('honors an explicit host override', () => {
50+
const url = resolveWsUrl(
51+
{ host: 'inner:1234', path: '/__devframe_ws' },
52+
'http://localhost:5173/__connection.json',
53+
httpLoc,
54+
)
55+
expect(url).toBe('ws://inner:1234/__devframe_ws')
56+
})
57+
58+
it('keeps the legacy numeric-port form (page hostname)', () => {
59+
expect(resolveWsUrl(9999, './', httpLoc)).toBe('ws://localhost:9999')
60+
expect(resolveWsUrl(9999, './', httpsProxyLoc)).toBe('wss://devtools.example.com:9999')
61+
})
62+
63+
it('uses a full ws/wss URL verbatim', () => {
64+
expect(resolveWsUrl('wss://example.com/socket', './', httpLoc)).toBe('wss://example.com/socket')
65+
})
66+
67+
it('swaps protocol on an http(s) URL string', () => {
68+
expect(resolveWsUrl('http://example.com:8080/x', './', httpLoc)).toBe('ws://example.com:8080/x')
69+
expect(resolveWsUrl('https://example.com/x', './', httpLoc)).toBe('wss://example.com/x')
70+
})
71+
72+
it('resolves a bare path string same-origin', () => {
73+
expect(resolveWsUrl('/socket', 'http://localhost:5173/__connection.json', httpLoc))
74+
.toBe('ws://localhost:5173/socket')
75+
})
76+
})

packages/devframe/src/client/rpc-ws.ts

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,85 @@ import { parseUA } from 'ua-parser-modern'
88
export interface CreateWsRpcClientModeOptions {
99
authToken?: string
1010
connectionMeta: ConnectionMeta
11+
/**
12+
* Absolute URL of where `__connection.json` was loaded from. Relative WS
13+
* paths in the connection meta are resolved against it so the endpoint
14+
* lands on the same origin the SPA loaded from (proxy-safe).
15+
*/
16+
metaBaseUrl?: string
1117
events: EventEmitter<RpcClientEvents>
1218
clientRpc: DevframeClientRpcHost
1319
rpcOptions?: DevframeRpcClientOptions['rpcOptions']
1420
wsOptions?: DevframeRpcClientOptions['wsOptions']
1521
}
1622

17-
function isNumeric(str: string | number | undefined) {
18-
if (str == null)
19-
return false
20-
return `${+str}` === `${str}`
23+
/** Minimal subset of `window.location` needed to resolve a WS URL. */
24+
export interface WsUrlLocation {
25+
protocol: string
26+
host: string
27+
hostname: string
28+
href: string
29+
}
30+
31+
/**
32+
* Resolve a {@link ConnectionMeta.websocket} descriptor into a concrete
33+
* `ws(s)://` URL.
34+
*
35+
* The object / relative-path forms connect to the page's own origin (only the
36+
* `http`→`ws` protocol swap is applied), resolving the path against where
37+
* `__connection.json` was loaded. This is deliberately host-agnostic so the
38+
* connection survives a reverse proxy that changes the domain or port — the
39+
* client trusts its own location, never a server-baked hostname. An explicit
40+
* `port`/`host` (or a full `ws(s)://` URL string) opts into a cross-origin
41+
* endpoint, e.g. a side-car server on its own port.
42+
*/
43+
export function resolveWsUrl(
44+
websocket: ConnectionMeta['websocket'],
45+
metaBaseUrl: string,
46+
loc: WsUrlLocation,
47+
): string {
48+
const wsProtocol = loc.protocol === 'https:' ? 'wss:' : 'ws:'
49+
const base = (() => {
50+
try {
51+
return new URL(metaBaseUrl, loc.href)
52+
}
53+
catch {
54+
return new URL(loc.href)
55+
}
56+
})()
57+
58+
// Object form — the proxy-flexible default.
59+
if (websocket && typeof websocket === 'object') {
60+
// An explicit host/port marks a cross-origin endpoint (e.g. a side-car on
61+
// its own port): root the path at that origin, independent of where the
62+
// meta file sits. Otherwise stay same-origin and resolve the path relative
63+
// to the meta base so a reverse-proxied subpath is honored.
64+
if (websocket.host != null || websocket.port != null) {
65+
const host = websocket.host ?? `${loc.hostname}:${websocket.port}`
66+
const target = new URL(websocket.path ?? '/', `${wsProtocol}//${host}`)
67+
target.protocol = wsProtocol
68+
return target.href
69+
}
70+
const target = new URL(websocket.path ?? '', base)
71+
target.protocol = wsProtocol
72+
return target.href
73+
}
74+
75+
// Legacy numeric port — page hostname, explicit port.
76+
if (typeof websocket === 'number')
77+
return `${wsProtocol}//${loc.hostname}:${websocket}`
78+
79+
const str = websocket ?? ''
80+
// Full WS URL — used verbatim.
81+
if (/^wss?:\/\//i.test(str))
82+
return str
83+
// HTTP(S) URL — swap to the matching WS protocol.
84+
if (/^https?:\/\//i.test(str))
85+
return str.replace(/^http/i, 'ws')
86+
// Path string — resolve same-origin against the meta base.
87+
const target = new URL(str, base)
88+
target.protocol = wsProtocol
89+
return target.href
2190
}
2291

2392
export function createWsRpcClientMode(
@@ -26,6 +95,7 @@ export function createWsRpcClientMode(
2695
const {
2796
authToken,
2897
connectionMeta,
98+
metaBaseUrl,
2999
events,
30100
clientRpc,
31101
rpcOptions = {},
@@ -34,9 +104,11 @@ export function createWsRpcClientMode(
34104

35105
let isTrusted = false
36106
const trustedPromise = promiseWithResolver<boolean>()
37-
const url = isNumeric(connectionMeta.websocket)
38-
? `${location.protocol.replace('http', 'ws')}//${location.hostname}:${connectionMeta.websocket}`
39-
: connectionMeta.websocket as string
107+
const url = resolveWsUrl(
108+
connectionMeta.websocket,
109+
metaBaseUrl ?? './',
110+
location,
111+
)
40112

41113
// Build a minimal `defs` map from the connection meta so the per-call
42114
// wire serializer dispatches outgoing requests with the correct

packages/devframe/src/client/rpc.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,19 @@ export async function getDevframeRpcClient(
227227
let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || findConnectionMetaFromWindows()
228228
let resolvedBaseURL = bases[0] ?? './'
229229

230+
// Absolute URL of where `__connection.json` lives, used to resolve a
231+
// relative WS path against the SPA's own origin (proxy-safe). Falls back to
232+
// the page location when running outside a browser document.
233+
function resolveMetaBaseUrl(): string {
234+
const metaPath = resolveBasePath(resolvedBaseURL, DEVFRAME_CONNECTION_META_FILENAME)
235+
try {
236+
return new URL(metaPath, globalThis.location?.href).href
237+
}
238+
catch {
239+
return metaPath
240+
}
241+
}
242+
230243
function normalizeBase(base: string): string {
231244
return base.endsWith('/') ? base : `${base}/`
232245
}
@@ -306,6 +319,7 @@ export async function getDevframeRpcClient(
306319
: createWsRpcClientMode({
307320
authToken,
308321
connectionMeta,
322+
metaBaseUrl: resolveMetaBaseUrl(),
309323
events,
310324
clientRpc,
311325
rpcOptions: {

packages/devframe/src/constants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ export const DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH = '/__devframe'
44
export const DEVFRAME_DIRNAME = '__devframe'
55

66
export const DEVFRAME_CONNECTION_META_FILENAME = '__connection.json'
7+
8+
/**
9+
* Route the WebSocket RPC endpoint is bound to, relative to a devframe's
10+
* base path. Sits next to `__connection.json` so the deployed SPA can reach
11+
* it on the same origin it loaded from — the dev server shares one port for
12+
* both HTTP and WS, and a host server (Vite, etc.) can mount the WS upgrade
13+
* handler here without colliding with its own routes (HMR, asset serving).
14+
*/
15+
export const DEVFRAME_WS_ROUTE = '__devframe_ws'
716
export const DEVFRAME_RPC_DUMP_MANIFEST_FILENAME = '__rpc-dump/index.json'
817
export const DEVFRAME_DOCK_IMPORTS_FILENAME = '__client-imports.js'
918
export const DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID = '/__devframe-client-imports.js'

0 commit comments

Comments
 (0)