diff --git a/packages/vite/ambient.d.ts b/packages/vite/ambient.d.ts
index 3814acb97c3d..64c58d8e3a00 100644
--- a/packages/vite/ambient.d.ts
+++ b/packages/vite/ambient.d.ts
@@ -1,4 +1,5 @@
/* eslint-disable no-var */
+///
declare global {
var RWJS_ENV: {
diff --git a/packages/vite/bins/rw-vite-build.mjs b/packages/vite/bins/rw-vite-build.mjs
index c4380ada75ed..158e57b75905 100755
--- a/packages/vite/bins/rw-vite-build.mjs
+++ b/packages/vite/bins/rw-vite-build.mjs
@@ -4,7 +4,7 @@ import yargsParser from 'yargs-parser'
import { buildWeb } from '@redwoodjs/internal/dist/build/web.js'
import { getConfig, getPaths } from '@redwoodjs/project-config'
-import { buildFeServer } from '@redwoodjs/vite/dist/buildFeServer.js'
+import { buildFeServer } from '@redwoodjs/vite/buildFeServer'
const rwPaths = getPaths()
diff --git a/packages/vite/modules.d.ts b/packages/vite/modules.d.ts
new file mode 100644
index 000000000000..d10c6a02532b
--- /dev/null
+++ b/packages/vite/modules.d.ts
@@ -0,0 +1,5 @@
+declare module 'react-server-dom-webpack/node-loader'
+declare module 'react-server-dom-webpack/server'
+declare module 'react-server-dom-webpack/server.node.unbundled'
+declare module 'react-server-dom-webpack/client'
+declare module 'acorn-loose'
diff --git a/packages/vite/package.json b/packages/vite/package.json
index 25b8b9dfdc18..20dcd56c379e 100644
--- a/packages/vite/package.json
+++ b/packages/vite/package.json
@@ -12,7 +12,21 @@
"dist",
"inject"
],
- "main": "dist/index.js",
+ "exports": {
+ "./package.json": "./package.json",
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./client": {
+ "types": "./dist/client.d.ts",
+ "default": "./dist/client.js"
+ },
+ "./buildFeServer": {
+ "types": "./dist/buildFeServer.d.ts",
+ "default": "./dist/buildFeServer.js"
+ }
+ },
"bin": {
"rw-dev-fe": "./dist/devFeServer.js",
"rw-serve-fe": "./dist/runFeServer.js",
@@ -33,13 +47,17 @@
"@redwoodjs/internal": "5.0.0",
"@redwoodjs/project-config": "5.0.0",
"@redwoodjs/web": "5.0.0",
+ "@swc/core": "1.3.60",
"@vitejs/plugin-react": "4.0.1",
+ "acorn-loose": "^8.3.0",
"buffer": "6.0.3",
"core-js": "3.31.0",
"dotenv-defaults": "5.0.2",
"express": "4.18.2",
"http-proxy-middleware": "2.0.6",
"isbot": "3.6.8",
+ "react": "18.3.0-canary-035a41c4e-20230704",
+ "react-server-dom-webpack": "18.3.0-canary-035a41c4e-20230704",
"vite": "4.3.9",
"vite-plugin-environment": "1.1.3",
"yargs-parser": "21.1.1"
@@ -47,6 +65,7 @@
"devDependencies": {
"@babel/cli": "7.22.5",
"@types/express": "4",
+ "@types/react": "18.2.14",
"@types/yargs-parser": "21.0.0",
"glob": "10.3.1",
"jest": "29.5.0",
diff --git a/packages/vite/src/buildRscFeServer.ts b/packages/vite/src/buildRscFeServer.ts
new file mode 100644
index 000000000000..4a42d3ef783f
--- /dev/null
+++ b/packages/vite/src/buildRscFeServer.ts
@@ -0,0 +1,327 @@
+import fs from 'fs/promises'
+import path from 'path'
+
+import react from '@vitejs/plugin-react'
+import { build as viteBuild } from 'vite'
+import type { Manifest as ViteBuildManifest } from 'vite'
+
+import { RouteSpec } from '@redwoodjs/internal/dist/routes'
+import { getAppRouteHook, getPaths } from '@redwoodjs/project-config'
+
+import { RWRouteManifest } from './types'
+import { serverBuild } from './waku-lib/build-server'
+import { rscAnalyzePlugin, rscIndexPlugin } from './waku-lib/vite-plugin-rsc'
+
+interface BuildOptions {
+ verbose?: boolean
+}
+
+export const buildFeServer = async ({ verbose: _verbose }: BuildOptions) => {
+ const rwPaths = getPaths()
+
+ const clientEntryFileSet = new Set()
+ const serverEntryFileSet = new Set()
+
+ /**
+ * RSC build
+ * Uses rscAnalyzePlugin to collect client and server entry points
+ * Starts building the AST in entries.ts
+ * Doesn't output any files, only collects a list of RSCs and RSFs
+ */
+ await viteBuild({
+ // ...configFileConfig,
+ root: rwPaths.base,
+ plugins: [
+ react(),
+ {
+ name: 'rsc-test-plugin',
+ transform(_code, id) {
+ console.log('rsc-test-plugin id', id)
+ },
+ },
+ rscAnalyzePlugin(
+ (id) => clientEntryFileSet.add(id),
+ (id) => serverEntryFileSet.add(id)
+ ),
+ ],
+ // ssr: {
+ // // FIXME Without this, waku/router isn't considered to have client
+ // // entries, and "No client entry" error occurs.
+ // // Unless we fix this, RSC-capable packages aren't supported.
+ // // This also seems to cause problems with pnpm.
+ // // noExternal: ['@redwoodjs/web', '@redwoodjs/router'],
+ // },
+ build: {
+ write: false,
+ ssr: true,
+ rollupOptions: {
+ input: {
+ // entries: rwPaths.web.entryServer,
+ entries: path.join(rwPaths.web.src, 'entries.ts'),
+ },
+ },
+ },
+ })
+
+ const clientEntryFiles = Object.fromEntries(
+ Array.from(clientEntryFileSet).map((filename, i) => [`rsc${i}`, filename])
+ )
+ const serverEntryFiles = Object.fromEntries(
+ Array.from(serverEntryFileSet).map((filename, i) => [`rsf${i}`, filename])
+ )
+
+ console.log('clientEntryFileSet', Array.from(clientEntryFileSet))
+ console.log('serverEntryFileSet', Array.from(serverEntryFileSet))
+ console.log('clientEntryFiles', clientEntryFiles)
+ console.log('serverEntryFiles', serverEntryFiles)
+
+ const clientEntryPath = rwPaths.web.entryClient
+
+ if (!clientEntryPath) {
+ throw new Error(
+ 'Vite client entry point not found. Please check that your project ' +
+ 'has an entry.client.{jsx,tsx} file in the web/src directory.'
+ )
+ }
+
+ const clientBuildOutput = await viteBuild({
+ // ...configFileConfig,
+ root: rwPaths.web.src,
+ plugins: [
+ // TODO (RSC) Update index.html to include the entry.client.js script
+ // TODO (RSC) Do the above in the exp-rsc setup command
+ // {
+ // name: 'redwood-plugin-vite',
+
+ // // ---------- Bundle injection ----------
+ // // Used by rollup during build to inject the entrypoint
+ // // but note index.html does not come through as an id during dev
+ // transform: (code: string, id: string) => {
+ // if (
+ // existsSync(clientEntryPath) &&
+ // // TODO (RSC) Is this even needed? We throw if we can't find it above
+ // // TODO (RSC) Consider making this async (if we do need it)
+ // normalizePath(id) === normalizePath(rwPaths.web.html)
+ // ) {
+ // const newCode = code.replace(
+ // '',
+ // ''
+ // )
+ //
+ // return { code: newCode, map: null }
+ // } else {
+ // // Returning null as the map preserves the original sourcemap
+ // return { code, map: null }
+ // }
+ // },
+ // },
+ react(),
+ rscIndexPlugin(),
+ ],
+ build: {
+ outDir: rwPaths.web.dist,
+ emptyOutDir: true, // Needed because `outDir` is not inside `root`
+ // TODO (RSC) Enable this when we switch to a server-first approach
+ // emptyOutDir: false, // Already done when building server
+ rollupOptions: {
+ input: {
+ main: rwPaths.web.html,
+ ...clientEntryFiles,
+ },
+ preserveEntrySignatures: 'exports-only',
+ },
+ manifest: 'build-manifest.json',
+ },
+ esbuild: {
+ logLevel: 'debug',
+ },
+ })
+
+ if (!('output' in clientBuildOutput)) {
+ throw new Error('Unexpected vite client build output')
+ }
+
+ const serverBuildOutput = await serverBuild(
+ // rwPaths.web.entryServer,
+ path.join(rwPaths.web.src, 'entries.ts'),
+ clientEntryFiles,
+ serverEntryFiles,
+ {}
+ )
+
+ const clientEntries: Record = {}
+ for (const item of clientBuildOutput.output) {
+ const { name, fileName } = item
+ const entryFile =
+ name &&
+ serverBuildOutput.output.find(
+ (item) =>
+ 'moduleIds' in item &&
+ item.moduleIds.includes(clientEntryFiles[name] as string)
+ )?.fileName
+ if (entryFile) {
+ clientEntries[entryFile] = fileName
+ }
+ }
+
+ console.log('clientEntries', clientEntries)
+
+ await fs.appendFile(
+ path.join(rwPaths.web.distServer, 'entries.js'),
+ `export const clientEntries=${JSON.stringify(clientEntries)};`
+ )
+
+ // // Step 1A: Generate the client bundle
+ // await buildWeb({ verbose })
+
+ // const rollupInput = {
+ // entries: rwPaths.web.entryServer,
+ // ...clientEntryFiles,
+ // ...serverEntryFiles,
+ // }
+
+ // Step 1B: Generate the server output
+ // await build({
+ // // TODO (RSC) I had this marked as 'FIXME'. I guess I just need to make
+ // // sure we still include it, or at least make it possible for users to pass
+ // // in their own config
+ // // configFile: viteConfig,
+ // ssr: {
+ // noExternal: Array.from(clientEntryFileSet).map(
+ // // TODO (RSC) I think the comment below is from waku. We don't care
+ // // about pnpm, do we? Does it also affect yarn?
+ // // FIXME this might not work with pnpm
+ // // TODO (RSC) No idea what's going on here
+ // (filename) => {
+ // const nodeModulesPath = path.join(rwPaths.base, 'node_modules')
+ // console.log('nodeModulesPath', nodeModulesPath)
+ // const relativePath = path.relative(nodeModulesPath, filename)
+ // console.log('relativePath', relativePath)
+ // console.log('first split', relativePath.split('/')[0])
+
+ // return relativePath.split('/')[0]
+ // }
+ // ),
+ // },
+ // build: {
+ // // Because we configure the root to be web/src, we need to go up one level
+ // outDir: rwPaths.web.distServer,
+ // // TODO (RSC) Maybe we should re-enable this. I can't remember anymore)
+ // // What does 'ssr' even mean?
+ // // ssr: rwPaths.web.entryServer,
+ // rollupOptions: {
+ // input: {
+ // // TODO (RSC) entries: rwPaths.web.entryServer,
+ // ...clientEntryFiles,
+ // ...serverEntryFiles,
+ // },
+ // output: {
+ // banner: (chunk) => {
+ // console.log('chunk', chunk)
+
+ // // HACK to bring directives to the front
+ // let code = ''
+
+ // if (chunk.moduleIds.some((id) => clientEntryFileSet.has(id))) {
+ // code += '"use client";'
+ // }
+
+ // if (chunk.moduleIds.some((id) => serverEntryFileSet.has(id))) {
+ // code += '"use server";'
+ // }
+
+ // console.log('code', code)
+ // return code
+ // },
+ // entryFileNames: (chunkInfo) => {
+ // console.log('chunkInfo', chunkInfo)
+
+ // // TODO (RSC) Don't hardcode 'entry.server'
+ // if (chunkInfo.name === 'entry.server') {
+ // return '[name].js'
+ // }
+
+ // return 'assets/[name].js'
+ // },
+ // },
+ // },
+ // },
+ // envFile: false,
+ // logLevel: verbose ? 'info' : 'warn',
+ // })
+
+ // Step 3: Generate route-manifest.json
+
+ // TODO When https://github.com/tc39/proposal-import-attributes and
+ // https://github.com/microsoft/TypeScript/issues/53656 have both landed we
+ // should try to do this instead:
+ // const clientBuildManifest: ViteBuildManifest = await import(
+ // path.join(getPaths().web.dist, 'build-manifest.json'),
+ // { with: { type: 'json' } }
+ // )
+ // NOTES:
+ // * There's a related babel plugin here
+ // https://babeljs.io/docs/babel-plugin-syntax-import-attributes
+ // * Included in `preset-env` if you set `shippedProposals: true`
+ // * We had this before, but with `assert` instead of `with`. We really
+ // should be using `with`. See motivation in issues linked above.
+ // * With `assert` and `@babel/plugin-syntax-import-assertions` the
+ // code compiled and ran properly, but Jest tests failed, complaining
+ // about the syntax.
+ const manifestPath = path.join(getPaths().web.dist, 'build-manifest.json')
+ const buildManifestStr = await fs.readFile(manifestPath, 'utf-8')
+ const clientBuildManifest: ViteBuildManifest = JSON.parse(buildManifestStr)
+
+ // TODO (RSC) We don't have support for a router yet, so skip all routes
+ const routesList = [] as RouteSpec[] // getProjectRoutes()
+
+ // This is all a no-op for now
+ const routeManifest = routesList.reduce((acc, route) => {
+ acc[route.path] = {
+ name: route.name,
+ bundle: route.relativeFilePath
+ ? clientBuildManifest[route.relativeFilePath].file
+ : null,
+ matchRegexString: route.matchRegexString,
+ // NOTE this is the path definition, not the actual path
+ // E.g. /blog/post/{id:Int}
+ pathDefinition: route.path,
+ hasParams: route.hasParams,
+ routeHooks: FIXME_constructRouteHookPath(route.routeHooks),
+ redirect: route.redirect
+ ? {
+ to: route.redirect?.to,
+ permanent: false,
+ }
+ : null,
+ renderMode: route.renderMode,
+ }
+
+ return acc
+ }, {})
+
+ await fs.writeFile(rwPaths.web.routeManifest, JSON.stringify(routeManifest))
+}
+
+// TODO (STREAMING) Hacky work around because when you don't have a App.routeHook, esbuild doesn't create
+// the pages folder in the dist/server/routeHooks directory.
+// @MARK need to change to .mjs here if we use esm
+const FIXME_constructRouteHookPath = (rhSrcPath: string | null | undefined) => {
+ const rwPaths = getPaths()
+ if (!rhSrcPath) {
+ return null
+ }
+
+ if (getAppRouteHook()) {
+ return path.relative(rwPaths.web.src, rhSrcPath).replace('.ts', '.js')
+ } else {
+ return path
+ .relative(path.join(rwPaths.web.src, 'pages'), rhSrcPath)
+ .replace('.ts', '.js')
+ }
+}
+
+if (require.main === module) {
+ const verbose = process.argv.includes('--verbose')
+ buildFeServer({ verbose })
+}
diff --git a/packages/vite/src/client.ts b/packages/vite/src/client.ts
new file mode 100644
index 000000000000..af3c5fa503c4
--- /dev/null
+++ b/packages/vite/src/client.ts
@@ -0,0 +1,108 @@
+import { cache, use, useEffect, useState } from 'react'
+import type { ReactElement } from 'react'
+
+import { createFromFetch, encodeReply } from 'react-server-dom-webpack/client'
+
+export function serve(rscId: string, basePath = '/RSC/') {
+ type SetRerender = (
+ rerender: (next: [ReactElement, string]) => void
+ ) => () => void
+ const fetchRSC = cache(
+ (serializedProps: string): readonly [React.ReactElement, SetRerender] => {
+ console.log('fetchRSC serializedProps', serializedProps)
+
+ let rerender: ((next: [ReactElement, string]) => void) | undefined
+ const setRerender: SetRerender = (fn) => {
+ rerender = fn
+ return () => {
+ rerender = undefined
+ }
+ }
+
+ const searchParams = new URLSearchParams()
+ searchParams.set('props', serializedProps)
+
+ const options = {
+ async callServer(rsfId: string, args: unknown[]) {
+ const isMutating = !!mutationMode
+ const searchParams = new URLSearchParams()
+ searchParams.set('action_id', rsfId)
+ let id: string
+ if (isMutating) {
+ id = rscId
+ searchParams.set('props', serializedProps)
+ } else {
+ id = '_'
+ }
+ const response = fetch(basePath + id + '/' + searchParams, {
+ method: 'POST',
+ body: await encodeReply(args),
+ })
+ const data = createFromFetch(response, options)
+ if (isMutating) {
+ rerender?.([data, serializedProps])
+ }
+ return data
+ },
+ }
+
+ const prefetched = (globalThis as any).__WAKU_PREFETCHED__?.[rscId]?.[
+ serializedProps
+ ]
+
+ console.log(
+ 'fetchRSC before createFromFetch',
+ basePath + rscId + '/' + searchParams
+ )
+ const data = createFromFetch(
+ prefetched || fetch(basePath + rscId + '/' + searchParams),
+ options
+ )
+ console.log('fetchRSC after createFromFetch. data:', data)
+ return [data, setRerender]
+ }
+ )
+
+ // Create temporary client component that wraps the ServerComponent returned
+ // by the `createFromFetch` call.
+ const ServerComponent = (props: Props) => {
+ console.log('ServerComponent props', props)
+
+ // FIXME we blindly expect JSON.stringify usage is deterministic
+ const serializedProps = JSON.stringify(props || {})
+ const [data, setRerender] = fetchRSC(serializedProps)
+ const [state, setState] = useState<
+ [dataToOverride: ReactElement, lastSerializedProps: string] | undefined
+ >()
+
+ // MARK Should this be useLayoutEffect?
+ useEffect(() => setRerender(setState), [setRerender])
+
+ let dataToReturn = data
+
+ if (state) {
+ if (state[1] === serializedProps) {
+ dataToReturn = state[0]
+ } else {
+ setState(undefined)
+ }
+ }
+
+ // FIXME The type error
+ // "Cannot read properties of null (reading 'alternate')"
+ // is caused with startTransition.
+ // Not sure if it's a React bug or our misusage.
+ // For now, using `use` seems to fix it. Is it a correct fix?
+ return use(dataToReturn as any) as typeof dataToReturn
+ }
+
+ return ServerComponent
+}
+
+let mutationMode = 0
+
+export function mutate(fn: () => void) {
+ ++mutationMode
+ fn()
+ --mutationMode
+}
diff --git a/packages/vite/src/react-server-dom-webpack/node-loader.ts b/packages/vite/src/react-server-dom-webpack/node-loader.ts
new file mode 100644
index 000000000000..565d126e8f0c
--- /dev/null
+++ b/packages/vite/src/react-server-dom-webpack/node-loader.ts
@@ -0,0 +1,582 @@
+/**
+ * @license React
+ * react-server-dom-webpack-node-loader.production.min.js
+ *
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+// Copied from https://github.com/facebook/react/blob/8ec962d825fc948ffda5ab863e639cd4158935ba/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js
+// and converted to TypeScript.
+
+import * as acorn from 'acorn-loose'
+
+interface ResolveContext {
+ parentURL: string | void
+ conditions: Array
+}
+
+export type ResolveFunction = (
+ specifier: string,
+ context: ResolveContext,
+ resolveFunction: ResolveFunction
+) => { url: string } | Promise<{ url: string }>
+
+interface GetSourceContext {
+ format: string
+}
+
+type GetSourceFunction = (
+ url: string,
+ context: GetSourceContext,
+ getSourceFunction: GetSourceFunction
+) => Promise<{ source: Source }>
+
+interface TransformSourceContext {
+ format: string
+ url: string
+}
+
+type TransformSourceFunction = (
+ source: Source,
+ context: TransformSourceContext,
+ transformSourceFunction: TransformSourceFunction
+) => Promise<{ source: Source }>
+
+interface LoadContext {
+ conditions: Array
+ format: string | null | void
+ importAssertions: object
+}
+
+type LoadFunction = (
+ url: string,
+ loadContext: LoadContext | null,
+ loadFunction: LoadFunction
+) => Promise<{ format: string; shortCircuit?: boolean; source: Source }>
+
+// This is the official type, but the code below throws if it isn't a string.
+// Tightening the type to string is required for interop with Vite (Rollup).
+// type Source = string | ArrayBuffer | Uint8Array
+type Source = string
+
+let warnedAboutConditionsFlag = false
+
+let stashedGetSource: null | GetSourceFunction = null
+let stashedResolve: null | ResolveFunction = null
+
+export async function resolve(
+ specifier: string,
+ context: ResolveContext,
+ defaultResolve: ResolveFunction
+): Promise<{ url: string }> {
+ // We stash this in case we end up needing to resolve export * statements later.
+ stashedResolve = defaultResolve
+
+ if (!context.conditions.includes('react-server')) {
+ context = {
+ ...context,
+ conditions: [...context.conditions, 'react-server'],
+ }
+
+ if (!warnedAboutConditionsFlag) {
+ warnedAboutConditionsFlag = true
+
+ console.warn(
+ 'You did not run Node.js with the `--conditions react-server` flag. ' +
+ 'Any "react-server" override will only work with ESM imports.'
+ )
+ }
+ }
+
+ return await defaultResolve(specifier, context, defaultResolve)
+}
+
+export async function getSource(
+ url: string,
+ context: GetSourceContext,
+ defaultGetSource: GetSourceFunction
+): Promise<{ source: Source }> {
+ // We stash this in case we end up needing to resolve export * statements later.
+ stashedGetSource = defaultGetSource
+ return defaultGetSource(url, context, defaultGetSource)
+}
+
+function addLocalExportedNames(names: Map, node: any) {
+ switch (node.type) {
+ case 'Identifier':
+ names.set(node.name, node.name)
+ return
+
+ case 'ObjectPattern':
+ for (let i = 0; i < node.properties.length; i++) {
+ addLocalExportedNames(names, node.properties[i])
+ }
+
+ return
+
+ case 'ArrayPattern':
+ for (let i = 0; i < node.elements.length; i++) {
+ const element = node.elements[i]
+ if (element) {
+ addLocalExportedNames(names, element)
+ }
+ }
+
+ return
+
+ case 'Property':
+ addLocalExportedNames(names, node.value)
+ return
+
+ case 'AssignmentPattern':
+ addLocalExportedNames(names, node.left)
+ return
+
+ case 'RestElement':
+ addLocalExportedNames(names, node.argument)
+ return
+
+ case 'ParenthesizedExpression':
+ addLocalExportedNames(names, node.expression)
+ return
+ }
+}
+
+function transformServerModule(
+ source: string,
+ body: any,
+ url: string,
+ _loader?: LoadFunction
+): string {
+ // If the same local name is exported more than once, we only need one of the names.
+ const localNames = new Map()
+ const localTypes = new Map()
+
+ for (let i = 0; i < body.length; i++) {
+ const node = body[i]
+
+ switch (node.type) {
+ case 'ExportAllDeclaration':
+ // If export * is used, the other file needs to explicitly opt into "use server" too.
+ break
+
+ case 'ExportDefaultDeclaration':
+ if (node.declaration.type === 'Identifier') {
+ localNames.set(node.declaration.name, 'default')
+ } else if (node.declaration.type === 'FunctionDeclaration') {
+ if (node.declaration.id) {
+ localNames.set(node.declaration.id.name, 'default')
+ localTypes.set(node.declaration.id.name, 'function')
+ }
+ }
+
+ continue
+
+ case 'ExportNamedDeclaration':
+ if (node.declaration) {
+ if (node.declaration.type === 'VariableDeclaration') {
+ const declarations = node.declaration.declarations
+
+ for (let j = 0; j < declarations.length; j++) {
+ addLocalExportedNames(localNames, declarations[j].id)
+ }
+ } else {
+ const name = node.declaration.id.name
+ localNames.set(name, name)
+
+ if (node.declaration.type === 'FunctionDeclaration') {
+ localTypes.set(name, 'function')
+ }
+ }
+ }
+
+ if (node.specifiers) {
+ const specifiers = node.specifiers
+
+ for (let j = 0; j < specifiers.length; j++) {
+ const specifier = specifiers[j]
+ localNames.set(specifier.local.name, specifier.exported.name)
+ }
+ }
+
+ continue
+ }
+ }
+
+ let newSrc = source + '\n\n;'
+ localNames.forEach(function (exported, local) {
+ if (localTypes.get(local) !== 'function') {
+ // We first check if the export is a function and if so annotate it.
+ newSrc += 'if (typeof ' + local + ' === "function") '
+ }
+
+ newSrc += 'Object.defineProperties(' + local + ',{'
+ newSrc += '$$typeof: {value: Symbol.for("react.server.reference")},'
+ newSrc += '$$id: {value: ' + JSON.stringify(url + '#' + exported) + '},'
+ newSrc += '$$bound: { value: null }'
+ newSrc += '});\n'
+ })
+ return newSrc
+}
+
+function addExportNames(names: Array, node: any) {
+ switch (node.type) {
+ case 'Identifier':
+ names.push(node.name)
+ return
+
+ case 'ObjectPattern':
+ for (let i = 0; i < node.properties.length; i++) {
+ addExportNames(names, node.properties[i])
+ }
+
+ return
+
+ case 'ArrayPattern':
+ for (let i = 0; i < node.elements.length; i++) {
+ const element = node.elements[i]
+ if (element) {
+ addExportNames(names, element)
+ }
+ }
+
+ return
+
+ case 'Property':
+ addExportNames(names, node.value)
+ return
+
+ case 'AssignmentPattern':
+ addExportNames(names, node.left)
+ return
+
+ case 'RestElement':
+ addExportNames(names, node.argument)
+ return
+
+ case 'ParenthesizedExpression':
+ addExportNames(names, node.expression)
+ return
+ }
+}
+
+function resolveClientImport(
+ specifier: string,
+ parentURL: string
+): { url: string } | Promise<{ url: string }> {
+ // Resolve an import specifier as if it was loaded by the client. This doesn't use
+ // the overrides that this loader does but instead reverts to the default.
+ // This resolution algorithm will not necessarily have the same configuration
+ // as the actual client loader. It should mostly work and if it doesn't you can
+ // always convert to explicit exported names instead.
+ const conditions = ['node', 'import']
+
+ if (stashedResolve === null) {
+ throw new Error(
+ 'Expected resolve to have been called before transformSource'
+ )
+ }
+
+ return stashedResolve(
+ specifier,
+ {
+ conditions,
+ parentURL,
+ },
+ stashedResolve
+ )
+}
+
+async function parseExportNamesInto(
+ body: any,
+ names: Array,
+ parentURL: string,
+ loader: LoadFunction
+): Promise {
+ for (let i = 0; i < body.length; i++) {
+ const node = body[i]
+
+ switch (node.type) {
+ case 'ExportAllDeclaration':
+ if (node.exported) {
+ addExportNames(names, node.exported)
+ continue
+ } else {
+ const _await$resolveClientI = await resolveClientImport(
+ node.source.value,
+ parentURL
+ ),
+ url = _await$resolveClientI.url
+
+ const _await$loader = await loader(
+ url,
+ {
+ format: 'module',
+ conditions: [],
+ importAssertions: {},
+ },
+ loader
+ ),
+ source = _await$loader.source
+
+ if (typeof source !== 'string') {
+ throw new Error('Expected the transformed source to be a string.')
+ }
+
+ let childBody
+
+ try {
+ childBody = acorn.parse(source, {
+ ecmaVersion: '2024',
+ sourceType: 'module',
+ }).body
+ } catch (x: any) {
+ console.error('Error parsing %s %s', url, x.message)
+ continue
+ }
+
+ await parseExportNamesInto(childBody, names, url, loader)
+ continue
+ }
+
+ case 'ExportDefaultDeclaration':
+ names.push('default')
+ continue
+
+ case 'ExportNamedDeclaration':
+ if (node.declaration) {
+ if (node.declaration.type === 'VariableDeclaration') {
+ const declarations = node.declaration.declarations
+
+ for (let j = 0; j < declarations.length; j++) {
+ addExportNames(names, declarations[j].id)
+ }
+ } else {
+ addExportNames(names, node.declaration.id)
+ }
+ }
+
+ if (node.specifiers) {
+ const specifiers = node.specifiers
+
+ for (let j = 0; j < specifiers.length; j++) {
+ addExportNames(names, specifiers[j].exported)
+ }
+ }
+
+ continue
+ }
+ }
+}
+
+async function transformClientModule(
+ body: any,
+ url: string,
+ loader: LoadFunction
+): Promise {
+ const names: Array = []
+ await parseExportNamesInto(body, names, url, loader)
+ let newSrc =
+ "const CLIENT_REFERENCE = Symbol.for('react.client.reference');\n"
+
+ for (let i = 0; i < names.length; i++) {
+ const name = names[i]
+
+ if (name === 'default') {
+ newSrc += 'export default '
+ newSrc += 'Object.defineProperties(function() {'
+ newSrc +=
+ 'throw new Error(' +
+ JSON.stringify(
+ 'Attempted to call the default export of ' +
+ url +
+ ' from the server' +
+ "but it's on the client. It's not possible to invoke a client function from " +
+ 'the server, it can only be rendered as a Component or passed to props of a' +
+ 'Client Component.'
+ ) +
+ ');'
+ } else {
+ newSrc += 'export const ' + name + ' = '
+ newSrc += 'Object.defineProperties(function() {'
+ newSrc +=
+ 'throw new Error(' +
+ JSON.stringify(
+ 'Attempted to call ' +
+ name +
+ '() from the server but ' +
+ name +
+ ' is on the client. ' +
+ "It's not possible to invoke a client function from the server, it can " +
+ 'only be rendered as a Component or passed to props of a Client Component.'
+ ) +
+ ');'
+ }
+
+ newSrc += '},{'
+ newSrc += '$$typeof: {value: CLIENT_REFERENCE},'
+ newSrc += '$$id: {value: ' + JSON.stringify(url + '#' + name) + '}'
+ newSrc += '});\n'
+ }
+
+ return newSrc
+}
+
+async function loadClientImport(
+ url: string,
+ defaultTransformSource: TransformSourceFunction
+): Promise<{ format: string; shortCircuit?: boolean; source: Source }> {
+ if (stashedGetSource === null) {
+ throw new Error(
+ 'Expected getSource to have been called before transformSource'
+ )
+ } // TODO: Validate that this is another module by calling getFormat.
+
+ const _await$stashedGetSour = await stashedGetSource(
+ url,
+ {
+ format: 'module',
+ },
+ stashedGetSource
+ ),
+ source = _await$stashedGetSour.source
+
+ const result = await defaultTransformSource(
+ source,
+ {
+ format: 'module',
+ url,
+ },
+ defaultTransformSource
+ )
+ return {
+ format: 'module',
+ source: result.source,
+ }
+}
+
+async function transformModuleIfNeeded(
+ source: string,
+ url: string,
+ loader: LoadFunction
+): Promise {
+ // Do a quick check for the exact string. If it doesn't exist, don't
+ // bother parsing.
+ if (
+ source.indexOf('use client') === -1 &&
+ source.indexOf('use server') === -1
+ ) {
+ return source
+ }
+
+ let body
+
+ try {
+ body = acorn.parse(source, {
+ ecmaVersion: '2024',
+ sourceType: 'module',
+ }).body
+ } catch (x: any) {
+ console.error('Error parsing %s %s', url, x.message)
+ return source
+ }
+
+ let useClient = false
+ let useServer = false
+
+ for (let i = 0; i < body.length; i++) {
+ const node = body[i]
+
+ if (node.type !== 'ExpressionStatement' || !node.directive) {
+ break
+ }
+
+ if (node.directive === 'use client') {
+ useClient = true
+ }
+
+ if (node.directive === 'use server') {
+ useServer = true
+ }
+ }
+
+ if (!useClient && !useServer) {
+ return source
+ }
+
+ if (useClient && useServer) {
+ throw new Error(
+ 'Cannot have both "use client" and "use server" directives in the same file.'
+ )
+ }
+
+ if (useClient) {
+ return transformClientModule(body, url, loader)
+ }
+
+ return transformServerModule(source, body, url)
+}
+
+export async function transformSource(
+ source: Source,
+ context: TransformSourceContext,
+ defaultTransformSource: TransformSourceFunction
+): Promise<{ source: Source }> {
+ const transformed = await defaultTransformSource(
+ source,
+ context,
+ defaultTransformSource
+ )
+
+ if (context.format === 'module') {
+ const transformedSource = transformed.source
+
+ if (typeof transformedSource !== 'string') {
+ throw new Error('Expected source to have been transformed to a string.')
+ }
+
+ const newSrc = await transformModuleIfNeeded(
+ transformedSource,
+ context.url,
+ (url: string) => {
+ return loadClientImport(url, defaultTransformSource)
+ }
+ )
+ return {
+ source: newSrc,
+ }
+ }
+
+ return transformed
+}
+
+export async function load(
+ url: string,
+ context: LoadContext | null,
+ defaultLoad: LoadFunction
+): Promise<{ format: string; shortCircuit?: boolean; source: Source }> {
+ const result = await defaultLoad(url, context, defaultLoad)
+
+ if (result.format === 'module') {
+ if (typeof result.source !== 'string') {
+ throw new Error('Expected source to have been loaded into a string.')
+ }
+
+ const newSrc = await transformModuleIfNeeded(
+ result.source,
+ url,
+ defaultLoad
+ )
+ return {
+ format: 'module',
+ source: newSrc,
+ }
+ }
+
+ return result
+}
+
+// export { getSource, load, resolve, transformSource }
diff --git a/packages/vite/src/runRscFeServer.ts b/packages/vite/src/runRscFeServer.ts
new file mode 100644
index 000000000000..e6f7d4620460
--- /dev/null
+++ b/packages/vite/src/runRscFeServer.ts
@@ -0,0 +1,186 @@
+// TODO (STREAMING) Move this to a new package called @redwoodjs/fe-server (goes
+// well in naming with with @redwoodjs/api-server)
+// Only things used during dev can be in @redwoodjs/vite. Everything else has
+// to go in fe-server
+
+import fs from 'fs/promises'
+import path from 'path'
+
+// @ts-expect-error We will remove dotenv-defaults from this package anyway
+import { config as loadDotEnv } from 'dotenv-defaults'
+import express from 'express'
+import { createProxyMiddleware } from 'http-proxy-middleware'
+import isbot from 'isbot'
+import type { Manifest as ViteBuildManifest } from 'vite'
+
+import { getConfig, getPaths } from '@redwoodjs/project-config'
+
+import { renderRSC, setClientEntries } from './waku-lib/rsc-handler-worker'
+
+globalThis.RWJS_ENV = {}
+
+/**
+ * TODO (STREAMING)
+ * We have this server in the vite package only temporarily.
+ * We will need to decide where to put it, so that rwjs/internal and other heavy dependencies
+ * can be removed from the final docker image
+ */
+
+// --- @MARK This should be removed once we have re-architected the rw serve command ---
+// We need the dotenv, so that prisma knows the DATABASE env var
+// Normally the RW cli loads this for us, but we expect this file to be run directly
+// without using the CLI. Remember to remove dotenv-defaults dependency from this package
+loadDotEnv({
+ path: path.join(getPaths().base, '.env'),
+ defaults: path.join(getPaths().base, '.env.defaults'),
+ multiline: true,
+})
+//------------------------------------------------
+
+const checkUaForSeoCrawler = isbot.spawn()
+checkUaForSeoCrawler.exclude(['chrome-lighthouse'])
+
+export async function runFeServer() {
+ const app = express()
+ const rwPaths = getPaths()
+ const rwConfig = getConfig()
+
+ await setClientEntries('load')
+
+ // TODO When https://github.com/tc39/proposal-import-attributes and
+ // https://github.com/microsoft/TypeScript/issues/53656 have both landed we
+ // should try to do this instead:
+ // const routeManifest: RWRouteManifest = await import(
+ // rwPaths.web.routeManifest, { with: { type: 'json' } }
+ // )
+ // NOTES:
+ // * There's a related babel plugin here
+ // https://babeljs.io/docs/babel-plugin-syntax-import-attributes
+ // * Included in `preset-env` if you set `shippedProposals: true`
+ // * We had this before, but with `assert` instead of `with`. We really
+ // should be using `with`. See motivation in issues linked above.
+ // * With `assert` and `@babel/plugin-syntax-import-assertions` the
+ // code compiled and ran properly, but Jest tests failed, complaining
+ // about the syntax.
+ // const routeManifestStr = await fs.readFile(rwPaths.web.routeManifest, 'utf-8')
+ // const routeManifest: RWRouteManifest = JSON.parse(routeManifestStr)
+
+ // TODO See above about using `import { with: { type: 'json' } }` instead
+ const manifestPath = path.join(getPaths().web.dist, 'build-manifest.json')
+ const buildManifestStr = await fs.readFile(manifestPath, 'utf-8')
+ const buildManifest: ViteBuildManifest = JSON.parse(buildManifestStr)
+
+ console.log('='.repeat(80))
+ console.log('buildManifest', buildManifest)
+ console.log('='.repeat(80))
+
+ const indexEntry = Object.values(buildManifest).find((manifestItem) => {
+ return manifestItem.isEntry
+ })
+
+ if (!indexEntry) {
+ throw new Error('Could not find index.html in build manifest')
+ }
+
+ // 👉 1. Use static handler for assets
+ // For CF workers, we'd need an equivalent of this
+ app.use('/assets', express.static(rwPaths.web.dist + '/assets'))
+
+ // 👉 2. Proxy the api server
+ // TODO (STREAMING) we need to be able to specify whether proxying is required or not
+ // e.g. deploying to Netlify, we don't need to proxy but configure it in Netlify
+ // Also be careful of differences between v2 and v3 of the server
+ app.use(
+ rwConfig.web.apiUrl,
+ // @WARN! Be careful, between v2 and v3 of http-proxy-middleware
+ // the syntax has changed https://github.com/chimurai/http-proxy-middleware
+ createProxyMiddleware({
+ changeOrigin: true,
+ pathRewrite: {
+ [`^${rwConfig.web.apiUrl}`]: '', // remove base path
+ },
+ // Using 127.0.0.1 to force ipv4. With `localhost` you don't really know
+ // if it's going to be ipv4 or ipv6
+ target: `http://127.0.0.1:${rwConfig.api.port}`,
+ })
+ )
+
+ app.use((req, _res, next) => {
+ console.log('req.url', req.url)
+ next()
+ })
+
+ // Mounting middleware at /RSC will strip /RSC from req.url
+ app.use('/RSC', async (req, res) => {
+ const basePath = '/RSC/'
+ console.log('basePath', basePath)
+ console.log('req.originalUrl', req.originalUrl, 'req.url', req.url)
+ console.log('req.headers.host', req.headers.host)
+ const url = new URL(req.originalUrl || '', 'http://' + req.headers.host)
+ let rscId: string | undefined
+ let props = {}
+ let rsfId: string | undefined
+ const args: unknown[] = []
+
+ console.log('url.pathname', url.pathname)
+ if (url.pathname.startsWith(basePath)) {
+ const index = url.pathname.lastIndexOf('/')
+ rscId = url.pathname.slice(basePath.length, index)
+ console.log('rscId', rscId)
+ const params = new URLSearchParams(url.pathname.slice(index + 1))
+ if (rscId && rscId !== '_') {
+ res.setHeader('Content-Type', 'text/x-component')
+ props = JSON.parse(params.get('props') || '{}')
+ } else {
+ rscId = undefined
+ }
+ rsfId = params.get('action_id') || undefined
+ if (rsfId) {
+ console.warn('RSF is not supported yet')
+ console.warn('RSF is not supported yet')
+ console.warn('RSF is not supported yet')
+ // if (req.headers["content-type"]?.startsWith("multipart/form-data")) {
+ // const bb = busboy({ headers: req.headers });
+ // const reply = decodeReplyFromBusboy(bb);
+ // req.pipe(bb);
+ // args = await reply;
+ // } else {
+ // let body = "";
+ // for await (const chunk of req) {
+ // body += chunk;
+ // }
+ // if (body) {
+ // args = await decodeReply(body);
+ // }
+ // }
+ }
+ }
+
+ if (rscId || rsfId) {
+ const pipeable = await renderRSC({ rscId, props, rsfId, args })
+
+ // TODO handle errors
+
+ // pipeable.on('error', (err) => {
+ // console.info('Cannot render RSC', err)
+ // res.statusCode = 500
+ // if (options.mode === 'development') {
+ // res.end(String(err))
+ // } else {
+ // res.end()
+ // }
+ // })
+ pipeable.pipe(res)
+ return
+ }
+ })
+
+ app.use(express.static(rwPaths.web.dist))
+
+ app.listen(rwConfig.web.port)
+ console.log(
+ `Started production FE server on http://localhost:${rwConfig.web.port}`
+ )
+}
+
+runFeServer()
diff --git a/packages/vite/src/waku-lib/build-server.ts b/packages/vite/src/waku-lib/build-server.ts
new file mode 100644
index 000000000000..09ef7b4be399
--- /dev/null
+++ b/packages/vite/src/waku-lib/build-server.ts
@@ -0,0 +1,82 @@
+// TODO (RSC) Take ownership of this file and move it out ouf the waku-lib folder
+import react from '@vitejs/plugin-react'
+import { build as viteBuild } from 'vite'
+
+import { getPaths } from '@redwoodjs/project-config'
+
+export async function serverBuild(
+ entriesFile: string,
+ clientEntryFiles: Record,
+ serverEntryFiles: Record,
+ customModules: Record
+) {
+ // const noExternal = Array.from(clientEntryFileSet).map(
+ // // FIXME this might not work with pnpm
+ // (fname) =>
+ // path
+ // .relative(path.join(config.root, "node_modules"), fname)
+ // .split("/")[0]!
+ // );
+ //
+ const input = {
+ entries: entriesFile,
+ ...clientEntryFiles,
+ ...serverEntryFiles,
+ ...customModules,
+ }
+
+ console.log('input', input)
+
+ const rwPaths = getPaths()
+
+ const serverBuildOutput = await viteBuild({
+ // ...configFileConfig,
+ root: rwPaths.web.base,
+ ssr: {
+ noExternal: ['..'],
+ },
+ plugins: [react()],
+ build: {
+ ssr: true,
+ // TODO (RSC) Change output dir to just dist. We should be "server
+ // first". Client components are the "special case" and should be output
+ // to dist/client
+ outDir: rwPaths.web.distServer,
+ rollupOptions: {
+ input,
+ output: {
+ banner: (chunk) => {
+ // HACK to bring directives to the front
+ let code = ''
+ const clientValues = Object.values(clientEntryFiles)
+ console.log('chunk.moduleIds', chunk.moduleIds)
+ console.log('clientValues', clientValues)
+ if (chunk.moduleIds.some((id) => clientValues.includes(id))) {
+ console.log('adding "use client" to', chunk.fileName)
+ code += '"use client";'
+ }
+
+ const serverKeys = Object.keys(serverEntryFiles)
+ if (chunk.moduleIds.some((id) => serverKeys.includes(id))) {
+ code += '"use server";'
+ }
+ return code
+ },
+ entryFileNames: (chunkInfo) => {
+ // TODO (RSC) Probably don't want 'entries'. And definitely don't want it hardcoded
+ if (chunkInfo.name === 'entries' || customModules[chunkInfo.name]) {
+ return '[name].js'
+ }
+ return 'assets/[name].js'
+ },
+ },
+ },
+ },
+ })
+
+ if (!('output' in serverBuildOutput)) {
+ throw new Error('Unexpected vite server build output')
+ }
+
+ return serverBuildOutput
+}
diff --git a/packages/vite/src/waku-lib/builder.ts b/packages/vite/src/waku-lib/builder.ts
new file mode 100644
index 000000000000..17aef1cdb43d
--- /dev/null
+++ b/packages/vite/src/waku-lib/builder.ts
@@ -0,0 +1,185 @@
+// TODO (RSC) Take ownership of this file and move it out ouf the waku-lib folder
+import fs from 'node:fs'
+import { createRequire } from 'node:module'
+import path from 'node:path'
+
+import react from '@vitejs/plugin-react'
+import { build as viteBuild } from 'vite'
+
+import { configFileConfig, resolveConfig } from './config'
+import {
+ shutdown,
+ setClientEntries,
+ getCustomModulesRSC,
+ buildRSC,
+} from './rsc-handler'
+import { rscIndexPlugin, rscAnalyzePlugin } from './vite-plugin-rsc'
+
+export async function build() {
+ const config = await resolveConfig('build')
+ const indexHtmlFile = path.join(config.root, config.framework.indexHtml)
+ const distEntriesFile = path.join(
+ config.root,
+ config.build.outDir,
+ config.framework.entriesJs
+ )
+ let entriesFile = path.join(config.root, config.framework.entriesJs)
+ if (entriesFile.endsWith('.js')) {
+ for (const ext of ['.js', '.ts', '.tsx', '.jsx']) {
+ const tmp = entriesFile.slice(0, -3) + ext
+ if (fs.existsSync(tmp)) {
+ entriesFile = tmp
+ break
+ }
+ }
+ }
+ const require = createRequire(import.meta.url)
+
+ const customModules = await getCustomModulesRSC()
+ const clientEntryFileSet = new Set()
+ const serverEntryFileSet = new Set()
+ await viteBuild({
+ ...configFileConfig,
+ plugins: [
+ rscAnalyzePlugin(
+ (id) => clientEntryFileSet.add(id),
+ (id) => serverEntryFileSet.add(id)
+ ),
+ ],
+ ssr: {
+ // FIXME Without this, waku/router isn't considered to have client
+ // entries, and "No client entry" error occurs.
+ // Unless we fix this, RSC-capable packages aren't supported.
+ // This also seems to cause problems with pnpm.
+ noExternal: ['waku'],
+ },
+ build: {
+ write: false,
+ ssr: true,
+ rollupOptions: {
+ input: {
+ entries: entriesFile,
+ ...customModules,
+ },
+ },
+ },
+ })
+ const clientEntryFiles = Object.fromEntries(
+ Array.from(clientEntryFileSet).map((fname, i) => [`rsc${i}`, fname])
+ )
+ const serverEntryFiles = Object.fromEntries(
+ Array.from(serverEntryFileSet).map((fname, i) => [`rsf${i}`, fname])
+ )
+
+ const serverBuildOutput = await viteBuild({
+ ...configFileConfig,
+ ssr: {
+ noExternal: Array.from(clientEntryFileSet).map(
+ // FIXME this might not work with pnpm
+ (fname) =>
+ path
+ .relative(path.join(config.root, 'node_modules'), fname)
+ .split('/')[0]
+ ),
+ },
+ build: {
+ ssr: true,
+ rollupOptions: {
+ input: {
+ entries: entriesFile,
+ ...clientEntryFiles,
+ ...serverEntryFiles,
+ ...customModules,
+ },
+ output: {
+ banner: (chunk) => {
+ // HACK to bring directives to the front
+ let code = ''
+ if (chunk.moduleIds.some((id) => clientEntryFileSet.has(id))) {
+ code += '"use client";'
+ }
+ if (chunk.moduleIds.some((id) => serverEntryFileSet.has(id))) {
+ code += '"use server";'
+ }
+ return code
+ },
+ entryFileNames: (chunkInfo) => {
+ if (chunkInfo.name === 'entries' || customModules[chunkInfo.name]) {
+ return '[name].js'
+ }
+ return 'assets/[name].js'
+ },
+ },
+ },
+ },
+ })
+ if (!('output' in serverBuildOutput)) {
+ throw new Error('Unexpected vite server build output')
+ }
+
+ const clientBuildOutput = await viteBuild({
+ ...configFileConfig,
+ plugins: [react(), rscIndexPlugin()],
+ build: {
+ outDir: path.join(config.build.outDir, config.framework.outPublic),
+ rollupOptions: {
+ input: {
+ main: indexHtmlFile,
+ ...clientEntryFiles,
+ },
+ preserveEntrySignatures: 'exports-only',
+ },
+ },
+ })
+ if (!('output' in clientBuildOutput)) {
+ throw new Error('Unexpected vite client build output')
+ }
+
+ const clientEntries: Record = {}
+ for (const item of clientBuildOutput.output) {
+ const { name, fileName } = item
+ const entryFile =
+ name &&
+ serverBuildOutput.output.find(
+ (item) =>
+ 'moduleIds' in item &&
+ item.moduleIds.includes(clientEntryFiles[name] as string)
+ )?.fileName
+ if (entryFile) {
+ clientEntries[entryFile] = fileName
+ }
+ }
+ console.log('clientEntries', clientEntries)
+ fs.appendFileSync(
+ distEntriesFile,
+ `export const clientEntries=${JSON.stringify(clientEntries)};`
+ )
+
+ const absoluteClientEntries = Object.fromEntries(
+ Object.entries(clientEntries).map(([key, val]) => [
+ path.join(path.dirname(entriesFile), config.build.outDir, key),
+ config.base + val,
+ ])
+ )
+ await setClientEntries(absoluteClientEntries)
+
+ await buildRSC()
+
+ const origPackageJson = require(path.join(config.root, 'package.json'))
+ const packageJson = {
+ name: origPackageJson.name,
+ version: origPackageJson.version,
+ private: true,
+ type: 'module',
+ scripts: {
+ start: 'waku start',
+ },
+ dependencies: origPackageJson.dependencies,
+ }
+ fs.writeFileSync(
+ path.join(config.root, config.build.outDir, 'package.json'),
+ JSON.stringify(packageJson, null, 2)
+ )
+
+ await shutdown()
+}
diff --git a/packages/vite/src/waku-lib/config.ts b/packages/vite/src/waku-lib/config.ts
new file mode 100644
index 000000000000..386ee6b1e91d
--- /dev/null
+++ b/packages/vite/src/waku-lib/config.ts
@@ -0,0 +1,41 @@
+// TODO (RSC) Take ownership of this file and move it out ouf the waku-lib folder
+import type { ConfigEnv, UserConfig } from 'vite'
+import { resolveConfig as viteResolveConfig } from 'vite'
+
+export interface FrameworkConfig {
+ indexHtml?: string // relative to root
+ entriesJs?: string // relative to root
+ outPublic?: string // relative to build.outDir
+ rscPrefix?: string // defaults to "RSC/"
+}
+
+export interface ExtendedUserConfig extends UserConfig {
+ framework?: FrameworkConfig
+}
+
+export function defineConfig(
+ config:
+ | ExtendedUserConfig
+ | Promise
+ | ((env: ConfigEnv) => ExtendedUserConfig)
+ | ((env: ConfigEnv) => Promise)
+) {
+ return config
+}
+
+export const configFileConfig = process.env.CONFIG_FILE
+ ? { configFile: process.env.CONFIG_FILE }
+ : {}
+
+export async function resolveConfig(command: 'build' | 'serve') {
+ const origConfig = await viteResolveConfig(configFileConfig, command)
+ const framework: Required = {
+ indexHtml: 'index.html',
+ entriesJs: 'entries.js',
+ outPublic: 'public',
+ rscPrefix: 'RSC/',
+ ...(origConfig as { framework?: FrameworkConfig }).framework,
+ }
+ const config = { ...origConfig, framework }
+ return config
+}
diff --git a/packages/vite/src/waku-lib/rsc-handler-worker.ts b/packages/vite/src/waku-lib/rsc-handler-worker.ts
new file mode 100644
index 000000000000..fff2308b6cb1
--- /dev/null
+++ b/packages/vite/src/waku-lib/rsc-handler-worker.ts
@@ -0,0 +1,450 @@
+// TODO (RSC) Take ownership of this file and move it out ouf the waku-lib folder
+// import fs from 'node:fs'
+import path from 'node:path'
+import { Writable } from 'node:stream'
+import { parentPort } from 'node:worker_threads'
+
+import { createElement } from 'react'
+
+import RSDWServer from 'react-server-dom-webpack/server'
+import { createServer } from 'vite'
+
+import { getPaths } from '@redwoodjs/project-config'
+
+import { defineEntries } from '../waku-server'
+// import type { unstable_GetCustomModules } from '../waku-server'
+
+import { configFileConfig, resolveConfig } from './config'
+// import type { RenderInput, MessageReq, MessageRes } from './rsc-handler'
+import type { RenderInput, MessageRes } from './rsc-handler'
+// import { transformRsfId, generatePrefetchCode } from './rsc-utils'
+import { transformRsfId } from './rsc-utils'
+import { rscTransformPlugin, rscReloadPlugin } from './vite-plugin-rsc'
+
+const { renderToPipeableStream } = RSDWServer
+
+type Entries = { default: ReturnType }
+type PipeableStream = { pipe(destination: T): T }
+
+// const handleSetClientEntries = async (
+// mesg: MessageReq & { type: 'setClientEntries' }
+// ) => {
+// const { id, value } = mesg
+// try {
+// await setClientEntries(value)
+
+// if (!parentPort) {
+// throw new Error('parentPort is undefined')
+// }
+
+// const message: MessageRes = { id, type: 'end' }
+// parentPort.postMessage(message)
+// } catch (err) {
+// if (!parentPort) {
+// throw new Error('parentPort is undefined')
+// }
+
+// const message: MessageRes = { id, type: 'err', err }
+// parentPort.postMessage(message)
+// }
+// }
+
+// const handleRender = async (message: MessageReq & { type: 'render' }) => {
+// const { id, input } = message
+
+// try {
+// const pipeable = await renderRSC(input)
+// const writable = new Writable({
+// write(chunk, encoding, callback) {
+// if (encoding !== ('buffer' as any)) {
+// throw new Error('Unknown encoding')
+// }
+
+// if (!parentPort) {
+// throw new Error('parentPort is undefined')
+// }
+
+// const buffer: Buffer = chunk
+// const msg: MessageRes = {
+// id,
+// type: 'buf',
+// buf: buffer.buffer,
+// offset: buffer.byteOffset,
+// len: buffer.length,
+// }
+// parentPort.postMessage(msg, [msg.buf])
+// callback()
+// },
+// final(callback) {
+// if (!parentPort) {
+// throw new Error('parentPort is undefined')
+// }
+
+// const mesg: MessageRes = { id, type: 'end' }
+// parentPort.postMessage(mesg)
+// callback()
+// },
+// })
+// pipeable.pipe(writable)
+// } catch (err) {
+// if (!parentPort) {
+// throw new Error('parentPort is undefined')
+// }
+
+// const mesg: MessageRes = { id, type: 'err', err }
+// parentPort.postMessage(mesg)
+// }
+// }
+
+// const handleGetCustomModules = async (
+// mesg: MessageReq & { type: 'getCustomModules' }
+// ) => {
+// const { id } = mesg
+// try {
+// if (!parentPort) {
+// throw new Error('parentPort is undefined')
+// }
+
+// const modules = await getCustomModulesRSC()
+// const mesg: MessageRes = { id, type: 'customModules', modules }
+// parentPort.postMessage(mesg)
+// } catch (err) {
+// if (!parentPort) {
+// throw new Error('parentPort is undefined')
+// }
+
+// const mesg: MessageRes = { id, type: 'err', err }
+// parentPort.postMessage(mesg)
+// }
+// }
+
+// const handleBuild = async (mesg: MessageReq & { type: 'build' }) => {
+// const { id } = mesg
+// try {
+// await buildRSC()
+
+// if (!parentPort) {
+// throw new Error('parentPort is undefined')
+// }
+
+// const mesg: MessageRes = { id, type: 'end' }
+// parentPort.postMessage(mesg)
+// } catch (err) {
+// if (!parentPort) {
+// throw new Error('parentPort is undefined')
+// }
+
+// const mesg: MessageRes = { id, type: 'err', err }
+// parentPort.postMessage(mesg)
+// }
+// }
+
+const vitePromise = createServer({
+ ...configFileConfig,
+ plugins: [
+ rscTransformPlugin(),
+ rscReloadPlugin((type) => {
+ if (!parentPort) {
+ throw new Error('parentPort is undefined')
+ }
+
+ const mesg: MessageRes = { type }
+ parentPort.postMessage(mesg)
+ }),
+ ],
+ ssr: {
+ // FIXME Without this, "use client" directive in waku/router/client
+ // is ignored, and some errors occur.
+ // Unless we fix this, RSC-capable packages aren't supported.
+ // This also seems to cause problems with pnpm.
+ noExternal: ['waku'],
+ },
+ appType: 'custom',
+})
+
+// const shutdown = async () => {
+// const vite = await vitePromise
+// await vite.close()
+// if (!parentPort) {
+// throw new Error('parentPort is undefined')
+// }
+
+// parentPort.close()
+// }
+
+const loadServerFile = async (fname: string) => {
+ const vite = await vitePromise
+ return vite.ssrLoadModule(fname)
+}
+
+// if (!parentPort) {
+// throw new Error('parentPort is undefined')
+// }
+
+// parentPort.on('message', (mesg: MessageReq) => {
+// if (mesg.type === 'shutdown') {
+// shutdown()
+// } else if (mesg.type === 'setClientEntries') {
+// handleSetClientEntries(mesg)
+// } else if (mesg.type === 'render') {
+// handleRender(mesg)
+// } else if (mesg.type === 'getCustomModules') {
+// handleGetCustomModules(mesg)
+// } else if (mesg.type === 'build') {
+// handleBuild(mesg)
+// }
+// })
+
+const configPromise = resolveConfig('serve')
+
+const getEntriesFile = async (
+ config: Awaited>,
+ isBuild: boolean
+) => {
+ const rwPaths = getPaths()
+
+ if (isBuild) {
+ return path.join(
+ config.root,
+ config.build.outDir,
+ config.framework.entriesJs
+ )
+ }
+
+ // TODO: Don't hardcode the name of the entries file
+ return path.join(rwPaths.web.distServer, 'entries.js') // path.join(config.root, config.framework.entriesJs)
+}
+
+const getFunctionComponent = async (
+ rscId: string,
+ config: Awaited>,
+ isBuild: boolean
+) => {
+ const entriesFile = await getEntriesFile(config, isBuild)
+ const {
+ default: { getEntry },
+ } = await (loadServerFile(entriesFile) as Promise)
+ const mod = await getEntry(rscId)
+ if (typeof mod === 'function') {
+ return mod
+ }
+ if (typeof mod?.default === 'function') {
+ return mod?.default
+ }
+ throw new Error('No function component found')
+}
+
+let absoluteClientEntries: Record = {}
+
+const resolveClientEntry = (
+ config: Awaited>,
+ filePath: string
+) => {
+ const clientEntry = absoluteClientEntries[filePath]
+ if (!clientEntry) {
+ if (absoluteClientEntries['*'] === '*') {
+ return config.base + path.relative(config.root, filePath)
+ }
+ throw new Error('No client entry found for ' + filePath)
+ }
+ return clientEntry
+}
+
+export async function setClientEntries(
+ value: 'load' | Record
+): Promise {
+ if (value !== 'load') {
+ absoluteClientEntries = value
+ return
+ }
+ const config = await configPromise
+ const entriesFile = await getEntriesFile(config, false)
+ console.log('entriesFile', entriesFile)
+ const { clientEntries } = await loadServerFile(entriesFile)
+ if (!clientEntries) {
+ throw new Error('Failed to load clientEntries')
+ }
+ const baseDir = path.dirname(entriesFile)
+ absoluteClientEntries = Object.fromEntries(
+ Object.entries(clientEntries).map(([key, val]) => [
+ path.join(baseDir, key),
+ config.base + val,
+ ])
+ )
+
+ console.log('absoluteClientEntries', absoluteClientEntries)
+}
+
+export async function renderRSC(input: RenderInput): Promise {
+ const config = await configPromise
+ const bundlerConfig = new Proxy(
+ {},
+ {
+ get(_target, encodedId: string) {
+ console.log('Proxy get', encodedId)
+ const [filePath, name] = encodedId.split('#') as [string, string]
+ // filePath /Users/tobbe/dev/waku/examples/01_counter/dist/assets/rsc0.js
+ // name Counter
+ const id = resolveClientEntry(config, filePath)
+ // id /assets/rsc0-beb48afe.js
+ return { id, chunks: [id], name, async: true }
+ },
+ }
+ )
+
+ if (input.rsfId && input.args) {
+ const [fileId, name] = input.rsfId.split('#')
+ const fname = path.join(config.root, fileId)
+ const mod = await loadServerFile(fname)
+ const data = await (mod[name] || mod)(...input.args)
+ if (!input.rscId) {
+ return renderToPipeableStream(data, bundlerConfig)
+ }
+ // continue for mutation mode
+ }
+
+ if (input.rscId && input.props) {
+ const component = await getFunctionComponent(input.rscId, config, false)
+ return renderToPipeableStream(
+ createElement(component, input.props),
+ bundlerConfig
+ ).pipe(transformRsfId(config.root))
+ }
+
+ throw new Error('Unexpected input')
+}
+
+// async function getCustomModulesRSC(): Promise<{ [name: string]: string }> {
+// const config = await configPromise
+// const entriesFile = await getEntriesFile(config, false)
+// const {
+// default: { unstable_getCustomModules: getCustomModules },
+// } = await (loadServerFile(entriesFile) as Promise<{
+// default: Entries['default'] & {
+// unstable_getCustomModules?: unstable_GetCustomModules
+// }
+// }>)
+// if (!getCustomModules) {
+// return {}
+// }
+// const modules = await getCustomModules()
+// return modules
+// }
+
+// // FIXME this may take too much responsibility
+// async function buildRSC(): Promise {
+// const config = await resolveConfig('build')
+// const basePath = config.base + config.framework.rscPrefix
+// const distEntriesFile = await getEntriesFile(config, true)
+// const {
+// default: { getBuilder },
+// } = await (loadServerFile(distEntriesFile) as Promise)
+// if (!getBuilder) {
+// console.warn(
+// "getBuilder is undefined. It's recommended for optimization and sometimes required."
+// )
+// return
+// }
+
+// // FIXME this doesn't seem an ideal solution
+// const decodeId = (encodedId: string): [id: string, name: string] => {
+// const [filePath, name] = encodedId.split('#') as [string, string]
+// const id = resolveClientEntry(config, filePath)
+// return [id, name]
+// }
+
+// const pathMap = await getBuilder(decodeId)
+// const clientModuleMap = new Map>()
+// const addClientModule = (pathStr: string, id: string) => {
+// let idSet = clientModuleMap.get(pathStr)
+// if (!idSet) {
+// idSet = new Set()
+// clientModuleMap.set(pathStr, idSet)
+// }
+// idSet.add(id)
+// }
+// await Promise.all(
+// Object.entries(pathMap).map(async ([pathStr, { elements }]) => {
+// for (const [rscId, props] of elements || []) {
+// // FIXME we blindly expect JSON.stringify usage is deterministic
+// const serializedProps = JSON.stringify(props)
+// const searchParams = new URLSearchParams()
+// searchParams.set('props', serializedProps)
+// const destFile = path.join(
+// config.root,
+// config.build.outDir,
+// config.framework.outPublic,
+// config.framework.rscPrefix,
+// decodeURIComponent(rscId),
+// decodeURIComponent(`${searchParams}`)
+// )
+// fs.mkdirSync(path.dirname(destFile), { recursive: true })
+// const bundlerConfig = new Proxy(
+// {},
+// {
+// get(_target, encodedId: string) {
+// const [id, name] = decodeId(encodedId)
+// addClientModule(pathStr, id)
+// return { id, chunks: [id], name, async: true }
+// },
+// }
+// )
+// const component = await getFunctionComponent(rscId, config, true)
+// const pipeable = renderToPipeableStream(
+// createElement(component, props as any),
+// bundlerConfig
+// ).pipe(transformRsfId(path.join(config.root, config.build.outDir)))
+// await new Promise((resolve, reject) => {
+// const stream = fs.createWriteStream(destFile)
+// stream.on('finish', resolve)
+// stream.on('error', reject)
+// pipeable.pipe(stream)
+// })
+// }
+// })
+// )
+
+// const publicIndexHtmlFile = path.join(
+// config.root,
+// config.build.outDir,
+// config.framework.outPublic,
+// config.framework.indexHtml
+// )
+// const publicIndexHtml = fs.readFileSync(publicIndexHtmlFile, {
+// encoding: 'utf8',
+// })
+// await Promise.all(
+// Object.entries(pathMap).map(async ([pathStr, { elements, customCode }]) => {
+// const destFile = path.join(
+// config.root,
+// config.build.outDir,
+// config.framework.outPublic,
+// pathStr,
+// pathStr.endsWith('/') ? 'index.html' : ''
+// )
+// let data = ''
+// if (fs.existsSync(destFile)) {
+// data = fs.readFileSync(destFile, { encoding: 'utf8' })
+// } else {
+// fs.mkdirSync(path.dirname(destFile), { recursive: true })
+// data = publicIndexHtml
+// }
+// const code =
+// generatePrefetchCode(
+// basePath,
+// Array.from(elements || []).flatMap(([rscId, props, skipPrefetch]) => {
+// if (skipPrefetch) {
+// return []
+// }
+// return [[rscId, props]]
+// }),
+// clientModuleMap.get(pathStr) || []
+// ) + (customCode || '')
+// if (code) {
+// // HACK is this too naive to inject script code?
+// data = data.replace(/<\/body>/, `