From aa1e0395b38ff2cd4c7f7bde84971a60ce76dd82 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Fri, 6 Sep 2024 22:41:24 +0200 Subject: [PATCH] chore(rsc): Remove Node RSC worker (#11449) --- packages/vite/src/rsc/rscRenderer.ts | 14 +-- packages/vite/src/rsc/rscRequestHandler.ts | 30 +---- packages/vite/src/rsc/rscStudioHandlers.ts | 48 +++----- packages/vite/src/rsc/rscWorker.ts | 114 ------------------ .../vite/src/rsc/rscWorkerCommunication.ts | 111 ----------------- packages/vite/src/runFeServer.ts | 3 +- packages/vite/src/utils.ts | 45 +++---- 7 files changed, 53 insertions(+), 312 deletions(-) delete mode 100644 packages/vite/src/rsc/rscWorker.ts delete mode 100644 packages/vite/src/rsc/rscWorkerCommunication.ts diff --git a/packages/vite/src/rsc/rscRenderer.ts b/packages/vite/src/rsc/rscRenderer.ts index 3d604f07a5e1..01af2b0ebefb 100644 --- a/packages/vite/src/rsc/rscRenderer.ts +++ b/packages/vite/src/rsc/rscRenderer.ts @@ -5,7 +5,6 @@ import { createElement } from 'react' import { renderToReadableStream } from 'react-server-dom-webpack/server.edge' -import type { ServerAuthState } from '@redwoodjs/auth/dist/AuthProvider/ServerAuthProvider.js' import { getPaths } from '@redwoodjs/project-config' import type { RscFetchProps } from '../../../router/src/rsc/ClientRouter.tsx' @@ -17,15 +16,14 @@ export type RenderInput = { props: RscFetchProps | Record rsaId?: string | undefined args?: unknown[] | undefined - serverState: { - headersInit: Record - fullUrl: string - serverAuthState: ServerAuthState - } } let absoluteClientEntries: Record = {} +export function renderRscToStream(input: RenderInput): Promise { + return input.rscId ? renderRsc(input) : executeRsa(input) +} + async function loadServerFile(filePath: string) { console.log('rscRenderer.ts loadServerFile filePath', filePath) return import(`file://${filePath}`) @@ -111,7 +109,7 @@ function getBundlerConfig() { return bundlerConfig } -export async function renderRsc(input: RenderInput): Promise { +async function renderRsc(input: RenderInput): Promise { if (input.rsaId || !input.args) { throw new Error( "Unexpected input. Can't request both RSCs and execute RSAs at the same time.", @@ -149,7 +147,7 @@ function isSerializedFormData(data?: unknown): data is SerializedFormData { return !!data && (data as SerializedFormData)?.__formData__ } -export async function executeRsa(input: RenderInput): Promise { +async function executeRsa(input: RenderInput): Promise { console.log('executeRsa input', input) if (!input.rsaId || !input.args) { diff --git a/packages/vite/src/rsc/rscRequestHandler.ts b/packages/vite/src/rsc/rscRequestHandler.ts index cd345eab064c..52aa126f6df1 100644 --- a/packages/vite/src/rsc/rscRequestHandler.ts +++ b/packages/vite/src/rsc/rscRequestHandler.ts @@ -1,3 +1,5 @@ +import { Readable } from 'node:stream' + import * as DefaultFetchAPI from '@whatwg-node/fetch' import { normalizeNodeRequest } from '@whatwg-node/server' import busboy from 'busboy' @@ -10,7 +12,6 @@ import type { HTTPMethod } from 'find-my-way' import type { ViteDevServer } from 'vite' import type { RscFetchProps } from '@redwoodjs/router/RscRouter' -import { getAuthState, getRequestHeaders } from '@redwoodjs/server-store' import type { Middleware } from '@redwoodjs/web/dist/server/middleware' import { @@ -19,10 +20,9 @@ import { } from '../bundled/react-server-dom-webpack.server.js' import { hasStatusCode } from '../lib/StatusError.js' import { invoke } from '../middleware/invokeMiddleware.js' -import { getFullUrlForFlightRequest } from '../utils.js' +import { renderRscToStream } from './rscRenderer.js' import { sendRscFlightToStudio } from './rscStudioHandlers.js' -import { renderRsc } from './rscWorkerCommunication.js' const BASE_PATH = '/rw-rsc/' @@ -188,27 +188,11 @@ export function createRscRequestHandler( } try { - // We construct the URL for the flight request from props - // e.g. http://localhost:8910/rw-rsc/__rwjs__Routes?props=location={pathname:"/about",search:"?foo=bar""} - // becomes http://localhost:8910/about?foo=bar - // In the component, getting location would otherwise be at the rw-rsc URL - const fullUrl = getFullUrlForFlightRequest(req, props) + const readable = await renderRscToStream({ rscId, props, rsaId, args }) - const pipeable = renderRsc({ - rscId, - props, - rsaId, - args, - // Pass the serverState from server to the worker - // Inside the worker, we'll use this to re-initalize the server state (because workers are stateless) - serverState: { - headersInit: Object.fromEntries(getRequestHeaders().entries()), - serverAuthState: getAuthState(), - fullUrl, - }, - }) + Readable.fromWeb(readable).pipe(res) - // TODO (RSC): Can we reuse `pipeable` here somehow? + // TODO (RSC): Can we reuse `readable` here somehow? await sendRscFlightToStudio({ rscId, props, @@ -221,8 +205,6 @@ export function createRscRequestHandler( // TODO (RSC): See if we can/need to do more error handling here // pipeable.on(handleError) - - pipeable.pipe(res) } catch (e) { handleError(e) } diff --git a/packages/vite/src/rsc/rscStudioHandlers.ts b/packages/vite/src/rsc/rscStudioHandlers.ts index d5a213234740..570ed1e06aa7 100644 --- a/packages/vite/src/rsc/rscStudioHandlers.ts +++ b/packages/vite/src/rsc/rscStudioHandlers.ts @@ -1,15 +1,12 @@ import http from 'node:http' -import type { PassThrough } from 'node:stream' +import type { ReadableStream } from 'node:stream/web' import type { Request } from 'express' import { getConfig, getRawConfig } from '@redwoodjs/project-config' -import { getAuthState, getRequestHeaders } from '@redwoodjs/server-store' - -import { getFullUrlForFlightRequest } from '../utils.js' +import { renderRscToStream } from './rscRenderer.js' import type { RenderInput } from './rscRenderer.js' -import { renderRsc } from './rscWorkerCommunication.js' const isTest = () => { return process.env.NODE_ENV === 'test' @@ -36,24 +33,21 @@ const getStudioPort = () => { } const processRenderRscStream = async ( - pipeable: PassThrough, + readable: ReadableStream, ): Promise => { return new Promise((resolve, reject) => { - const chunks = [] as any - - pipeable.on('data', (chunk: any) => { - chunks.push(chunk) - }) + const chunks: Uint8Array[] = [] - pipeable.on('end', () => { - const resultBuffer = Buffer.concat(chunks) - const resultString = resultBuffer.toString('utf-8') - resolve(resultString) + const writable = new WritableStream({ + write(chunk) { + chunks.push(chunk) + }, + close() { + resolve(Buffer.concat(chunks).toString('utf8')) + }, }) - pipeable.on('error', (error) => { - reject(error) - }) + readable.pipeTo(writable).catch((error) => reject(error)) }) } @@ -100,11 +94,11 @@ const postFlightToStudio = (payload: string, metadata: Record) => { } const createStudioFlightHandler = ( - pipeable: PassThrough, + readable: ReadableStream, metadata: Record, ) => { if (shouldSendToStudio()) { - processRenderRscStream(pipeable) + processRenderRscStream(readable) .then((payload) => { console.debug('Sending RSC Rendered stream to Studio') postFlightToStudio(payload, metadata) @@ -136,21 +130,11 @@ export const sendRscFlightToStudio = async (input: StudioRenderInput) => { const startedAt = Date.now() const start = performance.now() - // We construct the URL for the flight request from props - // e.g. http://localhost:8910/rw-rsc/__rwjs__Routes?props=location={pathname:"/about",search:"?foo=bar""} - // becomes http://localhost:8910/about?foo=bar - const fullUrl = getFullUrlForFlightRequest(req, props) - - const pipeable = renderRsc({ + const readable = await renderRscToStream({ rscId, props, rsaId, args, - serverState: { - headersInit: Object.fromEntries(getRequestHeaders().entries()), - serverAuthState: getAuthState(), - fullUrl, - }, }) const endedAt = Date.now() const end = performance.now() @@ -178,7 +162,7 @@ export const sendRscFlightToStudio = async (input: StudioRenderInput) => { } // send rendered request to Studio - createStudioFlightHandler(pipeable as PassThrough, metadata) + createStudioFlightHandler(readable, metadata) } catch (e) { if (e instanceof Error) { console.error('An error occurred rendering RSC and sending to Studio:', e) diff --git a/packages/vite/src/rsc/rscWorker.ts b/packages/vite/src/rsc/rscWorker.ts deleted file mode 100644 index eeb6b194007a..000000000000 --- a/packages/vite/src/rsc/rscWorker.ts +++ /dev/null @@ -1,114 +0,0 @@ -// This is a dedicated worker for RSCs. -// It's needed because the main process can't be loaded with -// `--condition react-server`. If we did try to do that the main process -// couldn't do SSR because it would be missing client-side React functions -// like `useState` and `createContext`. -import { Buffer } from 'node:buffer' -import { parentPort } from 'node:worker_threads' - -import { - createPerRequestMap, - createServerStorage, -} from '@redwoodjs/server-store' - -import { registerFwGlobalsAndShims } from '../lib/registerFwGlobalsAndShims.js' - -import { executeRsa, renderRsc, setClientEntries } from './rscRenderer.js' -import type { MessageReq, MessageRes } from './rscWorkerCommunication.js' - -const serverStorage = createServerStorage() - -const handleSetClientEntries = async ({ - id, -}: MessageReq & { type: 'setClientEntries' }) => { - try { - await setClientEntries() - - 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 ({ id, input }: MessageReq & { type: 'render' }) => { - console.log('handleRender', id, input) - - // Assumes that handleRender is only called once per request! - const reqMap = createPerRequestMap({ - headers: input.serverState.headersInit, - fullUrl: input.serverState.fullUrl, - serverAuthState: input.serverState.serverAuthState, - }) - - serverStorage.run(reqMap, async () => { - try { - // @MARK run render with map initialised - const readable = input.rscId - ? await renderRsc(input) - : await executeRsa(input) - - const writable = new WritableStream({ - write(chunk) { - if (!parentPort) { - throw new Error('parentPort is undefined') - } - - const buffer = Buffer.from(chunk) - const message: MessageRes = { - id, - type: 'buf', - buf: buffer.buffer, - offset: buffer.byteOffset, - len: buffer.length, - } - parentPort.postMessage(message, [message.buf]) - }, - close() { - if (!parentPort) { - throw new Error('parentPort is undefined') - } - - const message: MessageRes = { id, type: 'end' } - parentPort.postMessage(message) - }, - }) - - readable.pipeTo(writable) - } catch (err) { - if (!parentPort) { - throw new Error('parentPort is undefined') - } - - const message: MessageRes = { id, type: 'err', err } - parentPort.postMessage(message) - } - }) -} - -// This is a worker, so it doesn't share the same global variables as the main -// server. So we have to register them here again. -registerFwGlobalsAndShims() - -if (!parentPort) { - throw new Error('parentPort is undefined') -} - -parentPort.on('message', (message: MessageReq) => { - console.log('message', message) - - if (message.type === 'setClientEntries') { - handleSetClientEntries(message) - } else if (message.type === 'render') { - handleRender(message) - } -}) diff --git a/packages/vite/src/rsc/rscWorkerCommunication.ts b/packages/vite/src/rsc/rscWorkerCommunication.ts deleted file mode 100644 index f05f23367535..000000000000 --- a/packages/vite/src/rsc/rscWorkerCommunication.ts +++ /dev/null @@ -1,111 +0,0 @@ -import path from 'node:path' -import type { Readable } from 'node:stream' -import { PassThrough } from 'node:stream' -import { fileURLToPath } from 'node:url' -import { Worker } from 'node:worker_threads' - -import type { RenderInput } from './rscRenderer.js' - -const workerPath = path.join( - // __dirname. Use fileURLToPath for windows compatibility - path.dirname(fileURLToPath(import.meta.url)), - 'rscWorker.js', -) - -const worker = new Worker(workerPath, { - execArgv: [ - '--conditions', - 'react-server', - '--experimental-loader', - '@redwoodjs/vite/react-node-loader', - ], -}) - -export type MessageReq = - | { - id: number - type: 'setClientEntries' - } - | { - id: number - type: 'render' - input: RenderInput - } - -export type MessageRes = - | { type: 'full-reload' } - | { id: number; type: 'buf'; buf: ArrayBuffer; offset: number; len: number } - | { id: number; type: 'end' } - | { id: number; type: 'err'; err: unknown } - -const messageCallbacks = new Map void>() - -worker.on('message', (message: MessageRes) => { - if ('id' in message) { - messageCallbacks.get(message.id)?.(message) - } -}) - -export function registerReloadCallback(fn: (type: 'full-reload') => void) { - const listener = (message: MessageRes) => { - if (message.type === 'full-reload') { - fn(message.type) - } - } - - worker.on('message', listener) - - return () => worker.off('message', listener) -} - -let nextId = 1 - -/** Set the client entries in the worker (for the server build) */ -export function setClientEntries(): Promise { - // Just making this function async instead of callback based - return new Promise((resolve, reject) => { - const id = nextId++ - - messageCallbacks.set(id, (message) => { - if (message.type === 'end') { - resolve() - messageCallbacks.delete(id) - } else if (message.type === 'err') { - reject(message.err) - messageCallbacks.delete(id) - } - }) - - const message: MessageReq = { id, type: 'setClientEntries' } - worker.postMessage(message) - }) -} - -export function renderRsc(input: RenderInput): Readable { - // TODO (RSC): What's the biggest number JS handles here? What happens when - // it overflows? Will it just start over at 0? If so, we should be fine. If - // not, we need to figure out a more robust way to handle this. - const id = nextId++ - const passthrough = new PassThrough() - - messageCallbacks.set(id, (message) => { - if (message.type === 'buf') { - passthrough.write(Buffer.from(message.buf, message.offset, message.len)) - } else if (message.type === 'end') { - passthrough.end() - messageCallbacks.delete(id) - } else if (message.type === 'err') { - passthrough.destroy( - message.err instanceof Error - ? message.err - : new Error(String(message.err)), - ) - messageCallbacks.delete(id) - } - }) - - const message: MessageReq = { id, type: 'render', input } - worker.postMessage(message) - - return passthrough -} diff --git a/packages/vite/src/runFeServer.ts b/packages/vite/src/runFeServer.ts index 9d5d11a1c36e..a53f521d7336 100644 --- a/packages/vite/src/runFeServer.ts +++ b/packages/vite/src/runFeServer.ts @@ -61,7 +61,7 @@ export async function runFeServer() { registerFwGlobalsAndShims() if (rscEnabled) { - const { setClientEntries } = await import('./rsc/rscWorkerCommunication.js') + const { setClientEntries } = await import('./rsc/rscRenderer.js') createWebSocketServer() @@ -147,7 +147,6 @@ export async function runFeServer() { const perReqStore = createPerRequestMap({ headers, fullUrl }) // By wrapping next, we ensure that all of the other handlers will use this same perReqStore - // But note that the serverStorage is RE-initialised for the RSC worker serverStorage.run(perReqStore, next) }) diff --git a/packages/vite/src/utils.ts b/packages/vite/src/utils.ts index 678fe6808857..430854640638 100644 --- a/packages/vite/src/utils.ts +++ b/packages/vite/src/utils.ts @@ -3,7 +3,7 @@ import { pathToFileURL } from 'node:url' import type { Request as ExpressRequest } from 'express' import type { ViteDevServer } from 'vite' -import { getPaths } from '@redwoodjs/project-config' +import { getConfig, getPaths } from '@redwoodjs/project-config' import type { RscFetchProps } from '@redwoodjs/router/RscRouter' import type { EntryServer } from './types.js' @@ -68,6 +68,8 @@ export function convertExpressHeaders( } export const getFullUrl = (req: ExpressRequest) => { + const rscEnabled = getConfig().experimental?.rsc?.enabled + // For a standard request: // // req.originalUrl /about @@ -75,6 +77,8 @@ export const getFullUrl = (req: ExpressRequest) => { // req.headers.host localhost:8910 // req.get('host') localhost:8910 // baseUrl http://localhost:8910 + // url.href http://localhost:8910/about + // props {} // // For an RSC request: // @@ -83,6 +87,8 @@ export const getFullUrl = (req: ExpressRequest) => { // req.headers.host localhost:8910 // req.get('host') localhost:8910 // baseUrl http://localhost:8910 + // url.href http://localhost:8910/rw-rsc/__rwjs__Routes?props=%7B%22location%22%3A%7B%22pathname%22%3A%22%2Fabout%22%2C%22search%22%3A%22%22%7D%7D + // props { location: { pathname: '/about', search: '' } } console.log('getFullUrl req.originalUrl', req.originalUrl) console.log('getFullUrl req.protocol', req.protocol) @@ -93,7 +99,23 @@ export const getFullUrl = (req: ExpressRequest) => { console.log('getFullUrl baseUrl', baseUrl) - return baseUrl + req.originalUrl + // Properly parsing search params is difficult, so let's construct a URL + // object and have it do the parsing for us. + const url = new URL(req.originalUrl || '', baseUrl) + + // `props` will be something like: + // "location":{"pathname":"/about","search":""} + const props: RscFetchProps = JSON.parse(url.searchParams.get('props') || '{}') + + console.log('getFullUrl url.href', url.href) + console.log('getFullUrl props', props) + + const pathPlusSearch = + rscEnabled && isRscFetchProps(props) + ? props.location.pathname + props.location.search + : req.originalUrl + + return baseUrl + pathPlusSearch } function isRscFetchProps( @@ -105,22 +127,3 @@ function isRscFetchProps( 'pathname' in rscPropsMaybe.location ) } - -export const getFullUrlForFlightRequest = ( - req: ExpressRequest, - rscPropsMaybe: RscFetchProps | Record, -): string => { - if (isRscFetchProps(rscPropsMaybe)) { - return ( - req.protocol + - '://' + - req.get('host') + - rscPropsMaybe.location.pathname + - rscPropsMaybe.location.search - ) - } else { - // If it's not an RscFetchProps, then the url can be returned as is (for - // RSA requests) - return getFullUrl(req) - } -}