feat(ssr): support for ssr.resolve.conditions and ssr.resolve.externalConditions options (#14498)

This commit is contained in:
Marc MacLeod 2023-10-05 03:54:06 -05:00 committed by GitHub
parent 22bd67d70a
commit d0afc3948b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 416 additions and 2 deletions

View File

@ -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.

View File

@ -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).

View File

@ -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
const options: InternalResolveOptions = {
isRequire,
...resolveOptions,
scan: resolveOpts?.scan ?? resolveOptions.scan,
conditions: ssr ? ssrConditions : resolveOptions.conditions,
}
const resolvedImports = resolveSubpathImports(

View File

@ -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:
@ -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 {

View File

@ -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 = (

View File

@ -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
@ -281,6 +285,8 @@ async function nodeImport(
? { ...resolveOptions, tryEsmOnly: true }
: resolveOptions,
false,
undefined,
true,
)
if (!resolved) {
const err: any = new Error(

View File

@ -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)
}
})
}

View File

@ -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',
)
})

View File

@ -0,0 +1 @@
export default 'browser.js'

View File

@ -0,0 +1 @@
export default 'default.js'

View File

@ -0,0 +1 @@
export default 'edge.js'

View File

@ -0,0 +1 @@
export default 'node.js'

View File

@ -0,0 +1 @@
export default 'node.unbundled.js'

View File

@ -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"
}
}
}

View File

@ -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>

View File

@ -0,0 +1 @@
export default 'browser.js'

View File

@ -0,0 +1 @@
export default 'default.js'

View File

@ -0,0 +1 @@
export default 'edge.js'

View File

@ -0,0 +1 @@
export default 'node.js'

View File

@ -0,0 +1 @@
export default 'node.unbundled.js'

View File

@ -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"
}
}
}

View File

@ -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"
}
}

View File

@ -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')
}),
)
}

View File

@ -0,0 +1,16 @@
import noExternalReactServerMessage from '@vitejs/test-ssr-conditions-no-external/server'
import externalReactServerMessage from '@vitejs/test-ssr-conditions-external/server'
export async function render(url) {
let html = ''
html += `\n<p class="no-external-react-server">${noExternalReactServerMessage}</p>`
html += `\n<p class="browser-no-external-react-server"></p>`
html += `\n<p class="external-react-server">${externalReactServerMessage}</p>`
html += `\n<p class="browser-external-react-server"></p>`
return html + '\n'
}

View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
export default defineConfig({
ssr: {
external: ['@vitejs/test-ssr-conditions-external'],
noExternal: ['@vitejs/test-ssr-conditions-no-external'],
resolve: {
conditions: ['react-server'],
externalConditions: ['workerd', 'react-server'],
},
},
})

View File

@ -11,6 +11,18 @@ test('/', async () => {
expect(await page.textContent('.external')).toMatch('object')
})
test('supports resolve.conditions', async () => {
await page.goto(url)
expect(await page.textContent('.worker-exports')).toMatch('[success] worker')
})
test('respects browser export', async () => {
await page.goto(url)
expect(await page.textContent('.browser-exports')).toMatch(
'[success] browser',
)
})
test.runIf(isBuild)('inlineDynamicImports', () => {
const dynamicJsContent = findAssetFile(/dynamic-\w+\.js/, 'worker')
expect(dynamicJsContent).toBe('')

View File

@ -0,0 +1 @@
export default '[success] browser'

View File

@ -0,0 +1 @@
export default '[fail] should not load me'

View File

@ -0,0 +1,12 @@
{
"name": "@vitejs/test-browser-exports",
"private": true,
"version": "0.0.0",
"exports": {
".": {
"browser": "./browser.js",
"node": "./node.js",
"default": "./node.js"
}
}
}

View File

@ -8,7 +8,9 @@
"build:worker": "vite build --ssr src/entry-worker.jsx --outDir dist/worker"
},
"dependencies": {
"react": "^18.2.0"
"react": "^18.2.0",
"@vitejs/test-browser-exports": "file:./browser-exports",
"@vitejs/test-worker-exports": "file:./worker-exports"
},
"devDependencies": {
"miniflare": "^3.20231002.0",

View File

@ -1,4 +1,6 @@
import { msg as linkedMsg } from '@vitejs/test-resolve-linked'
import browserExportsMessage from '@vitejs/test-browser-exports'
import workerExportsMessage from '@vitejs/test-worker-exports'
import React from 'react'
let loaded = false
@ -14,6 +16,8 @@ addEventListener('fetch', function (event) {
<p class="linked">${linkedMsg}</p>
<p class="external">${typeof React}</p>
<p>dynamic: ${loaded}</p>
<p class="browser-exports">${browserExportsMessage}</p>
<p class="worker-exports">${workerExportsMessage}</p>
`,
{
headers: {

View File

@ -6,6 +6,7 @@ export default defineConfig({
},
resolve: {
dedupe: ['react'],
conditions: ['worker'],
},
ssr: {
target: 'webworker',

View File

@ -0,0 +1,2 @@
// conditions are set to worker, and worker is higher up in the exports object in package.json, so should be preferred
export default '[fail] should not load me'

View File

@ -0,0 +1 @@
export default '[fail] should not load me'

View File

@ -0,0 +1,13 @@
{
"name": "@vitejs/test-worker-exports",
"private": true,
"version": "0.0.0",
"exports": {
".": {
"worker": "./worker.js",
"browser": "./browser.js",
"node": "./node.js",
"default": "./node.js"
}
}
}

View File

@ -0,0 +1 @@
export default '[success] worker'

View File

@ -36,6 +36,7 @@ export const ports = {
'ssr-webworker': 9605,
'proxy-hmr': 9606, // not imported but used in `proxy-hmr/vite.config.js`
'proxy-hmr/other-app': 9607, // not imported but used in `proxy-hmr/other-app/vite.config.js`
'ssr-conditions': 9608,
'css/postcss-caching': 5005,
'css/postcss-plugins-different-dir': 5006,
'css/dynamic-import': 5007,
@ -50,6 +51,7 @@ export const hmrPorts = {
'ssr-pug': 24685,
'css/lightningcss-proxy': 24686,
json: 24687,
'ssr-conditions': 24688,
}
const hexToNameMap: Record<string, string> = {}

View File

@ -1176,6 +1176,23 @@ importers:
specifier: ^4.18.2
version: 4.18.2
playground/ssr-conditions:
dependencies:
'@vitejs/test-ssr-conditions-external':
specifier: file:./external
version: file:playground/ssr-conditions/external
'@vitejs/test-ssr-conditions-no-external':
specifier: file:./no-external
version: file:playground/ssr-conditions/no-external
devDependencies:
express:
specifier: ^4.18.2
version: 4.18.2
playground/ssr-conditions/external: {}
playground/ssr-conditions/no-external: {}
playground/ssr-deps:
dependencies:
'@vitejs/test-css-lib':
@ -1377,6 +1394,12 @@ importers:
playground/ssr-webworker:
dependencies:
'@vitejs/test-browser-exports':
specifier: file:./browser-exports
version: file:playground/ssr-webworker/browser-exports
'@vitejs/test-worker-exports':
specifier: file:./worker-exports
version: file:playground/ssr-webworker/worker-exports
react:
specifier: ^18.2.0
version: 18.2.0
@ -1388,6 +1411,10 @@ importers:
specifier: ^3.20231002.0
version: 3.20231002.0
playground/ssr-webworker/browser-exports: {}
playground/ssr-webworker/worker-exports: {}
playground/tailwind:
dependencies:
autoprefixer:
@ -10416,6 +10443,16 @@ packages:
dep-a: file:playground/preload/dep-a
dev: true
file:playground/ssr-conditions/external:
resolution: {directory: playground/ssr-conditions/external, type: directory}
name: '@vitejs/test-ssr-conditions-external'
dev: false
file:playground/ssr-conditions/no-external:
resolution: {directory: playground/ssr-conditions/no-external, type: directory}
name: '@vitejs/test-ssr-conditions-no-external'
dev: false
file:playground/ssr-deps/css-lib:
resolution: {directory: playground/ssr-deps/css-lib, type: directory}
name: '@vitejs/test-css-lib'
@ -10550,6 +10587,16 @@ packages:
name: '@vitejs/test-resolve-pkg-exports'
dev: false
file:playground/ssr-webworker/browser-exports:
resolution: {directory: playground/ssr-webworker/browser-exports, type: directory}
name: '@vitejs/test-browser-exports'
dev: false
file:playground/ssr-webworker/worker-exports:
resolution: {directory: playground/ssr-webworker/worker-exports, type: directory}
name: '@vitejs/test-worker-exports'
dev: false
file:playground/worker/dep-to-optimize:
resolution: {directory: playground/worker/dep-to-optimize, type: directory}
name: '@vitejs/test-dep-to-optimize'