Skip to content

Commit 595eb81

Browse files
antfubotwebfansplz
andauthored
fix(devframe,hub): republish remote-dock WS endpoint once it resolves (#217)
Co-authored-by: webfansplz <webfansplz@gmail.com>
1 parent 86375d3 commit 595eb81

5 files changed

Lines changed: 83 additions & 6 deletions

File tree

packages/devframe/src/node/hub-internals/context.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,23 @@ export interface DevframeInternalContext {
5353
/** Full `ws://` or `wss://` URL with host and port. */
5454
url: string
5555
}
56+
57+
/**
58+
* Set {@link DevframeInternalContext.wsEndpoint} and notify subscribers —
59+
* the WS-binding tiers (side-car, shared-server, and the `unbound` tier's
60+
* `attach()`) call this once the socket is bound (or `undefined` once torn
61+
* down) instead of assigning the field directly, so anything that already
62+
* projected the endpoint (a hub's remote-dock URLs, registered before an
63+
* async bind resolves) gets a chance to re-project it.
64+
*/
65+
setWsEndpoint: (endpoint: { url: string } | undefined) => void
66+
/**
67+
* Subscribe to every {@link DevframeInternalContext.setWsEndpoint} call.
68+
* Returns an unsubscribe function. The hub context uses this to refresh
69+
* the `devframe:docks` shared state so a remote dock registered before the
70+
* WS port resolves still ends up with a live connection URL.
71+
*/
72+
onWsEndpointChange: (cb: () => void) => () => void
5673
}
5774

5875
export const internalContextMap = new WeakMap<DevframeNodeContext, DevframeInternalContext>()
@@ -66,6 +83,7 @@ export function getInternalContext(context: DevframeNodeContext): DevframeIntern
6683
},
6784
})
6885
const remoteTokens = new Map<string, RemoteTokenRecord>()
86+
const wsEndpointListeners = new Set<() => void>()
6987

7088
function revokeRemoteToken(token: string): void {
7189
if (!remoteTokens.delete(token))
@@ -78,6 +96,14 @@ export function getInternalContext(context: DevframeNodeContext): DevframeIntern
7896
auth: storage,
7997
},
8098
revokeAuthToken: (token: string) => revokeAuthToken(context, storage, token),
99+
setWsEndpoint(endpoint) {
100+
internalContext.wsEndpoint = endpoint
101+
for (const listener of wsEndpointListeners) listener()
102+
},
103+
onWsEndpointChange(cb) {
104+
wsEndpointListeners.add(cb)
105+
return () => wsEndpointListeners.delete(cb)
106+
},
81107
remoteTokens,
82108
allocateRemoteToken(dockId, origin, originLock) {
83109
const token = randomToken()

packages/devframe/src/node/instance-shell.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
131131
const internal = getInternalContext(context)
132132
const wsUrl = `ws://${formatHostForUrl(bindHost)}:${resolvedPort}${options.path ?? ''}`
133133
if (websocket)
134-
internal.wsEndpoint = { url: wsUrl }
134+
internal.setWsEndpoint({ url: wsUrl })
135135

136136
function connectionMeta(): ConnectionMeta {
137137
const jsonSerializableMethods: string[] = []
@@ -154,7 +154,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
154154
if (ownsHttpServer)
155155
await new Promise<void>(r => httpServer.close(() => r()))
156156
if (websocket && getInternalContext(context).wsEndpoint?.url === wsUrl)
157-
getInternalContext(context).wsEndpoint = undefined
157+
getInternalContext(context).setWsEndpoint(undefined)
158158
},
159159
}
160160
}
@@ -701,9 +701,9 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
701701
if (typeof address !== 'object' || !address)
702702
return
703703
const host = options.host ?? (address.address === '::' || address.address === '0.0.0.0' ? 'localhost' : address.address)
704-
getInternalContext(ctx).wsEndpoint = {
704+
getInternalContext(ctx).setWsEndpoint({
705705
url: `ws://${formatHostForUrl(host)}:${address.port}${routePath}`,
706-
}
706+
})
707707
}
708708
if (server.listening)
709709
record()

packages/hub/src/node/__tests__/context.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,49 @@ describe('createHubContext dock activation', () => {
6060
})
6161
})
6262

63+
describe('createHubContext remote dock republishing', () => {
64+
it('re-projects a remote dock once the WS endpoint resolves after registration', async () => {
65+
// Mirrors vitejs/devtools#517/#520: a remote iframe dock can register
66+
// before an async WS bind (side-car port probing, an `unbound` tier
67+
// waiting on the host's own `attach()`) resolves `wsEndpoint`. Nothing
68+
// else re-registers that dock once the port is known, so the fix has to
69+
// re-project every dock when the endpoint changes.
70+
const context = await createHubContext({
71+
cwd: process.cwd(),
72+
mode: 'build',
73+
host: createHost(),
74+
})
75+
76+
context.docks.register({
77+
type: 'iframe',
78+
id: 'remote',
79+
title: 'Remote',
80+
icon: 'ph:cube-duotone',
81+
url: 'https://remote.test/app',
82+
remote: true,
83+
})
84+
85+
// The registration's own refresh is debounced too — let it settle before
86+
// asserting the pre-bind projection.
87+
await new Promise(resolve => setTimeout(resolve, 20))
88+
89+
const docksState = await context.rpc.sharedState.get<DevframeDockEntry[]>('devframe:docks')
90+
const beforeBind = docksState.value()[0]
91+
expect(beforeBind?.type === 'iframe' ? beforeBind.url : undefined).toBe('https://remote.test/app')
92+
93+
getInternalContext(context).setWsEndpoint({ url: 'ws://localhost:4173' })
94+
// The refresh is debounced (0ms in `mode: 'build'`, still a macrotask).
95+
await new Promise(resolve => setTimeout(resolve, 20))
96+
97+
const afterBind = docksState.value()[0]
98+
const afterUrl = afterBind?.type === 'iframe' ? afterBind.url : ''
99+
expect(afterUrl).not.toBe('https://remote.test/app')
100+
expect(afterUrl).toContain('https://remote.test/app')
101+
102+
getInternalContext(context).setWsEndpoint(undefined)
103+
})
104+
})
105+
63106
describe('served context remote endpoint metadata', () => {
64107
it('sets and clears the internal websocket endpoint', async () => {
65108
const context = await createHostContext({

packages/hub/src/node/context.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { DevframeMessageEntry, DevframeMessageEntryInput, DevframeMessagesH
66
import type { DevframeTerminalsHost } from '../types/terminals'
77
import type { InstallDevframeOptions } from './install-devframe'
88
import { createHostContext } from 'devframe/node'
9+
import { getInternalContext } from 'devframe/node/hub-internals'
910
import { debounce } from 'perfect-debounce'
1011
import { DevframeCommandsHost as CommandsHostImpl } from './host-commands'
1112
import { DevframeDocksHost as DocksHostImpl } from './host-docks'
@@ -155,6 +156,13 @@ export async function createHubContext(options: CreateHubContextOptions): Promis
155156
docksSharedState.mutate(() => docks.values())
156157
}, debounceMs)
157158
docks.events.on('dock:entry:updated', refreshDocks)
159+
// A remote iframe dock registered before the WS transport finishes binding
160+
// (the common case: `initHub` installs devframes — and their docks — before
161+
// resolving an async side-car/shared-server port) gets projected without a
162+
// connection URL, since `wsEndpoint` isn't set yet. Nothing re-registers
163+
// that dock once the port resolves, so re-project every dock once the
164+
// endpoint becomes known (or is torn down) instead of leaving it stale.
165+
getInternalContext(context).onWsEndpointChange(refreshDocks)
158166
docksSharedState.mutate(() => docks.values())
159167

160168
// Cross-iframe dock activation. A dock activation is a discrete user intent

tests/helpers/serve-test-context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export async function serveTestContext(options: ServeTestContextOptions): Promis
7676
// Publish the dialable socket URL on the context, mirroring the shell's own
7777
// binding, so surfaces that hand out a complete endpoint work in tests too.
7878
const wsUrl = `ws://${formatHostForUrl(bindHost)}:${resolvedPort}`
79-
getInternalContext(context).wsEndpoint = { url: wsUrl }
79+
getInternalContext(context).setWsEndpoint({ url: wsUrl })
8080

8181
function connectionMeta(): ConnectionMeta {
8282
const jsonSerializableMethods: string[] = []
@@ -98,7 +98,7 @@ export async function serveTestContext(options: ServeTestContextOptions): Promis
9898
await closeWs()
9999
await new Promise<void>(r => httpServer.close(() => r()))
100100
if (getInternalContext(context).wsEndpoint?.url === wsUrl)
101-
getInternalContext(context).wsEndpoint = undefined
101+
getInternalContext(context).setWsEndpoint(undefined)
102102
},
103103
}
104104
}

0 commit comments

Comments
 (0)