diff --git a/packages/devframe/src/node/hub-internals/context.ts b/packages/devframe/src/node/hub-internals/context.ts index 080da08b..4839279c 100644 --- a/packages/devframe/src/node/hub-internals/context.ts +++ b/packages/devframe/src/node/hub-internals/context.ts @@ -53,6 +53,23 @@ export interface DevframeInternalContext { /** Full `ws://` or `wss://` URL with host and port. */ url: string } + + /** + * Set {@link DevframeInternalContext.wsEndpoint} and notify subscribers — + * the WS-binding tiers (side-car, shared-server, and the `unbound` tier's + * `attach()`) call this once the socket is bound (or `undefined` once torn + * down) instead of assigning the field directly, so anything that already + * projected the endpoint (a hub's remote-dock URLs, registered before an + * async bind resolves) gets a chance to re-project it. + */ + setWsEndpoint: (endpoint: { url: string } | undefined) => void + /** + * Subscribe to every {@link DevframeInternalContext.setWsEndpoint} call. + * Returns an unsubscribe function. The hub context uses this to refresh + * the `devframe:docks` shared state so a remote dock registered before the + * WS port resolves still ends up with a live connection URL. + */ + onWsEndpointChange: (cb: () => void) => () => void } export const internalContextMap = new WeakMap() @@ -66,6 +83,7 @@ export function getInternalContext(context: DevframeNodeContext): DevframeIntern }, }) const remoteTokens = new Map() + const wsEndpointListeners = new Set<() => void>() function revokeRemoteToken(token: string): void { if (!remoteTokens.delete(token)) @@ -78,6 +96,14 @@ export function getInternalContext(context: DevframeNodeContext): DevframeIntern auth: storage, }, revokeAuthToken: (token: string) => revokeAuthToken(context, storage, token), + setWsEndpoint(endpoint) { + internalContext.wsEndpoint = endpoint + for (const listener of wsEndpointListeners) listener() + }, + onWsEndpointChange(cb) { + wsEndpointListeners.add(cb) + return () => wsEndpointListeners.delete(cb) + }, remoteTokens, allocateRemoteToken(dockId, origin, originLock) { const token = randomToken() diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 94fbac25..f887f7bf 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -131,7 +131,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise(r => httpServer.close(() => r())) if (websocket && getInternalContext(context).wsEndpoint?.url === wsUrl) - getInternalContext(context).wsEndpoint = undefined + getInternalContext(context).setWsEndpoint(undefined) }, } } @@ -701,9 +701,9 @@ export function createInstanceShell( if (typeof address !== 'object' || !address) return const host = options.host ?? (address.address === '::' || address.address === '0.0.0.0' ? 'localhost' : address.address) - getInternalContext(ctx).wsEndpoint = { + getInternalContext(ctx).setWsEndpoint({ url: `ws://${formatHostForUrl(host)}:${address.port}${routePath}`, - } + }) } if (server.listening) record() diff --git a/packages/hub/src/node/__tests__/context.test.ts b/packages/hub/src/node/__tests__/context.test.ts index 1b87e723..70ce0896 100644 --- a/packages/hub/src/node/__tests__/context.test.ts +++ b/packages/hub/src/node/__tests__/context.test.ts @@ -60,6 +60,49 @@ describe('createHubContext dock activation', () => { }) }) +describe('createHubContext remote dock republishing', () => { + it('re-projects a remote dock once the WS endpoint resolves after registration', async () => { + // Mirrors vitejs/devtools#517/#520: a remote iframe dock can register + // before an async WS bind (side-car port probing, an `unbound` tier + // waiting on the host's own `attach()`) resolves `wsEndpoint`. Nothing + // else re-registers that dock once the port is known, so the fix has to + // re-project every dock when the endpoint changes. + const context = await createHubContext({ + cwd: process.cwd(), + mode: 'build', + host: createHost(), + }) + + context.docks.register({ + type: 'iframe', + id: 'remote', + title: 'Remote', + icon: 'ph:cube-duotone', + url: 'https://remote.test/app', + remote: true, + }) + + // The registration's own refresh is debounced too — let it settle before + // asserting the pre-bind projection. + await new Promise(resolve => setTimeout(resolve, 20)) + + const docksState = await context.rpc.sharedState.get('devframe:docks') + const beforeBind = docksState.value()[0] + expect(beforeBind?.type === 'iframe' ? beforeBind.url : undefined).toBe('https://remote.test/app') + + getInternalContext(context).setWsEndpoint({ url: 'ws://localhost:4173' }) + // The refresh is debounced (0ms in `mode: 'build'`, still a macrotask). + await new Promise(resolve => setTimeout(resolve, 20)) + + const afterBind = docksState.value()[0] + const afterUrl = afterBind?.type === 'iframe' ? afterBind.url : '' + expect(afterUrl).not.toBe('https://remote.test/app') + expect(afterUrl).toContain('https://remote.test/app') + + getInternalContext(context).setWsEndpoint(undefined) + }) +}) + describe('served context remote endpoint metadata', () => { it('sets and clears the internal websocket endpoint', async () => { const context = await createHostContext({ diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index 870ffed9..71d7c182 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -6,6 +6,7 @@ import type { DevframeMessageEntry, DevframeMessageEntryInput, DevframeMessagesH import type { DevframeTerminalsHost } from '../types/terminals' import type { InstallDevframeOptions } from './install-devframe' import { createHostContext } from 'devframe/node' +import { getInternalContext } from 'devframe/node/hub-internals' import { debounce } from 'perfect-debounce' import { DevframeCommandsHost as CommandsHostImpl } from './host-commands' import { DevframeDocksHost as DocksHostImpl } from './host-docks' @@ -155,6 +156,13 @@ export async function createHubContext(options: CreateHubContextOptions): Promis docksSharedState.mutate(() => docks.values()) }, debounceMs) docks.events.on('dock:entry:updated', refreshDocks) + // A remote iframe dock registered before the WS transport finishes binding + // (the common case: `initHub` installs devframes — and their docks — before + // resolving an async side-car/shared-server port) gets projected without a + // connection URL, since `wsEndpoint` isn't set yet. Nothing re-registers + // that dock once the port resolves, so re-project every dock once the + // endpoint becomes known (or is torn down) instead of leaving it stale. + getInternalContext(context).onWsEndpointChange(refreshDocks) docksSharedState.mutate(() => docks.values()) // Cross-iframe dock activation. A dock activation is a discrete user intent diff --git a/tests/helpers/serve-test-context.ts b/tests/helpers/serve-test-context.ts index c9ab2919..4411ff09 100644 --- a/tests/helpers/serve-test-context.ts +++ b/tests/helpers/serve-test-context.ts @@ -76,7 +76,7 @@ export async function serveTestContext(options: ServeTestContextOptions): Promis // Publish the dialable socket URL on the context, mirroring the shell's own // binding, so surfaces that hand out a complete endpoint work in tests too. const wsUrl = `ws://${formatHostForUrl(bindHost)}:${resolvedPort}` - getInternalContext(context).wsEndpoint = { url: wsUrl } + getInternalContext(context).setWsEndpoint({ url: wsUrl }) function connectionMeta(): ConnectionMeta { const jsonSerializableMethods: string[] = [] @@ -98,7 +98,7 @@ export async function serveTestContext(options: ServeTestContextOptions): Promis await closeWs() await new Promise(r => httpServer.close(() => r())) if (getInternalContext(context).wsEndpoint?.url === wsUrl) - getInternalContext(context).wsEndpoint = undefined + getInternalContext(context).setWsEndpoint(undefined) }, } }