diff --git a/packages/script/src/kit.ts b/packages/script/src/kit.ts
index 211fa9646..a9ec075b2 100644
--- a/packages/script/src/kit.ts
+++ b/packages/script/src/kit.ts
@@ -11,8 +11,12 @@ interface EnsurePackageInstalledOptions {
}
async function promptToInstall(name: string, installCommand: () => Promise
, options: EnsurePackageInstalledOptions) {
- if (await resolvePackageJSON(name).catch(() => null))
+ if (await resolvePackageJSON(name).catch(() => {
+ // A missing package is expected here; the install prompt handles it below.
+ return null
+ })) {
return true
+ }
logger.info(`Package ${name} is missing`)
if (isCI)
diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts
index cdc99f7c5..56be2ac1b 100644
--- a/packages/script/src/module.ts
+++ b/packages/script/src/module.ts
@@ -35,6 +35,7 @@ import { setupPublicAssetStrategy } from './assets'
import { buildDevtoolsData, buildDevtoolsEntry, setupDevtools } from './devtools'
import { installNuxtModule } from './kit'
import { logger } from './logger'
+import { setupNitroRuntimeCompatibility } from './nitro-compatibility'
import { extractRequiredFields, migrateDeprecatedRegistryKeys, normalizeRegistryConfig } from './normalize'
import { NuxtScriptsCheckScripts } from './plugins/check-scripts'
import { generateInterceptPluginContents } from './plugins/intercept'
@@ -514,6 +515,7 @@ export default defineNuxtModule({
logger.debug('The module is disabled, skipping setup.')
return
}
+ await setupNitroRuntimeCompatibility(nuxt)
if (nuxt.options.dev) {
setupDevtools(nuxt, { standalone: config._standaloneDevtools })
if (config._standaloneDevtools) {
diff --git a/packages/script/src/nitro-compatibility.ts b/packages/script/src/nitro-compatibility.ts
new file mode 100644
index 000000000..e2091632e
--- /dev/null
+++ b/packages/script/src/nitro-compatibility.ts
@@ -0,0 +1,127 @@
+import type { Nuxt } from '@nuxt/schema'
+import { existsSync } from 'node:fs'
+import { pathToFileURL } from 'node:url'
+import { addTypeTemplate, getNuxtVersion, resolvePath as resolveNuxtPath } from '@nuxt/kit'
+import { dirname } from 'pathe'
+
+type NitroRuntimeCompatibility
+ = | { _tag: 'nitro-v2' }
+ | {
+ _tag: 'nitro-v3'
+ app: string
+ cache: string
+ h3: string
+ runtimeConfig: string
+ }
+
+type ResolveNitroImport = (id: string) => Promise
+
+interface NitroCompatibilityOptions {
+ alias?: Record
+ virtual?: Record
+}
+
+interface NitroCompatibilityDependencies {
+ addTypeTemplate: typeof addTypeTemplate
+ getNuxtVersion: typeof getNuxtVersion
+ resolveNitroImport?: ResolveNitroImport
+}
+
+const NITRO_RUNTIME_MODULE = '#nuxt-scripts/nitro'
+const H3_RUNTIME_MODULE = '#nuxt-scripts/h3'
+const TYPE_TEMPLATE_FILENAME = 'types/nuxt-scripts-nitro.d.ts'
+const defaultDependencies: NitroCompatibilityDependencies = {
+ addTypeTemplate,
+ getNuxtVersion,
+}
+
+const nitroV2Runtime = `export {
+ defineCachedFunction,
+ useNitroApp,
+ useRuntimeConfig,
+} from 'nitropack/runtime'
+`
+
+const nitroV3RuntimeTypes = `export { useNitroApp } from 'nitro/app'
+export { defineCachedFunction } from 'nitro/cache'
+export function useRuntimeConfig(event?: import('nitro/h3').H3Event): ReturnType
+`
+
+function indent(value: string, spaces: number): string {
+ const padding = ' '.repeat(spaces)
+ return value.split('\n').map(line => `${padding}${line}`).join('\n')
+}
+
+function renderRuntimeDeclarations(compatibility: NitroRuntimeCompatibility): string {
+ const nitroRuntime = compatibility._tag === 'nitro-v3' ? nitroV3RuntimeTypes : nitroV2Runtime
+ const h3Runtime = compatibility._tag === 'nitro-v3'
+ ? `export * from 'nitro/h3'\n`
+ : `export * from 'h3'\n`
+
+ return `declare module '${NITRO_RUNTIME_MODULE}' {
+${indent(nitroRuntime.trim(), 2)}
+}
+
+declare module '${H3_RUNTIME_MODULE}' {
+${indent(h3Runtime.trim(), 2)}
+}
+`
+}
+
+function renderNitroV3Runtime(compatibility: Extract): string {
+ return `export { useNitroApp } from ${JSON.stringify(compatibility.app)}
+export { defineCachedFunction } from ${JSON.stringify(compatibility.cache)}
+import { useRuntimeConfig as _useRuntimeConfig } from ${JSON.stringify(compatibility.runtimeConfig)}
+export function useRuntimeConfig(_event) { return _useRuntimeConfig() }
+`
+}
+
+function applyNitroRuntimeCompatibility(nuxt: Nuxt, compatibility: NitroRuntimeCompatibility): void {
+ const nuxtOptions = nuxt.options as Nuxt['options'] & { nitro?: NitroCompatibilityOptions }
+ const nitroOptions = nuxtOptions.nitro ||= {}
+ nitroOptions.alias ||= {}
+ nitroOptions.virtual ||= {}
+ nitroOptions.alias[H3_RUNTIME_MODULE] = compatibility._tag === 'nitro-v3' ? compatibility.h3 : 'h3'
+ nitroOptions.virtual[NITRO_RUNTIME_MODULE] = compatibility._tag === 'nitro-v3'
+ ? renderNitroV3Runtime(compatibility)
+ : nitroV2Runtime
+}
+
+async function createNuxtNitroImportResolver(): Promise {
+ const nuxtDir = dirname(await resolveNuxtPath('nuxt/package.json'))
+ const nitroDir = dirname(await resolveNuxtPath('@nuxt/nitro-server/package.json', { cwd: nuxtDir }))
+
+ return async (id: string) => {
+ const resolved = await resolveNuxtPath(id, { cwd: nitroDir })
+ if (!existsSync(resolved))
+ throw new Error(`[nuxt-scripts] Could not resolve Nitro runtime helper "${id}" from "${nitroDir}".`)
+ return pathToFileURL(resolved).href
+ }
+}
+
+async function resolveNitroV3Compatibility(resolveNitroImport: ResolveNitroImport): Promise {
+ const [app, cache, h3, runtimeConfig] = await Promise.all([
+ resolveNitroImport('nitro/app'),
+ resolveNitroImport('nitro/cache'),
+ resolveNitroImport('nitro/h3'),
+ resolveNitroImport('nitro/runtime-config'),
+ ])
+
+ return { _tag: 'nitro-v3', app, cache, h3, runtimeConfig }
+}
+
+export async function setupNitroRuntimeCompatibility(
+ nuxt: Nuxt,
+ dependencies: NitroCompatibilityDependencies = defaultDependencies,
+): Promise {
+ const compatibility: NitroRuntimeCompatibility = Number.parseInt(dependencies.getNuxtVersion(nuxt), 10) >= 5
+ ? await resolveNitroV3Compatibility(dependencies.resolveNitroImport || await createNuxtNitroImportResolver())
+ : { _tag: 'nitro-v2' }
+
+ applyNitroRuntimeCompatibility(nuxt, compatibility)
+ nuxt.hooks.hookOnce('modules:done', () => applyNitroRuntimeCompatibility(nuxt, compatibility))
+ dependencies.addTypeTemplate({
+ filename: TYPE_TEMPLATE_FILENAME,
+ getContents: async () => renderRuntimeDeclarations(compatibility),
+ }, { nitro: true, node: true, nuxt: true })
+}
diff --git a/packages/script/src/runtime/server/bluesky-embed.ts b/packages/script/src/runtime/server/bluesky-embed.ts
index 45090faa1..a3ce243e1 100644
--- a/packages/script/src/runtime/server/bluesky-embed.ts
+++ b/packages/script/src/runtime/server/bluesky-embed.ts
@@ -1,5 +1,5 @@
-import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
-import { useRuntimeConfig } from 'nitropack/runtime'
+import { createError, defineEventHandler, getQuery, setHeader } from '#nuxt-scripts/h3'
+import { useRuntimeConfig } from '#nuxt-scripts/nitro'
import { createCachedJsonFetch } from './utils/cached-upstream'
import { rewriteBlueskyPostImages } from './utils/embed-rewriters'
import { withSigning } from './utils/withSigning'
@@ -115,7 +115,7 @@ export default withSigning(defineEventHandler(async (event) => {
const handlerPath = event.path?.split('?')[0] || ''
const prefix = handlerPath.replace(EMBED_BSKY_SUFFIX_RE, '') || '/_scripts'
const imagePath = `${prefix}/embed/bluesky-image`
- const secret = (useRuntimeConfig(event)['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret
+ const secret = (useRuntimeConfig()['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret
rewriteBlueskyPostImages(post, imagePath, secret)
// Cache for 10 minutes
diff --git a/packages/script/src/runtime/server/google-maps-geocode-proxy.ts b/packages/script/src/runtime/server/google-maps-geocode-proxy.ts
index 79df25499..dde0e62aa 100644
--- a/packages/script/src/runtime/server/google-maps-geocode-proxy.ts
+++ b/packages/script/src/runtime/server/google-maps-geocode-proxy.ts
@@ -1,6 +1,6 @@
-import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
-import { useRuntimeConfig } from 'nitropack/runtime'
import { withQuery } from 'ufo'
+import { createError, defineEventHandler, getQuery, setHeader } from '#nuxt-scripts/h3'
+import { useRuntimeConfig } from '#nuxt-scripts/nitro'
import { createCachedJsonFetch } from './utils/cached-upstream'
import { withSigning } from './utils/withSigning'
diff --git a/packages/script/src/runtime/server/google-static-maps-proxy.ts b/packages/script/src/runtime/server/google-static-maps-proxy.ts
index e79486f99..51db1f51d 100644
--- a/packages/script/src/runtime/server/google-static-maps-proxy.ts
+++ b/packages/script/src/runtime/server/google-static-maps-proxy.ts
@@ -1,6 +1,6 @@
-import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
-import { useRuntimeConfig } from 'nitropack/runtime'
import { withQuery } from 'ufo'
+import { createError, defineEventHandler, getQuery, setHeader } from '#nuxt-scripts/h3'
+import { useRuntimeConfig } from '#nuxt-scripts/nitro'
import { createCachedBinaryFetch } from './utils/cached-upstream'
import { PAGE_TOKEN_PARAM, PAGE_TOKEN_TS_PARAM, SIG_PARAM } from './utils/sign-constants'
import { withSigning } from './utils/withSigning'
diff --git a/packages/script/src/runtime/server/gravatar-proxy.ts b/packages/script/src/runtime/server/gravatar-proxy.ts
index 693ae3a59..9467f65b1 100644
--- a/packages/script/src/runtime/server/gravatar-proxy.ts
+++ b/packages/script/src/runtime/server/gravatar-proxy.ts
@@ -1,6 +1,6 @@
-import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
-import { useRuntimeConfig } from 'nitropack/runtime'
import { withQuery } from 'ufo'
+import { createError, defineEventHandler, getQuery, setHeader } from '#nuxt-scripts/h3'
+import { useRuntimeConfig } from '#nuxt-scripts/nitro'
import { createCachedBinaryFetch } from './utils/cached-upstream'
import { withSigning } from './utils/withSigning'
diff --git a/packages/script/src/runtime/server/instagram-embed.ts b/packages/script/src/runtime/server/instagram-embed.ts
index 56d207bda..5ae052f69 100644
--- a/packages/script/src/runtime/server/instagram-embed.ts
+++ b/packages/script/src/runtime/server/instagram-embed.ts
@@ -1,8 +1,8 @@
-import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
-import { defineCachedFunction, useRuntimeConfig } from 'nitropack/runtime'
import { $fetch } from 'ofetch'
import { hash } from 'ohash'
import { ELEMENT_NODE, parse, renderSync, TEXT_NODE, walkSync } from 'ultrahtml'
+import { createError, defineEventHandler, getQuery, setHeader } from '#nuxt-scripts/h3'
+import { defineCachedFunction, useRuntimeConfig } from '#nuxt-scripts/nitro'
import { createCachedJsonFetch } from './utils/cached-upstream'
import { isEmbedShell, proxyAssetUrl, rewriteUrl, rewriteUrlsInText, RSRC_RE, scopeCss } from './utils/instagram-embed'
import { withSigning } from './utils/withSigning'
@@ -70,7 +70,7 @@ export default withSigning(defineEventHandler(async (event) => {
// The route is registered as `/embed/instagram`, so strip `/embed/instagram`.
const handlerPath = event.path?.split('?')[0] || ''
const prefix = handlerPath.replace(EMBED_INSTAGRAM_SUFFIX_RE, '') || '/_scripts'
- const secret = (useRuntimeConfig(event)['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret
+ const secret = (useRuntimeConfig()['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret
const query = getQuery(event)
const postUrl = query.url as string
diff --git a/packages/script/src/runtime/server/proxy-handler.ts b/packages/script/src/runtime/server/proxy-handler.ts
index 5c272a30b..0619e9866 100644
--- a/packages/script/src/runtime/server/proxy-handler.ts
+++ b/packages/script/src/runtime/server/proxy-handler.ts
@@ -1,6 +1,6 @@
import type { ProxyPrivacyInput, ResolvedProxyPrivacy } from './utils/privacy'
-import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, readBody, readRawBody, setResponseHeader, setResponseStatus } from 'h3'
-import { useNitroApp, useRuntimeConfig } from 'nitropack/runtime'
+import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, readBody, readRawBody, setResponseHeader, setResponseStatus } from '#nuxt-scripts/h3'
+import { useNitroApp, useRuntimeConfig } from '#nuxt-scripts/nitro'
import { matchDomain } from './utils/match-domain'
import {
anonymizeIP,
@@ -343,7 +343,7 @@ export default defineEventHandler(async (event) => {
// Emit hook for E2E testing — allows capturing before/after data
const nitro = useNitroApp()
- await (nitro.hooks.callHook as (name: string, ctx: any) => Promise)('nuxt-scripts:proxy', {
+ await (nitro.hooks?.callHook as ((name: string, ctx: any) => Promise) | undefined)?.('nuxt-scripts:proxy', {
timestamp: Date.now(),
path: event.path,
targetUrl,
diff --git a/packages/script/src/runtime/server/utils/cached-upstream.ts b/packages/script/src/runtime/server/utils/cached-upstream.ts
index 931d66f23..eda9f657f 100644
--- a/packages/script/src/runtime/server/utils/cached-upstream.ts
+++ b/packages/script/src/runtime/server/utils/cached-upstream.ts
@@ -1,7 +1,7 @@
import { Buffer } from 'node:buffer'
-import { defineCachedFunction } from 'nitropack/runtime'
import { $fetch } from 'ofetch'
import { hash } from 'ohash'
+import { defineCachedFunction } from '#nuxt-scripts/nitro'
/**
* Server-side caches for upstream proxy fetches.
diff --git a/packages/script/src/runtime/server/utils/image-proxy.ts b/packages/script/src/runtime/server/utils/image-proxy.ts
index bb90844b8..9c45e827c 100644
--- a/packages/script/src/runtime/server/utils/image-proxy.ts
+++ b/packages/script/src/runtime/server/utils/image-proxy.ts
@@ -1,4 +1,4 @@
-import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
+import { createError, defineEventHandler, getQuery, setHeader } from '#nuxt-scripts/h3'
import { createCachedBinaryFetch } from './cached-upstream'
import { withSigning } from './withSigning'
diff --git a/packages/script/src/runtime/server/utils/sign.ts b/packages/script/src/runtime/server/utils/sign.ts
index ab40d0c9a..13cd2d5a6 100644
--- a/packages/script/src/runtime/server/utils/sign.ts
+++ b/packages/script/src/runtime/server/utils/sign.ts
@@ -22,9 +22,9 @@
* prerendered HTML for no practical gain.
*/
-import type { H3Event } from 'h3'
+import type { H3Event } from '#nuxt-scripts/h3'
import { createHmac } from 'node:crypto'
-import { getQuery } from 'h3'
+import { getQuery } from '#nuxt-scripts/h3'
import {
PAGE_TOKEN_MAX_AGE,
PAGE_TOKEN_PARAM,
diff --git a/packages/script/src/runtime/server/utils/withSigning.ts b/packages/script/src/runtime/server/utils/withSigning.ts
index 495362226..604d494c7 100644
--- a/packages/script/src/runtime/server/utils/withSigning.ts
+++ b/packages/script/src/runtime/server/utils/withSigning.ts
@@ -20,16 +20,16 @@
* never reach the upstream fetch and cannot consume API quota.
*/
-import type { EventHandler, EventHandlerRequest, EventHandlerResponse } from 'h3'
-import { createError, defineEventHandler } from 'h3'
-import { useRuntimeConfig } from 'nitropack/runtime'
+import type { EventHandler, EventHandlerRequest, EventHandlerResponse } from '#nuxt-scripts/h3'
+import { createError, defineEventHandler } from '#nuxt-scripts/h3'
+import { useRuntimeConfig } from '#nuxt-scripts/nitro'
import { verifyProxyRequest } from './sign'
export function withSigning(
handler: EventHandler,
) {
return defineEventHandler(async (event) => {
- const runtimeConfig = useRuntimeConfig(event)
+ const runtimeConfig = useRuntimeConfig()
const scriptsConfig = runtimeConfig['nuxt-scripts'] as { proxySecret?: string, pageTokenMaxAge?: number } | undefined
const secret = scriptsConfig?.proxySecret
diff --git a/packages/script/src/runtime/server/vercel-insights-sink.ts b/packages/script/src/runtime/server/vercel-insights-sink.ts
index ad276bda0..80fb6eee8 100644
--- a/packages/script/src/runtime/server/vercel-insights-sink.ts
+++ b/packages/script/src/runtime/server/vercel-insights-sink.ts
@@ -1,4 +1,4 @@
-import { defineEventHandler, setResponseStatus } from 'h3'
+import { defineEventHandler, setResponseStatus } from '#nuxt-scripts/h3'
export default defineEventHandler((event) => {
setResponseStatus(event, 204)
diff --git a/packages/script/src/runtime/server/x-embed.ts b/packages/script/src/runtime/server/x-embed.ts
index bca7c8bd4..9af2eda69 100644
--- a/packages/script/src/runtime/server/x-embed.ts
+++ b/packages/script/src/runtime/server/x-embed.ts
@@ -1,5 +1,5 @@
-import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
-import { useRuntimeConfig } from 'nitropack/runtime'
+import { createError, defineEventHandler, getQuery, setHeader } from '#nuxt-scripts/h3'
+import { useRuntimeConfig } from '#nuxt-scripts/nitro'
import { createCachedJsonFetch } from './utils/cached-upstream'
import { rewriteTweetImages } from './utils/embed-rewriters'
import { withSigning } from './utils/withSigning'
@@ -104,7 +104,7 @@ export default withSigning(defineEventHandler(async (event) => {
const handlerPath = event.path?.split('?')[0] || ''
const prefix = handlerPath.replace(EMBED_X_SUFFIX_RE, '') || '/_scripts'
const imagePath = `${prefix}/embed/x-image`
- const secret = (useRuntimeConfig(event)['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret
+ const secret = (useRuntimeConfig()['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret
rewriteTweetImages(tweetData, imagePath, secret)
// Cache for 10 minutes
diff --git a/test/e2e/_usercentrics-suite.ts b/test/e2e/_usercentrics-suite.ts
index 7088ea690..1894c0baf 100644
--- a/test/e2e/_usercentrics-suite.ts
+++ b/test/e2e/_usercentrics-suite.ts
@@ -86,7 +86,10 @@ export function defineUsercentricsSuite() {
// integration breaks even though the stubbed behavioural tests above still
// pass. Skipped on offline CI (network failure is tolerated, not asserted).
it('live loader URL still serves a body that wires __ucCmp + UC_CMP_API_READY', async () => {
- const res = await fetch('https://web.cmp.usercentrics.eu/ui/loader.js').catch(() => null)
+ const res = await fetch('https://web.cmp.usercentrics.eu/ui/loader.js').catch((error) => {
+ console.warn('[usercentrics] live-loader contract request failed', error)
+ return null
+ })
if (!res || !res.ok) {
console.warn('[usercentrics] skipping live-loader contract check; fetch failed')
return
diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts
index 25bb3b94a..6d7322f97 100644
--- a/test/e2e/basic.test.ts
+++ b/test/e2e/basic.test.ts
@@ -337,7 +337,10 @@ describe('third-party-capital', () => {
return u.includes('google-analytics.com/g/collect') || u.includes('analytics.google.com/g/collect')
}, {
timeout: 15000,
- }).catch(() => null)
+ }).catch(() => {
+ // A missing network request is an allowed outcome for this optional integration.
+ return null
+ })
await page.getByText('trigger').click()
const result = await request
diff --git a/test/e2e/proxy-alias.test.ts b/test/e2e/proxy-alias.test.ts
index 11e2351b4..33ac6be85 100644
--- a/test/e2e/proxy-alias.test.ts
+++ b/test/e2e/proxy-alias.test.ts
@@ -26,6 +26,11 @@ describe('proxy path aliases', () => {
expect(res.status).toBe(403)
})
+ it('runs Nitro runtime helpers locally', async () => {
+ const result = await $fetch('/api/nitro-runtime')
+ expect(result).toEqual({ app: true, cached: 'ok', config: true })
+ })
+
it('resolves the alias back to the real domain instead of 403ing', async () => {
// `pl` resolves to plausible.io and is proxied upstream. Whatever the upstream
// returns (or a 502 if unreachable), it must not be our allowlist 403.
@@ -33,10 +38,7 @@ describe('proxy path aliases', () => {
method: 'POST',
body: '{}',
headers: { 'content-type': 'application/json' },
- }).catch(() => null)
- // A null here means a transport-level error reaching our own server, which is a
- // genuine test failure; a resolved alias always yields an HTTP response.
- expect(res).not.toBeNull()
- expect(res!.status).not.toBe(403)
+ })
+ expect(res.status).not.toBe(403)
}, 30000)
})
diff --git a/test/fixtures/proxy-alias/nuxt.config.ts b/test/fixtures/proxy-alias/nuxt.config.ts
index 3649889b6..f4f3f4091 100644
--- a/test/fixtures/proxy-alias/nuxt.config.ts
+++ b/test/fixtures/proxy-alias/nuxt.config.ts
@@ -20,6 +20,7 @@ export default defineNuxtConfig({
scripts: {
registry: {
+ instagramEmbed: {},
plausibleAnalytics: { domain: 'example.com' },
},
proxy: {
diff --git a/test/fixtures/proxy-alias/server/api/nitro-runtime.get.ts b/test/fixtures/proxy-alias/server/api/nitro-runtime.get.ts
new file mode 100644
index 000000000..cd1aab5d2
--- /dev/null
+++ b/test/fixtures/proxy-alias/server/api/nitro-runtime.get.ts
@@ -0,0 +1,10 @@
+import { defineEventHandler } from '#nuxt-scripts/h3'
+import { defineCachedFunction, useNitroApp, useRuntimeConfig } from '#nuxt-scripts/nitro'
+
+const getCachedValue = defineCachedFunction(() => 'ok')
+
+export default defineEventHandler(async () => ({
+ app: Boolean(useNitroApp()),
+ cached: await getCachedValue(),
+ config: Boolean(useRuntimeConfig()),
+}))
diff --git a/test/unit/cached-upstream.test.ts b/test/unit/cached-upstream.test.ts
index b69ccebf6..cda7fe26f 100644
--- a/test/unit/cached-upstream.test.ts
+++ b/test/unit/cached-upstream.test.ts
@@ -5,11 +5,12 @@ const { cacheDefinitions, hashMock } = vi.hoisted(() => ({
hashMock: vi.fn((value: unknown) => `hashed:${JSON.stringify(value)}`),
}))
-vi.mock('nitropack/runtime', () => ({
+vi.mock('#nuxt-scripts/nitro', () => ({
defineCachedFunction: vi.fn((handler, options) => {
cacheDefinitions.push(options)
return handler
}),
+ useRuntimeConfig: () => ({}),
}))
vi.mock('ohash', () => ({
diff --git a/test/unit/nitro-compatibility.test.ts b/test/unit/nitro-compatibility.test.ts
new file mode 100644
index 000000000..8ee07e93e
--- /dev/null
+++ b/test/unit/nitro-compatibility.test.ts
@@ -0,0 +1,77 @@
+import type { Nuxt } from '@nuxt/schema'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { setupNitroRuntimeCompatibility } from '../../packages/script/src/nitro-compatibility'
+
+const { addTypeTemplateMock, hookOnceMock } = vi.hoisted(() => ({
+ addTypeTemplateMock: vi.fn(),
+ hookOnceMock: vi.fn(),
+}))
+
+function createNuxt(): Nuxt {
+ return {
+ hooks: {
+ hookOnce: hookOnceMock,
+ },
+ options: {
+ nitro: {},
+ },
+ } as Nuxt
+}
+
+function createDependencies(version: string, resolveNitroImport?: (id: string) => Promise) {
+ return {
+ addTypeTemplate: addTypeTemplateMock,
+ getNuxtVersion: () => version,
+ resolveNitroImport,
+ }
+}
+
+describe('setupNitroRuntimeCompatibility', () => {
+ beforeEach(() => {
+ addTypeTemplateMock.mockReset()
+ hookOnceMock.mockReset()
+ })
+
+ it('registers explicit Nitro 2 runtime modules', async () => {
+ const nuxt = createNuxt()
+
+ await setupNitroRuntimeCompatibility(nuxt, createDependencies('4.5.0'))
+
+ expect(nuxt.options.nitro.alias?.['#nuxt-scripts/h3']).toBe('h3')
+ expect(nuxt.options.nitro.virtual?.['#nuxt-scripts/nitro']).toContain('from \'nitropack/runtime\'')
+ expect(addTypeTemplateMock).toHaveBeenCalledWith(expect.any(Object), { nitro: true, node: true, nuxt: true })
+
+ const template = addTypeTemplateMock.mock.calls[0]![0]
+ await expect(template.getContents()).resolves.toContain('declare module \'#nuxt-scripts/nitro\'')
+ await expect(template.getContents()).resolves.toContain('export * from \'h3\'')
+ })
+
+ it('normalizes resolved Nitro 3 runtime modules without package dependencies', async () => {
+ const nuxt = createNuxt()
+ const resolveNitroImport = vi.fn(async (id: string) => `file:///nuxt-nitro/${id.replace('/', '-')}.mjs`)
+
+ await setupNitroRuntimeCompatibility(nuxt, createDependencies('5.0.0', resolveNitroImport))
+
+ expect(resolveNitroImport).toHaveBeenCalledTimes(4)
+ expect(nuxt.options.nitro.alias?.['#nuxt-scripts/h3']).toBe('file:///nuxt-nitro/nitro-h3.mjs')
+ expect(nuxt.options.nitro.virtual?.['#nuxt-scripts/nitro']).toContain('file:///nuxt-nitro/nitro-app.mjs')
+ expect(nuxt.options.nitro.virtual?.['#nuxt-scripts/nitro']).toContain('file:///nuxt-nitro/nitro-cache.mjs')
+ expect(nuxt.options.nitro.virtual?.['#nuxt-scripts/nitro']).not.toContain('from \'nitro/')
+ expect(nuxt.options.nitro.virtual?.['#nuxt-scripts/nitro']).toContain('useRuntimeConfig(_event)')
+
+ const template = addTypeTemplateMock.mock.calls[0]![0]
+ await expect(template.getContents()).resolves.toContain('export * from \'nitro/h3\'')
+ await expect(template.getContents()).resolves.toContain('useRuntimeConfig(event?:')
+ })
+
+ it('reasserts compatibility after other modules finish setup', async () => {
+ const nuxt = createNuxt()
+ const resolveNitroImport = async (id: string) => `file:///nuxt-nitro/${id.replace('/', '-')}.mjs`
+
+ await setupNitroRuntimeCompatibility(nuxt, createDependencies('5.0.0', resolveNitroImport))
+ nuxt.options.nitro.virtual!['#nuxt-scripts/nitro'] = 'stale'
+ hookOnceMock.mock.calls[0]![1]()
+
+ expect(nuxt.options.nitro.virtual?.['#nuxt-scripts/nitro']).toContain('file:///nuxt-nitro/nitro-app.mjs')
+ })
+})
diff --git a/test/unit/proxy-handler-alias.test.ts b/test/unit/proxy-handler-alias.test.ts
index 4b5be523b..307887bce 100644
--- a/test/unit/proxy-handler-alias.test.ts
+++ b/test/unit/proxy-handler-alias.test.ts
@@ -9,7 +9,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
* validating the allowlist and forwarding upstream.
*/
-vi.mock('nitropack/runtime', () => ({
+vi.mock('#nuxt-scripts/nitro', () => ({
useRuntimeConfig: () => ({
'nuxt-scripts-proxy': {
proxyPrefix: '/_scripts/p',
diff --git a/test/unit/proxy-handler-body.test.ts b/test/unit/proxy-handler-body.test.ts
index f1932946f..65168dba5 100644
--- a/test/unit/proxy-handler-body.test.ts
+++ b/test/unit/proxy-handler-body.test.ts
@@ -5,7 +5,7 @@ import { createApp, defineEventHandler, readRawBody, toNodeListener } from 'h3'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import proxyHandler from '../../packages/script/src/runtime/server/proxy-handler'
-vi.mock('nitropack/runtime', () => ({
+vi.mock('#nuxt-scripts/nitro', () => ({
useRuntimeConfig: () => ({
'nuxt-scripts-proxy': {
proxyPrefix: '/_scripts/p',
diff --git a/test/unit/proxy-handler-hop-by-hop.test.ts b/test/unit/proxy-handler-hop-by-hop.test.ts
index 7ddaf12b1..a8554070b 100644
--- a/test/unit/proxy-handler-hop-by-hop.test.ts
+++ b/test/unit/proxy-handler-hop-by-hop.test.ts
@@ -13,7 +13,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
* Additionally, any header named in the `Connection` header value must also be stripped.
*/
-vi.mock('nitropack/runtime', () => ({
+vi.mock('#nuxt-scripts/nitro', () => ({
useRuntimeConfig: () => ({
'nuxt-scripts-proxy': {
proxyPrefix: '/_scripts/p',
diff --git a/test/unit/with-signing.test.ts b/test/unit/with-signing.test.ts
index 151144d4b..319836ba9 100644
--- a/test/unit/with-signing.test.ts
+++ b/test/unit/with-signing.test.ts
@@ -16,11 +16,11 @@ const { runtimeConfigMock } = vi.hoisted(() => ({
},
}))
-vi.mock('nitropack/runtime', () => ({
+vi.mock('#nuxt-scripts/nitro', () => ({
useRuntimeConfig: () => runtimeConfigMock.current,
}))
-// Import AFTER vi.mock so withSigning resolves against the mocked module.
+// Import after installing the runtime config mock.
const { withSigning } = await import('../../packages/script/src/runtime/server/utils/withSigning')
const SECRET = 'with-signing-test-secret'
diff --git a/vitest.config.ts b/vitest.config.ts
index 35b86e87a..9b08c28fb 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -23,6 +23,8 @@ export default defineConfig({
defineProject({
resolve: {
alias: {
+ '#nuxt-scripts/h3': 'h3',
+ '#nuxt-scripts/nitro': new URL('./test/unit/__mocks__/empty.ts', import.meta.url).pathname,
// Virtual emitted by the Nuxt module at build time; unit tests
// mock it via `vi.mock('#build/nuxt-scripts-snippets')`, but the
// import must first resolve to *something* the bundler accepts.