Skip to content

Commit 66210f6

Browse files
committed
fix(devframe): clean up and deduplicate relay handshakes
Close unclaimed grant ports and end their opposite endpoints. Retry pending document handshakes with distinct identities so delayed grants cannot replace newer connections. Cover cancellation, delayed grants, late scripts, and direct in-page connections.
1 parent 7f66ae0 commit 66210f6

3 files changed

Lines changed: 144 additions & 12 deletions

File tree

‎docs/content/8.references/5.browser-api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ The browser-only endpoint methods of the [in-page channel](/guide/in-page-channe
7171
| `transport.postMessage(data)` | Send a relay envelope to the paired relay. Preserve message order and structured-cloneable payloads. |
7272
| `transport.onMessage(handler)` | Subscribe to incoming relay envelopes and return an unsubscribe function. |
7373

74-
The returned function removes listeners and closes relayed ports. The hub UI provider binds the transport to one inspected document and one hub UI provider document, validates the transport's sender, and disposes both relays on navigation or disconnection. The panel relay accepts same-origin descendant-frame handshakes; the page relay grants connections through the existing page script. Channel calls, shared state, and heartbeat messages retain the existing protocol.
74+
The returned function removes listeners and closes relayed ports. The hub UI provider binds the transport to one inspected document and one hub UI provider document, validates the transport's sender, and disposes both relays on navigation or disconnection. The panel relay accepts same-origin descendant-frame handshakes; the page relay grants connections through the existing page script. Channel calls, shared state, and heartbeat messages retain the existing protocol. Pending document handshakes retry every second. Each attempt uses an opaque relay-owned panel identity; superseded grants are closed, while the original identity is restored in the grant delivered to the panel.
7575

7676
## In-page channel error codes
7777

‎packages/devframe/src/in-page-channel/relay.test.ts‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,109 @@ describe('in-page channel relay', () => {
202202
await vi.waitFor(() => expect(send).toHaveBeenCalledOnce())
203203
})
204204
})
205+
206+
it('deduplicates panel retries while the transport grant is delayed', async () => {
207+
const s = session('/')
208+
const send = s.transport.page.postMessage
209+
const grants: unknown[] = []
210+
s.transport.page.postMessage = (data) => {
211+
if ((data as { kind: string }).kind === 'grant')
212+
grants.push(data)
213+
else
214+
send(data)
215+
}
216+
await vi.waitFor(() => expect(grants).toHaveLength(1))
217+
await new Promise(resolve => setTimeout(resolve, 80))
218+
expect(grants).toHaveLength(1)
219+
send(grants[0])
220+
await vi.waitFor(() => expect(s.panel.status).toBe('connected'))
221+
await expect(s.panel.call('highlight', '#slow')).resolves.toBe('/:#slow')
222+
expect(s.pageScript.panels).toHaveLength(1)
223+
})
224+
225+
it('cleans a late page grant after the relay connection was cancelled without heartbeat', async () => {
226+
const s = session('/')
227+
const post = s.page.win.postMessage
228+
let release: (() => void) | undefined
229+
s.page.win.postMessage = (data, origin, ports) => {
230+
if ((data as { kind: string }).kind === 'grant')
231+
release = () => post(data, origin, ports)
232+
else
233+
post(data, origin, ports)
234+
}
235+
await vi.waitFor(() => expect(s.pageScript.panels).toHaveLength(1))
236+
s.stopPanel()
237+
await new Promise(resolve => setTimeout(resolve, 0))
238+
release!()
239+
await vi.waitFor(() => expect(s.pageScript.panels).toHaveLength(0))
240+
})
241+
242+
it('keeps one peer when a pending open is repeated before the local grant arrives', async () => {
243+
const s = session('/')
244+
const post = s.page.win.postMessage
245+
const grants: (() => void)[] = []
246+
s.page.win.postMessage = (data, origin, ports) => {
247+
if ((data as { kind: string }).kind === 'grant')
248+
grants.push(() => post(data, origin, ports))
249+
else
250+
post(data, origin, ports)
251+
}
252+
await vi.waitFor(() => expect(grants.length).toBeGreaterThan(0))
253+
await new Promise(resolve => setTimeout(resolve, 80))
254+
expect(grants).toHaveLength(1)
255+
grants[0]!()
256+
await vi.waitFor(() => expect(s.panel.status).toBe('connected'))
257+
await expect(s.panel.call('highlight', '#pending')).resolves.toBe('/:#pending')
258+
})
259+
260+
it('retries a timed-out local handshake and rejects its late grant', async () => {
261+
const s = session('/')
262+
const post = s.page.win.postMessage
263+
const grants: (() => void)[] = []
264+
s.page.win.postMessage = (data, origin, ports) => {
265+
if ((data as { kind: string }).kind === 'grant')
266+
grants.push(() => post(data, origin, ports))
267+
else
268+
post(data, origin, ports)
269+
}
270+
await vi.waitFor(() => expect(grants).toHaveLength(2), { timeout: 2500 })
271+
grants[1]!()
272+
await vi.waitFor(() => expect(s.panel.status).toBe('connected'))
273+
grants[0]!()
274+
await vi.waitFor(() => expect(s.pageScript.panels).toHaveLength(1))
275+
await expect(s.panel.call('highlight', '#new-attempt')).resolves.toBe('/:#new-attempt')
276+
})
277+
278+
it('connects when the page script starts after the first handshake', async () => {
279+
const s = session('/')
280+
s.pageScript.close()
281+
await new Promise(resolve => setTimeout(resolve, 50))
282+
const pageScript = createPageScriptChannel<Protocol>({
283+
name: 'devframes:relay-test',
284+
window: s.page.window,
285+
heartbeat: false,
286+
functions: { highlight: { handler: s.highlight } },
287+
})
288+
cleanup.push(() => pageScript.close())
289+
await vi.waitFor(() => expect(s.panel.status).toBe('connected'), { timeout: 2500 })
290+
await expect(s.panel.call('highlight', '#late-script')).resolves.toBe('/:#late-script')
291+
expect(pageScript.panels).toHaveLength(1)
292+
})
293+
294+
it('leaves direct in-page grants available to their own panel', async () => {
295+
const s = session('/')
296+
const direct = connectPanelChannel<Protocol>({
297+
name: 'devframes:relay-test',
298+
window: s.page.window,
299+
targets: [s.page.window],
300+
heartbeat: false,
301+
functions: {},
302+
})
303+
cleanup.push(() => direct.close())
304+
await vi.waitFor(() => {
305+
expect(s.panel.status).toBe('connected')
306+
expect(direct.status).toBe('connected')
307+
})
308+
await expect(direct.call('highlight', '#direct')).resolves.toBe('/:#direct')
309+
expect(s.pageScript.panels).toHaveLength(2)
310+
})

‎packages/devframe/src/in-page-channel/relay.ts‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ interface RelayConnection {
2929
source?: Window
3030
port?: MessagePort
3131
detach?: () => void
32+
forwardedHello?: InPageChannelHandshakeMessage
33+
retryTimer?: ReturnType<typeof setTimeout>
3234
}
3335

3436
function validHandshake(data: unknown, kind: 'hello' | 'grant'): data is InPageChannelHandshakeMessage {
@@ -77,6 +79,10 @@ export function createInPageChannelRelay(options: InPageChannelRelayOptions): ()
7779
throw new Error('An in-page channel relay requires a document with an origin')
7880
const connections = new Map<string, RelayConnection>()
7981
let disposed = false
82+
const grantPrefix = `${nanoid()}:`
83+
// Panel retries may arrive before the document answers. Only the page relay
84+
// retries the local handshake, with a fresh identity to reject late grants.
85+
const helloRetryMs = 1000
8086

8187
function send(message: Omit<RelayMessage, 'channel' | 'relay'>): void {
8288
try {
@@ -92,6 +98,7 @@ export function createInPageChannelRelay(options: InPageChannelRelayOptions): ()
9298
if (!connection)
9399
return
94100
connections.delete(id)
101+
clearTimeout(connection.retryTimer)
95102
connection.detach?.()
96103
// Endpoints recognize bye immediately, including browsers where closing
97104
// the other port does not dispatch a close event.
@@ -107,6 +114,7 @@ export function createInPageChannelRelay(options: InPageChannelRelayOptions): ()
107114
}
108115

109116
function attach(id: string, connection: RelayConnection, port: MessagePort): void {
117+
clearTimeout(connection.retryTimer)
110118
connection.port = port
111119
const onMessage = (event: MessageEvent): void => {
112120
send({ id, kind: 'data', data: event.data })
@@ -129,10 +137,6 @@ export function createInPageChannelRelay(options: InPageChannelRelayOptions): ()
129137
&& (!hello.instanceId || grant.instanceId === hello.instanceId)
130138
}
131139

132-
function sameHello(left: InPageChannelHandshakeMessage, right: InPageChannelHandshakeMessage): boolean {
133-
return left.name === right.name && left.panelId === right.panelId && left.instanceId === right.instanceId
134-
}
135-
136140
function onPanelHello(event: MessageEvent): void {
137141
if (!validHandshake(event.data, 'hello') || !event.source
138142
|| !isDescendant(event.source as Window, win)) {
@@ -155,15 +159,28 @@ export function createInPageChannelRelay(options: InPageChannelRelayOptions): ()
155159
}
156160

157161
function onPageGrant(event: MessageEvent): void {
158-
if (event.source !== win || !validHandshake(event.data, 'grant') || !event.ports[0])
162+
if (event.source !== win || !validHandshake(event.data, 'grant') || !event.ports[0]
163+
|| !event.data.panelId.startsWith(grantPrefix)) {
159164
return
165+
}
160166
for (const [id, connection] of connections) {
161-
if (!connection.port && matches(event.data, connection.hello)) {
167+
if (!connection.port && connection.forwardedHello && matches(event.data, connection.forwardedHello)) {
162168
attach(id, connection, event.ports[0])
163-
send({ id, kind: 'grant', handshake: event.data })
169+
send({ id, kind: 'grant', handshake: { ...event.data, panelId: connection.hello.panelId } })
164170
return
165171
}
166172
}
173+
// The page script has already registered its opposite endpoint. Explicitly
174+
// end it even when heartbeat is disabled or peer close events are unavailable.
175+
try {
176+
event.ports[0].postMessage({ __dfIpc: 'bye' })
177+
}
178+
catch {
179+
// A detached port is already disconnected.
180+
}
181+
finally {
182+
event.ports[0].close()
183+
}
167184
}
168185

169186
const onWindowMessage = (event: MessageEvent): void => {
@@ -176,11 +193,20 @@ export function createInPageChannelRelay(options: InPageChannelRelayOptions): ()
176193
}
177194

178195
function forwardHello(id: string, hello: InPageChannelHandshakeMessage): void {
179-
const connection = connections.get(id)
180-
if (connection?.port || (connection && !sameHello(connection.hello, hello)))
196+
if (connections.has(id))
181197
return
182-
connections.set(id, { hello })
183-
win.postMessage(hello, origin)
198+
const connection: RelayConnection = { hello }
199+
connections.set(id, connection)
200+
const requestPort = (): void => {
201+
if (disposed || connections.get(id) !== connection || connection.port)
202+
return
203+
// Scope grants to this attempt so a timed-out reply cannot replace a
204+
// newer connection, or be confused with a direct in-page panel's grant.
205+
connection.forwardedHello = { ...hello, panelId: `${grantPrefix}${nanoid()}` }
206+
connection.retryTimer = setTimeout(requestPort, helloRetryMs)
207+
win.postMessage(connection.forwardedHello, origin)
208+
}
209+
requestPort()
184210
}
185211

186212
const unsubscribe = options.transport.onMessage((data) => {

0 commit comments

Comments
 (0)