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)