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
26 changes: 25 additions & 1 deletion docs/guide/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ window or an accessible parent window. Cross-realm viewers can read the
serializable value through `DEVFRAME_CONNECTION_KEY` from
`devframe/constants`.

An external browser viewer can register its origin before opening the WebSocket:

```ts
import { registerDevframeViewerOrigin } from 'devframe/client'

await registerDevframeViewerOrigin(connection)
```

The host provides `viewerOriginToken` in its connection metadata to enable registration. The token-protected server registry is described in [External viewer origins](/guide/security#external-viewer-origins).

### Options

```ts
Expand Down Expand Up @@ -252,7 +262,7 @@ await connectDevframe({

## Remote docks

Remote docks are a host-side feature — hosts that support them (Vite DevTools is one; see [its remote-client docs](https://devtools.vite.dev/kit/remote-client) for that implementation) inject a connection descriptor into the iframe URL. On the hosted page, `connectDevframe` auto-detects the descriptor from the URL fragment / query string — call it as usual:
Remote docks are a host-side feature — hosts that support them (Vite DevTools is one; see [its remote-client docs](https://devtools.vite.dev/kit/remote-client) for that implementation) inject a connection descriptor into the iframe URL. On the hosted page, `connectDevframe` auto-detects the descriptor from the URL fragment or query string — call it as usual:

```ts
import { connectDevframe } from 'devframe/client'
Expand All @@ -263,6 +273,20 @@ const rpc = await connectDevframe()

The descriptor carries a session-only, pre-approved auth token, so `ensureTrusted()` resolves immediately.

An external hub can build a viewer URL from an existing trusted connection:

```ts
import {
buildRemoteDevframeUrl,
stripRemoteConnectionFromUrl,
} from '@devframes/hub/client'

const viewerUrl = buildRemoteDevframeUrl('/viewer/', connection)
const displayUrl = stripRemoteConnectionFromUrl(viewerUrl)
```

`buildRemoteDevframeUrl()` stores the descriptor in the URL fragment, keeping its token out of HTTP requests and referrer headers. Hub-managed remote docks continue to support their configured descriptor transport.

## Events

The client emits over `rpc.events`:
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ Plus broadcast notifications (`devframe:docks:activate`, `devframe:terminals:upd

The hub also ships a headless browser runtime, `createDevframeClientHost()` from `@devframes/hub/client`. Booted in the host page, it assembles the shared client context from the protocol above and imports each dock entry's client script into that page — how a plugin like the a11y inspector runs code inside the page being inspected. See [Client Scripts & Client Context](./client-context) for the boot flow, the context surface, and the dock-script contract.

External viewers resolve dock resources against the connection that delivered the dock entries. `resolveDockUrl(url, connection)` keeps iframe paths on the Devframe server, while `resolveDockIcon(icon, connection)` handles both string icons and `{ light, dark }` pairs. Absolute URLs, data URLs, and Iconify names remain unchanged.

## Example

Two minimal, copyable hubs mount every built-in plugin (git, terminals, code-server, inspect, a11y) behind an icon dock — the same shape [vite-devtools](https://github.com/vitejs/devtools) wears as the full Vite viewer, shrunk to the smallest thing you can build your own viewer from:
Expand Down
22 changes: 22 additions & 0 deletions docs/guide/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,25 @@ Higher-level integrations can drive their own authentication UI instead: disable
- **Authorize every handler.** A registered function is callable by any trusted client. Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them.
- **Origin-lock remote docks.** When a hub embeds a remote-UI dock, enable `originLock` so a dock token is only honored from its expected origin.
- **Serve encrypted off-machine.** Use `https://`/`wss://` for any surface reachable beyond `localhost`.

## External viewer origins

WebSocket handshakes from browser extensions and other external viewers carry the viewer's own `Origin` header. A host can authorize that origin through a live registry:

```ts
import { attachWsRpcTransport, createWsOriginRegistry } from 'devframe/rpc/transports/ws-server'

const viewerOrigins = createWsOriginRegistry({
validateOrigin: origin => origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://'),
})

attachWsRpcTransport(rpc, {
server,
allowedOrigins: viewerOrigins,
})
```

Include `viewerOrigins.token` as `viewerOriginToken` in the connection metadata. In the connection metadata handler, call `viewerOrigins.registerFromUrl(request.url)`. When it returns an origin, set `Access-Control-Allow-Origin` to that value. The external viewer then calls `registerDevframeViewerOrigin(connection)` before connecting.

The registration token grants access through the transport's origin check. RPC authentication still authorizes the session and every non-anonymous method. Keep metadata containing this token same-origin until the registration request has been verified.
32 changes: 31 additions & 1 deletion packages/devframe/src/client/connection.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ConnectionMeta } from 'devframe/types'
import { DEVFRAME_CONNECTION_KEY } from 'devframe/constants'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDevframeConnection, setupDevframeConnection } from './connection'
import { getDevframeConnection, registerDevframeViewerOrigin, setupDevframeConnection } from './connection'
import { getDevframeRpcClient } from './rpc'

const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__'
Expand Down Expand Up @@ -209,3 +209,33 @@ describe('setupDevframeConnection', () => {
})
})
})

describe('registerDevframeViewerOrigin', () => {
it('registers the exact origin with the advertised bootstrap token', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const registered = await registerDevframeViewerOrigin({
connectionMeta: {
backend: 'websocket',
viewerOriginToken: 'bootstrap-secret',
},
metaBaseUrl: 'http://localhost:5173/__connection.json',
}, 'chrome-extension://abcdefghijklmnop')

expect(registered).toBe(true)
const [url, init] = fetchMock.mock.calls[0]
expect(String(url)).toContain('devframe_viewer_origin=chrome-extension%3A%2F%2Fabcdefghijklmnop')
expect(String(url)).toContain('devframe_viewer_origin_token=bootstrap-secret')
expect(init).toEqual({ cache: 'no-store' })
})

it('does nothing when the host did not advertise registration', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(registerDevframeViewerOrigin({
connectionMeta: { backend: 'websocket' },
metaBaseUrl: 'http://localhost:5173/__connection.json',
}, 'chrome-extension://abcdefghijklmnop')).resolves.toBe(false)
expect(fetchMock).not.toHaveBeenCalled()
})
})
28 changes: 27 additions & 1 deletion packages/devframe/src/client/connection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { ConnectionMeta } from 'devframe/types'
import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants'
import {
DEVFRAME_CONNECTION_META_FILENAME,
DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM,
DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM,
} from 'devframe/constants'
import { withBase } from 'ufo'
import {
readStoredAuthToken,
Expand Down Expand Up @@ -32,6 +36,28 @@ export interface SetupDevframeConnectionOptions {
authToken?: string
}

/**
* Allow an external viewer to connect by registering its browser origin with
* the Devframe host. Returns `false` if the host did not provide an origin
* registration token.
*/
export async function registerDevframeViewerOrigin(
connection: DevframeConnection,
origin = globalThis.location?.origin,
): Promise<boolean> {
const token = connection.connectionMeta.viewerOriginToken
if (!token || !origin)
return false

const url = new URL(connection.metaBaseUrl)
url.searchParams.set(DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, origin)
url.searchParams.set(DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM, token)
const response = await fetch(url, { cache: 'no-store' })
if (!response.ok)
throw new Error(`Failed to register external viewer origin (${response.status}).`)
return true
}

function resolveMetaBaseUrl(baseURL: string): string {
const metaPath = withBase(DEVFRAME_CONNECTION_META_FILENAME, baseURL)
try {
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './connection'
export * from './otp'
export * from './rpc'
export * from './rpc-streaming'
export { resolveWsUrl, type WsUrlLocation } from './rpc-ws'
export * from './scope'
export * from './settings'

Expand Down
6 changes: 6 additions & 0 deletions packages/devframe/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ export const DEVFRAME_OTP_URL_PARAM = 'devframe_otp'
*/
export const DEVFRAME_AUTH_TOKEN_QUERY_PARAM = 'devframe_auth_token'

/** External viewer origin requested during connection bootstrap. */
export const DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM = 'devframe_viewer_origin'

/** Token that authorizes an external viewer origin registration. */
export const DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM = 'devframe_viewer_origin_token'

/**
* Prefix that marks an RPC method as callable before a connection is
* trusted. This is the *only* rule the pre-trust gate applies — there is no
Expand Down
3 changes: 2 additions & 1 deletion packages/devframe/src/node/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { BirpcGroup, EventOptions } from 'birpc'
import type { Peer } from 'crossws'
import type { NodeAdapter } from 'crossws/adapters/node'
import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'
import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types'
import type { Server as NodeHttpServer } from 'node:http'
import type { DevframeAuthHandler } from './auth'
Expand Down Expand Up @@ -102,7 +103,7 @@ export interface StartHttpAndWsOptions {
* from another host. Pass `false` to disable origin checking entirely
* (not recommended). Default: loopback-only.
*/
allowedOrigins?: readonly string[] | false
allowedOrigins?: readonly string[] | WsOriginRegistry | false
/**
* Called once the WS server is bound so callers can mount static
* handlers whose origin depends on the resolved port, or print their
Expand Down
89 changes: 86 additions & 3 deletions packages/devframe/src/rpc/transports/ws-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type { RpcFunctionDefinitionAny } from '../types'
import { createServer as createHttpServer } from 'node:http'
import { createServer as createHttpsServer } from 'node:https'
import crossws from 'crossws/adapters/node'
import { DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM } from 'devframe/constants'
import { randomToken, timingSafeEqual } from 'devframe/utils/crypto-token'
import { structuredCloneParse, structuredCloneStringify } from 'devframe/utils/structured-clone'
import { strictJsonStringify, STRUCTURED_CLONE_PREFIX } from '../serialization'

Expand Down Expand Up @@ -70,7 +72,7 @@ export interface WsRpcTransportOptions {
* Pass `false` to disable origin checking entirely (not recommended).
* Default: loopback-only.
*/
allowedOrigins?: readonly string[] | false
allowedOrigins?: readonly string[] | WsOriginRegistry | false
/**
* RPC function definitions, used by the per-call wire serializer to
* dispatch between strict-JSON and structured-clone encoding based
Expand All @@ -88,6 +90,78 @@ export interface WsRpcTransportOptions {
deserialize?: ChannelOptions['deserialize']
}

export interface CreateWsOriginRegistryOptions {
/** Origins allowed before any external viewers are registered. */
allowedOrigins?: readonly string[]
/** Additional validation to run after the registration token is verified. */
validateOrigin?: (origin: string) => boolean
}

export interface WsOriginRegistry {
/** Registration token to include in connection metadata. */
readonly token: string
/** Read and register an origin from a connection bootstrap URL. */
registerFromUrl: (url: string) => string | undefined
/** Check whether an origin is currently allowed. */
isAllowed: (origin: string | undefined) => boolean
}

/**
* Create a live, token-protected origin allowlist for external browser
* viewers. Pass it to {@link WsRpcTransportOptions.allowedOrigins}, then use
* `registerFromUrl()` in the connection metadata handler to authorize a
* viewer without sharing a mutable array or disabling DNS-rebinding protection.
*/
export function createWsOriginRegistry(
options: CreateWsOriginRegistryOptions = {},
): WsOriginRegistry {
const token = randomToken()
const origins = new Set(options.allowedOrigins ?? [])

function normalizeOrigin(origin: string | undefined): string | undefined {
if (!origin)
return
try {
const url = new URL(origin)
const normalized = url.origin === 'null'
? `${url.protocol}//${url.host}`
: url.origin
return origin === normalized ? normalized : undefined
}
catch {}
}

function registerOrigin(origin: string | undefined, candidateToken: string | undefined): boolean {
const normalized = normalizeOrigin(origin)
if (!normalized || !candidateToken || !timingSafeEqual(token, candidateToken))
return false
if (options.validateOrigin && !options.validateOrigin(normalized))
return false
origins.add(normalized)
return true
}

const registry: WsOriginRegistry = {
token,
registerFromUrl(url) {
let parsed: URL
try {
parsed = new URL(url, 'http://localhost')
}
catch {
return
}
const origin = parsed.searchParams.get(DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM) ?? undefined
const candidateToken = parsed.searchParams.get(DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM) ?? undefined
return registerOrigin(origin, candidateToken) ? origin : undefined
},
isAllowed(origin) {
return isAllowedOrigin(origin, [...origins])
},
}
return registry
}

export interface WsRpcTransport {
/**
* The crossws node adapter driving the socket — exposes the connected
Expand Down Expand Up @@ -142,6 +216,12 @@ export function isAllowedOrigin(origin: string | undefined, allowedOrigins: read
}
}

function isWsOriginRegistry(
value: readonly string[] | WsOriginRegistry | false | undefined,
): value is WsOriginRegistry {
return !!value && !Array.isArray(value)
}

/**
* Route `upgrade` events on a server to the crossws adapter, optionally
* filtered to a single `path`. Non-matching requests are left untouched so
Expand All @@ -154,7 +234,7 @@ function routeUpgrades(
ws: NodeAdapter,
path: string | undefined,
destroyUnmatched: boolean,
allowedOrigins: readonly string[] | false | undefined,
allowedOrigins: readonly string[] | WsOriginRegistry | false | undefined,
): () => void {
const listener = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
socket.on('error', () => {
Expand All @@ -176,7 +256,10 @@ function routeUpgrades(
return
}
}
if (allowedOrigins !== false && !isAllowedOrigin(req.headers.origin, allowedOrigins ?? [])) {
const originAllowed = isWsOriginRegistry(allowedOrigins)
? allowedOrigins.isAllowed(req.headers.origin)
: isAllowedOrigin(req.headers.origin, allowedOrigins || [])
if (allowedOrigins !== false && !originAllowed) {
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
socket.destroy()
return
Expand Down
Loading
Loading