Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions .changeset/nuxt-nitro2-adapter-floor.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 28 additions & 2 deletions packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -91,7 +109,15 @@ export default defineNuxtModule<ModuleOptions>({
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'))

Expand Down
25 changes: 25 additions & 0 deletions packages/nuxt/src/runtime/nitro-plugin-v2-legacy.ts
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions packages/nuxt/tests/nitro-plugin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'/)
Expand Down
45 changes: 35 additions & 10 deletions packages/nuxt/tests/sourcemaps-ssr.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = []
Expand All @@ -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] })
Expand All @@ -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() {},
}
Expand All @@ -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])
}
Expand Down