From dc6eda2563335c1167543e775bbe7948f43b6e6a Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 15 Jul 2026 13:09:52 +0000 Subject: [PATCH 1/2] 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. --- docs/errors/DF8106.md | 38 +++++++++++++ docs/guide/hub.md | 2 +- packages/devframe/src/client/rpc.test.ts | 34 +++++++++++ packages/devframe/src/client/rpc.ts | 57 +++++++++++++++++-- packages/devframe/src/types/host.ts | 9 ++- .../src/node/__tests__/mount-devframe.test.ts | 22 +++++++ packages/hub/src/node/diagnostics.ts | 4 ++ packages/hub/src/node/mount-devframe.ts | 8 ++- .../tsnapi/devframe/client.snapshot.d.ts | 5 ++ .../tsnapi/devframe/client.snapshot.js | 1 + 10 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 docs/errors/DF8106.md create mode 100644 packages/devframe/src/client/rpc.test.ts 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..ae940caf --- /dev/null +++ b/packages/devframe/src/client/rpc.test.ts @@ -0,0 +1,34 @@ +import type { ConnectionMeta } from 'devframe/types' +import { describe, expect, it } from 'vitest' +import { readPublishedConnectionMeta } from './rpc' + +describe('readPublishedConnectionMeta', () => { + const meta: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } } + + it('reads the wrapped form, carrying the resolved base', () => { + const result = readPublishedConnectionMeta({ + meta, + metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', + }) + expect(result).toEqual({ + meta, + metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', + }) + }) + + it('accepts a wrapped form without a base', () => { + expect(readPublishedConnectionMeta({ meta })).toEqual({ meta, metaBaseUrl: undefined }) + }) + + it('treats a bare ConnectionMeta as legacy, inheriting without a base', () => { + // Backward compatibility: older publishers (and hosts that set the shared + // window key directly) store a raw ConnectionMeta rather than the wrapper. + expect(readPublishedConnectionMeta(meta)).toEqual({ meta }) + }) + + it('returns undefined for non-object values', () => { + expect(readPublishedConnectionMeta(undefined)).toBeUndefined() + expect(readPublishedConnectionMeta(null)).toBeUndefined() + expect(readPublishedConnectionMeta('')).toBeUndefined() + }) +}) diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 191afd94..8eecdd8d 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -230,7 +230,42 @@ function persistAuthToken(token: string): void { ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token } -function findConnectionMetaFromWindows(): ConnectionMeta | undefined { +/** + * The connection meta published on a shared window for same-origin inheritance, + * paired with the absolute base URL it was resolved against. + * + * Carrying `metaBaseUrl` is what lets a same-origin child mounted at another + * base (e.g. a hub mounting several devframe SPAs at `/__foo/`, `/__bar/`, …) + * resolve a relative `websocket.path` against the base the publisher loaded + * `__connection.json` from, rather than against the child's own mount — which + * would dial the wrong endpoint. + */ +export interface PublishedConnectionMeta { + meta: ConnectionMeta + /** + * Absolute URL of the `__connection.json` the meta was resolved from. A + * relative `websocket.path` resolves against this, so it stays dialable no + * matter which base the inheriting SPA is mounted at. + */ + metaBaseUrl?: string +} + +/** + * Normalize a value read off a shared window under {@link CONNECTION_META_KEY} + * into a {@link PublishedConnectionMeta}. Accepts both the wrapped form (which + * carries the base) and a bare {@link ConnectionMeta} (older publishers, or a + * host that sets the key directly) — the latter inherits without a base. + */ +export function readPublishedConnectionMeta(value: unknown): PublishedConnectionMeta | undefined { + if (!value || typeof value !== 'object') + return undefined + const wrapped = value as Partial + if (wrapped.meta && typeof wrapped.meta === 'object') + return { meta: wrapped.meta, metaBaseUrl: wrapped.metaBaseUrl } + return { meta: value as ConnectionMeta } +} + +function findConnectionMetaFromWindows(): PublishedConnectionMeta | undefined { const getters = [ () => (window as any)?.[CONNECTION_META_KEY], () => (globalThis as any)?.[CONNECTION_META_KEY], @@ -241,7 +276,7 @@ function findConnectionMetaFromWindows(): ConnectionMeta | undefined { try { const value = getter() if (value) - return value + return readPublishedConnectionMeta(value) } catch {} } @@ -262,13 +297,20 @@ export async function getDevframeRpcClient( } = options const events = createEventEmitter() const bases = Array.isArray(baseURL) ? baseURL : [baseURL] - let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || findConnectionMetaFromWindows() + const inherited = options.connectionMeta ? undefined : findConnectionMetaFromWindows() + let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || inherited?.meta let resolvedBaseURL = bases[0] ?? './' + // When the meta is inherited from a same-origin parent, inherit the base it + // was resolved against too, so a relative `websocket.path` resolves against + // the publisher's mount rather than this SPA's own (possibly different) base. + const inheritedMetaBaseUrl = inherited?.metaBaseUrl // 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 +327,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 together with the absolute base it was resolved + // against, 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] = { + meta: connectionMeta, + metaBaseUrl: resolveMetaBaseUrl(), + } satisfies PublishedConnectionMeta break } catch (e) { 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({ diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 72ce9efb..d46a48e9 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -84,6 +84,10 @@ export interface DevframeScopedClientStreamingHost { subscribe: (_: string, _: string, _?: StreamingSubscribeOptions) => StreamReader; upload: (_: string, _: string) => StreamSink; } +export interface PublishedConnectionMeta { + meta: ConnectionMeta; + metaBaseUrl?: string; +} export interface RpcClientEvents { 'rpc:is-trusted:updated': (_: boolean) => void; 'connection:status': (_: DevframeConnectionStatus, _: DevframeConnectionStatus) => void; @@ -129,6 +133,7 @@ export declare function createScopedClientContext(_: export declare function getDevframeRpcClient(_?: DevframeRpcClientOptions): Promise; export declare function isCallableStatus(_: DevframeConnectionStatus): boolean; export declare function readOtpFromUrl(_?: string): string | undefined; +export declare function readPublishedConnectionMeta(_: unknown): PublishedConnectionMeta | undefined; // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js index fd810c9b..faec6a2e 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -18,6 +18,7 @@ export function createScopedClientContext(_, _) {} export async function getDevframeRpcClient(_) {} export function isCallableStatus(_) {} export function readOtpFromUrl(_) {} +export function readPublishedConnectionMeta(_) {} // #endregion // #region Variables From 09ab91182bf825152e785373e0ea6d6994a97146 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 15 Jul 2026 13:20:22 +0000 Subject: [PATCH 2/2] refactor(devframe): carry inheritance base as ConnectionMeta.baseUrl Replace the published `{ meta, metaBaseUrl }` wrapper with an optional `baseUrl` field annotated directly onto the ConnectionMeta the client publishes on the shared window. The value on the window stays a plain ConnectionMeta, dropping the wrapper type and the dual-form detection helper. `baseUrl` is client-annotated (not served), documented as such. --- packages/devframe/src/client/rpc.test.ts | 112 ++++++++++++++---- packages/devframe/src/client/rpc.ts | 65 +++------- packages/devframe/src/types/context.ts | 9 ++ .../tsnapi/devframe/client.snapshot.d.ts | 5 - .../tsnapi/devframe/client.snapshot.js | 1 - 5 files changed, 114 insertions(+), 78 deletions(-) diff --git a/packages/devframe/src/client/rpc.test.ts b/packages/devframe/src/client/rpc.test.ts index ae940caf..7d9b3cf7 100644 --- a/packages/devframe/src/client/rpc.test.ts +++ b/packages/devframe/src/client/rpc.test.ts @@ -1,34 +1,102 @@ import type { ConnectionMeta } from 'devframe/types' -import { describe, expect, it } from 'vitest' -import { readPublishedConnectionMeta } from './rpc' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDevframeRpcClient } from './rpc' -describe('readPublishedConnectionMeta', () => { - const meta: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } } +const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__' - it('reads the wrapped form, carrying the resolved base', () => { - const result = readPublishedConnectionMeta({ - meta, - metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', - }) - expect(result).toEqual({ - meta, - metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', +// 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('accepts a wrapped form without a base', () => { - expect(readPublishedConnectionMeta({ meta })).toEqual({ meta, metaBaseUrl: undefined }) + 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('treats a bare ConnectionMeta as legacy, inheriting without a base', () => { - // Backward compatibility: older publishers (and hosts that set the shared - // window key directly) store a raw ConnectionMeta rather than the wrapper. - expect(readPublishedConnectionMeta(meta)).toEqual({ meta }) + 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('returns undefined for non-object values', () => { - expect(readPublishedConnectionMeta(undefined)).toBeUndefined() - expect(readPublishedConnectionMeta(null)).toBeUndefined() - expect(readPublishedConnectionMeta('')).toBeUndefined() + 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 8eecdd8d..95742e68 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -230,42 +230,7 @@ function persistAuthToken(token: string): void { ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token } -/** - * The connection meta published on a shared window for same-origin inheritance, - * paired with the absolute base URL it was resolved against. - * - * Carrying `metaBaseUrl` is what lets a same-origin child mounted at another - * base (e.g. a hub mounting several devframe SPAs at `/__foo/`, `/__bar/`, …) - * resolve a relative `websocket.path` against the base the publisher loaded - * `__connection.json` from, rather than against the child's own mount — which - * would dial the wrong endpoint. - */ -export interface PublishedConnectionMeta { - meta: ConnectionMeta - /** - * Absolute URL of the `__connection.json` the meta was resolved from. A - * relative `websocket.path` resolves against this, so it stays dialable no - * matter which base the inheriting SPA is mounted at. - */ - metaBaseUrl?: string -} - -/** - * Normalize a value read off a shared window under {@link CONNECTION_META_KEY} - * into a {@link PublishedConnectionMeta}. Accepts both the wrapped form (which - * carries the base) and a bare {@link ConnectionMeta} (older publishers, or a - * host that sets the key directly) — the latter inherits without a base. - */ -export function readPublishedConnectionMeta(value: unknown): PublishedConnectionMeta | undefined { - if (!value || typeof value !== 'object') - return undefined - const wrapped = value as Partial - if (wrapped.meta && typeof wrapped.meta === 'object') - return { meta: wrapped.meta, metaBaseUrl: wrapped.metaBaseUrl } - return { meta: value as ConnectionMeta } -} - -function findConnectionMetaFromWindows(): PublishedConnectionMeta | undefined { +function findConnectionMetaFromWindows(): ConnectionMeta | undefined { const getters = [ () => (window as any)?.[CONNECTION_META_KEY], () => (globalThis as any)?.[CONNECTION_META_KEY], @@ -276,7 +241,7 @@ function findConnectionMetaFromWindows(): PublishedConnectionMeta | undefined { try { const value = getter() if (value) - return readPublishedConnectionMeta(value) + return value } catch {} } @@ -297,13 +262,13 @@ export async function getDevframeRpcClient( } = options const events = createEventEmitter() const bases = Array.isArray(baseURL) ? baseURL : [baseURL] - const inherited = options.connectionMeta ? undefined : findConnectionMetaFromWindows() - let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || inherited?.meta + let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || findConnectionMetaFromWindows() let resolvedBaseURL = bases[0] ?? './' - // When the meta is inherited from a same-origin parent, inherit the base it - // was resolved against too, so a relative `websocket.path` resolves against - // the publisher's mount rather than this SPA's own (possibly different) base. - const inheritedMetaBaseUrl = inherited?.metaBaseUrl + // 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 @@ -327,14 +292,14 @@ export async function getDevframeRpcClient( connectionMeta = await fetch(withBase(DEVFRAME_CONNECTION_META_FILENAME, base)) .then(r => r.json()) as ConnectionMeta resolvedBaseURL = base - // Publish the meta together with the absolute base it was resolved - // against, so a same-origin child mounted at another base inherits a - // dialable endpoint instead of resolving the relative WS path against - // its own mount. + // 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] = { - meta: connectionMeta, - metaBaseUrl: resolveMetaBaseUrl(), - } satisfies PublishedConnectionMeta + ...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/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index d46a48e9..72ce9efb 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -84,10 +84,6 @@ export interface DevframeScopedClientStreamingHost { subscribe: (_: string, _: string, _?: StreamingSubscribeOptions) => StreamReader; upload: (_: string, _: string) => StreamSink; } -export interface PublishedConnectionMeta { - meta: ConnectionMeta; - metaBaseUrl?: string; -} export interface RpcClientEvents { 'rpc:is-trusted:updated': (_: boolean) => void; 'connection:status': (_: DevframeConnectionStatus, _: DevframeConnectionStatus) => void; @@ -133,7 +129,6 @@ export declare function createScopedClientContext(_: export declare function getDevframeRpcClient(_?: DevframeRpcClientOptions): Promise; export declare function isCallableStatus(_: DevframeConnectionStatus): boolean; export declare function readOtpFromUrl(_?: string): string | undefined; -export declare function readPublishedConnectionMeta(_: unknown): PublishedConnectionMeta | undefined; // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js index faec6a2e..fd810c9b 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -18,7 +18,6 @@ export function createScopedClientContext(_, _) {} export async function getDevframeRpcClient(_) {} export function isCallableStatus(_) {} export function readOtpFromUrl(_) {} -export function readPublishedConnectionMeta(_) {} // #endregion // #region Variables