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 3 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
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
4 changes: 4 additions & 0 deletions packages/vite/src/node/ssr/ssrExternal.ts
Expand Up @@ -40,11 +40,15 @@ export function createIsConfiguredAsSsrExternal(
typeof noExternal !== 'boolean' &&
createFilter(undefined, noExternal, { resolve: false })

const targetConditions =
config.ssr.resolve?.externalConditions || config.resolve.conditions
marbemac marked this conversation as resolved.
Show resolved Hide resolved

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

const isExternalizable = (
Expand Down
12 changes: 10 additions & 2 deletions 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 @@ -271,6 +275,8 @@ async function nodeImport(
if (id.startsWith('data:') || isBuiltin(id)) {
url = id
} else {
const targetWeb = resolveOptions.ssrConfig?.target === 'webworker'

const resolved = tryNodeResolve(
id,
importer,
Expand All @@ -280,7 +286,9 @@ async function nodeImport(
typeof jest === 'undefined'
? { ...resolveOptions, tryEsmOnly: true }
: resolveOptions,
false,
targetWeb,
undefined,
true,
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
targetWeb,
undefined,
true,
false,
undefined,
true,

Because this part imitates Node and Node's resolver works like targetWeb=false, this needs to be false.
If we run this part with true, the same code won't run after build because of that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, but afaik a main use case for ssr.target = 'webworker' is for when the end user is planning to run in non-node runtimes (cloudflare, deno, bun, etc). This is how I've been using it at least - for example when using ssrLoadModule in dev to load a file that imports renderToReadableStream from react-dom.

Although I suppose the end user can now achieve this same result by explicitly listing the relevant conditions in ssr.resolve - the inconsistency between how dev server pipeline and build pipeline use ssr.target just felt a little weird.

Since there's another way to achieve the desired result via the new ssr.resolve options, I can change it back to false, just please confirm with a 👍 when you have a sec.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, but afaik a main use case for ssr.target = 'webworker' is for when the end user is planning to run in non-node runtimes (cloudflare, deno, bun, etc).

If a user is going to create a bundle for non-node runtimes, we expect them to set ssr.noExternal: true.
https://vitejs.dev/guide/ssr.html#ssr-bundle
This is because it doesn't make sense to externalize any modules in that case. (Bare import specifiers won't work in those environments because those environments doesn't support node_modules resolutions.)
If the module is not externalized, ssr.target = 'webworker' would set the targetWeb for vite:resolve plugin and that would resolve the import instead of ssrLoadModule.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, but I still think there are some rough edges when trying to use vite createServer + ssrLoadModule to create a nice local dev server experience (in conjunction w actually running dev server in non node runtimes like bun, or when wanting to better represent the final build target during local dev when that build target is non-node like cloudflare). BUT I think best as a separate discussion, the adjustments in this PR make it possible to cover everything :).

I reverted the targetWeb change - thanks for the review!

)
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')
}),
)
}
16 changes: 16 additions & 0 deletions playground/ssr-conditions/src/app.js
@@ -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'
}
4 changes: 4 additions & 0 deletions playground/ssr-conditions/src/direct-load.js
marbemac marked this conversation as resolved.
Show resolved Hide resolved
@@ -0,0 +1,4 @@
import noExternalReactServerMessage from '@vitejs/test-ssr-conditions-no-external/server'
import externalReactServerMessage from '@vitejs/test-ssr-conditions-external/server'

export { noExternalReactServerMessage, externalReactServerMessage }