Skip to content
Open
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
107 changes: 107 additions & 0 deletions packages/devframe/src/adapters/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from 'node:path'
import { defineDevframe } from 'devframe'
import { createRpcClient } from 'devframe/rpc/client'
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
import { createWsOriginRegistry } from 'devframe/rpc/transports/ws-server'
import { open } from 'devframe/utils/open'
import { getPort } from 'get-port-please'
import { describe, expect, it, vi } from 'vitest'
Expand All @@ -31,6 +32,19 @@ function makeTmpDist(): string {
return dir
}

async function connectRaw(url: string, origin?: string): Promise<'open' | 'closed'> {
return await new Promise((resolve) => {
const ws = new WebSocket(url, origin ? { headers: { origin } } : undefined)
ws.on('open', () => {
ws.close()
resolve('open')
})
ws.on('error', () => resolve('closed'))
ws.on('unexpected-response', () => resolve('closed'))
ws.on('close', () => resolve('closed'))
})
}

describe('adapters/dev', () => {
it('createDevServer starts, exposes __connection.json, and closes', async () => {
const distDir = makeTmpDist()
Expand Down Expand Up @@ -256,6 +270,99 @@ describe('adapters/dev', () => {
}
})

it('allowedOrigins: default stays loopback-only', async () => {
const devframe = defineDevframe({
id: 'devframe-origins-default',
name: 'Origins Default',
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: 19450, host })
const handle = await createDevServer(devframe, { host, port, openBrowser: false })

try {
await expect(connectRaw(`ws://${host}:${port}/__ws`)).resolves.toBe('open')
await expect(connectRaw(`ws://${host}:${port}/__ws`, `http://${host}:12345`)).resolves.toBe('open')
await expect(connectRaw(`ws://${host}:${port}/__ws`, 'http://evil.example')).resolves.toBe('closed')
}
finally {
await handle.close()
}
})

it('allowedOrigins: an array of extra origins is honored on top of the loopback default', async () => {
const devframe = defineDevframe({
id: 'devframe-origins-array',
name: 'Origins Array',
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: 19440, host })
const handle = await createDevServer(devframe, {
host,
port,
openBrowser: false,
allowedOrigins: ['http://evil.example'],
})

try {
await expect(connectRaw(`ws://${host}:${port}/__ws`, 'http://evil.example')).resolves.toBe('open')
// Still rejects an origin that's neither loopback nor allowlisted.
await expect(connectRaw(`ws://${host}:${port}/__ws`, 'http://other.example')).resolves.toBe('closed')
}
finally {
await handle.close()
}
})

it('allowedOrigins: a WsOriginRegistry gates the upgrade end-to-end', async () => {
const devframe = defineDevframe({
id: 'devframe-origins-registry',
name: 'Origins Registry',
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: 19430, host })
const origin = 'chrome-extension://abcdefghijklmnop'
const registry = createWsOriginRegistry({
validateOrigin: value => value.startsWith('chrome-extension://'),
})
const handle = await createDevServer(devframe, {
host,
port,
openBrowser: false,
allowedOrigins: registry,
})

try {
// Unregistered, the registry rejects the same origin it'll accept below.
await expect(connectRaw(`ws://${host}:${port}/__ws`, origin)).resolves.toBe('closed')

const params = new URLSearchParams({
devframe_viewer_origin: origin,
devframe_viewer_origin_token: registry.token,
})
expect(registry.registerFromUrl(`/__connection.json?${params}`)).toBe(origin)

await expect(connectRaw(`ws://${host}:${port}/__ws`, origin)).resolves.toBe('open')
}
finally {
await handle.close()
}
})

it('createDevServer runs in bridge mode when no distDir is configured', async () => {
const devframe = defineDevframe({
id: 'devframe-test-nodist',
Expand Down
10 changes: 9 additions & 1 deletion packages/devframe/src/adapters/dev.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DevframeRpcConnection } from 'devframe/rpc/transports/ws-server'
import type { DevframeRpcConnection, WsOriginRegistry } from 'devframe/rpc/transports/ws-server'
import type { DevframeAuthHandler } from '../node/auth/handler'
import type { StartedServer } from '../node/instance-shell'
import type { DevframeDefinition, DevframeSseOptions, DevframeWsOptions, McpRouteOptions } from '../types/devframe'
Expand Down Expand Up @@ -50,6 +50,13 @@ export interface CreateDevServerOptions {
* clients connect over the SSE endpoint instead (`backend: 'sse'`).
*/
ws?: DevframeWsOptions | false
/**
* Extra origins to accept on the WS upgrade beyond the loopback default.
* Add your LAN/tunnel origin here when reaching the tool from another
* host. Pass `false` to disable origin checking entirely (not
* recommended). Default: loopback-only.
*/
allowedOrigins?: readonly string[] | WsOriginRegistry | false
/**
* Override the SSE RPC endpoint control (`def.cli?.sse`) — enabled by
* default at `<base>__sse`. Pass `false` to disable, or a
Expand Down Expand Up @@ -175,6 +182,7 @@ export async function createDevServer(
host,
origin,
ws: options.ws,
allowedOrigins: options.allowedOrigins,
sse: options.sse,
// The `--no-auth` flag forces the gate off regardless of the `auth`
// option / definition default (which the instance resolves itself).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface CreateDevServerOptions {
distDir?: string;
basePath?: string;
ws?: DevframeWsOptions | false;
allowedOrigins?: readonly string[] | WsOriginRegistry | false;
sse?: boolean | DevframeSseOptions;
app?: H3;
openBrowser?: boolean | string;
Expand Down
Loading