diff --git a/.changeset/nuxt-nitro2-adapter-floor.md b/.changeset/nuxt-nitro2-adapter-floor.md new file mode 100644 index 0000000000..0b3090c88f --- /dev/null +++ b/.changeset/nuxt-nitro2-adapter-floor.md @@ -0,0 +1,7 @@ +--- +'@posthog/nuxt': patch +--- + +fix(nuxt): restore the declared Nuxt >= 3.7 floor with a legacy Nitro 2 adapter + +The Nitro 2 adapter imports `defineNitroPlugin` and `useRuntimeConfig` from the bare `nitropack/runtime` subpath, which only exists in nitropack >= 2.9.5 — so on Nuxt 3.7–3.11 (which can resolve older nitropack) builds emitted unresolved-import warnings and the packed server crashed at startup with `ERR_PACKAGE_PATH_NOT_EXPORTED`. The module now probes the export map of the nitropack copy Nuxt resolves and selects the adapter from it at build time: nitropack with the bare `./runtime` export keeps the existing explicit-import adapter, and older nitropack gets a legacy adapter using the `#imports` virtual module — the same mechanism this module shipped with before the adapter split. Verified against Nuxt 3.7.0 + nitropack 2.6.2 and Nuxt 4.5 (including with `nitro: { imports: false }`); the Nitro 3 (Nuxt 5) adapter is unchanged. diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index adabd5cbb7..f4b2412fb4 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -4,7 +4,8 @@ import type { PostHogOptions } from 'posthog-node' import type {} from 'nuxt/app' import { resolveBinaryPath, spawnLocal } from '@posthog/plugin-utils' import { fileURLToPath } from 'node:url' -import { dirname } from 'node:path' +import { dirname, join } from 'node:path' +import { createRequire } from 'node:module' const filename = fileURLToPath(import.meta.url) const resolvedDirname = dirname(filename) @@ -23,6 +24,23 @@ function normalizeHost(value?: unknown): string { return normalizedValue || DEFAULT_NUXT_HOST } +// The v2 nitro adapter imports the bare 'nitropack/runtime' subpath, which only exists +// in nitropack >= 2.9.5 (Nuxt < 3.11.2 can resolve older). Probe the export map of the +// nitropack copy Nuxt itself resolves instead of keying off Nuxt versions, so lockfiles +// that float or pin nitropack still get the right adapter. Returns null when no manifest +// is resolvable — e.g. Nuxt >= 4.5 depends on nitropack via @nuxt/nitro-server, so under +// isolated installs it is not reachable from the nuxt package; the caller falls back to +// a Nuxt-version gate there. +function nitropackHasBareRuntimeExport(appDir: string): boolean | null { + try { + const requireFromNuxt = createRequire(join(appDir, 'index.mjs')) + const manifest = requireFromNuxt('nitropack/package.json') + return Boolean(manifest.exports?.['./runtime']) + } catch { + return null + } +} + type LogLevel = 'debug' | 'info' | 'warn' | 'error' interface SourcemapsConfig { @@ -91,7 +109,15 @@ export default defineNuxtModule({ const normalizedPublicKey = normalizeApiKey(options.publicKey) const normalizedHost = normalizeHost(options.host) addPlugin({ src: resolver.resolve('./runtime/vue-plugin'), mode: 'client' }) - const nitroPlugin = Number.parseInt(getNuxtVersion(nuxt), 10) >= 5 ? 'nitro-plugin-v3' : 'nitro-plugin-v2' + const [nuxtMajor = 0, nuxtMinor = 0, nuxtPatch = 0] = getNuxtVersion(nuxt) + .split('.') + .map(part => Number.parseInt(part, 10)) + // When the export-map probe is inconclusive, gate on the Nuxt version instead: + // Nuxt >= 3.11.2 is the first release whose nitropack range guarantees >= 2.9.6. + const modernNuxt = nuxtMajor > 3 || (nuxtMajor === 3 && (nuxtMinor > 11 || (nuxtMinor === 11 && nuxtPatch >= 2))) + const hasBareRuntimeExport = nitropackHasBareRuntimeExport(nuxt.options.appDir) ?? modernNuxt + const nitroPlugin = + nuxtMajor >= 5 ? 'nitro-plugin-v3' : hasBareRuntimeExport ? 'nitro-plugin-v2' : 'nitro-plugin-v2-legacy' addServerPlugin(resolver.resolve(`./runtime/${nitroPlugin}`)) addImportsDir(resolver.resolve('./runtime/composables')) diff --git a/packages/nuxt/src/runtime/nitro-plugin-v2-legacy.ts b/packages/nuxt/src/runtime/nitro-plugin-v2-legacy.ts new file mode 100644 index 0000000000..f379a7da9f --- /dev/null +++ b/packages/nuxt/src/runtime/nitro-plugin-v2-legacy.ts @@ -0,0 +1,25 @@ +// Nitro 2 adapter for installs whose nitropack lacks the bare 'nitropack/runtime' subpath +// (nitropack < 2.9.5, possible on Nuxt < 3.11.2), where the import used by nitro-plugin-v2 +// does not resolve (unresolved at build time, ERR_PACKAGE_PATH_NOT_EXPORTED at server +// startup). Deep subpaths like +// 'nitropack/runtime/config' resolve but old Nitro externalizes them, so they crash at +// runtime on nitro's build-time-only '#internal/nitro/virtual/*' specifiers. The +// `#imports` virtual module is the one mechanism that works on every old Nitro 2 version +// (it is what this module shipped with before the adapter split), and defineNitroPlugin +// is an identity function, so a typed plain export is equivalent. +import { useRuntimeConfig } from '#imports' +import type { NitroAppPlugin } from 'nitropack' +import { setupPostHogNitroPlugin } from './nitro-plugin' + +const posthogNitroPlugin: NitroAppPlugin = (nitroApp) => { + setupPostHogNitroPlugin({ + useRuntimeConfig, + onError: handler => + nitroApp.hooks.hook('error', (error, { event }) => + handler(error, event ? { path: event.path, method: event.method } : undefined), + ), + onClose: handler => nitroApp.hooks.hook('close', handler), + }) +} + +export default posthogNitroPlugin diff --git a/packages/nuxt/tests/nitro-plugin.test.mjs b/packages/nuxt/tests/nitro-plugin.test.mjs index 9785ccc4dd..a071b4e0b6 100644 --- a/packages/nuxt/tests/nitro-plugin.test.mjs +++ b/packages/nuxt/tests/nitro-plugin.test.mjs @@ -97,7 +97,9 @@ function loadAdapter(filename, defineName) { const adapterSource = readFileSync(new URL(`../src/runtime/${filename}`, import.meta.url), 'utf8') const executableAdapter = adapterSource .replace(/^import .*$/gm, '') + .replace(': NitroAppPlugin', '') .replace(`export default ${defineName}(`, `return ${defineName}(`) + .replace(/^export default (\w+)$/m, 'return $1') let bindings const plugin = new Function(defineName, 'useRuntimeConfig', 'setupPostHogNitroPlugin', executableAdapter)( value => value, @@ -129,6 +131,25 @@ nitro2.bindings.onError((_error, request) => { assert.equal(nitro2.adapterHandlers.error(error, { event: { path: '/v2', method: 'GET' } }), nitro2Promise) assert.deepEqual(nitro2Request, { path: '/v2', method: 'GET' }) +// The legacy adapter serves Nuxt < 3.12, where nitropack can resolve below 2.9.5 and the +// bare 'nitropack/runtime' subpath does not exist (ERR_PACKAGE_PATH_NOT_EXPORTED at server +// startup); it must stay on the `#imports` virtual module, which every Nitro 2 provides. +const nitro2Legacy = loadAdapter('nitro-plugin-v2-legacy.ts', 'defineNitroPlugin') +assert.doesNotMatch(nitro2Legacy.adapterSource, /from 'nitropack\/runtime/) +assert.match(nitro2Legacy.adapterSource, /from '#imports'/) +let legacyRequest +const legacyPromise = Promise.resolve() +nitro2Legacy.bindings.onError((_error, request) => { + legacyRequest = request + return legacyPromise +}) +assert.equal(nitro2Legacy.adapterHandlers.error(error, { event: { path: '/legacy', method: 'GET' } }), legacyPromise) +assert.deepEqual(legacyRequest, { path: '/legacy', method: 'GET' }) + +// module.ts must route old Nuxt to the legacy adapter. +const moduleSource = readFileSync(new URL('../src/module.ts', import.meta.url), 'utf8') +assert.match(moduleSource, /nitro-plugin-v2-legacy/) + const nitro3 = loadAdapter('nitro-plugin-v3.ts', 'definePlugin') assert.match(nitro3.adapterSource, /from 'nitro'/) assert.match(nitro3.adapterSource, /from 'nitro\/runtime-config'/) diff --git a/packages/nuxt/tests/sourcemaps-ssr.test.mjs b/packages/nuxt/tests/sourcemaps-ssr.test.mjs index d6e1202876..2e35df2c9c 100644 --- a/packages/nuxt/tests/sourcemaps-ssr.test.mjs +++ b/packages/nuxt/tests/sourcemaps-ssr.test.mjs @@ -33,8 +33,10 @@ const executableSource = source .replace(/value\?: unknown/g, 'value') .replace(/\(directory: string, sourcemapsConfig: SourcemapsConfig\)/g, '(directory, sourcemapsConfig)') .replace(/\(args: string\[\]\)/g, '(args)') + .replace(/\(appDir: string\)/g, '(appDir)') .replace(/\): string \{/g, ') {') .replace(/\): boolean \{/g, ') {') + .replace(/\): boolean \| null \{/g, ') {') .replace(/let (outputDir|publicDir|serverDir): string \| undefined/g, 'let $1') .replace(/const processOptions: string\[\] = /g, 'const processOptions = ') // `import.meta.url` is not available inside `new Function`; the value is @@ -43,7 +45,7 @@ const executableSource = source // Turn the module's `export default` into a value the wrapper returns. .replace('export default defineNuxtModule(', 'return defineNuxtModule(') -function loadModule({ failPublicUpload = false, nuxtVersion = '4.1.2' } = {}) { +function loadModule({ failPublicUpload = false, nuxtVersion = '4.1.2', nitropackExports = { './runtime': {} } } = {}) { const spawnCalls = [] const pluginCalls = [] const serverPluginCalls = [] @@ -54,6 +56,11 @@ function loadModule({ failPublicUpload = false, nuxtVersion = '4.1.2' } = {}) { addImportsDir: () => {}, createResolver: () => ({ resolve: p => p }), getNuxtVersion: () => nuxtVersion, + createRequire: () => () => { + if (nitropackExports === null) throw new Error('nitropack not resolvable') + return { exports: nitropackExports } + }, + join: (...parts) => parts.join('/'), resolveBinaryPath: () => '/fake/posthog-cli', spawnLocal: async (bin, args) => { spawnCalls.push({ bin, args: [...args] }) @@ -71,13 +78,14 @@ function loadModule({ failPublicUpload = false, nuxtVersion = '4.1.2' } = {}) { return { mod, spawnCalls, pluginCalls, serverPluginCalls } } -async function runRegistration({ nuxtVersion, compatibilityVersion }) { - const { mod, pluginCalls, serverPluginCalls } = loadModule({ nuxtVersion }) +async function runRegistration({ nuxtVersion, compatibilityVersion, nitropackExports }) { + const { mod, pluginCalls, serverPluginCalls } = loadModule({ nuxtVersion, nitropackExports }) const nuxt = { options: { dev: true, future: { compatibilityVersion }, runtimeConfig: { public: {} }, + appDir: '/fake/nuxt/app', }, hook() {}, } @@ -95,14 +103,31 @@ async function runRegistration({ nuxtVersion, compatibilityVersion }) { return { pluginCalls, serverPluginCalls } } -for (const { nuxtVersion, compatibilityVersion, expectedServerPlugin } of [ - { nuxtVersion: '3.7.0', expectedServerPlugin: './runtime/nitro-plugin-v2' }, - { nuxtVersion: '4.1.2', expectedServerPlugin: './runtime/nitro-plugin-v2' }, - { nuxtVersion: '4.1.2', compatibilityVersion: 5, expectedServerPlugin: './runtime/nitro-plugin-v2' }, - { nuxtVersion: '5.0.0-0', expectedServerPlugin: './runtime/nitro-plugin-v3' }, - { nuxtVersion: '5.0.0-29762631.396a4ae3', expectedServerPlugin: './runtime/nitro-plugin-v3' }, +const NITROPACK_OLD_EXPORTS = { './runtime/*': {} } // nitropack < 2.9.5: no bare './runtime' +const NITROPACK_NEW_EXPORTS = { './runtime': {}, './runtime/*': {} } + +for (const { nuxtVersion, compatibilityVersion, nitropackExports, expectedServerPlugin } of [ + // Adapter choice follows the resolved nitropack's export map, not the Nuxt version: + // nitropack < 2.9.5 lacks the bare 'nitropack/runtime' subpath the v2 adapter imports + // and must get the legacy adapter, while old Nuxt whose lockfile floated to a newer + // nitropack keeps the explicit v2 adapter (it works with `nitro: { imports: false }`). + { nuxtVersion: '3.7.0', nitropackExports: NITROPACK_OLD_EXPORTS, expectedServerPlugin: './runtime/nitro-plugin-v2-legacy' }, + { nuxtVersion: '3.7.0', nitropackExports: NITROPACK_NEW_EXPORTS, expectedServerPlugin: './runtime/nitro-plugin-v2' }, + { nuxtVersion: '3.11.1', nitropackExports: NITROPACK_OLD_EXPORTS, expectedServerPlugin: './runtime/nitro-plugin-v2-legacy' }, + { nuxtVersion: '3.11.1', nitropackExports: NITROPACK_NEW_EXPORTS, expectedServerPlugin: './runtime/nitro-plugin-v2' }, + // Unresolvable nitropack manifest (e.g. Nuxt >= 4.5 isolated installs, where nitropack + // lives under @nuxt/nitro-server) falls back to the Nuxt-version gate: >= 3.11.2 is the + // first release whose nitropack range guarantees the bare './runtime' export. + { nuxtVersion: '3.7.0', nitropackExports: null, expectedServerPlugin: './runtime/nitro-plugin-v2-legacy' }, + { nuxtVersion: '3.11.1', nitropackExports: null, expectedServerPlugin: './runtime/nitro-plugin-v2-legacy' }, + { nuxtVersion: '3.11.2', nitropackExports: null, expectedServerPlugin: './runtime/nitro-plugin-v2' }, + { nuxtVersion: '4.5.1', nitropackExports: null, expectedServerPlugin: './runtime/nitro-plugin-v2' }, + { nuxtVersion: '4.1.2', nitropackExports: NITROPACK_NEW_EXPORTS, expectedServerPlugin: './runtime/nitro-plugin-v2' }, + { nuxtVersion: '4.1.2', compatibilityVersion: 5, nitropackExports: NITROPACK_NEW_EXPORTS, expectedServerPlugin: './runtime/nitro-plugin-v2' }, + { nuxtVersion: '5.0.0-0', nitropackExports: null, expectedServerPlugin: './runtime/nitro-plugin-v3' }, + { nuxtVersion: '5.0.0-29762631.396a4ae3', nitropackExports: null, expectedServerPlugin: './runtime/nitro-plugin-v3' }, ]) { - const { pluginCalls, serverPluginCalls } = await runRegistration({ nuxtVersion, compatibilityVersion }) + const { pluginCalls, serverPluginCalls } = await runRegistration({ nuxtVersion, compatibilityVersion, nitropackExports }) assert.deepEqual(pluginCalls, [{ src: './runtime/vue-plugin', mode: 'client' }]) assert.deepEqual(serverPluginCalls, [expectedServerPlugin]) }