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
1 change: 1 addition & 0 deletions docs/content/3.frameworks/1.vite.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Devframe spawns a separate RPC + WS server and registers Vite middleware at `<ba
| `flags` | none | To `def.setup(ctx, { flags })`. |
| `auth` | gated (interactive OTP) | `false` to opt out, or a `DevframeAuthHandler` for a custom scheme. |
| `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. |
| `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). |

## `devframeVite`: convenience wrapper

Expand Down
1 change: 1 addition & 0 deletions docs/content/3.frameworks/3.next.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const GET = handler.fetch
| `flags` | none | Passed to `def.setup(ctx, { flags })`. |
| `auth` | `false` | `true` for the OTP gate, or a handler. |
| `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. |
| `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). |
| `key` | `@devframes/next:<id>:<base>` | `globalThis` memoization key. |

## Hosting a hub
Expand Down
10 changes: 10 additions & 0 deletions packages/next/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ export interface CreateDevframeNextHandlerOptions {
* or a handler for a custom scheme.
*/
auth?: InitDevframeOptions['auth']
/**
* Widen the side-car WebSocket origin check beyond devframe's
* loopback-only default. Reaching a remotely-accessed Next dev server
* (containers, Codespaces, tunnels) needs the app's own origin allowed.
* Pass extra origins, a `WsOriginRegistry`, or `false` to disable the
* check (safe when the auth gate owns the trust boundary). Forwarded
* verbatim to `initDevframe`.
*/
allowedOrigins?: InitDevframeOptions['allowedOrigins']
/** Origin the Next app is reachable at, for docks needing an absolute URL. */
resolveOrigin?: () => string
/** Override where persisted devframe state lives (defaults under the cwd / home). */
Expand Down Expand Up @@ -139,6 +148,7 @@ export function createDevframeNextHandler(
*/
auth: options.auth,
mcp: options.mcp,
allowedOrigins: options.allowedOrigins,
/**
* Next's route handlers never see WebSocket upgrades, so the RPC socket
* lives on a side-car server (on `options.port` when pinned, otherwise
Expand Down
4 changes: 3 additions & 1 deletion packages/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@
"@devframes/hub-ui": "workspace:*",
"@modelcontextprotocol/client": "catalog:deps",
"@types/node": "catalog:types",
"@types/ws": "catalog:types",
"devframe": "workspace:*",
"get-port-please": "catalog:deps",
"tsdown": "catalog:build",
"vite": "catalog:build",
"vitest": "catalog:testing"
"vitest": "catalog:testing",
"ws": "catalog:deps"
}
}
12 changes: 12 additions & 0 deletions packages/vite/src/single.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DevframeDefinition, McpSetting } from 'devframe'
import type { DevframeInstance } from 'devframe/initiate'
import type { DevframeAuthHandler } from 'devframe/node/auth'
import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'
import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http'
import type { Plugin } from 'vite'
import process from 'node:process'
Expand Down Expand Up @@ -119,6 +120,16 @@ export interface DevframeViteBridgeOptions {
* surface is non-empty); `false` disables the route regardless.
*/
mcp?: McpSetting
/**
* Widen the WebSocket origin check beyond devframe's loopback-only
* default. A bridge serves the tool same-origin with the host Vite app,
* so reaching it from a non-loopback origin (`vite --host`, containers,
* Codespaces, tunnels) needs the dev server's own origin allowed. Pass
* extra origins, a {@link WsOriginRegistry}, or `false` to disable the
* check (safe when the bridge's auth gate owns the trust boundary).
* Forwarded verbatim to `initDevframe`.
*/
allowedOrigins?: readonly string[] | WsOriginRegistry | false
}

/**
Expand Down Expand Up @@ -185,6 +196,7 @@ export function devframeViteBridge(d: DevframeDefinition, options: DevframeViteB
*/
auth: options.auth,
mcp: options.mcp,
allowedOrigins: options.allowedOrigins,
})
server.middlewares.use(created.nodeMiddleware)
await created.ready
Expand Down
50 changes: 50 additions & 0 deletions packages/vite/test/single.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createRpcClient } from 'devframe/rpc/client'
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
import { getPort } from 'get-port-please'
import { afterEach, describe, expect, it } from 'vitest'
import { WebSocket } from 'ws'
import { devframeVite, devframeViteBridge, devframeVitePlugin } from '../src/single'

function defineTestDef(overrides: Partial<DevframeDefinition> = {}): DevframeDefinition {
Expand Down Expand Up @@ -243,6 +244,55 @@ describe('devframeViteBridge (auth default)', () => {
})
})

describe('devframeViteBridge (allowedOrigins)', () => {
let bridge: ReturnType<typeof devframeViteBridge> | undefined
let vite: FakeViteServer | undefined

afterEach(async () => {
await bridge?.closeBundle?.()
bridge = undefined
vite?.close()
vite = undefined
})

/** Attempt a raw WS upgrade carrying a spoofed browser Origin header. */
async function upgradeWithOrigin(port: number, origin: string): Promise<'open' | 'closed'> {
return await new Promise((resolve) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/__ws`, { headers: { origin } })
ws.on('open', () => {
ws.close()
resolve('open')
})
ws.on('error', () => resolve('closed'))
ws.on('unexpected-response', () => resolve('closed'))
ws.on('close', () => resolve('closed'))
})
}

it('rejects a non-loopback origin by default (loopback-only)', async () => {
const port = await getPort({ port: 19760, host: '127.0.0.1' })
bridge = devframeViteBridge(defineTestDef(), { port, host: '127.0.0.1', auth: false })
vite = fakeViteServer()
await bridge.configureServer(vite)

expect(await upgradeWithOrigin(port, 'https://tunnel.example.dev')).toBe('closed')
})

it('accepts a non-loopback origin when forwarded through allowedOrigins', async () => {
const port = await getPort({ port: 19770, host: '127.0.0.1' })
bridge = devframeViteBridge(defineTestDef(), {
port,
host: '127.0.0.1',
auth: false,
allowedOrigins: ['https://tunnel.example.dev'],
})
vite = fakeViteServer()
await bridge.configureServer(vite)

expect(await upgradeWithOrigin(port, 'https://tunnel.example.dev')).toBe('open')
})
})

describe('devframeVite (dispatcher)', () => {
let vite: FakeViteServer | undefined
let plugin: DevframeVitePlugin | undefined
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface CreateDevframeNextHandlerOptions {
port?: number;
flags?: Record<string, unknown>;
auth?: InitDevframeOptions['auth'];
allowedOrigins?: InitDevframeOptions['allowedOrigins'];
resolveOrigin?: () => string;
getStorageDir?: (_: DevframeStorageScope) => string;
mcp?: InitDevframeOptions['mcp'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface DevframeViteBridgeOptions {
flags?: Record<string, unknown>;
auth?: boolean | DevframeAuthHandler;
mcp?: McpSetting;
allowedOrigins?: readonly string[] | WsOriginRegistry | false;
}
export interface DevframeViteDevServerLike {
middlewares: {
Expand Down
Loading