Skip to content

Commit 476f228

Browse files
authored
feat(vite,next): forward allowedOrigins through bridge and Next handler (#350)
1 parent e3af508 commit 476f228

9 files changed

Lines changed: 85 additions & 1 deletion

File tree

docs/content/3.frameworks/1.vite.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Devframe spawns a separate RPC + WS server and registers Vite middleware at `<ba
4343
| `flags` | none | To `def.setup(ctx, { flags })`. |
4444
| `auth` | gated (interactive OTP) | `false` to opt out, or a `DevframeAuthHandler` for a custom scheme. |
4545
| `mcp` | `'auto'` | Expose the MCP route at `<base>__mcp`. `'auto'` mounts once agent tools exist; `true` forces the origin-only route on (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. |
46+
| `allowedOrigins` | loopback-only | Widen the WS origin check for a `vite --host` / container / tunnel origin: extra origins, a `WsOriginRegistry`, or `false` to disable (the auth gate stays the trust boundary). |
4647

4748
## `devframeVite`: convenience wrapper
4849

docs/content/3.frameworks/3.next.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export const GET = handler.fetch
4949
| `flags` | none | Passed to `def.setup(ctx, { flags })`. |
5050
| `auth` | `false` | `true` for the OTP gate, or a handler. |
5151
| `mcp` | `'auto'` | Expose the MCP route. `'auto'` mounts once agent tools exist; `true` forces the origin-only route on (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. |
52+
| `allowedOrigins` | loopback-only | Widen the side-car WS origin check for a remotely-accessed dev server (container / Codespace / tunnel): extra origins, a `WsOriginRegistry`, or `false` to disable (the auth gate stays the trust boundary). |
5253
| `key` | `@devframes/next:<id>:<base>` | `globalThis` memoization key. |
5354

5455
## Hosting a hub

packages/next/src/handler.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ export interface CreateDevframeNextHandlerOptions {
2727
* or a handler for a custom scheme.
2828
*/
2929
auth?: InitDevframeOptions['auth']
30+
/**
31+
* Widen the side-car WebSocket origin check beyond devframe's
32+
* loopback-only default. Reaching a remotely-accessed Next dev server
33+
* (containers, Codespaces, tunnels) needs the app's own origin allowed.
34+
* Pass extra origins, a `WsOriginRegistry`, or `false` to disable the
35+
* check (safe when the auth gate owns the trust boundary). Forwarded
36+
* verbatim to `initDevframe`.
37+
*/
38+
allowedOrigins?: InitDevframeOptions['allowedOrigins']
3039
/** Origin the Next app is reachable at, for docks needing an absolute URL. */
3140
resolveOrigin?: () => string
3241
/** Override where persisted devframe state lives (defaults under the cwd / home). */
@@ -139,6 +148,7 @@ export function createDevframeNextHandler(
139148
*/
140149
auth: options.auth,
141150
mcp: options.mcp,
151+
allowedOrigins: options.allowedOrigins,
142152
/**
143153
* Next's route handlers never see WebSocket upgrades, so the RPC socket
144154
* lives on a side-car server (on `options.port` when pinned, otherwise

packages/vite/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,12 @@
6161
"@devframes/hub-ui": "workspace:*",
6262
"@modelcontextprotocol/client": "catalog:deps",
6363
"@types/node": "catalog:types",
64+
"@types/ws": "catalog:types",
6465
"devframe": "workspace:*",
6566
"get-port-please": "catalog:deps",
6667
"tsdown": "catalog:build",
6768
"vite": "catalog:build",
68-
"vitest": "catalog:testing"
69+
"vitest": "catalog:testing",
70+
"ws": "catalog:deps"
6971
}
7072
}

packages/vite/src/single.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DevframeDefinition, McpSetting } from 'devframe'
22
import type { DevframeInstance } from 'devframe/initiate'
33
import type { DevframeAuthHandler } from 'devframe/node/auth'
4+
import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'
45
import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http'
56
import type { Plugin } from 'vite'
67
import process from 'node:process'
@@ -119,6 +120,16 @@ export interface DevframeViteBridgeOptions {
119120
* surface is non-empty); `false` disables the route regardless.
120121
*/
121122
mcp?: McpSetting
123+
/**
124+
* Widen the WebSocket origin check beyond devframe's loopback-only
125+
* default. A bridge serves the tool same-origin with the host Vite app,
126+
* so reaching it from a non-loopback origin (`vite --host`, containers,
127+
* Codespaces, tunnels) needs the dev server's own origin allowed. Pass
128+
* extra origins, a {@link WsOriginRegistry}, or `false` to disable the
129+
* check (safe when the bridge's auth gate owns the trust boundary).
130+
* Forwarded verbatim to `initDevframe`.
131+
*/
132+
allowedOrigins?: readonly string[] | WsOriginRegistry | false
122133
}
123134

124135
/**
@@ -185,6 +196,7 @@ export function devframeViteBridge(d: DevframeDefinition, options: DevframeViteB
185196
*/
186197
auth: options.auth,
187198
mcp: options.mcp,
199+
allowedOrigins: options.allowedOrigins,
188200
})
189201
server.middlewares.use(created.nodeMiddleware)
190202
await created.ready

packages/vite/test/single.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { createRpcClient } from 'devframe/rpc/client'
1010
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
1111
import { getPort } from 'get-port-please'
1212
import { afterEach, describe, expect, it } from 'vitest'
13+
import { WebSocket } from 'ws'
1314
import { devframeVite, devframeViteBridge, devframeVitePlugin } from '../src/single'
1415

1516
function defineTestDef(overrides: Partial<DevframeDefinition> = {}): DevframeDefinition {
@@ -243,6 +244,55 @@ describe('devframeViteBridge (auth default)', () => {
243244
})
244245
})
245246

247+
describe('devframeViteBridge (allowedOrigins)', () => {
248+
let bridge: ReturnType<typeof devframeViteBridge> | undefined
249+
let vite: FakeViteServer | undefined
250+
251+
afterEach(async () => {
252+
await bridge?.closeBundle?.()
253+
bridge = undefined
254+
vite?.close()
255+
vite = undefined
256+
})
257+
258+
/** Attempt a raw WS upgrade carrying a spoofed browser Origin header. */
259+
async function upgradeWithOrigin(port: number, origin: string): Promise<'open' | 'closed'> {
260+
return await new Promise((resolve) => {
261+
const ws = new WebSocket(`ws://127.0.0.1:${port}/__ws`, { headers: { origin } })
262+
ws.on('open', () => {
263+
ws.close()
264+
resolve('open')
265+
})
266+
ws.on('error', () => resolve('closed'))
267+
ws.on('unexpected-response', () => resolve('closed'))
268+
ws.on('close', () => resolve('closed'))
269+
})
270+
}
271+
272+
it('rejects a non-loopback origin by default (loopback-only)', async () => {
273+
const port = await getPort({ port: 19760, host: '127.0.0.1' })
274+
bridge = devframeViteBridge(defineTestDef(), { port, host: '127.0.0.1', auth: false })
275+
vite = fakeViteServer()
276+
await bridge.configureServer(vite)
277+
278+
expect(await upgradeWithOrigin(port, 'https://tunnel.example.dev')).toBe('closed')
279+
})
280+
281+
it('accepts a non-loopback origin when forwarded through allowedOrigins', async () => {
282+
const port = await getPort({ port: 19770, host: '127.0.0.1' })
283+
bridge = devframeViteBridge(defineTestDef(), {
284+
port,
285+
host: '127.0.0.1',
286+
auth: false,
287+
allowedOrigins: ['https://tunnel.example.dev'],
288+
})
289+
vite = fakeViteServer()
290+
await bridge.configureServer(vite)
291+
292+
expect(await upgradeWithOrigin(port, 'https://tunnel.example.dev')).toBe('open')
293+
})
294+
})
295+
246296
describe('devframeVite (dispatcher)', () => {
247297
let vite: FakeViteServer | undefined
248298
let plugin: DevframeVitePlugin | undefined

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/__snapshots__/tsnapi/@devframes/next/single.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface CreateDevframeNextHandlerOptions {
88
port?: number;
99
flags?: Record<string, unknown>;
1010
auth?: InitDevframeOptions['auth'];
11+
allowedOrigins?: InitDevframeOptions['allowedOrigins'];
1112
resolveOrigin?: () => string;
1213
getStorageDir?: (_: DevframeStorageScope) => string;
1314
mcp?: InitDevframeOptions['mcp'];

tests/__snapshots__/tsnapi/@devframes/vite/single.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface DevframeViteBridgeOptions {
99
flags?: Record<string, unknown>;
1010
auth?: boolean | DevframeAuthHandler;
1111
mcp?: McpSetting;
12+
allowedOrigins?: readonly string[] | WsOriginRegistry | false;
1213
}
1314
export interface DevframeViteDevServerLike {
1415
middlewares: {

0 commit comments

Comments
 (0)