Skip to content

Commit 24ba9f4

Browse files
committed
feat(client): support isolated RPC connections
1 parent 18fa60e commit 24ba9f4

6 files changed

Lines changed: 278 additions & 19 deletions

File tree

‎docs/content/8.references/5.browser-api.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The options of `connectDevframe()` / `getDevframeRpcClient()`: [Client](/guide/c
1313

1414
| Option | Description |
1515
|--------|-------------|
16+
| `isolateConnection` | `true` keeps endpoint discovery and credentials local to this RPC client. Skips shared window/localStorage caches and authentication broadcasts. Default `false`. |
1617
| `connection` | Connection prepared by `setupDevframeConnection()`. |
1718
| `baseURL` | Mount path to probe for `__connection.json` (array = fallback). Default `'./'` (relative to `document.baseURI`); use an absolute path (`'/__devframe/'`) from outside the SPA. |
1819
| `authToken` | Override the auth token (default: a locally-persisted id). |
@@ -23,6 +24,10 @@ The options of `connectDevframe()` / `getDevframeRpcClient()`: [Client](/guide/c
2324
| `connectionMeta` | Descriptor that skips the `__connection.json` fetch. |
2425
| `webmcp` | Mirror `agent`-flagged client RPC functions onto the page's WebMCP model context as tools; `false` opts out. Default `true` (applies only when the browser provides one). See [Agent-Native](/guide/agent-native#browser-side-tools-over-webmcp). |
2526

27+
Use `isolateConnection: true` for an external viewer that manages independent endpoints or supplies its own credential persistence. Explicit `connection`, `connectionMeta`, `authToken` and `baseURL` inputs retain their precedence. Token and one-time-code authentication update the RPC client's `connection`; the caller can retain that descriptor for reconnection.
28+
29+
The option applies separately to `setupDevframeConnection()` and `connectDevframe()`. When handing a prepared descriptor to another RPC client, pass `isolateConnection: true` again. URL code consumption and the authentication prompt remain controlled by `otpParam` and `simpleAuth`.
30+
2631
## RPC client events
2732

2833
Emitted over `rpc.events`: [Events](/guide/client#events).
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type {
2+
DevframeConnection,
3+
} from './index'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { setupDevframeConnection } from './index'
6+
7+
const storedConnection: DevframeConnection = {
8+
connectionMeta: { backend: 'static' },
9+
metaBaseUrl: 'http://stored.example/__connection.json',
10+
authToken: 'stored-token',
11+
}
12+
const explicitConnection: DevframeConnection = {
13+
connectionMeta: { backend: 'static' },
14+
metaBaseUrl: 'http://explicit.example/__connection.json',
15+
}
16+
const getItem = vi.fn<Storage['getItem']>()
17+
const setItem = vi.fn<Storage['setItem']>()
18+
const fetchMetadata = vi.fn<typeof fetch>()
19+
function readGlobal(name: string): unknown {
20+
return Reflect.get(globalThis, name)
21+
}
22+
23+
beforeEach(() => {
24+
vi.clearAllMocks()
25+
vi.stubGlobal('window', globalThis)
26+
vi.stubGlobal('parent', { window: globalThis })
27+
vi.stubGlobal('location', new URL('http://viewer.example/index.html'))
28+
vi.stubGlobal('localStorage', { getItem, setItem })
29+
vi.stubGlobal('fetch', fetchMetadata)
30+
vi.stubGlobal('__DEVFRAME_CONNECTION__', storedConnection)
31+
vi.stubGlobal('__DEVFRAME_CONNECTION_META__', storedConnection.connectionMeta)
32+
vi.stubGlobal('__DEVFRAME_CONNECTION_AUTH_TOKEN__', 'stored-token')
33+
getItem.mockReturnValue('stored-token')
34+
fetchMetadata.mockResolvedValue(Response.json({}))
35+
})
36+
37+
afterEach(() => {
38+
vi.unstubAllGlobals()
39+
})
40+
41+
describe('isolated connection setup', () => {
42+
it('does not discover or persist shared credentials for an explicit isolated connection', async () => {
43+
expect.assertions(5)
44+
const connection = await setupDevframeConnection({
45+
connection: explicitConnection,
46+
isolateConnection: true,
47+
})
48+
expect(connection).toBe(explicitConnection)
49+
expect(connection.authToken).toBeUndefined()
50+
expect(getItem).not.toHaveBeenCalled()
51+
expect(setItem).not.toHaveBeenCalled()
52+
expect(readGlobal('__DEVFRAME_CONNECTION__')).toBe(storedConnection)
53+
})
54+
55+
it('fetches the requested base despite a different cached connection', async () => {
56+
expect.assertions(6)
57+
fetchMetadata.mockResolvedValue(
58+
Response.json({ backend: 'static', authToken: 'metadata-token' }),
59+
)
60+
const connection = await setupDevframeConnection({
61+
baseURL: 'http://requested.example/provider/',
62+
isolateConnection: true,
63+
})
64+
expect(fetchMetadata).toHaveBeenCalledExactlyOnceWith(
65+
'http://requested.example/provider/__connection.json',
66+
)
67+
expect(connection.metaBaseUrl).toBe('http://requested.example/provider/__connection.json')
68+
expect(connection.authToken).toBe('metadata-token')
69+
expect(getItem).not.toHaveBeenCalled()
70+
expect(setItem).not.toHaveBeenCalled()
71+
expect(readGlobal('__DEVFRAME_CONNECTION__')).toBe(storedConnection)
72+
})
73+
74+
it('accepts explicit metadata and token without reading or writing shared caches', async () => {
75+
expect.assertions(6)
76+
const connection = await setupDevframeConnection({
77+
connectionMeta: { backend: 'static', authToken: 'metadata-token' },
78+
baseURL: 'http://requested.example/',
79+
authToken: 'explicit-token',
80+
isolateConnection: true,
81+
})
82+
expect(connection.authToken).toBe('explicit-token')
83+
expect(connection.metaBaseUrl).toBe('http://requested.example/__connection.json')
84+
expect(getItem).not.toHaveBeenCalled()
85+
expect(setItem).not.toHaveBeenCalled()
86+
expect(fetchMetadata).not.toHaveBeenCalled()
87+
expect(readGlobal('__DEVFRAME_CONNECTION_META__')).toBe(storedConnection.connectionMeta)
88+
})
89+
90+
it.each([{}, { isolateConnection: false }])(
91+
'retains default cache discovery with %j',
92+
async (options) => {
93+
expect.assertions(5)
94+
const connection = await setupDevframeConnection({
95+
baseURL: 'http://ignored.example/',
96+
...options,
97+
})
98+
expect(connection).toBe(storedConnection)
99+
expect(getItem).toHaveBeenCalled()
100+
expect(fetchMetadata).not.toHaveBeenCalled()
101+
expect(setItem).toHaveBeenCalledExactlyOnceWith(
102+
'__DEVFRAME_CONNECTION_AUTH_TOKEN__',
103+
'stored-token',
104+
)
105+
expect(readGlobal('__DEVFRAME_CONNECTION__')).toEqual(storedConnection)
106+
},
107+
)
108+
109+
it('ignores accessible-parent caches while retaining fetched metadata resolution', async () => {
110+
expect.assertions(5)
111+
vi.stubGlobal('__DEVFRAME_CONNECTION__', undefined)
112+
vi.stubGlobal('__DEVFRAME_CONNECTION_META__', undefined)
113+
vi.stubGlobal('__DEVFRAME_CONNECTION_AUTH_TOKEN__', undefined)
114+
const parentWindow = {
115+
__DEVFRAME_CONNECTION__: storedConnection,
116+
__DEVFRAME_CONNECTION_AUTH_TOKEN__: 'parent-token',
117+
}
118+
vi.stubGlobal('parent', { window: parentWindow })
119+
fetchMetadata.mockResolvedValue(Response.json({ backend: 'static', baseUrl: './nested/__connection.json' }))
120+
const connection = await setupDevframeConnection({
121+
baseURL: 'http://requested.example/',
122+
isolateConnection: true,
123+
})
124+
expect(connection.metaBaseUrl).toBe('http://requested.example/nested/__connection.json')
125+
expect(connection.authToken).toBeUndefined()
126+
expect(getItem).not.toHaveBeenCalled()
127+
expect(setItem).not.toHaveBeenCalled()
128+
expect(parentWindow.__DEVFRAME_CONNECTION__).toBe(storedConnection)
129+
})
130+
})

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

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ export interface DevframeConnection {
2626
}
2727

2828
export interface SetupDevframeConnectionOptions {
29+
/**
30+
* Keep connection discovery and credentials local to this RPC client.
31+
* Skips shared window/localStorage reads and writes, and authentication
32+
* broadcasts. Explicit connections, metadata and credentials still apply.
33+
* Pass this option again when reusing a prepared connection.
34+
*
35+
* @default false
36+
*/
37+
isolateConnection?: boolean
2938
/** Reuse a complete connection prepared in another viewer or JavaScript realm. */
3039
connection?: DevframeConnection
3140
/** Use a pre-known descriptor while deriving its source URL from `baseURL`. */
@@ -111,17 +120,27 @@ export function getDevframeConnection(): DevframeConnection | undefined {
111120
export async function setupDevframeConnection(
112121
options: SetupDevframeConnectionOptions = {},
113122
): Promise<DevframeConnection> {
123+
const isolateConnection = options.isolateConnection === true
124+
function resolveAuthToken(authToken: string | undefined): string | undefined {
125+
if (isolateConnection)
126+
return authToken
127+
return readStoredAuthToken(authToken)
128+
}
129+
function finishConnectionSetup(connection: DevframeConnection): DevframeConnection {
130+
if (!isolateConnection)
131+
storeConnection(connection)
132+
return connection
133+
}
114134
if (options.connection) {
115135
const connection = withAuthToken(
116136
options.connection,
117-
readStoredAuthToken(
137+
resolveAuthToken(
118138
options.authToken
119139
?? options.connection.authToken
120140
?? options.connection.connectionMeta.authToken,
121141
),
122142
)
123-
storeConnection(connection)
124-
return connection
143+
return finishConnectionSetup(connection)
125144
}
126145

127146
const bases = Array.isArray(options.baseURL)
@@ -136,26 +155,24 @@ export async function setupDevframeConnection(
136155
* supplied descriptor resolves from the caller's explicit base.
137156
*/
138157
metaBaseUrl: resolveMetaBaseUrl(bases[0] ?? './'),
139-
authToken: readStoredAuthToken(
158+
authToken: resolveAuthToken(
140159
options.authToken ?? options.connectionMeta.authToken,
141160
),
142161
}
143-
storeConnection(connection)
144-
return connection
162+
return finishConnectionSetup(connection)
145163
}
146164

147-
const existing = getDevframeConnection()
165+
const existing = isolateConnection ? undefined : getDevframeConnection()
148166
if (existing) {
149167
const connection = withAuthToken(
150168
existing,
151-
readStoredAuthToken(
169+
resolveAuthToken(
152170
options.authToken
153171
?? existing.authToken
154172
?? existing.connectionMeta.authToken,
155173
),
156174
)
157-
storeConnection(connection)
158-
return connection
175+
return finishConnectionSetup(connection)
159176
}
160177

161178
const errors: Error[] = []
@@ -179,12 +196,11 @@ export async function setupDevframeConnection(
179196
metaBaseUrl: connectionMeta.baseUrl
180197
? new URL(connectionMeta.baseUrl, loadedFrom).href
181198
: loadedFrom,
182-
authToken: readStoredAuthToken(
199+
authToken: resolveAuthToken(
183200
options.authToken ?? connectionMeta.authToken,
184201
),
185202
}
186-
storeConnection(connection)
187-
return connection
203+
return finishConnectionSetup(connection)
188204
}
189205
catch (error) {
190206
errors.push(error as Error)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { DevframeConnection } from './connection'
2+
import { DEVFRAME_CONNECTION_KEY } from 'devframe/constants'
3+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
4+
import { getDevframeRpcClient } from './rpc'
5+
6+
const transport = vi.hoisted(() => ({ close: vi.fn() }))
7+
8+
vi.mock('devframe/rpc/transports/ws-client', () => ({
9+
createWsRpcChannel: () => ({ post: vi.fn(), on: vi.fn(), close: transport.close }),
10+
}))
11+
vi.mock('devframe/rpc/client', () => ({
12+
createRpcClient: () => ({
13+
$callEvent: vi.fn(),
14+
$call: async (method: string, input: { code?: string }) => {
15+
if (method === 'anonymous:devframe:auth:exchange')
16+
return { authToken: `issued-${input.code}` }
17+
if (method === 'anonymous:devframe:auth')
18+
return { isTrusted: true }
19+
return {}
20+
},
21+
}),
22+
}))
23+
24+
const sharedConnection: DevframeConnection = {
25+
connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } },
26+
metaBaseUrl: 'http://shared.example/__connection.json',
27+
authToken: 'shared-token',
28+
}
29+
const storage = { getItem: vi.fn(), setItem: vi.fn() }
30+
const closeChannel = vi.fn()
31+
const channel = vi.fn(class {
32+
postMessage = vi.fn()
33+
close = closeChannel
34+
})
35+
const options = { isolateConnection: true, otpParam: false, simpleAuth: false, webmcp: false } as const
36+
37+
beforeEach(() => {
38+
vi.clearAllMocks()
39+
vi.stubGlobal('location', new URL('http://viewer.example/'))
40+
vi.stubGlobal('navigator', { userAgent: 'test' })
41+
vi.stubGlobal('localStorage', storage)
42+
vi.stubGlobal('BroadcastChannel', channel)
43+
vi.stubGlobal(DEVFRAME_CONNECTION_KEY, sharedConnection)
44+
vi.stubGlobal('__DEVFRAME_CONNECTION_AUTH_TOKEN__', 'shared-token')
45+
})
46+
47+
afterEach(() => {
48+
vi.unstubAllGlobals()
49+
})
50+
51+
it('keeps independent authentication and reconnection local while closing transports', async () => {
52+
expect.assertions(11)
53+
const first = await getDevframeRpcClient({
54+
...options,
55+
connection: { ...sharedConnection, metaBaseUrl: 'http://first.example/__connection.json', authToken: 'first-token' },
56+
})
57+
const second = await getDevframeRpcClient({
58+
...options,
59+
connection: { ...sharedConnection, metaBaseUrl: 'http://second.example/__connection.json', authToken: 'second-token' },
60+
})
61+
try {
62+
expect(await first.requestTrustWithCode('first-code')).toBe(true)
63+
expect(first.connection.authToken).toBe('issued-first-code')
64+
expect(second.connection.authToken).toBe('second-token')
65+
const recreated = await getDevframeRpcClient({ ...options, connection: second.connection })
66+
try {
67+
expect(recreated.connection.metaBaseUrl).toBe('http://second.example/__connection.json')
68+
expect(recreated.connection.authToken).toBe('second-token')
69+
expect(await recreated.requestTrustWithToken('second-updated')).toBe(true)
70+
expect(first.connection.authToken).toBe('issued-first-code')
71+
expect(Reflect.get(globalThis, '__DEVFRAME_CONNECTION_AUTH_TOKEN__')).toBe('shared-token')
72+
expect(storage.setItem).not.toHaveBeenCalled()
73+
expect(channel).not.toHaveBeenCalled()
74+
}
75+
finally {
76+
recreated.close?.()
77+
}
78+
}
79+
finally {
80+
first.close?.()
81+
second.close?.()
82+
}
83+
expect(transport.close).toHaveBeenCalledTimes(3)
84+
})
85+
86+
it.each([{}, { isolateConnection: false }])('preserves shared OTP persistence and broadcasts with %j', async (settings) => {
87+
expect.assertions(6)
88+
const rpcClient = await getDevframeRpcClient({
89+
...options,
90+
isolateConnection: undefined,
91+
...settings,
92+
connection: sharedConnection,
93+
})
94+
try {
95+
expect(await rpcClient.requestTrustWithCode('shared-code')).toBe(true)
96+
expect(rpcClient.connection.authToken).toBe('issued-shared-code')
97+
expect(storage.setItem).toHaveBeenLastCalledWith('__DEVFRAME_CONNECTION_AUTH_TOKEN__', 'issued-shared-code')
98+
expect(channel).toHaveBeenCalledExactlyOnceWith('devframe-auth')
99+
expect(channel.mock.results[0]?.value.postMessage).toHaveBeenCalledExactlyOnceWith({ type: 'auth-update', authToken: 'issued-shared-code' })
100+
}
101+
finally {
102+
rpcClient.close?.()
103+
}
104+
expect(closeChannel).toHaveBeenCalledOnce()
105+
})

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ export function resolveClientTransport(
330330
export async function getDevframeRpcClient(
331331
options: DevframeRpcClientOptions = {},
332332
): Promise<DevframeRpcClient> {
333+
const isolateConnection = options.isolateConnection === true
333334
// Default to a relative base: the SPA owns its mount path at runtime, so
334335
// connection meta and dump shards live alongside `index.html`. An embedded
335336
// surface inside a host page must pass an explicit `baseURL` - its
@@ -428,7 +429,8 @@ export async function getDevframeRpcClient(
428429
// Channel name kept for cross-tab interop with the Vite DevTools auth page.
429430
let authChannel: BroadcastChannel | undefined
430431
try {
431-
authChannel = new BroadcastChannel('devframe-auth')
432+
if (!isolateConnection)
433+
authChannel = new BroadcastChannel('devframe-auth')
432434
}
433435
catch {}
434436

@@ -485,18 +487,18 @@ export async function getDevframeRpcClient(
485487
ensureTrusted: mode.ensureTrusted,
486488
requestTrust: mode.requestTrust,
487489
requestTrustWithToken: async (token: string) => {
488-
// Update stored token for future reconnections
489-
storeAuthToken(token)
490+
if (!isolateConnection)
491+
storeAuthToken(token)
490492
connection = { ...connection, authToken: token }
491493
return mode.requestTrustWithToken(token)
492494
},
493495
requestTrustWithCode: async (code: string) => {
494496
const token = await mode.requestTrustWithCode(code)
495497
if (!token)
496498
return false
497-
// Persist the node-issued token and share it with sibling tabs so they
498-
// become trusted without re-entering the code.
499-
storeAuthToken(token)
499+
/** Shared mode also persists the issued token for sibling tabs. */
500+
if (!isolateConnection)
501+
storeAuthToken(token)
500502
connection = { ...connection, authToken: token }
501503
try {
502504
authChannel?.postMessage({ type: 'auth-update', authToken: token })

‎tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export interface RpcStreamingClientHost {
125125
upload: <T = unknown>(_: string, _: string) => StreamSink<T>;
126126
}
127127
export interface SetupDevframeConnectionOptions {
128+
isolateConnection?: boolean;
128129
connection?: DevframeConnection;
129130
connectionMeta?: ConnectionMeta;
130131
baseURL?: string | string[];

0 commit comments

Comments
 (0)