Skip to content

Commit acc95a3

Browse files
authored
fix(devframe): carry meta base for cross-base RPC inheritance; surface missing mountConnectionMeta (#98)
1 parent c46f01e commit acc95a3

9 files changed

Lines changed: 204 additions & 6 deletions

File tree

docs/errors/DF8106.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8106: Connection Meta Not Served
6+
7+
## Message
8+
9+
> The host cannot serve the RPC connection meta for devframe "`{name}`" (id "`{id}`") at "`{base}`" — its `DevframeHost` does not implement `mountConnectionMeta`.
10+
11+
## Cause
12+
13+
A mounted devframe's SPA loads in an iframe at its own base (e.g. `/__terminals/`) and calls `connectDevframe()`, which fetches `./__connection.json` relative to that base to discover the RPC/WebSocket endpoint. `mountDevframe` serves that file at each base by calling the host's `mountConnectionMeta(base)` alongside `mountStatic`.
14+
15+
This diagnostic is reported when a devframe with a servable `cli.distDir` is mounted on a `DevframeHost` that does not implement `mountConnectionMeta`. The SPA's `./__connection.json` fetch then falls through to the host's HTML fallback, so the SPA cannot discover the endpoint and its panel stays empty or stuck loading — previously a silent failure.
16+
17+
The SPA can still connect when it shares an origin with the hub UI, by inheriting the connection meta from the parent window. Cross-origin, sandboxed, or directly-opened iframes have no such parent to inherit from.
18+
19+
## Fix
20+
21+
Implement `mountConnectionMeta(base)` on your `DevframeHost` to serve the same connection meta you expose at the hub's own base:
22+
23+
```ts
24+
const host: DevframeHost = {
25+
mountStatic(base, distDir) { /* serve files */ },
26+
mountConnectionMeta(base) {
27+
// serve `${base}__connection.json` → { backend: 'websocket', websocket: port }
28+
},
29+
resolveOrigin() { /**/ },
30+
getStorageDir(scope) { /**/ },
31+
}
32+
```
33+
34+
A static-snapshot host that bakes `__connection.json` into its served files can implement `mountConnectionMeta` as a no-op to acknowledge this intentionally and silence the diagnostic.
35+
36+
## Source
37+
38+
- [`packages/hub/src/node/mount-devframe.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/mount-devframe.ts)`mountDevframe()` emits this when a devframe with a servable `distDir` is mounted on a host lacking `mountConnectionMeta`.

docs/guide/hub.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const host: DevframeHost = {
5555
}
5656
```
5757

58-
Hosts that omit `mountConnectionMeta` fall back to same-origin window inheritance, which connects an embedded SPA only when it shares an origin with the hub UI.
58+
A host that omits `mountConnectionMeta` while mounting a devframe with a servable `distDir` triggers a [`DF8106`](https://devfra.me/errors/DF8106) diagnostic and falls back to same-origin window inheritance, which connects an embedded SPA only when it shares an origin with the hub UI. When the hub mounts several devframe SPAs at different bases in the same page, inheritance still works: the connection meta is published together with the base it was resolved against, so each same-origin child resolves the RPC/WS endpoint against the publisher's base rather than its own.
5959

6060
### Bundled hosts (Next.js)
6161

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { ConnectionMeta } from 'devframe/types'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { getDevframeRpcClient } from './rpc'
4+
5+
const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__'
6+
7+
// Minimal fake WebSocket: records the URL it was dialed with (all this suite
8+
// needs) and never opens, so the trust handshake stays pending.
9+
class FakeWebSocket {
10+
static instances: FakeWebSocket[] = []
11+
constructor(public url: string) {
12+
FakeWebSocket.instances.push(this)
13+
}
14+
15+
addEventListener(): void {}
16+
removeEventListener(): void {}
17+
send(): void {}
18+
close(): void {}
19+
}
20+
21+
class FakeBroadcastChannel {
22+
onmessage: ((e: any) => void) | null = null
23+
postMessage(): void {}
24+
close(): void {}
25+
}
26+
27+
function lastWsUrl(): string {
28+
return FakeWebSocket.instances.at(-1)!.url
29+
}
30+
31+
describe('getDevframeRpcClient — connection meta base', () => {
32+
beforeEach(() => {
33+
FakeWebSocket.instances = []
34+
vi.stubGlobal('WebSocket', FakeWebSocket)
35+
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
36+
vi.stubGlobal('navigator', { userAgent: 'test' })
37+
vi.stubGlobal('location', {
38+
protocol: 'http:',
39+
host: 'localhost:5173',
40+
hostname: 'localhost',
41+
// The SPA under test is mounted at /__foo/.
42+
href: 'http://localhost:5173/__foo/index.html',
43+
origin: 'http://localhost:5173',
44+
})
45+
delete (globalThis as any)[CONNECTION_META_KEY]
46+
})
47+
48+
afterEach(() => {
49+
vi.restoreAllMocks()
50+
vi.unstubAllGlobals()
51+
delete (globalThis as any)[CONNECTION_META_KEY]
52+
})
53+
54+
it('publishes the meta annotated with the absolute base it resolved from', async () => {
55+
const served: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } }
56+
vi.stubGlobal('fetch', vi.fn(async () => ({ json: async () => served }) as any))
57+
58+
await getDevframeRpcClient({ baseURL: '/__foo/', otpParam: false })
59+
60+
const published = (globalThis as any)[CONNECTION_META_KEY] as ConnectionMeta
61+
expect(published.baseUrl).toBe('http://localhost:5173/__foo/__connection.json')
62+
// The publisher itself dials the endpoint relative to its own base.
63+
expect(lastWsUrl()).toBe('ws://localhost:5173/__foo/__ws')
64+
})
65+
66+
it('inherits the publisher base so a child at another base dials the shared endpoint', async () => {
67+
// A same-origin parent already published its meta, carrying the base it was
68+
// resolved against (`/__devtools/`), not this child's base (`/__foo/`).
69+
;(globalThis as any)[CONNECTION_META_KEY] = {
70+
backend: 'websocket',
71+
websocket: { path: '__ws' },
72+
baseUrl: 'http://localhost:5173/__devtools/__connection.json',
73+
} satisfies ConnectionMeta
74+
const fetchSpy = vi.fn()
75+
vi.stubGlobal('fetch', fetchSpy)
76+
77+
const rpc = await getDevframeRpcClient({ baseURL: '/__foo/', otpParam: false })
78+
79+
// No fetch — the meta came off the window.
80+
expect(fetchSpy).not.toHaveBeenCalled()
81+
// Resolved against the inherited base, not the child's own `/__foo/`.
82+
expect(lastWsUrl()).toBe('ws://localhost:5173/__devtools/__ws')
83+
expect(rpc.connectionMeta.baseUrl).toBe('http://localhost:5173/__devtools/__connection.json')
84+
})
85+
86+
it('ignores a window baseUrl when connection meta is passed explicitly', async () => {
87+
;(globalThis as any)[CONNECTION_META_KEY] = {
88+
backend: 'websocket',
89+
websocket: { path: '__ws' },
90+
baseUrl: 'http://localhost:5173/__devtools/__connection.json',
91+
} satisfies ConnectionMeta
92+
93+
await getDevframeRpcClient({
94+
baseURL: '/__foo/',
95+
otpParam: false,
96+
connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } },
97+
})
98+
99+
// An explicit meta resolves against the client's own base.
100+
expect(lastWsUrl()).toBe('ws://localhost:5173/__foo/__ws')
101+
})
102+
})

packages/devframe/src/client/rpc.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,11 +264,18 @@ export async function getDevframeRpcClient(
264264
const bases = Array.isArray(baseURL) ? baseURL : [baseURL]
265265
let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || findConnectionMetaFromWindows()
266266
let resolvedBaseURL = bases[0] ?? './'
267+
// When the meta is inherited from a same-origin parent, it carries the base
268+
// it was resolved against (`baseUrl`); reuse it so a relative `websocket.path`
269+
// resolves against the publisher's mount rather than this SPA's own
270+
// (possibly different) base.
271+
const inheritedMetaBaseUrl = options.connectionMeta ? undefined : connectionMeta?.baseUrl
267272

268273
// Absolute URL of where `__connection.json` lives, used to resolve a
269274
// relative WS path against the SPA's own origin (proxy-safe). Falls back to
270275
// the page location when running outside a browser document.
271276
function resolveMetaBaseUrl(): string {
277+
if (inheritedMetaBaseUrl)
278+
return inheritedMetaBaseUrl
272279
const metaPath = withBase(DEVFRAME_CONNECTION_META_FILENAME, resolvedBaseURL)
273280
try {
274281
return new URL(metaPath, globalThis.location?.href).href
@@ -285,7 +292,14 @@ export async function getDevframeRpcClient(
285292
connectionMeta = await fetch(withBase(DEVFRAME_CONNECTION_META_FILENAME, base))
286293
.then(r => r.json()) as ConnectionMeta
287294
resolvedBaseURL = base
288-
;(globalThis as any)[CONNECTION_META_KEY] = connectionMeta
295+
// Publish the meta annotated with the absolute base it was resolved
296+
// against (`baseUrl`), so a same-origin child mounted at another base
297+
// inherits a dialable endpoint instead of resolving the relative WS
298+
// path against its own mount.
299+
;(globalThis as any)[CONNECTION_META_KEY] = {
300+
...connectionMeta,
301+
baseUrl: resolveMetaBaseUrl(),
302+
} satisfies ConnectionMeta
289303
break
290304
}
291305
catch (e) {

packages/devframe/src/types/context.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,13 @@ export interface ConnectionMeta {
120120
* structured-clone.
121121
*/
122122
jsonSerializableMethods?: string[]
123+
/**
124+
* Absolute URL of the `__connection.json` this meta was loaded from.
125+
* Annotated by the client (not served) when it publishes the meta on a
126+
* shared window for same-origin inheritance: a relative `websocket.path`
127+
* resolves against this, so a child SPA mounted at another base (e.g. a hub
128+
* mounting several devframes at `/__foo/`, `/__bar/`, …) inherits a dialable
129+
* endpoint rather than resolving the path against its own mount.
130+
*/
131+
baseUrl?: string
123132
}

packages/devframe/src/types/host.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,13 @@ export interface DevframeHost {
2626
* `mountStatic`). Without it, an embedded SPA can only discover the
2727
* endpoint by inheriting it from a same-origin parent window — which fails
2828
* for cross-origin or sandboxed iframes. Implementations serve the same
29-
* meta they expose at the hub's own base. Optional: hosts that can't serve
30-
* a dynamic route (e.g. static-snapshot builds) may omit it.
29+
* meta they expose at the hub's own base.
30+
*
31+
* Optional in the type, but a host that mounts a devframe with a servable
32+
* `distDir` yet omits this hook triggers a `DF8106` diagnostic, since the
33+
* SPA's `./__connection.json` fetch would otherwise fall through and break
34+
* silently. A static-snapshot host that bakes the meta into its served files
35+
* can implement it as a no-op to acknowledge this intentionally.
3136
*/
3237
mountConnectionMeta?: (base: string) => void | Promise<void>
3338

packages/hub/src/node/__tests__/mount-devframe.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,28 @@ describe('mountDevframe', () => {
123123
expect(ctx.docks.views.size).toBe(1)
124124
})
125125

126+
it('serves connection meta at the mounted base when the host implements it', async () => {
127+
const ctx = createContext()
128+
const mountConnectionMeta = vi.fn()
129+
;(ctx.host as { mountConnectionMeta?: unknown }).mountConnectionMeta = mountConnectionMeta
130+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
131+
132+
await mountDevframe(ctx, makeDevframe({ cli: { distDir: '/tmp/demo-dist' } }))
133+
134+
expect(mountConnectionMeta).toHaveBeenCalledWith('/__demo/')
135+
expect(warn).not.toHaveBeenCalled()
136+
})
137+
138+
it('warns (DF8106) when a servable devframe is mounted on a host without mountConnectionMeta', async () => {
139+
const ctx = createContext()
140+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
141+
142+
await mountDevframe(ctx, makeDevframe({ cli: { distDir: '/tmp/demo-dist' } }))
143+
144+
expect(warn).toHaveBeenCalledTimes(1)
145+
expect(warn.mock.calls[0].join(' ')).toContain('DF8106')
146+
})
147+
126148
it('lets instances coexist under disambiguated ids when "duplicate"', async () => {
127149
const ctx = createContext()
128150
const setup = vi.fn()

packages/hub/src/node/diagnostics.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export const diagnostics = defineDiagnostics({
3838
why: (p: { id: string, name: string }) => `Devframe "${p.name}" (id "${p.id}") is already mounted on this hub`,
3939
fix: 'Each devframe is deduplicated by id. Set `duplicationStrategy: "duplicate"` on the definition to let instances coexist, `"silent"` to drop duplicates quietly, or `"throw"` to surface them as errors.',
4040
},
41+
DF8106: {
42+
why: (p: { id: string, name: string, base: string }) => `The host cannot serve the RPC connection meta for devframe "${p.name}" (id "${p.id}") at "${p.base}" — its \`DevframeHost\` does not implement \`mountConnectionMeta\`.`,
43+
fix: 'Implement `mountConnectionMeta(base)` on your DevframeHost so it serves `__connection.json` at each mounted base. Without it, the devframe SPA connects only when it shares an origin with the hub UI (same-origin window inheritance); cross-origin, sandboxed, or directly-opened iframes stay disconnected. Static-snapshot hosts that bake the meta into the served files can implement it as a no-op to acknowledge this intentionally.',
44+
},
4145
DF8200: {
4246
why: (p: { id: string }) => `Terminal session with id "${p.id}" already registered`,
4347
},

packages/hub/src/node/mount-devframe.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,12 @@ export async function mountDevframe(
7878
// discovers the RPC/WS endpoint via `connectDevframe()`'s relative
7979
// `./__connection.json` fetch — instead of relying on inheriting it from a
8080
// same-origin parent window (which breaks for cross-origin / sandboxed
81-
// iframes). Hosts that can't serve a dynamic route simply omit the hook.
82-
await ctx.host.mountConnectionMeta?.(base)
81+
// iframes). A host that omits the hook turns this into silent breakage
82+
// (empty panels / stuck-loading SPAs), so surface it rather than no-op away.
83+
if (ctx.host.mountConnectionMeta)
84+
await ctx.host.mountConnectionMeta(base)
85+
else
86+
diagnostics.DF8106({ id, name: d.name, base })
8387
}
8488

8589
ctx.docks.register({

0 commit comments

Comments
 (0)