diff --git a/packages/devtools-app/composables/rpc.ts b/packages/devtools-app/composables/rpc.ts index 1dc4e9300..e12722369 100644 --- a/packages/devtools-app/composables/rpc.ts +++ b/packages/devtools-app/composables/rpc.ts @@ -4,7 +4,7 @@ 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() @@ -12,7 +12,7 @@ export const devtools: Ref = 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 } @@ -27,20 +27,47 @@ 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 | 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 + } // 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 @@ -48,22 +75,32 @@ export function useDevtoolsConnection(options: DevtoolsConnectionOptions = {}): 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 | 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 @@ -71,22 +108,30 @@ export function useDevtoolsConnection(options: DevtoolsConnectionOptions = {}): 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 @@ -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) diff --git a/packages/devtools-app/composables/state.ts b/packages/devtools-app/composables/state.ts index 3275c6b84..79a37ce94 100644 --- a/packages/devtools-app/composables/state.ts +++ b/packages/devtools-app/composables/state.ts @@ -84,6 +84,7 @@ export const version = ref(null) export const firstPartyData = ref(null) let _lastSyncedScripts: any[] | null = null +const scriptFetches = new Map() export async function initRegistry() { scriptRegistry.value = await _registryPromise @@ -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]) => { @@ -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 @@ -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) { + 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 diff --git a/packages/devtools-app/utils/fetch.ts b/packages/devtools-app/utils/fetch.ts index 3d70db822..07d611f42 100644 --- a/packages/devtools-app/utils/fetch.ts +++ b/packages/devtools-app/utils/fetch.ts @@ -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) => { return { size: null, error: err, @@ -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}`), @@ -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, @@ -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) { diff --git a/packages/script/src/devtools.ts b/packages/script/src/devtools.ts index 5a78d289a..215a5942c 100644 --- a/packages/script/src/devtools.ts +++ b/packages/script/src/devtools.ts @@ -7,6 +7,7 @@ import { createResolver, extendViteConfig } from '@nuxt/kit' const DEVTOOLS_UI_ROUTE = '/__nuxt-scripts' const DEVTOOLS_UI_LOCAL_PORT = 3030 const DEVTOOLS_API_STATE_ROUTE = '/__nuxt-scripts-api/state' +const DEVTOOLS_API_MAX_BODY_SIZE = 2 * 1024 * 1024 export interface DevtoolsOptions { standalone?: boolean @@ -82,10 +83,44 @@ function setupStandaloneApi(nuxt: Nuxt) { if (req.method === 'POST') { let body = '' - req.on('data', (chunk: Buffer) => { + let size = 0 + let finished = false + + function cleanup() { + req.off('data', onData) + req.off('end', onEnd) + req.off('aborted', onAborted) + req.off('error', onAborted) + } + function onAborted() { + finished = true + body = '' + cleanup() + } + function onData(chunk: Buffer) { + if (finished) + return + size += chunk.byteLength + if (size > DEVTOOLS_API_MAX_BODY_SIZE) { + finished = true + body = '' + cleanup() + // Drain the remainder so the keep-alive connection can be reused. + // `cleanup()` removed the normal error handler, so keep one listener + // attached while the unread request bytes are being discarded. + req.once('error', () => {}) + req.resume() + res.statusCode = 413 + res.end('payload too large') + return + } body += chunk.toString() - }) - req.on('end', () => { + } + function onEnd() { + if (finished) + return + finished = true + cleanup() try { const data = JSON.parse(body) scriptsState = { ...data, updatedAt: Date.now() } @@ -96,7 +131,13 @@ function setupStandaloneApi(nuxt: Nuxt) { res.statusCode = 400 res.end('invalid json') } - }) + body = '' + } + + req.on('data', onData) + req.on('end', onEnd) + req.on('aborted', onAborted) + req.on('error', onAborted) return } diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 99131a99d..59fb668c4 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -41,6 +41,12 @@ import { generateInterceptPluginContents } from './plugins/intercept' import { NuxtScriptBundleTransformer } from './plugins/transform' import { aliasProxyValue, buildDomainAliasMap, invertAliasMap, isSafeAliasSegment } from './proxy-alias' import { buildProxyConfigsFromRegistry, generatePartytownResolveUrl, getPartytownForwards, registry, resolveCapabilities } from './registry' +import { + NUXT_SCRIPTS_CACHE_BASE, + NUXT_SCRIPTS_CACHE_MAX_ENTRIES, + NUXT_SCRIPTS_CACHE_MAX_ENTRY_SIZE, + NUXT_SCRIPTS_CACHE_MAX_SIZE, +} from './runtime/server/utils/cache-config' import { registerTypeTemplates, templatePlugin, templateTriggerResolver } from './templates' import { validateScriptsEnvVars } from './validate-env' @@ -1093,6 +1099,20 @@ export default defineNuxtModule({ } } + if (Object.keys(enabledEndpoints).length > 0) { + const nitroOptions = nuxt.options.nitro as any + nitroOptions.storage ||= {} + // Nitro's default memory storage has no eviction policy. Keep proxy and + // embed caches bounded unless the application supplied a dedicated + // persistent/distributed mount for this namespace. + nitroOptions.storage[NUXT_SCRIPTS_CACHE_BASE] ||= { + driver: 'lru-cache', + max: NUXT_SCRIPTS_CACHE_MAX_ENTRIES, + maxSize: NUXT_SCRIPTS_CACHE_MAX_SIZE, + maxEntrySize: NUXT_SCRIPTS_CACHE_MAX_ENTRY_SIZE, + } + } + // Publish enabled endpoints to client for component opt-in checks nuxt.options.runtimeConfig.public['nuxt-scripts'] = defu( { endpoints: enabledEndpoints }, diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index 702e8c02c..bd9d624e4 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -152,10 +152,11 @@ export interface ScriptGoogleMapsSlots {