Skip to content

Commit e696af6

Browse files
authored
feat: support portable connections for external viewers (#150)
1 parent 99a5473 commit e696af6

13 files changed

Lines changed: 553 additions & 131 deletions

File tree

docs/guide/client.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,42 @@ For SPA authors, that means:
3232

3333
That's how `createBuild` deploys SPA output verbatim under any URL — no build-time HTML rewriting needed.
3434

35+
### Sharing a connection with an external viewer
36+
37+
`setupDevframeConnection()` prepares a serializable connection independently
38+
of an RPC client. It records the metadata URL alongside the descriptor so a
39+
viewer running on another origin resolves relative paths and side-car ports
40+
against the Devframe server:
41+
42+
```ts
43+
import { setupDevframeConnection } from 'devframe/client'
44+
45+
const connection = await setupDevframeConnection({
46+
baseURL: '/__devframe/',
47+
})
48+
```
49+
50+
Pass that connection to `connectDevframe()` in the viewer:
51+
52+
```ts
53+
import { connectDevframe } from 'devframe/client'
54+
55+
const rpc = await connectDevframe({ connection })
56+
```
57+
58+
The RPC client retains the complete connection as `rpc.connection`, including
59+
the metadata source URL external viewers use to resolve relative resources.
60+
61+
`getDevframeConnection()` returns the prepared connection in the current
62+
window or an accessible parent window. Cross-realm viewers can read the
63+
serializable value through `DEVFRAME_CONNECTION_KEY` from
64+
`devframe/constants`.
65+
3566
### Options
3667

3768
```ts
3869
await connectDevframe({
70+
connection, // prepared by setupDevframeConnection()
3971
baseURL: './', // string or string[] fallback list — see notes below
4072
authToken: 'user-provided-token',
4173
cacheOptions: true, // enable response caching
@@ -46,6 +78,7 @@ await connectDevframe({
4678

4779
| Option | Description |
4880
|--------|-------------|
81+
| `connection` | A connection prepared by `setupDevframeConnection()`. Includes metadata, its source URL, and an optional auth token. |
4982
| `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. |
5083
| `authToken` | Override the auth token. Defaults to a locally-persisted human-readable id. |
5184
| `cacheOptions` | `true` to enable caching with defaults, or an options object. |
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { ConnectionMeta } from 'devframe/types'
2+
import type { DevframeConnection } from './connection'
3+
import { DEVFRAME_CONNECTION_KEY } from 'devframe/constants'
4+
5+
const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__'
6+
const CONNECTION_AUTH_TOKEN_KEY = '__DEVFRAME_CONNECTION_AUTH_TOKEN__'
7+
8+
function readFromWindows<T>(key: string): T | undefined {
9+
const getters = [
10+
() => (window as any)?.[key],
11+
() => (globalThis as any)?.[key],
12+
() => (parent.window as any)?.[key],
13+
]
14+
15+
for (const getter of getters) {
16+
try {
17+
const value = getter()
18+
if (value)
19+
return value as T
20+
}
21+
catch {}
22+
}
23+
}
24+
25+
export function readStoredConnection(): DevframeConnection | undefined {
26+
return readFromWindows<DevframeConnection>(DEVFRAME_CONNECTION_KEY)
27+
}
28+
29+
export function readStoredConnectionMeta(): (ConnectionMeta & { baseUrl?: string }) | undefined {
30+
return readFromWindows<ConnectionMeta & { baseUrl?: string }>(CONNECTION_META_KEY)
31+
}
32+
33+
export function readStoredAuthToken(userAuthToken?: string): string | undefined {
34+
if (userAuthToken)
35+
return userAuthToken
36+
37+
try {
38+
const token = localStorage.getItem(CONNECTION_AUTH_TOKEN_KEY)
39+
if (token)
40+
return token
41+
}
42+
catch {}
43+
44+
return readFromWindows<string>(CONNECTION_AUTH_TOKEN_KEY)
45+
}
46+
47+
export function storeConnection(connection: DevframeConnection): void {
48+
;(globalThis as any)[DEVFRAME_CONNECTION_KEY] = connection
49+
// Keep the established metadata/auth globals in sync for viewers that still
50+
// consume the legacy handoff directly.
51+
;(globalThis as any)[CONNECTION_META_KEY] = {
52+
...connection.connectionMeta,
53+
baseUrl: connection.metaBaseUrl,
54+
}
55+
if (connection.authToken)
56+
storeAuthToken(connection.authToken)
57+
}
58+
59+
export function storeAuthToken(token: string): void {
60+
try {
61+
localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, token)
62+
}
63+
catch {}
64+
;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token
65+
66+
const connection = readStoredConnection()
67+
if (connection) {
68+
;(globalThis as any)[DEVFRAME_CONNECTION_KEY] = {
69+
...connection,
70+
authToken: token,
71+
}
72+
}
73+
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import type { ConnectionMeta } from 'devframe/types'
2+
import { DEVFRAME_CONNECTION_KEY } from 'devframe/constants'
3+
import { afterEach, describe, expect, it, vi } from 'vitest'
4+
import { getDevframeConnection, setupDevframeConnection } from './connection'
5+
import { getDevframeRpcClient } from './rpc'
6+
7+
const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__'
8+
const CONNECTION_AUTH_TOKEN_KEY = '__DEVFRAME_CONNECTION_AUTH_TOKEN__'
9+
10+
const connectionMeta: ConnectionMeta = {
11+
backend: 'websocket',
12+
websocket: 7812,
13+
}
14+
15+
afterEach(() => {
16+
delete (globalThis as any)[DEVFRAME_CONNECTION_KEY]
17+
delete (globalThis as any)[CONNECTION_META_KEY]
18+
delete (globalThis as any)[CONNECTION_AUTH_TOKEN_KEY]
19+
vi.unstubAllGlobals()
20+
vi.restoreAllMocks()
21+
})
22+
23+
describe('setupDevframeConnection', () => {
24+
it('uses an explicit connection without fetching metadata', async () => {
25+
const fetch = vi.fn()
26+
vi.stubGlobal('fetch', fetch)
27+
const explicit = {
28+
connectionMeta,
29+
metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json',
30+
authToken: 'trusted-token',
31+
}
32+
33+
await expect(setupDevframeConnection({
34+
connection: explicit,
35+
})).resolves.toBe(explicit)
36+
expect(fetch).not.toHaveBeenCalled()
37+
expect(getDevframeConnection()).toEqual(explicit)
38+
})
39+
40+
it('exposes the complete connection on the RPC client', async () => {
41+
const connection = {
42+
connectionMeta: {
43+
backend: 'static' as const,
44+
},
45+
metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json',
46+
}
47+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
48+
ok: true,
49+
json: vi.fn().mockResolvedValue({}),
50+
}))
51+
52+
const rpc = await getDevframeRpcClient({
53+
connection,
54+
otpParam: false,
55+
})
56+
57+
expect(rpc.connection).toBe(connection)
58+
expect(rpc.connectionMeta).toBe(connection.connectionMeta)
59+
60+
await rpc.requestTrustWithToken('updated-token')
61+
62+
expect(rpc.connection).toEqual({
63+
...connection,
64+
authToken: 'updated-token',
65+
})
66+
})
67+
68+
it('prefers the explicit connection token over an older stored token', async () => {
69+
;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = 'older-token'
70+
const explicit = {
71+
connectionMeta,
72+
metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json',
73+
authToken: 'current-token',
74+
}
75+
76+
await expect(setupDevframeConnection({
77+
connection: explicit,
78+
})).resolves.toBe(explicit)
79+
})
80+
81+
it('refreshes a prepared connection from shared auth storage', () => {
82+
;(globalThis as any)[DEVFRAME_CONNECTION_KEY] = {
83+
connectionMeta,
84+
metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json',
85+
authToken: 'stale-token',
86+
}
87+
vi.stubGlobal('localStorage', {
88+
getItem: vi.fn().mockReturnValue('current-token'),
89+
})
90+
91+
expect(getDevframeConnection()?.authToken).toBe('current-token')
92+
})
93+
94+
it('uses a token embedded in explicit connection metadata', async () => {
95+
;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = 'older-token'
96+
97+
await expect(setupDevframeConnection({
98+
baseURL: '/__foo/',
99+
connectionMeta: {
100+
...connectionMeta,
101+
authToken: 'hub-token',
102+
},
103+
})).resolves.toMatchObject({
104+
connectionMeta: {
105+
...connectionMeta,
106+
authToken: 'hub-token',
107+
},
108+
metaBaseUrl: '/__foo/__connection.json',
109+
authToken: 'hub-token',
110+
})
111+
})
112+
113+
it('uses a token embedded in fetched connection metadata', async () => {
114+
;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = 'older-token'
115+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
116+
ok: true,
117+
json: vi.fn().mockResolvedValue({
118+
...connectionMeta,
119+
authToken: 'hub-token',
120+
}),
121+
url: 'http://localhost:5173/__devtools/__connection.json',
122+
}))
123+
124+
await expect(setupDevframeConnection()).resolves.toMatchObject({
125+
authToken: 'hub-token',
126+
})
127+
})
128+
129+
it('loads metadata from fallback bases and records its response URL', async () => {
130+
vi.stubGlobal('location', {
131+
href: 'http://app.example.com/',
132+
})
133+
const fetch = vi.fn()
134+
.mockResolvedValueOnce({
135+
ok: false,
136+
status: 404,
137+
url: 'http://app.example.com/__devtools/__connection.json',
138+
})
139+
.mockResolvedValueOnce({
140+
ok: true,
141+
json: vi.fn().mockResolvedValue(connectionMeta),
142+
url: 'http://localhost:5173/__devtools/__connection.json',
143+
})
144+
vi.stubGlobal('fetch', fetch)
145+
146+
const connection = await setupDevframeConnection({
147+
baseURL: [
148+
'/__devtools/',
149+
'http://localhost:5173/__devtools/',
150+
],
151+
})
152+
153+
expect(fetch).toHaveBeenNthCalledWith(
154+
1,
155+
'/__devtools/__connection.json',
156+
)
157+
expect(fetch).toHaveBeenNthCalledWith(
158+
2,
159+
'http://localhost:5173/__devtools/__connection.json',
160+
)
161+
expect(connection).toEqual({
162+
connectionMeta,
163+
metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json',
164+
authToken: undefined,
165+
})
166+
expect(getDevframeConnection()).toEqual(connection)
167+
})
168+
169+
it('normalizes the legacy metadata and auth globals', () => {
170+
;(globalThis as any)[CONNECTION_META_KEY] = {
171+
...connectionMeta,
172+
baseUrl: 'http://localhost:5173/__devtools/__connection.json',
173+
}
174+
;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = 'trusted-token'
175+
176+
expect(getDevframeConnection()).toEqual({
177+
connectionMeta: {
178+
...connectionMeta,
179+
baseUrl: 'http://localhost:5173/__devtools/__connection.json',
180+
},
181+
metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json',
182+
authToken: 'trusted-token',
183+
})
184+
})
185+
186+
it('reports every failed metadata base', async () => {
187+
vi.stubGlobal('location', {
188+
href: 'http://app.example.com/',
189+
})
190+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
191+
ok: false,
192+
status: 404,
193+
}))
194+
195+
const promise = setupDevframeConnection({
196+
baseURL: ['/first/', '/second/'],
197+
})
198+
199+
await expect(promise).rejects.toMatchObject({
200+
message: 'Failed to get connection meta from /first/, /second/',
201+
cause: [
202+
expect.objectContaining({
203+
message: 'Failed to fetch connection meta from http://app.example.com/first/__connection.json: 404',
204+
}),
205+
expect.objectContaining({
206+
message: 'Failed to fetch connection meta from http://app.example.com/second/__connection.json: 404',
207+
}),
208+
],
209+
})
210+
})
211+
})

0 commit comments

Comments
 (0)