Skip to content
115 changes: 87 additions & 28 deletions packages/devtools-app/composables/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import type { $Fetch } from 'nitropack/types'
import type { Ref } from 'vue'
import { onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
import { ofetch } from 'ofetch'
import { onScopeDispose, ref, watch, watchEffect } from 'vue'
import { ref, watch, watchEffect } from 'vue'
import { firstPartyData, isConnected, path, query, refreshSources, standaloneUrl, syncScripts, version } from './state'

export const appFetch: Ref<$Fetch | undefined> = ref()
export const devtools: Ref<NuxtDevtoolsClient | undefined> = ref()
export const colorMode: Ref<'dark' | 'light'> = ref('dark')

export interface DevtoolsConnectionOptions {
onConnected?: (client: any) => void
onConnected?: (client: any) => void | (() => void)
onRouteChange?: (route: any) => void
}

Expand All @@ -27,66 +27,111 @@ const STANDALONE_POLL_INTERVAL = 2000
* - **Embedded**: running inside Nuxt DevTools iframe (automatic)
* - **Standalone**: running directly in a browser tab with a manual dev server URL
*/
export function useDevtoolsConnection(options: DevtoolsConnectionOptions = {}): void {
export function useDevtoolsConnection(options: DevtoolsConnectionOptions = {}): () => void {
const inIframe = window.parent !== window
let disposed = false
const connectionCleanups: Array<() => void> = []
let pollTimer: ReturnType<typeof setInterval> | undefined
let pollController: AbortController | undefined

const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = undefined
}
pollController?.abort()
pollController = undefined
}

const cleanupConnection = () => {
connectionCleanups.splice(0).forEach(cleanup => cleanup())
devtools.value = undefined
appFetch.value = undefined
isConnected.value = false
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Embedded mode: connect via devtools-kit iframe client
let stopClientConnection = () => {}
if (inIframe) {
onDevtoolsClientConnected(async (client) => {
stopClientConnection = onDevtoolsClientConnected((client) => {
if (disposed)
return
stopPolling()
cleanupConnection()
isConnected.value = true
// @ts-expect-error untyped
appFetch.value = client.host.app.$fetch
watchEffect(() => {
connectionCleanups.push(watchEffect(() => {
colorMode.value = client.host.app.colorMode.value
})
}))
devtools.value = client.devtools
options.onConnected?.(client)
const cleanupConnected = options.onConnected?.(client)
if (cleanupConnected)
connectionCleanups.push(cleanupConnected)

if (options.onRouteChange) {
const $route = client.host.nuxt.vueApp.config.globalProperties?.$route
options.onRouteChange($route)
const removeAfterEach = client.host.nuxt.$router.afterEach((route: any) => {
options.onRouteChange!(route)
})
// Clean up when devtools client disconnects
// @ts-expect-error app:unmount exists at runtime but is not in RuntimeNuxtHooks
client.host.nuxt.hook('app:unmount', removeAfterEach)
connectionCleanups.push(removeAfterEach)
}
})
// @ts-expect-error app:unmount exists at runtime but is not in RuntimeNuxtHooks
connectionCleanups.push(client.host.nuxt.hook('app:unmount', cleanupConnection))
}) || (() => {})
}

// Standalone mode: create appFetch from manually entered URL and poll for state
let pollTimer: ReturnType<typeof setInterval> | undefined
const poll = async (url: string) => {
// A slow/unreachable app must not accumulate overlapping interval requests.
if (pollController)
return
const controller = new AbortController()
pollController = controller
try {
await pollStandaloneState(url, controller.signal)
}
finally {
if (pollController === controller)
pollController = undefined
}
}

watch(() => standaloneUrl.value, (url) => {
const stopStandaloneWatch = watch(() => standaloneUrl.value, (url) => {
// Clean up previous polling
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = undefined
}
stopPolling()

if (url && !isConnected.value) {
appFetch.value = ofetch.create({ baseURL: url }) as unknown as $Fetch
// Use system color scheme preference
colorMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
refreshSources()
// Start polling the standalone API for script state
pollStandaloneState(url)
pollTimer = setInterval(pollStandaloneState, STANDALONE_POLL_INTERVAL, url)
void poll(url)
pollTimer = setInterval(() => void poll(url), STANDALONE_POLL_INTERVAL)
}
}, { immediate: true })

onScopeDispose(() => {
if (pollTimer) {
clearInterval(pollTimer)
}
})
return () => {
if (disposed)
return
disposed = true
stopPolling()
stopStandaloneWatch()
stopClientConnection()
cleanupConnection()
}
}

async function pollStandaloneState(baseUrl: string) {
async function pollStandaloneState(baseUrl: string, signal: AbortSignal) {
const timeoutController = new AbortController()
const timeout = setTimeout(() => timeoutController.abort(), 3000)
const onAbort = () => timeoutController.abort()
signal.addEventListener('abort', onAbort, { once: true })
try {
const res = await fetch(`${baseUrl}${STANDALONE_API_PATH}`, {
signal: AbortSignal.timeout(3000),
signal: timeoutController.signal,
})
if (!res.ok)
return
Expand All @@ -106,15 +151,29 @@ async function pollStandaloneState(baseUrl: string) {
catch {
// Standalone API not available or not enabled, silently ignore
}
finally {
clearTimeout(timeout)
signal.removeEventListener('abort', onAbort)
}
}

useDevtoolsConnection({
const disposeConnection = useDevtoolsConnection({
onConnected: (client) => {
client.host.nuxt.hooks.hook('scripts:updated', (ctx: any) => {
const stopScriptsHook = client.host.nuxt.hooks.hook('scripts:updated', (ctx: any) => {
syncScripts(ctx.scripts)
})
version.value = client.host.nuxt.$config.public['nuxt-scripts'].version
firstPartyData.value = client.host.nuxt.$config.public['nuxt-scripts-devtools'] || null
syncScripts(client.host.nuxt._scripts || {})
return stopScriptsHook
},
})

function disposeModuleConnection() {
window.removeEventListener('beforeunload', disposeModuleConnection)
disposeConnection()
}

window.addEventListener('beforeunload', disposeModuleConnection, { once: true })
if (import.meta.hot)
import.meta.hot.dispose(disposeModuleConnection)
35 changes: 33 additions & 2 deletions packages/devtools-app/composables/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const version = ref<string | null>(null)
export const firstPartyData = ref<FirstPartyDevtoolsData | null>(null)

let _lastSyncedScripts: any[] | null = null
const scriptFetches = new Map<string, AbortController>()

export async function initRegistry() {
scriptRegistry.value = await _registryPromise
Expand All @@ -107,9 +108,16 @@ export function syncScripts(_scripts: any[]) {
if (!_scripts || typeof _scripts !== 'object') {
_lastSyncedScripts = null
scripts.value = {}
pruneScriptState(new Set())
return
}
_lastSyncedScripts = _scripts
const activeSources = new Set(
Object.values(_scripts)
.map((script: any) => script?.src)
.filter((src): src is string => typeof src === 'string' && !!src),
)
pruneScriptState(activeSources)
scripts.value = Object.fromEntries(
Object.entries({ ..._scripts })
.map(([key, script]: [string, any]) => {
Expand All @@ -124,9 +132,13 @@ export function syncScripts(_scripts: any[]) {
script.loadTime = msToHumanReadable(loadedAt - loadingAt)
const scriptSizeKey = script.src
// Skip size fetching in standalone mode (cross-origin fetch blocked by CORS)
if (!scriptSizes[scriptSizeKey] && script.src && !isStandalone.value) {
fetchScript(script.src)
if (!scriptSizes[scriptSizeKey] && !scriptErrors[scriptSizeKey] && script.src && !isStandalone.value && !scriptFetches.has(scriptSizeKey)) {
const controller = new AbortController()
scriptFetches.set(scriptSizeKey, controller)
fetchScript(script.src, controller.signal)
.then((res) => {
if (controller.signal.aborted || !activeSources.has(scriptSizeKey))
return
if (res.size) {
scriptSizes[scriptSizeKey] = res.size
script.size = res.size
Expand All @@ -136,12 +148,31 @@ export function syncScripts(_scripts: any[]) {
script.error = scriptErrors[scriptSizeKey]
}
})
.finally(() => {
if (scriptFetches.get(scriptSizeKey) === controller)
scriptFetches.delete(scriptSizeKey)
})
}
return [key, script]
}),
)
}

function pruneScriptState(activeSources: Set<string>) {
for (const [src, controller] of scriptFetches) {
if (!activeSources.has(src)) {
controller.abort()
scriptFetches.delete(src)
}
}
for (const state of [scriptSizes, scriptErrors, scriptTabs]) {
for (const src of Object.keys(state)) {
if (!activeSources.has(src))
delete state[src]
}
}
}

// Script status helper (handles both reactive refs from embedded mode and plain strings from standalone)
export function getScriptStatus(script: any): string {
const status = script?.$script?.status
Expand Down
51 changes: 40 additions & 11 deletions packages/devtools-app/utils/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export async function fetchScript(url: string) {
const compressedResponse = await fetch(url, { headers: { 'Accept-Encoding': 'gzip' } }).catch((err) => {
export async function fetchScript(url: string, signal?: AbortSignal) {
const compressedResponse = await fetch(url, {
headers: { 'Accept-Encoding': 'gzip' },
signal,
}).catch((err) => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return {
size: null,
error: err,
Expand All @@ -9,6 +12,7 @@ export async function fetchScript(url: string) {
return compressedResponse as { size: null, error: Error }
}
if (!compressedResponse.ok) {
await cancelResponseBody(compressedResponse)
return {
size: null,
error: new Error(`Failed to fetch ${compressedResponse.status} ${compressedResponse.statusText}`),
Expand All @@ -17,9 +21,19 @@ export async function fetchScript(url: string) {
// Guard against measuring HTML error pages as script sizes
const contentType = compressedResponse.headers.get('Content-Type') || ''
if (contentType.includes('text/html')) {
await cancelResponseBody(compressedResponse)
return { size: null }
}
const size = await getResponseSize(compressedResponse)
let size: number | null
try {
size = await getResponseSize(compressedResponse)
}
catch (error) {
return {
size: null,
error: error instanceof Error ? error : new Error(String(error)),
}
}
if (!size) {
return {
size: null,
Expand All @@ -31,23 +45,38 @@ export async function fetchScript(url: string) {
}

async function getResponseSize(response: Response) {
const reader = response.body?.getReader()
const contentLength = response.headers.get('Content-Length')

if (contentLength) {
await cancelResponseBody(response)
return Number(contentLength)
}
const reader = response.body?.getReader()
if (!reader) {
return null
}
let total = 0
let done = false
while (!done) {
const data = await reader.read()
done = data.done
total += data.value?.length || 0
try {
let total = 0
let done = false
while (!done) {
const data = await reader.read()
done = data.done
total += data.value?.length || 0
}
return total > 0 ? total : null
}
finally {
reader.releaseLock()
}
}

async function cancelResponseBody(response: Response) {
try {
await response.body?.cancel()
}
catch {
// The response is being discarded, so cancellation failure is non-fatal.
}
return total > 0 ? total : null
}

function bytesToSize(bytes: number) {
Expand Down
Loading
Loading