diff --git a/docs/content/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index adee7668..9fa88c58 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -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. | @@ -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). diff --git a/packages/devframe/src/client/connection-isolation.test.ts b/packages/devframe/src/client/connection-isolation.test.ts new file mode 100644 index 00000000..f8c71fe3 --- /dev/null +++ b/packages/devframe/src/client/connection-isolation.test.ts @@ -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() +const setItem = vi.fn() +const fetchMetadata = vi.fn() +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) + }) +}) diff --git a/packages/devframe/src/client/connection.ts b/packages/devframe/src/client/connection.ts index 3d7de3b5..e1d3c38d 100644 --- a/packages/devframe/src/client/connection.ts +++ b/packages/devframe/src/client/connection.ts @@ -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 + } /** Use a pre-known descriptor while deriving its source URL from `baseURL`. */ connectionMeta?: ConnectionMeta /** Base URL, or fallback list, used to locate `__connection.json`. */ @@ -111,52 +118,46 @@ export function getDevframeConnection(): DevframeConnection | undefined { export async function setupDevframeConnection( options: SetupDevframeConnectionOptions = {}, ): Promise { - 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 { + 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, + 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) { @@ -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, @@ -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, + isolated: options.connection?.isolated, } - storeConnection(connection) - return connection } catch (error) { errors.push(error as Error) diff --git a/packages/devframe/src/client/rpc-connection-isolation.test.ts b/packages/devframe/src/client/rpc-connection-isolation.test.ts new file mode 100644 index 00000000..2aa21ac5 --- /dev/null +++ b/packages/devframe/src/client/rpc-connection-isolation.test.ts @@ -0,0 +1,104 @@ +import type { DevframeConnection } from './connection' +import { DEVFRAME_CONNECTION_KEY } from 'devframe/constants' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getDevframeRpcClient } from './rpc' + +const transport = vi.hoisted(() => ({ close: vi.fn() })) + +vi.mock('devframe/rpc/transports/ws-client', () => ({ + createWsRpcChannel: () => ({ post: vi.fn(), on: vi.fn(), close: transport.close }), +})) +vi.mock('devframe/rpc/client', () => ({ + createRpcClient: () => ({ + $callEvent: vi.fn(), + $call: async (method: string, input: { code?: string }) => { + if (method === 'anonymous:devframe:auth:exchange') + return { authToken: `issued-${input.code}` } + if (method === 'anonymous:devframe:auth') + return { isTrusted: true } + return {} + }, + }), +})) + +const sharedConnection: DevframeConnection = { + connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } }, + metaBaseUrl: 'http://shared.example/__connection.json', + authToken: 'shared-token', +} +const storage = { getItem: vi.fn(), setItem: vi.fn() } +const closeChannel = vi.fn() +const channel = vi.fn(class { + postMessage = vi.fn() + close = closeChannel +}) +const options = { otpParam: false, simpleAuth: false, webmcp: false } as const + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('location', new URL('http://viewer.example/')) + vi.stubGlobal('navigator', { userAgent: 'test' }) + vi.stubGlobal('localStorage', storage) + vi.stubGlobal('BroadcastChannel', channel) + vi.stubGlobal(DEVFRAME_CONNECTION_KEY, sharedConnection) + vi.stubGlobal('__DEVFRAME_CONNECTION_AUTH_TOKEN__', 'shared-token') +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +it('keeps independent authentication and reconnection local while closing transports', async () => { + expect.assertions(12) + const first = await getDevframeRpcClient({ + ...options, + connection: { ...sharedConnection, metaBaseUrl: 'http://first.example/__connection.json', authToken: 'first-token', isolated: true }, + }) + const second = await getDevframeRpcClient({ + ...options, + connection: { ...sharedConnection, metaBaseUrl: 'http://second.example/__connection.json', authToken: 'second-token', isolated: true }, + }) + try { + expect(await first.requestTrustWithCode('first-code')).toBe(true) + expect(first.connection.authToken).toBe('issued-first-code') + expect(second.connection.authToken).toBe('second-token') + const recreated = await getDevframeRpcClient({ ...options, connection: second.connection }) + try { + expect(recreated.connection.metaBaseUrl).toBe('http://second.example/__connection.json') + expect(recreated.connection.authToken).toBe('second-token') + expect(recreated.connection.isolated).toBe(true) + expect(await recreated.requestTrustWithToken('second-updated')).toBe(true) + expect(first.connection.authToken).toBe('issued-first-code') + expect(Reflect.get(globalThis, '__DEVFRAME_CONNECTION_AUTH_TOKEN__')).toBe('shared-token') + expect(storage.setItem).not.toHaveBeenCalled() + expect(channel).not.toHaveBeenCalled() + } + finally { + recreated.close?.() + } + } + finally { + first.close?.() + second.close?.() + } + expect(transport.close).toHaveBeenCalledTimes(3) +}) + +it.each([{}, { isolated: false }])('preserves shared OTP persistence and broadcasts with %j', async (settings) => { + expect.assertions(6) + const rpcClient = await getDevframeRpcClient({ + ...options, + connection: { ...sharedConnection, ...settings }, + }) + try { + expect(await rpcClient.requestTrustWithCode('shared-code')).toBe(true) + expect(rpcClient.connection.authToken).toBe('issued-shared-code') + expect(storage.setItem).toHaveBeenLastCalledWith('__DEVFRAME_CONNECTION_AUTH_TOKEN__', 'issued-shared-code') + expect(channel).toHaveBeenCalledExactlyOnceWith('devframe-auth') + expect(channel.mock.results[0]?.value.postMessage).toHaveBeenCalledExactlyOnceWith({ type: 'auth-update', authToken: 'issued-shared-code' }) + } + finally { + rpcClient.close?.() + } + expect(closeChannel).toHaveBeenCalledOnce() +}) diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 4f09a287..5e220256 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -425,12 +425,22 @@ export async function getDevframeRpcClient( wsOptions: options.wsOptions, }) - // Channel name kept for cross-tab interop with the Vite DevTools auth page. + /** Channel name kept for cross-tab interop with the Vite DevTools auth page. */ let authChannel: BroadcastChannel | undefined - try { - authChannel = new BroadcastChannel('devframe-auth') + if (!connection.isolated) { + try { + authChannel = new BroadcastChannel('devframe-auth') + } + catch {} + } + + function updateAuthToken(token: string): void { + connection = { ...connection, authToken: token } + if (connection.isolated) + return + + storeAuthToken(token) } - catch {} // Gate outbound calls behind the auth bootstrap below. Without it, a // caller's first RPC calls, fired the moment `connectDevframe()` resolves, @@ -485,19 +495,14 @@ export async function getDevframeRpcClient( ensureTrusted: mode.ensureTrusted, requestTrust: mode.requestTrust, requestTrustWithToken: async (token: string) => { - // Update stored token for future reconnections - storeAuthToken(token) - connection = { ...connection, authToken: token } + updateAuthToken(token) return mode.requestTrustWithToken(token) }, requestTrustWithCode: async (code: string) => { const token = await mode.requestTrustWithCode(code) if (!token) return false - // Persist the node-issued token and share it with sibling tabs so they - // become trusted without re-entering the code. - storeAuthToken(token) - connection = { ...connection, authToken: token } + updateAuthToken(token) try { authChannel?.postMessage({ type: 'auth-update', authToken: token }) } diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 0eebf21b..70da9130 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -6,6 +6,7 @@ export interface DevframeConnection { connectionMeta: ConnectionMeta; metaBaseUrl: string; authToken?: string; + isolated?: boolean; } export interface DevframeRpcClient { events: EventEmitter; @@ -125,7 +126,12 @@ export interface RpcStreamingClientHost { upload: (_: string, _: string) => StreamSink; } export interface SetupDevframeConnectionOptions { - connection?: DevframeConnection; + connection?: DevframeConnection | { + isolated?: boolean; + connectionMeta?: never; + metaBaseUrl?: never; + authToken?: never; + }; connectionMeta?: ConnectionMeta; baseURL?: string | string[]; authToken?: string;