Skip to content
Open
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
113 changes: 113 additions & 0 deletions e2e/tests/webext-inspected-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { BrowserContext } from 'playwright-core'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { chromium } from 'playwright-core'
import { expect, it } from 'vitest'
import { installInspectedPageRelay } from '../../packages/webext/app/panel/inspected-page-relay'

it('preserves structured-clone values across the inspected-page extension relay', async () => {
const manifest = JSON.parse(await readFile(new URL('../../packages/webext/manifest.json', import.meta.url), 'utf8'))
const directory = await mkdtemp(join(tmpdir(), 'webext-relay-'))
const server = createServer((_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(`<script>
window.addEventListener('message', (event) => {
if (event.data?.type !== 'devframe:inspected-page:connect') return;
const port = event.ports[0];
port.onmessage = ({ data }) => {
if (data?.type === 'channel') port.postMessage(data);
};
port.postMessage({ type: 'ready' });
});
</script>`)
})
// Keep production transport settings and permissions; replace presentation
// entry points with a minimal worker so this test needs no UI build.
await writeFile(join(directory, 'manifest.json'), JSON.stringify({
...manifest,
action: undefined,
icons: undefined,
devtools_page: undefined,
background: { service_worker: 'background.js' },
}))
await writeFile(join(directory, 'background.js'), 'chrome.runtime.onInstalled.addListener(() => {})')
await writeFile(join(directory, 'relay.js'), `(${installInspectedPageRelay.toString()})()`)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string')
throw new Error('The relay fixture did not acquire a port')
const url = `http://127.0.0.1:${address.port}/`
let context: BrowserContext | undefined
try {
context = await chromium.launchPersistentContext('', {
channel: 'chromium',
headless: true,
args: [`--disable-extensions-except=${directory}`, `--load-extension=${directory}`],
})
const worker = context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker')
const page = await context.newPage()
await page.goto(url)
const result = await worker.evaluate(async (url) => {
const [tab] = await chrome.tabs.query({ url })
const [injection] = await chrome.scripting.executeScript({
target: { tabId: tab!.id!, frameIds: [0] },
files: ['relay.js'],
world: 'ISOLATED',
})
const port = chrome.tabs.connect(tab!.id!, {
documentId: injection!.documentId,
name: 'vite-devtools:inspected-page:roundtrip',
})
try {
await new Promise<void>(resolve => port.onMessage.addListener(data => data.type === 'ready' && resolve()))
const received = new Promise<any>(resolve => port.onMessage.addListener(data => data.type === 'channel' && resolve(data.data)))
const shared = { value: 42 }
const cycle: { self?: unknown } = {}
cycle.self = cycle
port.postMessage({
type: 'channel',
data: {
bytes: new Uint8Array([1, 2, 255]),
big: 9007199254740993n,
map: new Map([['shared', shared]]),
set: new Set([shared]),
shared,
cycle,
date: new Date('2026-01-01T00:00:00Z'),
blob: new Blob(['relay'], { type: 'text/plain' }),
},
})
const data = await received
return {
bytes: data.bytes instanceof Uint8Array && [...data.bytes],
big: data.big === 9007199254740993n,
map: data.map instanceof Map && data.map.get('shared') === data.shared,
set: data.set instanceof Set && data.set.has(data.shared),
cycle: data.cycle.self === data.cycle,
date: data.date instanceof Date && data.date.toISOString(),
blob: data.blob instanceof Blob && await data.blob.text(),
}
}
finally {
port.disconnect()
}
}, url)
expect(result).toEqual({
bytes: [1, 2, 255],
big: true,
map: true,
set: true,
cycle: true,
date: '2026-01-01T00:00:00.000Z',
blob: 'relay',
})
expect(Number.parseInt(manifest.minimum_chrome_version, 10)).toBeGreaterThanOrEqual(148)
}
finally {
await context?.close()
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
await rm(directory, { recursive: true, force: true })
}
})
9 changes: 9 additions & 0 deletions packages/webext/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,13 @@

Browser extension scaffolding for Vite DevTools. Work in progress.

Requires Chrome 148 or later. The extension uses native structured-clone messaging to preserve inspected-page channel values, including typed arrays, maps, big integers, and cyclic objects.

Run the browser transport regression after installing the test browser:

```sh
pnpm -C e2e exec playwright-core install chromium
pnpm -C e2e exec vitest run tests/webext-inspected-page.test.ts
```

📖 [Documentation](https://devtools.vite.dev/)
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