Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
107 changes: 83 additions & 24 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> = []

const cleanupConnection = () => {
connectionCleanups.splice(0).forEach(cleanup => cleanup())
devtools.value = undefined
}
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
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
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
// 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()
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
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
7 changes: 5 additions & 2 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 Down
46 changes: 42 additions & 4 deletions packages/script/src/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
const data = JSON.parse(body)
scriptsState = { ...data, updatedAt: Date.now() }
Expand All @@ -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
}

Expand Down
20 changes: 20 additions & 0 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -1093,6 +1099,20 @@ export default defineNuxtModule<ModuleOptions>({
}
}

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 },
Expand Down
Loading
Loading