Skip to content

Commit

Permalink
Use RSC payload t orender server components on server
Browse files Browse the repository at this point in the history
  • Loading branch information
AbanoubGhadban committed Feb 10, 2025
1 parent a3ad230 commit 3cace71
Show file tree
Hide file tree
Showing 17 changed files with 216 additions and 35 deletions.
9 changes: 7 additions & 2 deletions lib/react_on_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions lib/react_on_rails/packs_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 17 additions & 6 deletions lib/react_on_rails/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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?
Expand Down
59 changes: 59 additions & 0 deletions node_package/src/RSCServerRoot.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
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<string, unknown>,
};

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;
3 changes: 3 additions & 0 deletions node_package/src/ReactOnRails.node.ts
Original file line number Diff line number Diff line change
@@ -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';
2 changes: 2 additions & 0 deletions node_package/src/ReactOnRails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ ctx.ReactOnRails = {
resetOptions(): void {
this.options = Object.assign({}, DEFAULT_OPTIONS);
},

isRSCBundle: false,
};

ctx.ReactOnRails.resetOptions();
Expand Down
6 changes: 4 additions & 2 deletions node_package/src/ReactOnRailsRSC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -69,5 +69,7 @@ ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => {
}
};

ReactOnRails.isRSCBundle = true;

export * from './types';
export default ReactOnRails;
22 changes: 22 additions & 0 deletions node_package/src/loadJsonFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import path from 'path';
import fs from 'fs';

const loadedJsonFiles = new Map<string, { [key: string]: unknown; }>();

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)!;
}
17 changes: 0 additions & 17 deletions node_package/src/loadReactClientManifest.ts

This file was deleted.

19 changes: 19 additions & 0 deletions node_package/src/registerServerComponent.node.ts
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions node_package/src/registerServerComponent.rsc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import ReactOnRails from './ReactOnRails';
import { ReactComponent } from './types';

const registerServerComponent = (components: { [id: string]: ReactComponent }) => {
ReactOnRails.register(components);
};

export default registerServerComponent;
12 changes: 9 additions & 3 deletions node_package/src/streamServerRenderedReactComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -145,6 +145,12 @@ export const streamServerRenderedComponent = <T, P extends RenderParams>(
}
};

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;
31 changes: 31 additions & 0 deletions node_package/src/transformRSCNodeStreamAndReplayConsoleLogs.ts
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 6 additions & 0 deletions node_package/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -197,6 +202,7 @@ export interface ReactOnRails {
stores(): Map<string, Store>;
resetOptions(): void;
options: Record<string, string | number | boolean>;
isRSCBundle: boolean;
}

export type RenderState = {
Expand Down
Loading

0 comments on commit 3cace71

Please sign in to comment.