diff --git a/docs/content/docs/1.getting-started/2.installation.md b/docs/content/docs/1.getting-started/2.installation.md index ae378b663..928e9242f 100644 --- a/docs/content/docs/1.getting-started/2.installation.md +++ b/docs/content/docs/1.getting-started/2.installation.md @@ -5,7 +5,12 @@ description: Install Nuxt Scripts in an existing Nuxt project. ## Quick Start -Nuxt Scripts 1.x requires Nuxt 3.16 or newer. +Nuxt Scripts 2 requires Nuxt 4.5 or newer and Unhead 3.2 or newer. Upgrade an +existing project before installing: + +```bash +npx nuxi@latest upgrade --force +``` Run: diff --git a/docs/content/docs/1.guides/1.script-triggers.md b/docs/content/docs/1.guides/1.script-triggers.md index 3613f90b5..ef4d679f6 100644 --- a/docs/content/docs/1.guides/1.script-triggers.md +++ b/docs/content/docs/1.guides/1.script-triggers.md @@ -96,7 +96,7 @@ export default defineNuxtConfig({ ### User Interaction -[`useScriptTriggerInteraction()`{lang="ts"}](/docs/api/use-script-trigger-interaction){lang="ts"} resolves on the first configured interaction: +[`useScriptTriggerInteraction()`{lang="ts"}](/docs/api/use-script-trigger-interaction){lang="ts"} loads on the first configured interaction: ::code-group diff --git a/docs/content/docs/3.api/1.use-script.md b/docs/content/docs/3.api/1.use-script.md index f8d098a53..623250f38 100644 --- a/docs/content/docs/3.api/1.use-script.md +++ b/docs/content/docs/3.api/1.use-script.md @@ -50,7 +50,8 @@ Unhead's [complete script example](https://unhead.unjs.io/docs/head/guides/core- Nuxt Scripts extends Unhead's [script triggers and warmup options](https://unhead.unjs.io/docs/head/guides/core-concepts/loading-scripts/#how-do-i-control-when-scripts-load) with these options: -- `use` - The function to resolve the script. +- `resolve` - Resolve the loaded SDK with lifecycle-bound `signal` and `waitFor` helpers. +- `use` - Legacy synchronous or async SDK resolver. Prefer `resolve` for callback-based readiness. - `trigger` - [Triggering Script Loading](/docs/guides/script-triggers) - `bundle` - Control [first-party bundling](/docs/guides/first-party). - `proxy` - Enable or disable supported collection proxying for a registry script. @@ -119,10 +120,34 @@ Outside the Partytown path, the returned object includes: - `proxy` - A typed proxy that queues calls until loading finishes - `status` - Reactive ref with the script status: `'awaitingLoad'` | `'loading'` | `'loaded'` | `'error'` | `'removed'` - `load()`{lang="ts"} - Function to manually load the script -- `remove()`{lang="ts"} - Function to remove the script from the DOM +- `signal` - An `AbortSignal` scoped to this composable consumer +- `dispose()`{lang="ts"} - Release this consumer's callbacks and triggers without removing the shared script +- `script` - The shared Unhead script instance +- `remove()`{lang="ts"} - Remove the shared script for every consumer - `reload()`{lang="ts"} - Function to remove and reload the script (see below) - `onLoaded()`{lang="ts"} and `onError()`{lang="ts"} - Script lifecycle callbacks +Each call receives its own consumer scope. Vue disposes that scope when its +component unmounts, while the shared script remains available to other callers. +Call `remove()`{lang="ts"} only to remove the script globally. + +### Lifecycle-aware SDK readiness + +Use `resolve({ waitFor })`{lang="ts"} when a vendor exposes a callback that fires +after the script element's `load` event. Listener cleanup and abort rejection are +then tied to the shared script lifecycle. + +```ts +const sdk = useScript<{ ready: true }>('https://example.com/sdk.js', { + resolve: ({ waitFor }) => waitFor<{ ready: true }>((resolve) => { + window.onExampleReady = () => resolve({ ready: true }) + return () => delete window.onExampleReady + }), +}) + +const api = await sdk.load() +``` + ### `reload()`{lang="ts"} Removes the script, inserts it again, and re-executes it. Use this for a script that scans the DOM once and must scan again after SPA navigation. diff --git a/docs/content/docs/3.api/3.use-script-trigger-idle-timeout.md b/docs/content/docs/3.api/3.use-script-trigger-idle-timeout.md index 31b0664ca..523282c4d 100644 --- a/docs/content/docs/3.api/3.use-script-trigger-idle-timeout.md +++ b/docs/content/docs/3.api/3.use-script-trigger-idle-timeout.md @@ -15,7 +15,7 @@ The trigger uses a timer after `onNuxtReady`; it does not wait for the browser's ## Signature ```ts -function useScriptTriggerIdleTimeout(options: IdleTimeoutScriptTriggerOptions): Promise +function useScriptTriggerIdleTimeout(options: IdleTimeoutScriptTriggerOptions): UseScriptTrigger ``` ## Arguments @@ -31,7 +31,10 @@ export interface IdleTimeoutScriptTriggerOptions { ## Returns -A promise that resolves to `true` when the timeout completes. If the owning Vue scope is disposed after the timer starts, it stops the timer and resolves to `false`. On the server, and if the scope disappears before `onNuxtReady` runs, the promise remains pending. +An Unhead trigger function for `scriptOptions.trigger`. It starts the timer after +Nuxt is ready and loads the script when the timeout completes. Disposing the +consumer scope cancels the pending timer, including when disposal happens before +Nuxt becomes ready. The trigger does not install a timer during SSR. ## Nuxt Config Usage diff --git a/docs/content/docs/3.api/3.use-script-trigger-interaction.md b/docs/content/docs/3.api/3.use-script-trigger-interaction.md index 838d55666..5950af2fd 100644 --- a/docs/content/docs/3.api/3.use-script-trigger-interaction.md +++ b/docs/content/docs/3.api/3.use-script-trigger-interaction.md @@ -10,14 +10,13 @@ links: Load a script when any configured interaction event occurs. -Listeners are attached inside `onNuxtReady`, so interactions that happen before Nuxt is ready do not count. Passing an empty `events` array leaves the promise pending. - -The scope-disposal hook is also registered inside `onNuxtReady`. If the calling scope unmounts before Nuxt becomes ready, the callback can still attach listeners afterward; keep this trigger in a long-lived scope until that cleanup ordering is fixed. +Listeners are attached inside `onNuxtReady`, so interactions that happen before +Nuxt is ready do not count. Passing an empty `events` array throws an error. ## Signature ```ts -function useScriptTriggerInteraction(options: InteractionScriptTriggerOptions): Promise +function useScriptTriggerInteraction(options: InteractionScriptTriggerOptions): UseScriptTrigger ``` ## Arguments @@ -38,7 +37,10 @@ export interface InteractionScriptTriggerOptions { ## Returns -A promise that resolves to `true` after the first matching event. It resolves to `false` when `target` is null or the owning scope is disposed after the listeners have been attached. On the server, and when the scope disappears before `onNuxtReady` registers cleanup, it remains pending. +An Unhead trigger function for `scriptOptions.trigger`. It loads the script after +the first matching event, then removes every listener. Disposing the consumer +scope removes pending listeners, including when disposal happens before Nuxt +becomes ready. A `null` target leaves the script unloaded. ## Nuxt Config Usage @@ -125,4 +127,4 @@ The `target` option accepts an `EventTarget` that already exists when the compos - Listen for interactions that indicate users will need the script soon. - Include keyboard and touch events when the feature supports those input methods. - Use `target` to limit listeners to the relevant part of the page. -- If the script must load by a deadline even without interaction, race this trigger against a timeout in a custom promise. +- If the script must load by a deadline even without interaction, combine the event listeners and timeout in one custom trigger function. diff --git a/docs/content/docs/4.migration-guide/2.v1-to-v2.md b/docs/content/docs/4.migration-guide/2.v1-to-v2.md new file mode 100644 index 000000000..4580472ec --- /dev/null +++ b/docs/content/docs/4.migration-guide/2.v1-to-v2.md @@ -0,0 +1,68 @@ +--- +title: v1 to v2 +description: Migration guide for upgrading from Nuxt Scripts v1.x to v2.0. +--- + +Nuxt Scripts 2 moves script ownership and SDK readiness onto the lifecycle APIs +introduced in Unhead 3.2. This removes component callbacks and trigger listeners +as soon as their consumer unmounts, without tearing down a script still used by +other components. + +## Requirements + +| Dependency | Required version | +|---|---| +| Nuxt | `>=4.5.0` | +| `@unhead/vue` | `>=3.2.0 <4` | +| `unhead` | `>=3.2.0 <4` | + +Upgrade Nuxt and refresh its locked dependencies before installing v2: + +```bash +npx nuxi@latest upgrade --force +``` + +The module now stops setup with an actionable error when either Unhead package +is missing or outside the supported range. + +## Consumer scopes + +Every `useScript()`{lang="ts"} call now returns an Unhead consumer scope. +Component unmount automatically releases callbacks and trigger listeners owned +by that call. + +- `dispose()`{lang="ts"} releases only the current consumer. +- `signal` aborts when you dispose that consumer or any caller removes the shared script. +- `script` points to the shared script instance. +- `remove()`{lang="ts"} still removes the shared script for all consumers. + +If application code used `remove()`{lang="ts"} as component cleanup, switch it +to `dispose()`{lang="ts"}. In Vue components, manual cleanup is normally no +longer necessary. + +## Custom readiness callbacks + +The legacy `use` option remains supported. Callback-driven SDKs should migrate +to `resolve({ waitFor })`{lang="ts"}, which automatically removes listeners and +rejects pending readiness when the script lifecycle ends. + +```diff + const script = useScript('https://example.com/sdk.js', { +- use: () => readyPromise.then(() => window.example), ++ resolve: ({ waitFor }) => waitFor((resolve) => { ++ window.onExampleReady = () => resolve(window.example) ++ return () => delete window.onExampleReady ++ }), + }) +``` + +The bundled Google Maps, YouTube Player, Crisp, and Usercentrics integrations +now use this API. `load()`{lang="ts"} resolves only after each vendor's concrete +SDK API is ready. + +## Script triggers + +Nuxt's idle-timeout, interaction, and service-worker helpers now return Unhead +trigger functions. Existing `scriptOptions.trigger` usage is unchanged. Custom +trigger functions may return a cleanup callback; Unhead calls it when the +consumer scope is disposed. diff --git a/package.json b/package.json index 9446be21a..752984dda 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "@types/google.maps": "catalog:", "@types/jest-image-snapshot": "catalog:", "@types/node": "catalog:", + "@types/semver": "catalog:", "@types/youtube": "catalog:", + "@unhead/vue": "catalog:", "@vue/test-utils": "catalog:", "bumpp": "catalog:", "defu": "catalog:", @@ -59,6 +61,7 @@ "typescript": "catalog:", "ufo": "catalog:", "ultrahtml": "catalog:", + "unhead": "catalog:", "vitest": "catalog:", "vue": "catalog:", "vue-tsc": "catalog:" diff --git a/packages/devtools-app/composables/rpc.ts b/packages/devtools-app/composables/rpc.ts index 1dc4e9300..29dcb5903 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,50 @@ 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 + let connectionMode: 'embedded' | 'standalone' | undefined + 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()) + connectionMode = undefined + 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() + connectionMode = 'embedded' 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,45 +78,67 @@ 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 - - watch(() => standaloneUrl.value, (url) => { - // Clean up previous polling - if (pollTimer) { - clearInterval(pollTimer) - pollTimer = 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 + } + } - if (url && !isConnected.value) { + const stopStandaloneWatch = watch(() => standaloneUrl.value, (url) => { + // Reconnect when the configured standalone server changes. Embedded + // connections keep ownership until their host disconnects. + stopPolling() + if (connectionMode === 'standalone') + cleanupConnection() + + if (url && connectionMode !== 'embedded') { + connectionMode = 'standalone' 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 @@ -106,15 +158,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/package.json b/packages/devtools-app/package.json index d57b8cf2f..371544e5e 100644 --- a/packages/devtools-app/package.json +++ b/packages/devtools-app/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "nuxi dev", + "dev:prepare": "nuxi prepare", "build": "nuxi build", "generate": "nuxi generate" }, 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/package.json b/packages/script/package.json index e823ef881..8e53fc631 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -59,6 +59,7 @@ "build": { "externals": [ "@unhead/vue", + "unhead", "@unhead/schema", "knitwork", "#build/modules/nuxt-scripts-gtm", @@ -80,8 +81,9 @@ "@types/google.maps": "^3.58.1", "@types/vimeo__player": "^2.18.3", "@types/youtube": "^0.1.0", - "@unhead/vue": "^2.0.3 || ^3.0.0", - "posthog-js": "^1.0.0" + "@unhead/vue": "^3.2.0", + "posthog-js": "^1.0.0", + "unhead": "^3.2.0" }, "peerDependenciesMeta": { "@googlemaps/markerclusterer": { @@ -105,9 +107,6 @@ "@types/youtube": { "optional": true }, - "@unhead/vue": { - "optional": true - }, "posthog-js": { "optional": true } @@ -126,6 +125,7 @@ "oxc-walker": "catalog:", "pathe": "catalog:", "pkg-types": "catalog:", + "semver": "catalog:", "sirv": "catalog:", "std-env": "catalog:", "ufo": "catalog:", @@ -138,8 +138,10 @@ "@nuxt/kit": "catalog:", "@nuxt/module-builder": "catalog:", "@speedcurve/lux": "catalog:", + "@unhead/vue": "catalog:", "rollup": "catalog:", "unbuild": "catalog:", + "unhead": "catalog:", "unimport": "catalog:" } } diff --git a/packages/script/src/devtools.ts b/packages/script/src/devtools.ts index 5a78d289a..47894a2ef 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 @@ -54,7 +55,7 @@ export async function setupDevtools(nuxt: Nuxt, options: DevtoolsOptions = {}) { }) } -function setupStandaloneApi(nuxt: Nuxt) { +export function setupStandaloneApi(nuxt: Nuxt) { // In-memory store for serialized script state let scriptsState: { scripts: Record, version?: string, firstPartyData?: any, updatedAt: number } = { scripts: {}, @@ -81,13 +82,49 @@ function setupStandaloneApi(nuxt: Nuxt) { } if (req.method === 'POST') { - let body = '' - req.on('data', (chunk: Buffer) => { - body += chunk.toString() - }) - req.on('end', () => { + const chunks: 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 + chunks.length = 0 + cleanup() + } + function onData(chunk: Buffer) { + if (finished) + return + size += chunk.byteLength + if (size > DEVTOOLS_API_MAX_BODY_SIZE) { + finished = true + chunks.length = 0 + 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 + } + chunks.push(chunk) + } + function onEnd() { + if (finished) + return + finished = true + cleanup() try { - const data = JSON.parse(body) + // Decode once so a multi-byte UTF-8 sequence split across chunks + // is not replaced with invalid characters before JSON parsing. + const data = JSON.parse(Buffer.concat(chunks).toString('utf8')) scriptsState = { ...data, updatedAt: Date.now() } res.statusCode = 200 res.end('ok') @@ -96,7 +133,13 @@ function setupStandaloneApi(nuxt: Nuxt) { res.statusCode = 400 res.end('invalid json') } - }) + chunks.length = 0 + } + + 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 cdc99f7c5..aaebb6517 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -31,6 +31,7 @@ import { import { defu } from 'defu' import { resolve as resolvePath_ } from 'pathe' import { readPackageJSON } from 'pkg-types' +import { satisfies } from 'semver' import { setupPublicAssetStrategy } from './assets' import { buildDevtoolsData, buildDevtoolsEntry, setupDevtools } from './devtools' import { installNuxtModule } from './kit' @@ -41,6 +42,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' @@ -54,6 +61,7 @@ export type { FirstPartyPrivacy } // Matches self-closing PascalCase or kebab-case tags starting with "Script"/"script-" // e.g. or const SELF_CLOSING_SCRIPT_RE = /<((?:Script[A-Z]|script-)\w[\w-]*)\b([^>]*?)\/\s*>/g +const UNHEAD_VERSION_RANGE = '>=3.2.0 <4' /** * Expand self-closing `` component tags in page files to work around @@ -483,7 +491,7 @@ export default defineNuxtModule({ name: '@nuxt/scripts', configKey: 'scripts', compatibility: { - nuxt: '>=3.16', + nuxt: '>=4.5.0', }, }, defaults: { @@ -526,12 +534,21 @@ export default defineNuxtModule({ }) } } - // couldn't be found for some reason, assume compatibility - const { version: unheadVersion } = await readPackageJSON('@unhead/vue', { - from: nuxt.options.modulesDir, - }).catch(() => ({ version: null })) - if (unheadVersion?.startsWith('1')) { - logger.error(`Nuxt Scripts requires Unhead >= 2, you are using v${unheadVersion}. Please run \`nuxi upgrade --clean\` to upgrade...`) + const [unheadVuePackage, unheadCorePackage] = await Promise.all([ + readPackageJSON('@unhead/vue', { from: nuxt.options.modulesDir }).catch(() => null), + readPackageJSON('unhead', { from: nuxt.options.modulesDir }).catch(() => null), + ]) + const incompatibleUnheadPackages = [ + ['@unhead/vue', unheadVuePackage?.version], + ['unhead', unheadCorePackage?.version], + ].filter(([, dependencyVersion]) => !dependencyVersion || !satisfies(dependencyVersion, UNHEAD_VERSION_RANGE)) + if (incompatibleUnheadPackages.length) { + const resolvedVersions = incompatibleUnheadPackages + .map(([dependency, dependencyVersion]) => `${dependency}=${dependencyVersion ? `v${dependencyVersion}` : 'missing'}`) + .join(', ') + throw new Error( + `[nuxt-scripts] Nuxt Scripts 2 requires @unhead/vue and unhead ${UNHEAD_VERSION_RANGE}; resolved ${resolvedVersions}. Run \`npx nuxi@latest upgrade --force\` to upgrade Nuxt and refresh its dependencies.`, + ) } const scripts = await registry(resolvePath) as (RegistryScript & { _importRegistered?: boolean })[] @@ -1094,6 +1111,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/registry-types.json b/packages/script/src/registry-types.json index 7a9da743a..af9445624 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -348,12 +348,17 @@ { "name": "MapsNamespace", "kind": "type", - "code": "type MapsNamespace = typeof window.google.maps" + "code": "type MapsNamespace = typeof google.maps" + }, + { + "name": "GoogleMapsWindow", + "kind": "type", + "code": "type GoogleMapsWindow = Window & {\n google: {\n maps: MapsNamespace & { __ib__?: () => void }\n }\n}" }, { "name": "GoogleMapsApi", "kind": "interface", - "code": "export interface GoogleMapsApi {\n maps: Promise\n}" + "code": "export interface GoogleMapsApi {\n maps: MapsNamespace\n}" }, { "name": "ScriptGoogleMapsProps", @@ -1208,7 +1213,7 @@ { "name": "YouTubePlayerApi", "kind": "interface", - "code": "export interface YouTubePlayerApi {\n YT: MaybePromise<{\n Player: YT.Player\n PlayerState: YT.PlayerState\n get: (k: string) => any\n loaded: 0 | 1\n loading: 0 | 1\n ready: (f: () => void) => void\n scan: () => void\n setConfig: (config: YT.PlayerOptions) => void\n subscribe: (\n event: EventName,\n listener: YT.Events[EventName],\n context?: any,\n ) => void\n unsubscribe: (\n event: EventName,\n listener: YT.Events[EventName],\n context?: any,\n ) => void\n }>\n}" + "code": "export interface YouTubePlayerApi {\n YT: {\n Player: YT.Player\n PlayerState: YT.PlayerState\n get: (k: string) => any\n loaded: 0 | 1\n loading: 0 | 1\n ready: (f: () => void) => void\n scan: () => void\n setConfig: (config: YT.PlayerOptions) => void\n subscribe: (\n event: EventName,\n listener: YT.Events[EventName],\n context?: any,\n ) => void\n unsubscribe: (\n event: EventName,\n listener: YT.Events[EventName],\n context?: any,\n ) => void\n }\n}" }, { "name": "ScriptYouTubePlayerProps", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 591d7889b..b15dad5e1 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -634,10 +634,11 @@ export async function registry(resolve?: (path: string) => Promise): Pro // Clarity buckets visitors across letter/hash-prefixed shards (a/b/c/d/e/k/...). // Microsoft adds shards over time, so an enumerated list silently 403s // through the proxy when an unlisted letter is rolled out (#728-class bug). - // `*.clarity.ms` covers the full surface at runtime; `www.clarity.ms` is - // kept literal so the build-time URL rewrite (which filters wildcards) - // can still rewrite `https://www.clarity.ms/tag/` in bundled SDKs. - domains: ['www.clarity.ms', '*.clarity.ms'], + // `*.clarity.ms` covers the full surface at runtime. Literal hosts are + // also required because build-time URL rewriting filters wildcards; the + // bootstrap currently loads its SDK from `scripts`, uploads to `p`, and + // synchronizes consent through `c`. + domains: ['www.clarity.ms', 'scripts.clarity.ms', 'p.clarity.ms', 'c.clarity.ms', '*.clarity.ms'], privacy: PRIVACY_HEATMAP, }, partytown: { forwards: ['clarity'] }, 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 {