Skip to content

Commit dc6eda2

Browse files
committed
fix(devframe): carry meta base for cross-base RPC inheritance; surface missing mountConnectionMeta
A mounted devframe SPA inherits the connection meta from a same-origin parent window before it fetches its own `__connection.json`. The meta was published with a relative `websocket.path` but without the base it was resolved against, so a child mounted at a different base resolved the path against its own mount and dialed the wrong endpoint. Publish the meta paired with the absolute (proxy-safe) base it was resolved from, and have the client inherit that base. Backward-compatible: a bare ConnectionMeta on the shared key is still accepted. Exposes `readPublishedConnectionMeta` + `PublishedConnectionMeta` so hosts can reuse the shape. Also make a missing host `mountConnectionMeta` non-silent: mounting a devframe with a servable distDir on a host lacking the hook previously fell through to the HTML fallback and broke the SPA silently. Emit a new DF8106 diagnostic instead.
1 parent c46f01e commit dc6eda2

10 files changed

Lines changed: 171 additions & 9 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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { ConnectionMeta } from 'devframe/types'
2+
import { describe, expect, it } from 'vitest'
3+
import { readPublishedConnectionMeta } from './rpc'
4+
5+
describe('readPublishedConnectionMeta', () => {
6+
const meta: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } }
7+
8+
it('reads the wrapped form, carrying the resolved base', () => {
9+
const result = readPublishedConnectionMeta({
10+
meta,
11+
metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json',
12+
})
13+
expect(result).toEqual({
14+
meta,
15+
metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json',
16+
})
17+
})
18+
19+
it('accepts a wrapped form without a base', () => {
20+
expect(readPublishedConnectionMeta({ meta })).toEqual({ meta, metaBaseUrl: undefined })
21+
})
22+
23+
it('treats a bare ConnectionMeta as legacy, inheriting without a base', () => {
24+
// Backward compatibility: older publishers (and hosts that set the shared
25+
// window key directly) store a raw ConnectionMeta rather than the wrapper.
26+
expect(readPublishedConnectionMeta(meta)).toEqual({ meta })
27+
})
28+
29+
it('returns undefined for non-object values', () => {
30+
expect(readPublishedConnectionMeta(undefined)).toBeUndefined()
31+
expect(readPublishedConnectionMeta(null)).toBeUndefined()
32+
expect(readPublishedConnectionMeta('')).toBeUndefined()
33+
})
34+
})

packages/devframe/src/client/rpc.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,42 @@ function persistAuthToken(token: string): void {
230230
;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token
231231
}
232232

233-
function findConnectionMetaFromWindows(): ConnectionMeta | undefined {
233+
/**
234+
* The connection meta published on a shared window for same-origin inheritance,
235+
* paired with the absolute base URL it was resolved against.
236+
*
237+
* Carrying `metaBaseUrl` is what lets a same-origin child mounted at another
238+
* base (e.g. a hub mounting several devframe SPAs at `/__foo/`, `/__bar/`, …)
239+
* resolve a relative `websocket.path` against the base the publisher loaded
240+
* `__connection.json` from, rather than against the child's own mount — which
241+
* would dial the wrong endpoint.
242+
*/
243+
export interface PublishedConnectionMeta {
244+
meta: ConnectionMeta
245+
/**
246+
* Absolute URL of the `__connection.json` the meta was resolved from. A
247+
* relative `websocket.path` resolves against this, so it stays dialable no
248+
* matter which base the inheriting SPA is mounted at.
249+
*/
250+
metaBaseUrl?: string
251+
}
252+
253+
/**
254+
* Normalize a value read off a shared window under {@link CONNECTION_META_KEY}
255+
* into a {@link PublishedConnectionMeta}. Accepts both the wrapped form (which
256+
* carries the base) and a bare {@link ConnectionMeta} (older publishers, or a
257+
* host that sets the key directly) — the latter inherits without a base.
258+
*/
259+
export function readPublishedConnectionMeta(value: unknown): PublishedConnectionMeta | undefined {
260+
if (!value || typeof value !== 'object')
261+
return undefined
262+
const wrapped = value as Partial<PublishedConnectionMeta>
263+
if (wrapped.meta && typeof wrapped.meta === 'object')
264+
return { meta: wrapped.meta, metaBaseUrl: wrapped.metaBaseUrl }
265+
return { meta: value as ConnectionMeta }
266+
}
267+
268+
function findConnectionMetaFromWindows(): PublishedConnectionMeta | undefined {
234269
const getters = [
235270
() => (window as any)?.[CONNECTION_META_KEY],
236271
() => (globalThis as any)?.[CONNECTION_META_KEY],
@@ -241,7 +276,7 @@ function findConnectionMetaFromWindows(): ConnectionMeta | undefined {
241276
try {
242277
const value = getter()
243278
if (value)
244-
return value
279+
return readPublishedConnectionMeta(value)
245280
}
246281
catch {}
247282
}
@@ -262,13 +297,20 @@ export async function getDevframeRpcClient(
262297
} = options
263298
const events = createEventEmitter<RpcClientEvents>()
264299
const bases = Array.isArray(baseURL) ? baseURL : [baseURL]
265-
let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || findConnectionMetaFromWindows()
300+
const inherited = options.connectionMeta ? undefined : findConnectionMetaFromWindows()
301+
let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || inherited?.meta
266302
let resolvedBaseURL = bases[0] ?? './'
303+
// When the meta is inherited from a same-origin parent, inherit the base it
304+
// was resolved against too, so a relative `websocket.path` resolves against
305+
// the publisher's mount rather than this SPA's own (possibly different) base.
306+
const inheritedMetaBaseUrl = inherited?.metaBaseUrl
267307

268308
// Absolute URL of where `__connection.json` lives, used to resolve a
269309
// relative WS path against the SPA's own origin (proxy-safe). Falls back to
270310
// the page location when running outside a browser document.
271311
function resolveMetaBaseUrl(): string {
312+
if (inheritedMetaBaseUrl)
313+
return inheritedMetaBaseUrl
272314
const metaPath = withBase(DEVFRAME_CONNECTION_META_FILENAME, resolvedBaseURL)
273315
try {
274316
return new URL(metaPath, globalThis.location?.href).href
@@ -285,7 +327,14 @@ export async function getDevframeRpcClient(
285327
connectionMeta = await fetch(withBase(DEVFRAME_CONNECTION_META_FILENAME, base))
286328
.then(r => r.json()) as ConnectionMeta
287329
resolvedBaseURL = base
288-
;(globalThis as any)[CONNECTION_META_KEY] = connectionMeta
330+
// Publish the meta together with the absolute base it was resolved
331+
// against, so a same-origin child mounted at another base inherits a
332+
// dialable endpoint instead of resolving the relative WS path against
333+
// its own mount.
334+
;(globalThis as any)[CONNECTION_META_KEY] = {
335+
meta: connectionMeta,
336+
metaBaseUrl: resolveMetaBaseUrl(),
337+
} satisfies PublishedConnectionMeta
289338
break
290339
}
291340
catch (e) {

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({

tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ export interface DevframeScopedClientStreamingHost {
8484
subscribe: <T = unknown>(_: string, _: string, _?: StreamingSubscribeOptions) => StreamReader<T>;
8585
upload: <T = unknown>(_: string, _: string) => StreamSink<T>;
8686
}
87+
export interface PublishedConnectionMeta {
88+
meta: ConnectionMeta;
89+
metaBaseUrl?: string;
90+
}
8791
export interface RpcClientEvents {
8892
'rpc:is-trusted:updated': (_: boolean) => void;
8993
'connection:status': (_: DevframeConnectionStatus, _: DevframeConnectionStatus) => void;
@@ -129,6 +133,7 @@ export declare function createScopedClientContext<NS extends string = string>(_:
129133
export declare function getDevframeRpcClient(_?: DevframeRpcClientOptions): Promise<DevframeRpcClient>;
130134
export declare function isCallableStatus(_: DevframeConnectionStatus): boolean;
131135
export declare function readOtpFromUrl(_?: string): string | undefined;
136+
export declare function readPublishedConnectionMeta(_: unknown): PublishedConnectionMeta | undefined;
132137
// #endregion
133138

134139
// #region Variables

tests/__snapshots__/tsnapi/devframe/client.snapshot.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export function createScopedClientContext(_, _) {}
1818
export async function getDevframeRpcClient(_) {}
1919
export function isCallableStatus(_) {}
2020
export function readOtpFromUrl(_) {}
21+
export function readPublishedConnectionMeta(_) {}
2122
// #endregion
2223

2324
// #region Variables

0 commit comments

Comments
 (0)