Skip to content

Commit 09ab911

Browse files
committed
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.
1 parent dc6eda2 commit 09ab911

5 files changed

Lines changed: 114 additions & 78 deletions

File tree

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,102 @@
11
import type { ConnectionMeta } from 'devframe/types'
2-
import { describe, expect, it } from 'vitest'
3-
import { readPublishedConnectionMeta } from './rpc'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { getDevframeRpcClient } from './rpc'
44

5-
describe('readPublishedConnectionMeta', () => {
6-
const meta: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } }
5+
const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__'
76

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',
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',
1644
})
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]
1752
})
1853

19-
it('accepts a wrapped form without a base', () => {
20-
expect(readPublishedConnectionMeta({ meta })).toEqual({ meta, metaBaseUrl: undefined })
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')
2164
})
2265

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 })
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')
2784
})
2885

29-
it('returns undefined for non-object values', () => {
30-
expect(readPublishedConnectionMeta(undefined)).toBeUndefined()
31-
expect(readPublishedConnectionMeta(null)).toBeUndefined()
32-
expect(readPublishedConnectionMeta('')).toBeUndefined()
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')
33101
})
34102
})

packages/devframe/src/client/rpc.ts

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

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 {
233+
function findConnectionMetaFromWindows(): ConnectionMeta | undefined {
269234
const getters = [
270235
() => (window as any)?.[CONNECTION_META_KEY],
271236
() => (globalThis as any)?.[CONNECTION_META_KEY],
@@ -276,7 +241,7 @@ function findConnectionMetaFromWindows(): PublishedConnectionMeta | undefined {
276241
try {
277242
const value = getter()
278243
if (value)
279-
return readPublishedConnectionMeta(value)
244+
return value
280245
}
281246
catch {}
282247
}
@@ -297,13 +262,13 @@ export async function getDevframeRpcClient(
297262
} = options
298263
const events = createEventEmitter<RpcClientEvents>()
299264
const bases = Array.isArray(baseURL) ? baseURL : [baseURL]
300-
const inherited = options.connectionMeta ? undefined : findConnectionMetaFromWindows()
301-
let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || inherited?.meta
265+
let connectionMeta: ConnectionMeta | undefined = options.connectionMeta || findConnectionMetaFromWindows()
302266
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
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
307272

308273
// Absolute URL of where `__connection.json` lives, used to resolve a
309274
// relative WS path against the SPA's own origin (proxy-safe). Falls back to
@@ -327,14 +292,14 @@ export async function getDevframeRpcClient(
327292
connectionMeta = await fetch(withBase(DEVFRAME_CONNECTION_META_FILENAME, base))
328293
.then(r => r.json()) as ConnectionMeta
329294
resolvedBaseURL = base
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.
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.
334299
;(globalThis as any)[CONNECTION_META_KEY] = {
335-
meta: connectionMeta,
336-
metaBaseUrl: resolveMetaBaseUrl(),
337-
} satisfies PublishedConnectionMeta
300+
...connectionMeta,
301+
baseUrl: resolveMetaBaseUrl(),
302+
} satisfies ConnectionMeta
338303
break
339304
}
340305
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
}

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

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,6 @@ 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-
}
9187
export interface RpcClientEvents {
9288
'rpc:is-trusted:updated': (_: boolean) => void;
9389
'connection:status': (_: DevframeConnectionStatus, _: DevframeConnectionStatus) => void;
@@ -133,7 +129,6 @@ export declare function createScopedClientContext<NS extends string = string>(_:
133129
export declare function getDevframeRpcClient(_?: DevframeRpcClientOptions): Promise<DevframeRpcClient>;
134130
export declare function isCallableStatus(_: DevframeConnectionStatus): boolean;
135131
export declare function readOtpFromUrl(_?: string): string | undefined;
136-
export declare function readPublishedConnectionMeta(_: unknown): PublishedConnectionMeta | undefined;
137132
// #endregion
138133

139134
// #region Variables

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

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

2423
// #region Variables

0 commit comments

Comments
 (0)