diff --git a/docs/guide/client.md b/docs/guide/client.md index f0ee6a28..d1bdaf29 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -32,10 +32,42 @@ For SPA authors, that means: That's how `createBuild` deploys SPA output verbatim under any URL — no build-time HTML rewriting needed. +### Sharing a connection with an external viewer + +`setupDevframeConnection()` prepares a serializable connection independently +of an RPC client. It records the metadata URL alongside the descriptor so a +viewer running on another origin resolves relative paths and side-car ports +against the Devframe server: + +```ts +import { setupDevframeConnection } from 'devframe/client' + +const connection = await setupDevframeConnection({ + baseURL: '/__devframe/', +}) +``` + +Pass that connection to `connectDevframe()` in the viewer: + +```ts +import { connectDevframe } from 'devframe/client' + +const rpc = await connectDevframe({ connection }) +``` + +The RPC client retains the complete connection as `rpc.connection`, including +the metadata source URL external viewers use to resolve relative resources. + +`getDevframeConnection()` returns the prepared connection in the current +window or an accessible parent window. Cross-realm viewers can read the +serializable value through `DEVFRAME_CONNECTION_KEY` from +`devframe/constants`. + ### Options ```ts await connectDevframe({ + connection, // prepared by setupDevframeConnection() baseURL: './', // string or string[] fallback list — see notes below authToken: 'user-provided-token', cacheOptions: true, // enable response caching @@ -46,6 +78,7 @@ await connectDevframe({ | Option | Description | |--------|-------------| +| `connection` | A connection prepared by `setupDevframeConnection()`. Includes metadata, its source URL, and an optional auth token. | | `baseURL` | Mount path to probe for `__connection.json`. Accepts an array for fallback. Default: `'./'` — resolved relative to `document.baseURI` so the SPA finds its meta wherever it was deployed. Pass an explicit absolute path (e.g. `'/__devframe/'`) when calling from outside the SPA — say, an embedded webcomponent injected into a host app. | | `authToken` | Override the auth token. Defaults to a locally-persisted human-readable id. | | `cacheOptions` | `true` to enable caching with defaults, or an options object. | diff --git a/packages/devframe/src/client/connection-storage.ts b/packages/devframe/src/client/connection-storage.ts new file mode 100644 index 00000000..8bb16f2b --- /dev/null +++ b/packages/devframe/src/client/connection-storage.ts @@ -0,0 +1,73 @@ +import type { ConnectionMeta } from 'devframe/types' +import type { DevframeConnection } from './connection' +import { DEVFRAME_CONNECTION_KEY } from 'devframe/constants' + +const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__' +const CONNECTION_AUTH_TOKEN_KEY = '__DEVFRAME_CONNECTION_AUTH_TOKEN__' + +function readFromWindows(key: string): T | undefined { + const getters = [ + () => (window as any)?.[key], + () => (globalThis as any)?.[key], + () => (parent.window as any)?.[key], + ] + + for (const getter of getters) { + try { + const value = getter() + if (value) + return value as T + } + catch {} + } +} + +export function readStoredConnection(): DevframeConnection | undefined { + return readFromWindows(DEVFRAME_CONNECTION_KEY) +} + +export function readStoredConnectionMeta(): (ConnectionMeta & { baseUrl?: string }) | undefined { + return readFromWindows(CONNECTION_META_KEY) +} + +export function readStoredAuthToken(userAuthToken?: string): string | undefined { + if (userAuthToken) + return userAuthToken + + try { + const token = localStorage.getItem(CONNECTION_AUTH_TOKEN_KEY) + if (token) + return token + } + catch {} + + return readFromWindows(CONNECTION_AUTH_TOKEN_KEY) +} + +export function storeConnection(connection: DevframeConnection): void { + ;(globalThis as any)[DEVFRAME_CONNECTION_KEY] = connection + // Keep the established metadata/auth globals in sync for viewers that still + // consume the legacy handoff directly. + ;(globalThis as any)[CONNECTION_META_KEY] = { + ...connection.connectionMeta, + baseUrl: connection.metaBaseUrl, + } + if (connection.authToken) + storeAuthToken(connection.authToken) +} + +export function storeAuthToken(token: string): void { + try { + localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, token) + } + catch {} + ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token + + const connection = readStoredConnection() + if (connection) { + ;(globalThis as any)[DEVFRAME_CONNECTION_KEY] = { + ...connection, + authToken: token, + } + } +} diff --git a/packages/devframe/src/client/connection.test.ts b/packages/devframe/src/client/connection.test.ts new file mode 100644 index 00000000..dc9c6249 --- /dev/null +++ b/packages/devframe/src/client/connection.test.ts @@ -0,0 +1,211 @@ +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 { getDevframeRpcClient } from './rpc' + +const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__' +const CONNECTION_AUTH_TOKEN_KEY = '__DEVFRAME_CONNECTION_AUTH_TOKEN__' + +const connectionMeta: ConnectionMeta = { + backend: 'websocket', + websocket: 7812, +} + +afterEach(() => { + delete (globalThis as any)[DEVFRAME_CONNECTION_KEY] + delete (globalThis as any)[CONNECTION_META_KEY] + delete (globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('setupDevframeConnection', () => { + it('uses an explicit connection without fetching metadata', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + const explicit = { + connectionMeta, + metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', + authToken: 'trusted-token', + } + + await expect(setupDevframeConnection({ + connection: explicit, + })).resolves.toBe(explicit) + expect(fetch).not.toHaveBeenCalled() + expect(getDevframeConnection()).toEqual(explicit) + }) + + it('exposes the complete connection on the RPC client', async () => { + const connection = { + connectionMeta: { + backend: 'static' as const, + }, + metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', + } + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({}), + })) + + const rpc = await getDevframeRpcClient({ + connection, + otpParam: false, + }) + + expect(rpc.connection).toBe(connection) + expect(rpc.connectionMeta).toBe(connection.connectionMeta) + + await rpc.requestTrustWithToken('updated-token') + + expect(rpc.connection).toEqual({ + ...connection, + authToken: 'updated-token', + }) + }) + + it('prefers the explicit connection token over an older stored token', async () => { + ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = 'older-token' + const explicit = { + connectionMeta, + metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', + authToken: 'current-token', + } + + await expect(setupDevframeConnection({ + connection: explicit, + })).resolves.toBe(explicit) + }) + + it('refreshes a prepared connection from shared auth storage', () => { + ;(globalThis as any)[DEVFRAME_CONNECTION_KEY] = { + connectionMeta, + metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', + authToken: 'stale-token', + } + vi.stubGlobal('localStorage', { + getItem: vi.fn().mockReturnValue('current-token'), + }) + + expect(getDevframeConnection()?.authToken).toBe('current-token') + }) + + it('uses a token embedded in explicit connection metadata', async () => { + ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = 'older-token' + + await expect(setupDevframeConnection({ + baseURL: '/__foo/', + connectionMeta: { + ...connectionMeta, + authToken: 'hub-token', + }, + })).resolves.toMatchObject({ + connectionMeta: { + ...connectionMeta, + authToken: 'hub-token', + }, + metaBaseUrl: '/__foo/__connection.json', + authToken: 'hub-token', + }) + }) + + it('uses a token embedded in fetched connection metadata', async () => { + ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = 'older-token' + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + ...connectionMeta, + authToken: 'hub-token', + }), + url: 'http://localhost:5173/__devtools/__connection.json', + })) + + await expect(setupDevframeConnection()).resolves.toMatchObject({ + authToken: 'hub-token', + }) + }) + + it('loads metadata from fallback bases and records its response URL', async () => { + vi.stubGlobal('location', { + href: 'http://app.example.com/', + }) + const fetch = vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 404, + url: 'http://app.example.com/__devtools/__connection.json', + }) + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue(connectionMeta), + url: 'http://localhost:5173/__devtools/__connection.json', + }) + vi.stubGlobal('fetch', fetch) + + const connection = await setupDevframeConnection({ + baseURL: [ + '/__devtools/', + 'http://localhost:5173/__devtools/', + ], + }) + + expect(fetch).toHaveBeenNthCalledWith( + 1, + '/__devtools/__connection.json', + ) + expect(fetch).toHaveBeenNthCalledWith( + 2, + 'http://localhost:5173/__devtools/__connection.json', + ) + expect(connection).toEqual({ + connectionMeta, + metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', + authToken: undefined, + }) + expect(getDevframeConnection()).toEqual(connection) + }) + + it('normalizes the legacy metadata and auth globals', () => { + ;(globalThis as any)[CONNECTION_META_KEY] = { + ...connectionMeta, + baseUrl: 'http://localhost:5173/__devtools/__connection.json', + } + ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = 'trusted-token' + + expect(getDevframeConnection()).toEqual({ + connectionMeta: { + ...connectionMeta, + baseUrl: 'http://localhost:5173/__devtools/__connection.json', + }, + metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', + authToken: 'trusted-token', + }) + }) + + it('reports every failed metadata base', async () => { + vi.stubGlobal('location', { + href: 'http://app.example.com/', + }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 404, + })) + + const promise = setupDevframeConnection({ + baseURL: ['/first/', '/second/'], + }) + + await expect(promise).rejects.toMatchObject({ + message: 'Failed to get connection meta from /first/, /second/', + cause: [ + expect.objectContaining({ + message: 'Failed to fetch connection meta from http://app.example.com/first/__connection.json: 404', + }), + expect.objectContaining({ + message: 'Failed to fetch connection meta from http://app.example.com/second/__connection.json: 404', + }), + ], + }) + }) +}) diff --git a/packages/devframe/src/client/connection.ts b/packages/devframe/src/client/connection.ts index 133f62da..d4cd1bfb 100644 --- a/packages/devframe/src/client/connection.ts +++ b/packages/devframe/src/client/connection.ts @@ -1,3 +1,165 @@ +import type { ConnectionMeta } from 'devframe/types' +import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' +import { withBase } from 'ufo' +import { + readStoredAuthToken, + readStoredConnection, + readStoredConnectionMeta, + storeConnection, +} from './connection-storage' + +export interface DevframeConnection { + /** Server-advertised transport and serialization metadata. */ + connectionMeta: ConnectionMeta + /** + * Absolute URL of the `__connection.json` that produced + * {@link connectionMeta}. Relative transport paths and side-car ports are + * resolved from this URL, rather than from an external viewer's location. + */ + metaBaseUrl: string + /** Previously issued bearer token, when the connection is already trusted. */ + authToken?: string +} + +export interface SetupDevframeConnectionOptions { + /** Reuse a complete connection prepared in another viewer or JavaScript realm. */ + connection?: DevframeConnection + /** Use a pre-known descriptor while deriving its source URL from `baseURL`. */ + connectionMeta?: ConnectionMeta + /** Base URL, or fallback list, used to locate `__connection.json`. */ + baseURL?: string | string[] + /** Override the locally stored auth token. */ + authToken?: string +} + +function resolveMetaBaseUrl(baseURL: string): string { + const metaPath = withBase(DEVFRAME_CONNECTION_META_FILENAME, baseURL) + try { + return new URL(metaPath, globalThis.location?.href).href + } + catch { + return metaPath + } +} + +function withAuthToken( + connection: DevframeConnection, + authToken: string | undefined, +): DevframeConnection { + return authToken && authToken !== connection.authToken + ? { ...connection, authToken } + : connection +} + +/** + * Return connection information previously prepared in this window or an + * accessible parent window. + */ +export function getDevframeConnection(): DevframeConnection | undefined { + const connection = readStoredConnection() + if (connection) { + return withAuthToken( + connection, + readStoredAuthToken() + ?? connection.authToken + ?? connection.connectionMeta.authToken, + ) + } + + const connectionMeta = readStoredConnectionMeta() + if (!connectionMeta) + return undefined + + return { + connectionMeta, + metaBaseUrl: connectionMeta.baseUrl ?? resolveMetaBaseUrl('./'), + authToken: readStoredAuthToken(connectionMeta.authToken), + } +} + +/** + * Prepare the connection information shared by a devframe client and external + * viewers. Reuses an explicit or previously prepared connection before + * fetching `__connection.json` from the configured base URLs. + */ +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 bases = Array.isArray(options.baseURL) + ? options.baseURL + : [options.baseURL ?? './'] + + if (options.connectionMeta) { + const connection: DevframeConnection = { + 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, + ), + } + 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 errors: Error[] = [] + for (const base of bases) { + const metaPath = withBase(DEVFRAME_CONNECTION_META_FILENAME, base) + const metaUrl = resolveMetaBaseUrl(base) + try { + const response = await fetch(metaPath) + if (!response.ok) + throw new Error(`Failed to fetch connection meta from ${metaUrl}: ${response.status}`) + + const connectionMeta = await response.json() as ConnectionMeta + const connection: DevframeConnection = { + connectionMeta, + metaBaseUrl: response.url || metaUrl, + authToken: readStoredAuthToken( + options.authToken ?? connectionMeta.authToken, + ), + } + storeConnection(connection) + return connection + } + catch (error) { + errors.push(error as Error) + } + } + + throw new Error(`Failed to get connection meta from ${bases.join(', ')}`, { + cause: errors, + }) +} + /** * The connection lifecycle of a devframe client, as a single value a UI can * render from. Derived from the transport (WebSocket open/close/error) and the diff --git a/packages/devframe/src/client/rpc-ws.test.ts b/packages/devframe/src/client/rpc-ws.test.ts index 5f848f53..b636470c 100644 --- a/packages/devframe/src/client/rpc-ws.test.ts +++ b/packages/devframe/src/client/rpc-ws.test.ts @@ -17,6 +17,13 @@ const httpsProxyLoc: WsUrlLocation = { href: 'https://devtools.example.com/app/__foo/index.html', } +const extensionLoc: WsUrlLocation = { + protocol: 'chrome-extension:', + host: 'abcdefghijklmnopabcdefghijklmnop', + hostname: 'abcdefghijklmnopabcdefghijklmnop', + href: 'chrome-extension://abcdefghijklmnopabcdefghijklmnop/devtools-panel.html', +} + describe('resolveWsUrl', () => { it('resolves a relative path against the meta base, same-origin', () => { const url = resolveWsUrl( @@ -55,11 +62,19 @@ describe('resolveWsUrl', () => { expect(url).toBe('ws://inner:1234/__devframe_ws') }) - it('keeps the legacy numeric-port form (page hostname)', () => { + it('keeps the legacy numeric-port form on the metadata hostname', () => { expect(resolveWsUrl(9999, './', httpLoc)).toBe('ws://localhost:9999') expect(resolveWsUrl(9999, './', httpsProxyLoc)).toBe('wss://devtools.example.com:9999') }) + it('anchors a numeric port to the metadata host for external viewers', () => { + expect(resolveWsUrl( + 7812, + 'http://localhost:3000/__devtools/__connection.json', + extensionLoc, + )).toBe('ws://localhost:7812') + }) + it('uses a full ws/wss URL verbatim', () => { expect(resolveWsUrl('wss://example.com/socket', './', httpLoc)).toBe('wss://example.com/socket') }) diff --git a/packages/devframe/src/client/rpc-ws.ts b/packages/devframe/src/client/rpc-ws.ts index c2e92711..c6c1da5a 100644 --- a/packages/devframe/src/client/rpc-ws.ts +++ b/packages/devframe/src/client/rpc-ws.ts @@ -50,7 +50,6 @@ export function resolveWsUrl( metaBaseUrl: string, loc: WsUrlLocation, ): string { - const wsProtocol = loc.protocol === 'https:' ? 'wss:' : 'ws:' const base = (() => { try { return new URL(metaBaseUrl, loc.href) @@ -59,6 +58,7 @@ export function resolveWsUrl( return new URL(loc.href) } })() + const wsProtocol = base.protocol === 'https:' ? 'wss:' : 'ws:' // Object form — the proxy-flexible default. if (websocket && typeof websocket === 'object') { @@ -67,7 +67,7 @@ export function resolveWsUrl( // meta file sits. Otherwise stay same-origin and resolve the path relative // to the meta base so a reverse-proxied subpath is honored. if (websocket.host != null || websocket.port != null) { - const host = websocket.host ?? `${loc.hostname}:${websocket.port}` + const host = websocket.host ?? `${base.hostname}:${websocket.port}` const target = new URL(websocket.path ?? '/', `${wsProtocol}//${host}`) target.protocol = wsProtocol return target.href @@ -77,9 +77,10 @@ export function resolveWsUrl( return target.href } - // Legacy numeric port — page hostname, explicit port. + // Legacy numeric port — metadata hostname, explicit port. External viewers + // (such as browser extensions) have their own unrelated location hostname. if (typeof websocket === 'number') - return `${wsProtocol}//${loc.hostname}:${websocket}` + return `${wsProtocol}//${base.hostname}:${websocket}` const str = websocket ?? '' // Full WS URL — used verbatim. diff --git a/packages/devframe/src/client/rpc.test.ts b/packages/devframe/src/client/rpc.test.ts index 3bd88f9d..2fde3105 100644 --- a/packages/devframe/src/client/rpc.test.ts +++ b/packages/devframe/src/client/rpc.test.ts @@ -1,4 +1,5 @@ import type { ConnectionMeta } from 'devframe/types' +import { DEVFRAME_CONNECTION_KEY } from 'devframe/constants' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDevframeRpcClient } from './rpc' @@ -45,6 +46,7 @@ describe('getDevframeRpcClient — connection meta base', () => { }) delete (globalThis as any)[CONNECTION_META_KEY] delete (globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] + delete (globalThis as any)[DEVFRAME_CONNECTION_KEY] }) afterEach(() => { @@ -52,6 +54,7 @@ describe('getDevframeRpcClient — connection meta base', () => { vi.unstubAllGlobals() delete (globalThis as any)[CONNECTION_META_KEY] delete (globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] + delete (globalThis as any)[DEVFRAME_CONNECTION_KEY] }) it('publishes the meta annotated with the absolute base it resolved from', async () => { diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 05a1cfac..3fcf65f9 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -2,16 +2,15 @@ import type { BirpcOptions, BirpcReturn } from 'birpc' import type { RpcCacheOptions, RpcFunctionsCollector } from 'devframe/rpc' import type { WsRpcChannelOptions } from 'devframe/rpc/transports/ws-client' import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions, EventEmitter, RpcSharedStateHost, SettingsForNamespace } from 'devframe/types' -import type { DevframeConnectionStatus } from './connection' +import type { DevframeConnection, DevframeConnectionStatus, SetupDevframeConnectionOptions } from './connection' import type { RpcStreamingClientHost } from './rpc-streaming' import type { DevframeScopedClientContext } from './scope' -import { - DEVFRAME_CONNECTION_META_FILENAME, - DEVFRAME_OTP_URL_PARAM, -} from 'devframe/constants' +import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants' import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc' import { createEventEmitter } from 'devframe/utils/events' import { withBase } from 'ufo' +import { setupDevframeConnection } from './connection' +import { storeAuthToken } from './connection-storage' import { authenticateWithUrlOtp } from './otp' import { createRpcSharedStateClientHost } from './rpc-shared-state' import { createStaticRpcClientMode } from './rpc-static' @@ -47,12 +46,7 @@ export interface RpcClientEvents { 'rpc:error': (error: Error, method: string) => void } -const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__' -const CONNECTION_AUTH_TOKEN_KEY = '__DEVFRAME_CONNECTION_AUTH_TOKEN__' - -export interface DevframeRpcClientOptions { - connectionMeta?: ConnectionMeta - baseURL?: string | string[] +export interface DevframeRpcClientOptions extends SetupDevframeConnectionOptions { /** * The auth token to use for the client */ @@ -120,7 +114,12 @@ export interface DevframeRpcClient { */ readonly connectionError: Error | null /** - * The connection meta + * The complete connection used by this client, including the metadata source + * URL external viewers use to resolve relative resources. + */ + readonly connection: DevframeConnection + /** + * The server-advertised connection metadata. */ readonly connectionMeta: ConnectionMeta /** @@ -215,54 +214,6 @@ export interface DevframeRpcClientMode { callOptional: DevframeRpcClient['callOptional'] } -function getStoredAuthToken(userAuthToken?: string): string | undefined { - const getters = [ - () => userAuthToken, - () => localStorage.getItem(CONNECTION_AUTH_TOKEN_KEY) ?? undefined, - () => (window as any)?.[CONNECTION_AUTH_TOKEN_KEY], - () => (globalThis as any)?.[CONNECTION_AUTH_TOKEN_KEY], - () => (parent.window as any)?.[CONNECTION_AUTH_TOKEN_KEY], - ] - - for (const getter of getters) { - try { - const value = getter() - if (value) - return value - } - catch {} - } - - // No token yet — the client is unauthenticated and must exchange a one-time - // code (see `requestTrustWithCode`) to obtain a node-issued token. - return undefined -} - -function persistAuthToken(token: string): void { - try { - localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, token) - } - catch {} - ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token -} - -function findConnectionMetaFromWindows(): ConnectionMeta | undefined { - const getters = [ - () => (window as any)?.[CONNECTION_META_KEY], - () => (globalThis as any)?.[CONNECTION_META_KEY], - () => (parent.window as any)?.[CONNECTION_META_KEY], - ] - - for (const getter of getters) { - try { - const value = getter() - if (value) - return value - } - catch {} - } -} - export async function getDevframeRpcClient( options: DevframeRpcClientOptions = {}, ): Promise { @@ -278,73 +229,18 @@ export async function getDevframeRpcClient( } = options const events = createEventEmitter() const bases = Array.isArray(baseURL) ? baseURL : [baseURL] - let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || findConnectionMetaFromWindows() + let connection = await setupDevframeConnection(options) + const { connectionMeta, metaBaseUrl, authToken } = connection let resolvedBaseURL = bases[0] ?? './' - // When the meta is inherited from a same-origin parent, it carries the base - // it was resolved against (`baseUrl`); reuse it so a relative `websocket.path` - // resolves against the publisher's mount rather than this SPA's own - // (possibly different) base. - const inheritedMetaBaseUrl = options.connectionMeta ? undefined : connectionMeta?.baseUrl - - // Absolute URL of where `__connection.json` lives, used to resolve a - // relative WS path against the SPA's own origin (proxy-safe). Falls back to - // the page location when running outside a browser document. - function resolveMetaBaseUrl(): string { - if (inheritedMetaBaseUrl) - return inheritedMetaBaseUrl - const metaPath = withBase(DEVFRAME_CONNECTION_META_FILENAME, resolvedBaseURL) - try { - return new URL(metaPath, globalThis.location?.href).href - } - catch { - return metaPath - } - } - - if (!connectionMeta) { - const errors: Error[] = [] - for (const base of bases) { - try { - const response = await fetch(withBase(DEVFRAME_CONNECTION_META_FILENAME, base)) - if (!response.ok) - throw new Error(`Failed to fetch connection meta: ${response.status}`) - connectionMeta = await response.json() as ConnectionMeta - resolvedBaseURL = base - // Publish the meta annotated with the absolute base it was resolved - // against (`baseUrl`), so a same-origin child mounted at another base - // inherits a dialable endpoint instead of resolving the relative WS - // path against its own mount. - ;(globalThis as any)[CONNECTION_META_KEY] = { - ...connectionMeta, - baseUrl: resolveMetaBaseUrl(), - } satisfies ConnectionMeta - break - } - catch (e) { - errors.push(e as Error) - } - } - if (!connectionMeta) { - throw new Error(`Failed to get connection meta from ${bases.join(', ')}`, { - cause: errors, - }) - } + try { + resolvedBaseURL = new URL('.', metaBaseUrl).href } + catch {} const cacheManager = new RpcCacheManager({ functions: [], ...(typeof options.cacheOptions === 'object' ? options.cacheOptions : {}) }) const context: DevframeRpcContext = { rpc: undefined!, } - // An explicit option wins, then a token baked into the (hub-served) meta — - // the cross-origin channel a framed plugin relies on since it can't read the - // hub's `localStorage` — then this origin's own stored token. - const authToken = getStoredAuthToken(options.authToken || connectionMeta.authToken) - // Persist a resolved token so one supplied out-of-band — e.g. a host that - // bootstraps trust by passing `authToken` (read from its own page URL query) - // — survives reconnects. The token is still sent to the server via the WS - // URL query param (`?devframe_auth_token=`) by the transport. - if (authToken) - persistAuthToken(authToken) const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase(context) async function fetchJsonFromBases(path: string): Promise { @@ -380,7 +276,7 @@ export async function getDevframeRpcClient( : createWsRpcClientMode({ authToken, connectionMeta, - metaBaseUrl: resolveMetaBaseUrl(), + metaBaseUrl, events, clientRpc, callTimeout: options.callTimeout, @@ -445,12 +341,16 @@ export async function getDevframeRpcClient( get connectionError() { return mode.connectionError }, + get connection() { + return connection + }, connectionMeta, ensureTrusted: mode.ensureTrusted, requestTrust: mode.requestTrust, requestTrustWithToken: async (token: string) => { // Update stored token for future reconnections - persistAuthToken(token) + storeAuthToken(token) + connection = { ...connection, authToken: token } return mode.requestTrustWithToken(token) }, requestTrustWithCode: async (code: string) => { @@ -459,7 +359,8 @@ export async function getDevframeRpcClient( return false // Persist the node-issued token and share it with sibling tabs so they // become trusted without re-entering the code. - persistAuthToken(token) + storeAuthToken(token) + connection = { ...connection, authToken: token } try { authChannel?.postMessage({ type: 'auth-update', authToken: token }) } diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index 21f9d64f..90ef82e0 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -5,6 +5,13 @@ export const DEVFRAME_DIRNAME = '__devframe' export const DEVFRAME_CONNECTION_META_FILENAME = '__connection.json' +/** + * Global key holding the serializable connection prepared by + * `setupDevframeConnection()`. External viewers can use this key to read the + * connection from another JavaScript realm. + */ +export const DEVFRAME_CONNECTION_KEY = '__DEVFRAME_CONNECTION__' + /** * Route the WebSocket RPC endpoint is bound to, relative to a devframe's * base path. Sits next to `__connection.json` so the deployed SPA can reach diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 0974a003..79a44dba 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -2,11 +2,17 @@ * Generated by tsnapi — public API snapshot of `devframe/client` */ // #region Interfaces +export interface DevframeConnection { + connectionMeta: ConnectionMeta; + metaBaseUrl: string; + authToken?: string; +} export interface DevframeRpcClient { events: EventEmitter; readonly isTrusted: boolean | null; readonly status: DevframeConnectionStatus; readonly connectionError: Error | null; + readonly connection: DevframeConnection; readonly connectionMeta: ConnectionMeta; ensureTrusted: (_?: number) => Promise; requestTrust: () => Promise; @@ -36,9 +42,7 @@ export interface DevframeRpcClientMode { callEvent: DevframeRpcClient['callEvent']; callOptional: DevframeRpcClient['callOptional']; } -export interface DevframeRpcClientOptions { - connectionMeta?: ConnectionMeta; - baseURL?: string | string[]; +export interface DevframeRpcClientOptions extends SetupDevframeConnectionOptions { authToken?: string; otpParam?: string | false; simpleAuth?: boolean; @@ -95,6 +99,12 @@ export interface RpcStreamingClientHost { subscribe: (_: string, _: string, _?: StreamingSubscribeOptions) => StreamReader; upload: (_: string, _: string) => StreamSink; } +export interface SetupDevframeConnectionOptions { + connection?: DevframeConnection; + connectionMeta?: ConnectionMeta; + baseURL?: string | string[]; + authToken?: string; +} export interface StreamingSubscribeOptions { highWaterMark?: number; } @@ -127,9 +137,11 @@ export declare function consumeOtpFromUrl(_?: string): string | undefined; export declare function createClientSettings = Record>(_: DevframeRpcClient, _: string): DevframeSettings; export declare function createRpcStreamingClientHost(_: DevframeRpcClient): RpcStreamingClientHost; export declare function createScopedClientContext(_: DevframeRpcClient, _: NS): DevframeScopedClientContext; +export declare function getDevframeConnection(): DevframeConnection | undefined; export declare function getDevframeRpcClient(_?: DevframeRpcClientOptions): Promise; export declare function isCallableStatus(_: DevframeConnectionStatus): boolean; export declare function readOtpFromUrl(_?: string): string | undefined; +export declare function setupDevframeConnection(_?: SetupDevframeConnectionOptions): Promise; // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js index fd810c9b..b37b884a 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -15,9 +15,11 @@ export function consumeOtpFromUrl(_) {} export function createClientSettings(_, _) {} export function createRpcStreamingClientHost(_) {} export function createScopedClientContext(_, _) {} +export function getDevframeConnection() {} export async function getDevframeRpcClient(_) {} export function isCallableStatus(_) {} export function readOtpFromUrl(_) {} +export async function setupDevframeConnection(_) {} // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index 134ff969..6c00b0cd 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -8,6 +8,7 @@ export declare function isAnonymousRpcMethod(_: string): boolean; // #region Variables export declare const ANONYMOUS_RPC_PREFIX: string; export declare const DEVFRAME_AUTH_TOKEN_QUERY_PARAM: string; +export declare const DEVFRAME_CONNECTION_KEY: string; export declare const DEVFRAME_CONNECTION_META_FILENAME: string; export declare const DEVFRAME_DIRNAME: string; export declare const DEVFRAME_DOCK_IMPORTS_FILENAME: string; diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js index 9f16fe56..462baa00 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js @@ -8,6 +8,7 @@ export function isAnonymousRpcMethod(_) {} // #region Variables export var ANONYMOUS_RPC_PREFIX /* const */ export var DEVFRAME_AUTH_TOKEN_QUERY_PARAM /* const */ +export var DEVFRAME_CONNECTION_KEY /* const */ export var DEVFRAME_CONNECTION_META_FILENAME /* const */ export var DEVFRAME_DIRNAME /* const */ export var DEVFRAME_DOCK_IMPORTS_FILENAME /* const */