Skip to content
Merged
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
26 changes: 26 additions & 0 deletions docs/adapters/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <route> }` (same origin) |
| `port` | different port | `{ port, path: <route> }` (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:
Expand Down
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
4 changes: 2 additions & 2 deletions examples/streaming-chat/src/client/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export function App() {
)}
</form>

<div class="text-xs text-muted-foreground">
<div class="text-xs text-muted-foreground" data-testid="status">
backend:
{' '}
<code class="font-mono text-foreground">{ctx.base.connectionMeta.backend}</code>
Expand Down Expand Up @@ -299,7 +299,7 @@ function Message({ msg, live }: { msg: ChatMessage, live: string | undefined })
)

return (
<div class={cls}>
<div class={cls} data-role={msg.role} data-streaming={msg.streamId !== undefined ? 'true' : undefined}>
{displayed || (msg.streamId ? '' : '(empty)')}
{/* Live "typing" indicator while the producer is still streaming tokens. */}
{msg.streamId && <span class={spinner('ml-1 size-3! align-[-0.2em]')} />}
Expand Down
153 changes: 151 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,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 `/<route>`.
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()
Expand Down Expand Up @@ -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
Expand Down
59 changes: 51 additions & 8 deletions packages/devframe/src/adapters/dev.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` / `./<route>` 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))
Expand All @@ -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)
Expand All @@ -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
// `/<route>`. The client targets `ws(s)://<page-host>:<port>/<route>`.
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<string, unknown>,
Expand Down
Loading
Loading