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(ssr): support for ssr.resolve.conditions and ssr.resolve.externalConditions options #14498

Merged
merged 8 commits into from Oct 5, 2023
Merged
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
16 changes: 16 additions & 0 deletions docs/config/ssr-options.md
Expand Up @@ -20,3 +20,19 @@ Prevent listed dependencies from being externalized for SSR. If `true`, no depen
- **Default:** `node`

Build target for the SSR server.

## ssr.resolve.conditions

- **Type:** `string[]`
- **Related:** [Resolve Conditions](./shared-options.md#resolve-conditions)

Defaults to the the root [`resolve.conditions`](./shared-options.md#resolve-conditions).

These conditions are used in the plugin pipeline, and only affect non-externalized dependencies during the SSR build. Use `ssr.resolve.externalConditions` to affect externalized imports.

## ssr.resolve.externalConditions

- **Type:** `string[]`
- **Default:** `[]`

Conditions that are used during ssr import (including `ssrLoadModule`) of externalized dependencies.
4 changes: 4 additions & 0 deletions docs/guide/ssr.md
Expand Up @@ -259,6 +259,10 @@ In some cases like `webworker` runtimes, you might want to bundle your SSR build
- Treat all dependencies as `noExternal`
- Throw an error if any Node.js built-ins are imported

## SSR Resolve Conditions

By default package entry resolution will use the conditions set in [`resolve.conditions`](../config/shared-options.md#resolve-conditions) for the SSR build. You can use [`ssr.resolve.conditions`](../config/ssr-options.md#ssr-resolve-conditions) and [`ssr.resolve.externalConditions`](../config/ssr-options.md#ssr-resolve-externalconditions) to customize this behavior.

## Vite CLI

The CLI commands `$ vite dev` and `$ vite preview` can also be used for SSR apps. You can add your SSR middlewares to the development server with [`configureServer`](/guide/api-plugin#configureserver) and to the preview server with [`configurePreviewServer`](/guide/api-plugin#configurepreviewserver).
Expand Down
7 changes: 7 additions & 0 deletions packages/vite/src/node/plugins/resolve.ts
Expand Up @@ -173,10 +173,17 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin {
const isRequire: boolean =
resolveOpts?.custom?.['node-resolve']?.isRequire ?? false

// end user can configure different conditions for ssr and client.
// falls back to client conditions if no ssr conditions supplied
const ssrConditions =
resolveOptions.ssrConfig?.resolve?.conditions ||
resolveOptions.conditions
Comment on lines +178 to +180
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maintains current behavior if the new ssr.resolve.conditions field is not set - backwards compatible.


const options: InternalResolveOptions = {
isRequire,
...resolveOptions,
scan: resolveOpts?.scan ?? resolveOptions.scan,
conditions: ssr ? ssrConditions : resolveOptions.conditions,
}

const resolvedImports = resolveSubpathImports(
Expand Down
20 changes: 20 additions & 0 deletions packages/vite/src/node/ssr/index.ts
Expand Up @@ -7,12 +7,14 @@ export type SsrDepOptimizationOptions = DepOptimizationConfig
export interface SSROptions {
noExternal?: string | RegExp | (string | RegExp)[] | true
external?: string[]

/**
* Define the target for the ssr build. The browser field in package.json
* is ignored for node but used if webworker is the target
* @default 'node'
*/
target?: SSRTarget

/**
* Control over which dependencies are optimized during SSR and esbuild options
* During build:
Expand All @@ -22,6 +24,24 @@ export interface SSROptions {
* @experimental
*/
optimizeDeps?: SsrDepOptimizationOptions

resolve?: {
/**
* Conditions that are used in the plugin pipeline. The default value is the root config's `resolve.conditions`.
*
* Use this to override the default ssr conditions for the ssr build.
*
* @default rootConfig.resolve.conditions
*/
conditions?: string[]

/**
* Conditions that are used during ssr import (including `ssrLoadModule`) of externalized dependencies.
*
* @default []
*/
externalConditions?: string[]
}
}

export interface ResolvedSSROptions extends SSROptions {
Expand Down
3 changes: 3 additions & 0 deletions packages/vite/src/node/ssr/ssrExternal.ts
Expand Up @@ -40,11 +40,14 @@ export function createIsConfiguredAsSsrExternal(
typeof noExternal !== 'boolean' &&
createFilter(undefined, noExternal, { resolve: false })

const targetConditions = config.ssr.resolve?.externalConditions || []

const resolveOptions: InternalResolveOptions = {
...config.resolve,
root,
isProduction: false,
isBuild: true,
conditions: targetConditions,
}

const isExternalizable = (
Expand Down
8 changes: 7 additions & 1 deletion packages/vite/src/node/ssr/ssrModuleLoader.ts
Expand Up @@ -123,19 +123,23 @@ async function instantiateModule(
isProduction,
resolve: { dedupe, preserveSymlinks },
root,
ssr,
} = server.config

const overrideConditions = ssr.resolve?.externalConditions || []

const resolveOptions: InternalResolveOptionsWithOverrideConditions = {
mainFields: ['main'],
browserField: true,
conditions: [],
overrideConditions: ['production', 'development'],
overrideConditions: [...overrideConditions, 'production', 'development'],
extensions: ['.js', '.cjs', '.json'],
dedupe,
preserveSymlinks,
isBuild: false,
isProduction,
root,
ssrConfig: ssr,
}

// Since dynamic imports can happen in parallel, we need to
Expand Down Expand Up @@ -281,6 +285,8 @@ async function nodeImport(
? { ...resolveOptions, tryEsmOnly: true }
: resolveOptions,
false,
undefined,
true,
)
if (!resolved) {
const err: any = new Error(
Expand Down
35 changes: 35 additions & 0 deletions playground/ssr-conditions/__tests__/serve.ts
@@ -0,0 +1,35 @@
// this is automatically detected by playground/vitestSetup.ts and will replace
// the default e2e test serve behavior

import path from 'node:path'
import kill from 'kill-port'
import { hmrPorts, ports, rootDir } from '~utils'

export const port = ports['ssr-conditions']

export async function serve(): Promise<{ close(): Promise<void> }> {
await kill(port)

const { createServer } = await import(path.resolve(rootDir, 'server.js'))
const { app, vite } = await createServer(rootDir, hmrPorts['ssr-conditions'])

return new Promise((resolve, reject) => {
try {
const server = app.listen(port, () => {
resolve({
// for test teardown
async close() {
await new Promise((resolve) => {
server.close(resolve)
})
if (vite) {
await vite.close()
}
},
})
})
} catch (e) {
reject(e)
}
})
}
27 changes: 27 additions & 0 deletions playground/ssr-conditions/__tests__/ssr-conditions.spec.ts
@@ -0,0 +1,27 @@
import { expect, test } from 'vitest'
import { port } from './serve'
import { page } from '~utils'

const url = `http://localhost:${port}`

test('ssr.resolve.conditions affect non-externalized imports during ssr', async () => {
await page.goto(url)
expect(await page.textContent('.no-external-react-server')).toMatch(
'node.unbundled.js',
)
})

test('ssr.resolve.externalConditions affect externalized imports during ssr', async () => {
await page.goto(url)
expect(await page.textContent('.external-react-server')).toMatch('edge.js')
})

test('ssr.resolve settings do not affect non-ssr imports', async () => {
await page.goto(url)
expect(await page.textContent('.browser-no-external-react-server')).toMatch(
'default.js',
)
expect(await page.textContent('.browser-external-react-server')).toMatch(
'default.js',
)
})
1 change: 1 addition & 0 deletions playground/ssr-conditions/external/browser.js
@@ -0,0 +1 @@
export default 'browser.js'
1 change: 1 addition & 0 deletions playground/ssr-conditions/external/default.js
@@ -0,0 +1 @@
export default 'default.js'
1 change: 1 addition & 0 deletions playground/ssr-conditions/external/edge.js
@@ -0,0 +1 @@
export default 'edge.js'
1 change: 1 addition & 0 deletions playground/ssr-conditions/external/node.js
@@ -0,0 +1 @@
export default 'node.js'
1 change: 1 addition & 0 deletions playground/ssr-conditions/external/node.unbundled.js
@@ -0,0 +1 @@
export default 'node.unbundled.js'
21 changes: 21 additions & 0 deletions playground/ssr-conditions/external/package.json
@@ -0,0 +1,21 @@
{
"name": "@vitejs/test-ssr-conditions-external",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./server": {
"react-server": {
"workerd": "./edge.js",
"deno": "./browser.js",
"node": {
"webpack": "./node.js",
"default": "./node.unbundled.js"
},
"edge-light": "./edge.js",
"browser": "./browser.js"
},
"default": "./default.js"
}
}
}
29 changes: 29 additions & 0 deletions playground/ssr-conditions/index.html
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSR Resolve Conditions</title>
</head>
<body>
<h1>SSR Resolve Conditions</h1>
<div id="app"><!--app-html--></div>

<script type="module">
import('@vitejs/test-ssr-conditions-no-external/server').then(
({ default: message }) => {
document.querySelector(
'.browser-no-external-react-server',
).textContent = message
},
)

import('@vitejs/test-ssr-conditions-external/server').then(
({ default: message }) => {
document.querySelector('.browser-external-react-server').textContent =
message
},
)
</script>
</body>
</html>
1 change: 1 addition & 0 deletions playground/ssr-conditions/no-external/browser.js
@@ -0,0 +1 @@
export default 'browser.js'
1 change: 1 addition & 0 deletions playground/ssr-conditions/no-external/default.js
@@ -0,0 +1 @@
export default 'default.js'
1 change: 1 addition & 0 deletions playground/ssr-conditions/no-external/edge.js
@@ -0,0 +1 @@
export default 'edge.js'
1 change: 1 addition & 0 deletions playground/ssr-conditions/no-external/node.js
@@ -0,0 +1 @@
export default 'node.js'
1 change: 1 addition & 0 deletions playground/ssr-conditions/no-external/node.unbundled.js
@@ -0,0 +1 @@
export default 'node.unbundled.js'
21 changes: 21 additions & 0 deletions playground/ssr-conditions/no-external/package.json
@@ -0,0 +1,21 @@
{
"name": "@vitejs/test-ssr-conditions-no-external",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./server": {
"react-server": {
"workerd": "./edge.js",
"deno": "./browser.js",
"node": {
"webpack": "./node.js",
"default": "./node.unbundled.js"
},
"edge-light": "./edge.js",
"browser": "./browser.js"
},
"default": "./default.js"
}
}
}
18 changes: 18 additions & 0 deletions playground/ssr-conditions/package.json
@@ -0,0 +1,18 @@
{
"name": "@vitejs/test-ssr-conditions",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "node server",
"serve": "NODE_ENV=production node server",
"debug": "node --inspect-brk server"
},
"dependencies": {
"@vitejs/test-ssr-conditions-external": "file:./external",
"@vitejs/test-ssr-conditions-no-external": "file:./no-external"
},
"devDependencies": {
"express": "^4.18.2"
}
}
70 changes: 70 additions & 0 deletions playground/ssr-conditions/server.js
@@ -0,0 +1,70 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

const isTest = process.env.VITEST

export async function createServer(root = process.cwd(), hmrPort) {
const resolve = (p) => path.resolve(__dirname, p)

const app = express()

/**
* @type {import('vite').ViteDevServer}
*/
const vite = await (
await import('vite')
).createServer({
root,
logLevel: isTest ? 'error' : 'info',
server: {
middlewareMode: true,
watch: {
// During tests we edit the files too fast and sometimes chokidar
// misses change events, so enforce polling for consistency
usePolling: true,
interval: 100,
},
hmr: {
port: hmrPort,
},
},
appType: 'custom',
})

app.use(vite.middlewares)

app.use('*', async (req, res) => {
try {
const url = req.originalUrl

let template
template = fs.readFileSync(resolve('index.html'), 'utf-8')
template = await vite.transformIndexHtml(url, template)
const render = (await vite.ssrLoadModule('/src/app.js')).render

const appHtml = await render(url, __dirname)

const html = template.replace(`<!--app-html-->`, appHtml)

res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch (e) {
vite && vite.ssrFixStacktrace(e)
console.log(e.stack)
res.status(500).end(e.stack)
}
})

return { app, vite }
}

if (!isTest) {
createServer().then(({ app }) =>
app.listen(5173, () => {
console.log('http://localhost:5173')
}),
)
}