diff --git a/docs/errors/DF8106.md b/docs/errors/DF8106.md new file mode 100644 index 00000000..ee3d8bc4 --- /dev/null +++ b/docs/errors/DF8106.md @@ -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`. diff --git a/docs/guide/hub.md b/docs/guide/hub.md index d2f6f60e..a83e44bc 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -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) diff --git a/packages/devframe/src/client/rpc.test.ts b/packages/devframe/src/client/rpc.test.ts new file mode 100644 index 00000000..7d9b3cf7 --- /dev/null +++ b/packages/devframe/src/client/rpc.test.ts @@ -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') + }) +}) diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 191afd94..95742e68 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -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 @@ -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) { diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index 2b6de576..a7a2fd0d 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -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 } diff --git a/packages/devframe/src/types/host.ts b/packages/devframe/src/types/host.ts index 83a71baf..0c2798e9 100644 --- a/packages/devframe/src/types/host.ts +++ b/packages/devframe/src/types/host.ts @@ -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 diff --git a/packages/hub/src/node/__tests__/mount-devframe.test.ts b/packages/hub/src/node/__tests__/mount-devframe.test.ts index f4c6cc09..67a81e63 100644 --- a/packages/hub/src/node/__tests__/mount-devframe.test.ts +++ b/packages/hub/src/node/__tests__/mount-devframe.test.ts @@ -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() diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index 08ce5c0d..65c279d4 100644 --- a/packages/hub/src/node/diagnostics.ts +++ b/packages/hub/src/node/diagnostics.ts @@ -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`, }, diff --git a/packages/hub/src/node/mount-devframe.ts b/packages/hub/src/node/mount-devframe.ts index 9839c889..2569af12 100644 --- a/packages/hub/src/node/mount-devframe.ts +++ b/packages/hub/src/node/mount-devframe.ts @@ -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({