From 74fedd5d75f54edcc79e735a1e46d6ea90f5d548 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Nov 2025 23:54:04 -0800 Subject: [PATCH 01/20] chore(rsc-demo): runtime client ref discovery + MF manifest data --- apps/rsc-demo/.gitignore | 41 + apps/rsc-demo/.nvmrc | 1 + apps/rsc-demo/.prettierignore | 27 + apps/rsc-demo/.prettierrc.js | 18 + apps/rsc-demo/AGENTS.md | 43 + apps/rsc-demo/CODE_OF_CONDUCT.md | 76 + apps/rsc-demo/Dockerfile | 12 + apps/rsc-demo/LICENSE | 21 + apps/rsc-demo/README.md | 163 + apps/rsc-demo/credentials.js | 7 + apps/rsc-demo/docker-compose.yml | 34 + apps/rsc-demo/notes/.gitkeep | 0 apps/rsc-demo/notes/RESEARCH.md | 842 +++ apps/rsc-demo/package.json | 80 + .../app-shared/framework/bootstrap.js | 54 + .../packages/app-shared/framework/router.js | 162 + .../app-shared/mf/runtimeLogPlugin.js | 100 + .../scripts/rscDebugRuntimePlugin.js | 235 + .../app-shared/scripts/rscRuntimePlugin.js | 371 + .../app-shared/scripts/webpackShared.js | 26 + apps/rsc-demo/packages/app1/credentials.js | 7 + apps/rsc-demo/packages/app1/package.json | 33 + apps/rsc-demo/packages/app1/project.json | 27 + .../packages/app1/public/checkmark.svg | 3 + .../packages/app1/public/chevron-down.svg | 3 + .../packages/app1/public/chevron-up.svg | 3 + apps/rsc-demo/packages/app1/public/cross.svg | 3 + .../rsc-demo/packages/app1/public/favicon.ico | Bin 0 -> 15406 bytes apps/rsc-demo/packages/app1/public/index.html | 32 + apps/rsc-demo/packages/app1/public/logo.svg | 9 + apps/rsc-demo/packages/app1/public/style.css | 700 ++ apps/rsc-demo/packages/app1/scripts/build.js | 629 ++ .../rsc-demo/packages/app1/scripts/init_db.sh | 13 + apps/rsc-demo/packages/app1/scripts/seed.js | 92 + .../packages/app1/server/api.server.js | 697 ++ .../packages/app1/server/package.json | 4 + .../packages/app1/server/ssr-worker.js | 105 + apps/rsc-demo/packages/app1/src/App.js | 56 + .../packages/app1/src/DemoCounter.server.js | 14 + .../packages/app1/src/DemoCounterButton.js | 39 + apps/rsc-demo/packages/app1/src/EditButton.js | 38 + .../packages/app1/src/FederatedActionDemo.js | 122 + .../packages/app1/src/FederatedDemo.server.js | 96 + .../packages/app1/src/InlineActionButton.js | 93 + .../app1/src/InlineActionDemo.server.js | 37 + apps/rsc-demo/packages/app1/src/Note.js | 66 + apps/rsc-demo/packages/app1/src/NoteEditor.js | 123 + apps/rsc-demo/packages/app1/src/NoteList.js | 43 + .../packages/app1/src/NoteListSkeleton.js | 34 + .../rsc-demo/packages/app1/src/NotePreview.js | 17 + .../packages/app1/src/NoteSkeleton.js | 77 + .../packages/app1/src/RemoteButton.js | 88 + .../rsc-demo/packages/app1/src/SearchField.js | 42 + .../packages/app1/src/SharedDemo.server.js | 12 + .../rsc-demo/packages/app1/src/SidebarNote.js | 35 + .../packages/app1/src/SidebarNoteContent.js | 91 + apps/rsc-demo/packages/app1/src/Spinner.js | 17 + .../packages/app1/src/TextWithMarkdown.js | 38 + apps/rsc-demo/packages/app1/src/db.js | 103 + .../packages/app1/src/framework/bootstrap.js | 1 + .../packages/app1/src/framework/router.js | 11 + .../packages/app1/src/framework/ssr-entry.js | 43 + .../app1/src/inline-actions.server.js | 43 + .../packages/app1/src/server-actions.js | 12 + .../packages/app1/src/server-entry.js | 128 + .../packages/app1/src/test-default-action.js | 6 + apps/rsc-demo/packages/app2/credentials.js | 7 + apps/rsc-demo/packages/app2/package.json | 35 + apps/rsc-demo/packages/app2/project.json | 27 + .../packages/app2/public/checkmark.svg | 3 + .../packages/app2/public/chevron-down.svg | 3 + .../packages/app2/public/chevron-up.svg | 3 + apps/rsc-demo/packages/app2/public/cross.svg | 3 + .../rsc-demo/packages/app2/public/favicon.ico | Bin 0 -> 15406 bytes apps/rsc-demo/packages/app2/public/index.html | 32 + apps/rsc-demo/packages/app2/public/logo.svg | 9 + apps/rsc-demo/packages/app2/public/style.css | 700 ++ apps/rsc-demo/packages/app2/scripts/build.js | 595 ++ .../rsc-demo/packages/app2/scripts/init_db.sh | 13 + apps/rsc-demo/packages/app2/scripts/seed.js | 92 + .../packages/app2/server/api.server.js | 548 ++ .../packages/app2/server/package.json | 4 + .../packages/app2/server/ssr-worker.js | 105 + apps/rsc-demo/packages/app2/src/App.js | 56 + apps/rsc-demo/packages/app2/src/Button.js | 44 + .../packages/app2/src/DemoCounter.server.js | 14 + .../packages/app2/src/DemoCounterButton.js | 39 + apps/rsc-demo/packages/app2/src/EditButton.js | 38 + .../packages/app2/src/InlineActionButton.js | 94 + .../app2/src/InlineActionDemo.server.js | 37 + apps/rsc-demo/packages/app2/src/Note.js | 66 + apps/rsc-demo/packages/app2/src/NoteEditor.js | 123 + apps/rsc-demo/packages/app2/src/NoteList.js | 43 + .../packages/app2/src/NoteListSkeleton.js | 34 + .../rsc-demo/packages/app2/src/NotePreview.js | 17 + .../packages/app2/src/NoteSkeleton.js | 77 + .../rsc-demo/packages/app2/src/SearchField.js | 42 + .../packages/app2/src/SharedDemo.server.js | 12 + .../rsc-demo/packages/app2/src/SidebarNote.js | 35 + .../packages/app2/src/SidebarNoteContent.js | 91 + apps/rsc-demo/packages/app2/src/Spinner.js | 17 + .../packages/app2/src/TextWithMarkdown.js | 38 + apps/rsc-demo/packages/app2/src/db.js | 103 + .../packages/app2/src/framework/bootstrap.js | 1 + .../packages/app2/src/framework/router.js | 11 + .../packages/app2/src/framework/ssr-entry.js | 43 + .../app2/src/inline-actions.server.js | 42 + .../packages/app2/src/server-actions.js | 12 + .../packages/app2/src/server-entry.js | 56 + .../packages/app2/src/test-default-action.js | 6 + .../packages/e2e/e2e/mf.apps.e2e.test.js | 720 ++ .../e2e/e2e/rsc.app2.notes.e2e.test.js | 180 + .../packages/e2e/e2e/rsc.notes.e2e.test.js | 625 ++ .../packages/e2e/mf/mf.bundle-exec.test.js | 54 + apps/rsc-demo/packages/e2e/package.json | 25 + .../e2e/rsc/combination-matrix.test.js | 520 ++ .../rsc-demo/packages/e2e/rsc/loaders.test.js | 504 ++ .../e2e/rsc/server.action.endpoint.test.js | 329 + .../packages/e2e/rsc/server.action.test.js | 69 + .../packages/e2e/rsc/server.endpoint.test.js | 112 + .../e2e/rsc/server.federation.test.js | 483 ++ .../packages/e2e/rsc/server.html.test.js | 47 + .../server.inline-actions.endpoint.test.js | 128 + .../e2e/rsc/server.mfNativeActions.test.js | 771 ++ .../e2e/rsc/server2.action.endpoint.test.js | 178 + .../packages/e2e/rsc/server2.endpoint.test.js | 107 + .../packages/e2e/rsc/server2.html.test.js | 47 + .../server2.inline-actions.endpoint.test.js | 129 + .../packages/e2e/rsc/ssr.smoke.test.js | 82 + .../packages/e2e/rsc/ssr2.smoke.test.js | 80 + .../packages/react-server-dom-webpack/LICENSE | 21 + .../react-server-dom-webpack/README.md | 5 + ...-dom-webpack-client.browser.development.js | 4973 +++++++++++++ ...r-dom-webpack-client.browser.production.js | 1902 +++++ ...ver-dom-webpack-client.edge.development.js | 4954 +++++++++++++ ...rver-dom-webpack-client.edge.production.js | 2082 ++++++ ...ver-dom-webpack-client.node.development.js | 5097 ++++++++++++++ ...rver-dom-webpack-client.node.production.js | 2210 ++++++ ...bpack-client.node.unbundled.development.js | 5057 ++++++++++++++ ...ebpack-client.node.unbundled.production.js | 2175 ++++++ .../react-server-dom-webpack-node-register.js | 219 + .../cjs/react-server-dom-webpack-plugin.js | 702 ++ ...-dom-webpack-server.browser.development.js | 5297 ++++++++++++++ ...r-dom-webpack-server.browser.production.js | 3055 ++++++++ ...ver-dom-webpack-server.edge.development.js | 5398 ++++++++++++++ ...rver-dom-webpack-server.edge.production.js | 3088 +++++++++ ...ver-dom-webpack-server.node.development.js | 6171 +++++++++++++++++ ...rver-dom-webpack-server.node.production.js | 3289 +++++++++ ...bpack-server.node.unbundled.development.js | 6134 ++++++++++++++++ ...ebpack-server.node.unbundled.production.js | 3255 +++++++++ .../cjs/rsc-client-loader.js | 137 + .../cjs/rsc-server-loader.js | 372 + .../cjs/rsc-ssr-loader.js | 117 + .../client.browser.js | 7 + .../react-server-dom-webpack/client.edge.js | 7 + .../react-server-dom-webpack/client.js | 3 + .../react-server-dom-webpack/client.node.js | 7 + .../client.node.unbundled.js | 7 + .../react-server-dom-webpack/esm/package.json | 3 + ...rver-dom-webpack-node-loader.production.js | 686 ++ .../react-server-dom-webpack/index.js | 12 + .../react-server-dom-webpack/node-register.js | 3 + .../react-server-dom-webpack/package.json | 114 + .../react-server-dom-webpack/plugin.js | 3 + .../server.browser.js | 17 + .../react-server-dom-webpack/server.edge.js | 18 + .../react-server-dom-webpack/server.js | 6 + .../react-server-dom-webpack/server.node.js | 71 + .../server.node.unbundled.js | 70 + .../static.browser.js | 10 + .../react-server-dom-webpack/static.edge.js | 10 + .../react-server-dom-webpack/static.js | 6 + .../react-server-dom-webpack/static.node.js | 11 + .../static.node.unbundled.js | 10 + .../packages/shared-components/package.json | 12 + .../src/SharedClientButton.js | 23 + .../src/SharedServerAction.js | 17 + .../src/SharedServerComponent.js | 12 + .../packages/shared-components/src/index.js | 3 + .../rsc-demo/packages/shared-rsc/package.json | 6 + .../shared-rsc/src/SharedClientWidget.js | 7 + .../rsc-demo/packages/shared-rsc/src/index.js | 2 + .../shared-rsc/src/shared-server-actions.js | 12 + apps/rsc-demo/project.json | 48 + packages/manifest/src/ManifestManager.ts | 90 +- pnpm-lock.yaml | 2605 +++++-- pnpm-workspace.yaml | 1 + 187 files changed, 83653 insertions(+), 734 deletions(-) create mode 100644 apps/rsc-demo/.gitignore create mode 100644 apps/rsc-demo/.nvmrc create mode 100644 apps/rsc-demo/.prettierignore create mode 100644 apps/rsc-demo/.prettierrc.js create mode 100644 apps/rsc-demo/AGENTS.md create mode 100644 apps/rsc-demo/CODE_OF_CONDUCT.md create mode 100644 apps/rsc-demo/Dockerfile create mode 100644 apps/rsc-demo/LICENSE create mode 100644 apps/rsc-demo/README.md create mode 100644 apps/rsc-demo/credentials.js create mode 100644 apps/rsc-demo/docker-compose.yml create mode 100644 apps/rsc-demo/notes/.gitkeep create mode 100644 apps/rsc-demo/notes/RESEARCH.md create mode 100644 apps/rsc-demo/package.json create mode 100644 apps/rsc-demo/packages/app-shared/framework/bootstrap.js create mode 100644 apps/rsc-demo/packages/app-shared/framework/router.js create mode 100644 apps/rsc-demo/packages/app-shared/mf/runtimeLogPlugin.js create mode 100644 apps/rsc-demo/packages/app-shared/scripts/rscDebugRuntimePlugin.js create mode 100644 apps/rsc-demo/packages/app-shared/scripts/rscRuntimePlugin.js create mode 100644 apps/rsc-demo/packages/app-shared/scripts/webpackShared.js create mode 100644 apps/rsc-demo/packages/app1/credentials.js create mode 100644 apps/rsc-demo/packages/app1/package.json create mode 100644 apps/rsc-demo/packages/app1/project.json create mode 100644 apps/rsc-demo/packages/app1/public/checkmark.svg create mode 100644 apps/rsc-demo/packages/app1/public/chevron-down.svg create mode 100644 apps/rsc-demo/packages/app1/public/chevron-up.svg create mode 100644 apps/rsc-demo/packages/app1/public/cross.svg create mode 100644 apps/rsc-demo/packages/app1/public/favicon.ico create mode 100644 apps/rsc-demo/packages/app1/public/index.html create mode 100644 apps/rsc-demo/packages/app1/public/logo.svg create mode 100644 apps/rsc-demo/packages/app1/public/style.css create mode 100644 apps/rsc-demo/packages/app1/scripts/build.js create mode 100755 apps/rsc-demo/packages/app1/scripts/init_db.sh create mode 100644 apps/rsc-demo/packages/app1/scripts/seed.js create mode 100644 apps/rsc-demo/packages/app1/server/api.server.js create mode 100644 apps/rsc-demo/packages/app1/server/package.json create mode 100644 apps/rsc-demo/packages/app1/server/ssr-worker.js create mode 100644 apps/rsc-demo/packages/app1/src/App.js create mode 100644 apps/rsc-demo/packages/app1/src/DemoCounter.server.js create mode 100644 apps/rsc-demo/packages/app1/src/DemoCounterButton.js create mode 100644 apps/rsc-demo/packages/app1/src/EditButton.js create mode 100644 apps/rsc-demo/packages/app1/src/FederatedActionDemo.js create mode 100644 apps/rsc-demo/packages/app1/src/FederatedDemo.server.js create mode 100644 apps/rsc-demo/packages/app1/src/InlineActionButton.js create mode 100644 apps/rsc-demo/packages/app1/src/InlineActionDemo.server.js create mode 100644 apps/rsc-demo/packages/app1/src/Note.js create mode 100644 apps/rsc-demo/packages/app1/src/NoteEditor.js create mode 100644 apps/rsc-demo/packages/app1/src/NoteList.js create mode 100644 apps/rsc-demo/packages/app1/src/NoteListSkeleton.js create mode 100644 apps/rsc-demo/packages/app1/src/NotePreview.js create mode 100644 apps/rsc-demo/packages/app1/src/NoteSkeleton.js create mode 100644 apps/rsc-demo/packages/app1/src/RemoteButton.js create mode 100644 apps/rsc-demo/packages/app1/src/SearchField.js create mode 100644 apps/rsc-demo/packages/app1/src/SharedDemo.server.js create mode 100644 apps/rsc-demo/packages/app1/src/SidebarNote.js create mode 100644 apps/rsc-demo/packages/app1/src/SidebarNoteContent.js create mode 100644 apps/rsc-demo/packages/app1/src/Spinner.js create mode 100644 apps/rsc-demo/packages/app1/src/TextWithMarkdown.js create mode 100644 apps/rsc-demo/packages/app1/src/db.js create mode 100644 apps/rsc-demo/packages/app1/src/framework/bootstrap.js create mode 100644 apps/rsc-demo/packages/app1/src/framework/router.js create mode 100644 apps/rsc-demo/packages/app1/src/framework/ssr-entry.js create mode 100644 apps/rsc-demo/packages/app1/src/inline-actions.server.js create mode 100644 apps/rsc-demo/packages/app1/src/server-actions.js create mode 100644 apps/rsc-demo/packages/app1/src/server-entry.js create mode 100644 apps/rsc-demo/packages/app1/src/test-default-action.js create mode 100644 apps/rsc-demo/packages/app2/credentials.js create mode 100644 apps/rsc-demo/packages/app2/package.json create mode 100644 apps/rsc-demo/packages/app2/project.json create mode 100644 apps/rsc-demo/packages/app2/public/checkmark.svg create mode 100644 apps/rsc-demo/packages/app2/public/chevron-down.svg create mode 100644 apps/rsc-demo/packages/app2/public/chevron-up.svg create mode 100644 apps/rsc-demo/packages/app2/public/cross.svg create mode 100644 apps/rsc-demo/packages/app2/public/favicon.ico create mode 100644 apps/rsc-demo/packages/app2/public/index.html create mode 100644 apps/rsc-demo/packages/app2/public/logo.svg create mode 100644 apps/rsc-demo/packages/app2/public/style.css create mode 100644 apps/rsc-demo/packages/app2/scripts/build.js create mode 100755 apps/rsc-demo/packages/app2/scripts/init_db.sh create mode 100644 apps/rsc-demo/packages/app2/scripts/seed.js create mode 100644 apps/rsc-demo/packages/app2/server/api.server.js create mode 100644 apps/rsc-demo/packages/app2/server/package.json create mode 100644 apps/rsc-demo/packages/app2/server/ssr-worker.js create mode 100644 apps/rsc-demo/packages/app2/src/App.js create mode 100644 apps/rsc-demo/packages/app2/src/Button.js create mode 100644 apps/rsc-demo/packages/app2/src/DemoCounter.server.js create mode 100644 apps/rsc-demo/packages/app2/src/DemoCounterButton.js create mode 100644 apps/rsc-demo/packages/app2/src/EditButton.js create mode 100644 apps/rsc-demo/packages/app2/src/InlineActionButton.js create mode 100644 apps/rsc-demo/packages/app2/src/InlineActionDemo.server.js create mode 100644 apps/rsc-demo/packages/app2/src/Note.js create mode 100644 apps/rsc-demo/packages/app2/src/NoteEditor.js create mode 100644 apps/rsc-demo/packages/app2/src/NoteList.js create mode 100644 apps/rsc-demo/packages/app2/src/NoteListSkeleton.js create mode 100644 apps/rsc-demo/packages/app2/src/NotePreview.js create mode 100644 apps/rsc-demo/packages/app2/src/NoteSkeleton.js create mode 100644 apps/rsc-demo/packages/app2/src/SearchField.js create mode 100644 apps/rsc-demo/packages/app2/src/SharedDemo.server.js create mode 100644 apps/rsc-demo/packages/app2/src/SidebarNote.js create mode 100644 apps/rsc-demo/packages/app2/src/SidebarNoteContent.js create mode 100644 apps/rsc-demo/packages/app2/src/Spinner.js create mode 100644 apps/rsc-demo/packages/app2/src/TextWithMarkdown.js create mode 100644 apps/rsc-demo/packages/app2/src/db.js create mode 100644 apps/rsc-demo/packages/app2/src/framework/bootstrap.js create mode 100644 apps/rsc-demo/packages/app2/src/framework/router.js create mode 100644 apps/rsc-demo/packages/app2/src/framework/ssr-entry.js create mode 100644 apps/rsc-demo/packages/app2/src/inline-actions.server.js create mode 100644 apps/rsc-demo/packages/app2/src/server-actions.js create mode 100644 apps/rsc-demo/packages/app2/src/server-entry.js create mode 100644 apps/rsc-demo/packages/app2/src/test-default-action.js create mode 100644 apps/rsc-demo/packages/e2e/e2e/mf.apps.e2e.test.js create mode 100644 apps/rsc-demo/packages/e2e/e2e/rsc.app2.notes.e2e.test.js create mode 100644 apps/rsc-demo/packages/e2e/e2e/rsc.notes.e2e.test.js create mode 100644 apps/rsc-demo/packages/e2e/mf/mf.bundle-exec.test.js create mode 100644 apps/rsc-demo/packages/e2e/package.json create mode 100644 apps/rsc-demo/packages/e2e/rsc/combination-matrix.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/loaders.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/server.action.endpoint.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/server.action.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/server.endpoint.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/server.federation.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/server.html.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/server.inline-actions.endpoint.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/server.mfNativeActions.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/server2.action.endpoint.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/server2.endpoint.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/server2.html.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/server2.inline-actions.endpoint.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/ssr.smoke.test.js create mode 100644 apps/rsc-demo/packages/e2e/rsc/ssr2.smoke.test.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/LICENSE create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/README.md create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.production.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.development.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.production.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-node-register.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-server.browser.development.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-server.browser.production.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-server.edge.development.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-server.edge.production.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.development.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.production.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.unbundled.development.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.unbundled.production.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/rsc-client-loader.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/rsc-server-loader.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/cjs/rsc-ssr-loader.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/client.browser.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/client.edge.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/client.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/client.node.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/client.node.unbundled.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/esm/package.json create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/esm/react-server-dom-webpack-node-loader.production.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/index.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/node-register.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/package.json create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/plugin.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/server.browser.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/server.edge.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/server.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/server.node.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/server.node.unbundled.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/static.browser.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/static.edge.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/static.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/static.node.js create mode 100644 apps/rsc-demo/packages/react-server-dom-webpack/static.node.unbundled.js create mode 100644 apps/rsc-demo/packages/shared-components/package.json create mode 100644 apps/rsc-demo/packages/shared-components/src/SharedClientButton.js create mode 100644 apps/rsc-demo/packages/shared-components/src/SharedServerAction.js create mode 100644 apps/rsc-demo/packages/shared-components/src/SharedServerComponent.js create mode 100644 apps/rsc-demo/packages/shared-components/src/index.js create mode 100644 apps/rsc-demo/packages/shared-rsc/package.json create mode 100644 apps/rsc-demo/packages/shared-rsc/src/SharedClientWidget.js create mode 100644 apps/rsc-demo/packages/shared-rsc/src/index.js create mode 100644 apps/rsc-demo/packages/shared-rsc/src/shared-server-actions.js create mode 100644 apps/rsc-demo/project.json diff --git a/apps/rsc-demo/.gitignore b/apps/rsc-demo/.gitignore new file mode 100644 index 00000000000..26972fcd86b --- /dev/null +++ b/apps/rsc-demo/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +**/node_modules/ +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build +/dist +**/dist +/packages/app1/dist +/packages/app2/dist +**/build +/packages/app1/build +/packages/app2/build + +# notes +notes/*.md +!notes/RESEARCH.md +test-results/ + +# misc +.DS_Store +.eslintcache +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# vscode +.vscode diff --git a/apps/rsc-demo/.nvmrc b/apps/rsc-demo/.nvmrc new file mode 100644 index 00000000000..3c5535cf60a --- /dev/null +++ b/apps/rsc-demo/.nvmrc @@ -0,0 +1 @@ +18.19.1 diff --git a/apps/rsc-demo/.prettierignore b/apps/rsc-demo/.prettierignore new file mode 100644 index 00000000000..fea8f052ae5 --- /dev/null +++ b/apps/rsc-demo/.prettierignore @@ -0,0 +1,27 @@ +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build +/dist + +# misc +.DS_Store +.eslintcache +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +*.html +*.json +*.md diff --git a/apps/rsc-demo/.prettierrc.js b/apps/rsc-demo/.prettierrc.js new file mode 100644 index 00000000000..205c7ef5f08 --- /dev/null +++ b/apps/rsc-demo/.prettierrc.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use strict'; + +module.exports = { + arrowParens: 'always', + bracketSpacing: false, + singleQuote: true, + jsxBracketSameLine: true, + trailingComma: 'es5', + printWidth: 80, +}; diff --git a/apps/rsc-demo/AGENTS.md b/apps/rsc-demo/AGENTS.md new file mode 100644 index 00000000000..7121b4ae7aa --- /dev/null +++ b/apps/rsc-demo/AGENTS.md @@ -0,0 +1,43 @@ +# Repository Guidelines + +## Project Structure & Modules +- Monorepo managed by `pnpm`. Primary apps live in `packages/app1` and `packages/app2`; shared RSC tooling is in `packages/react-server-dom-webpack`. +- App source: `packages/*/src`. Servers: `packages/*/server`. Webpack configs and build scripts: `packages/*/scripts`. +- Tests: unit/integration in `packages/e2e/rsc`, Playwright E2E in `packages/e2e/e2e`. Build output lands in `packages/*/build` (gitignored). + +## Build, Test, Dev Commands +- `pnpm install` — install workspace deps. +- `pnpm start` — run app1 dev server with webpack watch (bundler + server). +- `pnpm --filter app2 start` — same for app2. +- `pnpm run build` — production builds for app1 and app2 (client + server layers). +- `pnpm test` — top-level test entry; runs RSC tests and MF tests after building. +- `pnpm run test:rsc` — RSC unit/integration tests (Node `--test`). +- `pnpm run test:e2e:rsc` — Playwright smoke for the RSC notes apps. +- `pnpm run test:e2e` — all Playwright suites (requires prior build). + +## Coding Style & Naming +- JavaScript/React with ES modules; prefer functional components. +- Indent with 2 spaces; keep files ASCII-only unless existing file uses Unicode. +- Client components carry the `'use client'` directive; server actions/components avoid it. Name server action files `*.server.js` when possible. +- Webpack chunk/module ids are kept readable (`chunkIds: 'named', moduleIds: 'named'`). + +## Testing Guidelines +- Frameworks: Node’s built-in `node --test`, Playwright for E2E. +- Place unit/integration specs under `packages/e2e/rsc`. Name with `.test.js`. +- E2E specs live in `packages/e2e/e2e`; keep them idempotent and avoid relying on pre-existing data. +- Run `pnpm run build` before E2E to ensure assets exist. + +## Commit & PR Expectations +- Use concise, descriptive commit messages (e.g., `fix: inline action manifest ids`). +- For PRs, include: summary of changes, testing performed (`pnpm test:rsc`, `pnpm test:e2e:rsc`), and any follow-up risks or TODOs. + +## Module Federation Configuration +- ALL Module Federation plugins MUST include `experiments: { asyncStartup: true }` in their configuration (both client and server). +- ALL shared modules MUST use `eager: false` - no exceptions. The federation runtime handles async loading. +- Server-side code using asyncStartup bundles must `await` the module loads since module init is async. +- Use separate share scopes for different layers: `'client'` for browser bundles, `'rsc'` for RSC server bundles. +- Shared modules must also specify `layer` and `issuerLayer` matching the webpack layer they belong to (e.g., `client`, `rsc`, `ssr`). + +## Security & Config Tips +- Do not check `packages/*/build` or credentials into git; `.gitignore` already covers build artifacts. +- If enabling Postgres locally, gate with `USE_POSTGRES` and ensure fallback to the mock DB for offline runs. diff --git a/apps/rsc-demo/CODE_OF_CONDUCT.md b/apps/rsc-demo/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..f049d4c5317 --- /dev/null +++ b/apps/rsc-demo/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces, and it also applies when +an individual is representing the project or its community in public spaces. +Examples of representing a project or community include using an official +project e-mail address, posting via an official social media account, or acting +as an appointed representative at an online or offline event. Representation of +a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at . All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/apps/rsc-demo/Dockerfile b/apps/rsc-demo/Dockerfile new file mode 100644 index 00000000000..c0d7899c53b --- /dev/null +++ b/apps/rsc-demo/Dockerfile @@ -0,0 +1,12 @@ +FROM node:lts-hydrogen + +WORKDIR /opt/notes-app + +COPY package.json package-lock.json ./ + +RUN npm install --legacy-peer-deps + +COPY . . + +ENTRYPOINT [ "npm", "run" ] +CMD [ "start" ] diff --git a/apps/rsc-demo/LICENSE b/apps/rsc-demo/LICENSE new file mode 100644 index 00000000000..b96dcb0480a --- /dev/null +++ b/apps/rsc-demo/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Facebook, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/rsc-demo/README.md b/apps/rsc-demo/README.md new file mode 100644 index 00000000000..475c0cadaaa --- /dev/null +++ b/apps/rsc-demo/README.md @@ -0,0 +1,163 @@ +# React Server Components Demo + +* [What is this?](#what-is-this) +* [When will I be able to use this?](#when-will-i-be-able-to-use-this) +* [Should I use this demo for benchmarks?](#should-i-use-this-demo-for-benchmarks) +* [Setup](#setup) +* [DB Setup](#db-setup) + + [Step 1. Create the Database](#step-1-create-the-database) + + [Step 2. Connect to the Database](#step-2-connect-to-the-database) + + [Step 3. Run the seed script](#step-3-run-the-seed-script) +* [Module Federation & RSC](#module-federation--rsc) +* [Notes about this app](#notes-about-this-app) + + [Interesting things to try](#interesting-things-to-try) +* [Built by (A-Z)](#built-by-a-z) +* [Code of Conduct](#code-of-conduct) +* [License](#license) + +## What is this? + +This is a demo app built with Server Components, an experimental React feature. **We strongly recommend [watching our talk introducing Server Components](https://reactjs.org/server-components) before exploring this demo.** The talk includes a walkthrough of the demo code and highlights key points of how Server Components work and what features they provide. + +**Update (March 2023):** This demo has been updated to match the [latest conventions](https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023#react-server-components). + +## When will I be able to use this? + +Server Components are an experimental feature and **are not ready for adoption**. For now, we recommend experimenting with Server Components via this demo app. **Use this in your projects at your own risk.** + +## Should I use this demo for benchmarks? + +If you use this demo to compare React Server Components to the framework of your choice, keep this in mind: + +* **This demo doesn’t have server rendering.** Server Components are a separate (but complementary) technology from Server Rendering (SSR). Server Components let you run some of your components purely on the server. SSR, on the other hand, lets you generate HTML before any JavaScript loads. This demo *only* shows Server Components, and not SSR. Because it doesn't have SSR, the initial page load in this demo has a client-server network waterfall, and **will be much slower than any SSR framework**. However, Server Components are meant to be integrated together with SSR, and they *will* be in a future release. +* **This demo doesn’t have an efficient bundling strategy.** When you use Server Components, a bundler plugin will automatically split the client JS bundle. However, the way it's currently being split is not necessarily optimal. We are investigating more efficient ways to split the bundles, but they are out of scope of this demo. +* **This demo doesn’t have partial refetching.** Currently, when you click on different “notes”, the entire app shell is refetched from the server. However, that’s not ideal: for example, it’s unnecessary to refetch the sidebar content if all that changed is the inner content of the right pane. Partial refetching is an [open area of research](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md#open-areas-of-research) and we don’t yet know how exactly it will work. + +This demo is provided “as is” to show the parts that are ready for experimentation. It is not intended to reflect the performance characteristics of a real app driven by a future stable release of Server Components. + +## Setup + +You will need to have [Node 18 LTS](https://nodejs.org/en) in order to run this demo. (If you use `nvm`, run `nvm i` before running `npm install` to install the recommended Node version.) + + ``` + npm install --legacy-peer-deps + npm start + ``` + +(Or `npm run start:prod` for a production build.) + +Then open http://localhost:4000. + +The app won't work until you set up the database, as described below. + +
+ Setup with Docker (optional) +

You can also start dev build of the app by using docker-compose.

+

⚠️ This is completely optional, and is only for people who prefer Docker to global installs!

+

If you prefer Docker, make sure you have docker and docker-compose installed then run:

+
docker-compose up
+

Running seed script

+

1. Run containers in the detached mode

+
docker-compose up -d
+

2. Run seed script

+
docker-compose exec notes-app npm run seed
+

If you'd rather not use Docker, skip this section and continue below.

+
+ +## DB Setup + +This demo uses Postgres. First, follow its [installation link](https://wiki.postgresql.org/wiki/Detailed_installation_guides) for your platform. + +Alternatively, you can check out this [fork](https://github.com/pomber/server-components-demo/) which will let you run the demo app without needing a database. However, you won't be able to execute SQL queries (but fetch should still work). There is also [another fork](https://github.com/prisma/server-components-demo) that uses Prisma with SQLite, so it doesn't require additional setup. + +The below example will set up the database for this app, assuming that you have a UNIX-like platform: + +### Step 1. Create the Database + +``` +psql postgres + +CREATE DATABASE notesapi; +CREATE ROLE notesadmin WITH LOGIN PASSWORD 'password'; +ALTER ROLE notesadmin WITH SUPERUSER; +ALTER DATABASE notesapi OWNER TO notesadmin; +\q +``` + +### Step 2. Connect to the Database + +``` +psql -d postgres -U notesadmin; + +\c notesapi + +DROP TABLE IF EXISTS notes; +CREATE TABLE notes ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + title TEXT, + body TEXT +); + +\q +``` + +### Step 3. Run the seed script + +Finally, run `npm run seed` to populate some data. + +And you're done! + +## Module Federation & RSC + +This fork additionally experiments with **React Server Components + Module Federation** across two apps (`packages/app1`, `packages/app2`): + +- Client‑side federation is handled with `@module-federation/enhanced` in the **client** layer. +- RSC/server federation is handled with a Node MF container in the **rsc** layer. +- Federated server actions currently run via **HTTP forwarding** from app1 → app2. +- The architecture is designed to also support **in‑process MF‑native actions** (no HTTP hop) as a next step. + +For a deep dive into the layering, manifests, and both federation strategies (HTTP proxy + MF‑native design), see `notes/RESEARCH.md`. + +## Notes about this app + +The demo is a note-taking app called **React Notes**. It consists of a few major parts: + +- It uses a Webpack plugin (not defined in this repo) that allows us to only include client components in build artifacts +- An Express server that: + - Serves API endpoints used in the app + - Renders Server Components into a special format that we can read on the client +- A React app containing Server and Client components used to build React Notes + +This demo is built on top of our Webpack plugin, but this is not how we envision using Server Components when they are stable. They are intended to be used in a framework that supports server rendering — for example, in Next.js. This is an early demo -- the real integration will be developed in the coming months. Learn more in the [announcement post](https://reactjs.org/server-components). + +### Interesting things to try + +- Expand note(s) by hovering over the note in the sidebar, and clicking the expand/collapse toggle. Next, create or delete a note. What happens to the expanded notes? +- Change a note's title while editing, and notice how editing an existing item animates in the sidebar. What happens if you edit a note in the middle of the list? +- Search for any title. With the search text still in the search input, create a new note with a title matching the search text. What happens? +- Search while on Slow 3G, observe the inline loading indicator. +- Switch between two notes back and forth. Observe we don't send new responses next time we switch them again. +- Uncomment the `await fetch('http://localhost:4000/sleep/....')` call in `Note.js` or `NoteList.js` to introduce an artificial delay and trigger Suspense. + - If you only uncomment it in `Note.js`, you'll see the fallback every time you open a note. + - If you only uncomment it in `NoteList.js`, you'll see the list fallback on first page load. + - If you uncomment it in both, it won't be very interesting because we have nothing new to show until they both respond. +- Add a new Server Component and place it above the search bar in `App.js`. Import `db` from `db.js` and use `await db.query()` from it to get the number of notes. Oberserve what happens when you add or delete a note. + +You can watch a [recorded walkthrough of all these demo points here](https://youtu.be/La4agIEgoNg?t=600) with timestamps. (**Note:** this recording is slightly outdated because the repository has been updated to match the [latest conventions](https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023#react-server-components).) + +## Built by (A-Z) + +- [Andrew Clark](https://twitter.com/acdlite) +- [Dan Abramov](https://twitter.com/dan_abramov) +- [Joe Savona](https://twitter.com/en_JS) +- [Lauren Tan](https://twitter.com/sugarpirate_) +- [Sebastian Markbåge](https://twitter.com/sebmarkbage) +- [Tate Strickland](http://www.tatestrickland.com/) (Design) + +## [Code of Conduct](https://engineering.fb.com/codeofconduct/) +Facebook has adopted a Code of Conduct that we expect project participants to adhere to. Please read the [full text](https://engineering.fb.com/codeofconduct/) so that you can understand what actions will and will not be tolerated. + +## License +This demo is MIT licensed. diff --git a/apps/rsc-demo/credentials.js b/apps/rsc-demo/credentials.js new file mode 100644 index 00000000000..edc6d3d66e7 --- /dev/null +++ b/apps/rsc-demo/credentials.js @@ -0,0 +1,7 @@ +module.exports = { + host: process.env.DB_HOST || 'localhost', + database: 'notesapi', + user: 'notesadmin', + password: 'password', + port: '5432', +}; diff --git a/apps/rsc-demo/docker-compose.yml b/apps/rsc-demo/docker-compose.yml new file mode 100644 index 00000000000..567f78d5e24 --- /dev/null +++ b/apps/rsc-demo/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' +services: + postgres: + image: postgres:13 + environment: + POSTGRES_USER: notesadmin + POSTGRES_PASSWORD: password + POSTGRES_DB: notesapi + ports: + - '5432:5432' + volumes: + - ./scripts/init_db.sh:/docker-entrypoint-initdb.d/init_db.sh + - db:/var/lib/postgresql/data + + notes-app: + build: + context: . + depends_on: + - postgres + ports: + - '4000:4000' + environment: + DB_HOST: postgres + PORT: 4000 + volumes: + - ./notes:/opt/notes-app/notes + - ./public:/opt/notes-app/public + - ./scripts:/opt/notes-app/scripts + - ./server:/opt/notes-app/server + - ./src:/opt/notes-app/src + - ./credentials.js:/opt/notes-app/credentials.js + +volumes: + db: diff --git a/apps/rsc-demo/notes/.gitkeep b/apps/rsc-demo/notes/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/rsc-demo/notes/RESEARCH.md b/apps/rsc-demo/notes/RESEARCH.md new file mode 100644 index 00000000000..cdb5707d9c7 --- /dev/null +++ b/apps/rsc-demo/notes/RESEARCH.md @@ -0,0 +1,842 @@ +# Repository Research: React Server Components Architecture + +This document explains how React Server Components (RSC) and Server Actions work in this repo, how webpack layering and the manifests fit together, and what actually runs on the server vs in the browser. + +**Key Architecture Decision**: This repo uses **webpack build-time conditions** (`resolve.conditionNames`) to resolve `react-server` exports. No `--conditions=react-server` Node.js flag is needed at runtime. The bundled server (`server.rsc.js`) contains all React server exports pre-resolved. + +--- + +## 1. Layout (RSC-Relevant Parts) + +- `packages/app1`, `packages/app2` + - `src/App.js` – root RSC tree (server components + client islands). + - `src/framework/router.js` – browser RSC router, `callServer`, and Flight client APIs. + - `src/InlineActionDemo.server.js` – example server component using inline `'use server'` actions. + - `src/InlineActionButton.js` – client component that calls server actions. + - `scripts/build.js` – layered webpack build (client + RSC bundles, manifests). + - `server/api.server.js` – Express server, RSC renderer and `/react` actions endpoint. +- `packages/react-server-dom-webpack` + - Vendored `react-server-dom-webpack` runtime with custom hooks/loaders: + - `cjs/react-server-dom-webpack-node-register.js` + - `cjs/rsc-client-loader.js` + - `cjs/rsc-server-loader.js` + - `cjs/rsc-ssr-loader.js` + - `server.node.js` / `server.node.unbundled.js` (dynamic manifest + action registry). +- `packages/e2e/rsc` – node test suite that exercises the built server and manifests. + +--- + +## 2. Build Pipeline & Webpack Layers + +Each app has a layered webpack build (`experiments.layers = true`) in `scripts/build.js`. + +### 2.1 Layers + +We use explicit layer tags (mirroring Next.js): + +- `rsc` – React Server Components / Server Actions layer. Webpack uses `resolve.conditionNames: ['react-server', ...]` to resolve React's server exports **at build time**. +- `ssr` – Server-Side Rendering layer for rendering RSC flight streams to HTML (no `react-server` condition). +- `client` – browser/client bundle (hydration, client components). + +**Important**: The `rsc` layer's `resolve.conditionNames` configuration means webpack resolves `react-server` exports during bundling. The output (`server.rsc.js`) contains pre-resolved server APIs, so **no `--conditions=react-server` flag is needed at runtime**. + +### 2.2 Client Bundle (browser) + +Client config (`webpackConfig`): + +- `entry.main` → `../src/framework/bootstrap.js` with `layer: 'client'`. +- `module.rules` for `*.js`: + - `issuerLayer: 'rsc'` → `babel-loader` + `rsc-server-loader` (for RSC imports pushed into the client build). + - `issuerLayer: 'ssr'` → `babel-loader` + `rsc-ssr-loader`. + - default (client) → `babel-loader` + `rsc-client-loader`. +- `ReactServerWebpackPlugin({ isServer: false })` produces: + - `build/react-client-manifest.json` + - Maps client modules (e.g. `'(client)/./src/InlineActionButton.js'`) to chunk ids (`client2`, `client2.js`). + - This manifest is passed to `renderToPipeableStream` so the server knows how to refer to client components in the Flight stream. + +### 2.3 RSC Build (server bundle) + +RSC config (`serverConfig`): + +- `target: 'node'`, library `commonjs2`. +- `entry.server` → `../src/server-entry.js` with `layer: 'rsc'`. +- Same `layers`/`oneOf` scheme, but biased towards `rsc` (server-only): + - For server we always apply `rsc-server-loader` so file‑level `'use server'` and inline actions get registered. +- `ReactServerWebpackPlugin({ isServer: true })` produces: + - `build/react-server-actions-manifest.json` + - Keys: action IDs like `file:///.../src/server-actions.js#incrementCount`. + - Values: `{ id: moduleUrl, name: exportName, chunks: [] }`. + - Sources: + - File‑level `'use server'` modules (AST scan in the plugin). + - `rsc-client-loader.serverReferencesMap` (for client-transformed actions). + - `rsc-server-loader.inlineServerActionsMap` (for inline actions inside components). + +> **Production Mode**: The server loads `build/server.rsc.js` directly—no `node-register` or `--conditions` flag needed. Webpack already resolved all `react-server` exports at build time via `resolve.conditionNames`. + +### 2.4 SSR Build (server-side rendering bundle, app1) + +For **app1**, SSR config (`ssrConfig`) lives in `packages/app1/scripts/build.js`: + +- `target: 'node'`, library `commonjs2`. +- `entry.ssr` → `../src/framework/ssr-entry.js` with `layer: 'ssr'`. +- Bundles all client components for Node.js execution (without `--conditions=react-server`). +- Output: `build/ssr.js` – contains compiled client components for SSR. + +**Why a separate SSR bundle (app1)?** + +In app1, the RSC bundle is built with `react-server` condition, which means `react-dom/server` APIs are not included in `server.rsc.js`. To render RSC flight streams to HTML, we need a separate SSR process that: +1. Can import `react-dom/server` (renderToPipeableStream) +2. Can import `react-server-dom-webpack/client.node` (createFromNodeStream) +3. Has access to all client components compiled for Node.js + +The SSR entry (`src/framework/ssr-entry.js`) exports all app1 client components with a `componentMap`: + +```js +export const componentMap = { + './src/DemoCounterButton.js': { default: DemoCounterButton }, + './src/EditButton.js': { default: EditButton }, + './src/NoteEditor.js': { default: NoteEditor }, + './src/SearchField.js': { default: SearchField }, + './src/SidebarNoteContent.js': { default: SidebarNoteContent }, + './src/framework/router.js': Router, +}; +``` + +--- + +## 3. Runtime: Bundled RSC Server + +The server entry (`server/api.server.js`) loads the **pre-built RSC bundle**: + +```js +function getRSCServer() { + if (!rscServer) { + const bundlePath = path.resolve(__dirname, '../build/server.rsc.js'); + rscServer = require(bundlePath); + } + return rscServer; +} +``` + +The bundle (`server.rsc.js`) was built by webpack with `resolve.conditionNames: ['react-server', ...]`, so it already contains the correct React server exports. **No `--conditions=react-server` flag or `node-register` hook is needed.** + +### 3.0 How Webpack Resolves `react-server` Exports + +When webpack builds the RSC layer, it uses: + +```js +resolve: { + conditionNames: ['react-server', 'import', 'require', 'node'], +} +``` + +This tells webpack to check `package.json` exports for `react-server` condition first. For example, React's `package.json` has: + +```json +{ + "exports": { + ".": { + "react-server": "./react.react-server.js", + "default": "./index.js" + } + } +} +``` + +Webpack resolves to `react.react-server.js` at **build time** and bundles those exports into `server.rsc.js`. At runtime, Node.js just loads the pre-resolved bundle. + +### 3.1 `'use client'` Handling + +`rsc-server-loader` transforms `'use client'` modules into `createClientModuleProxy(moduleUrl)` calls. When a Server Component imports a client file, the server never sees actual React code, only a serializable proxy reference. + +### 3.2 File-level `'use server'` modules (server actions) + +`rsc-server-loader` processes modules that start with `'use server'`: + +1. Adds `registerServerReference(fn, moduleUrl, exportName)` calls for each export. +2. At runtime, `server.node.js` stores entries into: + - `serverActionRegistry` – `Map` for lookup by action ID. + - `dynamicServerActionsManifest` – merged into the static manifest at request time. + +Action IDs: + +- Default exports: `file:///path/to/module.js#default`. +- Named exports: `file:///path/to/module.js#exportName`. + +### 3.3 Inline `'use server'` functions + +Inside server components like `InlineActionDemo.server.js`, actions can be defined inline: + +```js +async function addMessage(formData) { + 'use server'; + // update server-only state +} +``` + +`rsc-server-loader` detects inline server actions via AST analysis and injects registration calls: + +```js +;(function(){ + if (typeof addMessage === 'function') { + require('react-server-dom-webpack/server') + .registerServerReference(addMessage, moduleUrl, 'addMessage'); + } +})(); +``` + +The loader populates `inlineServerActionsMap` for manifest generation. + +--- + +## 4. Manifests & How They’re Used + +### 4.1 Client Manifest – `react-client-manifest.json` + +Generated by `ReactServerWebpackPlugin({ isServer: false })` in the **client** build. + +- Keys: internal client module IDs like `"(client)/./src/InlineActionButton.js"`. +- Values: `{ id: 'client2', name: 'default', chunks: ['client2.js'] }` etc. +- `server/api.server.js` loads this file in `renderReactTree` and passes it to the bundled RSC server: + ```js + async function renderReactTree(res, props) { + await waitForWebpack(); + const manifest = readFileSync( + path.resolve(__dirname, '../build/react-client-manifest.json'), + 'utf8' + ); + const moduleMap = JSON.parse(manifest); + + const server = getRSCServer(); + const { pipe } = server.renderApp(props, moduleMap); + pipe(res); + } + ``` +- Inside the bundled RSC server, `renderApp` calls `renderToPipeableStream` with this manifest so the Flight payload can reference the right client chunks. + +### 4.2 Server Actions Manifest – `react-server-actions-manifest.json` + +Generated by `ReactServerWebpackPlugin({ isServer: true })` in the **RSC/server** build. + +- Keys: action IDs, always including `#name` (default uses `#default`). +- Values: `{ id: moduleUrl, name: exportName, chunks: [] }`. +- At request time the Express `/react` POST handler does: + ```js + const manifestPath = path.resolve(__dirname, '../build/react-server-actions-manifest.json'); + let serverActionsManifest = {}; + if (existsSync(manifestPath)) { + serverActionsManifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + } + + const server = getRSCServer(); + const dynamicManifest = server.getDynamicServerActionsManifest() || {}; + serverActionsManifest = Object.assign({}, serverActionsManifest, dynamicManifest); + + const actionFn = server.getServerAction(actionId); + ``` +- Both file-level `'use server'` exports and inline actions are ultimately resolved via `server.getServerAction(actionId)` from the bundled RSC server. The manifest is primarily used by React’s `decodeReply`/`decodeReplyFromBusboy` to understand how to deserialize arguments. + +### 4.3 Dynamic Manifest + +`server.node.js` maintains `dynamicServerActionsManifest` so that inline actions registered at runtime (e.g. inside component functions) still have manifest entries. This is merged into the static manifest for `decodeReply`/`decodeReplyFromBusboy` so React’s decoder knows the action shapes. + +--- + +## 5. Server Actions Request Flow + +Client code (`src/framework/router.js`) exposes: + +```js +export async function callServer(actionId, args) { + const body = await encodeReply(args); + const response = await fetch('/react', { + method: 'POST', + headers: { 'Accept': 'text/x-component', [RSC_ACTION_HEADER]: actionId }, + body, + }); + const resultHeader = response.headers.get('X-Action-Result'); + return resultHeader ? JSON.parse(resultHeader) : undefined; +} +``` + +`rsc-client-loader` transforms `'use server'` modules so each exported action is a `createServerReference(actionId, callServer)` stub; calling it from a client component ultimately invokes `callServer`. + +On the server (`server/api.server.js`): + +1. `app.post('/react', handler)` reads the `rsc-action` header into `actionId`. +2. It waits for the webpack build output and loads the bundled RSC server via `getRSCServer()`. +3. It loads the static server actions manifest from disk and merges it with the dynamic manifest from `server.getDynamicServerActionsManifest()`. +4. It looks up the function with `const actionFn = server.getServerAction(actionId)`; if this returns something other than a function, the handler responds with 404. +5. It decodes args using React’s Flight reply helpers on the bundled server: + - If `Content-Type` is `multipart/form-data`, it pipes the request into Busboy and calls `server.decodeReplyFromBusboy(busboy, serverActionsManifest)`. + - Otherwise, it reads the body as text and calls `server.decodeReply(body, serverActionsManifest)`. +6. It calls the action and sets the `X-Action-Result` header if a non-`undefined` result is returned: + ```js + const result = await actionFn(...argsArray); + if (result !== undefined) { + res.set('X-Action-Result', JSON.stringify(result)); + } + ``` +7. Finally, it re-renders the RSC tree via `renderReactTree` and streams a Flight payload back to the client. + +On the client, the router receives the new Flight response, parses it with `createFromReadableStream`, and swaps the view. + +--- + +## 6. Flight Payload & Hydration + +The **Flight protocol** is a serialized stream of React elements and module references: + +- The bundled RSC server’s `renderApp(props, moduleMap)` calls `renderToPipeableStream(React.createElement(ReactApp, props), moduleMap)` to produce chunks. +- The payload contains: + - Module references: `I["(client)/./src/InlineActionButton.js",["client2","client2.js"],""]`. + - Serialized props and JSX for server components. + - Server action references: objects like `{ "id": "file:///...InlineActionDemo.server.js#addMessage", "bound": null }`. + +Client side (`router.js`): + +- Initial load: `createFromFetch(fetch('/react?location=...'))` returns a then-usable value for `use(...)`. +- After actions or mutations: `createFromReadableStream(response.body)` parses the new Flight stream and refreshes the view. + +This architecture keeps server-only logic and data access on the server (RSC/RSA), while only sending serialized UI + client islands to the browser. + +--- + +## 7. Server-Side Rendering (SSR) Implementation + +### 7.1 The SSR Challenge (app1 + app2) + +In both **app1** and **app2**, the RSC bundle is built with `react-server` condition, which uses React's server-only exports (no `react-dom/server`). To render HTML from RSC flight streams, both apps use a **subprocess architecture** with a separate SSR bundle. + +### 7.2 SSR Worker (`packages/app1/server/ssr-worker.js`) + +The SSR worker is a separate Node.js process that: +- Loads the SSR bundle (`build/ssr.js`) built without `react-server` condition +- Receives RSC flight data on stdin +- Outputs rendered HTML on stdout + +**Key components:** + +```js +const { renderToPipeableStream } = require('react-dom/server'); +const { createFromNodeStream } = require('react-server-dom-webpack/client.node'); +const ssrBundle = require('../build/ssr.js'); +``` + +**SSR Manifest Format (following Next.js serverConsumerManifest):** + +```js +function buildSSRManifest() { + const moduleMap = {}; + for (const [fileUri, manifestEntry] of Object.entries(clientManifest)) { + const moduleId = manifestEntry.id; + moduleMap[moduleId] = { + 'default': { id: moduleId, name: 'default', chunks: [] }, + '*': { id: moduleId, name: '*', chunks: [] }, + '': { id: moduleId, name: '', chunks: [] }, + }; + } + return { + moduleLoading: { prefix: '', crossOrigin: null }, + moduleMap, + serverModuleMap: null, + }; +} +``` + +The manifest maps module IDs to SSR resolution info. Each entry has three export name variants: +- `'default'` - for default exports (most React components) +- `'*'` - for namespace imports +- `''` - alternative way to reference default + +**Module Loader Setup:** + +```js +globalThis.__webpack_require__ = function(moduleId) { + // Extract: "(client)/./src/Foo.js" -> "./src/Foo.js" + const match = moduleId.match(/\(client\)\/(.+)/); + const relativePath = match ? match[1] : moduleId; + + // Look up in componentMap from ssr-entry.js + if (componentMap && componentMap[relativePath]) { + return componentMap[relativePath]; + } + return { default: function Placeholder() { return null; } }; +}; +``` + +### 7.3 SSR Request Flow + +When a browser requests `/` (HTML page): + +1. **Main server** (`api.server.js`) renders RSC to a flight buffer using the bundled RSC server: + ```js + const rscBuffer = await renderRSCToBuffer(props); + ``` + +2. **Spawns SSR worker** as a subprocess and pipes the buffer to stdin: + ```js + const ssrWorker = spawn('node', [workerPath], { stdio: ['pipe', 'pipe', 'pipe'] }); + ssrWorker.stdin.write(rscBuffer); + ssrWorker.stdin.end(); + ``` + +3. **Worker parses** the flight stream into a React tree: + ```js + const tree = await createFromNodeStream(flightStream, ssrManifest); + ``` + +4. **Worker renders** to HTML with `react-dom/server`: + ```js + const { pipe } = renderToPipeableStream(tree, { onShellReady() { pipe(process.stdout); } }); + ``` + +5. **Main server** collects HTML, reads `index.html`, and injects: + - The SSR HTML into `
` + - A ` +``` + +On the client (`bootstrap.js`): + +```js +const rscDataEl = document.getElementById('__RSC_DATA__'); +if (rscDataEl) { + const rscData = JSON.parse(rscDataEl.textContent); + initFromSSR(rscData); // Populate router cache from embedded data +} +hydrateRoot(document.getElementById('root'), ); +``` + +`initFromSSR` (`router.js`) converts the embedded data to a readable stream and caches it: + +```js +export function initFromSSR(rscData) { + const initialLocation = { + selectedId: null, + isEditing: false, + searchText: '', + }; + const locationKey = JSON.stringify(initialLocation); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(rscData)); + controller.close(); + } + }); + const content = createFromReadableStream(stream); + initialCache.set(locationKey, content); +} +``` + +This ensures hydration uses the same React tree that was server-rendered, avoiding mismatches. + +### 7.5 SSR-Safe Client Components + +Client components that use hooks like `useRouter()` must handle SSR gracefully. Since the `Router` component doesn't render during SSR (the worker renders the tree directly), the context is undefined: + +```js +export function useRouter() { + const context = useContext(RouterContext); + // During SSR, return stub functions to prevent errors + if (!context) { + return { + location: { selectedId: null, isEditing: false, searchText: '' }, + navigate: () => {}, + refresh: () => {}, + }; + } + return context; +} +``` + +The stub values are safe because: +- Server-rendered HTML is static (navigation doesn't happen) +- After hydration, the real `Router` component provides the actual context +- This is the standard pattern for SSR-safe hooks + +--- + +## 8. Complete Request Flow Diagram + +### 8.1 Initial Page Load (with SSR) + +``` +Browser Server (bundled RSC) SSR Worker + | | | + |--- GET / -------------------------->| | + | | | + | renderRSCToBuffer(props) | + | -> bundled server.renderApp(props, manifest) | + | -> produces RSC flight buffer | + | | | + | spawn('node', ['ssr-worker.js']) | + | |--- flight data (stdin) -------->| + | | | + | | createFromNodeStream(stream) | + | | -> React tree | + | | | + | | renderToPipeableStream(tree) | + | | -> HTML output | + | | | + | |<-- HTML (stdout) ----------------| + | | | + | Build full HTML page: | + | - SSR HTML content | + | - + + diff --git a/apps/rsc-demo/packages/app1/public/logo.svg b/apps/rsc-demo/packages/app1/public/logo.svg new file mode 100644 index 00000000000..ea77a618d94 --- /dev/null +++ b/apps/rsc-demo/packages/app1/public/logo.svg @@ -0,0 +1,9 @@ + + React Logo + + + + + + + diff --git a/apps/rsc-demo/packages/app1/public/style.css b/apps/rsc-demo/packages/app1/public/style.css new file mode 100644 index 00000000000..7742845ebf1 --- /dev/null +++ b/apps/rsc-demo/packages/app1/public/style.css @@ -0,0 +1,700 @@ +/* -------------------------------- CSSRESET --------------------------------*/ +/* CSS Reset adapted from https://dev.to/hankchizljaw/a-modern-css-reset-6p3 */ +/* Box sizing rules */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Remove default padding */ +ul[class], +ol[class] { + padding: 0; +} + +/* Remove default margin */ +body, +h1, +h2, +h3, +h4, +p, +ul[class], +ol[class], +li, +figure, +figcaption, +blockquote, +dl, +dd { + margin: 0; +} + +/* Set core body defaults */ +body { + min-height: 100vh; + scroll-behavior: smooth; + text-rendering: optimizeSpeed; + line-height: 1.5; +} + +/* Remove list styles on ul, ol elements with a class attribute */ +ul[class], +ol[class] { + list-style: none; +} + +/* A elements that don't have a class get default styles */ +a:not([class]) { + text-decoration-skip-ink: auto; +} + +/* Make images easier to work with */ +img { + max-width: 100%; + display: block; +} + +/* Natural flow and rhythm in articles by default */ +article > * + * { + margin-block-start: 1em; +} + +/* Inherit fonts for inputs and buttons */ +input, +button, +textarea, +select { + font: inherit; +} + +/* Remove all animations and transitions for people that prefer not to see them */ +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} +/* -------------------------------- /CSSRESET --------------------------------*/ + +:root { + /* Colors */ + --main-border-color: #ddd; + --primary-border: #037dba; + --gray-20: #404346; + --gray-60: #8a8d91; + --gray-70: #bcc0c4; + --gray-80: #c9ccd1; + --gray-90: #e4e6eb; + --gray-95: #f0f2f5; + --gray-100: #f5f7fa; + --primary-blue: #037dba; + --secondary-blue: #0396df; + --tertiary-blue: #c6efff; + --flash-blue: #4cf7ff; + --outline-blue: rgba(4, 164, 244, 0.6); + --navy-blue: #035e8c; + --red-25: #bd0d2a; + --secondary-text: #65676b; + --white: #fff; + --yellow: #fffae1; + + --outline-box-shadow: 0 0 0 2px var(--outline-blue); + --outline-box-shadow-contrast: 0 0 0 2px var(--navy-blue); + + /* Fonts */ + --sans-serif: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, + Ubuntu, Helvetica, sans-serif; + --monospace: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, + monospace; +} + +html { + font-size: 100%; +} + +body { + font-family: var(--sans-serif); + background: var(--gray-100); + font-weight: 400; + line-height: 1.75; +} + +h1, +h2, +h3, +h4, +h5 { + margin: 0; + font-weight: 700; + line-height: 1.3; +} + +h1 { + font-size: 3.052rem; +} +h2 { + font-size: 2.441rem; +} +h3 { + font-size: 1.953rem; +} +h4 { + font-size: 1.563rem; +} +h5 { + font-size: 1.25rem; +} +small, +.text_small { + font-size: 0.8rem; +} +pre, +code { + font-family: var(--monospace); + border-radius: 6px; +} +pre { + background: var(--gray-95); + padding: 12px; + line-height: 1.5; +} +code { + background: var(--yellow); + padding: 0 3px; + font-size: 0.94rem; + word-break: break-word; +} +pre code { + background: none; +} +a { + color: var(--primary-blue); +} + +.text-with-markdown h1, +.text-with-markdown h2, +.text-with-markdown h3, +.text-with-markdown h4, +.text-with-markdown h5 { + margin-block: 2rem 0.7rem; + margin-inline: 0; +} + +.text-with-markdown blockquote { + font-style: italic; + color: var(--gray-20); + border-left: 3px solid var(--gray-80); + padding-left: 10px; +} + +hr { + border: 0; + height: 0; + border-top: 1px solid rgba(0, 0, 0, 0.1); + border-bottom: 1px solid rgba(255, 255, 255, 0.3); +} + +/* ---------------------------------------------------------------------------*/ +.main { + display: flex; + height: 100vh; + width: 100%; + overflow: hidden; +} + +.col { + height: 100%; +} +.col:last-child { + flex-grow: 1; +} + +.logo { + height: 20px; + width: 22px; + margin-inline-end: 10px; +} + +.edit-button { + border-radius: 100px; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 6px 20px 8px; + cursor: pointer; + font-weight: 700; + outline-style: none; +} +.edit-button--solid { + background: var(--primary-blue); + color: var(--white); + border: none; + margin-inline-start: 6px; + transition: all 0.2s ease-in-out; +} +.edit-button--solid:hover { + background: var(--secondary-blue); +} +.edit-button--solid:focus { + box-shadow: var(--outline-box-shadow-contrast); +} +.edit-button--outline { + background: var(--white); + color: var(--primary-blue); + border: 1px solid var(--primary-blue); + margin-inline-start: 12px; + transition: all 0.1s ease-in-out; +} +.edit-button--outline:disabled { + opacity: 0.5; +} +.edit-button--outline:hover:not([disabled]) { + background: var(--primary-blue); + color: var(--white); +} +.edit-button--outline:focus { + box-shadow: var(--outline-box-shadow); +} + +ul.notes-list { + padding: 16px 0; +} +.notes-list > li { + padding: 0 16px; +} +.notes-empty { + padding: 16px; +} + +.sidebar { + background: var(--white); + box-shadow: + 0px 8px 24px rgba(0, 0, 0, 0.1), + 0px 2px 2px rgba(0, 0, 0, 0.1); + overflow-y: scroll; + z-index: 1000; + flex-shrink: 0; + max-width: 350px; + min-width: 250px; + width: 30%; +} +.sidebar-header { + letter-spacing: 0.15em; + text-transform: uppercase; + padding: 36px 16px 16px; + display: flex; + align-items: center; +} +.sidebar-menu { + padding: 0 16px 16px; + display: flex; + justify-content: space-between; +} +.sidebar-menu > .search { + position: relative; + flex-grow: 1; +} +.sidebar-note-list-item { + position: relative; + margin-bottom: 12px; + padding: 16px; + width: 100%; + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + max-height: 100px; + transition: max-height 250ms ease-out; + transform: scale(1); +} +.sidebar-note-list-item.note-expanded { + max-height: 300px; + transition: max-height 0.5s ease; +} +.sidebar-note-list-item.flash { + animation-name: flash; + animation-duration: 0.6s; +} + +.sidebar-note-open { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + z-index: 0; + border: none; + border-radius: 6px; + text-align: start; + background: var(--gray-95); + cursor: pointer; + outline-style: none; + color: transparent; + font-size: 0px; +} +.sidebar-note-open:focus { + box-shadow: var(--outline-box-shadow); +} +.sidebar-note-open:hover { + background: var(--gray-90); +} +.sidebar-note-header { + z-index: 1; + max-width: 85%; + pointer-events: none; +} +.sidebar-note-header > strong { + display: block; + font-size: 1.25rem; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sidebar-note-toggle-expand { + z-index: 2; + border-radius: 50%; + height: 24px; + border: 1px solid var(--gray-60); + cursor: pointer; + flex-shrink: 0; + visibility: hidden; + opacity: 0; + cursor: default; + transition: + visibility 0s linear 20ms, + opacity 300ms; + outline-style: none; +} +.sidebar-note-toggle-expand:focus { + box-shadow: var(--outline-box-shadow); +} +.sidebar-note-open:hover + .sidebar-note-toggle-expand, +.sidebar-note-open:focus + .sidebar-note-toggle-expand, +.sidebar-note-toggle-expand:hover, +.sidebar-note-toggle-expand:focus { + visibility: visible; + opacity: 1; + transition: + visibility 0s linear 0s, + opacity 300ms; +} +.sidebar-note-toggle-expand img { + width: 10px; + height: 10px; +} + +.sidebar-note-excerpt { + pointer-events: none; + z-index: 2; + flex: 1 1 250px; + color: var(--secondary-text); + position: relative; + animation: slideIn 100ms; +} + +.search input { + padding: 0 16px; + border-radius: 100px; + border: 1px solid var(--gray-90); + width: 100%; + height: 100%; + outline-style: none; +} +.search input:focus { + box-shadow: var(--outline-box-shadow); +} +.search .spinner { + position: absolute; + right: 10px; + top: 10px; +} + +.note-viewer { + display: flex; + align-items: center; + justify-content: center; +} +.note { + background: var(--white); + box-shadow: + 0px 0px 5px rgba(0, 0, 0, 0.1), + 0px 0px 1px rgba(0, 0, 0, 0.1); + border-radius: 8px; + height: 95%; + width: 95%; + min-width: 400px; + padding: 8%; + overflow-y: auto; +} +.note--empty-state { + margin-inline: 20px 20px; +} +.note-text--empty-state { + font-size: 1.5rem; +} +.note-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap-reverse; + margin-inline-start: -12px; +} +.note-menu { + display: flex; + justify-content: space-between; + align-items: center; + flex-grow: 1; +} +.note-title { + line-height: 1.3; + flex-grow: 1; + overflow-wrap: break-word; + margin-inline-start: 12px; +} +.note-updated-at { + color: var(--secondary-text); + white-space: nowrap; + margin-inline-start: 12px; +} +.note-preview { + margin-block-start: 50px; +} + +.note-editor { + background: var(--white); + display: flex; + height: 100%; + width: 100%; + padding: 58px; + overflow-y: auto; +} +.note-editor .label { + margin-bottom: 20px; +} +.note-editor-form { + display: flex; + flex-direction: column; + width: 400px; + flex-shrink: 0; + position: sticky; + top: 0; +} +.note-editor-form input, +.note-editor-form textarea { + background: none; + border: 1px solid var(--gray-70); + border-radius: 2px; + font-family: var(--monospace); + font-size: 0.8rem; + padding: 12px; + outline-style: none; +} +.note-editor-form input:focus, +.note-editor-form textarea:focus { + box-shadow: var(--outline-box-shadow); +} +.note-editor-form input { + height: 44px; + margin-bottom: 16px; +} +.note-editor-form textarea { + height: 100%; + max-width: 400px; +} +.note-editor-menu { + display: flex; + justify-content: flex-end; + align-items: center; + margin-bottom: 12px; +} +.note-editor-preview { + margin-inline-start: 40px; + width: 100%; +} +.note-editor-done, +.note-editor-delete { + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 100px; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 6px 20px 8px; + cursor: pointer; + font-weight: 700; + margin-inline-start: 12px; + outline-style: none; + transition: all 0.2s ease-in-out; +} +.note-editor-done:disabled, +.note-editor-delete:disabled { + opacity: 0.5; +} +.note-editor-done { + border: none; + background: var(--primary-blue); + color: var(--white); +} +.note-editor-done:focus { + box-shadow: var(--outline-box-shadow-contrast); +} +.note-editor-done:hover:not([disabled]) { + background: var(--secondary-blue); +} +.note-editor-delete { + border: 1px solid var(--red-25); + background: var(--white); + color: var(--red-25); +} +.note-editor-delete:focus { + box-shadow: var(--outline-box-shadow); +} +.note-editor-delete:hover:not([disabled]) { + background: var(--red-25); + color: var(--white); +} +/* Hack to color our svg */ +.note-editor-delete:hover:not([disabled]) img { + filter: grayscale(1) invert(1) brightness(2); +} +.note-editor-done > img { + width: 14px; +} +.note-editor-delete > img { + width: 10px; +} +.note-editor-done > img, +.note-editor-delete > img { + margin-inline-end: 12px; +} +.note-editor-done[disabled], +.note-editor-delete[disabled] { + opacity: 0.5; +} + +.label { + display: inline-block; + border-radius: 100px; + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; + padding: 4px 14px; +} +.label--preview { + background: rgba(38, 183, 255, 0.15); + color: var(--primary-blue); +} + +.text-with-markdown p { + margin-bottom: 16px; +} +.text-with-markdown img { + width: 100%; +} + +/* https://codepen.io/mandelid/pen/vwKoe */ +.spinner { + display: inline-block; + transition: opacity linear 0.1s 0.2s; + width: 20px; + height: 20px; + border: 3px solid rgba(80, 80, 80, 0.5); + border-radius: 50%; + border-top-color: #fff; + animation: spin 1s ease-in-out infinite; + opacity: 0; +} +.spinner--active { + opacity: 1; +} + +.skeleton::after { + content: 'Loading...'; +} +.skeleton { + height: 100%; + background-color: #eee; + background-image: linear-gradient(90deg, #eee, #f5f5f5, #eee); + background-size: 200px 100%; + background-repeat: no-repeat; + border-radius: 4px; + display: block; + line-height: 1; + width: 100%; + animation: shimmer 1.2s ease-in-out infinite; + color: transparent; +} +.skeleton:first-of-type { + margin: 0; +} +.skeleton--button { + border-radius: 100px; + padding: 6px 20px 8px; + width: auto; +} +.v-stack + .v-stack { + margin-block-start: 0.8em; +} + +.offscreen { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + width: 1px; + position: absolute; +} + +/* ---------------------------------------------------------------------------*/ +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes shimmer { + 0% { + background-position: -200px 0; + } + 100% { + background-position: calc(200px + 100%) 0; + } +} + +@keyframes slideIn { + 0% { + top: -10px; + opacity: 0; + } + 100% { + top: 0; + opacity: 1; + } +} + +@keyframes flash { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.05); + opacity: 0.9; + } + 100% { + transform: scale(1); + opacity: 1; + } +} diff --git a/apps/rsc-demo/packages/app1/scripts/build.js b/apps/rsc-demo/packages/app1/scripts/build.js new file mode 100644 index 00000000000..c96bf1907f8 --- /dev/null +++ b/apps/rsc-demo/packages/app1/scripts/build.js @@ -0,0 +1,629 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use strict'; + +const path = require('path'); +const rimraf = require('rimraf'); +const webpack = require('webpack'); +const fs = require('fs'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const ReactServerWebpackPlugin = require('react-server-dom-webpack/plugin'); +const {ModuleFederationPlugin} = require('@module-federation/enhanced/webpack'); +const { + WEBPACK_LAYERS, + babelLoader, +} = require('../../app-shared/scripts/webpackShared'); +// React 19 exports don't expose these subpaths via "exports", so resolve by file path +const reactPkgRoot = path.dirname(require.resolve('react/package.json')); +const reactServerEntry = path.join(reactPkgRoot, 'react.react-server.js'); +const reactJSXServerEntry = path.join( + reactPkgRoot, + 'jsx-runtime.react-server.js' +); +const reactJSXDevServerEntry = path.join( + reactPkgRoot, + 'jsx-dev-runtime.react-server.js' +); +const rsdwServerPath = path.resolve( + require.resolve('react-server-dom-webpack/package.json'), + '..', + 'server.node.js' +); +const rsdwServerUnbundledPath = require.resolve( + 'react-server-dom-webpack/server.node.unbundled' +); + +const isProduction = process.env.NODE_ENV === 'production'; +const isWatchMode = process.argv.includes('--watch'); +rimraf.sync(path.resolve(__dirname, '../build')); + +/** + * Client bundle configuration + * + * Uses webpack layers for proper code separation: + * - 'use server' modules → createServerReference() calls (tree-shaken) + * - 'use client' modules → actual component code (bundled) + * - Server components → excluded from client bundle + */ +const webpackConfig = { + mode: isProduction ? 'production' : 'development', + devtool: isProduction ? 'source-map' : 'cheap-module-source-map', + entry: { + main: { + import: path.resolve(__dirname, '../src/framework/bootstrap.js'), + layer: WEBPACK_LAYERS.client, // Entry point is in client layer + }, + }, + output: { + path: path.resolve(__dirname, '../build'), + filename: '[name].js', + publicPath: 'http://localhost:4101/', + }, + optimization: { + minimize: false, + chunkIds: 'named', + moduleIds: 'named', + }, + // Enable webpack layers (stable feature) + experiments: { + layers: true, + }, + module: { + rules: [ + { + test: /\.js$/, + // Exclude node_modules EXCEPT our workspace packages + exclude: (modulePath) => { + // Include shared-components (workspace package) + if ( + modulePath.includes('shared-components') || + modulePath.includes('shared-rsc') + ) + return false; + // Exclude other node_modules + return /node_modules/.test(modulePath); + }, + // Use oneOf for layer-based loader selection + oneOf: [ + // RSC layer: Server Components + // Transforms 'use client' → client reference proxies + // Transforms 'use server' → registerServerReference + { + issuerLayer: WEBPACK_LAYERS.rsc, + layer: WEBPACK_LAYERS.rsc, + use: [ + babelLoader, + { + loader: require.resolve( + 'react-server-dom-webpack/rsc-server-loader' + ), + }, + ], + }, + // SSR layer: Server-Side Rendering + // Transforms 'use server' → error stubs (can't call actions during SSR) + // Passes through 'use client' (renders actual components) + { + issuerLayer: WEBPACK_LAYERS.ssr, + layer: WEBPACK_LAYERS.ssr, + use: [ + babelLoader, + { + loader: require.resolve( + 'react-server-dom-webpack/rsc-ssr-loader' + ), + }, + ], + }, + // Client/Browser layer (default) + // Transforms 'use server' → createServerReference() stubs + // Passes through 'use client' (actual component code) + { + layer: WEBPACK_LAYERS.client, + use: [ + babelLoader, + { + loader: require.resolve( + 'react-server-dom-webpack/rsc-client-loader' + ), + }, + ], + }, + ], + }, + // CSS handling (if needed) + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + }, + ], + }, + plugins: [ + new HtmlWebpackPlugin({ + inject: true, + template: path.resolve(__dirname, '../public/index.html'), + }), + // Generate client manifest for 'use client' components + new ReactServerWebpackPlugin({isServer: false}), + // Enable Module Federation for the client bundle (app1 as a host). + // This runs in the client layer, so we use a dedicated 'client' shareScope + // and mark shares as client-layer React/DOM. + new ModuleFederationPlugin({ + name: 'app1', + filename: 'remoteEntry.client.js', + // Consume app2's federated modules (Button, DemoCounterButton) + remotes: { + app2: 'app2@http://localhost:4102/remoteEntry.client.js', + }, + experiments: { + asyncStartup: true, + }, + shared: { + react: { + singleton: true, + eager: false, + requiredVersion: false, + shareScope: 'client', + layer: WEBPACK_LAYERS.client, + issuerLayer: WEBPACK_LAYERS.client, + }, + 'react-dom': { + singleton: true, + eager: false, + requiredVersion: false, + shareScope: 'client', + layer: WEBPACK_LAYERS.client, + issuerLayer: WEBPACK_LAYERS.client, + }, + '@rsc-demo/shared-rsc': { + singleton: true, + eager: false, + requiredVersion: false, + shareScope: 'client', + layer: WEBPACK_LAYERS.client, + issuerLayer: WEBPACK_LAYERS.client, + }, + 'shared-components': { + singleton: true, + eager: false, + requiredVersion: false, + shareScope: 'client', + layer: WEBPACK_LAYERS.client, + issuerLayer: WEBPACK_LAYERS.client, + }, + }, + // Initialize default + client scopes; this share lives in 'client'. + shareScope: ['default', 'client'], + shareStrategy: 'version-first', + }), + ], + resolve: { + // Condition names for proper module resolution per layer + // Client bundle uses browser conditions + conditionNames: ['browser', 'import', 'require', 'default'], + }, +}; + +/** + * Server bundle configuration (for RSC rendering) + * + * This builds the RSC server entry with resolve.conditionNames: ['react-server', ...] + * which means React packages resolve to their server versions at BUILD time. + * No --conditions=react-server flag needed at runtime! + */ +const serverConfig = { + mode: isProduction ? 'production' : 'development', + devtool: isProduction ? 'source-map' : 'cheap-module-source-map', + target: 'async-node', + entry: { + server: { + // Bundle server-entry.js which exports ReactApp and rendering utilities + import: path.resolve(__dirname, '../src/server-entry.js'), + layer: WEBPACK_LAYERS.rsc, // Entry point is in RSC layer + }, + }, + output: { + path: path.resolve(__dirname, '../build'), + filename: '[name].rsc.js', + libraryTarget: 'commonjs2', + // Allow Node federation runtime to fetch chunks over HTTP (needed for remote entry) + publicPath: 'http://localhost:4101/', + }, + optimization: { + minimize: false, + chunkIds: 'named', + moduleIds: 'named', + }, + experiments: { + layers: true, + }, + module: { + rules: [ + // Allow imports without .js extension in ESM modules (only for workspace packages) + { + test: /\.m?js$/, + include: (modulePath) => { + return ( + modulePath.includes('shared-components') || + modulePath.includes('shared-rsc') + ); + }, + resolve: {fullySpecified: false}, + }, + { + test: /\.js$/, + // Exclude node_modules EXCEPT our workspace packages + exclude: (modulePath) => { + if ( + modulePath.includes('shared-components') || + modulePath.includes('shared-rsc') + ) + return false; + return /node_modules/.test(modulePath); + }, + oneOf: [ + // RSC layer for server bundle + { + issuerLayer: WEBPACK_LAYERS.rsc, + layer: WEBPACK_LAYERS.rsc, + use: [ + babelLoader, + { + loader: require.resolve( + 'react-server-dom-webpack/rsc-server-loader' + ), + }, + ], + }, + // Default to RSC layer for server bundle + { + layer: WEBPACK_LAYERS.rsc, + use: [ + babelLoader, + { + loader: require.resolve( + 'react-server-dom-webpack/rsc-server-loader' + ), + }, + ], + }, + ], + }, + ], + }, + plugins: [ + // Generate server actions manifest for 'use server' modules from the server bundle + new ReactServerWebpackPlugin({ + isServer: true, + extraServerActionsManifests: [ + path.resolve( + __dirname, + '../app2/build/react-server-actions-manifest.json' + ), + ], + }), + // Enable Module Federation for the RSC server bundle (app1 as a Node host). + // This is the RSC layer, so we use a dedicated 'rsc' shareScope and + // mark React/RSDW as rsc-layer shares. + // + // SERVER-SIDE FEDERATION: app1 consumes app2's RSC container for: + // - Server components (rendered in app1's RSC stream) + // - Client component references (serialized as $L client refs) + // + // TODO (Option 2 - Deep MF Integration): + // To fully federate server actions via MF (not HTTP forwarding), we would need to: + // 1. Modify rsc-server-loader.js to call registerServerReference for remote modules + // 2. Modify react-server-dom-webpack-plugin.js to include remote actions in manifest + // 3. Ensure remote 'use server' modules register with host's serverActionRegistry + // See: packages/react-server-dom-webpack/cjs/rsc-server-loader.js + // See: packages/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js + new ModuleFederationPlugin({ + name: 'app1', + filename: 'remoteEntry.server.js', + // Consume app2's RSC container for server-side federation + remotes: { + app2: 'app2@http://localhost:4102/remoteEntry.server.js', + }, + experiments: { + asyncStartup: true, + }, + runtimePlugins: [ + require.resolve('@module-federation/node/runtimePlugin'), + require.resolve('../../app-shared/scripts/rscRuntimePlugin.js'), + ], + shared: { + react: { + singleton: true, + eager: false, + requiredVersion: false, + shareScope: 'rsc', + layer: WEBPACK_LAYERS.rsc, + issuerLayer: WEBPACK_LAYERS.rsc, + import: reactServerEntry, + shareKey: 'react', + shareScope: 'rsc', + allowNodeModulesSuffixMatch: true, + }, + 'react-dom': { + singleton: true, + eager: false, + requiredVersion: false, + shareScope: 'rsc', + layer: WEBPACK_LAYERS.rsc, + issuerLayer: WEBPACK_LAYERS.rsc, + allowNodeModulesSuffixMatch: true, + }, + 'react/jsx-runtime': { + singleton: true, + eager: false, + requiredVersion: false, + shareScope: 'rsc', + layer: WEBPACK_LAYERS.rsc, + issuerLayer: WEBPACK_LAYERS.rsc, + import: reactJSXServerEntry, + shareKey: 'react/jsx-runtime', + allowNodeModulesSuffixMatch: true, + }, + 'react/jsx-dev-runtime': { + singleton: true, + eager: false, + requiredVersion: false, + shareScope: 'rsc', + layer: WEBPACK_LAYERS.rsc, + issuerLayer: WEBPACK_LAYERS.rsc, + import: reactJSXDevServerEntry, + shareKey: 'react/jsx-dev-runtime', + allowNodeModulesSuffixMatch: true, + }, + 'react-server-dom-webpack': { + singleton: true, + eager: false, + requiredVersion: false, + shareScope: 'rsc', + layer: WEBPACK_LAYERS.rsc, + issuerLayer: WEBPACK_LAYERS.rsc, + }, + 'react-server-dom-webpack/server': { + // Match require('react-server-dom-webpack/server') if any code uses it + import: rsdwServerPath, + requiredVersion: false, + singleton: true, + shareScope: 'rsc', + layer: WEBPACK_LAYERS.rsc, + issuerLayer: WEBPACK_LAYERS.rsc, + }, + 'react-server-dom-webpack/server.node': { + // The rsc-server-loader emits require('react-server-dom-webpack/server.node') + // This resolves it to the correct server writer (no --conditions flag needed) + import: rsdwServerPath, + requiredVersion: false, + singleton: true, + shareScope: 'rsc', + layer: WEBPACK_LAYERS.rsc, + issuerLayer: WEBPACK_LAYERS.rsc, + }, + 'react-server-dom-webpack/server.node.unbundled': { + import: rsdwServerUnbundledPath, + requiredVersion: false, + singleton: true, + shareScope: 'rsc', + layer: WEBPACK_LAYERS.rsc, + issuerLayer: WEBPACK_LAYERS.rsc, + }, + '@rsc-demo/shared-rsc': { + singleton: true, + eager: false, + requiredVersion: false, + shareScope: 'rsc', + layer: WEBPACK_LAYERS.rsc, + issuerLayer: WEBPACK_LAYERS.rsc, + }, + 'shared-components': { + singleton: true, + eager: false, + requiredVersion: false, + shareScope: 'rsc', + layer: WEBPACK_LAYERS.rsc, + issuerLayer: WEBPACK_LAYERS.rsc, + }, + }, + // Initialize default + rsc scopes; this share lives in 'rsc'. + shareScope: ['default', 'rsc'], + shareStrategy: 'version-first', + }), + ], + resolve: { + // Server uses react-server condition for proper RSC module resolution + conditionNames: ['react-server', 'node', 'import', 'require', 'default'], + }, +}; + +/** + * SSR bundle configuration (for server-side rendering of client components) + * This builds client components for Node.js execution during SSR + */ +const ssrConfig = { + mode: isProduction ? 'production' : 'development', + devtool: isProduction ? 'source-map' : 'cheap-module-source-map', + target: 'node', + entry: { + ssr: { + import: path.resolve(__dirname, '../src/framework/ssr-entry.js'), + layer: WEBPACK_LAYERS.ssr, // Entry point is in SSR layer + }, + }, + output: { + path: path.resolve(__dirname, '../build'), + filename: '[name].js', + libraryTarget: 'commonjs2', + }, + optimization: { + minimize: false, + chunkIds: 'named', + moduleIds: 'named', + }, + experiments: { + layers: true, + }, + module: { + rules: [ + // Allow imports without .js extension in ESM modules (only for workspace packages) + { + test: /\.m?js$/, + include: (modulePath) => { + return ( + modulePath.includes('shared-components') || + modulePath.includes('shared-rsc') + ); + }, + resolve: {fullySpecified: false}, + }, + { + test: /\.js$/, + // Exclude node_modules EXCEPT our workspace packages + exclude: (modulePath) => { + if ( + modulePath.includes('shared-components') || + modulePath.includes('shared-rsc') + ) + return false; + return /node_modules/.test(modulePath); + }, + oneOf: [ + // SSR layer: transforms 'use server' to stubs, keeps client components + { + issuerLayer: WEBPACK_LAYERS.ssr, + layer: WEBPACK_LAYERS.ssr, + use: [ + babelLoader, + { + loader: require.resolve( + 'react-server-dom-webpack/rsc-ssr-loader' + ), + }, + ], + }, + // Default to SSR layer for SSR bundle + { + layer: WEBPACK_LAYERS.ssr, + use: [ + babelLoader, + { + loader: require.resolve( + 'react-server-dom-webpack/rsc-ssr-loader' + ), + }, + ], + }, + ], + }, + // CSS handling (if needed) + { + test: /\.css$/, + use: ['null-loader'], // Ignore CSS in SSR bundle + }, + ], + }, + plugins: [ + // Generate SSR manifest for client component resolution during SSR + new ReactServerWebpackPlugin({ + isServer: true, + ssrManifestFilename: 'react-ssr-manifest.json', + extraServerActionsManifests: [ + path.resolve( + __dirname, + '../app2/build/react-server-actions-manifest.json' + ), + ], + }), + ], + resolve: { + // SSR uses node conditions + conditionNames: ['node', 'import', 'require', 'default'], + }, + externals: { + // Externalize React packages to use the same instances as the server + react: 'commonjs react', + 'react-dom': 'commonjs react-dom', + 'react-dom/server': 'commonjs react-dom/server', + 'react-server-dom-webpack': 'commonjs react-server-dom-webpack', + 'react-server-dom-webpack/server': + 'commonjs react-server-dom-webpack/server', + }, +}; + +function handleStats(err, stats) { + if (err) { + console.error(err.stack || err); + if (err.details) { + console.error(err.details); + } + if (!isWatchMode) { + process.exit(1); + } + return; + } + const info = stats.toJson(); + if (stats.hasErrors()) { + console.log('Finished running webpack with errors.'); + info.errors.forEach((e) => console.error(e)); + if (!isWatchMode) { + process.exit(1); + } + } else { + console.log('Finished running webpack.'); + } +} + +const compiler = webpack([webpackConfig, serverConfig, ssrConfig]); + +function mergeRemoteServerActions() { + const hostManifestPath = path.resolve( + __dirname, + '../build/react-server-actions-manifest.json' + ); + const remoteManifestPath = path.resolve( + __dirname, + '../../app2/build/react-server-actions-manifest.json' + ); + + if (!fs.existsSync(hostManifestPath) || !fs.existsSync(remoteManifestPath)) { + return; + } + + const host = JSON.parse(fs.readFileSync(hostManifestPath, 'utf8')); + const remote = JSON.parse(fs.readFileSync(remoteManifestPath, 'utf8')); + + let merged = false; + for (const [key, value] of Object.entries(remote)) { + if (!host[key]) { + host[key] = value; + merged = true; + } + } + + if (merged) { + fs.writeFileSync(hostManifestPath, JSON.stringify(host, null, 2)); + console.log( + '[ReactFlightPlugin] Merged app2 server actions into app1 manifest' + ); + } +} + +if (isWatchMode) { + console.log('Starting webpack (client + rsc) in watch mode...'); + compiler.watch({aggregateTimeout: 300, poll: undefined}, handleStats); +} else { + compiler.run((err, stats) => { + handleStats(err, stats); + if (!err && stats && !stats.hasErrors()) { + mergeRemoteServerActions(); + } + }); +} diff --git a/apps/rsc-demo/packages/app1/scripts/init_db.sh b/apps/rsc-demo/packages/app1/scripts/init_db.sh new file mode 100755 index 00000000000..b6e1a2f69cc --- /dev/null +++ b/apps/rsc-demo/packages/app1/scripts/init_db.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + DROP TABLE IF EXISTS notes; + CREATE TABLE notes ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + title TEXT, + body TEXT + ); +EOSQL diff --git a/apps/rsc-demo/packages/app1/scripts/seed.js b/apps/rsc-demo/packages/app1/scripts/seed.js new file mode 100644 index 00000000000..cf8462f59c5 --- /dev/null +++ b/apps/rsc-demo/packages/app1/scripts/seed.js @@ -0,0 +1,92 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const {Pool} = require('pg'); +const {readdir, unlink, writeFile} = require('fs/promises'); +const startOfYear = require('date-fns/startOfYear'); +const credentials = require('../credentials'); + +const NOTES_PATH = './notes'; +const pool = new Pool(credentials); + +const now = new Date(); +const startOfThisYear = startOfYear(now); +// Thanks, https://stackoverflow.com/a/9035732 +function randomDateBetween(start, end) { + return new Date( + start.getTime() + Math.random() * (end.getTime() - start.getTime()) + ); +} + +const dropTableStatement = 'DROP TABLE IF EXISTS notes;'; +const createTableStatement = `CREATE TABLE notes ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + title TEXT, + body TEXT +);`; +const insertNoteStatement = `INSERT INTO notes(title, body, created_at, updated_at) + VALUES ($1, $2, $3, $3) + RETURNING *`; +const seedData = [ + [ + 'Meeting Notes', + 'This is an example note. It contains **Markdown**!', + randomDateBetween(startOfThisYear, now), + ], + [ + 'Make a thing', + `It's very easy to make some words **bold** and other words *italic* with +Markdown. You can even [link to React's website!](https://www.reactjs.org).`, + randomDateBetween(startOfThisYear, now), + ], + [ + 'A note with a very long title because sometimes you need more words', + `You can write all kinds of [amazing](https://en.wikipedia.org/wiki/The_Amazing) +notes in this app! These note live on the server in the \`notes\` folder. + +![This app is powered by React](https://upload.wikimedia.org/wikipedia/commons/thumb/1/18/React_Native_Logo.png/800px-React_Native_Logo.png)`, + randomDateBetween(startOfThisYear, now), + ], + ['I wrote this note today', 'It was an excellent note.', now], +]; + +async function seed() { + await pool.query(dropTableStatement); + await pool.query(createTableStatement); + const res = await Promise.all( + seedData.map((row) => pool.query(insertNoteStatement, row)) + ); + + const oldNotes = await readdir(path.resolve(NOTES_PATH)); + await Promise.all( + oldNotes + .filter((filename) => filename.endsWith('.md')) + .map((filename) => unlink(path.resolve(NOTES_PATH, filename))) + ); + + await Promise.all( + res.map(({rows}) => { + const id = rows[0].id; + const content = rows[0].body; + const data = new Uint8Array(Buffer.from(content)); + return writeFile(path.resolve(NOTES_PATH, `${id}.md`), data, (err) => { + if (err) { + throw err; + } + }); + }) + ); +} + +seed(); diff --git a/apps/rsc-demo/packages/app1/server/api.server.js b/apps/rsc-demo/packages/app1/server/api.server.js new file mode 100644 index 00000000000..bd04b347bd1 --- /dev/null +++ b/apps/rsc-demo/packages/app1/server/api.server.js @@ -0,0 +1,697 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use strict'; + +/** + * Express Server for RSC Application + * + * This server uses BUNDLED RSC code from webpack. + * The webpack build uses resolve.conditionNames: ['react-server', ...] + * to resolve React packages at BUILD time. + * + * NO --conditions=react-server flag needed at runtime! + */ + +const express = require('express'); +const compress = require('compression'); +const Busboy = require('busboy'); +const {readFileSync, existsSync} = require('fs'); +const {unlink, writeFile} = require('fs').promises; +const {spawn} = require('child_process'); +const {PassThrough} = require('stream'); +const path = require('path'); +const React = require('react'); + +// RSC Action header (similar to Next.js's 'Next-Action') +const RSC_ACTION_HEADER = 'rsc-action'; + +// Host app runs on 4101 by default (tests assume this) +const PORT = process.env.PORT || 4101; + +// Remote app configuration for federated server actions (Option 1 - HTTP forwarding) +// Action IDs prefixed with 'remote:app2:' or containing 'app2/' are forwarded to app2 +const REMOTE_APP_CONFIG = { + app2: { + url: process.env.APP2_URL || 'http://localhost:4102', + // Patterns to match action IDs that belong to app2 + patterns: [ + /^remote:app2:/, // Explicit prefix + /app2\/src\//, // File path contains app2 + /packages\/app2\//, // Full package path + ], + }, +}; + +/** + * Check if an action ID belongs to a remote app and compute the ID that the + * remote server should see. + * + * For example, an ID like `remote:app2:file:///...#increment` should be + * forwarded as `file:///...#increment` so it matches the remote manifest keys. + * + * @param {string} actionId - The (possibly prefixed) server action ID + * @returns {{ app: string, config: object, forwardedId: string } | null} + * + * TODO (Option 2 - Deep MF Integration): + * Instead of HTTP forwarding, remote actions could be executed via MF: + * 1. Import remote action modules via MF in server-entry.js + * 2. Remote 'use server' functions register with host's serverActionRegistry + * 3. getServerAction(actionId) returns federated functions directly + * This requires changes to: + * - rsc-server-loader.js to handle remote module registration + * - react-server-dom-webpack-plugin.js to include remote actions in manifest + * - server.node.js to support federated action lookups + * Remote manifests (react-server-actions-manifest.json) would then be merged + * into the host's manifest instead of being consulted via HTTP. + */ +function getRemoteAppForAction(actionId) { + for (const [app, config] of Object.entries(REMOTE_APP_CONFIG)) { + for (const pattern of config.patterns) { + if (pattern.test(actionId)) { + // Strip explicit remote prefix if present so the remote sees the + // original manifest ID (e.g. file:///...#name). + let forwardedId = actionId; + const prefix = `remote:${app}:`; + if (forwardedId.startsWith(prefix)) { + forwardedId = forwardedId.slice(prefix.length); + } + return {app, config, forwardedId}; + } + } + } + return null; +} + +/** + * Forward a server action request to a remote app (Option 1) + * Proxies the full request/response to preserve RSC Flight protocol + */ +async function forwardActionToRemote( + req, + res, + forwardedActionId, + remoteConfig +) { + const targetUrl = `${remoteConfig.url}/react${req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''}`; + + console.log( + `[Federation] Forwarding action ${forwardedActionId} to ${targetUrl}` + ); + + // Collect request body + const bodyChunks = []; + req.on('data', (chunk) => bodyChunks.push(chunk)); + + await new Promise((resolve, reject) => { + req.on('end', resolve); + req.on('error', reject); + }); + + const bodyBuffer = Buffer.concat(bodyChunks); + + // Start from original headers so we preserve cookies/auth/etc. + const headers = {...req.headers}; + + // Never forward host/header values directly; let fetch set Host. + delete headers.host; + delete headers.connection; + delete headers['content-length']; + + // Force the action header to the ID the remote expects. + headers[RSC_ACTION_HEADER] = forwardedActionId; + + // Ensure content-type is present if we have a body. + if ( + bodyBuffer.length && + !headers['content-type'] && + !headers['Content-Type'] + ) { + headers['content-type'] = 'application/octet-stream'; + } + + // Forward to remote app + const response = await fetch(targetUrl, { + method: 'POST', + headers, + body: bodyBuffer, + }); + + // Copy response headers + for (const [key, value] of response.headers.entries()) { + // Skip some headers that shouldn't be forwarded + if ( + !['content-encoding', 'transfer-encoding', 'connection'].includes( + key.toLowerCase() + ) + ) { + res.set(key, value); + } + } + + res.status(response.status); + + // Stream response body + if (response.body) { + const reader = response.body.getReader(); + while (true) { + const {done, value} = await reader.read(); + if (done) break; + res.write(value); + } + } + res.end(); +} + +// Database will be loaded from bundled RSC server +// This is lazy-loaded to allow the bundle to be loaded first +let pool = null; +const app = express(); + +app.use(compress()); +const buildDir = path.resolve(__dirname, '../build'); +app.use(express.static(buildDir)); +app.use('/build', express.static(buildDir)); +app.use(express.static(path.resolve(__dirname, '../public'))); + +// Lazy-load the bundled RSC server code +// This is built by webpack with react-server condition resolved at build time +// With asyncStartup: true, the require returns a promise that resolves to the module +let rscServerPromise = null; +let rscServerResolved = null; +let remoteActionsInitPromise = null; + +async function getRSCServer() { + if (rscServerResolved) { + return rscServerResolved; + } + if (!rscServerPromise) { + const bundlePath = path.resolve(__dirname, '../build/server.rsc.js'); + if (!existsSync(bundlePath)) { + throw new Error( + 'RSC server bundle not found. Run `pnpm build` first.\n' + + 'The server bundle is built with webpack and includes React with react-server exports.' + ); + } + const mod = require(bundlePath); + // With asyncStartup, the module might be a promise or have async init + rscServerPromise = Promise.resolve(mod).then((resolved) => { + rscServerResolved = resolved; + return resolved; + }); + } + return rscServerPromise; +} + +async function ensureRemoteActionsRegistered(server, serverActionsManifest) { + // Option 2: In-process MF-native federated actions. + // If the RSC server exposes registerRemoteApp2Actions, call it once to + // register remote actions into the shared serverActionRegistry. We guard + // with a promise so multiple /react requests don't re-register. + if (!server || typeof server.registerRemoteApp2Actions !== 'function') { + return; + } + if (!remoteActionsInitPromise) { + remoteActionsInitPromise = Promise.resolve().then(async () => { + try { + await server.registerRemoteApp2Actions(serverActionsManifest); + } catch (error) { + console.error( + '[Federation] Failed to register remote actions via Module Federation:', + error + ); + // Allow a future attempt if registration fails. + remoteActionsInitPromise = null; + } + }); + } + return remoteActionsInitPromise; +} + +async function getPool() { + if (!pool) { + const server = await getRSCServer(); + pool = server.pool; + } + return pool; +} + +if (!process.env.RSC_TEST_MODE) { + app + .listen(PORT, () => { + console.log(`React Notes listening at ${PORT}...`); + console.log('Using bundled RSC server (no --conditions flag needed)'); + }) + .on('error', function (error) { + if (error.syscall !== 'listen') { + throw error; + } + const isPipe = (portOrPipe) => Number.isNaN(portOrPipe); + const bind = isPipe(PORT) ? 'Pipe ' + PORT : 'Port ' + PORT; + switch (error.code) { + case 'EACCES': + console.error(bind + ' requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + console.error(bind + ' is already in use'); + process.exit(1); + break; + default: + throw error; + } + }); +} + +function handleErrors(fn) { + return async function (req, res, next) { + try { + return await fn(req, res); + } catch (x) { + next(x); + } + }; +} + +async function readRequestBody(req) { + if (req.body && typeof req.body === 'string') { + return req.body; + } + if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) { + return JSON.stringify(req.body); + } + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +/** + * Render RSC to a buffer (flight stream) + * Uses the bundled RSC server code (webpack-built with react-server condition) + */ +async function renderRSCToBuffer(props) { + const manifest = readFileSync( + path.resolve(__dirname, '../build/react-client-manifest.json'), + 'utf8' + ); + const moduleMap = JSON.parse(manifest); + + // Use bundled RSC server (await for asyncStartup) + const server = await getRSCServer(); + + return new Promise((resolve, reject) => { + const chunks = []; + const passThrough = new PassThrough(); + passThrough.on('data', (chunk) => chunks.push(chunk)); + passThrough.on('end', () => resolve(Buffer.concat(chunks))); + passThrough.on('error', reject); + + const {pipe} = server.renderApp(props, moduleMap); + pipe(passThrough); + }); +} + +/** + * Render RSC flight stream to HTML using SSR worker + * The SSR worker uses the bundled SSR code (webpack-built without react-server condition) + */ +function renderSSR(rscBuffer) { + return new Promise((resolve, reject) => { + const workerPath = path.resolve(__dirname, './ssr-worker.js'); + const ssrWorker = spawn('node', [workerPath], { + stdio: ['pipe', 'pipe', 'pipe'], + env: {...process.env}, + }); + + const chunks = []; + ssrWorker.stdout.on('data', (chunk) => chunks.push(chunk)); + ssrWorker.stdout.on('end', () => + resolve(Buffer.concat(chunks).toString('utf8')) + ); + + ssrWorker.stderr.on('data', (data) => { + console.error('SSR Worker stderr:', data.toString()); + }); + + ssrWorker.on('error', reject); + ssrWorker.on('close', (code) => { + if (code !== 0 && chunks.length === 0) { + reject(new Error(`SSR worker exited with code ${code}`)); + } + }); + + // Send RSC flight data to worker + ssrWorker.stdin.write(rscBuffer); + ssrWorker.stdin.end(); + }); +} + +app.get( + '/', + handleErrors(async function (_req, res) { + await waitForWebpack(); + + const props = { + selectedId: null, + isEditing: false, + searchText: '', + }; + + // Check if SSR bundle exists + const ssrBundlePath = path.resolve(__dirname, '../build/ssr.js'); + if (!existsSync(ssrBundlePath)) { + // Fallback to shell if SSR bundle not built + const html = readFileSync( + path.resolve(__dirname, '../build/index.html'), + 'utf8' + ); + res.send(html); + return; + } + + try { + // Step 1: Render RSC to flight stream (using bundled RSC server) + const rscBuffer = await renderRSCToBuffer(props); + + // Step 2: Render flight stream to HTML using SSR worker (using bundled SSR code) + const ssrHtml = await renderSSR(rscBuffer); + + // Step 3: Inject SSR HTML into the shell template + const shellHtml = readFileSync( + path.resolve(__dirname, '../build/index.html'), + 'utf8' + ); + + // Embed the RSC flight data for hydration + const rscDataScript = ``; + + // Replace the empty root div with SSR content + RSC data + const finalHtml = shellHtml.replace( + '
', + `
${ssrHtml}
${rscDataScript}` + ); + + res.send(finalHtml); + } catch (error) { + console.error('SSR Error, falling back to shell:', error); + // Fallback to shell rendering on error + const html = readFileSync( + path.resolve(__dirname, '../build/index.html'), + 'utf8' + ); + res.send(html); + } + }) +); + +async function renderReactTree(res, props) { + await waitForWebpack(); + const manifest = readFileSync( + path.resolve(__dirname, '../build/react-client-manifest.json'), + 'utf8' + ); + const moduleMap = JSON.parse(manifest); + + // Use bundled RSC server (await for asyncStartup) + const server = await getRSCServer(); + const {pipe} = server.renderApp(props, moduleMap); + pipe(res); +} + +function sendResponse(req, res, redirectToId) { + const location = JSON.parse(req.query.location); + if (redirectToId) { + location.selectedId = redirectToId; + } + res.set('X-Location', JSON.stringify(location)); + renderReactTree(res, { + selectedId: location.selectedId, + isEditing: location.isEditing, + searchText: location.searchText, + }); +} + +app.get('/react', function (req, res) { + sendResponse(req, res, null); +}); + +// Server Actions endpoint - spec-compliant implementation +// Uses RSC-Action header to identify action (like Next.js's Next-Action) +// +// FEDERATED ACTIONS: +// - Option 2 (preferred): In-process MF-native actions. Remote 'use server' +// modules from app2 are imported via Module Federation in server-entry.js +// and registered into the shared serverActionRegistry. getServerAction(id) +// returns a callable function that runs in this process. +// - Option 1 (fallback): HTTP forwarding. If an action ID matches a remote +// app pattern but is not registered via MF, the request is forwarded to +// that app's /react endpoint and the response is proxied back. +app.post( + '/react', + handleErrors(async function (req, res) { + const actionId = req.get(RSC_ACTION_HEADER); + + if (!actionId) { + res.status(400).send('Missing RSC-Action header'); + return; + } + + await waitForWebpack(); + + // Get the bundled RSC server (await for asyncStartup) + const server = await getRSCServer(); + + // Load server actions manifest from build + const manifestPath = path.resolve( + __dirname, + '../build/react-server-actions-manifest.json' + ); + let serverActionsManifest = {}; + if (existsSync(manifestPath)) { + serverActionsManifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + } + + // Merge dynamic inline actions registered at runtime + const dynamicManifest = server.getDynamicServerActionsManifest() || {}; + serverActionsManifest = Object.assign( + {}, + serverActionsManifest, + dynamicManifest + ); + + const actionEntry = serverActionsManifest[actionId]; + + // Ensure any MF-native remote actions are registered into the host + // registry before we attempt lookup. This enables Option 2 for app2. + await ensureRemoteActionsRegistered(server, serverActionsManifest); + + // Load and execute the action + // First check the global registry (for inline server actions registered at runtime) + // Then fall back to module exports (for file-level 'use server' from manifest) + let actionFn = server.getServerAction(actionId); + let actionName = actionId.split('#')[1] || 'default'; + + // If MF-native registration did not provide a function, fall back to + // Option 1 (HTTP forwarding) for known remote actions. + if (!actionFn) { + const remoteApp = getRemoteAppForAction(actionId); + if (remoteApp) { + console.log( + `[Federation] Action ${actionId} belongs to ${remoteApp.app}, ` + + 'no MF-registered handler found, forwarding via HTTP...' + ); + await forwardActionToRemote( + req, + res, + remoteApp.forwardedId, + remoteApp.config + ); + return; + } + } + + if (!actionFn && actionEntry) { + // For bundled server actions, they should be in the registry + // File-level actions are also bundled into server.rsc.js + console.warn( + `Action ${actionId} not in registry, manifest entry:`, + actionEntry + ); + } + + if (typeof actionFn !== 'function') { + res + .status(404) + .send( + `Server action "${actionId}" not found. ` + + `Ensure the action module is imported in server-entry.js.` + ); + return; + } + + // Decode the action arguments using React's Flight Reply protocol + const contentType = req.headers['content-type'] || ''; + let args; + if (contentType.startsWith('multipart/form-data')) { + const busboy = new Busboy({headers: req.headers}); + const pending = server.decodeReplyFromBusboy( + busboy, + serverActionsManifest + ); + req.pipe(busboy); + args = await pending; + } else { + const body = await readRequestBody(req); + args = await server.decodeReply(body, serverActionsManifest); + } + + // Execute the server action + const result = await actionFn(...(Array.isArray(args) ? args : [args])); + + // Return the result as RSC Flight stream + res.set('Content-Type', 'text/x-component'); + + // For now, re-render the app tree with the action result + const location = req.query.location + ? JSON.parse(req.query.location) + : { + selectedId: null, + isEditing: false, + searchText: '', + }; + + // Include action result in response header for client consumption + if (result !== undefined) { + res.set('X-Action-Result', JSON.stringify(result)); + } + + renderReactTree(res, { + selectedId: location.selectedId, + isEditing: location.isEditing, + searchText: location.searchText, + }); + }) +); + +const NOTES_PATH = path.resolve(__dirname, '../notes'); + +app.post( + '/notes', + express.json(), + handleErrors(async function (req, res) { + const now = new Date(); + const pool = await getPool(); + const result = await pool.query( + 'insert into notes (title, body, created_at, updated_at) values ($1, $2, $3, $3) returning id', + [req.body.title, req.body.body, now] + ); + const insertedId = result.rows[0].id; + await writeFile( + path.resolve(NOTES_PATH, `${insertedId}.md`), + req.body.body, + 'utf8' + ); + sendResponse(req, res, insertedId); + }) +); + +app.put( + '/notes/:id', + express.json(), + handleErrors(async function (req, res) { + const now = new Date(); + const updatedId = Number(req.params.id); + const pool = await getPool(); + await pool.query( + 'update notes set title = $1, body = $2, updated_at = $3 where id = $4', + [req.body.title, req.body.body, now, updatedId] + ); + await writeFile( + path.resolve(NOTES_PATH, `${updatedId}.md`), + req.body.body, + 'utf8' + ); + sendResponse(req, res, null); + }) +); + +app.delete( + '/notes/:id', + handleErrors(async function (req, res) { + const pool = await getPool(); + await pool.query('delete from notes where id = $1', [req.params.id]); + await unlink(path.resolve(NOTES_PATH, `${req.params.id}.md`)); + sendResponse(req, res, null); + }) +); + +app.get( + '/notes', + handleErrors(async function (_req, res) { + const pool = await getPool(); + const {rows} = await pool.query('select * from notes order by id desc'); + res.json(rows); + }) +); + +app.get( + '/notes/:id', + handleErrors(async function (req, res) { + const pool = await getPool(); + const {rows} = await pool.query('select * from notes where id = $1', [ + req.params.id, + ]); + res.json(rows[0]); + }) +); + +app.get('/sleep/:ms', function (req, res) { + setTimeout(() => { + res.json({ok: true}); + }, req.params.ms); +}); + +app.use(express.static('build')); +app.use(express.static('public')); + +async function waitForWebpack() { + const requiredFiles = [ + path.resolve(__dirname, '../build/index.html'), + path.resolve(__dirname, '../build/server.rsc.js'), + path.resolve(__dirname, '../build/react-client-manifest.json'), + ]; + + // In test mode we don't want to loop forever; just assert once. + const isTest = !!process.env.RSC_TEST_MODE; + + // eslint-disable-next-line no-constant-condition + while (true) { + const missing = requiredFiles.filter((file) => !existsSync(file)); + if (missing.length === 0) { + return; + } + + const msg = + 'Could not find webpack build output: ' + + missing.map((f) => path.basename(f)).join(', ') + + '. Will retry in a second...'; + console.log(msg); + + if (isTest) { + // In tests, fail fast instead of looping forever. + throw new Error(msg); + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} + +module.exports = app; diff --git a/apps/rsc-demo/packages/app1/server/package.json b/apps/rsc-demo/packages/app1/server/package.json new file mode 100644 index 00000000000..cd4d70b9771 --- /dev/null +++ b/apps/rsc-demo/packages/app1/server/package.json @@ -0,0 +1,4 @@ +{ + "type": "commonjs", + "main": "./api.server.js" +} diff --git a/apps/rsc-demo/packages/app1/server/ssr-worker.js b/apps/rsc-demo/packages/app1/server/ssr-worker.js new file mode 100644 index 00000000000..9b9c3de8100 --- /dev/null +++ b/apps/rsc-demo/packages/app1/server/ssr-worker.js @@ -0,0 +1,105 @@ +/** + * SSR Worker (app1) + * + * This worker renders RSC flight streams to HTML using react-dom/server. + * It must run WITHOUT --conditions=react-server to access react-dom/server. + */ + +'use strict'; + +const {Readable} = require('stream'); +const {renderToPipeableStream} = require('react-dom/server'); +const {createFromNodeStream} = require('react-server-dom-webpack/client.node'); + +const ssrBundle = require('../build/ssr.js'); +const clientManifest = require('../build/react-client-manifest.json'); + +function buildSSRManifest() { + const moduleMap = {}; + + for (const manifestEntry of Object.values(clientManifest)) { + const moduleId = manifestEntry.id; + moduleMap[moduleId] = { + default: {id: moduleId, name: 'default', chunks: []}, + '*': {id: moduleId, name: '*', chunks: []}, + '': {id: moduleId, name: '', chunks: []}, + }; + } + + return { + moduleLoading: {prefix: '', crossOrigin: null}, + moduleMap, + serverModuleMap: null, + }; +} + +function setupModuleLoader() { + const {componentMap} = ssrBundle; + + if (typeof globalThis.__webpack_require__ === 'undefined') { + globalThis.__webpack_chunk_load__ = () => Promise.resolve(); + globalThis.__webpack_require__ = function (moduleId) { + const match = moduleId.match(/\(client\)\/(.+)/); + const relativePath = match ? match[1] : moduleId; + + if (componentMap && componentMap[relativePath]) { + const mod = componentMap[relativePath]; + if (!mod.__esModule) { + mod.__esModule = true; + } + return mod; + } + + const componentName = relativePath.split('/').pop().replace('.js', ''); + if (ssrBundle[componentName]) { + return {__esModule: true, default: ssrBundle[componentName]}; + } + + console.warn(`SSR: Module not found: ${moduleId}`); + return { + default: function PlaceholderComponent() { + return null; + }, + }; + }; + + globalThis.__webpack_require__.c = {}; + } +} + +async function renderSSR() { + setupModuleLoader(); + + const chunks = []; + + process.stdin.on('data', (chunk) => { + chunks.push(chunk); + }); + + process.stdin.on('end', async () => { + try { + const flightData = Buffer.concat(chunks); + const flightStream = Readable.from([flightData]); + const ssrManifest = buildSSRManifest(); + const tree = await createFromNodeStream(flightStream, ssrManifest); + + const {pipe} = renderToPipeableStream(tree, { + onShellReady() { + pipe(process.stdout); + }, + onShellError(error) { + console.error('SSR Shell Error:', error); + process.exit(1); + }, + onError(error) { + console.error('SSR Error:', error); + }, + }); + } catch (error) { + console.error('SSR Worker Error:', error); + process.exit(1); + } + }); +} + +renderSSR(); diff --git a/apps/rsc-demo/packages/app1/src/App.js b/apps/rsc-demo/packages/app1/src/App.js new file mode 100644 index 00000000000..4cc55e6b695 --- /dev/null +++ b/apps/rsc-demo/packages/app1/src/App.js @@ -0,0 +1,56 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {Suspense} from 'react'; + +import Note from './Note'; +import NoteList from './NoteList'; +import EditButton from './EditButton'; +import SearchField from './SearchField'; +import NoteSkeleton from './NoteSkeleton'; +import NoteListSkeleton from './NoteListSkeleton'; +import DemoCounter from './DemoCounter.server'; +import InlineActionDemo from './InlineActionDemo.server'; +import SharedDemo from './SharedDemo.server'; + +export default function App({selectedId, isEditing, searchText}) { + return ( +
+
+
+ + React Notes +
+
+ + New +
+ +
+
+ }> + + + + + +
+
+ ); +} diff --git a/apps/rsc-demo/packages/app1/src/DemoCounter.server.js b/apps/rsc-demo/packages/app1/src/DemoCounter.server.js new file mode 100644 index 00000000000..286afc94f94 --- /dev/null +++ b/apps/rsc-demo/packages/app1/src/DemoCounter.server.js @@ -0,0 +1,14 @@ +import React from 'react'; +import DemoCounterButton from './DemoCounterButton'; +import {getCount} from './server-actions'; + +export default async function DemoCounter() { + const count = getCount(); + return ( +
+

Server Action Demo

+

Current count (fetched on server render): {count}

+ +
+ ); +} diff --git a/apps/rsc-demo/packages/app1/src/DemoCounterButton.js b/apps/rsc-demo/packages/app1/src/DemoCounterButton.js new file mode 100644 index 00000000000..1a9d60a9f21 --- /dev/null +++ b/apps/rsc-demo/packages/app1/src/DemoCounterButton.js @@ -0,0 +1,39 @@ +'use client'; +import React, {useState} from 'react'; +// This import is transformed by the server-action-client-loader +// into a createServerReference call at build time +import {incrementCount} from './server-actions'; +// Test default export action (for P1 bug regression test) +import testDefaultAction from './test-default-action'; + +export default function DemoCounterButton({initialCount}) { + const [count, setCount] = useState(initialCount); + const [loading, setLoading] = useState(false); + + async function increment() { + setLoading(true); + try { + // incrementCount is now a server reference that calls the server action + const result = await incrementCount(); + + if (typeof result === 'number') { + setCount(result); + } else { + setCount((c) => c + 1); + } + } catch (error) { + console.error('Server action failed:', error); + } finally { + setLoading(false); + } + } + + return ( +
+

Client view of count: {count}

+ +
+ ); +} diff --git a/apps/rsc-demo/packages/app1/src/EditButton.js b/apps/rsc-demo/packages/app1/src/EditButton.js new file mode 100644 index 00000000000..f3fde196a9b --- /dev/null +++ b/apps/rsc-demo/packages/app1/src/EditButton.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use client'; + +import {useTransition} from 'react'; +import {useRouter} from './framework/router'; + +export default function EditButton({noteId, children}) { + const [isPending, startTransition] = useTransition(); + const {navigate} = useRouter(); + const isDraft = noteId == null; + return ( + + ); +} diff --git a/apps/rsc-demo/packages/app1/src/FederatedActionDemo.js b/apps/rsc-demo/packages/app1/src/FederatedActionDemo.js new file mode 100644 index 00000000000..a419a8baa0e --- /dev/null +++ b/apps/rsc-demo/packages/app1/src/FederatedActionDemo.js @@ -0,0 +1,122 @@ +'use client'; + +import React, {useState, useTransition, useEffect} from 'react'; + +/** + * FederatedActionDemo - Client component demonstrating cross-app server action forwarding + * + * This component shows Option 1 (HTTP forwarding) for federated server actions: + * 1. Imports action reference from app2 via Module Federation + * 2. Calls the action through app1's server (host) + * 3. app1's server detects the action belongs to app2 and forwards via HTTP + * 4. app2's server executes the action and returns the result + * 5. app1 proxies the response back to the client + * + * Architecture flow: + * [Client] --callServer--> [app1 /react] --HTTP forward--> [app2 /react] --execute--> [action] + * <--proxy response-- <--result-- + * + * TODO (Option 2 - Deep MF Integration): + * With native MF action federation, the flow would be: + * [Client] --callServer--> [app1 /react] --MF require--> [app2 action in memory] + * No HTTP hop needed, action executes directly in app1's process. + * Requires changes to rsc-server-loader.js, react-server-dom-webpack-plugin.js, server.node.js + */ +export default function FederatedActionDemo() { + const [count, setCount] = useState(0); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [actionModule, setActionModule] = useState(null); + + // Dynamically import the action module from app2 via Module Federation + // This happens client-side after hydration + useEffect(() => { + import('app2/server-actions') + .then((mod) => { + setActionModule(mod); + }) + .catch((err) => { + console.error('Failed to load federated actions:', err); + setError('Failed to load federated actions'); + }); + }, []); + + const handleClick = async () => { + if (!actionModule?.incrementCount) { + setError('Action not available'); + return; + } + + startTransition(async () => { + try { + // Call the federated action + // The action reference from app2 will have an action ID that includes 'app2' + // app1's server will detect this and forward to app2 + const result = await actionModule.incrementCount(); + setCount(result); + setError(null); + } catch (err) { + console.error('Federated action failed:', err); + setError(err.message || 'Action failed'); + } + }); + }; + + return ( +
+

+ Federated Action Demo (Option 1: HTTP Forwarding) +

+

+ Calls app2's incrementCount action via HTTP forwarding through app1 +

+ +
+ + + + Count: {count} + +
+ + {error && ( +

+ Error: {error} +

+ )} + +

+ Action flows: Client → app1 server → HTTP forward → app2 server → + execute +

+
+ ); +} diff --git a/apps/rsc-demo/packages/app1/src/FederatedDemo.server.js b/apps/rsc-demo/packages/app1/src/FederatedDemo.server.js new file mode 100644 index 00000000000..916c68f38d4 --- /dev/null +++ b/apps/rsc-demo/packages/app1/src/FederatedDemo.server.js @@ -0,0 +1,96 @@ +/** + * FederatedDemo.server.js - Server Component that imports federated modules from app2 + * + * This demonstrates SERVER-SIDE Module Federation: + * - app1's RSC server imports components from app2's MF container (app2-remote.js) + * - The imported components render server-side in app1's RSC stream + * - React/RSDW are shared via 'rsc' shareScope (singleton) + * + * For 'use client' components from app2: + * - They serialize to client references ($L) in the RSC payload + * - The actual component code is loaded by app1's client via client-side federation + * + * For server components from app2: + * - They execute in app1's RSC server and render their output inline + * + * TODO (Option 2 - Deep MF Integration for Server Actions): + * To invoke app2's server actions via MF (not HTTP forwarding): + * 1. The remote 'use server' module would need to register with app1's serverActionRegistry + * 2. The action ID would need to be in app1's react-server-actions-manifest.json + * 3. Changes required in: + * - packages/react-server-dom-webpack/cjs/rsc-server-loader.js + * - packages/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js + * - packages/react-server-dom-webpack/server.node.js + * Currently, cross-app actions use HTTP forwarding (Option 1) instead. + */ + +import React from 'react'; + +/** + * FederatedDemo.server.js - Server Component demonstrating server-side federation concepts + * + * IMPORTANT: Server-side federation of 'use client' components requires additional work: + * - The RSC server needs to serialize 'use client' components as client references ($L) + * - The client manifest (react-client-manifest.json) must include the remote component + * - Currently, app1's manifest only knows about app1's components, not app2's + * + * For full server-side federation of 'use client' components, we would need to: + * 1. Merge app2's client manifest into app1's at build time, OR + * 2. Have app1's RSC server dynamically load and merge app2's client manifest + * + * For now, this component demonstrates the CONCEPT of server-side federation + * without actually importing 'use client' components from app2. + * + * What DOES work for server-side federation: + * - Pure server components from app2 (no 'use client' directive) + * - Server actions via HTTP forwarding (Option 1) + * - The FederatedActionDemo client component handles client-side federation + * + * TODO (Option 2 - Deep MF Integration): + * To fully support server-side federation of 'use client' components: + * 1. Modify webpack build to merge remote client manifests + * 2. Ensure action IDs from remotes are included in host manifest + * 3. Changes needed in packages/react-server-dom-webpack/: + * - plugin to merge remote manifests + * - loader to handle remote client references + */ +export default function FederatedDemo() { + return ( +
+

+ Server-Side Federation Demo +

+

+ This server component demonstrates the architecture for server-side MF. +

+
+ Current Status: +
    +
  • Server components: Ready (pure RSC from remotes)
  • +
  • Client components: Via client-side MF (see RemoteButton)
  • +
  • Server actions: Via HTTP forwarding (see FederatedActionDemo)
  • +
+
+

+ Full 'use client' federation requires manifest merging (TODO) +

+
+ ); +} diff --git a/apps/rsc-demo/packages/app1/src/InlineActionButton.js b/apps/rsc-demo/packages/app1/src/InlineActionButton.js new file mode 100644 index 00000000000..24f40987ef8 --- /dev/null +++ b/apps/rsc-demo/packages/app1/src/InlineActionButton.js @@ -0,0 +1,93 @@ +'use client'; + +import React, {useState} from 'react'; + +export default function InlineActionButton({ + addMessage, + clearMessages, + getMessageCount, +}) { + const [message, setMessage] = useState(''); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(false); + const [lastResult, setLastResult] = useState('Last action result: 0 message'); + + async function handleAdd(e) { + e.preventDefault(); + if (!message.trim()) return; + + setLoading(true); + try { + // Give the UI a moment to show the loading label + await new Promise((r) => setTimeout(r, 50)); + const newCount = await addMessage(message); + const value = typeof newCount === 'number' ? newCount : (count ?? 0) + 1; + setCount(value); + setLastResult(`Last action result: ${value} message`); + setMessage(''); + } catch (error) { + console.error('Failed to add message:', error); + } finally { + setLoading(false); + } + } + + async function handleClear() { + setLoading(true); + try { + const newCount = await clearMessages(); + const value = typeof newCount === 'number' ? newCount : 0; + setCount(value); + setLastResult(`Last action result: ${value} message`); + } catch (error) { + console.error('Failed to clear messages:', error); + } finally { + setLoading(false); + } + } + + async function handleGetCount() { + setLoading(true); + try { + const currentCount = await getMessageCount(); + const value = + typeof currentCount === 'number' ? currentCount : (count ?? 0); + setCount(value); + setLastResult(`Last action result: ${value} message`); + } catch (error) { + console.error('Failed to get count:', error); + } finally { + setLoading(false); + } + } + + return ( +
+
+ setMessage(e.target.value)} + placeholder="Enter a message" + disabled={loading} + style={{flex: 1, padding: 8}} + /> + +
+
+ + +
+

{lastResult}

+
+ ); +} diff --git a/apps/rsc-demo/packages/app1/src/InlineActionDemo.server.js b/apps/rsc-demo/packages/app1/src/InlineActionDemo.server.js new file mode 100644 index 00000000000..713594d88a7 --- /dev/null +++ b/apps/rsc-demo/packages/app1/src/InlineActionDemo.server.js @@ -0,0 +1,37 @@ +import React from 'react'; +import InlineActionButton from './InlineActionButton'; +import { + addMessage, + clearMessages, + getMessageCount, + getMessagesSnapshot, +} from './inline-actions.server'; + +export default async function InlineActionDemo() { + const snapshot = await getMessagesSnapshot(); + + return ( +
+

Inline Server Action Demo

+

This demonstrates server actions used from a Server Component.

+

Current message count: {snapshot.count}

+
    + {snapshot.messages.map((msg, i) => ( +
  • {msg}
  • + ))} +
+ +
+ ); +} diff --git a/apps/rsc-demo/packages/app1/src/Note.js b/apps/rsc-demo/packages/app1/src/Note.js new file mode 100644 index 00000000000..990cf52c0f1 --- /dev/null +++ b/apps/rsc-demo/packages/app1/src/Note.js @@ -0,0 +1,66 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {format} from 'date-fns'; + +// Uncomment if you want to read from a file instead. +// import {readFile} from 'fs/promises'; +// import {resolve} from 'path'; + +import NotePreview from './NotePreview'; +import EditButton from './EditButton'; +import NoteEditor from './NoteEditor'; + +export default async function Note({selectedId, isEditing}) { + if (selectedId === null) { + if (isEditing) { + return ( + + ); + } else { + return ( +
+ + Click a note on the left to view something! 🥺 + +
+ ); + } + } + + const noteResponse = await fetch(`http://localhost:4000/notes/${selectedId}`); + const note = await noteResponse.json(); + + let {id, title, body, updated_at} = note; + const updatedAt = new Date(updated_at); + + // We could also read from a file instead. + // body = await readFile(resolve(`./notes/${note.id}.md`), 'utf8'); + + // Now let's see how the Suspense boundary above lets us not block on this. + // await fetch('http://localhost:4000/sleep/3000'); + + if (isEditing) { + return ; + } else { + return ( +
+
+

{title}

+
+ + Last updated on {format(updatedAt, "d MMM yyyy 'at' h:mm bb")} + + Edit +
+
+ +
+ ); + } +} diff --git a/apps/rsc-demo/packages/app1/src/NoteEditor.js b/apps/rsc-demo/packages/app1/src/NoteEditor.js new file mode 100644 index 00000000000..74c079e822b --- /dev/null +++ b/apps/rsc-demo/packages/app1/src/NoteEditor.js @@ -0,0 +1,123 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use client'; + +import {useState, useTransition} from 'react'; +import {useRouter, useMutation} from './framework/router'; + +import NotePreview from './NotePreview'; + +export default function NoteEditor({noteId, initialTitle, initialBody}) { + const [title, setTitle] = useState(initialTitle); + const [body, setBody] = useState(initialBody); + const {location} = useRouter(); + const [isNavigating, startNavigating] = useTransition(); + const [isSaving, saveNote] = useMutation({ + endpoint: noteId !== null ? `/notes/${noteId}` : `/notes`, + method: noteId !== null ? 'PUT' : 'POST', + }); + const [isDeleting, deleteNote] = useMutation({ + endpoint: `/notes/${noteId}`, + method: 'DELETE', + }); + + async function handleSave() { + const payload = {title, body}; + const requestedLocation = { + selectedId: noteId, + isEditing: false, + searchText: location.searchText, + }; + await saveNote(payload, requestedLocation); + } + + async function handleDelete() { + const payload = {}; + const requestedLocation = { + selectedId: null, + isEditing: false, + searchText: location.searchText, + }; + await deleteNote(payload, requestedLocation); + } + + const isDraft = noteId === null; + return ( +
+
e.preventDefault()} + > + + { + setTitle(e.target.value); + }} + /> + +