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
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