Skip to content

Commit 1166a60

Browse files
authored
feat: add external viewer connection utilities (#160)
1 parent de44144 commit 1166a60

27 files changed

Lines changed: 647 additions & 67 deletions

‎docs/guide/client.md‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ window or an accessible parent window. Cross-realm viewers can read the
6363
serializable value through `DEVFRAME_CONNECTION_KEY` from
6464
`devframe/constants`.
6565

66+
An external browser viewer can register its origin before opening the WebSocket:
67+
68+
```ts
69+
import { registerDevframeViewerOrigin } from 'devframe/client'
70+
71+
await registerDevframeViewerOrigin(connection)
72+
```
73+
74+
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).
75+
6676
### Options
6777

6878
```ts
@@ -252,7 +262,7 @@ await connectDevframe({
252262

253263
## Remote docks
254264

255-
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:
265+
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:
256266

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

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

276+
An external hub can build a viewer URL from an existing trusted connection:
277+
278+
```ts
279+
import {
280+
buildRemoteDevframeUrl,
281+
stripRemoteConnectionFromUrl,
282+
} from '@devframes/hub/client'
283+
284+
const viewerUrl = buildRemoteDevframeUrl('/viewer/', connection)
285+
const displayUrl = stripRemoteConnectionFromUrl(viewerUrl)
286+
```
287+
288+
`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.
289+
266290
## Events
267291

268292
The client emits over `rpc.events`:

‎docs/guide/hub.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,8 @@ Plus broadcast notifications (`devframe:docks:activate`, `devframe:terminals:upd
279279

280280
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.
281281

282+
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.
283+
282284
## Example
283285

284286
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:

‎docs/guide/security.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,25 @@ Higher-level integrations can drive their own authentication UI instead: disable
9999
- **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.
100100
- **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.
101101
- **Serve encrypted off-machine.** Use `https://`/`wss://` for any surface reachable beyond `localhost`.
102+
103+
## External viewer origins
104+
105+
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:
106+
107+
```ts
108+
import { attachWsRpcTransport, createWsOriginRegistry } from 'devframe/rpc/transports/ws-server'
109+
110+
const viewerOrigins = createWsOriginRegistry({
111+
validateOrigin: origin => origin.startsWith('chrome-extension://')
112+
|| origin.startsWith('moz-extension://'),
113+
})
114+
115+
attachWsRpcTransport(rpc, {
116+
server,
117+
allowedOrigins: viewerOrigins,
118+
})
119+
```
120+
121+
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.
122+
123+
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.

‎packages/devframe/src/client/connection.test.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ConnectionMeta } from 'devframe/types'
22
import { DEVFRAME_CONNECTION_KEY } from 'devframe/constants'
33
import { afterEach, describe, expect, it, vi } from 'vitest'
4-
import { getDevframeConnection, setupDevframeConnection } from './connection'
4+
import { getDevframeConnection, registerDevframeViewerOrigin, setupDevframeConnection } from './connection'
55
import { getDevframeRpcClient } from './rpc'
66

77
const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__'
@@ -209,3 +209,33 @@ describe('setupDevframeConnection', () => {
209209
})
210210
})
211211
})
212+
213+
describe('registerDevframeViewerOrigin', () => {
214+
it('registers the exact origin with the advertised bootstrap token', async () => {
215+
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
216+
vi.stubGlobal('fetch', fetchMock)
217+
const registered = await registerDevframeViewerOrigin({
218+
connectionMeta: {
219+
backend: 'websocket',
220+
viewerOriginToken: 'bootstrap-secret',
221+
},
222+
metaBaseUrl: 'http://localhost:5173/__connection.json',
223+
}, 'chrome-extension://abcdefghijklmnop')
224+
225+
expect(registered).toBe(true)
226+
const [url, init] = fetchMock.mock.calls[0]
227+
expect(String(url)).toContain('devframe_viewer_origin=chrome-extension%3A%2F%2Fabcdefghijklmnop')
228+
expect(String(url)).toContain('devframe_viewer_origin_token=bootstrap-secret')
229+
expect(init).toEqual({ cache: 'no-store' })
230+
})
231+
232+
it('does nothing when the host did not advertise registration', async () => {
233+
const fetchMock = vi.fn()
234+
vi.stubGlobal('fetch', fetchMock)
235+
await expect(registerDevframeViewerOrigin({
236+
connectionMeta: { backend: 'websocket' },
237+
metaBaseUrl: 'http://localhost:5173/__connection.json',
238+
}, 'chrome-extension://abcdefghijklmnop')).resolves.toBe(false)
239+
expect(fetchMock).not.toHaveBeenCalled()
240+
})
241+
})

‎packages/devframe/src/client/connection.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { ConnectionMeta } from 'devframe/types'
2-
import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants'
2+
import {
3+
DEVFRAME_CONNECTION_META_FILENAME,
4+
DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM,
5+
DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM,
6+
} from 'devframe/constants'
37
import { withBase } from 'ufo'
48
import {
59
readStoredAuthToken,
@@ -32,6 +36,28 @@ export interface SetupDevframeConnectionOptions {
3236
authToken?: string
3337
}
3438

39+
/**
40+
* Allow an external viewer to connect by registering its browser origin with
41+
* the Devframe host. Returns `false` if the host did not provide an origin
42+
* registration token.
43+
*/
44+
export async function registerDevframeViewerOrigin(
45+
connection: DevframeConnection,
46+
origin = globalThis.location?.origin,
47+
): Promise<boolean> {
48+
const token = connection.connectionMeta.viewerOriginToken
49+
if (!token || !origin)
50+
return false
51+
52+
const url = new URL(connection.metaBaseUrl)
53+
url.searchParams.set(DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, origin)
54+
url.searchParams.set(DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM, token)
55+
const response = await fetch(url, { cache: 'no-store' })
56+
if (!response.ok)
57+
throw new Error(`Failed to register external viewer origin (${response.status}).`)
58+
return true
59+
}
60+
3561
function resolveMetaBaseUrl(baseURL: string): string {
3662
const metaPath = withBase(DEVFRAME_CONNECTION_META_FILENAME, baseURL)
3763
try {

‎packages/devframe/src/client/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './connection'
44
export * from './otp'
55
export * from './rpc'
66
export * from './rpc-streaming'
7+
export { resolveWsUrl, type WsUrlLocation } from './rpc-ws'
78
export * from './scope'
89
export * from './settings'
910

‎packages/devframe/src/constants.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export const DEVFRAME_OTP_URL_PARAM = 'devframe_otp'
5858
*/
5959
export const DEVFRAME_AUTH_TOKEN_QUERY_PARAM = 'devframe_auth_token'
6060

61+
/** External viewer origin requested during connection bootstrap. */
62+
export const DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM = 'devframe_viewer_origin'
63+
64+
/** Token that authorizes an external viewer origin registration. */
65+
export const DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM = 'devframe_viewer_origin_token'
66+
6167
/**
6268
* Prefix that marks an RPC method as callable before a connection is
6369
* trusted. This is the *only* rule the pre-trust gate applies — there is no

‎packages/devframe/src/node/server.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { BirpcGroup, EventOptions } from 'birpc'
22
import type { Peer } from 'crossws'
33
import type { NodeAdapter } from 'crossws/adapters/node'
4+
import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'
45
import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types'
56
import type { Server as NodeHttpServer } from 'node:http'
67
import type { DevframeAuthHandler } from './auth'
@@ -102,7 +103,7 @@ export interface StartHttpAndWsOptions {
102103
* from another host. Pass `false` to disable origin checking entirely
103104
* (not recommended). Default: loopback-only.
104105
*/
105-
allowedOrigins?: readonly string[] | false
106+
allowedOrigins?: readonly string[] | WsOriginRegistry | false
106107
/**
107108
* Called once the WS server is bound so callers can mount static
108109
* handlers whose origin depends on the resolved port, or print their

‎packages/devframe/src/rpc/transports/ws-server.ts‎

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type { RpcFunctionDefinitionAny } from '../types'
99
import { createServer as createHttpServer } from 'node:http'
1010
import { createServer as createHttpsServer } from 'node:https'
1111
import crossws from 'crossws/adapters/node'
12+
import { DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM } from 'devframe/constants'
13+
import { randomToken, timingSafeEqual } from 'devframe/utils/crypto-token'
1214
import { structuredCloneParse, structuredCloneStringify } from 'devframe/utils/structured-clone'
1315
import { strictJsonStringify, STRUCTURED_CLONE_PREFIX } from '../serialization'
1416

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

93+
export interface CreateWsOriginRegistryOptions {
94+
/** Origins allowed before any external viewers are registered. */
95+
allowedOrigins?: readonly string[]
96+
/** Additional validation to run after the registration token is verified. */
97+
validateOrigin?: (origin: string) => boolean
98+
}
99+
100+
export interface WsOriginRegistry {
101+
/** Registration token to include in connection metadata. */
102+
readonly token: string
103+
/** Read and register an origin from a connection bootstrap URL. */
104+
registerFromUrl: (url: string) => string | undefined
105+
/** Check whether an origin is currently allowed. */
106+
isAllowed: (origin: string | undefined) => boolean
107+
}
108+
109+
/**
110+
* Create a live, token-protected origin allowlist for external browser
111+
* viewers. Pass it to {@link WsRpcTransportOptions.allowedOrigins}, then use
112+
* `registerFromUrl()` in the connection metadata handler to authorize a
113+
* viewer without sharing a mutable array or disabling DNS-rebinding protection.
114+
*/
115+
export function createWsOriginRegistry(
116+
options: CreateWsOriginRegistryOptions = {},
117+
): WsOriginRegistry {
118+
const token = randomToken()
119+
const origins = new Set(options.allowedOrigins ?? [])
120+
121+
function normalizeOrigin(origin: string | undefined): string | undefined {
122+
if (!origin)
123+
return
124+
try {
125+
const url = new URL(origin)
126+
const normalized = url.origin === 'null'
127+
? `${url.protocol}//${url.host}`
128+
: url.origin
129+
return origin === normalized ? normalized : undefined
130+
}
131+
catch {}
132+
}
133+
134+
function registerOrigin(origin: string | undefined, candidateToken: string | undefined): boolean {
135+
const normalized = normalizeOrigin(origin)
136+
if (!normalized || !candidateToken || !timingSafeEqual(token, candidateToken))
137+
return false
138+
if (options.validateOrigin && !options.validateOrigin(normalized))
139+
return false
140+
origins.add(normalized)
141+
return true
142+
}
143+
144+
const registry: WsOriginRegistry = {
145+
token,
146+
registerFromUrl(url) {
147+
let parsed: URL
148+
try {
149+
parsed = new URL(url, 'http://localhost')
150+
}
151+
catch {
152+
return
153+
}
154+
const origin = parsed.searchParams.get(DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM) ?? undefined
155+
const candidateToken = parsed.searchParams.get(DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM) ?? undefined
156+
return registerOrigin(origin, candidateToken) ? origin : undefined
157+
},
158+
isAllowed(origin) {
159+
return isAllowedOrigin(origin, [...origins])
160+
},
161+
}
162+
return registry
163+
}
164+
91165
export interface WsRpcTransport {
92166
/**
93167
* The crossws node adapter driving the socket — exposes the connected
@@ -142,6 +216,12 @@ export function isAllowedOrigin(origin: string | undefined, allowedOrigins: read
142216
}
143217
}
144218

219+
function isWsOriginRegistry(
220+
value: readonly string[] | WsOriginRegistry | false | undefined,
221+
): value is WsOriginRegistry {
222+
return !!value && !Array.isArray(value)
223+
}
224+
145225
/**
146226
* Route `upgrade` events on a server to the crossws adapter, optionally
147227
* filtered to a single `path`. Non-matching requests are left untouched so
@@ -154,7 +234,7 @@ function routeUpgrades(
154234
ws: NodeAdapter,
155235
path: string | undefined,
156236
destroyUnmatched: boolean,
157-
allowedOrigins: readonly string[] | false | undefined,
237+
allowedOrigins: readonly string[] | WsOriginRegistry | false | undefined,
158238
): () => void {
159239
const listener = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
160240
socket.on('error', () => {
@@ -176,7 +256,10 @@ function routeUpgrades(
176256
return
177257
}
178258
}
179-
if (allowedOrigins !== false && !isAllowedOrigin(req.headers.origin, allowedOrigins ?? [])) {
259+
const originAllowed = isWsOriginRegistry(allowedOrigins)
260+
? allowedOrigins.isAllowed(req.headers.origin)
261+
: isAllowedOrigin(req.headers.origin, allowedOrigins || [])
262+
if (allowedOrigins !== false && !originAllowed) {
180263
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
181264
socket.destroy()
182265
return

0 commit comments

Comments
 (0)