Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions packages/webext/app/panel/App.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { createInspectedPageBridge } from './inspected-page'
import { getInspectedWindowConnection } from './inspected-window'

const viewerUrl = ref<string | null>(null)
const viewer = ref<HTMLIFrameElement>()
const errorMessage = ref<string | null>(null)
const status = ref<'loading' | 'unavailable'>('loading')
const METADATA_RETRY_COUNT = 200
const METADATA_RETRY_DELAY = 100
let generation = 0
let disposeBridge: (() => void) | undefined

function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
Expand All @@ -18,8 +22,10 @@ const statusText = computed(() => {
: 'Vite DevTools unavailable'
})

async function resolveInspectedWindowConnection() {
async function resolveInspectedWindowConnection(currentGeneration: number) {
for (let i = 0; i < METADATA_RETRY_COUNT; i++) {
if (currentGeneration !== generation)
return
const connection = await getInspectedWindowConnection()
if (connection)
return connection
Expand All @@ -29,15 +35,36 @@ async function resolveInspectedWindowConnection() {
}

async function initialize() {
const currentGeneration = ++generation
disposeBridge?.()
disposeBridge = undefined
viewerUrl.value = null
status.value = 'loading'
errorMessage.value = null

try {
const connection = await resolveInspectedWindowConnection()
const connection = await resolveInspectedWindowConnection(currentGeneration)
if (currentGeneration !== generation)
return
if (!connection)
throw new Error('Unable to reconnect to the inspected page.')

viewerUrl.value = new URL('./', connection.metaBaseUrl).href
const session = crypto.randomUUID()
const url = new URL('./', connection.metaBaseUrl)
url.searchParams.set('devframe-inspected-page', session)
url.searchParams.set('devframe-parent-origin', location.origin)
disposeBridge = createInspectedPageBridge({
tabId: chrome.devtools.inspectedWindow.tabId,
viewerUrl: url.href,
session,
getViewerWindow: () => viewer.value?.contentWindow,
onError(message) {
viewerUrl.value = null
status.value = 'unavailable'
errorMessage.value = message
},
})
viewerUrl.value = url.href
}
catch (err) {
status.value = 'unavailable'
Expand All @@ -46,7 +73,7 @@ async function initialize() {
}

function handleInspectedWindowNavigated() {
location.reload()
initialize()
}

onMounted(() => {
Expand All @@ -55,13 +82,16 @@ onMounted(() => {
})

onUnmounted(() => {
generation++
disposeBridge?.()
chrome.devtools.network.onNavigated.removeListener(handleInspectedWindowNavigated)
})
</script>

<template>
<iframe
v-if="viewerUrl"
ref="viewer"
:src="viewerUrl"
title="Vite DevTools"
class="h-screen w-screen border-0"
Expand Down
98 changes: 98 additions & 0 deletions packages/webext/app/panel/inspected-page-relay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { installInspectedPageRelay } from './inspected-page-relay'

function listeners() {
const callbacks = new Set<(...args: any[]) => void>()
return {
addListener: vi.fn(callback => callbacks.add(callback)),
removeListener: vi.fn(callback => callbacks.delete(callback)),
emit: (...args: any[]) => callbacks.forEach(callback => callback(...args)),
}
}

function setup() {
vi.useFakeTimers()
const window = Object.assign(new EventTarget(), { postMessage: vi.fn() })
const onConnect = listeners()
const channels: {
port1: { onmessage?: (event: { data: unknown }) => void, close: ReturnType<typeof vi.fn>, postMessage: ReturnType<typeof vi.fn> }
port2: object
}[] = []
vi.stubGlobal('window', window)
vi.stubGlobal('location', { origin: 'http://localhost:5173' })
vi.stubGlobal('chrome', { runtime: { onConnect } })
vi.stubGlobal('MessageChannel', class {
port1 = { close: vi.fn(), postMessage: vi.fn() }
port2 = {}
constructor() {
channels.push(this)
}
})
const extensionPort = {
name: 'vite-devtools:inspected-page:session',
onMessage: listeners(),
onDisconnect: listeners(),
postMessage: vi.fn(),
disconnect: vi.fn(),
}
installInspectedPageRelay()
onConnect.emit(extensionPort)
return { channels, window, onConnect, extensionPort }
}

describe('isolated inspected page relay', () => {
afterEach(() => {
Reflect.deleteProperty(globalThis, '__VITE_DEVTOOLS_PAGE_RELAY__')
vi.unstubAllGlobals()
vi.useRealTimers()
})

it('reuses the isolated-world listener across panel reconnections', () => {
const state = setup()
installInspectedPageRelay()
expect(state.onConnect.addListener).toHaveBeenCalledOnce()
expect(state.window.postMessage).toHaveBeenCalledWith(
{ type: 'devframe:inspected-page:connect', session: 'session' },
'http://localhost:5173',
[state.channels[0]!.port2],
)
state.extensionPort.onDisconnect.emit()
})

it('waits for the page bootstrap and drains requests only after its handshake', () => {
const state = setup()
const request = { type: 'request', id: '1', method: 'prepare', entryId: 'a11y' }
state.extensionPort.onMessage.emit(request)
expect(state.channels[0]!.port1.postMessage).not.toHaveBeenCalled()
vi.advanceTimersByTime(250)
expect(state.channels[0]!.port1.close).toHaveBeenCalledOnce()
expect(state.channels).toHaveLength(2)
state.channels[1]!.port1.onmessage?.({ data: { type: 'ready' } })
expect(state.channels[1]!.port1.postMessage).toHaveBeenCalledWith(request)
expect(state.extensionPort.postMessage).toHaveBeenCalledWith({ type: 'ready' })
vi.advanceTimersByTime(1000)
expect(state.channels).toHaveLength(2)
state.extensionPort.onDisconnect.emit()
})

it('releases page actions and stops relaying after the panel disconnects', () => {
const state = setup()
const page = state.channels[0]!.port1
page.onmessage?.({ data: { type: 'ready' } })
state.extensionPort.onDisconnect.emit()
expect(page.postMessage).toHaveBeenCalledWith({ type: 'disconnect' })
expect(page.close).toHaveBeenCalledOnce()
page.postMessage.mockClear()
state.extensionPort.onMessage.emit({ type: 'request' })
expect(page.postMessage).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(state.channels).toHaveLength(1)
})

it('disconnects the extension port when its specific page is unloaded', () => {
const state = setup()
state.window.dispatchEvent(new Event('pagehide'))
expect(state.extensionPort.disconnect).toHaveBeenCalledOnce()
expect(state.channels[0]!.port1.close).toHaveBeenCalledOnce()
})
})
74 changes: 74 additions & 0 deletions packages/webext/app/panel/inspected-page-relay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/** Runs in the inspected document's isolated world; keep this function self-contained. */
export function installInspectedPageRelay() {
const global = globalThis as typeof globalThis & { __VITE_DEVTOOLS_PAGE_RELAY__?: boolean }
if (global.__VITE_DEVTOOLS_PAGE_RELAY__)
return
global.__VITE_DEVTOOLS_PAGE_RELAY__ = true

chrome.runtime.onConnect.addListener((extensionPort) => {
const prefix = 'vite-devtools:inspected-page:'
if (!extensionPort.name.startsWith(prefix))
return

const session = extensionPort.name.slice(prefix.length)
if (!/^[\w-]+$/.test(session))
return

let pagePort: MessagePort | undefined
let ready = false
let closed = false
let retry: ReturnType<typeof setTimeout> | undefined
const pending: unknown[] = []

function close() {
if (closed)
return
closed = true
clearTimeout(retry)
pagePort?.postMessage({ type: 'disconnect' })
pagePort?.close()
pending.length = 0
extensionPort.onMessage.removeListener(receive)
extensionPort.onDisconnect.removeListener(close)
window.removeEventListener('pagehide', disconnect)
}

function disconnect() {
close()
extensionPort.disconnect()
}

function receive(message: unknown) {
if (ready)
pagePort?.postMessage(message)
else
pending.push(message)
}

function connect() {
if (closed || ready)
return
pagePort?.close()
const channel = new MessageChannel()
pagePort = channel.port1
pagePort.onmessage = ({ data }) => {
if (closed)
return
if (data?.type === 'ready') {
ready = true
clearTimeout(retry)
for (const message of pending.splice(0))
pagePort?.postMessage(message)
}
extensionPort.postMessage(data)
}
window.postMessage({ type: 'devframe:inspected-page:connect', session }, location.origin, [channel.port2])
retry = setTimeout(connect, 250)
}

extensionPort.onMessage.addListener(receive)
extensionPort.onDisconnect.addListener(close)
window.addEventListener('pagehide', disconnect)
connect()
})
}
Loading
Loading