Skip to content
Draft
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
6 changes: 5 additions & 1 deletion docs/content/8.references/5.browser-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The options of `connectDevframe()` / `getDevframeRpcClient()`: [Client](/guide/c

| Option | Description |
|--------|-------------|
| `connection` | Connection prepared by `setupDevframeConnection()`. |
| `connection` | Prepared connection, or `{ isolated: true }` to discover one without shared browser caches or authentication broadcasts. A prepared connection retains its `isolated` setting when reused. Omitted or `false` uses shared behavior. |
| `baseURL` | Mount path to probe for `__connection.json` (array = fallback). Default `'./'` (relative to `document.baseURI`); use an absolute path (`'/__devframe/'`) from outside the SPA. |
| `authToken` | Override the auth token (default: a locally-persisted id). |
| `cacheOptions` | `true` for default caching, or an options object. |
Expand All @@ -23,6 +23,10 @@ The options of `connectDevframe()` / `getDevframeRpcClient()`: [Client](/guide/c
| `connectionMeta` | Descriptor that skips the `__connection.json` fetch. |
| `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). |

Use `connection: { isolated: 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.

When handing a prepared descriptor to another RPC client, pass `{ connection }`; its `isolated` setting is retained. To explicitly change the setting, pass a copied descriptor such as `{ connection: { ...connection, isolated: false } }`. URL code consumption and the authentication prompt remain controlled by `otpParam` and `simpleAuth`.

## RPC client events

Emitted over `rpc.events`: [Events](/guide/client#events).
Expand Down
163 changes: 163 additions & 0 deletions packages/devframe/src/client/connection-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type {
DevframeConnection,
SetupDevframeConnectionOptions,
} from './index'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setupDevframeConnection } from './index'

const storedConnection: DevframeConnection = {
connectionMeta: { backend: 'static' },
metaBaseUrl: 'http://stored.example/__connection.json',
authToken: 'stored-token',
}
const explicitConnection: DevframeConnection = {
connectionMeta: { backend: 'static' },
metaBaseUrl: 'http://explicit.example/__connection.json',
isolated: true,
}
const getItem = vi.fn<Storage['getItem']>()
const setItem = vi.fn<Storage['setItem']>()
const fetchMetadata = vi.fn<typeof fetch>()
function readGlobal(name: string): unknown {
return Reflect.get(globalThis, name)
}

beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('window', globalThis)
vi.stubGlobal('parent', { window: globalThis })
vi.stubGlobal('location', new URL('http://viewer.example/index.html'))
vi.stubGlobal('localStorage', { getItem, setItem })
vi.stubGlobal('fetch', fetchMetadata)
vi.stubGlobal('__DEVFRAME_CONNECTION__', storedConnection)
vi.stubGlobal('__DEVFRAME_CONNECTION_META__', storedConnection.connectionMeta)
vi.stubGlobal('__DEVFRAME_CONNECTION_AUTH_TOKEN__', 'stored-token')
getItem.mockReturnValue('stored-token')
fetchMetadata.mockResolvedValue(Response.json({}))
})

afterEach(() => {
vi.unstubAllGlobals()
})

describe('isolated connection setup', () => {
it('does not discover or persist shared credentials for an explicit isolated connection', async () => {
expect.assertions(5)
const connection = await setupDevframeConnection({
connection: explicitConnection,
})
expect(connection).toBe(explicitConnection)
expect(connection.authToken).toBeUndefined()
expect(getItem).not.toHaveBeenCalled()
expect(setItem).not.toHaveBeenCalled()
expect(readGlobal('__DEVFRAME_CONNECTION__')).toBe(storedConnection)
})

it('fetches the requested base and retains isolation when reusing its descriptor', async () => {
expect.assertions(8)
fetchMetadata.mockResolvedValue(
Response.json({ backend: 'static', authToken: 'metadata-token' }),
)
const connection = await setupDevframeConnection({
baseURL: 'http://requested.example/provider/',
connection: { isolated: true },
})
expect(fetchMetadata).toHaveBeenCalledExactlyOnceWith(
'http://requested.example/provider/__connection.json',
)
expect(connection.metaBaseUrl).toBe('http://requested.example/provider/__connection.json')
expect(connection.authToken).toBe('metadata-token')
expect(connection.isolated).toBe(true)
expect(await setupDevframeConnection({ connection })).toBe(connection)
expect(getItem).not.toHaveBeenCalled()
expect(setItem).not.toHaveBeenCalled()
expect(readGlobal('__DEVFRAME_CONNECTION__')).toBe(storedConnection)
})

it('accepts explicit metadata and token without reading or writing shared caches', async () => {
expect.assertions(6)
const connection = await setupDevframeConnection({
connectionMeta: { backend: 'static', authToken: 'metadata-token' },
baseURL: 'http://requested.example/',
authToken: 'explicit-token',
connection: { isolated: true },
})
expect(connection.authToken).toBe('explicit-token')
expect(connection.metaBaseUrl).toBe('http://requested.example/__connection.json')
expect(getItem).not.toHaveBeenCalled()
expect(setItem).not.toHaveBeenCalled()
expect(fetchMetadata).not.toHaveBeenCalled()
expect(readGlobal('__DEVFRAME_CONNECTION_META__')).toBe(storedConnection.connectionMeta)
})

it.each([{}, { connection: {} }, { connection: { isolated: false } }])(
'retains default cache discovery with %j',
async (options) => {
expect.assertions(5)
const connection = await setupDevframeConnection({
baseURL: 'http://ignored.example/',
...options,
})
expect(connection).toBe(storedConnection)
expect(getItem).toHaveBeenCalled()
expect(fetchMetadata).not.toHaveBeenCalled()
expect(setItem).toHaveBeenCalledExactlyOnceWith(
'__DEVFRAME_CONNECTION_AUTH_TOKEN__',
'stored-token',
)
expect(readGlobal('__DEVFRAME_CONNECTION__')).toEqual(storedConnection)
},
)

it('ignores accessible-parent caches while retaining fetched metadata resolution', async () => {
expect.assertions(5)
vi.stubGlobal('__DEVFRAME_CONNECTION__', undefined)
vi.stubGlobal('__DEVFRAME_CONNECTION_META__', undefined)
vi.stubGlobal('__DEVFRAME_CONNECTION_AUTH_TOKEN__', undefined)
const parentWindow = {
__DEVFRAME_CONNECTION__: storedConnection,
__DEVFRAME_CONNECTION_AUTH_TOKEN__: 'parent-token',
}
vi.stubGlobal('parent', { window: parentWindow })
fetchMetadata.mockResolvedValue(Response.json({ backend: 'static', baseUrl: './nested/__connection.json' }))
const connection = await setupDevframeConnection({
baseURL: 'http://requested.example/',
connection: { isolated: true },
})
expect(connection.metaBaseUrl).toBe('http://requested.example/nested/__connection.json')
expect(connection.authToken).toBeUndefined()
expect(getItem).not.toHaveBeenCalled()
expect(setItem).not.toHaveBeenCalled()
expect(parentWindow.__DEVFRAME_CONNECTION__).toBe(storedConnection)
})
})

describe.each(['provided', 'fetched'] as const)('shared credentials with %s metadata', (metadataSource) => {
it.each([
{ name: 'explicit token overrides metadata and storage', authToken: 'explicit-token', metadataToken: 'metadata-token', storedToken: 'stored-token', expectedToken: 'explicit-token' },
{ name: 'metadata token overrides storage', authToken: undefined, metadataToken: 'metadata-token', storedToken: 'stored-token', expectedToken: 'metadata-token' },
{ name: 'local storage supplies a missing token', authToken: undefined, metadataToken: undefined, storedToken: 'stored-token', expectedToken: 'stored-token' },
{ name: 'window storage supplies a missing token', authToken: undefined, metadataToken: undefined, storedToken: null, expectedToken: 'window-token' },
])('$name', async ({ authToken, metadataToken, storedToken, expectedToken }) => {
expect.assertions(4)
vi.stubGlobal('__DEVFRAME_CONNECTION__', undefined)
vi.stubGlobal('__DEVFRAME_CONNECTION_META__', undefined)
vi.stubGlobal('__DEVFRAME_CONNECTION_AUTH_TOKEN__', 'window-token')
getItem.mockReturnValue(storedToken)
const connectionMeta = { backend: 'static' as const, authToken: metadataToken }
const options: SetupDevframeConnectionOptions = {
baseURL: 'http://requested.example/',
authToken,
}
if (metadataSource === 'provided')
options.connectionMeta = connectionMeta
else
fetchMetadata.mockResolvedValue(Response.json(connectionMeta))

const connection = await setupDevframeConnection(options)
expect(connection.authToken).toBe(expectedToken)
expect(readGlobal('__DEVFRAME_CONNECTION__')).toStrictEqual(connection)
expect(setItem).toHaveBeenCalledExactlyOnceWith('__DEVFRAME_CONNECTION_AUTH_TOKEN__', expectedToken)
expect(fetchMetadata).toHaveBeenCalledTimes(metadataSource === 'fetched' ? 1 : 0)
})
})
76 changes: 37 additions & 39 deletions packages/devframe/src/client/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@ export interface DevframeConnection {
metaBaseUrl: string
/** Previously issued bearer token, when the connection is already trusted. */
authToken?: string
/** Skip shared browser caches and authentication broadcasts. Retained when reconnecting. */
isolated?: boolean
}

export interface SetupDevframeConnectionOptions {
/** Reuse a complete connection prepared in another viewer or JavaScript realm. */
connection?: DevframeConnection
/** Reuse a prepared connection, or configure isolation before resolving its metadata. */
connection?: DevframeConnection | {
isolated?: boolean
connectionMeta?: never
metaBaseUrl?: never
authToken?: never
}
Comment thread
dvcolomban marked this conversation as resolved.
/** Use a pre-known descriptor while deriving its source URL from `baseURL`. */
connectionMeta?: ConnectionMeta
/** Base URL, or fallback list, used to locate `__connection.json`. */
Expand Down Expand Up @@ -111,52 +118,46 @@ export function getDevframeConnection(): DevframeConnection | undefined {
export async function setupDevframeConnection(
options: SetupDevframeConnectionOptions = {},
): Promise<DevframeConnection> {
if (options.connection) {
const connection = withAuthToken(
options.connection,
readStoredAuthToken(
options.authToken
?? options.connection.authToken
?? options.connection.connectionMeta.authToken,
),
)
storeConnection(connection)
return connection
}
const connection = await resolveDevframeConnection(options)
/** Apply token precedence once, regardless of how the connection metadata was resolved. */
const authToken = options.authToken ?? connection.authToken ?? connection.connectionMeta.authToken
const resolvedConnection = withAuthToken(
connection,
connection.isolated ? authToken : readStoredAuthToken(authToken),
)
if (connection.isolated)
return resolvedConnection

storeConnection(resolvedConnection)
return resolvedConnection
}

async function resolveDevframeConnection(
options: SetupDevframeConnectionOptions,
): Promise<DevframeConnection> {
if (options.connection?.connectionMeta)
return options.connection

const bases = Array.isArray(options.baseURL)
? options.baseURL
: [options.baseURL ?? './']

if (options.connectionMeta) {
const connection: DevframeConnection = {
return {
connectionMeta: options.connectionMeta,
/**
* Preserve the established connectionMeta behavior: an explicitly
* supplied descriptor resolves from the caller's explicit base.
*/
metaBaseUrl: resolveMetaBaseUrl(bases[0] ?? './'),
authToken: readStoredAuthToken(
options.authToken ?? options.connectionMeta.authToken,
),
authToken: options.connectionMeta.authToken,
Comment thread
dvcolomban marked this conversation as resolved.
isolated: options.connection?.isolated,
}
storeConnection(connection)
return connection
}

const existing = getDevframeConnection()
if (existing) {
const connection = withAuthToken(
existing,
readStoredAuthToken(
options.authToken
?? existing.authToken
?? existing.connectionMeta.authToken,
),
)
storeConnection(connection)
return connection
}
const existing = options.connection?.isolated ? undefined : getDevframeConnection()
if (existing)
return existing

const errors: Error[] = []
for (const base of bases) {
Expand All @@ -169,7 +170,7 @@ export async function setupDevframeConnection(

const connectionMeta = await response.json() as ConnectionMeta
const loadedFrom = response.url || metaUrl
const connection: DevframeConnection = {
return {
connectionMeta,
/**
* A served `baseUrl` re-points relative resolution (RPC dump shards,
Expand All @@ -179,12 +180,9 @@ export async function setupDevframeConnection(
metaBaseUrl: connectionMeta.baseUrl
? new URL(connectionMeta.baseUrl, loadedFrom).href
: loadedFrom,
authToken: readStoredAuthToken(
options.authToken ?? connectionMeta.authToken,
),
authToken: connectionMeta.authToken,
Comment thread
dvcolomban marked this conversation as resolved.
isolated: options.connection?.isolated,
}
storeConnection(connection)
return connection
}
catch (error) {
errors.push(error as Error)
Expand Down
Loading
Loading