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
38 changes: 38 additions & 0 deletions docs/errors/DF8106.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
outline: deep
---

# DF8106: Connection Meta Not Served

## Message

> The host cannot serve the RPC connection meta for devframe "`{name}`" (id "`{id}`") at "`{base}`" — its `DevframeHost` does not implement `mountConnectionMeta`.

## Cause

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`.

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.

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.

## Fix

Implement `mountConnectionMeta(base)` on your `DevframeHost` to serve the same connection meta you expose at the hub's own base:

```ts
const host: DevframeHost = {
mountStatic(base, distDir) { /* serve files */ },
mountConnectionMeta(base) {
// serve `${base}__connection.json` → { backend: 'websocket', websocket: port }
},
resolveOrigin() { /* … */ },
getStorageDir(scope) { /* … */ },
}
```

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.

## Source

- [`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`.
2 changes: 1 addition & 1 deletion docs/guide/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const host: DevframeHost = {
}
```

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.
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.

### Bundled hosts (Next.js)

Expand Down
102 changes: 102 additions & 0 deletions packages/devframe/src/client/rpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { ConnectionMeta } from 'devframe/types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDevframeRpcClient } from './rpc'

const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__'

// Minimal fake WebSocket: records the URL it was dialed with (all this suite
// needs) and never opens, so the trust handshake stays pending.
class FakeWebSocket {
static instances: FakeWebSocket[] = []
constructor(public url: string) {
FakeWebSocket.instances.push(this)
}

addEventListener(): void {}
removeEventListener(): void {}
send(): void {}
close(): void {}
}

class FakeBroadcastChannel {
onmessage: ((e: any) => void) | null = null
postMessage(): void {}
close(): void {}
}

function lastWsUrl(): string {
return FakeWebSocket.instances.at(-1)!.url
}

describe('getDevframeRpcClient — connection meta base', () => {
beforeEach(() => {
FakeWebSocket.instances = []
vi.stubGlobal('WebSocket', FakeWebSocket)
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
vi.stubGlobal('navigator', { userAgent: 'test' })
vi.stubGlobal('location', {
protocol: 'http:',
host: 'localhost:5173',
hostname: 'localhost',
// The SPA under test is mounted at /__foo/.
href: 'http://localhost:5173/__foo/index.html',
origin: 'http://localhost:5173',
})
delete (globalThis as any)[CONNECTION_META_KEY]
})

afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
delete (globalThis as any)[CONNECTION_META_KEY]
})

it('publishes the meta annotated with the absolute base it resolved from', async () => {
const served: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } }
vi.stubGlobal('fetch', vi.fn(async () => ({ json: async () => served }) as any))

await getDevframeRpcClient({ baseURL: '/__foo/', otpParam: false })

const published = (globalThis as any)[CONNECTION_META_KEY] as ConnectionMeta
expect(published.baseUrl).toBe('http://localhost:5173/__foo/__connection.json')
// The publisher itself dials the endpoint relative to its own base.
expect(lastWsUrl()).toBe('ws://localhost:5173/__foo/__ws')
})

it('inherits the publisher base so a child at another base dials the shared endpoint', async () => {
// A same-origin parent already published its meta, carrying the base it was
// resolved against (`/__devtools/`), not this child's base (`/__foo/`).
;(globalThis as any)[CONNECTION_META_KEY] = {
backend: 'websocket',
websocket: { path: '__ws' },
baseUrl: 'http://localhost:5173/__devtools/__connection.json',
} satisfies ConnectionMeta
const fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)

const rpc = await getDevframeRpcClient({ baseURL: '/__foo/', otpParam: false })

// No fetch — the meta came off the window.
expect(fetchSpy).not.toHaveBeenCalled()
// Resolved against the inherited base, not the child's own `/__foo/`.
expect(lastWsUrl()).toBe('ws://localhost:5173/__devtools/__ws')
expect(rpc.connectionMeta.baseUrl).toBe('http://localhost:5173/__devtools/__connection.json')
})

it('ignores a window baseUrl when connection meta is passed explicitly', async () => {
;(globalThis as any)[CONNECTION_META_KEY] = {
backend: 'websocket',
websocket: { path: '__ws' },
baseUrl: 'http://localhost:5173/__devtools/__connection.json',
} satisfies ConnectionMeta

await getDevframeRpcClient({
baseURL: '/__foo/',
otpParam: false,
connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } },
})

// An explicit meta resolves against the client's own base.
expect(lastWsUrl()).toBe('ws://localhost:5173/__foo/__ws')
})
})
16 changes: 15 additions & 1 deletion packages/devframe/src/client/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,18 @@ export async function getDevframeRpcClient(
const bases = Array.isArray(baseURL) ? baseURL : [baseURL]
let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || findConnectionMetaFromWindows()
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
Expand All @@ -285,7 +292,14 @@ export async function getDevframeRpcClient(
connectionMeta = await fetch(withBase(DEVFRAME_CONNECTION_META_FILENAME, base))
.then(r => r.json()) as ConnectionMeta
resolvedBaseURL = base
;(globalThis as any)[CONNECTION_META_KEY] = connectionMeta
// 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) {
Expand Down
9 changes: 9 additions & 0 deletions packages/devframe/src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,13 @@ export interface ConnectionMeta {
* structured-clone.
*/
jsonSerializableMethods?: string[]
/**
* Absolute URL of the `__connection.json` this meta was loaded from.
* Annotated by the client (not served) when it publishes the meta on a
* shared window for same-origin inheritance: a relative `websocket.path`
* resolves against this, so a child SPA mounted at another base (e.g. a hub
* mounting several devframes at `/__foo/`, `/__bar/`, …) inherits a dialable
* endpoint rather than resolving the path against its own mount.
*/
baseUrl?: string
}
9 changes: 7 additions & 2 deletions packages/devframe/src/types/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,13 @@ export interface DevframeHost {
* `mountStatic`). Without it, an embedded SPA can only discover the
* endpoint by inheriting it from a same-origin parent window — which fails
* for cross-origin or sandboxed iframes. Implementations serve the same
* meta they expose at the hub's own base. Optional: hosts that can't serve
* a dynamic route (e.g. static-snapshot builds) may omit it.
* meta they expose at the hub's own base.
*
* Optional in the type, but a host that mounts a devframe with a servable
* `distDir` yet omits this hook triggers a `DF8106` diagnostic, since the
* SPA's `./__connection.json` fetch would otherwise fall through and break
* silently. A static-snapshot host that bakes the meta into its served files
* can implement it as a no-op to acknowledge this intentionally.
*/
mountConnectionMeta?: (base: string) => void | Promise<void>

Expand Down
22 changes: 22 additions & 0 deletions packages/hub/src/node/__tests__/mount-devframe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,28 @@ describe('mountDevframe', () => {
expect(ctx.docks.views.size).toBe(1)
})

it('serves connection meta at the mounted base when the host implements it', async () => {
const ctx = createContext()
const mountConnectionMeta = vi.fn()
;(ctx.host as { mountConnectionMeta?: unknown }).mountConnectionMeta = mountConnectionMeta
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})

await mountDevframe(ctx, makeDevframe({ cli: { distDir: '/tmp/demo-dist' } }))

expect(mountConnectionMeta).toHaveBeenCalledWith('/__demo/')
expect(warn).not.toHaveBeenCalled()
})

it('warns (DF8106) when a servable devframe is mounted on a host without mountConnectionMeta', async () => {
const ctx = createContext()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})

await mountDevframe(ctx, makeDevframe({ cli: { distDir: '/tmp/demo-dist' } }))

expect(warn).toHaveBeenCalledTimes(1)
expect(warn.mock.calls[0].join(' ')).toContain('DF8106')
})

it('lets instances coexist under disambiguated ids when "duplicate"', async () => {
const ctx = createContext()
const setup = vi.fn()
Expand Down
4 changes: 4 additions & 0 deletions packages/hub/src/node/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export const diagnostics = defineDiagnostics({
why: (p: { id: string, name: string }) => `Devframe "${p.name}" (id "${p.id}") is already mounted on this hub`,
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.',
},
DF8106: {
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\`.`,
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.',
},
DF8200: {
why: (p: { id: string }) => `Terminal session with id "${p.id}" already registered`,
},
Expand Down
8 changes: 6 additions & 2 deletions packages/hub/src/node/mount-devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@ export async function mountDevframe(
// discovers the RPC/WS endpoint via `connectDevframe()`'s relative
// `./__connection.json` fetch — instead of relying on inheriting it from a
// same-origin parent window (which breaks for cross-origin / sandboxed
// iframes). Hosts that can't serve a dynamic route simply omit the hook.
await ctx.host.mountConnectionMeta?.(base)
// iframes). A host that omits the hook turns this into silent breakage
// (empty panels / stuck-loading SPAs), so surface it rather than no-op away.
if (ctx.host.mountConnectionMeta)
await ctx.host.mountConnectionMeta(base)
else
diagnostics.DF8106({ id, name: d.name, base })
}

ctx.docks.register({
Expand Down
Loading