next.js/packages/next/src/build/webpack/loaders/next-barrel-loader.ts
next-barrel-loader.ts341 lines10.1 KB
/**
 * ## Barrel Optimizations
 *
 * This loader is used to optimize the imports of "barrel" files that have many
 * re-exports. Currently, both Node.js and Webpack have to enter all of these
 * submodules even if we only need a few of them.
 *
 * For example, say a file `foo.js` with the following contents:
 *
 *   export { a } from './a'
 *   export { b } from './b'
 *   export { c } from './c'
 *   ...
 *
 * If the user imports `a` only, this loader will accept the `names` option to
 * be `['a']`. Then, it request the "__barrel_transform__" SWC transform to load
 * `foo.js` and receive the following output:
 *
 *   export const __next_private_export_map__ = '[["a","./a","a"],["b","./b","b"],["c","./c","c"],...]'
 *
 *   format: '["<imported identifier>", "<import path>", "<exported name>"]'
 *   e.g.: import { a as b } from './module-a' => '["b", "./module-a", "a"]'
 *
 * The export map, generated by SWC, is a JSON that represents the exports of
 * that module, their original file, and their original name (since you can do
 * `export { a as b }`).
 *
 * Then, this loader can safely remove all the exports that are not needed and
 * re-export the ones from `names`:
 *
 *   export { a } from './a'
 *
 * That's the basic situation and also the happy path.
 *
 *
 *
 * ## Wildcard Exports
 *
 * For wildcard exports (e.g. `export * from './a'`), it becomes a bit more complicated.
 * Say `foo.js` with the following contents:
 *
 *   export * from './a'
 *   export * from './b'
 *   export * from './c'
 *   ...
 *
 * If the user imports `bar` from it, SWC can never know which files are going to be
 * exporting `bar`. So, we have to keep all the wildcard exports and do the same
 * process recursively. This loader will return the following output:
 *
 *   export * from '__barrel_optimize__?names=bar&wildcard!=!./a'
 *   export * from '__barrel_optimize__?names=bar&wildcard!=!./b'
 *   export * from '__barrel_optimize__?names=bar&wildcard!=!./c'
 *   ...
 *
 * The "!=!" tells Webpack to use the same loader to process './a', './b', and './c'.
 * After the recursive process, the "inner loaders" will either return an empty string
 * or:
 *
 *   export * from './target'
 *
 * Where `target` is the file that exports `bar`.
 *
 *
 *
 * ## Non-Barrel Files
 *
 * If the file is not a barrel, we can't apply any optimizations. That's because
 * we can't easily remove things from the file. For example, say `foo.js` with:
 *
 *   const v = 1
 *   export function b () {
 *     return v
 *   }
 *
 * If the user imports `b` only, we can't remove the `const v = 1` even though
 * the file is side-effect free. In these caes, this loader will simply re-export
 * `foo.js`:
 *
 *   export * from './foo'
 *
 * Besides these cases, this loader also carefully handles the module cache so
 * SWC won't analyze the same file twice, and no instance of the same file will
 * be accidentally created as different instances.
 */

import type webpack from 'webpack'

import path from 'path'
import { transform } from '../../swc'
import { installBindings } from '../../swc/install-bindings'

// This is a in-memory cache for the mapping of barrel exports. This only applies
// to the packages that we optimize. It will never change (e.g. upgrading packages)
// during the lifetime of the server so we can safely cache it.
// There is also no need to collect the cache for the same reason.
const barrelTransformMappingCache = new Map<
  string,
  {
    exportList: [string, string, string][]
    wildcardExports: string[]
    isClientEntry: boolean
  } | null
>()

async function getBarrelMapping(
  resourcePath: string,
  swcCacheDir: string,
  resolve: (context: string, request: string) => Promise<string>,
  fs: {
    readFile: (
      path: string,
      callback: (err: any, data: string | Buffer | undefined) => void
    ) => void
  }
) {
  if (barrelTransformMappingCache.has(resourcePath)) {
    return barrelTransformMappingCache.get(resourcePath)!
  }

  // This is a SWC transform specifically for `optimizeBarrelExports`. We don't
  // care about other things but the export map only.
  async function transpileSource(
    filename: string,
    source: string,
    isWildcard: boolean
  ) {
    const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx')
    return new Promise<string>((res) =>
      transform(source, {
        filename,
        inputSourceMap: undefined,
        sourceFileName: filename,
        optimizeBarrelExports: {
          wildcard: isWildcard,
        },
        jsc: {
          parser: {
            syntax: isTypeScript ? 'typescript' : 'ecmascript',
            [isTypeScript ? 'tsx' : 'jsx']: true,
          },
          experimental: {
            cacheRoot: swcCacheDir,
          },
        },
      }).then((output) => {
        res(output.code)
      })
    )
  }

  // Avoid circular `export *` dependencies
  const visited = new Set<string>()
  async function getMatches(
    file: string,
    isWildcard: boolean,
    isClientEntry: boolean
  ) {
    if (visited.has(file)) {
      return null
    }
    visited.add(file)

    const source = await new Promise<string>((res, rej) => {
      fs.readFile(file, (err, data) => {
        if (err || data === undefined) {
          rej(err)
        } else {
          res(data.toString())
        }
      })
    })

    const output = await transpileSource(file, source, isWildcard)

    const matches = output.match(
      /^([^]*)export (const|var) __next_private_export_map__ = ('[^']+'|"[^"]+")/
    )
    if (!matches) {
      return null
    }

    const matchedDirectives = output.match(
      /^([^]*)export (const|var) __next_private_directive_list__ = '([^']+)'/
    )
    const directiveList = matchedDirectives
      ? JSON.parse(matchedDirectives[3])
      : []
    // "use client" in barrel files has to be transferred to the target file.
    isClientEntry = directiveList.includes('use client')

    let exportList = JSON.parse(matches[3].slice(1, -1)) as [
      string,
      string,
      string,
    ][]
    const wildcardExports = [
      ...output.matchAll(/export \* from "([^"]+)"/g),
    ].map((match) => match[1])

    // In the wildcard case, if the value is exported from another file, we
    // redirect to that file (decl[0]). Otherwise, export from the current
    // file itself.
    if (isWildcard) {
      for (const decl of exportList) {
        decl[1] = file
        decl[2] = decl[0]
      }
    }

    // This recursively handles the wildcard exports (e.g. `export * from './a'`)
    if (wildcardExports.length) {
      await Promise.all(
        wildcardExports.map(async (req) => {
          const targetPath = await resolve(
            path.dirname(file),
            req.replace('__barrel_optimize__?names=__PLACEHOLDER__!=!', '')
          )

          const targetMatches = await getMatches(
            targetPath,
            true,
            isClientEntry
          )

          if (targetMatches) {
            // Merge the export list
            exportList = exportList.concat(targetMatches.exportList)
          }
        })
      )
    }

    return {
      exportList,
      wildcardExports,
      isClientEntry,
    }
  }

  const res = await getMatches(resourcePath, false, false)
  barrelTransformMappingCache.set(resourcePath, res)

  return res
}

const NextBarrelLoader = async function (
  this: webpack.LoaderContext<{
    names: string[]
    swcCacheDir: string
  }>
) {
  this.async()
  this.cacheable(true)
  // Install bindings early so they are definitely available.
  // When run by webpack in next this is already done with correct configuration so this is a no-op.
  // In turbopack loaders are run in a subprocess so it may or may not be done.
  await installBindings()

  const { names, swcCacheDir } = this.getOptions()

  // For barrel optimizations, we always prefer the "module" field over the
  // "main" field because ESM handling is more robust with better tree-shaking.
  const resolve = this.getResolve({
    mainFields: ['module', 'main'],
  })

  const mapping = await getBarrelMapping(
    this.resourcePath,
    swcCacheDir,
    resolve,
    this.fs
  )

  // `resolve` adds all sub-paths to the dependency graph. However, we already
  // cached the mapping and we assume them to not change. So, we can safely
  // clear the dependencies here to avoid unnecessary watchers which turned out
  // to be very expensive.
  this.clearDependencies()

  if (!mapping) {
    // This file isn't a barrel and we can't apply any optimizations. Let's re-export everything.
    // Since this loader accepts `names` and the request is keyed with `names`, we can't simply
    // return the original source here. That will create these imports with different names as
    // different modules instances.
    this.callback(null, `export * from ${JSON.stringify(this.resourcePath)}`)
    return
  }

  const exportList = mapping.exportList
  const isClientEntry = mapping.isClientEntry
  const exportMap = new Map<string, [string, string]>()
  for (const [name, filePath, orig] of exportList) {
    exportMap.set(name, [filePath, orig])
  }

  let output = ''
  let missedNames: string[] = []
  for (const name of names) {
    // If the name matches
    if (exportMap.has(name)) {
      const decl = exportMap.get(name)!

      if (decl[1] === '*') {
        output += `\nexport * as ${name} from ${JSON.stringify(decl[0])}`
      } else if (decl[1] === 'default') {
        output += `\nexport { default as ${name} } from ${JSON.stringify(
          decl[0]
        )}`
      } else if (decl[1] === name) {
        output += `\nexport { ${name} } from ${JSON.stringify(decl[0])}`
      } else {
        output += `\nexport { ${decl[1]} as ${name} } from ${JSON.stringify(
          decl[0]
        )}`
      }
    } else {
      missedNames.push(name)
    }
  }

  // These are from wildcard exports.
  if (missedNames.length > 0) {
    for (const req of mapping.wildcardExports) {
      output += `\nexport * from ${JSON.stringify(
        req.replace('__PLACEHOLDER__', missedNames.join(',') + '&wildcard')
      )}`
    }
  }

  // When it has `"use client"` inherited from its barrel files, we need to
  // prefix it to this target file as well.
  if (isClientEntry) {
    output = `"use client";\n${output}`
  }

  this.callback(null, output)
}

export default NextBarrelLoader
Quest for Codev2.0.0
/
SIGN IN