Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support a vite entry point (export condition / main field) #15643

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ export default defineConfig({
text: 'Server-Side Rendering (SSR)',
link: '/guide/ssr',
},
{
text: 'Monorepo packages',
link: '/guide/monorepo-packages',
},
{
text: 'Backend Integration',
link: '/guide/backend-integration',
Expand Down
115 changes: 115 additions & 0 deletions docs/guide/monorepo-packages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Monorepo packages

Vite has first-class support for bundling local packages in a monorepo without the need for a build step. We currently support 2 methods for bundling (A and B below), each with their own tradeoffs, so use the method that works best for you!

## A) Add `vite` entry points (experimental)

This method is based entirely around `package.json` entry points. For local packages using `exports`, add a `vite` condition that maps to a source file (condition must be listed first). For other packages, add a `vite` entry point similar to `main`.

### Requirements

- Requires entry points within the package's `package.json`.
- Requires package (npm, pnpm, yarn) workspaces.
- Requires the package to be symlinked in `node_modules`.

Because this functionality is experimental, it must be enabled in your config.

```js
import { defineConfig } from 'vite'

export default defineConfig({
experimental: {
vitePackageEntryPoints: true,
},
})
```

### Implementation

For each local package that you want to bundle source files, add `vite` entry points (1 or 2, not both).

```json
// 1) Using exports (supports deep imports)
{
"exports": {
"." {
"vite": "./src/index.ts",
"module": "./lib/index.js"
},
"./*": {
"vite": ["./src/*.ts", "./src/*.tsx"],
"module": "./lib/*.js"
}
}
}

// 2) Not using exports (default import only)
{
"vite": "src/index.ts",
"main": "lib/index.js"
}
```

:::info
For packages that are depended on (not symlinked in `node_modules`), the `vite` entry points are ignored, and are safe to publish.
:::

### Caveats

- If using `vite` export condition:
- To support deep imports, the `./*` entry point must be defined, which maps 1:1 to the file system.
- To support multiple source files with different extensions, use an array as the `vite` condition value (example above).
- If using `vite` main entry point:
- Only default/index imports are supported.
- Deep imports are not supported.
- Packages that are not symlinked into `node_modules` will _not_ use the `vite` entry points. For reference, `workspace:`, `portal:`, and `link:` are supported, while `file:`, `git:`, etc are not.

## B) Define resolve aliases

The tried-and-true method for bundling local packages is to define the [`resolve.alias`](/config/shared-options.html#resolve-alias) setting, and map each package name to their source folder.

### Requirements

- Ignores `main` and `exports` within the package's `package.json`.
- Does not require package workspaces.
- Does not require the package to exist in `node_modules`.

### Implementation

```js
import path from 'node:path'
import { defineConfig } from 'vite'

export default defineConfig({
resolve: {
alias: {
'@brand/components': path.join(__dirname, '../packages/components'),
'@brand/utils': path.join(__dirname, '../packages/utils'),
},
},
})
```

Once aliases have been defined, you can import from them as if they were installed from npm. Both default and deep imports are supported!

```js
import Button from '@brand/components/Button.vue'
import { camelCase } from '@brand/utils/string'
```

## TypeScript path aliases

Regardless of the method you choose above, you'll most likely need to define `tsconfig.json` paths for TypeScript to resolve type information from the local package source files.

```json
{
"compilerOptions": {
"paths": {
// Default import only
"@brand/components": ["../packages/components/src/index.ts"],
// With deep imports
"@brand/components/*": ["../packages/components/src/*"]
}
}
}
```
10 changes: 9 additions & 1 deletion packages/vite/src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,14 @@ export interface ExperimentalOptions {
* @default false
*/
skipSsrTransform?: boolean
/**
* Resolves source files of local packages (in a monorepo workspace) instead of using
* pre-built files.
*
* @experimental
* @default false
*/
resolveLocalPackageSources?: boolean
}

export interface LegacyOptions {
Expand Down Expand Up @@ -1258,7 +1266,7 @@ function optimizeDepsDisabledBackwardCompatibility(
}
resolved.logger.warn(
colors.yellow(`(!) Experimental ${optimizeDepsPath}optimizeDeps.disabled and deps pre-bundling during build were removed in Vite 5.1.
To disable the deps optimizer, set ${optimizeDepsPath}optimizeDeps.noDiscovery to true and ${optimizeDepsPath}optimizeDeps.include as undefined or empty.
To disable the deps optimizer, set ${optimizeDepsPath}optimizeDeps.noDiscovery to true and ${optimizeDepsPath}optimizeDeps.include as undefined or empty.
Please remove ${optimizeDepsPath}optimizeDeps.disabled from your config.
${
commonjsPluginDisabled
Expand Down
37 changes: 37 additions & 0 deletions packages/vite/src/node/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export type PackageCache = Map<string, PackageData>

export interface PackageData {
dir: string
srcDir: string | null
hasSideEffects: (id: string) => boolean | 'no-treeshake' | null
inWorkspace: boolean
webResolvedImports: Record<string, string | undefined>
nodeResolvedImports: Record<string, string | undefined>
setResolvedCache: (key: string, entry: string, targetWeb: boolean) => void
Expand Down Expand Up @@ -194,10 +196,30 @@ export function loadPackageData(pkgPath: string): PackageData {
hasSideEffects = () => null
}

// Determine if the package is locally within a monorepo workspace
const inWorkspace = isPackageInWorkspace(pkgPath)
let srcDir = null

// When in a workspace, attempt to find a source directory.
// We do this check here, so that it only runs once per package,
// and not for every file in the package!
if (inWorkspace) {
for (const srcName of ['src', 'sources']) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think Vite should be hardcoding these directories. The users should be free to arrange the output however they want.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. I originally started adding this behind a config setting, similar to file extensions, but pushed it off to get the POC working.

const srcPath = path.join(pkgDir, srcName)

if (fs.existsSync(srcPath)) {
srcDir = srcPath
break
}
}
}

const pkg: PackageData = {
dir: pkgDir,
data,
hasSideEffects,
inWorkspace,
srcDir,
webResolvedImports: {},
nodeResolvedImports: {},
setResolvedCache(key: string, entry: string, targetWeb: boolean) {
Expand All @@ -219,6 +241,21 @@ export function loadPackageData(pkgPath: string): PackageData {
return pkg
}

// Determine if the package is within a monorepo workspace.
// We can do this by resolving the symlink, comparing the resolved path,
// and ensuring the resolved path is not within node_modules.
function isPackageInWorkspace(basePath: string): boolean {
if (!basePath.includes('node_modules')) {
return true
}

// For soft links (workspace:, link:), this will return the source path
// For hard links (file:, node modules), this will return self
const realPath = fs.realpathSync(basePath)

return realPath !== basePath && !realPath.includes('node_modules')
}

export function watchPackageDataPlugin(packageCache: PackageCache): Plugin {
// a list of files to watch before the plugin is ready
const watchQueue = new Set<string>()
Expand Down
2 changes: 2 additions & 0 deletions packages/vite/src/node/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export async function resolvePlugins(
isBuild && config.build.ssr
? (id, importer) => shouldExternalizeForSSR(id, importer, config)
: undefined,
resolveLocalPackageSources:
config.experimental.resolveLocalPackageSources,
}),
htmlInlineProxyPlugin(config),
cssPlugin(config),
Expand Down
90 changes: 71 additions & 19 deletions packages/vite/src/node/plugins/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ export interface InternalResolveOptions extends Required<ResolveOptions> {
* @internal
*/
idOnly?: boolean

// Maps to the experiment of the same name
resolveLocalPackageSources?: boolean
}

export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin {
Expand Down Expand Up @@ -479,7 +482,7 @@ function resolveSubpathImports(
if (!pkgData) return

let importsPath = resolveExportsOrImports(
pkgData.data,
pkgData,
id,
options,
targetWeb,
Expand Down Expand Up @@ -761,13 +764,27 @@ export function tryNodeResolve(
const unresolvedId = deepMatch ? '.' + id.slice(pkgId.length) : id

let resolved: string | undefined
try {
resolved = resolveId(unresolvedId, pkg, targetWeb, options)
} catch (err) {
if (!options.tryEsmOnly) {
throw err

// If a local package, attempt to resolve the original source file,
// and avoid entry points or pre-built files
if (pkg.inWorkspace && options.resolveLocalPackageSources) {
try {
resolved = resolveSourceFile(unresolvedId, pkg, targetWeb, options)
} catch {
// If nothing found, resolve with other methods
}
}

if (!resolved) {
try {
resolved = resolveId(unresolvedId, pkg, targetWeb, options)
} catch (err) {
if (!options.tryEsmOnly) {
throw err
}
}
}

if (!resolved && options.tryEsmOnly) {
resolved = resolveId(unresolvedId, pkg, targetWeb, {
...options,
Expand Down Expand Up @@ -959,11 +976,12 @@ export async function tryOptimizedResolve(

export function resolvePackageEntry(
id: string,
{ dir, data, setResolvedCache, getResolvedCache }: PackageData,
pkg: PackageData,
targetWeb: boolean,
options: InternalResolveOptions,
): string | undefined {
const { file: idWithoutPostfix, postfix } = splitFileAndPostfix(id)
const { dir, data, setResolvedCache, getResolvedCache } = pkg

const cached = getResolvedCache('.', targetWeb)
if (cached) {
Expand All @@ -977,7 +995,7 @@ export function resolvePackageEntry(
// using https://github.com/lukeed/resolve.exports
if (data.exports) {
entryPoint = resolveExportsOrImports(
data,
pkg,
'.',
options,
targetWeb,
Expand Down Expand Up @@ -1054,6 +1072,27 @@ export function resolvePackageEntry(
packageEntryFailure(id)
}

export function resolveSourceFile(
id: string,
pkg: PackageData,
targetWeb: boolean,
options: InternalResolveOptions,
): string | undefined {
const srcDir = pkg.srcDir ?? pkg.dir

return tryFsResolve(
id.startsWith('.')
? // File relative from package source directory
path.join(srcDir, id)
: // Is the package name, so return an index entry point
srcDir,
options,
true,
targetWeb,
true,
)
}

function packageEntryFailure(id: string, details?: string) {
const err: any = new Error(
`Failed to resolve entry for package "${id}". ` +
Expand All @@ -1065,7 +1104,7 @@ function packageEntryFailure(id: string, details?: string) {
}

function resolveExportsOrImports(
pkg: PackageData['data'],
pkg: PackageData,
key: string,
options: InternalResolveOptionsWithOverrideConditions,
targetWeb: boolean,
Expand All @@ -1091,27 +1130,40 @@ function resolveExportsOrImports(
})

const fn = type === 'imports' ? imports : exports
const result = fn(pkg, key, {
const result = fn(pkg.data, key, {
browser: targetWeb && !additionalConditions.has('node'),
require: options.isRequire && !additionalConditions.has('import'),
conditions,
})

return result ? result[0] : undefined
if (!result) {
return undefined
}

// We need to support array conditions like `["./*.js", "./*.mjs"]`
// So loop through each result and find one that actually exists
if (result.length > 1) {
for (const entry of result) {
if (fs.existsSync(path.join(pkg.dir, entry))) {
return entry
}
}
}
Comment on lines +1143 to +1151
Copy link
Member

@bluwy bluwy Jan 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We currently intentionally don't support this as it's not following the spec. Node.js only specifies to use the next element if the specifier is invalid, not if it don't exist on the fs.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know, I can remove it.


// If nothing exists, return the 1st entry and let the other layers
// handle the appropriate error
return result[0]
}

function resolveDeepImport(
id: string,
{
webResolvedImports,
setResolvedCache,
getResolvedCache,
dir,
data,
}: PackageData,
pkg: PackageData,
targetWeb: boolean,
options: InternalResolveOptions,
): string | undefined {
const { webResolvedImports, setResolvedCache, getResolvedCache, dir, data } =
pkg

const cache = getResolvedCache(id, targetWeb)
if (cache) {
return cache
Expand All @@ -1126,7 +1178,7 @@ function resolveDeepImport(
// resolve without postfix (see #7098)
const { file, postfix } = splitFileAndPostfix(relativeId)
const exportsId = resolveExportsOrImports(
data,
pkg,
file,
options,
targetWeb,
Expand Down