diff --git a/.changesets/11684.md b/.changesets/11684.md new file mode 100644 index 000000000000..7ded1a92cf02 --- /dev/null +++ b/.changesets/11684.md @@ -0,0 +1,5 @@ +- feat(rsc): dev server (#11684) by @Tobbe + +✅ Adds support for running `yarn rw dev` with RSC projects +✅ Adds support for fast-refresh for client components +❌ Adds support for HMR for server components diff --git a/__fixtures__/test-project/web/package.json b/__fixtures__/test-project/web/package.json index 453b79a5c5c8..2612c328037a 100644 --- a/__fixtures__/test-project/web/package.json +++ b/__fixtures__/test-project/web/package.json @@ -27,6 +27,6 @@ "postcss": "^8.4.47", "postcss-loader": "^8.1.1", "prettier-plugin-tailwindcss": "^0.5.12", - "tailwindcss": "^3.4.13" + "tailwindcss": "^3.4.14" } } diff --git a/packages/router/ambient.d.ts b/packages/router/ambient.d.ts index 2c7a13488d0e..5bd33020b020 100644 --- a/packages/router/ambient.d.ts +++ b/packages/router/ambient.d.ts @@ -1,8 +1,12 @@ /* eslint-disable no-var */ /// +import type { ViteRuntime } from 'vite/runtime' declare global { var __REDWOOD__PRERENDERING: boolean + var __rwjs__vite_ssr_runtime: ViteRuntime | undefined + var __rwjs__vite_rsc_runtime: ViteRuntime | undefined + /** * URL or absolute path to the GraphQL serverless function, without the trailing slash. * Example: `./redwood/functions/graphql` or `https://api.redwoodjs.com/graphql` diff --git a/packages/router/src/rsc/RscCache.ts b/packages/router/src/rsc/RscCache.ts index 3ebb2ea52600..3fe273946a07 100644 --- a/packages/router/src/rsc/RscCache.ts +++ b/packages/router/src/rsc/RscCache.ts @@ -1,3 +1,15 @@ +class DummyWS { + readyState = WebSocket?.OPEN + addEventListener() {} + send() {} +} + +if (typeof globalThis.WebSocket === 'undefined') { + // @ts-expect-error - We're just trying to make sure WebSocket is defined for + // when Vite analyzes this file during SSR + globalThis.WebSocket = DummyWS +} + export interface RscModel { __rwjs__Routes: [React.ReactElement] __rwjs__rsa_data?: unknown @@ -114,7 +126,7 @@ export class RscCache { } else if (this.sendRetries >= 10) { console.error('Exhausted retries to send message to WebSocket server.') } else { - console.error('WebSocket connection is closed.') + console.error('RscCache: WebSocket connection is closed.') } } diff --git a/packages/router/src/rsc/clientSsr.ts b/packages/router/src/rsc/clientSsr.ts index 2ab04859ed74..17ecf136ef26 100644 --- a/packages/router/src/rsc/clientSsr.ts +++ b/packages/router/src/rsc/clientSsr.ts @@ -3,16 +3,35 @@ import path from 'node:path' import { getPaths } from '@redwoodjs/project-config' import { moduleMap } from './ssrModuleMap.js' -import { importRsdwClient, importRsdwServer, importReact } from './utils.js' +import { importRsdwClient, importReact, importRsdwServer } from './utils.js' import { makeFilePath } from './utils.js' async function getEntries() { + if (globalThis.__rwjs__vite_ssr_runtime) { + return { + serverEntries: { + __rwjs__Routes: '../../src/Routes.tsx', + }, + ssrEntries: {}, + } + } + const entriesPath = getPaths().web.distRscEntries const entries = await import(makeFilePath(entriesPath)) return entries } async function getRoutesComponent(): Promise { + // For SSR during dev + if (globalThis.__rwjs__vite_rsc_runtime) { + const routesMod = await globalThis.__rwjs__vite_rsc_runtime.executeUrl( + getPaths().web.routes, + ) + + return routesMod.default + } + + // For SSR during prod const { serverEntries } = await getEntries() const entryPath = path.join( getPaths().web.distRsc, @@ -69,6 +88,13 @@ function resolveClientEntryForProd( const rscCache = new Map>() +/** + * Render the RW App's Routes.{tsx,jsx} component. + * In production, this function will read the Routes component from the App's + * dist directory. + * During dev, this function will use Vite to load the Routes component from + * the App's src directory. + */ export async function renderRoutesSsr(pathname: string) { console.log('renderRoutesSsr pathname', pathname) @@ -94,7 +120,9 @@ export async function renderRoutesSsr(pathname: string) { // filePath /Users/tobbe/tmp/test-project-rsc-kitchen-sink/web/dist/rsc/assets/rsc-AboutCounter.tsx-1.mjs // name AboutCounter - const id = resolveClientEntryForProd(filePath, clientEntries) + const id = globalThis.__rwjs__vite_ssr_runtime + ? filePath + : resolveClientEntryForProd(filePath, clientEntries) console.log('clientSsr.ts::Proxy id', id) // id /Users/tobbe/tmp/test-project-rsc-kitchen-sink/web/dist/browser/assets/rsc-AboutCounter.tsx-1-4kTKU8GC.mjs @@ -110,7 +138,32 @@ export async function renderRoutesSsr(pathname: string) { // We're in clientSsr.ts, but we're supposed to be pretending we're in the // RSC server "world" and that `stream` comes from `fetch`. So this is us // emulating the reply (stream) you'd get from a fetch call. - const stream = renderToReadableStream(createElement(Routes), bundlerConfig) + const originalStream = renderToReadableStream( + createElement(Routes), + bundlerConfig, + ) + + // Clone and log the stream + const [streamForLogging, streamForRendering] = originalStream.tee() + + // Log the stream content + ;(async () => { + const reader = streamForLogging.getReader() + const decoder = new TextDecoder() + let logContent = '' + + while (true /* eslint-disable-line no-constant-condition */) { + const { done, value } = await reader.read() + + if (done) { + break + } + + logContent += decoder.decode(value, { stream: true }) + } + + console.log('Stream content:', logContent) + })() // We have to do this weird import thing because we need a version of // react-server-dom-webpack/client.edge that uses the same bundled version @@ -120,7 +173,7 @@ export async function renderRoutesSsr(pathname: string) { // Here we use `createFromReadableStream`, which is equivalent to // `createFromFetch` as used in the browser - const data = createFromReadableStream(stream, { + const data = createFromReadableStream(streamForRendering, { ssrManifest: { moduleMap, moduleLoading: null }, }) diff --git a/packages/router/src/rsc/utils.ts b/packages/router/src/rsc/utils.ts index df4724d9ca0d..ead56f519677 100644 --- a/packages/router/src/rsc/utils.ts +++ b/packages/router/src/rsc/utils.ts @@ -16,9 +16,15 @@ export function makeFilePath(path: string) { /** * See vite/streamHelpers.ts. * - * This function ensures we load the same version of rsdw_client to prevent multiple instances of React + * This function ensures we load the bundled version of React to prevent + * multiple instances of React */ export async function importReact() { + if (globalThis.__rwjs__vite_ssr_runtime) { + const reactMod = await import('react') + return reactMod.default + } + const distSsr = getPaths().web.distSsr const reactPath = makeFilePath(path.join(distSsr, '__rwjs__react.mjs')) @@ -28,9 +34,15 @@ export async function importReact() { /** * See vite/streamHelpers.ts. * - * This function ensures we load the same version of rsdw_client to prevent multiple instances of React + * This function ensures we load the same version of rsdw_client everywhere to + * prevent multiple instances of React */ export async function importRsdwClient(): Promise { + if (globalThis.__rwjs__vite_ssr_runtime) { + const rsdwcMod = await import('react-server-dom-webpack/client.edge') + return rsdwcMod.default + } + const distSsr = getPaths().web.distSsr const rsdwClientPath = makeFilePath( path.join(distSsr, '__rwjs__rsdw-client.mjs'), @@ -40,14 +52,22 @@ export async function importRsdwClient(): Promise { } export async function importRsdwServer(): Promise { - // We need to do this weird import dance because we need to import a version - // of react-server-dom-webpack/server.edge that has been built with the - // `react-server` condition. If we just did a regular import, we'd get the - // generic version in node_modules, and it'd throw an error about not being - // run in an environment with the `react-server` condition. - const dynamicImport = '' - return import( - /* @vite-ignore */ - dynamicImport + 'react-server-dom-webpack/server.edge' - ) + if (globalThis.__rwjs__vite_rsc_runtime) { + const rsdwServerMod = await globalThis.__rwjs__vite_rsc_runtime.executeUrl( + 'react-server-dom-webpack/server.edge', + ) + + return rsdwServerMod.default + } else { + // We need to do this weird import dance because we need to import a version + // of react-server-dom-webpack/server.edge that has been built with the + // `react-server` condition. If we just did a regular import, we'd get the + // generic version in node_modules, and it'd throw an error about not being + // run in an environment with the `react-server` condition. + const dynamicImport = '' + return import( + /* @vite-ignore */ + dynamicImport + 'react-server-dom-webpack/server.edge' + ) + } } diff --git a/packages/vite/ambient.d.ts b/packages/vite/ambient.d.ts index 75ed8e8fed2c..0766153b7e33 100644 --- a/packages/vite/ambient.d.ts +++ b/packages/vite/ambient.d.ts @@ -1,6 +1,7 @@ /* eslint-disable no-var */ /// import type { HelmetServerState } from 'react-helmet-async' +import type { ViteRuntime } from 'vite/runtime' declare global { // Provided by Vite.config @@ -23,6 +24,9 @@ declare global { } var __REDWOOD__PRERENDER_PAGES: any + var __rwjs__vite_ssr_runtime: ViteRuntime | undefined + var __rwjs__vite_rsc_runtime: ViteRuntime | undefined + var __rwjs__client_references: Set | undefined var __REDWOOD__HELMET_CONTEXT: { helmet?: HelmetServerState } diff --git a/packages/vite/src/devFeServer.ts b/packages/vite/src/devFeServer.ts index 1c66c23679c0..51e0b826a251 100644 --- a/packages/vite/src/devFeServer.ts +++ b/packages/vite/src/devFeServer.ts @@ -1,8 +1,10 @@ +import http from 'node:http' + import { createServerAdapter } from '@whatwg-node/server' import express from 'express' import type { HTTPMethod } from 'find-my-way' import type { ViteDevServer } from 'vite' -import { createServer as createViteServer } from 'vite' +import { createServer as createViteServer, createViteRuntime } from 'vite' import { cjsInterop } from 'vite-plugin-cjs-interop' import type { RouteSpec } from '@redwoodjs/internal/dist/routes.js' @@ -19,6 +21,9 @@ import { registerFwGlobalsAndShims } from './lib/registerFwGlobalsAndShims.js' import { invoke } from './middleware/invokeMiddleware.js' import { createMiddlewareRouter } from './middleware/register.js' import { rscRoutesAutoLoader } from './plugins/vite-plugin-rsc-routes-auto-loader.js' +import { rscRoutesImports } from './plugins/vite-plugin-rsc-routes-imports.js' +import { rscSsrRouterImport } from './plugins/vite-plugin-rsc-ssr-router-import.js' +import { createWebSocketServer } from './rsc/rscWebSocketServer.js' import { collectCssPaths, componentsModules } from './streaming/collectCss.js' import { createReactStreamingHandler } from './streaming/createReactStreamingHandler.js' import { @@ -29,6 +34,8 @@ import { // TODO (STREAMING) Just so it doesn't error out. Not sure how to handle this. globalThis.__REDWOOD__PRERENDER_PAGES = {} +globalThis.__rwjs__vite_ssr_runtime = undefined +globalThis.__rwjs__vite_rsc_runtime = undefined async function createServer() { ensureProcessDirWeb() @@ -36,6 +43,11 @@ async function createServer() { registerFwGlobalsAndShims() const app = express() + // We do this to have a server to pass to Vite's HMR functionality, to be + // able to pass it along to the express server, to get WS support all the way + // through. (This is not currently implemented, this is just in preparation) + // TODO (RSC): Figure out all the HMR stuff + const server = http.createServer(app) const rwPaths = getPaths() const rscEnabled = getConfig().experimental.rsc?.enabled ?? false @@ -81,6 +93,74 @@ async function createServer() { // can take control const vite = await createViteServer({ configFile: rwPaths.web.viteConfig, + envFile: false, + define: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), + }, + ssr: { + // Inline every file apart from node built-ins. We want vite/rollup to + // inline dependencies in the server build. This gets round runtime + // importing of "server-only" and other packages with poisoned imports. + // + // Files included in `noExternal` are files we want Vite to analyze + // As of vite 5.2 `true` here means "all except node built-ins" + // noExternal: true, + // TODO (RSC): Other frameworks build for RSC without `noExternal: true`. + // What are we missing here? When/why is that a better choice? I know + // we would have to explicitly add a bunch of packages to noExternal, if + // we wanted to go that route. + // noExternal: ['@tobbe.dev/rsc-test'], + // Can't inline prisma client (db calls fail at runtime) or react-dom + // (css pre-init failure) + // Server store has to be externalized, because it's a singleton (shared between FW and App) + external: [ + '@prisma/client', + '@prisma/fetch-engine', + '@prisma/internals', + '@redwoodjs/auth-dbauth-api', + '@redwoodjs/cookie-jar', + '@redwoodjs/server-store', + '@simplewebauthn/server', + 'graphql-scalars', + 'minimatch', + 'playwright', + 'react-dom', + ], + resolve: { + // These conditions are used in the plugin pipeline, and only affect non-externalized + // dependencies during the SSR build. Which because of `noExternal: true` means all + // dependencies apart from node built-ins. + // TODO (RSC): What's the difference between `conditions` and + // `externalConditions`? When is one used over the other? + // conditions: ['react-server'], + // externalConditions: ['react-server'], + }, + optimizeDeps: { + // We need Vite to optimize these dependencies so that they are resolved + // with the correct conditions. And so that CJS modules work correctly. + // include: [ + // 'react/**/*', + // 'react-dom/server', + // 'react-dom/server.edge', + // 'rehackt', + // 'react-server-dom-webpack/server', + // 'react-server-dom-webpack/client', + // '@apollo/client/cache/*', + // '@apollo/client/utilities/*', + // '@apollo/client/react/hooks/*', + // 'react-fast-compare', + // 'invariant', + // 'shallowequal', + // 'graphql/language/*', + // 'stacktracey', + // 'deepmerge', + // 'fast-glob', + // ], + }, + }, + resolve: { + // conditions: ['react-server'], + }, plugins: [ cjsInterop({ dependencies: [ @@ -92,6 +172,7 @@ async function createServer() { ], }), rscEnabled && rscRoutesAutoLoader(), + rscEnabled && rscSsrRouterImport(), ], server: { middlewareMode: true }, logLevel: 'info', @@ -99,6 +180,176 @@ async function createServer() { appType: 'custom', }) + globalThis.__rwjs__vite_ssr_runtime = await createViteRuntime(vite) + globalThis.__rwjs__client_references = new Set() + + // const clientEntryFileSet = new Set() + // const serverEntryFileSet = new Set() + + // TODO (RSC): No redwood-vite plugin, add it in here + const viteRscServer = await createViteServer({ + envFile: false, + define: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), + }, + ssr: { + // Inline every file apart from node built-ins. We want vite/rollup to + // inline dependencies in the server build. This gets round runtime + // importing of "server-only" and other packages with poisoned imports. + // + // Files included in `noExternal` are files we want Vite to analyze + // As of vite 5.2 `true` here means "all except node built-ins" + noExternal: true, + // TODO (RSC): Other frameworks build for RSC without `noExternal: true`. + // What are we missing here? When/why is that a better choice? I know + // we would have to explicitly add a bunch of packages to noExternal, if + // we wanted to go that route. + // noExternal: ['@tobbe.dev/rsc-test'], + // Can't inline prisma client (db calls fail at runtime) or react-dom + // (css pre-init failure) + // Server store has to be externalized, because it's a singleton (shared between FW and App) + external: [ + '@prisma/client', + '@prisma/fetch-engine', + '@prisma/internals', + '@redwoodjs/auth-dbauth-api', + '@redwoodjs/cookie-jar', + '@redwoodjs/server-store', + '@redwoodjs/structure', + '@simplewebauthn/server', + 'graphql-scalars', + 'minimatch', + 'playwright', + 'react-dom', + ], + resolve: { + // These conditions are used in the plugin pipeline, and only affect non-externalized + // dependencies during the SSR build. Which because of `noExternal: true` means all + // dependencies apart from node built-ins. + // TODO (RSC): What's the difference between `conditions` and + // `externalConditions`? When is one used over the other? + conditions: ['react-server'], + externalConditions: ['react-server'], + }, + optimizeDeps: { + // We need Vite to optimize these dependencies so that they are resolved + // with the correct conditions. And so that CJS modules work correctly. + include: [ + 'react/**/*', + 'react-dom/server', + 'react-dom/server.edge', + 'rehackt', + 'react-server-dom-webpack/server', + 'react-server-dom-webpack/server.edge', + 'react-server-dom-webpack/client', + 'react-server-dom-webpack/client.edge', + '@apollo/client/cache/*', + '@apollo/client/utilities/*', + '@apollo/client/react/hooks/*', + 'react-fast-compare', + 'invariant', + 'shallowequal', + 'graphql/language/*', + 'stacktracey', + 'deepmerge', + 'fast-glob', + '@whatwg-node/fetch', + 'busboy', + 'cookie', + ], + // exclude: ['webpack'] + }, + }, + resolve: { + conditions: ['react-server'], + }, + plugins: [ + { + name: 'rsc-record-and-tranform-use-client-plugin', + transform(code, id, _options) { + // TODO (RSC): We need to make sure this `id` always matches what + // vite uses + globalThis.__rwjs__client_references?.delete(id) + + if (/^(["'])use client\1/.test(code)) { + console.log( + 'rsc-record-and-transform-use-client-plugin: ' + + 'adding client reference', + id, + ) + globalThis.__rwjs__client_references?.add(id) + + // TODO (RSC): Proper AST parsing would be more robust than simple + // regex matching. But this is a quick and dirty way to get started + const fns = code.matchAll(/export function (\w+)\(/g) + const consts = code.matchAll(/export const (\w+) = \(/g) + const names = [...fns, ...consts].map(([, name]) => name) + + const result = [ + `import { registerClientReference } from "react-server-dom-webpack/server.edge";`, + '', + ...names.map((name) => { + return name === 'default' + ? `export default registerClientReference({}, "${id}", "${name}");` + : `export const ${name} = registerClientReference({}, "${id}", "${name}");` + }), + ].join('\n') + + console.log('rsc-record-and-transform-use-client-plugin result') + console.log( + result + .split('\n') + .map((line, i) => ` ${i + 1}: ${line}`) + .join('\n'), + ) + + return { code: result, map: null } + } + + return undefined + }, + }, + + // The rscTransformUseClientPlugin maps paths like + // /Users/tobbe/.../rw-app/node_modules/@tobbe.dev/rsc-test/dist/rsc-test.es.js + // to + // /Users/tobbe/.../rw-app/web/dist/ssr/assets/rsc0.js + // That's why it needs the `clientEntryFiles` data + // (It does other things as well, but that's why it needs clientEntryFiles) + // rscTransformUseClientPlugin(clientEntryFiles), + // rscTransformUseServerPlugin(outDir, serverEntryFiles), + rscRoutesImports(), + { + name: 'rsc-hot-update', + handleHotUpdate(ctx) { + console.log('rsc-hot-update ctx.modules', ctx.modules) + return [] + }, + }, + ], + build: { + ssr: true, + }, + server: { + // We never call `viteRscServer.listen()`, so we should run this in + // middleware mode + middlewareMode: true, + // The hmr/fast-refresh websocket in this server collides with the one in + // the other Vite server. So we can either disable it or run it on a + // different port. + // TODO (RSC): Figure out if we should disable or just pick a different + // port + ws: false, + // hmr: { + // port: 24679, + // }, + }, + appType: 'custom', + cacheDir: './node_modules/.vite-rsc', + }) + + globalThis.__rwjs__vite_rsc_runtime = await createViteRuntime(viteRscServer) + // create a handler that will invoke middleware with or without a route // The DEV one will create a new middleware router on each request const handleWithMiddleware = (route?: RouteSpec) => { @@ -127,13 +378,17 @@ async function createServer() { app.use(vite.middlewares) if (rscEnabled) { - const { createRscRequestHandler } = await import( - './rsc/rscRequestHandler.js' - ) + createWebSocketServer() + + const { createRscRequestHandler } = + await globalThis.__rwjs__vite_rsc_runtime.executeUrl( + new URL('./rsc/rscRequestHandler.js', import.meta.url).pathname, + ) + // Mounting middleware at /rw-rsc will strip /rw-rsc from req.url app.use( '/rw-rsc', - createRscRequestHandler({ + await createRscRequestHandler({ getMiddlewareRouter: async () => createMiddlewareRouter(vite), viteDevServer: vite, }), @@ -163,7 +418,7 @@ async function createServer() { const port = getConfig().web.port console.log(`Started server on http://localhost:${port}`) - return app.listen(port) + return server.listen(port) } let devApp = createServer() diff --git a/packages/vite/src/lib/registerFwGlobalsAndShims.ts b/packages/vite/src/lib/registerFwGlobalsAndShims.ts index 86bbc60ed663..4644e5473722 100644 --- a/packages/vite/src/lib/registerFwGlobalsAndShims.ts +++ b/packages/vite/src/lib/registerFwGlobalsAndShims.ts @@ -110,6 +110,22 @@ function registerFwShims() { globalThis.__webpack_chunk_load__ ||= async (id: string) => { console.log('registerFwShims chunk load id', id) + + if (globalThis.__rwjs__vite_ssr_runtime) { + return globalThis.__rwjs__vite_ssr_runtime?.executeUrl(id).then((mod) => { + console.log('registerFwShims chunk load mod', mod) + + // checking m.default to better support CJS. If it's an object, it's + // likely a CJS module. Otherwise it's probably an ES module with a + // default export + if (mod.default && typeof mod.default === 'object') { + return globalThis.__rw_module_cache__.set(id, mod.default) + } + + return globalThis.__rw_module_cache__.set(id, mod) + }) + } + return import(id).then((mod) => { console.log('registerFwShims chunk load mod', mod) diff --git a/packages/vite/src/plugins/vite-plugin-rsc-ssr-router-import.ts b/packages/vite/src/plugins/vite-plugin-rsc-ssr-router-import.ts index d7793cd2b0dc..52afc7cf95c0 100644 --- a/packages/vite/src/plugins/vite-plugin-rsc-ssr-router-import.ts +++ b/packages/vite/src/plugins/vite-plugin-rsc-ssr-router-import.ts @@ -23,9 +23,9 @@ export function rscSsrRouterImport(): Plugin { return { name: 'rsc-ssr-router-import', - transform: async function (code, id) { + transform: async function (code, id, options) { // We only care about the routes file - if (id !== routesFileId) { + if (!options?.ssr || id !== routesFileId) { return null } @@ -50,6 +50,7 @@ export function rscSsrRouterImport(): Plugin { } }, }) + return generate(ast).code }, } diff --git a/packages/vite/src/rsc/rscRenderer.ts b/packages/vite/src/rsc/rscRenderer.ts index b72f91ea3cfe..5b026a423653 100644 --- a/packages/vite/src/rsc/rscRenderer.ts +++ b/packages/vite/src/rsc/rscRenderer.ts @@ -1,15 +1,13 @@ import path from 'node:path' import type { ReadableStream } from 'node:stream/web' -import { createElement } from 'react' - -import { renderToReadableStream } from 'react-server-dom-webpack/server.edge' - import { getPaths } from '@redwoodjs/project-config' import { getEntriesFromDist } from '../lib/entries.js' import { StatusError } from '../lib/StatusError.js' +import { importRscReact, importRsdwServer } from './utils.js' + export type RenderInput = { rscId?: string | undefined rsaId?: string | undefined @@ -18,7 +16,9 @@ export type RenderInput = { let absoluteClientEntries: Record = {} -export function renderRscToStream(input: RenderInput): Promise { +export async function renderRscToStream( + input: RenderInput, +): Promise { return input.rscId ? renderRsc(input) : executeRsa(input) } @@ -28,6 +28,15 @@ async function loadServerFile(filePath: string) { } const getRoutesComponent: any = async () => { + if (globalThis.__rwjs__vite_rsc_runtime) { + const routesPath = getPaths().web.routes + + const routesMod = + await globalThis.__rwjs__vite_rsc_runtime.executeUrl(routesPath) + + return routesMod.default + } + const serverEntries = await getEntriesFromDist() console.log('rscRenderer.ts serverEntries', serverEntries) @@ -82,13 +91,17 @@ function getBundlerConfig() { {}, { get(_target, encodedId: string) { - console.log('Proxy get encodedId', encodedId) + console.log('rscRenderer.ts Proxy get encodedId', encodedId) const [filePath, name] = encodedId.split('#') as [string, string] - // filePath /Users/tobbe/dev/waku/examples/01_counter/dist/assets/rsc0.js + console.log('filePath', filePath) + console.log('name', name) + // filePath /Users/tobbe/tmp/rw-rsc-status/web/src/components/Counter/Counter.tsx // name Counter const filePathSlash = filePath.replaceAll('\\', '/') - const id = absoluteClientEntries[filePathSlash] + const id = globalThis.__rwjs__vite_rsc_runtime + ? filePath + : absoluteClientEntries[filePathSlash] console.log('absoluteClientEntries', absoluteClientEntries) console.log('filePath', filePathSlash) @@ -125,6 +138,12 @@ async function renderRsc(input: RenderInput): Promise { console.log('renderRsc input', input) + // TODO (RSC): This is currently duplicated in executeRsa. The importing + // should be moved to the top of `createRscRequestHandler` so we only have to + // do it once. Then just pass the imported functions down to where they're + // used + const { createElement } = await importRscReact() + const { renderToReadableStream } = await importRsdwServer() const serverRoutes = await getRoutesComponent() const model: RscModel = { __rwjs__Routes: createElement(serverRoutes), @@ -184,6 +203,13 @@ async function executeRsa(input: RenderInput): Promise { const data = await method(...input.args) console.log('rscRenderer.ts rsa return data', data) + // TODO (RSC): This is currently duplicated in renderRsc. See further comments + // there. Do we also need to use the importXyz() helper methods here? + const { createElement } = await import('react') + const { renderToReadableStream } = await import( + 'react-server-dom-webpack/server.edge' + ) + const serverRoutes = await getRoutesComponent() console.log('rscRenderer.ts executeRsa serverRoutes', serverRoutes) const model: RscModel = { diff --git a/packages/vite/src/rsc/rscRequestHandler.ts b/packages/vite/src/rsc/rscRequestHandler.ts index 43b26eaf5db3..1d36236195d2 100644 --- a/packages/vite/src/rsc/rscRequestHandler.ts +++ b/packages/vite/src/rsc/rscRequestHandler.ts @@ -20,9 +20,6 @@ import { import { hasStatusCode } from '../lib/StatusError.js' import { invoke } from '../middleware/invokeMiddleware.js' -import { renderRscToStream } from './rscRenderer.js' -import { sendRscFlightToStudio } from './rscStudioHandlers.js' - const BASE_PATH = '/rw-rsc/' interface CreateRscRequestHandlerOptions { @@ -30,11 +27,14 @@ interface CreateRscRequestHandlerOptions { viteDevServer?: ViteDevServer } -export function createRscRequestHandler( +export async function createRscRequestHandler( options: CreateRscRequestHandlerOptions, ) { // This is mounted at /rw-rsc, so will have /rw-rsc stripped from req.url + const { renderRscToStream } = await import('./rscRenderer.js') + const { sendRscFlightToStudio } = await import('./rscStudioHandlers.js') + // TODO (RSC): Switch from Express to Web compatible Request and Response return async ( req: ExpressRequest, diff --git a/packages/vite/src/rsc/rscWebSocketServer.ts b/packages/vite/src/rsc/rscWebSocketServer.ts new file mode 100644 index 000000000000..7ac340f4ad19 --- /dev/null +++ b/packages/vite/src/rsc/rscWebSocketServer.ts @@ -0,0 +1,27 @@ +import WebSocket, { WebSocketServer } from 'ws' + +export function createWebSocketServer() { + const wsServer = new WebSocketServer({ port: 18998 }) + + wsServer.on('connection', (ws) => { + console.log('A new client connected.') + + // Event listener for incoming messages. The `data` is a Buffer + ws.on('message', (data) => { + const message = data.toString() + console.log('Received message:', message) + + // Broadcast the message to all connected clients + wsServer.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(message) + } + }) + }) + + // Event listener for client disconnection + ws.on('close', () => { + console.log('A client disconnected.') + }) + }) +} diff --git a/packages/vite/src/rsc/utils.ts b/packages/vite/src/rsc/utils.ts new file mode 100644 index 000000000000..a017f8e9a42d --- /dev/null +++ b/packages/vite/src/rsc/utils.ts @@ -0,0 +1,29 @@ +import type { default as RSDWServerModule } from 'react-server-dom-webpack/server.edge' + +type RSDWServerType = typeof RSDWServerModule + +/** + * This function ensures we load the version of React that's been imported with + * the react-server condition. + */ +export async function importRscReact() { + if (globalThis.__rwjs__vite_rsc_runtime) { + const reactMod = + await globalThis.__rwjs__vite_rsc_runtime.executeUrl('react') + return reactMod.default + } + + return import('react') +} + +export async function importRsdwServer(): Promise { + if (globalThis.__rwjs__vite_rsc_runtime) { + const rsdwServerMod = await globalThis.__rwjs__vite_rsc_runtime.executeUrl( + 'react-server-dom-webpack/server.edge', + ) + + return rsdwServerMod.default + } else { + return import('react-server-dom-webpack/server.edge') + } +} diff --git a/packages/vite/src/runFeServer.ts b/packages/vite/src/runFeServer.ts index 509ed5954d0d..1676d901c92b 100644 --- a/packages/vite/src/runFeServer.ts +++ b/packages/vite/src/runFeServer.ts @@ -14,7 +14,6 @@ import express from 'express' import type { HTTPMethod } from 'find-my-way' import { createProxyMiddleware } from 'http-proxy-middleware' import type { Manifest as ViteBuildManifest } from 'vite' -import WebSocket, { WebSocketServer } from 'ws' import { getConfig, getPaths } from '@redwoodjs/project-config' import { getRscStylesheetLinkGenerator } from '@redwoodjs/router/rscCss' @@ -27,6 +26,7 @@ import type { Middleware } from '@redwoodjs/web/dist/server/middleware' import { registerFwGlobalsAndShims } from './lib/registerFwGlobalsAndShims.js' import { invoke } from './middleware/invokeMiddleware.js' import { createMiddlewareRouter } from './middleware/register.js' +import { createWebSocketServer } from './rsc/rscWebSocketServer.js' import { createReactStreamingHandler } from './streaming/createReactStreamingHandler.js' import type { RWRouteManifest } from './types.js' import { convertExpressHeaders, getFullUrl } from './utils.js' @@ -169,7 +169,7 @@ export async function runFeServer() { // Mounting middleware at /rw-rsc will strip /rw-rsc from req.url app.use( '/rw-rsc', - createRscRequestHandler({ + await createRscRequestHandler({ getMiddlewareRouter: async () => middlewareRouter, }), ) @@ -203,31 +203,4 @@ export async function runFeServer() { ) } -function createWebSocketServer() { - const wsServer = new WebSocketServer({ port: 18998 }) - - wsServer.on('connection', (ws) => { - console.log('A new client connected.') - - // Event listener for incoming messages. The `data` is a Buffer - ws.on('message', (data) => { - const message = data.toString() - console.log('runFeServer.ts: Received message:') - console.log(message.slice(0, 120) + '...') - - // Broadcast the message to all connected clients - wsServer.clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(message) - } - }) - }) - - // Event listener for client disconnection - ws.on('close', () => { - console.log('A client disconnected.') - }) - }) -} - runFeServer() diff --git a/packages/vite/src/streaming/createReactStreamingHandler.ts b/packages/vite/src/streaming/createReactStreamingHandler.ts index cc9ed6a2fa73..a0f0dcfe4b36 100644 --- a/packages/vite/src/streaming/createReactStreamingHandler.ts +++ b/packages/vite/src/streaming/createReactStreamingHandler.ts @@ -208,6 +208,7 @@ export const createReactStreamingHandler = async ( console.error(err) }, }, + viteDevServer, ) return reactResponse diff --git a/packages/vite/src/streaming/streamHelpers.ts b/packages/vite/src/streaming/streamHelpers.ts index fcc9abce7608..42c3c879f06c 100644 --- a/packages/vite/src/streaming/streamHelpers.ts +++ b/packages/vite/src/streaming/streamHelpers.ts @@ -7,6 +7,7 @@ import type { ReactDOMServerReadableStream, } from 'react-dom/server' import type { default as RDServerModule } from 'react-dom/server.edge' +import type { ViteDevServer } from 'vite' import type { ServerAuthState } from '@redwoodjs/auth/dist/AuthProvider/ServerAuthProvider.js' import type * as ServerAuthProviderModule from '@redwoodjs/auth/dist/AuthProvider/ServerAuthProvider.js' @@ -73,6 +74,7 @@ export async function reactRenderToStreamResponse( mwRes: MiddlewareResponse, renderOptions: RenderToStreamArgs, streamOptions: StreamOptions, + viteDevServer?: ViteDevServer, ) { const { waitForAllReady = false } = streamOptions const { @@ -102,7 +104,7 @@ export async function reactRenderToStreamResponse( const rscEnabled = getConfig().experimental?.rsc?.enabled const { createElement }: React = rscEnabled - ? await importModule('__rwjs__react') + ? await importModule('__rwjs__react', !!viteDevServer) : await import('react') const { @@ -110,10 +112,10 @@ export async function reactRenderToStreamResponse( ServerHtmlProvider, ServerInjectedHtml, }: ServerInjectType = rscEnabled - ? await importModule('__rwjs__server_inject') + ? await importModule('__rwjs__server_inject', !!viteDevServer) : await import('@redwoodjs/web/serverInject') const { renderToString }: RDServerType = rscEnabled - ? await importModule('rd-server') + ? await importModule('rd-server', !!viteDevServer) : await import('react-dom/server') // This ensures an isolated state for each request @@ -141,10 +143,10 @@ export async function reactRenderToStreamResponse( const timeoutTransform = createTimeoutTransform(timeoutHandle) const { ServerAuthProvider }: ServerAuthProviderType = rscEnabled - ? await importModule('__rwjs__server_auth_provider') + ? await importModule('__rwjs__server_auth_provider', !!viteDevServer) : await import('@redwoodjs/auth/dist/AuthProvider/ServerAuthProvider.js') const { LocationProvider }: LocationType = rscEnabled - ? await importModule('__rwjs__location') + ? await importModule('__rwjs__location', !!viteDevServer) : await import('@redwoodjs/router/location') const renderRoot = (url: URL) => { @@ -190,13 +192,13 @@ export async function reactRenderToStreamResponse( // modules (components) will later use when they render. Had we just imported // `react-dom/server.edge` normally we would have gotten an instance based on // react and react-dom in node_modules. All client components however uses a - // bundled version of React (so that we can have one version of react without + // bundled version of React (so that we can have one version of react with // the react-server condition and one without at the same time). Importing it // like this we make sure that SSR uses that same bundled version of react // and react-dom as the components. // TODO (RSC): Always import using importModule when RSC is on by default const { renderToReadableStream }: RDServerType = rscEnabled - ? await importModule('rd-server') + ? await importModule('rd-server', !!viteDevServer) : await import('react-dom/server.edge') try { @@ -291,36 +293,60 @@ function applyStreamTransforms( // React. But the app itself already uses the bundled version of React, so we // can't do that, because then we'd have to different Reacts where one isn't // initialized properly -export async function importModule( +async function importModule( mod: | 'rd-server' | '__rwjs__react' | '__rwjs__location' | '__rwjs__server_auth_provider' | '__rwjs__server_inject', + isDev?: boolean, ) { - const distSsr = getPaths().web.distSsr - const rdServerPath = makeFilePath(path.join(distSsr, 'rd-server.mjs')) - const reactPath = makeFilePath(path.join(distSsr, '__rwjs__react.mjs')) - const locationPath = makeFilePath(path.join(distSsr, '__rwjs__location.mjs')) - const ServerAuthProviderPath = makeFilePath( - path.join(distSsr, '__rwjs__server_auth_provider.mjs'), - ) - const ServerInjectPath = makeFilePath( - path.join(distSsr, '__rwjs__server_inject.mjs'), - ) - - if (mod === 'rd-server') { - return (await import(rdServerPath)).default - } else if (mod === '__rwjs__react') { - return (await import(reactPath)).default - } else if (mod === '__rwjs__location') { - return await import(locationPath) - } else if (mod === '__rwjs__server_auth_provider') { - return await import(ServerAuthProviderPath) - } else if (mod === '__rwjs__server_inject') { - // Don't need default because rwjs/web is now ESM - return await import(ServerInjectPath) + if (isDev) { + if (mod === 'rd-server') { + const loadedMod = await import('react-dom/server.edge') + return loadedMod.default + } else if (mod === '__rwjs__react') { + const loadedMod = await import('react') + return loadedMod.default + } else if (mod === '__rwjs__location') { + const loadedMod = await import('@redwoodjs/router/location') + return loadedMod + } else if (mod === '__rwjs__server_auth_provider') { + const loadedMod = await import( + '@redwoodjs/auth/dist/AuthProvider/ServerAuthProvider.js' + ) + return loadedMod + } else if (mod === '__rwjs__server_inject') { + const loadedMod = await import('@redwoodjs/web/serverInject') + return loadedMod + } + } else { + const distSsr = getPaths().web.distSsr + const rdServerPath = makeFilePath(path.join(distSsr, 'rd-server.mjs')) + const reactPath = makeFilePath(path.join(distSsr, '__rwjs__react.mjs')) + const locationPath = makeFilePath( + path.join(distSsr, '__rwjs__location.mjs'), + ) + const serverAuthProviderPath = makeFilePath( + path.join(distSsr, '__rwjs__server_auth_provider.mjs'), + ) + const serverInjectPath = makeFilePath( + path.join(distSsr, '__rwjs__server_inject.mjs'), + ) + + if (mod === 'rd-server') { + return (await import(rdServerPath)).default + } else if (mod === '__rwjs__react') { + return (await import(reactPath)).default + } else if (mod === '__rwjs__location') { + return await import(locationPath) + } else if (mod === '__rwjs__server_auth_provider') { + return await import(serverAuthProviderPath) + } else if (mod === '__rwjs__server_inject') { + // Don't need default because rwjs/web is now ESM + return await import(serverInjectPath) + } } throw new Error('Unknown module ' + mod)