Skip to content
Merged
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
33 changes: 33 additions & 0 deletions docs/guide/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |
Expand Down
73 changes: 73 additions & 0 deletions packages/devframe/src/client/connection-storage.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<DevframeConnection>(DEVFRAME_CONNECTION_KEY)
}

export function readStoredConnectionMeta(): (ConnectionMeta & { baseUrl?: string }) | undefined {
return readFromWindows<ConnectionMeta & { baseUrl?: string }>(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<string>(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,
}
}
}
211 changes: 211 additions & 0 deletions packages/devframe/src/client/connection.test.ts
Original file line number Diff line number Diff line change
@@ -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',
}),
],
})
})
})
Loading
Loading