diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 6a17a0d90..5aa28c2dd 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -10,6 +10,7 @@ def self.configure DEFAULT_GENERATED_ASSETS_DIR = File.join(%w[public webpack], Rails.env).freeze DEFAULT_REACT_CLIENT_MANIFEST_FILE = "react-client-manifest.json" + DEFAULT_REACT_SERVER_MANIFEST_FILE = "react-server-manifest.json" DEFAULT_COMPONENT_REGISTRY_TIMEOUT = 5000 def self.configuration @@ -21,6 +22,7 @@ def self.configuration server_bundle_js_file: "", rsc_bundle_js_file: "", react_client_manifest_file: DEFAULT_REACT_CLIENT_MANIFEST_FILE, + react_server_manifest_file: DEFAULT_REACT_SERVER_MANIFEST_FILE, prerender: false, auto_load_bundle: false, replay_console: true, @@ -65,7 +67,7 @@ class Configuration :same_bundle_for_client_and_server, :rendering_props_extension, :make_generated_server_bundle_the_entrypoint, :defer_generated_component_packs, :force_load, :rsc_bundle_js_file, - :react_client_manifest_file, :component_registry_timeout + :react_client_manifest_file, :react_server_manifest_file, :component_registry_timeout # rubocop:disable Metrics/AbcSize def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil, @@ -81,7 +83,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil, i18n_yml_safe_load_options: nil, random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil, components_subdirectory: nil, auto_load_bundle: nil, force_load: nil, - rsc_bundle_js_file: nil, react_client_manifest_file: nil, component_registry_timeout: nil) + rsc_bundle_js_file: nil, react_client_manifest_file: nil, react_server_manifest_file: nil, + component_registry_timeout: nil) self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root self.generated_assets_dirs = generated_assets_dirs self.generated_assets_dir = generated_assets_dir @@ -111,6 +114,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender self.server_bundle_js_file = server_bundle_js_file self.rsc_bundle_js_file = rsc_bundle_js_file self.react_client_manifest_file = react_client_manifest_file + self.react_server_manifest_file = react_server_manifest_file self.same_bundle_for_client_and_server = same_bundle_for_client_and_server self.server_renderer_pool_size = self.development_mode ? 1 : server_renderer_pool_size self.server_renderer_timeout = server_renderer_timeout # seconds @@ -266,6 +270,7 @@ def ensure_webpack_generated_files_exists files << server_bundle_js_file if server_bundle_js_file.present? files << rsc_bundle_js_file if rsc_bundle_js_file.present? files << react_client_manifest_file if react_client_manifest_file.present? + files << react_server_manifest_file if react_server_manifest_file.present? self.webpack_generated_files = files end diff --git a/lib/react_on_rails/packs_generator.rb b/lib/react_on_rails/packs_generator.rb index 30be75993..d38b11661 100644 --- a/lib/react_on_rails/packs_generator.rb +++ b/lib/react_on_rails/packs_generator.rb @@ -129,14 +129,24 @@ def generated_server_pack_file_content "import #{name} from '#{relative_path(generated_server_bundle_file_path, component_path)}';" end - components_to_register = component_for_server_registration_to_path.keys + load_server_components = ReactOnRails::Utils.react_on_rails_pro? && + ReactOnRailsPro.configuration.enable_rsc_support + server_components_to_register = component_for_server_registration_to_path.keys.delete_if do |name| + next true unless load_server_components + + component_path = component_for_server_registration_to_path[name] + client_entrypoint?(component_path) + end + client_components_to_register = component_for_server_registration_to_path.keys - server_components_to_register <<~FILE_CONTENT import ReactOnRails from 'react-on-rails'; + import registerServerComponent from 'react-on-rails/registerServerComponent'; #{server_component_imports.join("\n")} - ReactOnRails.register({#{components_to_register.join(",\n")}}); + ReactOnRails.register({#{client_components_to_register.join(",\n")}}); + registerServerComponent({#{server_components_to_register.join(",\n")}}); FILE_CONTENT end diff --git a/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb b/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb index f3014af8d..a201364a0 100644 --- a/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb +++ b/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb @@ -52,6 +52,8 @@ def all_compiled_assets webpack_generated_files = @webpack_generated_files.map do |bundle_name| if bundle_name == ReactOnRails.configuration.react_client_manifest_file ReactOnRails::Utils.react_client_manifest_file_path + elsif bundle_name == ReactOnRails.configuration.react_server_manifest_file + ReactOnRails::Utils.react_server_manifest_file_path else ReactOnRails::Utils.bundle_js_file_path(bundle_name) end diff --git a/lib/react_on_rails/utils.rb b/lib/react_on_rails/utils.rb index c1a794a8f..88417b6cb 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -95,6 +95,14 @@ def self.bundle_js_file_path(bundle_name) end end + def self.asset_file_path(asset_name) + if ReactOnRails::PackerUtils.using_packer? + ReactOnRails::PackerUtils.asset_uri_from_packer(asset_name) + else + File.join(generated_assets_full_path, asset_name) + end + end + def self.server_bundle_js_file_path return @server_bundle_path if @server_bundle_path && !Rails.env.development? @@ -112,12 +120,15 @@ def self.rsc_bundle_js_file_path def self.react_client_manifest_file_path return @react_client_manifest_path if @react_client_manifest_path && !Rails.env.development? - file_name = ReactOnRails.configuration.react_client_manifest_file - @react_client_manifest_path = if ReactOnRails::PackerUtils.using_packer? - ReactOnRails::PackerUtils.asset_uri_from_packer(file_name) - else - File.join(generated_assets_full_path, file_name) - end + asset_name = ReactOnRails.configuration.react_client_manifest_file + @react_client_manifest_path = asset_file_path(asset_name) + end + + def self.react_server_manifest_file_path + return @react_server_manifest_path if @react_server_manifest_path && !Rails.env.development? + + asset_name = ReactOnRails.configuration.react_server_manifest_file + @react_server_manifest_path = File.join(generated_assets_full_path, asset_name) end def self.running_on_windows? diff --git a/node_package/src/RSCServerRoot.ts b/node_package/src/RSCServerRoot.ts new file mode 100644 index 000000000..597e6280d --- /dev/null +++ b/node_package/src/RSCServerRoot.ts @@ -0,0 +1,59 @@ +import fs from 'fs'; +import * as React from 'react'; +import RSDWClient from 'react-server-dom-webpack/client.node'; +import transformRSCStream from './transformRSCNodeStreamAndReplayConsoleLogs'; +import loadJsonFile from './loadJsonFile'; + +if (!('use' in React && typeof React.use === 'function')) { + throw new Error('React.use is not defined. Please ensure you are using React 18 with experimental features enabled or React 19+ to use server components.'); +} + +const { use } = React; + +export type RSCServerRootProps = { + getRscPromise: NodeJS.ReadableStream, + reactClientManifestFileName: string, + reactServerManifestFileName: string, +} + +const createFromFetch = (stream: NodeJS.ReadableStream, ssrManifest: Record) => { + const transformedStream = transformRSCStream(stream); + return RSDWClient.createFromNodeStream(transformedStream, ssrManifest); +} + +const createSSRManifest = (reactServerManifestFileName: string, reactClientManifestFileName: string) => { + const reactServerManifest = loadJsonFile(reactServerManifestFileName); + const reactClientManifest = loadJsonFile(reactClientManifestFileName); + + const ssrManifest = { + moduleLoading: { + prefix: "/webpack/development/", + crossOrigin: null, + }, + moduleMap: {} as Record, + }; + + Object.entries(reactClientManifest).forEach(([aboluteFileUrl, clientFileBundlingInfo]) => { + const serverFileBundlingInfo = reactServerManifest[aboluteFileUrl]; + ssrManifest.moduleMap[(clientFileBundlingInfo as { id: string }).id] = { + '*': { + id: (serverFileBundlingInfo as { id: string }).id, + chunks: (serverFileBundlingInfo as { chunks: string[] }).chunks, + name: '*', + } + }; + }); + + return ssrManifest; +} + +const RSCServerRoot = ({ + getRscPromise, + reactClientManifestFileName, + reactServerManifestFileName, +}: RSCServerRootProps) => { + const ssrManifest = createSSRManifest(reactServerManifestFileName, reactClientManifestFileName); + return use(createFromFetch(getRscPromise, ssrManifest)); +}; + +export default RSCServerRoot; diff --git a/node_package/src/ReactOnRails.node.ts b/node_package/src/ReactOnRails.node.ts index fea963ca0..e7c4a0cfd 100644 --- a/node_package/src/ReactOnRails.node.ts +++ b/node_package/src/ReactOnRails.node.ts @@ -1,7 +1,10 @@ import ReactOnRails from './ReactOnRails'; import streamServerRenderedReactComponent from './streamServerRenderedReactComponent'; +import RSCServerRoot from './RSCServerRoot'; ReactOnRails.streamServerRenderedReactComponent = streamServerRenderedReactComponent; +// @ts-expect-error eeee +ReactOnRails.RSCServerRoot = RSCServerRoot; export * from './ReactOnRails'; export { default } from './ReactOnRails'; diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts index 02bcec4d5..2d409ebaa 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -335,6 +335,8 @@ ctx.ReactOnRails = { resetOptions(): void { this.options = Object.assign({}, DEFAULT_OPTIONS); }, + + isRSCBundle: false, }; ctx.ReactOnRails.resetOptions(); diff --git a/node_package/src/ReactOnRailsRSC.ts b/node_package/src/ReactOnRailsRSC.ts index 5c4bd24e5..679a93824 100644 --- a/node_package/src/ReactOnRailsRSC.ts +++ b/node_package/src/ReactOnRailsRSC.ts @@ -15,7 +15,7 @@ import { streamServerRenderedComponent, transformRenderStreamChunksToResultObject, } from './streamServerRenderedReactComponent'; -import loadReactClientManifest from './loadReactClientManifest'; +import loadJsonFile from './loadJsonFile'; const stringToStream = (str: string) => { const stream = new PassThrough(); @@ -36,7 +36,7 @@ const streamRenderRSCComponent = (reactElement: ReactElement, options: RSCRender try { const rscStream = renderToPipeableStream( reactElement, - loadReactClientManifest(reactClientManifestFileName), + loadJsonFile(reactClientManifestFileName), { onError: (err) => { const error = convertToError(err); @@ -69,5 +69,7 @@ ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => { } }; +ReactOnRails.isRSCBundle = true; + export * from './types'; export default ReactOnRails; diff --git a/node_package/src/loadJsonFile.ts b/node_package/src/loadJsonFile.ts new file mode 100644 index 000000000..8afab2de1 --- /dev/null +++ b/node_package/src/loadJsonFile.ts @@ -0,0 +1,22 @@ +import path from 'path'; +import fs from 'fs'; + +const loadedJsonFiles = new Map(); + +export default function loadJsonFile(fileName: string) { + // Asset JSON files are uploaded to node renderer. + // Renderer copies assets to the same place as the server-bundle.js and rsc-bundle.js. + // Thus, the __dirname of this code is where we can find the manifest file. + const filePath = path.resolve(__dirname, fileName); + if (!loadedJsonFiles.has(filePath)) { + try { + const file = JSON.parse(fs.readFileSync(filePath, 'utf8')); + loadedJsonFiles.set(filePath, file); + } catch (error) { + console.error(`Failed to load JSON file: ${filePath}`, error); + throw error; + } + } + + return loadedJsonFiles.get(filePath)!; +} diff --git a/node_package/src/loadReactClientManifest.ts b/node_package/src/loadReactClientManifest.ts deleted file mode 100644 index e79181912..000000000 --- a/node_package/src/loadReactClientManifest.ts +++ /dev/null @@ -1,17 +0,0 @@ -import path from 'path'; -import fs from 'fs'; - -const loadedReactClientManifests = new Map(); - -export default function loadReactClientManifest(reactClientManifestFileName: string) { - // React client manifest is uploaded to node renderer as an asset. - // Renderer copies assets to the same place as the server-bundle.js and rsc-bundle.js. - // Thus, the __dirname of this code is where we can find the manifest file. - const manifestPath = path.resolve(__dirname, reactClientManifestFileName); - if (!loadedReactClientManifests.has(manifestPath)) { - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - loadedReactClientManifests.set(manifestPath, manifest); - } - - return loadedReactClientManifests.get(manifestPath)!; -} diff --git a/node_package/src/registerServerComponent.node.ts b/node_package/src/registerServerComponent.node.ts new file mode 100644 index 000000000..2f69754b2 --- /dev/null +++ b/node_package/src/registerServerComponent.node.ts @@ -0,0 +1,19 @@ +import React from 'react'; +import ReactOnRails from './ReactOnRails'; +import RSCServerRoot, { RSCServerRootProps } from './RSCServerRoot'; +import { ReactComponent } from './types'; + +const registerServerComponent = (components: { [id: string]: ReactComponent }) => { + const componentsWrappedInRSCServerRoot = Object.entries(components).reduce( + (acc, [name]) => ({ + ...acc, + [name]: (props: RSCServerRootProps) => React.createElement(RSCServerRoot, { + ...props, + }) + }), + {} + ); + ReactOnRails.register(componentsWrappedInRSCServerRoot); +}; + +export default registerServerComponent; diff --git a/node_package/src/registerServerComponent.rsc.ts b/node_package/src/registerServerComponent.rsc.ts new file mode 100644 index 000000000..582f644f6 --- /dev/null +++ b/node_package/src/registerServerComponent.rsc.ts @@ -0,0 +1,8 @@ +import ReactOnRails from './ReactOnRails'; +import { ReactComponent } from './types'; + +const registerServerComponent = (components: { [id: string]: ReactComponent }) => { + ReactOnRails.register(components); +}; + +export default registerServerComponent; diff --git a/node_package/src/streamServerRenderedReactComponent.ts b/node_package/src/streamServerRenderedReactComponent.ts index 7e76a5140..f8c826117 100644 --- a/node_package/src/streamServerRenderedReactComponent.ts +++ b/node_package/src/streamServerRenderedReactComponent.ts @@ -8,7 +8,7 @@ import { isPromise, isServerRenderHash } from './isServerRenderResult'; import buildConsoleReplay from './buildConsoleReplay'; import handleError from './handleError'; import { createResultObject, convertToError, validateComponent } from './serverRenderUtils'; -import type { RenderParams, StreamRenderState } from './types'; +import type { RenderParams, StreamRenderParams, StreamRenderState } from './types'; const stringToStream = (str: string): Readable => { const stream = new PassThrough(); @@ -55,7 +55,7 @@ export const transformRenderStreamChunksToResultObject = (renderState: StreamRen return { readableStream, pipeToTransform, writeChunk, emitError, endStream }; } -const streamRenderReactComponent = (reactRenderingResult: ReactElement, options: RenderParams) => { +const streamRenderReactComponent = (reactRenderingResult: ReactElement, options: StreamRenderParams) => { const { name: componentName, throwJsErrors } = options; const renderState: StreamRenderState = { result: null, @@ -145,6 +145,12 @@ export const streamServerRenderedComponent = ( } }; -const streamServerRenderedReactComponent = (options: RenderParams): Readable => streamServerRenderedComponent(options, streamRenderReactComponent); +const streamServerRenderedReactComponent = (options: StreamRenderParams): Readable => { + const { rscResult, reactClientManifestFileName, reactServerManifestFileName } = options; + return streamServerRenderedComponent({ + ...options, + props: { ...options.props, getRscPromise: rscResult, reactClientManifestFileName, reactServerManifestFileName } + }, streamRenderReactComponent); +} export default streamServerRenderedReactComponent; diff --git a/node_package/src/transformRSCNodeStreamAndReplayConsoleLogs.ts b/node_package/src/transformRSCNodeStreamAndReplayConsoleLogs.ts new file mode 100644 index 000000000..ea6365a55 --- /dev/null +++ b/node_package/src/transformRSCNodeStreamAndReplayConsoleLogs.ts @@ -0,0 +1,31 @@ +import { Transform } from 'stream'; + +export default function transformRSCStream(stream: NodeJS.ReadableStream): NodeJS.ReadableStream { + const decoder = new TextDecoder(); + let lastIncompleteChunk = ''; + + const htmlExtractor = new Transform({ + transform(oneOrMoreChunks, _, callback) { + try { + const decodedChunk = lastIncompleteChunk + decoder.decode(oneOrMoreChunks); + const separateChunks = decodedChunk.split('\n').filter(chunk => chunk.trim() !== ''); + + if (!decodedChunk.endsWith('\n')) { + lastIncompleteChunk = separateChunks.pop() ?? ''; + } else { + lastIncompleteChunk = ''; + } + + for (const chunk of separateChunks) { + const parsedData = JSON.parse(chunk) as { html: string }; + this.push(parsedData.html); + } + callback(); + } catch (error) { + callback(error as Error); + } + } + }); + + return stream.pipe(htmlExtractor); +} diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index 6dfb2c3ce..2950255c5 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -131,6 +131,11 @@ export interface RSCRenderParams extends RenderParams { reactClientManifestFileName: string; } +export interface StreamRenderParams extends RSCRenderParams { + reactServerManifestFileName?: string; + rscResult?: string | Readable; +} + export interface CreateParams extends Params { componentObj: RegisteredComponent; shouldHydrate?: boolean; @@ -197,6 +202,7 @@ export interface ReactOnRails { stores(): Map; resetOptions(): void; options: Record; + isRSCBundle: boolean; } export type RenderState = { diff --git a/node_package/types/react-server-dom-webpack.d.ts b/node_package/types/react-server-dom-webpack.d.ts index 6f422e9c6..0690b7da3 100644 --- a/node_package/types/react-server-dom-webpack.d.ts +++ b/node_package/types/react-server-dom-webpack.d.ts @@ -39,7 +39,15 @@ declare module 'react-server-dom-webpack/server.node' { } declare module 'react-server-dom-webpack/client' { - export const createFromFetch: (promise: Promise) => Promise; + import type { ReactElement } from 'react'; - export const createFromReadableStream: (stream: ReadableStream) => Promise; + export const createFromFetch: (promise: Promise) => Promise; + + export const createFromReadableStream: (stream: ReadableStream) => Promise; +} + +declare module 'react-server-dom-webpack/client.node' { + import type { ReactElement } from 'react'; + + export const createFromNodeStream: (stream: NodeJS.ReadableStream, manifest: Record) => Promise; } diff --git a/package.json b/package.json index dc2b11156..80f1a9d37 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,11 @@ "default": "./node_package/lib/ReactOnRails.js" }, "./RSCWebpackLoader": "./node_package/lib/RSCWebpackLoader.js", - "./registerServerComponent": "./node_package/lib/registerServerComponent.js" + "./registerServerComponent": { + "rsc-server": "./node_package/lib/registerServerComponent.rsc.js", + "node": "./node_package/lib/registerServerComponent.node.js", + "default": "./node_package/lib/registerServerComponent.js" + } }, "directories": { "doc": "docs"