From 16e09391b94a01dd35e1983145c371243f1b9391 Mon Sep 17 00:00:00 2001
From: Daniel Roe <daniel@roe.dev>
Date: Thu, 6 Mar 2025 09:59:36 +0000
Subject: [PATCH] fix(nuxt): resolve shared externals to absolute paths
 (#31227)

---
 .../src/core/plugins/resolve-deep-imports.ts  | 40 +++++++++++++++++--
 packages/vite/src/server.ts                   | 19 +--------
 test/bundle.test.ts                           |  4 +-
 3 files changed, 39 insertions(+), 24 deletions(-)

diff --git a/packages/nuxt/src/core/plugins/resolve-deep-imports.ts b/packages/nuxt/src/core/plugins/resolve-deep-imports.ts
index 949573559d..2af0062c7c 100644
--- a/packages/nuxt/src/core/plugins/resolve-deep-imports.ts
+++ b/packages/nuxt/src/core/plugins/resolve-deep-imports.ts
@@ -2,9 +2,11 @@ import { parseNodeModulePath } from 'mlly'
 import { resolveModulePath } from 'exsolve'
 import { isAbsolute, normalize, resolve } from 'pathe'
 import type { Plugin } from 'vite'
-import { directoryToURL, resolveAlias } from '@nuxt/kit'
+import { directoryToURL, resolveAlias, tryImportModule } from '@nuxt/kit'
 import type { Nuxt } from '@nuxt/schema'
+import type { Nitro } from 'nitropack'
 
+import type { PackageJson } from 'pkg-types'
 import { pkgDir } from '../../dirs'
 import { logger } from '../../utils'
 
@@ -13,10 +15,12 @@ const VIRTUAL_RE = /^\0?virtual:(?:nuxt:)?/
 export function resolveDeepImportsPlugin (nuxt: Nuxt): Plugin {
   const exclude: string[] = ['virtual:', '\0virtual:', '/__skip_vite', '@vitest/']
   let conditions: string[]
+  let external: Set<string>
+
   return {
     name: 'nuxt:resolve-bare-imports',
     enforce: 'post',
-    configResolved (config) {
+    async configResolved (config) {
       const resolvedConditions = new Set([nuxt.options.dev ? 'development' : 'production', ...config.resolve.conditions])
       if (resolvedConditions.has('browser')) {
         resolvedConditions.add('web')
@@ -29,12 +33,27 @@ export function resolveDeepImportsPlugin (nuxt: Nuxt): Plugin {
         resolvedConditions.add('require')
       }
       conditions = [...resolvedConditions]
+
+      const runtimeDependencies = await tryImportModule<PackageJson>('nitropack/package.json', {
+        url: new URL(import.meta.url),
+      })?.then(r => r?.dependencies ? Object.keys(r.dependencies) : []).catch(() => []) || []
+
+      external = new Set([
+        // explicit dependencies we use in our ssr renderer - these can be inlined (if necessary) in the nitro build
+        'unhead', '@unhead/vue', 'unctx', 'h3', 'devalue', '@nuxt/devalue', 'radix3', 'rou3', 'unstorage', 'hookable',
+        // ensure we only have one version of vue if nitro is going to inline anyway
+        ...((nuxt as any)._nitro as Nitro).options.inlineDynamicImports ? ['vue', '@vue/server-renderer', '@unhead/vue'] : [],
+        // dependencies we might share with nitro - these can be inlined (if necessary) in the nitro build
+        ...runtimeDependencies,
+      ])
     },
     async resolveId (id, importer) {
       if (!importer || isAbsolute(id) || (!isAbsolute(importer) && !VIRTUAL_RE.test(importer)) || exclude.some(e => id.startsWith(e))) {
         return
       }
 
+      const overrides = external.has(id) ? { external: 'absolute' } as const : {}
+
       const normalisedId = resolveAlias(normalize(id), nuxt.options.alias)
       const isNuxtTemplate = importer.startsWith('virtual:nuxt')
       const normalisedImporter = (isNuxtTemplate ? decodeURIComponent(importer) : importer).replace(VIRTUAL_RE, '')
@@ -44,7 +63,10 @@ export function resolveDeepImportsPlugin (nuxt: Nuxt): Plugin {
         if (template?._path) {
           const res = await this.resolve?.(normalisedId, template._path, { skipSelf: true })
           if (res !== undefined && res !== null) {
-            return res
+            return {
+              ...res,
+              ...overrides,
+            }
           }
         }
       }
@@ -53,7 +75,10 @@ export function resolveDeepImportsPlugin (nuxt: Nuxt): Plugin {
 
       const res = await this.resolve?.(normalisedId, dir, { skipSelf: true })
       if (res !== undefined && res !== null) {
-        return res
+        return {
+          ...res,
+          ...overrides,
+        }
       }
 
       const path = resolveModulePath(id, {
@@ -68,6 +93,13 @@ export function resolveDeepImportsPlugin (nuxt: Nuxt): Plugin {
         return null
       }
 
+      if (external.has(id)) {
+        return {
+          id: normalize(path),
+          external: 'absolute',
+        }
+      }
+
       return normalize(path)
     },
   }
diff --git a/packages/vite/src/server.ts b/packages/vite/src/server.ts
index 90c23a129d..76b3fce2a4 100644
--- a/packages/vite/src/server.ts
+++ b/packages/vite/src/server.ts
@@ -2,10 +2,9 @@ import { resolve } from 'pathe'
 import * as vite from 'vite'
 import vuePlugin from '@vitejs/plugin-vue'
 import viteJsxPlugin from '@vitejs/plugin-vue-jsx'
-import { directoryToURL, logger, resolvePath, tryImportModule } from '@nuxt/kit'
+import { logger, resolvePath } from '@nuxt/kit'
 import { joinURL, withTrailingSlash, withoutLeadingSlash } from 'ufo'
 import type { ViteConfig } from '@nuxt/schema'
-import type { PackageJson } from 'pkg-types'
 import defu from 'defu'
 import type { Nitro } from 'nitropack'
 import escapeStringRegexp from 'escape-string-regexp'
@@ -115,22 +114,6 @@ export async function buildServer (ctx: ViteBuildContext) {
     },
   } satisfies vite.InlineConfig, ctx.nuxt.options.vite.$server || {}))
 
-  if (!ctx.nuxt.options.dev) {
-    const runtimeDependencies = await tryImportModule<PackageJson>('nitropack/package.json', {
-      url: ctx.nuxt.options.modulesDir.map(d => directoryToURL(d)),
-    })?.then(r => r?.dependencies ? Object.keys(r.dependencies) : []).catch(() => []) || []
-    if (Array.isArray(serverConfig.ssr!.external)) {
-      serverConfig.ssr!.external.push(
-        // explicit dependencies we use in our ssr renderer - these can be inlined (if necessary) in the nitro build
-        'unhead', '@unhead/vue', 'unctx', 'h3', 'devalue', '@nuxt/devalue', 'radix3', 'rou3', 'unstorage', 'hookable',
-        // ensure we only have one version of vue if nitro is going to inline anyway
-        ...((ctx.nuxt as any)._nitro as Nitro).options.inlineDynamicImports ? ['vue', '@vue/server-renderer', '@unhead/vue'] : [],
-        // dependencies we might share with nitro - these can be inlined (if necessary) in the nitro build
-        ...runtimeDependencies,
-      )
-    }
-  }
-
   // tell rollup's nitro build about the original sources of the generated vite server build
   if (ctx.nuxt.options.sourcemap.server && !ctx.nuxt.options.dev) {
     const { vitePlugin, nitroPlugin } = createSourcemapPreserver()
diff --git a/test/bundle.test.ts b/test/bundle.test.ts
index 5582d2c149..0cd70b986d 100644
--- a/test/bundle.test.ts
+++ b/test/bundle.test.ts
@@ -37,7 +37,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
     const serverDir = join(rootDir, '.output/server')
 
     const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
-    expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"197k"`)
+    expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"209k"`)
 
     const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
     expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1384k"`)
@@ -74,7 +74,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
     const serverDir = join(rootDir, '.output-inline/server')
 
     const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
-    expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"547k"`)
+    expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"559k"`)
 
     const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
     expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"77.8k"`)