From 4d09e130580381b1c78a444feb3ebc104bf1f8e8 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 4 Apr 2026 00:18:02 -1000 Subject: [PATCH 01/11] Add React Server Components with React on Rails Pro Upgrade from react_on_rails to react_on_rails_pro gem (16.5.1) and corresponding npm packages. Add RSC infrastructure with a demo page at /server-components that showcases: - Server components using Node.js os module and lodash (never shipped to client) - Async data fetching with Suspense streaming (comments from Rails API) - Interactive client components ('use client' TogglePanel) mixed with server-rendered content (donut pattern) - Markdown rendering with marked + sanitize-html on server only Key changes: - Three-bundle build: client, server, and RSC bundles via Rspack - Custom RspackRscPlugin for manifest generation (the standard RSCWebpackPlugin uses webpack internals incompatible with Rspack) - 'use client' directives on all existing client component entry points - Alias react-on-rails to react-on-rails-pro in webpack resolve to handle third-party packages (rescript-react-on-rails) - Dedicated rsc-bundle.js entry with registerServerComponent - RSC payload route and client-side registration Co-Authored-By: Claude Opus 4.6 (1M context) --- Gemfile | 2 +- Gemfile.lock | 36 ++++- Procfile.dev | 2 + app/controllers/pages_controller.rb | 2 + app/views/pages/server_components.html.erb | 5 + .../Footer/ror_components/Footer.jsx | 2 + .../NavigationBar/NavigationBar.jsx | 8 + .../ror_components/SimpleCommentScreen.jsx | 4 +- .../app/bundles/comments/constants/paths.js | 1 + .../ror_components/RescriptShow.jsx | 2 + .../startup/App/ror_components/App.jsx | 4 +- .../startup/ClientRouterAppExpress.jsx | 2 +- .../ror_components/NavigationBarApp.jsx | 4 +- .../ror_components/RouterApp.client.jsx | 4 +- .../ror_components/RouterApp.server.jsx | 4 +- .../comments/startup/serverRegistration.jsx | 4 +- .../ServerComponentsPage.jsx | 129 ++++++++++++++++ .../components/CommentsFeed.jsx | 85 +++++++++++ .../components/ServerInfo.jsx | 58 +++++++ .../components/TogglePanel.jsx | 34 +++++ client/app/libs/requestsManager.js | 2 +- client/app/packs/rsc-bundle.js | 16 ++ client/app/packs/rsc-client-components.js | 11 ++ client/app/packs/stimulus-bundle.js | 12 +- client/app/packs/stores-registration.js | 4 +- config/initializers/react_on_rails_pro.rb | 9 ++ config/routes.rb | 4 + config/webpack/clientWebpackConfig.js | 22 ++- config/webpack/commonWebpackConfig.js | 7 + config/webpack/rscWebpackConfig.js | 111 ++++++++++++++ config/webpack/rspackRscPlugin.js | 144 ++++++++++++++++++ config/webpack/serverWebpackConfig.js | 21 ++- config/webpack/webpackConfig.js | 28 ++-- package.json | 11 +- yarn.lock | 61 +++++--- 35 files changed, 804 insertions(+), 51 deletions(-) create mode 100644 app/views/pages/server_components.html.erb create mode 100644 client/app/bundles/server-components/ServerComponentsPage.jsx create mode 100644 client/app/bundles/server-components/components/CommentsFeed.jsx create mode 100644 client/app/bundles/server-components/components/ServerInfo.jsx create mode 100644 client/app/bundles/server-components/components/TogglePanel.jsx create mode 100644 client/app/packs/rsc-bundle.js create mode 100644 client/app/packs/rsc-client-components.js create mode 100644 config/initializers/react_on_rails_pro.rb create mode 100644 config/webpack/rscWebpackConfig.js create mode 100644 config/webpack/rspackRscPlugin.js diff --git a/Gemfile b/Gemfile index e9eb85053..7acd9bfc0 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby "3.4.6" -gem "react_on_rails", "16.6.0.rc.0" +gem "react_on_rails_pro", "16.5.1" gem "shakapacker", "10.0.0.rc.0" # Bundle edge Rails instead: gem "rails", github: "rails/rails" diff --git a/Gemfile.lock b/Gemfile.lock index b290c3986..3e0ee2285 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -78,6 +78,12 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) + async (2.38.1) + console (~> 1.29) + fiber-annotation + io-event (~> 1.11) + metrics (~> 0.12) + traces (~> 0.18) autoprefixer-rails (10.4.16.0) execjs (~> 2) awesome_print (1.9.2) @@ -115,6 +121,10 @@ GEM coffee-script-source (1.12.2) concurrent-ruby (1.3.6) connection_pool (3.0.2) + console (1.34.3) + fiber-annotation + fiber-local (~> 1.1) + json coveralls_reborn (0.25.0) simplecov (>= 0.18.1, < 0.22.0) term-ansicolor (~> 1.6) @@ -146,16 +156,24 @@ GEM railties (>= 5.0.0) ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) + fiber-annotation (0.2.0) + fiber-local (1.1.0) + fiber-storage + fiber-storage (1.0.1) foreman (0.88.1) generator_spec (0.10.0) activesupport (>= 3.0.0) railties (>= 3.0.0) globalid (1.3.0) activesupport (>= 6.1) + http-2 (1.1.3) + httpx (1.7.5) + http-2 (>= 1.1.3) i18n (1.14.8) concurrent-ruby (~> 1.0) interception (0.5) io-console (0.8.2) + io-event (1.14.5) irb (1.17.0) pp (>= 0.6.0) prism (>= 1.3.0) @@ -165,6 +183,8 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) json (2.19.1) + jwt (2.10.2) + base64 language_server-protocol (3.17.0.5) launchy (3.0.1) addressable (~> 2.8) @@ -182,6 +202,7 @@ GEM marcel (1.1.0) matrix (0.4.2) method_source (1.1.0) + metrics (0.15.0) mini_mime (1.1.5) minitest (6.0.2) drb (~> 2.0) @@ -296,13 +317,23 @@ GEM erb psych (>= 4.0.0) tsort - react_on_rails (16.6.0.rc.0) + react_on_rails (16.5.1) addressable connection_pool execjs (~> 2.5) rails (>= 5.2) rainbow (~> 3.0) shakapacker (>= 6.0) + react_on_rails_pro (16.5.1) + addressable + async (>= 2.29) + connection_pool + execjs (~> 2.9) + http-2 (>= 1.1.1) + httpx (~> 1.5) + jwt (~> 2.7) + rainbow + react_on_rails (= 16.5.1) redcarpet (3.6.0) redis (5.3.0) redis-client (>= 0.22.0) @@ -425,6 +456,7 @@ GEM tins (1.33.0) bigdecimal sync + traces (0.18.2) tsort (0.2.0) turbo-rails (2.0.11) actionpack (>= 6.0.0) @@ -486,7 +518,7 @@ DEPENDENCIES rails-html-sanitizer rails_best_practices rainbow - react_on_rails (= 16.6.0.rc.0) + react_on_rails_pro (= 16.5.1) redcarpet redis (~> 5.0) rspec-rails (~> 6.0.0) diff --git a/Procfile.dev b/Procfile.dev index 102c0a8df..cae685eca 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -12,3 +12,5 @@ rails: bundle exec thrust bin/rails server -p 3000 wp-client: RAILS_ENV=development NODE_ENV=development bin/shakapacker-dev-server # Server Rspack watcher for SSR bundle wp-server: SERVER_BUNDLE_ONLY=yes bin/shakapacker --watch +# RSC Rspack watcher for React Server Components bundle +wp-rsc: RSC_BUNDLE_ONLY=true bin/shakapacker --watch diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 507cc6cf7..f435f04eb 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -38,6 +38,8 @@ def simple; end def rescript; end + def server_components; end + private def set_comments diff --git a/app/views/pages/server_components.html.erb b/app/views/pages/server_components.html.erb new file mode 100644 index 000000000..edff32059 --- /dev/null +++ b/app/views/pages/server_components.html.erb @@ -0,0 +1,5 @@ +<%= append_javascript_pack_tag('rsc-client-components') %> +<%= react_component("ServerComponentsPage", + prerender: false, + trace: true, + id: "ServerComponentsPage-react-component-0") %> diff --git a/client/app/bundles/comments/components/Footer/ror_components/Footer.jsx b/client/app/bundles/comments/components/Footer/ror_components/Footer.jsx index 5e7f42104..d153dfb22 100644 --- a/client/app/bundles/comments/components/Footer/ror_components/Footer.jsx +++ b/client/app/bundles/comments/components/Footer/ror_components/Footer.jsx @@ -1,3 +1,5 @@ +'use client'; + import React from 'react'; import PropTypes from 'prop-types'; import BaseComponent from 'libs/components/BaseComponent'; diff --git a/client/app/bundles/comments/components/NavigationBar/NavigationBar.jsx b/client/app/bundles/comments/components/NavigationBar/NavigationBar.jsx index db2b4e53c..30b99f371 100644 --- a/client/app/bundles/comments/components/NavigationBar/NavigationBar.jsx +++ b/client/app/bundles/comments/components/NavigationBar/NavigationBar.jsx @@ -102,6 +102,14 @@ function NavigationBar(props) { Rescript +
  • + + RSC Demo + +
  • { + return ( +
    +
    +

    + React Server Components Demo +

    +

    + This page is rendered using React Server Components with React on Rails Pro. + Server components run on the server and stream their output to the client, keeping + heavy dependencies out of the browser bundle entirely. +

    +
    + +
    + {/* Server Info - uses Node.js os module (impossible on client) */} +
    +

    + Server Environment + + Server Only + +

    + +
    + + {/* Interactive toggle - demonstrates mixing server + client components */} +
    +

    + Interactive Client Component + + Client Hydrated + +

    + +
    +

    + This toggle is a 'use client' component, meaning it ships JavaScript + to the browser for interactivity. But the content inside is rendered on the server + and passed as children — a key RSC pattern called the donut pattern. +

    +
      +
    • The TogglePanel wrapper runs on the client (handles click events)
    • +
    • The children content is rendered on the server (no JS cost)
    • +
    • Heavy libraries used by server components never reach the browser
    • +
    +
    +
    +
    + + {/* Async data fetching with Suspense streaming */} +
    +

    + Streamed Comments + + Async + Suspense + +

    +

    + Comments are fetched directly on the server using the Rails API. + The page shell renders immediately while this section streams in progressively. +

    + + {[1, 2, 3].map((i) => ( +
    +
    +
    +
    + ))} +
    + } + > + + +
    + + {/* Architecture explanation */} +
    +

    + What makes this different? +

    +
    +
    +

    Smaller Client Bundle

    +

    + Libraries like lodash, marked, and Node.js os module + are used on this page but never downloaded by the browser. +

    +
    +
    +

    Direct Data Access

    +

    + Server components fetch data by calling your Rails API internally — no + client-side fetch waterfalls or loading spinners for initial data. +

    +
    +
    +

    Progressive Streaming

    +

    + The page shell renders instantly. Async components (like the comments feed) + stream in as their data resolves, with Suspense boundaries showing fallbacks. +

    +
    +
    +

    Selective Hydration

    +

    + Only client components (like the toggle above) receive JavaScript. + Everything else is pure HTML — zero hydration cost. +

    +
    +
    +
    +
    +
    + ); +}; + +export default ServerComponentsPage; diff --git a/client/app/bundles/server-components/components/CommentsFeed.jsx b/client/app/bundles/server-components/components/CommentsFeed.jsx new file mode 100644 index 000000000..3d023cad4 --- /dev/null +++ b/client/app/bundles/server-components/components/CommentsFeed.jsx @@ -0,0 +1,85 @@ +// Server Component - fetches comments directly from the Rails API on the server. +// Uses marked for markdown rendering. Both fetch and marked stay server-side. + +import React from 'react'; +import { Marked } from 'marked'; +import { gfmHeadingId } from 'marked-gfm-heading-id'; +import sanitizeHtml from 'sanitize-html'; +import _ from 'lodash'; +import TogglePanel from './TogglePanel'; + +const marked = new Marked(); +marked.use(gfmHeadingId()); + +async function CommentsFeed() { + // Simulate network latency to demonstrate streaming + await new Promise((resolve) => setTimeout(resolve, 800)); + + // Fetch comments directly from the Rails API — no client-side fetch needed + const response = await fetch('http://localhost:3000/comments.json'); + const comments = await response.json(); + + // Use lodash to process (stays on server) + const sortedComments = _.orderBy(comments, ['created_at'], ['desc']); + const recentComments = _.take(sortedComments, 10); + + if (recentComments.length === 0) { + return ( +
    +

    + No comments yet. Add some comments from the{' '} + home page to see them rendered here + by server components. +

    +
    + ); + } + + return ( +
    + {recentComments.map((comment) => { + // Render markdown on the server using marked + sanitize-html. + // sanitize-html strips any dangerous HTML before rendering. + // These libraries (combined ~200KB) never reach the client. + const rawHtml = marked.parse(comment.text || ''); + const safeHtml = sanitizeHtml(rawHtml, { + allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']), + }); + + return ( +
    +
    + {comment.author} + + {new Date(comment.created_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + +
    + + {/* Content is sanitized via sanitize-html before rendering */} +
    + +

    {comment.text}

    +
    + ); + })} +

    + {recentComments.length} comment{recentComments.length !== 1 ? 's' : ''} rendered on the server using + {' '}marked + sanitize-html (never sent to browser) +

    +
    + ); +} + +export default CommentsFeed; diff --git a/client/app/bundles/server-components/components/ServerInfo.jsx b/client/app/bundles/server-components/components/ServerInfo.jsx new file mode 100644 index 000000000..7c059e3c7 --- /dev/null +++ b/client/app/bundles/server-components/components/ServerInfo.jsx @@ -0,0 +1,58 @@ +// Server Component - uses Node.js os module, which only exists on the server. +// This component and its dependencies are never sent to the browser. + +import React from 'react'; +import os from 'os'; +import _ from 'lodash'; + +async function ServerInfo() { + const serverInfo = { + platform: os.platform(), + arch: os.arch(), + nodeVersion: process.version, + uptime: Math.floor(os.uptime() / 3600), + totalMemory: (os.totalmem() / (1024 * 1024 * 1024)).toFixed(1), + freeMemory: (os.freemem() / (1024 * 1024 * 1024)).toFixed(1), + cpus: os.cpus().length, + hostname: os.hostname(), + }; + + // Using lodash on the server — this 70KB+ library stays server-side + const infoEntries = _.toPairs(serverInfo); + const grouped = _.chunk(infoEntries, 4); + + const labels = { + platform: 'Platform', + arch: 'Architecture', + nodeVersion: 'Node.js', + uptime: 'Uptime (hrs)', + totalMemory: 'Total RAM (GB)', + freeMemory: 'Free RAM (GB)', + cpus: 'CPU Cores', + hostname: 'Hostname', + }; + + return ( +
    +

    + This data comes from the Node.js os module + — it runs only on the server. The lodash library + used to format it never reaches the browser. +

    +
    + {grouped.map((group, gi) => ( +
    + {group.map(([key, value]) => ( +
    + {labels[key] || key} + {value} +
    + ))} +
    + ))} +
    +
    + ); +} + +export default ServerInfo; diff --git a/client/app/bundles/server-components/components/TogglePanel.jsx b/client/app/bundles/server-components/components/TogglePanel.jsx new file mode 100644 index 000000000..f5a38a9eb --- /dev/null +++ b/client/app/bundles/server-components/components/TogglePanel.jsx @@ -0,0 +1,34 @@ +'use client'; + +import React, { useState } from 'react'; + +const TogglePanel = ({ title, children }) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
    + + {isOpen && ( +
    + {children} +
    + )} +
    + ); +}; + +export default TogglePanel; diff --git a/client/app/libs/requestsManager.js b/client/app/libs/requestsManager.js index 6b5fad453..c9209c7b4 100644 --- a/client/app/libs/requestsManager.js +++ b/client/app/libs/requestsManager.js @@ -1,5 +1,5 @@ import request from 'axios'; -import ReactOnRails from 'react-on-rails'; +import ReactOnRails from 'react-on-rails-pro'; const API_URL = 'comments.json'; diff --git a/client/app/packs/rsc-bundle.js b/client/app/packs/rsc-bundle.js new file mode 100644 index 000000000..83d8936a1 --- /dev/null +++ b/client/app/packs/rsc-bundle.js @@ -0,0 +1,16 @@ +// RSC (React Server Components) bundle entry point. +// This file is only used by the RSC bundle configuration. +// It imports the same client component registrations as server-bundle.js, +// plus the server component registrations. + +// Import existing client component registrations +import './stores-registration'; +import './../generated/server-bundle-generated.js'; + +// React Server Components registration (server-side) +import registerServerComponent from 'react-on-rails-pro/registerServerComponent/server'; +import ServerComponentsPage from '../bundles/server-components/ServerComponentsPage'; + +registerServerComponent({ + ServerComponentsPage, +}); diff --git a/client/app/packs/rsc-client-components.js b/client/app/packs/rsc-client-components.js new file mode 100644 index 000000000..b33ff5a20 --- /dev/null +++ b/client/app/packs/rsc-client-components.js @@ -0,0 +1,11 @@ +'use client'; + +// RSC client components registration. +// Components with 'use client' that are used in server components must be +// available in a client bundle chunk so the React flight client can load them. + +import TogglePanel from '../bundles/server-components/components/TogglePanel'; +import ReactOnRails from 'react-on-rails-pro'; + +// Register as a standard component so it's bundled in a client-accessible chunk +ReactOnRails.register({ TogglePanel }); diff --git a/client/app/packs/stimulus-bundle.js b/client/app/packs/stimulus-bundle.js index a11ccf149..97b9586b5 100644 --- a/client/app/packs/stimulus-bundle.js +++ b/client/app/packs/stimulus-bundle.js @@ -1,4 +1,6 @@ -import ReactOnRails from 'react-on-rails'; +'use client'; + +import ReactOnRails from 'react-on-rails-pro'; import 'jquery-ujs'; import { Turbo } from '@hotwired/turbo-rails'; @@ -17,3 +19,11 @@ ReactOnRails.setOptions({ // Components are now auto-registered via ror_components directories // No need for manual registration + +// React Server Components registration (client-side) +import registerServerComponent from 'react-on-rails-pro/registerServerComponent/client'; + +registerServerComponent( + { rscPayloadGenerationUrlPath: 'rsc_payload/' }, + 'ServerComponentsPage', +); diff --git a/client/app/packs/stores-registration.js b/client/app/packs/stores-registration.js index 435653379..cecf0a958 100644 --- a/client/app/packs/stores-registration.js +++ b/client/app/packs/stores-registration.js @@ -1,4 +1,6 @@ -import ReactOnRails from 'react-on-rails'; +'use client'; + +import ReactOnRails from 'react-on-rails-pro'; import routerCommentsStore from '../bundles/comments/store/routerCommentsStore'; import commentsStore from '../bundles/comments/store/commentsStore'; diff --git a/config/initializers/react_on_rails_pro.rb b/config/initializers/react_on_rails_pro.rb new file mode 100644 index 000000000..a3a8c1312 --- /dev/null +++ b/config/initializers/react_on_rails_pro.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +ReactOnRailsPro.configure do |config| + # Enable React Server Components support + config.enable_rsc_support = true + + # RSC bundle file name (built by rscWebpackConfig.js) + config.rsc_bundle_js_file = "rsc-bundle.js" +end diff --git a/config/routes.rb b/config/routes.rb index 1d8c7b7a5..50a6b1e7e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,9 @@ Rails.application.routes.draw do # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html + # React Server Components payload route + rsc_payload_route + # Serve websocket cable requests in-process # mount ActionCable.server => '/cable' @@ -11,6 +14,7 @@ get "simple", to: "pages#simple" get "rescript", to: "pages#rescript" get "no-router", to: "pages#no_router" + get "server-components", to: "pages#server_components" # React Router needs a wildcard get "react-router(/*all)", to: "pages#index" diff --git a/config/webpack/clientWebpackConfig.js b/config/webpack/clientWebpackConfig.js index 6352208fb..84cd35230 100644 --- a/config/webpack/clientWebpackConfig.js +++ b/config/webpack/clientWebpackConfig.js @@ -3,6 +3,7 @@ const commonWebpackConfig = require('./commonWebpackConfig'); const { getBundler } = require('./bundlerUtils'); +const { RspackRscPlugin } = require('./rspackRscPlugin'); const configureClient = () => { const bundler = getBundler(); @@ -16,11 +17,28 @@ const configureClient = () => { }), ); - // server-bundle is special and should ONLY be built by the serverConfig - // In case this entry is not deleted, a very strange "window" not found + // RSC: Generate react-client-manifest.json for client component resolution + clientConfig.plugins.push(new RspackRscPlugin({ isServer: false })); + + // server-bundle and rsc-bundle should ONLY be built by their respective configs. + // In case these entries are not deleted, a very strange "window" not found // error shows referring to window["webpackJsonp"]. That is because the // client config is going to try to load chunks. delete clientConfig.entry['server-bundle']; + delete clientConfig.entry['rsc-bundle']; + + // react-on-rails-pro includes server-side code that imports Node.js modules. + // Provide empty fallbacks for the client bundle so it doesn't fail to resolve them. + clientConfig.resolve = { + ...clientConfig.resolve, + fallback: { + ...clientConfig.resolve?.fallback, + path: false, + fs: false, + 'fs/promises': false, + stream: false, + }, + }; return clientConfig; }; diff --git a/config/webpack/commonWebpackConfig.js b/config/webpack/commonWebpackConfig.js index 1a99ddbc5..6aad08238 100644 --- a/config/webpack/commonWebpackConfig.js +++ b/config/webpack/commonWebpackConfig.js @@ -8,6 +8,13 @@ const commonOptions = { resolve: { // Add .res.js extension for ReScript-compiled modules (modern ReScript convention) extensions: ['.css', '.ts', '.tsx', '.res.js'], + // Alias react-on-rails to react-on-rails-pro so third-party packages + // (like rescript-react-on-rails) that import 'react-on-rails' get the Pro version. + // This avoids "Cannot mix react-on-rails (core) with react-on-rails-pro" errors. + alias: { + 'react-on-rails$': 'react-on-rails-pro', + 'react-on-rails/node_package': 'react-on-rails-pro/node_package', + }, }, }; diff --git a/config/webpack/rscWebpackConfig.js b/config/webpack/rscWebpackConfig.js new file mode 100644 index 000000000..3d76a7148 --- /dev/null +++ b/config/webpack/rscWebpackConfig.js @@ -0,0 +1,111 @@ +// RSC (React Server Components) bundle configuration. +// +// This creates a third bundle alongside client and server bundles. +// The RSC bundle runs server components in the Node renderer and produces +// the Flight payload that React uses to hydrate on the client. +// +// Unlike the server bundle (which uses ExecJS), the RSC bundle targets Node.js +// and can use Node.js built-in modules like os, fs, path, etc. + +const path = require('path'); +const { config } = require('shakapacker'); +const commonWebpackConfig = require('./commonWebpackConfig'); +const { getBundler } = require('./bundlerUtils'); +const { RspackRscPlugin } = require('./rspackRscPlugin'); + +function extractLoader(rule, loaderName) { + if (!Array.isArray(rule.use)) return null; + return rule.use.find((item) => { + const testValue = typeof item === 'string' ? item : item?.loader; + return testValue && testValue.includes(loaderName); + }); +} + +const configureRsc = () => { + const bundler = getBundler(); + const rscConfig = commonWebpackConfig(); + + // Use the dedicated rsc-bundle entry point + const rscEntry = rscConfig.entry['rsc-bundle']; + if (!rscEntry) { + throw new Error( + 'RSC bundle entry not found. Ensure client/app/packs/rsc-bundle.js exists.', + ); + } + rscConfig.entry = { 'rsc-bundle': rscEntry }; + + // Remove CSS extraction plugins (same as server config — CSS handled by client) + rscConfig.plugins = rscConfig.plugins.filter( + (plugin) => + plugin.constructor.name !== 'WebpackAssetsManifest' && + plugin.constructor.name !== 'MiniCssExtractPlugin' && + plugin.constructor.name !== 'CssExtractRspackPlugin' && + plugin.constructor.name !== 'ForkTsCheckerWebpackPlugin', + ); + + // Remove CSS extraction loaders from style rules + rscConfig.module.rules.forEach((rule) => { + if (Array.isArray(rule.use)) { + rule.use = rule.use.filter((item) => { + const testValue = typeof item === 'string' ? item : item?.loader; + return !( + testValue?.match(/mini-css-extract-plugin/) || + testValue?.match(/CssExtractRspackPlugin/) || + testValue?.includes('cssExtractLoader') || + testValue === 'style-loader' + ); + }); + const cssLoader = rule.use.find((item) => { + const testValue = typeof item === 'string' ? item : item?.loader; + return testValue?.includes('css-loader'); + }); + if (cssLoader?.options?.modules) { + cssLoader.options.modules = { ...cssLoader.options.modules, exportOnlyLocals: true }; + } + } else if (rule.use && (rule.use.loader === 'url-loader' || rule.use.loader === 'file-loader')) { + rule.use.options.emitFile = false; + } + }); + + // Add the RSC WebpackLoader to transpiler rules. + // This loader handles 'use client' directive detection and server/client component separation. + rscConfig.module.rules.forEach((rule) => { + if (Array.isArray(rule.use)) { + const transpilerLoader = extractLoader(rule, 'swc-loader') || extractLoader(rule, 'babel-loader'); + if (transpilerLoader) { + rule.use.push({ + loader: 'react-on-rails-rsc/WebpackLoader', + }); + } + } + }); + + // Enable react-server condition for server component resolution + rscConfig.resolve = { + ...rscConfig.resolve, + conditionNames: ['react-server', '...'], + }; + + // No code splitting for the RSC bundle + rscConfig.optimization = { minimize: false }; + rscConfig.plugins.unshift(new bundler.optimize.LimitChunkCountPlugin({ maxChunks: 1 })); + + // Output to the same SSR directory as the server bundle + rscConfig.output = { + filename: 'rsc-bundle.js', + globalObject: 'this', + path: path.resolve(__dirname, '../../ssr-generated'), + publicPath: config.publicPath, + }; + + // Target Node.js so server-only modules (os, fs, stream, etc.) resolve correctly + rscConfig.target = 'node'; + rscConfig.devtool = 'eval'; + + // RSC manifest plugin + rscConfig.plugins.push(new RspackRscPlugin({ isServer: true })); + + return rscConfig; +}; + +module.exports = configureRsc; diff --git a/config/webpack/rspackRscPlugin.js b/config/webpack/rspackRscPlugin.js new file mode 100644 index 000000000..d3cbf9a22 --- /dev/null +++ b/config/webpack/rspackRscPlugin.js @@ -0,0 +1,144 @@ +// Rspack-compatible RSC manifest plugin. +// +// The standard RSCWebpackPlugin from react-on-rails-rsc uses webpack internals +// (ModuleDependency, NullDependency, contextModuleFactory.resolveDependencies) +// that are not available in Rspack. This lightweight plugin generates the +// react-client-manifest.json and react-server-client-manifest.json files +// that the React flight protocol needs to resolve client component references. + +const fs = require('fs'); +const { sources } = require('@rspack/core'); + +// Cache for file 'use client' checks +const useClientCache = new Map(); + +function hasUseClientDirective(filePath) { + if (useClientCache.has(filePath)) return useClientCache.get(filePath); + + let result = false; + try { + // Read the first ~200 bytes — 'use client' must be at the very top of the file + const fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(200); + fs.readSync(fd, buf, 0, 200, 0); + fs.closeSync(fd); + + const head = buf.toString('utf-8'); + // Check for 'use client' as the first statement (with or without semicolons/quotes) + result = /^(?:\s*\/\/[^\n]*\n)*\s*['"]use client['"]/.test(head); + } catch (_) { + // file doesn't exist or can't be read + } + + useClientCache.set(filePath, result); + return result; +} + +class RspackRscPlugin { + constructor(options) { + if (!options || typeof options.isServer !== 'boolean') { + throw new Error('RspackRscPlugin: isServer option (boolean) is required.'); + } + this.isServer = options.isServer; + this.clientManifestFilename = options.isServer + ? 'react-server-client-manifest.json' + : 'react-client-manifest.json'; + this.ssrManifestFilename = 'react-ssr-manifest.json'; + } + + apply(compiler) { + compiler.hooks.thisCompilation.tap('RspackRscPlugin', (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'RspackRscPlugin', + stage: compilation.constructor.PROCESS_ASSETS_STAGE_REPORT || 5000, + }, + () => { + const manifest = {}; + + for (const chunk of compilation.chunks) { + const chunkFiles = []; + for (const file of chunk.files) { + if (file.endsWith('.js') && !file.endsWith('.hot-update.js')) { + chunkFiles.push(file); + break; + } + } + + const modules = compilation.chunkGraph + ? compilation.chunkGraph.getChunkModulesIterable(chunk) + : []; + + for (const mod of modules) { + this._processModule(mod, chunk, chunkFiles, manifest, compilation); + // Handle concatenated modules + if (mod.modules) { + for (const innerMod of mod.modules) { + this._processModule(innerMod, chunk, chunkFiles, manifest, compilation); + } + } + } + } + + compilation.emitAsset( + this.clientManifestFilename, + new sources.RawSource(JSON.stringify(manifest, null, 2)), + ); + + // Emit SSR manifest (maps module IDs to SSR module data) + if (!this.isServer) { + compilation.emitAsset( + this.ssrManifestFilename, + new sources.RawSource(JSON.stringify({}, null, 2)), + ); + } + }, + ); + }); + } + + _processModule(mod, chunk, chunkFiles, manifest, compilation) { + const resource = mod.resource || mod.userRequest; + if (!resource || !resource.match(/\.(js|jsx|ts|tsx)$/)) return; + // Skip node_modules + if (resource.includes('node_modules')) return; + + // Check original file for 'use client' directive + if (!hasUseClientDirective(resource)) return; + + const moduleId = compilation.chunkGraph + ? compilation.chunkGraph.getModuleId(mod) + : mod.id; + + if (moduleId == null) return; + + const chunks = []; + for (const file of chunkFiles) { + chunks.push(chunk.id, file); + } + + // Build the module entry with all exported names + const ssrEntry = { + id: moduleId, + chunks: chunks, + name: '*', + async: false, + }; + + // Use resource path as the key (React flight protocol convention) + const key = resource; + if (!manifest[key]) { + manifest[key] = {}; + } + manifest[key]['*'] = ssrEntry; + manifest[key][''] = ssrEntry; + + // Also add default export entry + manifest[key]['default'] = { + ...ssrEntry, + name: 'default', + }; + } +} + +module.exports = { RspackRscPlugin }; diff --git a/config/webpack/serverWebpackConfig.js b/config/webpack/serverWebpackConfig.js index 8dada6496..70d29cc6e 100644 --- a/config/webpack/serverWebpackConfig.js +++ b/config/webpack/serverWebpackConfig.js @@ -5,6 +5,22 @@ const path = require('path'); const { config } = require('shakapacker'); const commonWebpackConfig = require('./commonWebpackConfig'); const { getBundler } = require('./bundlerUtils'); +const { RspackRscPlugin } = require('./rspackRscPlugin'); + +/** + * Extract a specific loader from a webpack rule's use array. + * + * @param {Object} rule - Webpack rule with a use array + * @param {string} loaderName - Substring to match against loader names + * @returns {Object|null} The matching loader entry, or null + */ +function extractLoader(rule, loaderName) { + if (!Array.isArray(rule.use)) return null; + return rule.use.find((item) => { + const testValue = typeof item === 'string' ? item : item?.loader; + return testValue && testValue.includes(loaderName); + }); +} /** * Generates the server-side rendering (SSR) bundle configuration. @@ -153,7 +169,10 @@ const configureServer = () => { // If using the React on Rails Pro node server renderer, uncomment the next line // serverWebpackConfig.target = 'node' + // RSC: Generate react-server-client-manifest.json for SSR component resolution + serverWebpackConfig.plugins.push(new RspackRscPlugin({ isServer: true })); + return serverWebpackConfig; }; -module.exports = configureServer; +module.exports = { default: configureServer, extractLoader }; diff --git a/config/webpack/webpackConfig.js b/config/webpack/webpackConfig.js index 4f68574e2..007135387 100644 --- a/config/webpack/webpackConfig.js +++ b/config/webpack/webpackConfig.js @@ -2,31 +2,37 @@ // https://github.com/shakacode/react_on_rails_tutorial_with_ssr_and_hmr_fast_refresh/blob/master/config/webpack/webpackConfig.js const clientWebpackConfig = require('./clientWebpackConfig'); -const serverWebpackConfig = require('./serverWebpackConfig'); +const { default: serverWebpackConfig } = require('./serverWebpackConfig'); +const rscWebpackConfig = require('./rscWebpackConfig'); const webpackConfig = (envSpecific) => { - const clientConfig = clientWebpackConfig(); - const serverConfig = serverWebpackConfig(); - - if (envSpecific) { - envSpecific(clientConfig, serverConfig); - } - let result; // For HMR, need to separate the the client and server webpack configurations if (process.env.WEBPACK_SERVE || process.env.CLIENT_BUNDLE_ONLY) { + const clientConfig = clientWebpackConfig(); + if (envSpecific) envSpecific(clientConfig, null); // eslint-disable-next-line no-console console.log('[React on Rails] Creating only the client bundles.'); result = clientConfig; } else if (process.env.SERVER_BUNDLE_ONLY) { + const serverConfig = serverWebpackConfig(); + if (envSpecific) envSpecific(null, serverConfig); // eslint-disable-next-line no-console console.log('[React on Rails] Creating only the server bundle.'); result = serverConfig; + } else if (process.env.RSC_BUNDLE_ONLY) { + const rscConfig = rscWebpackConfig(); + // eslint-disable-next-line no-console + console.log('[React on Rails] Creating only the RSC bundle.'); + result = rscConfig; } else { - // default is the standard client and server build + const clientConfig = clientWebpackConfig(); + const serverConfig = serverWebpackConfig(); + const rscConfig = rscWebpackConfig(); + if (envSpecific) envSpecific(clientConfig, serverConfig); // eslint-disable-next-line no-console - console.log('[React on Rails] Creating both client and server bundles.'); - result = [clientConfig, serverConfig]; + console.log('[React on Rails] Creating client, server, and RSC bundles.'); + result = [clientConfig, serverConfig, rscConfig]; } // To debug, uncomment next line and inspect "result" diff --git a/package.json b/package.json index d52d2980c..31da36d60 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,10 @@ "@hotwired/stimulus-webpack-helpers": "^1.0.1", "@hotwired/turbo-rails": "^7.3.0", "@rails/actioncable": "7.0.5", - "@rspack/cli": "2.0.0-beta.7", - "@rspack/core": "2.0.0-beta.7", "@rescript/core": "^0.5.0", "@rescript/react": "^0.11.0", + "@rspack/cli": "2.0.0-beta.7", + "@rspack/core": "2.0.0-beta.7", "@swc/core": "^1.13.5", "ajv": "^8.17.1", "autoprefixer": "^10.4.14", @@ -77,10 +77,11 @@ "postcss-loader": "7.3.3", "postcss-preset-env": "^8.5.0", "prop-types": "^15.8.1", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "19.0.4", + "react-dom": "19.0.4", "react-intl": "^6.4.4", - "react-on-rails": "16.6.0-rc.0", + "react-on-rails-pro": "16.5.1", + "react-on-rails-rsc": "19.0.5-rc.1", "react-redux": "^8.1.0", "react-router": "^6.13.0", "react-router-dom": "^6.13.0", diff --git a/yarn.lock b/yarn.lock index 14b990d67..f4fb21a9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2840,6 +2840,13 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== +acorn-loose@^8.3.0: + version "8.5.2" + resolved "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz#a7cc7dfbb7c8f3c2e55b055db640dc657e278d26" + integrity sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A== + dependencies: + acorn "^8.15.0" + acorn@^8.15.0, acorn@^8.9.0: version "8.15.0" resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" @@ -7190,7 +7197,7 @@ negotiator@~0.6.4: resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7" integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== -neo-async@^2.6.2: +neo-async@^2.6.1, neo-async@^2.6.2: version "2.6.2" resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== @@ -8365,12 +8372,12 @@ react-deep-force-update@^1.0.0: resolved "https://registry.npmjs.org/react-deep-force-update/-/react-deep-force-update-1.1.2.tgz" integrity sha512-WUSQJ4P/wWcusaH+zZmbECOk7H5N2pOIl0vzheeornkIMhu+qrNdGFm0bDZLCb0hSF0jf/kH1SgkNGfBdTc4wA== -react-dom@^19.0.0: - version "19.2.0" - resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz" - integrity sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ== +react-dom@19.0.4: + version "19.0.4" + resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.0.4.tgz#792d2868dc672c6f8abfce62bdb250e913dcfe2b" + integrity sha512-JiVlwQwuINIQf2+XUjtRFtLxhTE6hcyX7ZyCmY0HM7I/Kgi7qyXThkzwzg6uCfu3rTg9Ofe1x8qWYrfqthIrzg== dependencies: - scheduler "^0.27.0" + scheduler "^0.25.0" react-intl@^6.4.4: version "6.8.9" @@ -8403,10 +8410,26 @@ react-is@^18.0.0, react-is@^18.3.1: resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react-on-rails@16.6.0-rc.0: - version "16.6.0-rc.0" - resolved "https://registry.npmjs.org/react-on-rails/-/react-on-rails-16.6.0-rc.0.tgz#ed0ed7085133905ad1e243cc97233e97d10a1c99" - integrity sha512-fSEomzwojgWob6uTWSfkbpP+XE++8kQBjNFpTT7V419QOON1daIHypQwA9gc8L8uX1If5r8hmAs57iWyGWmJuQ== +react-on-rails-pro@16.5.1: + version "16.5.1" + resolved "https://registry.npmjs.org/react-on-rails-pro/-/react-on-rails-pro-16.5.1.tgz#6b2503e7db55a3ff088d51675ccc345b8adb95a5" + integrity sha512-IhE1QklbvWRq4CEZayDUoAKzYbqICdUsQuhC4+NUF1/K0Z6e+a8VJtDK/8nMUZBIDjR/bomE+dez+rnF9F+sHw== + dependencies: + react-on-rails "16.5.1" + +react-on-rails-rsc@19.0.5-rc.1: + version "19.0.5-rc.1" + resolved "https://registry.npmjs.org/react-on-rails-rsc/-/react-on-rails-rsc-19.0.5-rc.1.tgz#a08818b9995bdaf93cdb2b04cb2e7cd5251be08b" + integrity sha512-Qf+pT82eKsicLW29/2Mfz6H3Cq+2rn5tDGSTy2tdvMGsQhq5gVFwnhLKE66FasH/gvFjoTIZJmUFy7PLmHoMMw== + dependencies: + acorn-loose "^8.3.0" + neo-async "^2.6.1" + webpack-sources "^3.2.0" + +react-on-rails@16.5.1: + version "16.5.1" + resolved "https://registry.npmjs.org/react-on-rails/-/react-on-rails-16.5.1.tgz#7fc4eb502e48445ab4f01ae039a25e2aa72447d4" + integrity sha512-IrfmuY5z0GN596nyE27teLbBTvxcaYR+MVjMGbkmoDV79/Tm2MsWt3hdPeYqD7gXu0AQagd+oHvYuyxfSJ4RGw== react-proxy@^1.1.7: version "1.1.8" @@ -8471,10 +8494,10 @@ react-transition-group@4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" -react@^19.0.0: - version "19.2.0" - resolved "https://registry.npmjs.org/react/-/react-19.2.0.tgz" - integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ== +react@19.0.4: + version "19.0.4" + resolved "https://registry.npmjs.org/react/-/react-19.0.4.tgz#8031673e73cbb8ecba2c35c8c461396aa38dc69d" + integrity sha512-6RpEg9/n0sThnO+2CaMLWuvL1iyctm9/lcSTwvmyCoJYD5eiIrwxevXtrMqrtUr96HCdQB8/Yf+oK1QGy8kXEQ== read-cache@^1.0.0: version "1.0.0" @@ -8878,10 +8901,10 @@ saxes@^6.0.0: dependencies: xmlchars "^2.2.0" -scheduler@^0.27.0: - version "0.27.0" - resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz" - integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== +scheduler@^0.25.0: + version "0.25.0" + resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015" + integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== schema-utils@^3.0.0, schema-utils@^3.3.0: version "3.3.0" @@ -10101,7 +10124,7 @@ webpack-merge@5, webpack-merge@^5.7.3, webpack-merge@^5.8.0: flat "^5.0.2" wildcard "^2.0.0" -webpack-sources@^3.3.4: +webpack-sources@^3.2.0, webpack-sources@^3.3.4: version "3.3.4" resolved "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891" integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q== From 4c0df6e159e9eb413f2acff4c04633cfbd955714 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 4 Apr 2026 00:24:16 -1000 Subject: [PATCH 02/11] Fix ESLint violations in RSC components and updated files - Move eslint-disable after 'use client' directive in SimpleCommentScreen - Add no-promise-executor-return disable for setTimeout in CommentsFeed - Replace array index key with semantic key in ServerInfo - Add PropTypes to TogglePanel component - Fix import ordering in stimulus-bundle.js Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ror_components/SimpleCommentScreen.jsx | 2 +- .../bundles/server-components/components/CommentsFeed.jsx | 1 + .../app/bundles/server-components/components/ServerInfo.jsx | 4 ++-- .../bundles/server-components/components/TogglePanel.jsx | 6 ++++++ client/app/packs/stimulus-bundle.js | 3 +-- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx b/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx index 157943dab..158cbbfb3 100644 --- a/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx +++ b/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx @@ -1,6 +1,6 @@ 'use client'; +/* eslint-disable max-classes-per-file */ -// eslint-disable-next-line max-classes-per-file import React from 'react'; import request from 'axios'; import Immutable from 'immutable'; diff --git a/client/app/bundles/server-components/components/CommentsFeed.jsx b/client/app/bundles/server-components/components/CommentsFeed.jsx index 3d023cad4..04cd1549f 100644 --- a/client/app/bundles/server-components/components/CommentsFeed.jsx +++ b/client/app/bundles/server-components/components/CommentsFeed.jsx @@ -13,6 +13,7 @@ marked.use(gfmHeadingId()); async function CommentsFeed() { // Simulate network latency to demonstrate streaming + // eslint-disable-next-line no-promise-executor-return await new Promise((resolve) => setTimeout(resolve, 800)); // Fetch comments directly from the Rails API — no client-side fetch needed diff --git a/client/app/bundles/server-components/components/ServerInfo.jsx b/client/app/bundles/server-components/components/ServerInfo.jsx index 7c059e3c7..4475eb826 100644 --- a/client/app/bundles/server-components/components/ServerInfo.jsx +++ b/client/app/bundles/server-components/components/ServerInfo.jsx @@ -40,8 +40,8 @@ async function ServerInfo() { used to format it never reaches the browser.

    - {grouped.map((group, gi) => ( -
    + {grouped.map((group) => ( +
    k).join('-')} className="space-y-1"> {group.map(([key, value]) => (
    {labels[key] || key} diff --git a/client/app/bundles/server-components/components/TogglePanel.jsx b/client/app/bundles/server-components/components/TogglePanel.jsx index f5a38a9eb..1336b56b3 100644 --- a/client/app/bundles/server-components/components/TogglePanel.jsx +++ b/client/app/bundles/server-components/components/TogglePanel.jsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState } from 'react'; +import PropTypes from 'prop-types'; const TogglePanel = ({ title, children }) => { const [isOpen, setIsOpen] = useState(false); @@ -31,4 +32,9 @@ const TogglePanel = ({ title, children }) => { ); }; +TogglePanel.propTypes = { + title: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + export default TogglePanel; diff --git a/client/app/packs/stimulus-bundle.js b/client/app/packs/stimulus-bundle.js index 97b9586b5..920396bab 100644 --- a/client/app/packs/stimulus-bundle.js +++ b/client/app/packs/stimulus-bundle.js @@ -1,6 +1,7 @@ 'use client'; import ReactOnRails from 'react-on-rails-pro'; +import registerServerComponent from 'react-on-rails-pro/registerServerComponent/client'; import 'jquery-ujs'; import { Turbo } from '@hotwired/turbo-rails'; @@ -21,8 +22,6 @@ ReactOnRails.setOptions({ // No need for manual registration // React Server Components registration (client-side) -import registerServerComponent from 'react-on-rails-pro/registerServerComponent/client'; - registerServerComponent( { rscPayloadGenerationUrlPath: 'rsc_payload/' }, 'ServerComponentsPage', From 2f3c42c017fca1ce633caa50cf83ac14c5e4d9a1 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 4 Apr 2026 00:27:42 -1000 Subject: [PATCH 03/11] Fix remaining lint issues: eslint-disable placement and no-danger - Use single-line comment eslint-disable before 'use client' directive (file-level rules must be disabled before line 1) - Suppress react/no-danger for sanitized HTML in CommentsFeed Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx | 2 +- .../app/bundles/server-components/components/CommentsFeed.jsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx b/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx index 158cbbfb3..a10b09daa 100644 --- a/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx +++ b/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx @@ -1,5 +1,5 @@ +// eslint-disable max-classes-per-file 'use client'; -/* eslint-disable max-classes-per-file */ import React from 'react'; import request from 'axios'; diff --git a/client/app/bundles/server-components/components/CommentsFeed.jsx b/client/app/bundles/server-components/components/CommentsFeed.jsx index 04cd1549f..bdab06114 100644 --- a/client/app/bundles/server-components/components/CommentsFeed.jsx +++ b/client/app/bundles/server-components/components/CommentsFeed.jsx @@ -66,6 +66,7 @@ async function CommentsFeed() {
    {/* Content is sanitized via sanitize-html before rendering */} + {/* eslint-disable-next-line react/no-danger */}
    Date: Sat, 4 Apr 2026 00:31:10 -1000 Subject: [PATCH 04/11] Fix eslint-disable syntax for file-level rules before 'use client' File-level ESLint rules require block comment /* */ syntax, not single-line //. Update RspackRscPlugin regex to also recognize block comments before 'use client' directives. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ror_components/SimpleCommentScreen.jsx | 2 +- config/webpack/rspackRscPlugin.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx b/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx index a10b09daa..cac19b8a2 100644 --- a/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx +++ b/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx @@ -1,4 +1,4 @@ -// eslint-disable max-classes-per-file +/* eslint-disable max-classes-per-file */ 'use client'; import React from 'react'; diff --git a/config/webpack/rspackRscPlugin.js b/config/webpack/rspackRscPlugin.js index d3cbf9a22..af821e042 100644 --- a/config/webpack/rspackRscPlugin.js +++ b/config/webpack/rspackRscPlugin.js @@ -24,8 +24,9 @@ function hasUseClientDirective(filePath) { fs.closeSync(fd); const head = buf.toString('utf-8'); - // Check for 'use client' as the first statement (with or without semicolons/quotes) - result = /^(?:\s*\/\/[^\n]*\n)*\s*['"]use client['"]/.test(head); + // Check for 'use client' as the first statement. + // Allow comments (single-line // or block /* */) before the directive. + result = /^(?:\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/))*\s*['"]use client['"]/.test(head); } catch (_) { // file doesn't exist or can't be read } From 05cb2bc6d3eed7a184214936a842978cfd7a10f2 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 4 Apr 2026 22:00:22 -1000 Subject: [PATCH 05/11] Add Node renderer and fix RSC page rendering - Add react-on-rails-pro-node-renderer for SSR and RSC payload generation - Configure Pro initializer with NodeRenderer, renderer_url, password - Alias react-dom/server.browser to react-dom/server.node in server webpack config (React 19's browser build requires MessageChannel which isn't available in the Node renderer VM) - Add auto_load_bundle: false to RSC view (server components use registerServerComponent, not ror_components auto-loading) - Add node-renderer to Procfile.dev Co-Authored-By: Claude Opus 4.6 (1M context) --- Procfile.dev | 2 + app/views/pages/server_components.html.erb | 1 + config/initializers/react_on_rails_pro.rb | 5 + config/webpack/serverWebpackConfig.js | 13 +- package.json | 4 +- react-on-rails-pro-node-renderer.js | 17 + yarn.lock | 451 ++++++++++++++++++++- 7 files changed, 484 insertions(+), 9 deletions(-) create mode 100644 react-on-rails-pro-node-renderer.js diff --git a/Procfile.dev b/Procfile.dev index cae685eca..94a640088 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -14,3 +14,5 @@ wp-client: RAILS_ENV=development NODE_ENV=development bin/shakapacker-dev-server wp-server: SERVER_BUNDLE_ONLY=yes bin/shakapacker --watch # RSC Rspack watcher for React Server Components bundle wp-rsc: RSC_BUNDLE_ONLY=true bin/shakapacker --watch +# React on Rails Pro Node renderer for SSR and RSC payload generation +node-renderer: RENDERER_PASSWORD=devPassword node react-on-rails-pro-node-renderer.js diff --git a/app/views/pages/server_components.html.erb b/app/views/pages/server_components.html.erb index edff32059..f5cada566 100644 --- a/app/views/pages/server_components.html.erb +++ b/app/views/pages/server_components.html.erb @@ -1,5 +1,6 @@ <%= append_javascript_pack_tag('rsc-client-components') %> <%= react_component("ServerComponentsPage", prerender: false, + auto_load_bundle: false, trace: true, id: "ServerComponentsPage-react-component-0") %> diff --git a/config/initializers/react_on_rails_pro.rb b/config/initializers/react_on_rails_pro.rb index a3a8c1312..e5ab7965a 100644 --- a/config/initializers/react_on_rails_pro.rb +++ b/config/initializers/react_on_rails_pro.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true ReactOnRailsPro.configure do |config| + # Node renderer for server-side rendering and RSC payload generation + config.server_renderer = "NodeRenderer" + config.renderer_url = ENV["REACT_RENDERER_URL"] || "http://localhost:3800" + config.renderer_password = ENV.fetch("RENDERER_PASSWORD", "devPassword") + # Enable React Server Components support config.enable_rsc_support = true diff --git a/config/webpack/serverWebpackConfig.js b/config/webpack/serverWebpackConfig.js index 70d29cc6e..d61bb6868 100644 --- a/config/webpack/serverWebpackConfig.js +++ b/config/webpack/serverWebpackConfig.js @@ -95,7 +95,6 @@ const configureServer = () => { serverWebpackConfig.output = { filename: 'server-bundle.js', globalObject: 'this', - // If using the React on Rails Pro node server renderer, uncomment the next line // libraryTarget: 'commonjs2', path: path.resolve(__dirname, '../../ssr-generated'), publicPath: config.publicPath, @@ -164,10 +163,14 @@ const configureServer = () => { // The default of cheap-module-source-map is slow and provides poor info. serverWebpackConfig.devtool = 'eval'; - // If using the default 'web', then libraries like Emotion and loadable-components - // break with SSR. The fix is to use a node renderer and change the target. - // If using the React on Rails Pro node server renderer, uncomment the next line - // serverWebpackConfig.target = 'node' + // Alias react-dom/server to the Node.js version for the Pro Node renderer. + // The default browser version uses MessageChannel which isn't available in the Node VM. + serverWebpackConfig.resolve = serverWebpackConfig.resolve || {}; + serverWebpackConfig.resolve.alias = { + ...serverWebpackConfig.resolve.alias, + 'react-dom/server.browser$': 'react-dom/server.node', + 'react-dom/server.browser.js$': 'react-dom/server.node.js', + }; // RSC: Generate react-server-client-manifest.json for SSR component resolution serverWebpackConfig.plugins.push(new RspackRscPlugin({ isServer: true })); diff --git a/package.json b/package.json index 31da36d60..697827ebe 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "test:client": "yarn jest", "build:test": "rm -rf public/packs-test && RAILS_ENV=test NODE_ENV=test bin/shakapacker", "build:dev": "rm -rf public/packs && RAILS_ENV=development NODE_ENV=development bin/shakapacker", - "build:clean": "rm -rf public/packs || true" + "build:clean": "rm -rf public/packs || true", + "node-renderer": "node ./react-on-rails-pro-node-renderer.js" }, "dependencies": { "@babel/cli": "^7.21.0", @@ -81,6 +82,7 @@ "react-dom": "19.0.4", "react-intl": "^6.4.4", "react-on-rails-pro": "16.5.1", + "react-on-rails-pro-node-renderer": "16.5.1", "react-on-rails-rsc": "19.0.5-rc.1", "react-redux": "^8.1.0", "react-router": "^6.13.0", diff --git a/react-on-rails-pro-node-renderer.js b/react-on-rails-pro-node-renderer.js new file mode 100644 index 000000000..0f728691b --- /dev/null +++ b/react-on-rails-pro-node-renderer.js @@ -0,0 +1,17 @@ +const path = require('path'); +const { reactOnRailsProNodeRenderer } = require('react-on-rails-pro-node-renderer'); + +const config = { + serverBundleCachePath: path.resolve(__dirname, '.node-renderer-bundles'), + logLevel: process.env.RENDERER_LOG_LEVEL || 'debug', + password: process.env.RENDERER_PASSWORD || 'devPassword', + port: process.env.RENDERER_PORT || 3800, + supportModules: true, + workersCount: Number(process.env.NODE_RENDERER_CONCURRENCY || 3), +}; + +if (process.env.CI) { + config.workersCount = 2; +} + +reactOnRailsProNodeRenderer(config); diff --git a/yarn.lock b/yarn.lock index f4fb21a9b..88c9651b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1336,6 +1336,76 @@ resolved "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz" integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== +"@fastify/ajv-compiler@^4.0.5": + version "4.0.5" + resolved "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz#fdb0887a7af51abaae8c1829e8099d34f8ddd302" + integrity sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A== + dependencies: + ajv "^8.12.0" + ajv-formats "^3.0.1" + fast-uri "^3.0.0" + +"@fastify/busboy@^3.0.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz#13ed8212f3b9ba697611529d15347f8528058cea" + integrity sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA== + +"@fastify/deepmerge@^3.0.0": + version "3.2.1" + resolved "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz#0fe56a4ee3eec874556006439f7bc7d616f10dc1" + integrity sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA== + +"@fastify/error@^4.0.0": + version "4.2.0" + resolved "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz#d40f46ba75f541fdcc4dc276b7308bbc8e8e6d7a" + integrity sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ== + +"@fastify/fast-json-stringify-compiler@^5.0.0": + version "5.0.3" + resolved "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz#fae495bf30dbbd029139839ec5c2ea111bde7d3f" + integrity sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ== + dependencies: + fast-json-stringify "^6.0.0" + +"@fastify/formbody@^7.4.0 || ^8.0.2": + version "8.0.2" + resolved "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz#7f97c8ab25933db77760bbeaacd2ff5355a54682" + integrity sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA== + dependencies: + fast-querystring "^1.1.2" + fastify-plugin "^5.0.0" + +"@fastify/forwarded@^3.0.0": + version "3.0.1" + resolved "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz#9662b7bd4a59f6d123cc3487494f75f635c32d23" + integrity sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw== + +"@fastify/merge-json-schemas@^0.2.0": + version "0.2.1" + resolved "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz#3aa30d2f0c81a8ac5995b6d94ed4eaa2c3055824" + integrity sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A== + dependencies: + dequal "^2.0.3" + +"@fastify/multipart@^8.3.1 || ^9.0.3": + version "9.4.0" + resolved "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz#be50e7d12d989cb42b835a5e46e08b40ab5b0728" + integrity sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ== + dependencies: + "@fastify/busboy" "^3.0.0" + "@fastify/deepmerge" "^3.0.0" + "@fastify/error" "^4.0.0" + fastify-plugin "^5.0.0" + secure-json-parse "^4.0.0" + +"@fastify/proxy-addr@^5.0.0": + version "5.1.0" + resolved "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz#f5360b5dd83c7de3d41b415be4aab84ae44aa106" + integrity sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw== + dependencies: + "@fastify/forwarded" "^3.0.0" + ipaddr.js "^2.1.0" + "@formatjs/ecma402-abstract@2.2.4": version "2.2.4" resolved "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.2.4.tgz" @@ -1956,6 +2026,11 @@ "@parcel/watcher-win32-ia32" "2.5.1" "@parcel/watcher-win32-x64" "2.5.1" +"@pinojs/redact@^0.4.0": + version "0.4.0" + resolved "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz#c3de060dd12640dcc838516aa2a6803cc7b2e9d6" + integrity sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" @@ -2822,6 +2897,11 @@ resolved "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== +abstract-logging@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz#6b0c371df212db7129b57d2e7fcf282b8bf1c839" + integrity sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA== + accepts@~1.3.8: version "1.3.8" resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz" @@ -2882,6 +2962,13 @@ ajv-formats@^2.1.1: dependencies: ajv "^8.0.0" +ajv-formats@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + ajv-keywords@^3.5.2: version "3.5.2" resolved "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz" @@ -2914,6 +3001,16 @@ ajv@^8.0.0, ajv@^8.17.1, ajv@^8.9.0: json-schema-traverse "^1.0.0" require-from-string "^2.0.2" +ajv@^8.12.0: + version "8.18.0" + resolved "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc" + integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ansi-escapes@^4.2.1: version "4.3.2" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz" @@ -3156,6 +3253,11 @@ atob@^2.1.2: resolved "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +atomic-sleep@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" + integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== + autoprefixer@^10.4.14: version "10.4.21" resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz" @@ -3175,6 +3277,14 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" +avvio@^9.0.0: + version "9.2.0" + resolved "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz#16bb653c022237d1aeb984b00d3cbe2d96b77c20" + integrity sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ== + dependencies: + "@fastify/error" "^4.0.0" + fastq "^1.17.1" + axe-core@^4.10.0: version "4.10.3" resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz" @@ -3414,6 +3524,11 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" +buffer-equal-constant-time@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz" @@ -3787,6 +3902,11 @@ cookie@0.7.1: resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz" integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w== +cookie@^1.0.1: + version "1.1.1" + resolved "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" + integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== + cookie@~0.7.1: version "0.7.2" resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" @@ -4336,6 +4456,13 @@ eastasianwidth@^0.2.0: resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" @@ -5000,6 +5127,11 @@ express@^4.18.2: utils-merge "1.0.1" vary "~1.1.2" +fast-decode-uri-component@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz#46f8b6c22b30ff7a81357d4f59abfae938202543" + integrity sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" @@ -5026,12 +5158,31 @@ fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-json-stringify@^6.0.0: + version "6.3.0" + resolved "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz#e59f2fbd558842d7ec085276444d15e6500c16d4" + integrity sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA== + dependencies: + "@fastify/merge-json-schemas" "^0.2.0" + ajv "^8.12.0" + ajv-formats "^3.0.1" + fast-uri "^3.0.0" + json-schema-ref-resolver "^3.0.0" + rfdc "^1.2.0" + fast-levenshtein@^2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== -fast-uri@^3.0.1: +fast-querystring@^1.0.0, fast-querystring@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz#a6d24937b4fc6f791b4ee31dcb6f53aeafb89f53" + integrity sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg== + dependencies: + fast-decode-uri-component "^1.0.1" + +fast-uri@^3.0.0, fast-uri@^3.0.1: version "3.1.0" resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz" integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== @@ -5041,6 +5192,39 @@ fastest-levenshtein@^1.0.12: resolved "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== +fastify-plugin@^5.0.0: + version "5.1.0" + resolved "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz#7083e039d6418415f9a669f8c25e72fc5bf2d3e7" + integrity sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw== + +fastify@^5.8.1: + version "5.8.4" + resolved "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz#9ad9ebeea57980cf8722b5c44ca27ea9255cf2d5" + integrity sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ== + dependencies: + "@fastify/ajv-compiler" "^4.0.5" + "@fastify/error" "^4.0.0" + "@fastify/fast-json-stringify-compiler" "^5.0.0" + "@fastify/proxy-addr" "^5.0.0" + abstract-logging "^2.0.1" + avvio "^9.0.0" + fast-json-stringify "^6.0.0" + find-my-way "^9.0.0" + light-my-request "^6.0.0" + pino "^9.14.0 || ^10.1.0" + process-warning "^5.0.0" + rfdc "^1.3.1" + secure-json-parse "^4.0.0" + semver "^7.6.0" + toad-cache "^3.7.0" + +fastq@^1.17.1: + version "1.20.1" + resolved "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675" + integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw== + dependencies: + reusify "^1.0.4" + fastq@^1.6.0: version "1.19.1" resolved "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz" @@ -5110,6 +5294,15 @@ finalhandler@~1.3.1: statuses "~2.0.2" unpipe "~1.0.0" +find-my-way@^9.0.0: + version "9.5.0" + resolved "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz#3e6819bf4310b5293f490c032e70be0b506d0dc8" + integrity sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ== + dependencies: + fast-deep-equal "^3.1.3" + fast-querystring "^1.0.0" + safe-regex2 "^5.0.0" + find-root@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz" @@ -5208,6 +5401,15 @@ fs-extra@^10.0.0: jsonfile "^6.0.1" universalify "^2.0.0" +fs-extra@^11.2.0: + version "11.3.4" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz#ab6934eca8bcf6f7f6b82742e33591f86301d6fc" + integrity sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-monkey@^1.0.4: version "1.1.0" resolved "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz#632aa15a20e71828ed56b24303363fb1414e5997" @@ -5765,7 +5967,7 @@ ipaddr.js@1.9.1: resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== -ipaddr.js@^2.0.1: +ipaddr.js@^2.0.1, ipaddr.js@^2.1.0: version "2.3.0" resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz#71dce70e1398122208996d1c22f2ba46a24b1abc" integrity sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg== @@ -6612,6 +6814,13 @@ json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== +json-schema-ref-resolver@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz#28f6a410122cde9238762a5e9296faa38be28708" + integrity sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A== + dependencies: + dequal "^2.0.3" + json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" @@ -6664,6 +6873,22 @@ jsonify@^0.0.1: resolved "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== +jsonwebtoken@^9.0.3: + version "9.0.3" + resolved "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== + dependencies: + jws "^4.0.1" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + "jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.5: version "3.3.5" resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz" @@ -6674,6 +6899,23 @@ jsonify@^0.0.1: object.assign "^4.1.4" object.values "^1.1.6" +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" + keyv@^4.5.3: version "4.5.4" resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz" @@ -6731,6 +6973,15 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +light-my-request@^6.0.0: + version "6.6.0" + resolved "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz#c9448772323f65f33720fb5979c7841f14060add" + integrity sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A== + dependencies: + cookie "^1.0.1" + process-warning "^4.0.0" + set-cookie-parser "^2.6.0" + lilconfig@^3.1.1, lilconfig@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz" @@ -6872,6 +7123,11 @@ lodash.has@^4.5.2: resolved "https://registry.npmjs.org/lodash.has/-/lodash.has-4.5.2.tgz#d19f4dc1095058cccbe2b0cdf4ee0fe4aa37c862" integrity sha512-rnYUdIo6xRCJnQmbVFEwcxF144erlD+M3YcJUVesflU9paQaE8p+fJDcIQrlMYbxoANFL+AB9hZrzSBBk5PL+g== +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + lodash.isarguments@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz" @@ -6882,6 +7138,31 @@ lodash.isarray@^3.0.0: resolved "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz" integrity sha512-JwObCrNJuT0Nnbuecmqr5DgtuBppuCvGD9lxjFpAzwnVtdGoDQ1zig+5W8k5/6Gcn0gZ3936HDAlGd28i7sOGQ== +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + lodash.keys@^3.0.0: version "3.1.2" resolved "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz" @@ -6901,6 +7182,11 @@ lodash.merge@^4.6.0, lodash.merge@^4.6.2: resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + lodash.restparam@^3.0.0: version "3.6.1" resolved "https://registry.npmjs.org/lodash.restparam/-/lodash.restparam-3.6.1.tgz" @@ -7350,6 +7636,11 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== +on-exit-leak-free@^2.1.0: + version "2.1.2" + resolved "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" + integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== + on-finished@2.4.1, on-finished@~2.4.1: version "2.4.1" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz" @@ -7610,6 +7901,59 @@ pify@^4.0.1: resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz" integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== +pino-abstract-transport@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz#de241578406ac7b8a33ce0d77ae6e8a0b3b68a60" + integrity sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw== + dependencies: + split2 "^4.0.0" + +pino-abstract-transport@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz#b21e5f33a297e8c4c915c62b3ce5dd4a87a52c23" + integrity sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg== + dependencies: + split2 "^4.0.0" + +pino-std-serializers@^7.0.0: + version "7.1.0" + resolved "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz#a7b0cd65225f29e92540e7853bd73b07479893fc" + integrity sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw== + +pino@^9.0.0: + version "9.14.0" + resolved "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz#673d9711c2d1e64d18670c1ec05ef7ba14562556" + integrity sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w== + dependencies: + "@pinojs/redact" "^0.4.0" + atomic-sleep "^1.0.0" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^2.0.0" + pino-std-serializers "^7.0.0" + process-warning "^5.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^4.0.1" + thread-stream "^3.0.0" + +"pino@^9.14.0 || ^10.1.0": + version "10.3.1" + resolved "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz#6552c8f8d8481844c9e452e7bf0be90bff1939ce" + integrity sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg== + dependencies: + "@pinojs/redact" "^0.4.0" + atomic-sleep "^1.0.0" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^3.0.0" + pino-std-serializers "^7.0.0" + process-warning "^5.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^4.0.1" + thread-stream "^4.0.0" + pirates@^4.0.1, pirates@^4.0.4: version "4.0.7" resolved "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz" @@ -8271,6 +8615,16 @@ process-nextick-args@~2.0.0: resolved "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== +process-warning@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz#5c1db66007c67c756e4e09eb170cdece15da32fb" + integrity sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q== + +process-warning@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz#566e0bf79d1dff30a72d8bbbe9e8ecefe8d378d7" + integrity sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA== + process@^0.11.10: version "0.11.10" resolved "https://registry.npmjs.org/process/-/process-0.11.10.tgz" @@ -8330,6 +8684,11 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +quick-format-unescaped@^4.0.3: + version "4.0.4" + resolved "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" + integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== + quick-lru@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz" @@ -8410,6 +8769,19 @@ react-is@^18.0.0, react-is@^18.3.1: resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== +react-on-rails-pro-node-renderer@16.5.1: + version "16.5.1" + resolved "https://registry.npmjs.org/react-on-rails-pro-node-renderer/-/react-on-rails-pro-node-renderer-16.5.1.tgz#34d08578981593d567838cc82c77b3fd4d03df47" + integrity sha512-QkW+BUhczPbgJ/kLJzEuMoL08wQiZUox7HG1ZBi1cZdjNxGPTcZC7qrD8+NEPgjAoUGZazCcv7DR0kTpYfPURw== + dependencies: + "@fastify/formbody" "^7.4.0 || ^8.0.2" + "@fastify/multipart" "^8.3.1 || ^9.0.3" + fastify "^5.8.1" + fs-extra "^11.2.0" + jsonwebtoken "^9.0.3" + lockfile "^1.0.4" + pino "^9.0.0" + react-on-rails-pro@16.5.1: version "16.5.1" resolved "https://registry.npmjs.org/react-on-rails-pro/-/react-on-rails-pro-16.5.1.tgz#6b2503e7db55a3ff088d51675ccc345b8adb95a5" @@ -8540,6 +8912,11 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +real-require@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" + integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== + rechoir@^0.8.0: version "0.8.0" resolved "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22" @@ -8750,6 +9127,11 @@ resolve@^2.0.0-next.5: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +ret@~0.5.0: + version "0.5.0" + resolved "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz#30a4d38a7e704bd96dc5ffcbe7ce2a9274c41c95" + integrity sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw== + retry@^0.13.1: version "0.13.1" resolved "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" @@ -8773,6 +9155,11 @@ rework@^1.0.1: convert-source-map "^0.3.3" css "^2.0.0" +rfdc@^1.2.0, rfdc@^1.3.1: + version "1.4.1" + resolved "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" + integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + rimraf@^3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz" @@ -8817,7 +9204,7 @@ safe-array-concat@^1.1.3: has-symbols "^1.1.0" isarray "^2.0.5" -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -8849,6 +9236,18 @@ safe-regex-test@^1.0.3, safe-regex-test@^1.1.0: es-errors "^1.3.0" is-regex "^1.2.1" +safe-regex2@^5.0.0: + version "5.1.0" + resolved "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz#758fd224d066f5abe24f67bd574a01c9dd447f51" + integrity sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw== + dependencies: + ret "~0.5.0" + +safe-stable-stringify@^2.3.1: + version "2.5.0" + resolved "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" + integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== + "safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" @@ -8935,6 +9334,11 @@ schema-utils@^4.3.3: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" +secure-json-parse@^4.0.0: + version "4.1.0" + resolved "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz#4f1ab41c67a13497ea1b9131bb4183a22865477c" + integrity sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA== + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -8963,6 +9367,11 @@ semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4: resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== +semver@^7.6.0: + version "7.7.4" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + send@0.19.0: version "0.19.0" resolved "https://registry.npmjs.org/send/-/send-0.19.0.tgz" @@ -9046,6 +9455,11 @@ set-blocking@^2.0.0: resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz" integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== +set-cookie-parser@^2.6.0: + version "2.7.2" + resolved "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68" + integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw== + set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz" @@ -9190,6 +9604,13 @@ sockjs@^0.3.24: uuid "^8.3.2" websocket-driver "^0.7.4" +sonic-boom@^4.0.1: + version "4.2.1" + resolved "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz#28598250df4899c0ac572d7e2f0460690ba6a030" + integrity sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q== + dependencies: + atomic-sleep "^1.0.0" + "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" @@ -9260,6 +9681,11 @@ spdy@^4.0.2: select-hose "^2.0.0" spdy-transport "^3.0.0" +split2@^4.0.0: + version "4.2.0" + resolved "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" + integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" @@ -9678,6 +10104,20 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +thread-stream@^3.0.0: + version "3.1.0" + resolved "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz#4b2ef252a7c215064507d4ef70c05a5e2d34c4f1" + integrity sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A== + dependencies: + real-require "^0.2.0" + +thread-stream@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz#732f007c24da7084f729d6e3a7e3f5934a7380b7" + integrity sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA== + dependencies: + real-require "^0.2.0" + thunky@^1.0.2: version "1.1.0" resolved "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" @@ -9712,6 +10152,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +toad-cache@^3.7.0: + version "3.7.0" + resolved "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz#b9b63304ea7c45ec34d91f1d2fa513517025c441" + integrity sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw== + toidentifier@1.0.1, toidentifier@~1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz" From 0d8d75ac39219a50522b513a8cddd570a51443d5 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 4 Apr 2026 22:05:10 -1000 Subject: [PATCH 06/11] Address PR review feedback: fix bugs, security, and config issues - Replace hardcoded localhost URL with RAILS_INTERNAL_URL env variable - Add response.ok check to prevent silent fetch failures - Guard 800ms demo delay to non-production environments - Restrict sanitize-html img tag to explicit allowed attributes/schemes - Clear useClientCache on each compilation for correct watch mode - Remove incorrect 'use client' from server-only files - Fix import/order lint violation in rsc-client-components - Gate trace option to development environment only - Remove duplicate RspackRscPlugin from server config (RSC-only) - Fix url-loader/file-loader guard to use .includes() matching - Pass RSC config to envSpecific callback Co-Authored-By: Claude Opus 4.6 (1M context) --- app/views/pages/server_components.html.erb | 2 +- .../ror_components/RouterApp.server.jsx | 2 -- .../comments/startup/serverRegistration.jsx | 2 -- .../components/CommentsFeed.jsx | 20 +++++++++++++++---- client/app/packs/rsc-client-components.js | 2 +- config/webpack/rscWebpackConfig.js | 7 +++++-- config/webpack/rspackRscPlugin.js | 5 +++++ config/webpack/serverWebpackConfig.js | 4 ---- config/webpack/webpackConfig.js | 3 ++- 9 files changed, 30 insertions(+), 17 deletions(-) diff --git a/app/views/pages/server_components.html.erb b/app/views/pages/server_components.html.erb index f5cada566..d9643a213 100644 --- a/app/views/pages/server_components.html.erb +++ b/app/views/pages/server_components.html.erb @@ -2,5 +2,5 @@ <%= react_component("ServerComponentsPage", prerender: false, auto_load_bundle: false, - trace: true, + trace: Rails.env.development?, id: "ServerComponentsPage-react-component-0") %> diff --git a/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.server.jsx b/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.server.jsx index 7dd9d0a81..dd3578ba8 100644 --- a/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.server.jsx +++ b/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.server.jsx @@ -1,5 +1,3 @@ -'use client'; - // Compare to ./RouterApp.client.jsx import { Provider } from 'react-redux'; import React from 'react'; diff --git a/client/app/bundles/comments/startup/serverRegistration.jsx b/client/app/bundles/comments/startup/serverRegistration.jsx index da44c5abd..c7db967ab 100644 --- a/client/app/bundles/comments/startup/serverRegistration.jsx +++ b/client/app/bundles/comments/startup/serverRegistration.jsx @@ -1,5 +1,3 @@ -'use client'; - // Example of React + Redux import ReactOnRails from 'react-on-rails-pro'; diff --git a/client/app/bundles/server-components/components/CommentsFeed.jsx b/client/app/bundles/server-components/components/CommentsFeed.jsx index bdab06114..0230c1d19 100644 --- a/client/app/bundles/server-components/components/CommentsFeed.jsx +++ b/client/app/bundles/server-components/components/CommentsFeed.jsx @@ -12,12 +12,19 @@ const marked = new Marked(); marked.use(gfmHeadingId()); async function CommentsFeed() { - // Simulate network latency to demonstrate streaming - // eslint-disable-next-line no-promise-executor-return - await new Promise((resolve) => setTimeout(resolve, 800)); + // Simulate network latency to demonstrate Suspense streaming (development only) + if (process.env.NODE_ENV !== 'production') { + await new Promise((resolve) => { + setTimeout(resolve, 800); + }); + } // Fetch comments directly from the Rails API — no client-side fetch needed - const response = await fetch('http://localhost:3000/comments.json'); + const baseUrl = process.env.RAILS_INTERNAL_URL || 'http://localhost:3000'; + const response = await fetch(`${baseUrl}/comments.json`); + if (!response.ok) { + throw new Error(`Failed to fetch comments: ${response.status} ${response.statusText}`); + } const comments = await response.json(); // Use lodash to process (stays on server) @@ -45,6 +52,11 @@ async function CommentsFeed() { const rawHtml = marked.parse(comment.text || ''); const safeHtml = sanitizeHtml(rawHtml, { allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']), + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + img: ['src', 'alt', 'title', 'width', 'height'], + }, + allowedSchemes: ['https', 'http', 'data'], }); return ( diff --git a/client/app/packs/rsc-client-components.js b/client/app/packs/rsc-client-components.js index b33ff5a20..55773819c 100644 --- a/client/app/packs/rsc-client-components.js +++ b/client/app/packs/rsc-client-components.js @@ -4,8 +4,8 @@ // Components with 'use client' that are used in server components must be // available in a client bundle chunk so the React flight client can load them. -import TogglePanel from '../bundles/server-components/components/TogglePanel'; import ReactOnRails from 'react-on-rails-pro'; +import TogglePanel from '../bundles/server-components/components/TogglePanel'; // Register as a standard component so it's bundled in a client-accessible chunk ReactOnRails.register({ TogglePanel }); diff --git a/config/webpack/rscWebpackConfig.js b/config/webpack/rscWebpackConfig.js index 3d76a7148..ebd137cda 100644 --- a/config/webpack/rscWebpackConfig.js +++ b/config/webpack/rscWebpackConfig.js @@ -62,8 +62,11 @@ const configureRsc = () => { if (cssLoader?.options?.modules) { cssLoader.options.modules = { ...cssLoader.options.modules, exportOnlyLocals: true }; } - } else if (rule.use && (rule.use.loader === 'url-loader' || rule.use.loader === 'file-loader')) { - rule.use.options.emitFile = false; + } else if ( + rule.use?.loader + && (rule.use.loader.includes('url-loader') || rule.use.loader.includes('file-loader')) + ) { + rule.use.options = { ...(rule.use.options || {}), emitFile: false }; } }); diff --git a/config/webpack/rspackRscPlugin.js b/config/webpack/rspackRscPlugin.js index af821e042..1c3c2f659 100644 --- a/config/webpack/rspackRscPlugin.js +++ b/config/webpack/rspackRscPlugin.js @@ -48,6 +48,11 @@ class RspackRscPlugin { } apply(compiler) { + // Clear cache on each compilation so watch-mode picks up 'use client' changes + compiler.hooks.thisCompilation.tap('RspackRscPlugin-ClearCache', () => { + useClientCache.clear(); + }); + compiler.hooks.thisCompilation.tap('RspackRscPlugin', (compilation) => { compilation.hooks.processAssets.tap( { diff --git a/config/webpack/serverWebpackConfig.js b/config/webpack/serverWebpackConfig.js index d61bb6868..a97ae19b2 100644 --- a/config/webpack/serverWebpackConfig.js +++ b/config/webpack/serverWebpackConfig.js @@ -5,7 +5,6 @@ const path = require('path'); const { config } = require('shakapacker'); const commonWebpackConfig = require('./commonWebpackConfig'); const { getBundler } = require('./bundlerUtils'); -const { RspackRscPlugin } = require('./rspackRscPlugin'); /** * Extract a specific loader from a webpack rule's use array. @@ -172,9 +171,6 @@ const configureServer = () => { 'react-dom/server.browser.js$': 'react-dom/server.node.js', }; - // RSC: Generate react-server-client-manifest.json for SSR component resolution - serverWebpackConfig.plugins.push(new RspackRscPlugin({ isServer: true })); - return serverWebpackConfig; }; diff --git a/config/webpack/webpackConfig.js b/config/webpack/webpackConfig.js index 007135387..595fc63c3 100644 --- a/config/webpack/webpackConfig.js +++ b/config/webpack/webpackConfig.js @@ -22,6 +22,7 @@ const webpackConfig = (envSpecific) => { result = serverConfig; } else if (process.env.RSC_BUNDLE_ONLY) { const rscConfig = rscWebpackConfig(); + if (envSpecific) envSpecific(null, null, rscConfig); // eslint-disable-next-line no-console console.log('[React on Rails] Creating only the RSC bundle.'); result = rscConfig; @@ -29,7 +30,7 @@ const webpackConfig = (envSpecific) => { const clientConfig = clientWebpackConfig(); const serverConfig = serverWebpackConfig(); const rscConfig = rscWebpackConfig(); - if (envSpecific) envSpecific(clientConfig, serverConfig); + if (envSpecific) envSpecific(clientConfig, serverConfig, rscConfig); // eslint-disable-next-line no-console console.log('[React on Rails] Creating client, server, and RSC bundles.'); result = [clientConfig, serverConfig, rscConfig]; From aaeba86e4d2728bcd1a6d771bd56b990b4d0373f Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 4 Apr 2026 22:47:51 -1000 Subject: [PATCH 07/11] Address PR review feedback: fix bugs, security, and config issues - Add .node-renderer-bundles/ to .gitignore (Node renderer cache) - CommentsFeed: skip artificial delay in production, use configurable base URL, add error handling for fetch, tighten sanitize-html config - RspackRscPlugin: clear cache on each compilation for watch mode - View: use trace only in development - rscWebpackConfig: safer file-loader option merge Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 366e6a9b7..5c7b739bb 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,9 @@ client/app/bundles/comments/rescript/**/*.bs.js # Using React on Rails default directory /ssr-generated/ +# Node renderer bundle cache +.node-renderer-bundles/ + # Generated React on Rails packs **/generated/** From 92306ff01022d265bd7f4d3b45d9e63e067140dd Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 4 Apr 2026 22:54:46 -1000 Subject: [PATCH 08/11] Fix CI: resolve RSC bundle and server bundle build errors Two issues: 1. RSC bundle (127 errors): Remove server-bundle-generated.js import from rsc-bundle.js. It includes RouterApp.server.jsx (traditional SSR component) that uses react-redux/react-router with client-only React APIs incompatible with the react-server condition. Client references are handled automatically by the RSC loader/plugin. 2. Server bundle (3 errors): Add Node.js builtin fallbacks (path, fs, stream) to server webpack config. react-on-rails-pro now includes RSC modules that import these builtins, but they aren't used in the traditional SSR path. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/app/packs/rsc-bundle.js | 7 +++++-- config/webpack/serverWebpackConfig.js | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/client/app/packs/rsc-bundle.js b/client/app/packs/rsc-bundle.js index 83d8936a1..b6fcae1bb 100644 --- a/client/app/packs/rsc-bundle.js +++ b/client/app/packs/rsc-bundle.js @@ -3,11 +3,14 @@ // It imports the same client component registrations as server-bundle.js, // plus the server component registrations. -// Import existing client component registrations +// Import stores registration (has 'use client' — RSC loader replaces with client reference) import './stores-registration'; -import './../generated/server-bundle-generated.js'; // React Server Components registration (server-side) +// Note: server-bundle-generated.js is NOT imported here because it contains +// traditional SSR components (e.g., RouterApp.server.jsx) that use client-only +// React APIs (useState, Component, etc.) incompatible with the react-server condition. +// Client component references are handled automatically by the RSC loader/plugin. import registerServerComponent from 'react-on-rails-pro/registerServerComponent/server'; import ServerComponentsPage from '../bundles/server-components/ServerComponentsPage'; diff --git a/config/webpack/serverWebpackConfig.js b/config/webpack/serverWebpackConfig.js index a97ae19b2..e79fabe6c 100644 --- a/config/webpack/serverWebpackConfig.js +++ b/config/webpack/serverWebpackConfig.js @@ -171,6 +171,17 @@ const configureServer = () => { 'react-dom/server.browser.js$': 'react-dom/server.node.js', }; + // react-on-rails-pro includes RSC-related modules that import Node.js builtins + // (path, fs, stream). These code paths aren't used in the traditional SSR bundle, + // so provide empty fallbacks to avoid resolution errors. + serverWebpackConfig.resolve.fallback = { + ...serverWebpackConfig.resolve.fallback, + path: false, + fs: false, + 'fs/promises': false, + stream: false, + }; + return serverWebpackConfig; }; From 3f7e452b77b2fa0137aa29e92dd31ff5da1db9b8 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 5 Apr 2026 00:47:56 -1000 Subject: [PATCH 09/11] Use externals instead of fallbacks for Node builtins in server bundle The server bundle runs in Node.js, so use externals to resolve path, fs, stream at runtime via require() instead of replacing them with empty modules. This avoids potential runtime crashes when react-on-rails-pro RSC modules are imported transitively. Co-Authored-By: Claude Opus 4.6 (1M context) --- config/webpack/serverWebpackConfig.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/config/webpack/serverWebpackConfig.js b/config/webpack/serverWebpackConfig.js index e79fabe6c..6967ed4f2 100644 --- a/config/webpack/serverWebpackConfig.js +++ b/config/webpack/serverWebpackConfig.js @@ -172,14 +172,15 @@ const configureServer = () => { }; // react-on-rails-pro includes RSC-related modules that import Node.js builtins - // (path, fs, stream). These code paths aren't used in the traditional SSR bundle, - // so provide empty fallbacks to avoid resolution errors. - serverWebpackConfig.resolve.fallback = { - ...serverWebpackConfig.resolve.fallback, - path: false, - fs: false, - 'fs/promises': false, - stream: false, + // (path, fs, stream). Externalize them so they resolve at runtime via require() + // in the Node.js environment where the SSR bundle executes. + const existingExternals = serverWebpackConfig.externals || {}; + serverWebpackConfig.externals = { + ...(typeof existingExternals === 'object' && !Array.isArray(existingExternals) ? existingExternals : {}), + path: 'commonjs path', + fs: 'commonjs fs', + 'fs/promises': 'commonjs fs/promises', + stream: 'commonjs stream', }; return serverWebpackConfig; From 1022234fa883e2a60bf9640bbd32312b8962dd93 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 5 Apr 2026 15:24:23 -1000 Subject: [PATCH 10/11] Fix SSR runtime failures: Node renderer, polyfills, and RSC classification Three root causes for the 37/38 rspec test failures: 1. CI missing Node renderer: The RSC branch switched SSR from ExecJS to the react-on-rails-pro NodeRenderer service (port 3800). CI never started this service, causing Net::ReadTimeout on all SSR requests. Added renderer startup step and RENDERER_PASSWORD env var to CI. 2. Server bundle externals broke in VM sandbox: The previous commit externalized Node builtins (path/fs/stream) as CommonJS requires, but the Node renderer runs bundles in a vm.createContext() sandbox where require() is unavailable. Reverted to resolve.fallback: false which stubs these unused code paths at build time instead. 3. MessageChannel undefined in VM: react-dom/server.browser.js instantiates MessageChannel at module load time. The Node renderer's VM sandbox lacks this browser global (unlike Bun/ExecJS on master). Added a BannerPlugin polyfill injected at bundle top. 4. RouterApp.server.jsx misclassified as RSC: The auto-bundling system registered it via registerServerComponent() because it lacked 'use client'. But it's a traditional SSR component (StaticRouter), not an RSC. Added 'use client' directive so it registers via ReactOnRails.register() instead. All 38 rspec tests now pass locally. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/rspec_test.yml | 15 ++++++ .../ror_components/RouterApp.server.jsx | 2 + config/webpack/serverWebpackConfig.js | 47 ++++++++++++------- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/.github/workflows/rspec_test.yml b/.github/workflows/rspec_test.yml index d117458a6..5ebcb89da 100644 --- a/.github/workflows/rspec_test.yml +++ b/.github/workflows/rspec_test.yml @@ -33,6 +33,7 @@ jobs: DRIVER: selenium_chrome CHROME_BIN: /usr/bin/google-chrome USE_COVERALLS: true + RENDERER_PASSWORD: devPassword steps: - name: Install Chrome @@ -82,6 +83,20 @@ jobs: - name: Build shakapacker chunks run: NODE_ENV=development bundle exec bin/shakapacker + - name: Start Node renderer for SSR + run: | + node react-on-rails-pro-node-renderer.js & + echo "Waiting for Node renderer on port 3800..." + for i in $(seq 1 30); do + if nc -z localhost 3800 2>/dev/null; then + echo "Node renderer is ready" + exit 0 + fi + sleep 1 + done + echo "Node renderer failed to start within 30 seconds" + exit 1 + - name: Run rspec with xvfb uses: coactions/setup-xvfb@v1 with: diff --git a/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.server.jsx b/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.server.jsx index dd3578ba8..7dd9d0a81 100644 --- a/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.server.jsx +++ b/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.server.jsx @@ -1,3 +1,5 @@ +'use client'; + // Compare to ./RouterApp.client.jsx import { Provider } from 'react-redux'; import React from 'react'; diff --git a/config/webpack/serverWebpackConfig.js b/config/webpack/serverWebpackConfig.js index 6967ed4f2..48e98424a 100644 --- a/config/webpack/serverWebpackConfig.js +++ b/config/webpack/serverWebpackConfig.js @@ -162,26 +162,39 @@ const configureServer = () => { // The default of cheap-module-source-map is slow and provides poor info. serverWebpackConfig.devtool = 'eval'; - // Alias react-dom/server to the Node.js version for the Pro Node renderer. - // The default browser version uses MessageChannel which isn't available in the Node VM. + // react-on-rails-pro includes RSC-related modules that import Node.js builtins + // (path, fs, stream). These code paths aren't exercised in the SSR bundle, + // so provide empty fallbacks to satisfy the resolver without bundling them. serverWebpackConfig.resolve = serverWebpackConfig.resolve || {}; - serverWebpackConfig.resolve.alias = { - ...serverWebpackConfig.resolve.alias, - 'react-dom/server.browser$': 'react-dom/server.node', - 'react-dom/server.browser.js$': 'react-dom/server.node.js', + serverWebpackConfig.resolve.fallback = { + ...serverWebpackConfig.resolve.fallback, + path: false, + fs: false, + 'fs/promises': false, + stream: false, }; - // react-on-rails-pro includes RSC-related modules that import Node.js builtins - // (path, fs, stream). Externalize them so they resolve at runtime via require() - // in the Node.js environment where the SSR bundle executes. - const existingExternals = serverWebpackConfig.externals || {}; - serverWebpackConfig.externals = { - ...(typeof existingExternals === 'object' && !Array.isArray(existingExternals) ? existingExternals : {}), - path: 'commonjs path', - fs: 'commonjs fs', - 'fs/promises': 'commonjs fs/promises', - stream: 'commonjs stream', - }; + // The Node renderer runs bundles in a VM sandbox that lacks browser globals + // like MessageChannel and TextEncoder. Inject polyfills at the top of the + // bundle so react-dom/server.browser can initialize. + serverWebpackConfig.plugins.push( + new bundler.BannerPlugin({ + banner: [ + 'if(typeof MessageChannel==="undefined"){', + ' globalThis.MessageChannel=class MessageChannel{', + ' constructor(){', + ' this.port1={onmessage:null};', + ' this.port2={postMessage:function(msg){', + ' var p=this._port1;if(p.onmessage)p.onmessage({data:msg});', + ' }};', + ' this.port2._port1=this.port1;', + ' }', + ' };', + '}', + ].join('\n'), + raw: true, + }), + ); return serverWebpackConfig; }; From bc41706de36c47620a9d89b3495de2c0f2bb754c Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 5 Apr 2026 19:57:42 -1000 Subject: [PATCH 11/11] Address PR review fixes 1-12 and resolve review threads --- Procfile.dev | 2 +- .../components/CommentsFeed.jsx | 60 ++++++++++++----- config/initializers/react_on_rails_pro.rb | 17 ++++- config/webpack/rscWebpackConfig.js | 10 ++- config/webpack/rspackRscPlugin.js | 65 ++++++++++--------- config/webpack/serverWebpackConfig.js | 19 +++--- config/webpack/webpackConfig.js | 2 +- react-on-rails-pro-node-renderer.js | 9 ++- 8 files changed, 115 insertions(+), 69 deletions(-) diff --git a/Procfile.dev b/Procfile.dev index 94a640088..d37228687 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -15,4 +15,4 @@ wp-server: SERVER_BUNDLE_ONLY=yes bin/shakapacker --watch # RSC Rspack watcher for React Server Components bundle wp-rsc: RSC_BUNDLE_ONLY=true bin/shakapacker --watch # React on Rails Pro Node renderer for SSR and RSC payload generation -node-renderer: RENDERER_PASSWORD=devPassword node react-on-rails-pro-node-renderer.js +node-renderer: node react-on-rails-pro-node-renderer.js diff --git a/client/app/bundles/server-components/components/CommentsFeed.jsx b/client/app/bundles/server-components/components/CommentsFeed.jsx index 0230c1d19..1260ffdf8 100644 --- a/client/app/bundles/server-components/components/CommentsFeed.jsx +++ b/client/app/bundles/server-components/components/CommentsFeed.jsx @@ -11,33 +11,59 @@ import TogglePanel from './TogglePanel'; const marked = new Marked(); marked.use(gfmHeadingId()); +function resolveRailsBaseUrl() { + if (process.env.RAILS_INTERNAL_URL) { + return process.env.RAILS_INTERNAL_URL; + } + + // Local defaults are okay in development/test, but production should be explicit. + if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') { + return 'http://localhost:3000'; + } + + throw new Error('RAILS_INTERNAL_URL must be set outside development/test'); +} + async function CommentsFeed() { - // Simulate network latency to demonstrate Suspense streaming (development only) - if (process.env.NODE_ENV !== 'production') { + // Simulate network latency only when explicitly enabled for demos. + if (process.env.RSC_SUSPENSE_DEMO_DELAY === 'true') { await new Promise((resolve) => { setTimeout(resolve, 800); }); } - // Fetch comments directly from the Rails API — no client-side fetch needed - const baseUrl = process.env.RAILS_INTERNAL_URL || 'http://localhost:3000'; - const response = await fetch(`${baseUrl}/comments.json`); - if (!response.ok) { - throw new Error(`Failed to fetch comments: ${response.status} ${response.statusText}`); - } - const comments = await response.json(); + let recentComments = []; + try { + // Fetch comments directly from the Rails API — no client-side fetch needed + const baseUrl = resolveRailsBaseUrl(); + const response = await fetch(`${baseUrl}/comments.json`); + if (!response.ok) { + throw new Error(`Failed to fetch comments: ${response.status} ${response.statusText}`); + } + const comments = await response.json(); - // Use lodash to process (stays on server) - const sortedComments = _.orderBy(comments, ['created_at'], ['desc']); - const recentComments = _.take(sortedComments, 10); + // Use lodash to process (stays on server) + const sortedComments = _.orderBy(comments, ['created_at'], ['desc']); + recentComments = _.take(sortedComments, 10); + } catch (error) { + // eslint-disable-next-line no-console + console.error('CommentsFeed failed to load comments', error); + return ( +
    +

    Could not load comments right now. Please try again later.

    +
    + ); + } if (recentComments.length === 0) { return (

    No comments yet. Add some comments from the{' '} - home page to see them rendered here - by server components. + + home page + {' '} + to see them rendered here by server components.

    ); @@ -56,7 +82,7 @@ async function CommentsFeed() { ...sanitizeHtml.defaults.allowedAttributes, img: ['src', 'alt', 'title', 'width', 'height'], }, - allowedSchemes: ['https', 'http', 'data'], + allowedSchemes: ['https', 'http'], }); return ( @@ -89,8 +115,8 @@ async function CommentsFeed() { ); })}

    - {recentComments.length} comment{recentComments.length !== 1 ? 's' : ''} rendered on the server using - {' '}marked + sanitize-html (never sent to browser) + {recentComments.length} comment{recentComments.length !== 1 ? 's' : ''} rendered on the server using{' '} + marked + sanitize-html (never sent to browser)

    ); diff --git a/config/initializers/react_on_rails_pro.rb b/config/initializers/react_on_rails_pro.rb index e5ab7965a..c31b87ab1 100644 --- a/config/initializers/react_on_rails_pro.rb +++ b/config/initializers/react_on_rails_pro.rb @@ -2,9 +2,20 @@ ReactOnRailsPro.configure do |config| # Node renderer for server-side rendering and RSC payload generation - config.server_renderer = "NodeRenderer" - config.renderer_url = ENV["REACT_RENDERER_URL"] || "http://localhost:3800" - config.renderer_password = ENV.fetch("RENDERER_PASSWORD", "devPassword") + use_node_renderer = Rails.env.development? || ENV["REACT_USE_NODE_RENDERER"] == "true" + + if use_node_renderer + renderer_host = ENV.fetch("RENDERER_HOST", "localhost") + renderer_port = ENV.fetch("RENDERER_PORT", "3800") + + config.server_renderer = "NodeRenderer" + config.renderer_url = ENV.fetch("REACT_RENDERER_URL", "http://#{renderer_host}:#{renderer_port}") + config.renderer_password = if Rails.env.local? + ENV.fetch("RENDERER_PASSWORD", "local-dev-renderer-password") + else + ENV.fetch("RENDERER_PASSWORD") + end + end # Enable React Server Components support config.enable_rsc_support = true diff --git a/config/webpack/rscWebpackConfig.js b/config/webpack/rscWebpackConfig.js index ebd137cda..83ffb504a 100644 --- a/config/webpack/rscWebpackConfig.js +++ b/config/webpack/rscWebpackConfig.js @@ -28,9 +28,7 @@ const configureRsc = () => { // Use the dedicated rsc-bundle entry point const rscEntry = rscConfig.entry['rsc-bundle']; if (!rscEntry) { - throw new Error( - 'RSC bundle entry not found. Ensure client/app/packs/rsc-bundle.js exists.', - ); + throw new Error('RSC bundle entry not found. Ensure client/app/packs/rsc-bundle.js exists.'); } rscConfig.entry = { 'rsc-bundle': rscEntry }; @@ -63,8 +61,8 @@ const configureRsc = () => { cssLoader.options.modules = { ...cssLoader.options.modules, exportOnlyLocals: true }; } } else if ( - rule.use?.loader - && (rule.use.loader.includes('url-loader') || rule.use.loader.includes('file-loader')) + rule.use?.loader && + (rule.use.loader.includes('url-loader') || rule.use.loader.includes('file-loader')) ) { rule.use.options = { ...(rule.use.options || {}), emitFile: false }; } @@ -103,7 +101,7 @@ const configureRsc = () => { // Target Node.js so server-only modules (os, fs, stream, etc.) resolve correctly rscConfig.target = 'node'; - rscConfig.devtool = 'eval'; + rscConfig.devtool = process.env.NODE_ENV === 'production' ? 'source-map' : 'eval'; // RSC manifest plugin rscConfig.plugins.push(new RspackRscPlugin({ isServer: true })); diff --git a/config/webpack/rspackRscPlugin.js b/config/webpack/rspackRscPlugin.js index 1c3c2f659..331cc1170 100644 --- a/config/webpack/rspackRscPlugin.js +++ b/config/webpack/rspackRscPlugin.js @@ -9,32 +9,6 @@ const fs = require('fs'); const { sources } = require('@rspack/core'); -// Cache for file 'use client' checks -const useClientCache = new Map(); - -function hasUseClientDirective(filePath) { - if (useClientCache.has(filePath)) return useClientCache.get(filePath); - - let result = false; - try { - // Read the first ~200 bytes — 'use client' must be at the very top of the file - const fd = fs.openSync(filePath, 'r'); - const buf = Buffer.alloc(200); - fs.readSync(fd, buf, 0, 200, 0); - fs.closeSync(fd); - - const head = buf.toString('utf-8'); - // Check for 'use client' as the first statement. - // Allow comments (single-line // or block /* */) before the directive. - result = /^(?:\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/))*\s*['"]use client['"]/.test(head); - } catch (_) { - // file doesn't exist or can't be read - } - - useClientCache.set(filePath, result); - return result; -} - class RspackRscPlugin { constructor(options) { if (!options || typeof options.isServer !== 'boolean') { @@ -45,12 +19,13 @@ class RspackRscPlugin { ? 'react-server-client-manifest.json' : 'react-client-manifest.json'; this.ssrManifestFilename = 'react-ssr-manifest.json'; + this.useClientCache = new Map(); } apply(compiler) { // Clear cache on each compilation so watch-mode picks up 'use client' changes compiler.hooks.thisCompilation.tap('RspackRscPlugin-ClearCache', () => { - useClientCache.clear(); + this.useClientCache.clear(); }); compiler.hooks.thisCompilation.tap('RspackRscPlugin', (compilation) => { @@ -103,6 +78,36 @@ class RspackRscPlugin { }); } + _hasUseClientDirective(filePath) { + if (this.useClientCache.has(filePath)) return this.useClientCache.get(filePath); + + let result = false; + let fd; + try { + // Read the first ~200 bytes — 'use client' must be at the very top of the file. + fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(200); + fs.readSync(fd, buf, 0, 200, 0); + + const head = buf.toString('utf-8'); + // Allow comments before the directive. + result = /^(?:\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/))*\s*['"]use client['"]/.test(head); + } catch (_) { + // file doesn't exist or can't be read + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch (_) { + // no-op + } + } + } + + this.useClientCache.set(filePath, result); + return result; + } + _processModule(mod, chunk, chunkFiles, manifest, compilation) { const resource = mod.resource || mod.userRequest; if (!resource || !resource.match(/\.(js|jsx|ts|tsx)$/)) return; @@ -110,11 +115,9 @@ class RspackRscPlugin { if (resource.includes('node_modules')) return; // Check original file for 'use client' directive - if (!hasUseClientDirective(resource)) return; + if (!this._hasUseClientDirective(resource)) return; - const moduleId = compilation.chunkGraph - ? compilation.chunkGraph.getModuleId(mod) - : mod.id; + const moduleId = compilation.chunkGraph ? compilation.chunkGraph.getModuleId(mod) : mod.id; if (moduleId == null) return; diff --git a/config/webpack/serverWebpackConfig.js b/config/webpack/serverWebpackConfig.js index 48e98424a..dd20c06d8 100644 --- a/config/webpack/serverWebpackConfig.js +++ b/config/webpack/serverWebpackConfig.js @@ -58,13 +58,13 @@ const configureServer = () => { throw new Error( `Server bundle entry 'server-bundle' not found.\n` + - `Expected file: ${fullPath}\n` + - `Current source_path: ${config.source_path}\n` + - `Current source_entry_path: ${config.source_entry_path}\n` + - `Verify:\n` + - `1. The server-bundle.js file exists at the expected location\n` + - `2. nested_entries is configured correctly in shakapacker.yml\n` + - `3. The file is properly exported from your entry point`, + `Expected file: ${fullPath}\n` + + `Current source_path: ${config.source_path}\n` + + `Current source_entry_path: ${config.source_entry_path}\n` + + `Verify:\n` + + `1. The server-bundle.js file exists at the expected location\n` + + `2. nested_entries is configured correctly in shakapacker.yml\n` + + `3. The file is properly exported from your entry point`, ); } @@ -94,7 +94,7 @@ const configureServer = () => { serverWebpackConfig.output = { filename: 'server-bundle.js', globalObject: 'this', - // libraryTarget: 'commonjs2', + libraryTarget: 'commonjs2', path: path.resolve(__dirname, '../../ssr-generated'), publicPath: config.publicPath, // https://webpack.js.org/configuration/output/#outputglobalobject @@ -199,4 +199,5 @@ const configureServer = () => { return serverWebpackConfig; }; -module.exports = { default: configureServer, extractLoader }; +module.exports = configureServer; +module.exports.extractLoader = extractLoader; diff --git a/config/webpack/webpackConfig.js b/config/webpack/webpackConfig.js index 595fc63c3..df036a145 100644 --- a/config/webpack/webpackConfig.js +++ b/config/webpack/webpackConfig.js @@ -2,7 +2,7 @@ // https://github.com/shakacode/react_on_rails_tutorial_with_ssr_and_hmr_fast_refresh/blob/master/config/webpack/webpackConfig.js const clientWebpackConfig = require('./clientWebpackConfig'); -const { default: serverWebpackConfig } = require('./serverWebpackConfig'); +const serverWebpackConfig = require('./serverWebpackConfig'); const rscWebpackConfig = require('./rscWebpackConfig'); const webpackConfig = (envSpecific) => { diff --git a/react-on-rails-pro-node-renderer.js b/react-on-rails-pro-node-renderer.js index 0f728691b..d80045899 100644 --- a/react-on-rails-pro-node-renderer.js +++ b/react-on-rails-pro-node-renderer.js @@ -1,10 +1,17 @@ const path = require('path'); const { reactOnRailsProNodeRenderer } = require('react-on-rails-pro-node-renderer'); +const isProduction = process.env.NODE_ENV === 'production'; +const rendererPassword = process.env.RENDERER_PASSWORD || (!isProduction && 'local-dev-renderer-password'); + +if (!rendererPassword) { + throw new Error('RENDERER_PASSWORD must be set in production'); +} + const config = { serverBundleCachePath: path.resolve(__dirname, '.node-renderer-bundles'), logLevel: process.env.RENDERER_LOG_LEVEL || 'debug', - password: process.env.RENDERER_PASSWORD || 'devPassword', + password: rendererPassword, port: process.env.RENDERER_PORT || 3800, supportModules: true, workersCount: Number(process.env.NODE_RENDERER_CONCURRENCY || 3),