Skip to content

Commit

Permalink
feat(rsc): dev server (#11684)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tobbe authored Oct 22, 2024
1 parent 9b11928 commit 9d03faa
Show file tree
Hide file tree
Showing 16 changed files with 548 additions and 96 deletions.
5 changes: 5 additions & 0 deletions .changesets/11684.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions packages/router/ambient.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
/* eslint-disable no-var */
/// <reference types="react/experimental" />
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`
Expand Down
14 changes: 13 additions & 1 deletion packages/router/src/rsc/RscCache.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.')
}
}

Expand Down
61 changes: 57 additions & 4 deletions packages/router/src/rsc/clientSsr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.FunctionComponent> {
// 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,
Expand Down Expand Up @@ -69,6 +88,13 @@ function resolveClientEntryForProd(

const rscCache = new Map<string, Thenable<React.ReactElement>>()

/**
* 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)

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

Expand Down
44 changes: 32 additions & 12 deletions packages/router/src/rsc/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))

Expand All @@ -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<RSDWClientType> {
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'),
Expand All @@ -40,14 +52,22 @@ export async function importRsdwClient(): Promise<RSDWClientType> {
}

export async function importRsdwServer(): Promise<RSDWServerType> {
// 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'
)
}
}
4 changes: 4 additions & 0 deletions packages/vite/ambient.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable no-var */
/// <reference types="react/experimental" />
import type { HelmetServerState } from 'react-helmet-async'
import type { ViteRuntime } from 'vite/runtime'

declare global {
// Provided by Vite.config
Expand All @@ -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<string> | undefined

var __REDWOOD__HELMET_CONTEXT: { helmet?: HelmetServerState }

Expand Down
Loading

0 comments on commit 9d03faa

Please sign in to comment.