From f1dd75d0b1552417b9a9addab66b280086175c69 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 16 Jul 2026 02:26:47 +1000 Subject: [PATCH 1/6] perf: memory leak hardening --- packages/devtools-app/composables/rpc.ts | 107 +++++++++--- packages/devtools-app/composables/state.ts | 35 +++- packages/devtools-app/utils/fetch.ts | 7 +- packages/script/src/devtools.ts | 46 +++++- packages/script/src/module.ts | 20 +++ .../GoogleMaps/ScriptGoogleMaps.vue | 109 ++++++++++--- .../GoogleMaps/useGoogleMapsResource.ts | 55 ++++++- .../runtime/components/ScriptCarbonAds.vue | 14 +- .../src/runtime/components/ScriptCrisp.vue | 7 +- .../src/runtime/components/ScriptIntercom.vue | 7 +- .../runtime/components/ScriptLemonSqueezy.vue | 11 +- .../components/ScriptPayPalButtons.vue | 7 +- .../components/ScriptPayPalMessages.vue | 4 + .../components/ScriptStripePricingTable.vue | 4 +- .../runtime/components/ScriptVimeoPlayer.vue | 13 +- .../src/runtime/composables/useScript.ts | 152 +++++++++++++----- .../runtime/composables/useScriptEventPage.ts | 39 +++-- .../useScriptTriggerIdleTimeout.ts | 30 +++- .../useScriptTriggerInteraction.ts | 37 +++-- .../useScriptTriggerServiceWorker.ts | 42 ++--- .../devtools-standalone-bridge.client.ts | 31 +++- .../script/src/runtime/npm-script-stub.ts | 8 +- .../script/src/runtime/registry/speedcurve.ts | 35 +++- .../src/runtime/registry/usercentrics.ts | 61 +++++-- .../src/runtime/registry/youtube-player.ts | 13 +- .../src/runtime/server/instagram-embed.ts | 2 + .../src/runtime/server/proxy-handler.ts | 106 ++++++++++-- .../src/runtime/server/utils/cache-config.ts | 10 ++ .../runtime/server/utils/cached-upstream.ts | 3 + .../src/runtime/utils/after-next-paint.ts | 20 ++- test/unit/google-maps-lifecycle.test.ts | 24 +++ test/unit/speedcurve-after-next-paint.test.ts | 18 +++ vitest.config.ts | 2 + 33 files changed, 894 insertions(+), 185 deletions(-) create mode 100644 packages/script/src/runtime/server/utils/cache-config.ts diff --git a/packages/devtools-app/composables/rpc.ts b/packages/devtools-app/composables/rpc.ts index 1dc4e9300..651bf0cbf 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,33 @@ 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> = [] + + const cleanupConnection = () => { + connectionCleanups.splice(0).forEach(cleanup => cleanup()) + devtools.value = undefined + } // Embedded mode: connect via devtools-kit iframe client + let stopClientConnection = () => {} if (inIframe) { - onDevtoolsClientConnected(async (client) => { + stopClientConnection = onDevtoolsClientConnected((client) => { + if (disposed) + return + 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 +61,44 @@ 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 + let pollController: AbortController | undefined - watch(() => standaloneUrl.value, (url) => { - // Clean up previous polling + const stopPolling = () => { if (pollTimer) { clearInterval(pollTimer) pollTimer = undefined } + pollController?.abort() + pollController = 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 + } + } + + const stopStandaloneWatch = watch(() => standaloneUrl.value, (url) => { + // Clean up previous polling + stopPolling() if (url && !isConnected.value) { appFetch.value = ofetch.create({ baseURL: url }) as unknown as $Fetch @@ -71,22 +106,32 @@ 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() + appFetch.value = undefined + isConnected.value = false + } } -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..47d6d356b 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, diff --git a/packages/script/src/devtools.ts b/packages/script/src/devtools.ts index 5a78d289a..5e8a3fb02 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,41 @@ 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. + 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 +128,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..292f0354e 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -152,7 +152,7 @@ export interface ScriptGoogleMapsSlots {