diff --git a/.eslintrc.js b/.eslintrc.js index 00e3094b06347..d8ffb89c3846f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -325,6 +325,7 @@ module.exports = { 'packages/react-noop-renderer/**/*.js', 'packages/react-refresh/**/*.js', 'packages/react-server-dom-esm/**/*.js', + 'packages/react-server-dom-vite/**/*.js', 'packages/react-server-dom-webpack/**/*.js', 'packages/react-test-renderer/**/*.js', 'packages/react-debug-tools/**/*.js', @@ -416,7 +417,7 @@ module.exports = { }, { files: [ - 'packages/react-native-renderer/**/*.js', + 'packages/react-native-renderer/**/*.js' ], globals: { nativeFabricUIManager: 'readonly', @@ -429,6 +430,13 @@ module.exports = { __webpack_require__: 'readonly', }, }, + { + files: ['packages/react-server-dom-vite/**/*.js'], + globals: { + __vite_preload__: 'readonly', + __vite_require__: 'readonly', + }, + }, { files: ['packages/scheduler/**/*.js'], globals: { diff --git a/fixtures/flight-vite/.gitignore b/fixtures/flight-vite/.gitignore new file mode 100644 index 0000000000000..89967363fce28 --- /dev/null +++ b/fixtures/flight-vite/.gitignore @@ -0,0 +1,20 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/fixtures/flight-vite/package.json b/fixtures/flight-vite/package.json new file mode 100644 index 0000000000000..279245c81ccc4 --- /dev/null +++ b/fixtures/flight-vite/package.json @@ -0,0 +1,33 @@ +{ + "name": "flight-vite", + "type": "module", + "version": "0.1.0", + "private": true, + "dependencies": { + "@vitejs/plugin-react": "^4.0.0", + "body-parser": "^1.20.1", + "browserslist": "^4.18.1", + "busboy": "^1.6.0", + "compression": "^1.7.4", + "concurrently": "^7.3.0", + "nodemon": "^2.0.19", + "prompts": "^2.4.2", + "react": "experimental", + "react-dev-utils": "^12.0.1", + "react-dom": "experimental", + "undici": "^5.20.0", + "vite": "^4.3.9" + }, + "scripts": { + "predev": "cp -r ../../build/oss-experimental/* ./node_modules/", + "prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/", + "prestart": "cp -r ../../build/oss-experimental/* ./node_modules/", + "dev": "concurrently \"npm run dev:region\" \"npm run dev:global\"", + "dev:global": "NODE_ENV=development BUILD_PATH=dist node server/global", + "dev:region": "NODE_ENV=development BUILD_PATH=dist node --inspect --conditions=react-server server/region", + "start": "concurrently \"npm run start:region\" \"npm run start:global\"", + "start:global": "NODE_ENV=production node server/global", + "start:region": "NODE_ENV=production node --conditions=react-server server/region", + "build": "node scripts/build.js" + } +} diff --git a/fixtures/flight-vite/public/favicon.ico b/fixtures/flight-vite/public/favicon.ico new file mode 100644 index 0000000000000..5c125de5d897c Binary files /dev/null and b/fixtures/flight-vite/public/favicon.ico differ diff --git a/fixtures/flight-vite/scripts/build.js b/fixtures/flight-vite/scripts/build.js new file mode 100644 index 0000000000000..075bf639f0fc1 --- /dev/null +++ b/fixtures/flight-vite/scripts/build.js @@ -0,0 +1,181 @@ +'use strict'; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'production'; +process.env.NODE_ENV = 'production'; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', err => { + throw err; +}); + +const path = require('path'); +const chalk = require('chalk'); +const fs = require('fs-extra'); +const {builtinModules} = require('module'); + +function hash(str) { + let hash = 0; + + for (let i = 0; i < str.length; i++) { + hash += str.charCodeAt(i); + } + + return hash; +} + +async function build() { + const vite = await import('vite'); + const {default: reactServer} = await import('react-server-dom-vite/plugin'); + const serverModules = new Set(); + const clientModules = new Set(); + + // Building the react server bundle, includes App entry point and + // server actions + let bundle = await vite.build({ + build: { + rollupOptions: { + input: ['src/App.jsx'], + external: [...builtinModules.map(m => `node:${m}`)], + onwarn: (warning, warn) => { + // suppress warnings about source map issues for now + // these are caused originally by rollup trying to complain about directives + // in the middle of the files + // TODO: fix source map issues + if (warning.code === 'SOURCEMAP_ERROR') { + return; + } + }, + output: { + // preserve the export names of the server actions in chunks + minifyInternalExports: false, + manualChunks: chunk => { + // server references should be emitted as separate chunks + // so that we can load them individually when server actions + // are called. we need to do this in manualChunks because we don't + // want to run a preanalysis pass just to identify these + if (serverModules.has(chunk)) { + return `${hash(chunk)}`; + } + }, + format: 'cjs', + // we want to control the chunk names so that we can load them + // individually when server actions are called + chunkFileNames: '[name].cjs', + }, + }, + ssr: true, + ssrManifest: true, + ssrEmitAssets: true, + target: 'node18', + manifest: true, + outDir: 'build/react-server', + }, + resolve: { + conditions: ['node', 'import', 'react-server', process.env.NODE_ENV], + }, + plugins: [ + reactServer({ + hash, + onClientReference: id => { + clientModules.add(id); + }, + onServerReference: id => { + serverModules.add(id); + }, + }), + ], + ssr: { + noExternal: true, + external: ['react', 'react-dom', 'react-server-dom-vite'], + }, + }); + + // Building the SSR server bundle, includes the client components for SSR + await vite.build({ + build: { + rollupOptions: { + onwarn: (warning, warn) => { + // suppress warnings about source map issues for now + // these are caused originally by rollup trying to complain about directives + // in the middle of the files + // TODO: fix source map issues + if (warning.code === 'SOURCEMAP_ERROR') { + return; + } + }, + input: { + entry: 'src/index.js', + ...Object.fromEntries( + [...clientModules.values()].map(c => [hash(c), c]) + ), + }, + output: { + entryFileNames: chunk => { + return chunk.name + '.cjs'; + }, + format: 'cjs', + }, + }, + ssr: true, + ssrManifest: true, + ssrEmitAssets: true, + target: 'node18', + manifest: true, + outDir: 'build/server', + }, + ssr: { + external: ['react', 'react-dom', 'react-server-dom-vite'], + }, + }); + + // Building the client bundle, includes the client entry point and client components for hydration + await vite.build({ + build: { + rollupOptions: { + input: { + entry: 'src/index.js', + ...Object.fromEntries( + [...clientModules.values()].map(c => [hash(c), c]) + ), + }, + onwarn: (warning, warn) => { + // suppress warnings about source map issues for now + // these are caused originally by rollup trying to complain about directives + // in the middle of the files + // TODO: fix source map issues + if (warning.code === 'SOURCEMAP_ERROR') { + return; + } + }, + output: { + // we want to control the names of the client component chunks + // so that we can load them individually when they are requested + entryFileNames: chunk => { + return chunk.name + '.js'; + }, + }, + treeshake: true, + // required otherwise rollup will remove the exports since they are not used + // by the other entries + preserveEntrySignatures: 'exports-only', + }, + ssrManifest: true, + target: 'esnext', + manifest: true, + outDir: 'build/static', + }, + ssr: { + external: ['react', 'react-dom', 'react-server-dom-vite'], + }, + }); + + // copy assets from react-server build to static build, this includes stylesheets improted from server components + await fs.promises.cp('build/react-server/assets', 'build/static/assets', { + recursive: true, + }); +} + +build(); diff --git a/fixtures/flight-vite/scripts/package.json b/fixtures/flight-vite/scripts/package.json new file mode 100644 index 0000000000000..5bbefffbabee3 --- /dev/null +++ b/fixtures/flight-vite/scripts/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/fixtures/flight-vite/scripts/test.js b/fixtures/flight-vite/scripts/test.js new file mode 100644 index 0000000000000..bad5a20e3d0ee --- /dev/null +++ b/fixtures/flight-vite/scripts/test.js @@ -0,0 +1,51 @@ +'use strict'; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'test'; +process.env.NODE_ENV = 'test'; +process.env.PUBLIC_URL = ''; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', err => { + throw err; +}); + +// Ensure environment variables are read. +require('../config/env'); + +const jest = require('jest'); +const execSync = require('child_process').execSync; +let argv = process.argv.slice(2); + +function isInGitRepository() { + try { + execSync('git rev-parse --is-inside-work-tree', {stdio: 'ignore'}); + return true; + } catch (e) { + return false; + } +} + +function isInMercurialRepository() { + try { + execSync('hg --cwd . root', {stdio: 'ignore'}); + return true; + } catch (e) { + return false; + } +} + +// Watch unless on CI or explicitly running all tests +if ( + !process.env.CI && + argv.indexOf('--watchAll') === -1 && + argv.indexOf('--watchAll=false') === -1 +) { + // https://github.com/facebook/create-react-app/issues/5210 + const hasSourceControl = isInGitRepository() || isInMercurialRepository(); + argv.push(hasSourceControl ? '--watch' : '--watchAll'); +} + +jest.run(argv); diff --git a/fixtures/flight-vite/server/global.js b/fixtures/flight-vite/server/global.js new file mode 100644 index 0000000000000..151e12dc4331b --- /dev/null +++ b/fixtures/flight-vite/server/global.js @@ -0,0 +1,242 @@ +'use strict'; + +// This is a server to host CDN distributed resources like module source files and SSR + +const path = require('path'); +const url = require('url'); + +const fs = require('fs').promises; +const compress = require('compression'); +const chalk = require('chalk'); +const express = require('express'); +const http = require('http'); +const vite = require('vite'); + +const {renderToPipeableStream} = require('react-dom/server'); +const {createFromNodeStream} = require('react-server-dom-vite/client'); + +const moduleBasePath = new URL('../src', url.pathToFileURL(__filename)).href; + +async function createApp() { + const app = express(); + + app.use(compress()); + + function request(options, body) { + return new Promise((resolve, reject) => { + const req = http.request(options, res => { + resolve(res); + }); + req.on('error', e => { + reject(e); + }); + body.pipe(req); + }); + } + + let getClientAsset; + + if (process.env.NODE_ENV === 'development') { + const vite = await import('vite'); + const {default: reactRefresh} = await import('@vitejs/plugin-react'); + const viteServer = await vite.createServer({ + appType: 'custom', + server: {middlewareMode: true}, + plugins: [ + reactRefresh(), + { + name: 'react-server-dom-vite:react-refresh', + handleHotUpdate({file}) { + // clear vite module cache so when its imported again, we will + // get the new version + globalThis.__vite_module_cache__.delete(file); + }, + }, + ], + ssr: { + external: ['react', 'react-dom', 'react-server-dom-vite'], + }, + }); + + globalThis.__vite_module_cache__ = new Map(); + globalThis.__vite_preload__ = metadata => { + const existingPromise = __vite_module_cache__.get(metadata.specifier); + if (existingPromise) { + if (existingPromise.status === 'fulfilled') { + return null; + } + return existingPromise; + } else { + const modulePromise = viteServer.ssrLoadModule(metadata.specifier); + modulePromise.then( + value => { + const fulfilledThenable = modulePromise; + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = value; + }, + reason => { + const rejectedThenable = modulePromise; + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = reason; + } + ); + __vite_module_cache__.set(metadata.specifier, modulePromise); + return modulePromise; + } + }; + + globalThis.__vite_require__ = metadata => { + let moduleExports; + // We assume that preloadModule has been called before, which + // should have added something to the module cache. + const promise = __vite_module_cache__.get(metadata.specifier); + if (promise) { + if (promise.status === 'fulfilled') { + moduleExports = promise.value; + } else { + throw promise.reason; + } + return moduleExports[metadata.name]; + } else { + throw new Error('Module not found in cache: ' + id); + } + }; + + app.use('/__refresh', (req, res) => { + viteServer.ws.send('reload-rsc', {}); + }); + + app.use(viteServer.middlewares); + + getClientAsset = id => { + return path.join(viteServer.config.root, id); + }; + } else { + globalThis.__vite_module_cache__ = new Map(); + globalThis.__vite_preload__ = metadata => { + return null; + }; + + globalThis.__vite_require__ = metadata => { + const module = require(path.join( + process.cwd(), + 'build', + 'server', + metadata.specifier + '.cjs' + )); + + if (metadata.name === 'default') { + return module; + } + return module[metadata.name]; + }; + + app.use(express.static('build/static')); + + const clientManifest = JSON.parse( + await fs.readFile(path.join('build', 'static', 'manifest.json'), 'utf-8') + ); + getClientAsset = id => { + return clientManifest[id].file; + }; + } + + app.all('/', async function (req, res) { + const proxiedHeaders = { + 'X-Forwarded-Host': req.hostname, + 'X-Forwarded-For': req.ips, + 'X-Forwarded-Port': 3000, + 'X-Forwarded-Proto': req.protocol, + }; + // Proxy other headers as desired. + if (req.get('rsc-action')) { + proxiedHeaders['Content-type'] = req.get('Content-type'); + proxiedHeaders['rsc-action'] = req.get('rsc-action'); + } else if (req.get('Content-type')) { + proxiedHeaders['Content-type'] = req.get('Content-type'); + } + const promiseForData = request( + { + host: '127.0.0.1', + port: 3001, + method: req.method, + path: '/', + headers: proxiedHeaders, + }, + req + ); + if (req.accepts('text/html')) { + try { + const rscResponse = await promiseForData; + const moduleBaseURL = '/src'; + // For HTML, we're a "client" emulator that runs the client code, + // so we start by consuming the RSC payload. This needs the local file path + // to load the source files from as well as the URL path for preloads. + const root = await createFromNodeStream( + rscResponse, + new Proxy( + {}, + { + get(t, p) { + console.log(t, p); + }, + } + ), + moduleBaseURL + ); + // Render it into HTML by resolving the client components + res.set('Content-type', 'text/html'); + const {pipe} = renderToPipeableStream(root, { + bootstrapModules: [getClientAsset('src/index.js')], + }); + pipe(res); + } catch (e) { + console.error(`Failed to SSR: ${e.stack}`); + res.statusCode = 500; + res.end(); + } + } else { + try { + const rscResponse = await promiseForData; + // For other request, we pass-through the RSC payload. + res.set('Content-type', 'text/x-component'); + rscResponse.on('data', data => { + res.write(data); + res.flush(); + }); + rscResponse.on('end', data => { + res.end(); + }); + } catch (e) { + console.error(`Failed to proxy request: ${e.stack}`); + res.statusCode = 500; + res.end(); + } + } + }); + + app.on('error', function (error) { + if (error.syscall !== 'listen') { + throw error; + } + + switch (error.code) { + case 'EACCES': + console.error('port 3000 requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + console.error('Port 3000 is already in use'); + process.exit(1); + break; + default: + throw error; + } + }); + + app.listen(3000, () => { + console.log('Global Fizz/Webpack Server listening on port 3000...'); + }); +} + +createApp(); diff --git a/fixtures/flight-vite/server/manifest.js b/fixtures/flight-vite/server/manifest.js new file mode 100644 index 0000000000000..029e926c96f01 --- /dev/null +++ b/fixtures/flight-vite/server/manifest.js @@ -0,0 +1,52 @@ +'use strict'; + +/** + * Traverses the module graph and collects assets for a given chunk + * + * @param manifest Client manifest + * @param id Chunk id + * @param assetMap Cache of assets + * @returns Array of asset URLs + */ +const findAssetsInManifest = (manifest, id, assetMap = new Map()) => { + function traverse(id) { + const cached = assetMap.get(id); + if (cached) { + return cached; + } + const chunk = manifest[id]; + if (!chunk) { + return []; + } + const assets = [ + ...(chunk.assets || []), + ...(chunk.css || []), + ...(chunk.imports?.flatMap(traverse) || []), + ]; + const imports = chunk.imports?.flatMap(traverse) || []; + const all = [...assets, ...imports].filter(Boolean); + all.push(chunk.file); + assetMap.set(id, all); + return Array.from(new Set(all)); + } + return traverse(id); +}; + +const findAssetsInModuleNode = moduleNode => { + const seen = new Set(); + function traverse(node) { + if (seen.has(node.url)) { + return []; + } + seen.add(node.url); + + const imports = [...node.importedModules].flatMap(traverse) || []; + imports.push(node.url); + return Array.from(new Set(imports)); + } + return traverse(moduleNode); +}; + +module.exports = { + findAssetsInManifest, +}; diff --git a/fixtures/flight-vite/server/package.json b/fixtures/flight-vite/server/package.json new file mode 100644 index 0000000000000..5bbefffbabee3 --- /dev/null +++ b/fixtures/flight-vite/server/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/fixtures/flight-vite/server/region.js b/fixtures/flight-vite/server/region.js new file mode 100644 index 0000000000000..b0efc3861026e --- /dev/null +++ b/fixtures/flight-vite/server/region.js @@ -0,0 +1,269 @@ +'use strict'; + +// This is a server to host data-local resources like databases and RSC + +const path = require('path'); +const url = require('url'); + +if (typeof fetch === 'undefined') { + // Patch fetch for earlier Node versions. + global.fetch = require('undici').fetch; +} + +const express = require('express'); +const bodyParser = require('body-parser'); +const busboy = require('busboy'); +const compress = require('compression'); +const {Readable} = require('node:stream'); +const {readFile} = require('fs').promises; +const React = require('react'); + +async function createApp() { + // Application + const app = express(); + app.use(compress()); + + const moduleBasePath = new URL('../src', url.pathToFileURL(__filename)).href; + + let loadModule; + if (process.env.NODE_ENV === 'development') { + const vite = await import('vite'); + const {default: reactRefresh} = await import('@vitejs/plugin-react'); + const {default: reactServer} = await import('react-server-dom-vite/plugin'); + const viteServer = await vite.createServer({ + appType: 'custom', + server: {middlewareMode: true, hmr: {port: 9898}}, + resolve: { + conditions: ['node', 'import', 'react-server', process.env.NODE_ENV], + }, + plugins: [ + reactServer(), + reactRefresh(), + { + name: 'react-server-dom-vite:react-refresh', + handleHotUpdate({file}) { + // clear vite module cache so when its imported again, we will + fetch(`http://localhost:3000/__refresh`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({file}), + }) + .then(() => {}) + .catch(err => console.error(err)); + }, + }, + ], + ssr: { + noExternal: true, + external: ['react', 'react-dom', 'react-server-dom-vite'], + }, + }); + + globalThis.__vite_module_cache__ = new Map(); + globalThis.__vite_preload__ = metadata => { + const existingPromise = __vite_module_cache__.get(metadata.specifier); + if (existingPromise) { + if (existingPromise.status === 'fulfilled') { + return null; + } + return existingPromise; + } else { + const modulePromise = viteServer.ssrLoadModule(metadata.specifier); + modulePromise.then( + value => { + const fulfilledThenable = modulePromise; + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = value; + }, + reason => { + const rejectedThenable = modulePromise; + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = reason; + } + ); + __vite_module_cache__.set(metadata.specifier, modulePromise); + return modulePromise; + } + }; + + globalThis.__vite_require__ = metadata => { + let moduleExports; + // We assume that preloadModule has been called before, which + // should have added something to the module cache. + const promise = __vite_module_cache__.get(metadata.specifier); + if (promise) { + if (promise.status === 'fulfilled') { + moduleExports = promise.value; + } else { + throw promise.reason; + } + return moduleExports[metadata.name]; + } else { + throw new Error('Module not found in cache: ' + id); + } + }; + + const {collectStyles} = require('./styles.js'); + globalThis.__vite_find_assets__ = async entries => { + return Object.keys(await collectStyles(viteServer, entries)); + }; + } else { + const reactServerManifest = JSON.parse( + await readFile('build/react-server/manifest.json', 'utf8') + ); + + globalThis.__vite_module_cache__ = new Map(); + globalThis.__vite_preload__ = metadata => { + return null; + }; + + globalThis.__vite_require__ = metadata => { + const module = require(path.join( + process.cwd(), + 'build', + 'server', + metadata.specifier + '.cjs' + )); + + if (metadata.name === 'default') { + return module; + } + return module[metadata.name]; + }; + + const {findAssetsInManifest} = require('./manifest.js'); + + globalThis.__vite_find_assets__ = async entries => { + return findAssetsInManifest(reactServerManifest, entries[0]).filter( + asset => asset.endsWith('.css') + ); + }; + } + + loadModule = async metadata => { + await __vite_preload__(metadata); + return __vite_require__(metadata); + }; + + async function renderApp(res, returnValue) { + const {renderToPipeableStream} = await import( + 'react-server-dom-vite/server' + ); + + const App = await loadModule({ + specifier: + process.env.NODE_ENV === 'development' + ? path.join(process.cwd(), 'src/App.jsx') + : 'App', + name: 'default', + }); + const root = React.createElement(App); + + // For client-invoked server actions we refresh the tree and return a return value. + const payload = returnValue ? {returnValue, root} : root; + const {pipe} = renderToPipeableStream(payload, moduleBasePath); + pipe(res); + } + + app.get('/', async function (req, res) { + await renderApp(res, null); + }); + + app.post('/', bodyParser.text(), async function (req, res) { + const { + renderToPipeableStream, + decodeReply, + decodeReplyFromBusboy, + decodeAction, + } = await import('react-server-dom-vite/server'); + const serverReference = req.get('rsc-action'); + if (serverReference) { + // This is the client-side case + const [filepath, name] = serverReference.split('#'); + const action = await loadModule({specifier: filepath, name}); + // Validate that this is actually a function we intended to expose and + // not the client trying to invoke arbitrary functions. In a real app, + // you'd have a manifest verifying this before even importing it. + if (action.$$typeof !== Symbol.for('react.server.reference')) { + throw new Error('Invalid action'); + } + + let args; + if (req.is('multipart/form-data')) { + // Use busboy to streamingly parse the reply from form-data. + const bb = busboy({headers: req.headers}); + const reply = decodeReplyFromBusboy(bb, moduleBasePath); + req.pipe(bb); + args = await reply; + } else { + args = await decodeReply(req.body, moduleBasePath); + } + const result = action.apply(null, args); + try { + // Wait for any mutations + await result; + } catch (x) { + // We handle the error on the client + } + // Refresh the client and return the value + renderApp(res, result); + } else { + // This is the progressive enhancement case + const UndiciRequest = require('undici').Request; + const fakeRequest = new UndiciRequest('http://localhost', { + method: 'POST', + headers: {'Content-Type': req.headers['content-type']}, + body: Readable.toWeb(req), + duplex: 'half', + }); + const formData = await fakeRequest.formData(); + const action = await decodeAction(formData, moduleBasePath); + try { + // Wait for any mutations + await action(); + } catch (x) { + const {setServerState} = await import('../src/ServerState.js'); + setServerState('Error: ' + x.message); + } + renderApp(res, null); + } + }); + + app.get('/todos', function (req, res) { + res.json([ + { + id: 1, + text: 'Shave yaks', + }, + { + id: 2, + text: 'Eat kale', + }, + ]); + }); + + app.listen(3001, () => { + console.log('Regional Flight Server listening on port 3001...'); + }); + + app.on('error', function (error) { + if (error.syscall !== 'listen') { + throw error; + } + + switch (error.code) { + case 'EACCES': + console.error('port 3001 requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + console.error('Port 3001 is already in use'); + process.exit(1); + break; + default: + throw error; + } + }); +} + +createApp(); diff --git a/fixtures/flight-vite/server/styles.js b/fixtures/flight-vite/server/styles.js new file mode 100644 index 0000000000000..72976228ec8fe --- /dev/null +++ b/fixtures/flight-vite/server/styles.js @@ -0,0 +1,103 @@ +'use strict'; + +const path = require('node:path'); + +async function findDeps(vite, node, deps) { + // since `ssrTransformResult.deps` contains URLs instead of `ModuleNode`s, this process is asynchronous. + // instead of using `await`, we resolve all branches in parallel. + const branches = []; + + async function add(node) { + if (!deps.has(node)) { + deps.add(node); + await findDeps(vite, node, deps); + } + } + + async function add_by_url(url) { + const node = await vite.moduleGraph.getModuleByUrl(url); + + if (node) { + await add(node); + } + } + + if (node.ssrTransformResult) { + if (node.ssrTransformResult.deps) { + node.ssrTransformResult.deps.forEach(url => + branches.push(add_by_url(url)) + ); + } + + // if (node.ssrTransformResult.dynamicDeps) { + // node.ssrTransformResult.dynamicDeps.forEach(url => branches.push(add_by_url(url))); + // } + } else { + node.importedModules.forEach(node => branches.push(add(node))); + } + + await Promise.all(branches); +} + +// Vite doesn't expose this so we just copy the list for now +// https://github.com/vitejs/vite/blob/3edd1af56e980aef56641a5a51cf2932bb580d41/packages/vite/src/node/plugins/css.ts#L96 +const STYLE_ASSET_REGEX = /\.(css|less|sass|scss|styl|stylus|pcss|postcss)$/; +const MODULE_STYLE_ASSET_REGEX = + /\.module\.(css|less|sass|scss|styl|stylus|pcss|postcss)$/; + +async function collectStyles(devServer, match) { + const styles = {}; + const deps = new Set(); + try { + for (const file of match) { + const resolvedId = await devServer.pluginContainer.resolveId(file); + + if (!resolvedId) { + console.log('not found'); + continue; + } + + const id = resolvedId.id; + + const normalizedPath = path.resolve(id).replace(/\\/g, '/'); + let node = devServer.moduleGraph.getModuleById(normalizedPath); + if (!node) { + const absolutePath = path.resolve(file); + await devServer.ssrLoadModule(absolutePath); + node = await devServer.moduleGraph.getModuleByUrl(absolutePath); + + if (!node) { + console.log('not found'); + return; + } + } + + await findDeps(devServer, node, deps); + } + } catch (e) { + console.error(e); + } + + for (const dep of deps) { + const parsed = new URL(dep.url, 'http://localhost/'); + const query = parsed.searchParams; + + if (STYLE_ASSET_REGEX.test(dep.file ?? '')) { + try { + const mod = await devServer.ssrLoadModule(dep.url); + // if (module_STYLE_ASSET_REGEX.test(dep.file)) { + // styles[dep.url] = env.cssModules?.[dep.file]; + // } else { + styles[dep.url] = mod.default; + // } + } catch { + // this can happen with dynamically imported modules, I think + // because the Vite module graph doesn't distinguish between + // static and dynamic imports? TODO investigate, submit fix + } + } + } + return styles; +} + +module.exports = {collectStyles}; diff --git a/fixtures/flight-vite/src/App.jsx b/fixtures/flight-vite/src/App.jsx new file mode 100644 index 0000000000000..a4aad822ee3eb --- /dev/null +++ b/fixtures/flight-vite/src/App.jsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import Button from './Button.jsx'; +import Form from './Form.jsx'; +import {like, greet} from './actions.js'; +import {getServerState} from './ServerState.js'; +import {Counter} from './Counter2.jsx'; +import './style.css'; +import {cache, useState} from 'react'; + +const REACT_REFRESH_PREAMBLE = ` + import RefreshRuntime from "/@react-refresh" + RefreshRuntime.injectIntoGlobalHook(window) + window.$RefreshReg$ = () => {} + window.$RefreshSig$ = () => (type) => type + window.__vite_plugin_react_preamble_installed__ = true +`; + +async function Assets() { + const styles = await __vite_find_assets__(['src/App.jsx']); + return ( + <> + {styles.map(key => ( + + ))} + + ); +} + +const data = cache(async () => { + return {foo: 'bar'}; +}); + +export default async function App() { + const res = await fetch('http://localhost:3001/todos'); + const todos = await res.json(); + const cachedData = await data(); + return ( + + + + + Flight + {import.meta.env.DEV ? ( + <> +