├── .changeset └── config.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── ci.yml │ ├── deployment.yml │ └── release.yml ├── .gitignore ├── .node-version ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── apps ├── aws-app │ ├── .swcrc │ ├── cdk │ │ ├── app.ts │ │ ├── env.ts │ │ └── stack.ts │ ├── dev-server │ │ ├── auth-middleware.ts │ │ ├── run.ts │ │ └── stub-awslambda.ts │ ├── package.json │ ├── src │ │ ├── client.tsx │ │ └── handler │ │ │ ├── app.tsx │ │ │ ├── auth-middleware.ts │ │ │ ├── index.tsx │ │ │ ├── logger-middleware.ts │ │ │ └── manifests.js │ ├── tailwind.config.cjs │ ├── tsconfig.json │ ├── turbo.json │ ├── types │ │ └── aws-lambda.d.ts │ └── webpack.config.js ├── cloudflare-app │ ├── .swcrc │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── client.tsx │ │ └── worker │ │ │ ├── app.tsx │ │ │ ├── index.tsx │ │ │ ├── manifests.js │ │ │ ├── worker.test.ts │ │ │ └── wrangler.toml │ ├── tailwind.config.cjs │ ├── tsconfig.json │ ├── turbo.json │ └── webpack.config.js ├── shared-app │ ├── package.json │ ├── src │ │ ├── client │ │ │ ├── button.tsx │ │ │ ├── countries-search.tsx │ │ │ ├── navigation-container.tsx │ │ │ └── product.tsx │ │ ├── server │ │ │ ├── app.tsx │ │ │ ├── buy.ts │ │ │ ├── countries-fuse.ts │ │ │ ├── countries-list.tsx │ │ │ ├── fast-page.tsx │ │ │ ├── hello.tsx │ │ │ ├── home-page.tsx │ │ │ ├── markdown.tsx │ │ │ ├── routes.tsx │ │ │ ├── slow-page.tsx │ │ │ ├── suspended.tsx │ │ │ ├── track-click.ts │ │ │ └── wait.ts │ │ └── shared │ │ │ ├── main.tsx │ │ │ ├── navigation-item.tsx │ │ │ ├── navigation.tsx │ │ │ └── notification.tsx │ ├── static │ │ ├── favicon.ico │ │ └── github-mark-white.svg │ ├── tailwind.config.cjs │ └── tsconfig.json └── vercel-app │ ├── .swcrc │ ├── dev-server │ ├── build-options.ts │ ├── build.ts │ ├── run.ts │ ├── transform-headers.ts │ └── watch.ts │ ├── package.json │ ├── src │ ├── client.tsx │ ├── config.json │ ├── edge-function-handler │ │ ├── .vc-config.json │ │ ├── app.tsx │ │ ├── index.tsx │ │ └── manifests.js │ └── vitals.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ ├── turbo.json │ └── webpack.config.js ├── package-lock.json ├── package.json ├── packages ├── core │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── client │ │ │ ├── browser.ts │ │ │ ├── call-server-error.ts │ │ │ ├── call-server.ts │ │ │ ├── create-simple-promise-cache.ts │ │ │ ├── hydrate-app.tsx │ │ │ ├── index.ts │ │ │ ├── link.tsx │ │ │ ├── router-location-utils.ts │ │ │ ├── router.tsx │ │ │ ├── use-router-location.ts │ │ │ └── use-router.ts │ │ ├── global.d.ts │ │ ├── server │ │ │ ├── rsc │ │ │ │ ├── create-rsc-action-stream.ts │ │ │ │ ├── create-rsc-app-stream.tsx │ │ │ │ ├── create-rsc-form-state.ts │ │ │ │ └── index.ts │ │ │ ├── shared │ │ │ │ ├── router-location-async-local-storage.ts │ │ │ │ └── use-router-location.ts │ │ │ └── ssr │ │ │ │ ├── create-html-stream.tsx │ │ │ │ ├── create-initial-rsc-response-transform-stream.ts │ │ │ │ ├── index.ts │ │ │ │ ├── next-macro-task.ts │ │ │ │ └── node-compat-environment.ts │ │ └── use-router-location.ts │ └── tsconfig.json └── webpack-rsc │ ├── .swcrc │ ├── CHANGELOG.md │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ ├── __fixtures__ │ │ ├── client-component-with-server-action.js │ │ ├── client-component.js │ │ ├── client-components-shared-dependency │ │ │ ├── client-component-1.js │ │ │ └── client-component-2.js │ │ ├── client-components.js │ │ ├── client-entry.js │ │ ├── foo.json │ │ ├── import-assertions.js │ │ ├── import-attributes.js │ │ ├── main-component.js │ │ ├── main.js │ │ ├── rsc.js │ │ ├── server-component.js │ │ ├── server-function-imported-from-client.js │ │ ├── server-function-passed-from-server.js │ │ ├── server-functions-inline-directive.js │ │ └── server-functions.js │ ├── index.ts │ ├── webpack-rsc-client-loader.cts │ ├── webpack-rsc-client-loader.test.ts │ ├── webpack-rsc-client-plugin.test.ts │ ├── webpack-rsc-client-plugin.ts │ ├── webpack-rsc-server-loader.cts │ ├── webpack-rsc-server-loader.test.ts │ ├── webpack-rsc-server-plugin.test.ts │ ├── webpack-rsc-server-plugin.ts │ ├── webpack-rsc-ssr-loader.cts │ └── webpack-rsc-ssr-loader.test.ts │ └── tsconfig.json ├── tsconfig.base.json ├── tsconfig.json ├── turbo.json └── types ├── react-dom-server.d.ts ├── react-dom-server.edge.d.ts ├── react-server-dom-webpack-client.browser.d.ts ├── react-server-dom-webpack-client.edge.d.ts ├── react-server-dom-webpack-server.d.ts ├── react-server-dom-webpack.d.ts └── wrangler.d.ts /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | lib 4 | manifests.js 5 | cdk.out 6 | packages/webpack-rsc/src/__fixtures__/import-attributes.js 7 | packages/webpack-rsc/src/__fixtures__/import-assertions.js 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "extends": ["prettier"], 4 | "overrides": [ 5 | { 6 | "files": ["**/*.cjs"], 7 | "parserOptions": { 8 | "sourceType": "script" 9 | }, 10 | "rules": { 11 | "import/extensions": "off", 12 | "import/no-commonjs": "off", 13 | "no-restricted-globals": "off" 14 | } 15 | }, 16 | { 17 | "files": ["**/*.mjs"], 18 | "parserOptions": { 19 | "sourceType": "module" 20 | } 21 | }, 22 | { 23 | "files": ["**/*.md"], 24 | "processor": "markdown/markdown" 25 | }, 26 | { 27 | "files": ["**/*.md/*.js", "**/*.md/*.jsx"], 28 | "rules": { 29 | "quotes": ["error", "single"] 30 | } 31 | }, 32 | { 33 | "files": ["**/*.cts", "**/*.ts", "**/*.tsx"], 34 | "parser": "@typescript-eslint/parser", 35 | "parserOptions": { 36 | "EXPERIMENTAL_useProjectService": { 37 | // https://github.com/typescript-eslint/typescript-eslint/issues/9032 38 | "maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING": 1000 39 | }, 40 | "EXPERIMENTAL_useSourceOfProjectReferenceRedirect": true 41 | }, 42 | "rules": { 43 | "@typescript-eslint/await-thenable": "error", 44 | "@typescript-eslint/consistent-type-imports": [ 45 | "error", 46 | {"prefer": "type-imports"} 47 | ], 48 | "@typescript-eslint/explicit-module-boundary-types": [ 49 | "error", 50 | {"allowDirectConstAssertionInArrowFunctions": true} 51 | ], 52 | "@typescript-eslint/no-floating-promises": "error", 53 | "@typescript-eslint/no-require-imports": "error", 54 | "@typescript-eslint/no-shadow": ["error", {"hoist": "all"}], 55 | "@typescript-eslint/promise-function-async": "error", 56 | "@typescript-eslint/quotes": ["error", "backtick"], 57 | "@typescript-eslint/require-await": "error", 58 | "no-shadow": "off", 59 | "quotes": "off" 60 | } 61 | }, 62 | { 63 | "files": ["**/*.cts"], 64 | "rules": { 65 | "@typescript-eslint/no-require-imports": "off", 66 | "no-require-imports": "off", 67 | "import/extensions": "off", 68 | "import/no-commonjs": "off", 69 | "no-restricted-globals": "off" 70 | } 71 | } 72 | ], 73 | "parserOptions": { 74 | "ecmaFeatures": {"jsx": true}, 75 | "ecmaVersion": "latest", 76 | "sourceType": "module" 77 | }, 78 | "plugins": ["import", "markdown", "@typescript-eslint"], 79 | "root": true, 80 | "rules": { 81 | "complexity": "error", 82 | "curly": "error", 83 | "eqeqeq": ["error", "always", {"null": "ignore"}], 84 | "import/extensions": ["error", "always", {"ignorePackages": true}], 85 | "import/no-commonjs": "error", 86 | "import/no-duplicates": ["error", {"considerQueryString": true}], 87 | "import/no-extraneous-dependencies": "error", 88 | "import/order": [ 89 | "error", 90 | { 91 | "alphabetize": {"order": "asc"}, 92 | "newlines-between": "never", 93 | "warnOnUnassignedImports": true 94 | } 95 | ], 96 | "no-restricted-globals": [ 97 | "error", 98 | { 99 | "message": "Use \"dirname(fileURLToPath(import.meta.url))\" instead.", 100 | "name": "__dirname" 101 | }, 102 | { 103 | "message": "Use \"fileURLToPath(import.meta.url)\" instead.", 104 | "name": "__filename" 105 | } 106 | ], 107 | "no-shadow": "error", 108 | "object-shorthand": "error", 109 | "prefer-const": "error", 110 | "quotes": ["error", "backtick"], 111 | "sort-imports": [ 112 | "error", 113 | {"ignoreDeclarationSort": true, "ignoreMemberSort": false} 114 | ] 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | compile: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | - name: Setup Node.js 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version-file: '.node-version' 15 | cache: 'npm' 16 | - name: Install 17 | run: npm ci --no-audit --no-fund 18 | - name: Compile 19 | run: npm run compile 20 | lint: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version-file: '.node-version' 29 | cache: 'npm' 30 | - name: Install 31 | run: npm ci --no-audit --no-fund 32 | - name: Lint 33 | run: npm run lint 34 | format: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v4 39 | - name: Setup Node.js 40 | uses: actions/setup-node@v4 41 | with: 42 | node-version-file: '.node-version' 43 | cache: 'npm' 44 | - name: Install 45 | run: npm ci --no-audit --no-fund 46 | - name: Check Formatting 47 | run: npm run format:check 48 | test: 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Checkout 52 | uses: actions/checkout@v4 53 | - name: Setup Node.js 54 | uses: actions/setup-node@v4 55 | with: 56 | node-version-file: '.node-version' 57 | cache: 'npm' 58 | - name: Install 59 | run: npm ci --no-audit --no-fund 60 | - name: Test 61 | run: npm test 62 | -------------------------------------------------------------------------------- /.github/workflows/deployment.yml: -------------------------------------------------------------------------------- 1 | name: Deployment 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy-cloudflare: 10 | runs-on: ubuntu-latest 11 | environment: Cloudflare Workers 12 | concurrency: 13 | group: deploy-cloudflare 14 | cancel-in-progress: false 15 | env: 16 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version-file: '.node-version' 24 | cache: 'npm' 25 | - name: Install 26 | run: npm ci --no-audit --no-fund 27 | - name: Test 28 | run: npm t 29 | - name: Deploy 30 | run: npm run deploy -- -F cloudflare-app 31 | 32 | deploy-aws: 33 | runs-on: ubuntu-latest 34 | environment: AWS 35 | concurrency: 36 | group: deploy-aws 37 | cancel-in-progress: false 38 | env: 39 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 40 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 41 | AWS_HANDLER_VERIFY_HEADER: ${{ secrets.AWS_HANDLER_VERIFY_HEADER }} 42 | AWS_REGION: ${{ vars.AWS_REGION }} 43 | steps: 44 | - name: Checkout 45 | uses: actions/checkout@v4 46 | - name: Setup Node.js 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version-file: '.node-version' 50 | cache: 'npm' 51 | - name: Install 52 | run: npm ci --no-audit --no-fund 53 | - name: Test 54 | run: npm t 55 | - name: Deploy 56 | run: npm run deploy -- -F aws-app -- --require-approval never 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | permissions: write-all 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version-file: '.node-version' 23 | cache: 'npm' 24 | 25 | - name: Install 26 | run: npm ci --no-audit --no-fund 27 | 28 | - name: Setup npm Auth 29 | run: 30 | echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> "$HOME/.npmrc" 31 | env: 32 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | 34 | - name: Create Release Pull Request or Publish to npm 35 | id: changesets 36 | uses: changesets/action@v1 37 | with: 38 | version: npm run version 39 | publish: npm run release 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | dist 4 | lib 5 | tsconfig.tsbuildinfo 6 | .turbo 7 | .vercel 8 | .wrangler 9 | cdk.out 10 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | package-lock.json 3 | dist 4 | lib 5 | **/.vercel/output 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "printWidth": 80, 4 | "proseWrap": "always", 5 | "quoteProps": "consistent", 6 | "singleQuote": true, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "editorconfig.editorconfig", 4 | "dbaeumer.vscode-eslint", 5 | "eg2.vscode-npm-script", 6 | "esbenp.prettier-vscode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[css]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[html]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[javascript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[javascriptreact]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "[json]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode" 16 | }, 17 | "[typescript]": { 18 | "editor.defaultFormatter": "esbenp.prettier-vscode" 19 | }, 20 | "[typescriptreact]": { 21 | "editor.defaultFormatter": "esbenp.prettier-vscode" 22 | }, 23 | "[yaml]": { 24 | "editor.defaultFormatter": "esbenp.prettier-vscode" 25 | }, 26 | "editor.codeActionsOnSave": { 27 | "source.addMissingImports": "explicit", 28 | "source.fixAll.eslint": "explicit" 29 | }, 30 | "editor.formatOnSave": true, 31 | "editor.rulers": [80], 32 | "typescript.tsdk": "node_modules/typescript/lib" 33 | } 34 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "compile", 6 | "command": "npx", 7 | "args": ["tsc", "--build", "--watch"], 8 | "isBackground": true, 9 | "group": { 10 | "kind": "build", 11 | "isDefault": true 12 | }, 13 | "problemMatcher": ["$tsc-watch"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Hendrik Liebau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MFNG 2 | 3 | ⚗️ A Minimal React Server Components Bundler & Library 4 | 5 | ## Packages 6 | 7 | MFNG offers two packages that together enable the building of a production-ready 8 | RSC app. 9 | 10 | ### `@mfng/core` 11 | 12 | This package contains the essential building blocks required on both the server 13 | and the client to create a streaming, server-side rendered, and properly 14 | hydrated RSC app. It also provides utilities that are needed for server-centric, 15 | client-side navigation. 16 | 17 | ↪ 18 | [Documentation](https://github.com/unstubbable/mfng/blob/main/packages/core/README.md) 19 | 20 | ### `@mfng/webpack-rsc` 21 | 22 | This package provides a set of Webpack loaders and plugins required for building 23 | an RSC application bundle for the browser, as well as for the server. The server 24 | bundle can be deployed to any serverless, edge, or Node.js-based environment. 25 | `@mfng/webpack-rsc` can be used standalone as an RSC bundling solution or in 26 | conjunction with `@mfng/core`. 27 | 28 | ↪ 29 | [Documentation](https://github.com/unstubbable/mfng/blob/main/packages/webpack-rsc/README.md) 30 | 31 | ## Features 32 | 33 | - [x] React server components 34 | - [x] Server-side rendering 35 | - [x] Client components, lazily loaded as separate chunks 36 | - [x] Server actions 37 | - [x] passed as props from the server to the client 38 | - [x] imported from the client 39 | - [x] top-level functions/closures with inline `'use server'` directive 40 | - [ ] auto-binding of closed-over variables (not planned) 41 | - [x] Progressively enhanced form actions 42 | - [x] Suspensy routing 43 | - [x] Production builds 44 | - [x] Development server 45 | - [x] Serverless deployment examples 46 | - using a [Cloudflare Worker](https://workers.cloudflare.com) 47 | - using a 48 | [Vercel Edge Function](https://vercel.com/docs/functions/edge-functions) 49 | - using an [AWS Lambda Function](https://aws.amazon.com/lambda/), with 50 | [AWS CloudFront](https://aws.amazon.com/cloudfront/) as CDN 51 | - [x] Support for 52 | [poisoned imports](https://github.com/reactjs/rfcs/blob/main/text/0227-server-module-conventions.md#poisoned-imports) 53 | - [x] Support for the [Vercel AI SDK](https://sdk.vercel.ai/docs) 54 | - [ ] Partial prerendering 55 | - [ ] Advanced routing 56 | 57 | ## Name Origin 58 | 59 | The name MFNG stands for "Microfrontends Next Generation". The project was 60 | originally motivated by the following questions: 61 | 62 | > Do we still need to deploy microfrontends and their APIs (also known as BFFs – 63 | > Backends for Frontends) independently of the main app? Or can we integrate 64 | > them at build-time, though dynamically composed at run-time, and allow them to 65 | > use server components to fetch their data on the server? 66 | 67 | It has since evolved into a general-purpose RSC library, not specifically 68 | targeted at the microfrontends use case... until we explore some form of 69 | federation integration, as pioneered by 70 | [federated-rsc](https://github.com/jacob-ebey/federated-rsc/), perhaps. 71 | -------------------------------------------------------------------------------- /apps/aws-app/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "jsc": {"parser": {"syntax": "typescript", "tsx": true}}, 4 | "sourceMaps": true 5 | } 6 | -------------------------------------------------------------------------------- /apps/aws-app/cdk/app.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import './env.js'; 3 | import {Stack} from './stack.js'; 4 | 5 | const app = new cdk.App(); 6 | 7 | new Stack(app, `mfng-app`, { 8 | env: { 9 | account: process.env.CDK_DEFAULT_ACCOUNT, 10 | // A certificate for CloudFront must be created in the US East (N. Virginia) 11 | // Region, us-east-1. 12 | region: `us-east-1`, 13 | }, 14 | bucketName: `mfng-app-assets`, 15 | customDomain: {domainName: `strict.software`, subdomainName: `mfng`}, 16 | }); 17 | -------------------------------------------------------------------------------- /apps/aws-app/cdk/env.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | 3 | declare global { 4 | namespace NodeJS { 5 | interface ProcessEnv extends z.infer {} 6 | } 7 | } 8 | 9 | const envVariables = z.object({ 10 | AWS_ACCESS_KEY_ID: z.string(), 11 | AWS_HANDLER_VERIFY_HEADER: z.string(), 12 | AWS_REGION: z.string(), 13 | AWS_SECRET_ACCESS_KEY: z.string(), 14 | }); 15 | 16 | envVariables.parse(process.env); 17 | -------------------------------------------------------------------------------- /apps/aws-app/cdk/stack.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import * as cdk from 'aws-cdk-lib'; 3 | import type {Construct} from 'constructs'; 4 | 5 | const distDirname = path.join(import.meta.dirname, `../dist/`); 6 | 7 | export interface StackProps extends cdk.StackProps { 8 | readonly bucketName: string; 9 | readonly customDomain?: { 10 | readonly domainName: string; 11 | readonly subdomainName: string; 12 | }; 13 | } 14 | 15 | export class Stack extends cdk.Stack { 16 | constructor(scope: Construct, id: string, props: StackProps) { 17 | const {bucketName, customDomain, ...otherProps} = props; 18 | super(scope, id, otherProps); 19 | 20 | const lambdaFunction = new cdk.aws_lambda_nodejs.NodejsFunction( 21 | this, 22 | `function`, 23 | { 24 | entry: path.join(distDirname, `handler/index.js`), 25 | runtime: cdk.aws_lambda.Runtime.NODEJS_20_X, 26 | bundling: {format: cdk.aws_lambda_nodejs.OutputFormat.ESM}, 27 | timeout: cdk.Duration.minutes(1), 28 | environment: { 29 | AWS_HANDLER_VERIFY_HEADER: process.env.AWS_HANDLER_VERIFY_HEADER, 30 | }, 31 | memorySize: 1769, // equivalent of one vCPU 32 | }, 33 | ); 34 | 35 | const functionUrl = new cdk.aws_lambda.FunctionUrl(this, `function-url`, { 36 | function: lambdaFunction, 37 | authType: cdk.aws_lambda.FunctionUrlAuthType.NONE, 38 | invokeMode: cdk.aws_lambda.InvokeMode.RESPONSE_STREAM, 39 | }); 40 | 41 | const bucket = new cdk.aws_s3.Bucket(this, `assets-bucket`, { 42 | bucketName, 43 | removalPolicy: cdk.RemovalPolicy.DESTROY, 44 | autoDeleteObjects: true, 45 | }); 46 | 47 | const customDomainName = 48 | customDomain && 49 | `${customDomain.subdomainName}.${customDomain.domainName}`; 50 | 51 | const hostedZone = 52 | customDomain && 53 | cdk.aws_route53.HostedZone.fromLookup(this, `hosted-zone-lookup`, { 54 | domainName: customDomain.domainName, 55 | }); 56 | 57 | const distribution = new cdk.aws_cloudfront.Distribution(this, `cdn`, { 58 | certificate: 59 | customDomainName && hostedZone 60 | ? new cdk.aws_certificatemanager.Certificate(this, `certificate`, { 61 | domainName: customDomainName, 62 | validation: 63 | cdk.aws_certificatemanager.CertificateValidation.fromDns( 64 | hostedZone, 65 | ), 66 | }) 67 | : undefined, 68 | domainNames: customDomainName ? [customDomainName] : undefined, 69 | defaultBehavior: { 70 | origin: new cdk.aws_cloudfront_origins.FunctionUrlOrigin(functionUrl, { 71 | customHeaders: { 72 | 'X-Origin-Verify': process.env.AWS_HANDLER_VERIFY_HEADER, 73 | }, 74 | }), 75 | allowedMethods: cdk.aws_cloudfront.AllowedMethods.ALLOW_ALL, 76 | cachePolicy: new cdk.aws_cloudfront.CachePolicy(this, `cache-policy`, { 77 | enableAcceptEncodingGzip: true, 78 | enableAcceptEncodingBrotli: true, 79 | queryStringBehavior: 80 | cdk.aws_cloudfront.CacheQueryStringBehavior.all(), 81 | headerBehavior: 82 | cdk.aws_cloudfront.CacheHeaderBehavior.allowList(`accept`), 83 | }), 84 | originRequestPolicy: 85 | cdk.aws_cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER, 86 | viewerProtocolPolicy: 87 | cdk.aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, 88 | responseHeadersPolicy: new cdk.aws_cloudfront.ResponseHeadersPolicy( 89 | this, 90 | `response-headers-policy`, 91 | { 92 | securityHeadersBehavior: { 93 | frameOptions: { 94 | frameOption: cdk.aws_cloudfront.HeadersFrameOption.DENY, 95 | override: true, 96 | }, 97 | strictTransportSecurity: { 98 | accessControlMaxAge: cdk.Duration.days(365), 99 | includeSubdomains: true, 100 | override: true, 101 | }, 102 | }, 103 | }, 104 | ), 105 | }, 106 | additionalBehaviors: { 107 | '/client/*': { 108 | origin: new cdk.aws_cloudfront_origins.S3Origin(bucket), 109 | cachePolicy: cdk.aws_cloudfront.CachePolicy.CACHING_OPTIMIZED, 110 | viewerProtocolPolicy: 111 | cdk.aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, 112 | }, 113 | }, 114 | priceClass: cdk.aws_cloudfront.PriceClass.PRICE_CLASS_100, 115 | }); 116 | 117 | if (customDomain && hostedZone) { 118 | new cdk.aws_route53.ARecord(this, `a-record`, { 119 | zone: hostedZone, 120 | recordName: customDomain.subdomainName, 121 | target: cdk.aws_route53.RecordTarget.fromAlias( 122 | new cdk.aws_route53_targets.CloudFrontTarget(distribution), 123 | ), 124 | }); 125 | 126 | new cdk.aws_route53.AaaaRecord(this, `aaaa-record`, { 127 | zone: hostedZone, 128 | recordName: customDomain.subdomainName, 129 | target: cdk.aws_route53.RecordTarget.fromAlias( 130 | new cdk.aws_route53_targets.CloudFrontTarget(distribution), 131 | ), 132 | }); 133 | } 134 | 135 | new cdk.aws_s3_deployment.BucketDeployment(this, `assets-deployment`, { 136 | destinationBucket: bucket, 137 | destinationKeyPrefix: `client`, 138 | sources: [ 139 | cdk.aws_s3_deployment.Source.asset( 140 | path.join(distDirname, `static/client`), 141 | ), 142 | ], 143 | distribution, 144 | distributionPaths: [`/client/*`], 145 | cacheControl: [ 146 | cdk.aws_s3_deployment.CacheControl.setPublic(), 147 | cdk.aws_s3_deployment.CacheControl.maxAge(cdk.Duration.days(365)), 148 | cdk.aws_s3_deployment.CacheControl.immutable(), 149 | ], 150 | }); 151 | 152 | new cdk.CfnOutput(this, `function-url-output`, { 153 | value: functionUrl.url, 154 | }); 155 | 156 | new cdk.CfnOutput(this, `cdn-cloudfront-url-output`, { 157 | value: `https://${distribution.domainName}`, 158 | }); 159 | 160 | if (customDomainName) { 161 | new cdk.CfnOutput(this, `cdn-custom-domain-url-output`, { 162 | value: `https://${customDomainName}`, 163 | }); 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /apps/aws-app/dev-server/auth-middleware.ts: -------------------------------------------------------------------------------- 1 | import type {MiddlewareHandler} from 'hono'; 2 | 3 | const verifyHeader = process.env.AWS_HANDLER_VERIFY_HEADER; 4 | 5 | export const authMiddleware: MiddlewareHandler = async (context, next) => { 6 | if (verifyHeader) { 7 | context.req.raw.headers.set(`X-Origin-Verify`, verifyHeader); 8 | } else { 9 | console.warn(`process.env.AWS_HANDLER_VERIFY_HEADER is undefined`); 10 | } 11 | 12 | return next(); 13 | }; 14 | -------------------------------------------------------------------------------- /apps/aws-app/dev-server/run.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import url from 'url'; 4 | import {serve} from '@hono/node-server'; 5 | import {serveStatic} from '@hono/node-server/serve-static'; 6 | import {Hono} from 'hono'; 7 | import {authMiddleware} from './auth-middleware.js'; 8 | import './stub-awslambda.js'; 9 | 10 | // @ts-ignore 11 | const handlerModule = await import(`../dist/handler/index.js`); 12 | const {app: handlerApp} = handlerModule as {app: Hono}; 13 | const app = new Hono(); 14 | 15 | app.use(authMiddleware); 16 | app.use(`/client/*`, serveStatic({root: `dist/static`})); 17 | 18 | app.get(`/source-maps`, async (context) => { 19 | const filenameQueryParam = context.req.query(`filename`); 20 | 21 | if (!filenameQueryParam) { 22 | return context.newResponse(`Missing query parameter "filename"`, 400); 23 | } 24 | 25 | const filename = filenameQueryParam.startsWith(`file://`) 26 | ? url.fileURLToPath(filenameQueryParam) 27 | : path.join(import.meta.dirname, `../dist/static`, filenameQueryParam); 28 | 29 | try { 30 | const sourceMapFilename = `${filename}.map`; 31 | const sourceMapContents = await fs.readFile(sourceMapFilename); 32 | 33 | return context.newResponse(sourceMapContents); 34 | } catch (error) { 35 | console.error(error); 36 | return context.newResponse(null, 404); 37 | } 38 | }); 39 | 40 | app.route(`/`, handlerApp); 41 | 42 | const server = serve({fetch: app.fetch, port: 3002}, ({address, port}) => { 43 | const serverUrl = `http://${address.replace(`0.0.0.0`, `localhost`)}:${port}`; 44 | 45 | return console.log(`Started dev server at ${serverUrl}`); 46 | }); 47 | 48 | process.on(`SIGINT`, () => { 49 | console.log(`Closing dev server`); 50 | 51 | server.close((error) => { 52 | if (error) { 53 | console.error(error); 54 | process.exit(1); 55 | } 56 | 57 | console.log(`Dev server closed`); 58 | process.exit(0); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /apps/aws-app/dev-server/stub-awslambda.ts: -------------------------------------------------------------------------------- 1 | // The global awslambda namespace can be stubbed, since it's not needed in the 2 | // dev server. Instead, the dev server consumes the handler app directly. 3 | global.awslambda = { 4 | streamifyResponse: (handler) => handler, 5 | // @ts-expect-error 6 | HttpResponseStream: undefined, 7 | }; 8 | -------------------------------------------------------------------------------- /apps/aws-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mfng/aws-app", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "author": "Hendrik Liebau ", 7 | "type": "module", 8 | "scripts": { 9 | "build": "NODE_OPTIONS='--import=tsx --conditions=@mfng:internal' webpack --mode production", 10 | "build:dev": "NODE_OPTIONS='--import=tsx --conditions=@mfng:internal' webpack --mode development", 11 | "predeploy": "cdk bootstrap --app 'tsx cdk/app.ts'", 12 | "deploy": "cdk diff --app 'tsx cdk/app.ts' && cdk deploy --app 'tsx cdk/app.ts' --all", 13 | "dev": "npm start", 14 | "start": "tsx watch --clear-screen=false --enable-source-maps --inspect dev-server/run.ts", 15 | "watch": "NODE_OPTIONS='--import=tsx --conditions=@mfng:internal' webpack --mode production --watch", 16 | "watch:dev": "NODE_OPTIONS='--import=tsx --conditions=@mfng:internal' webpack --mode development --watch" 17 | }, 18 | "dependencies": { 19 | "@mfng/core": "*", 20 | "@mfng/shared-app": "*", 21 | "react": "0.0.0-experimental-778e1ed2-20240926", 22 | "react-dom": "0.0.0-experimental-778e1ed2-20240926" 23 | }, 24 | "devDependencies": { 25 | "@hono/node-server": "^1.8.2", 26 | "@mfng/webpack-rsc": "*", 27 | "@swc/core": "^1.3.22", 28 | "@types/aws-lambda": "^8.10.136", 29 | "@types/react": "^18.3.2", 30 | "@types/react-dom": "^18.3.0", 31 | "autoprefixer": "^10.4.14", 32 | "aws-cdk": "^2.132.1", 33 | "aws-cdk-lib": "^2.132.1", 34 | "constructs": "^10.3.0", 35 | "copy-webpack-plugin": "^11.0.0", 36 | "css-loader": "^6.7.3", 37 | "cssnano": "^5.1.15", 38 | "hono": "^4.1.0", 39 | "mini-css-extract-plugin": "^2.7.5", 40 | "postcss": "^8.4.21", 41 | "postcss-loader": "^7.0.2", 42 | "resolve-typescript-plugin": "^2.0.0", 43 | "source-map-loader": "^4.0.1", 44 | "swc-loader": "^0.2.3", 45 | "tailwindcss": "^3.2.7", 46 | "tsx": "^4.7.1", 47 | "webpack": "^5.77.0", 48 | "webpack-cli": "^5.0.1", 49 | "webpack-manifest-plugin": "^5.0.0", 50 | "zod": "^3.22.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /apps/aws-app/src/client.tsx: -------------------------------------------------------------------------------- 1 | import {hydrateApp} from '@mfng/core/client/browser'; 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import 'tailwindcss/tailwind.css'; 4 | 5 | hydrateApp().catch(console.error); 6 | -------------------------------------------------------------------------------- /apps/aws-app/src/handler/app.tsx: -------------------------------------------------------------------------------- 1 | // This is in a separate file so that we can configure webpack to use the 2 | // `react-server` layer for this module, and therefore the imported modules 3 | // (React and the server components) will be imported with the required 4 | // `react-server` condition. 5 | 6 | import {App as SharedApp} from '@mfng/shared-app/app.js'; 7 | import * as React from 'react'; 8 | 9 | export function App(): React.ReactNode { 10 | return `AWS RSC/SSR demo ${pathname}`} />; 11 | } 12 | -------------------------------------------------------------------------------- /apps/aws-app/src/handler/auth-middleware.ts: -------------------------------------------------------------------------------- 1 | import type {MiddlewareHandler} from 'hono'; 2 | 3 | export const authMiddleware: MiddlewareHandler = async (context, next) => { 4 | if ( 5 | context.req.header(`X-Origin-Verify`) !== 6 | process.env.AWS_HANDLER_VERIFY_HEADER 7 | ) { 8 | return context.text(`Unauthorized`, 401); 9 | } 10 | 11 | return next(); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/aws-app/src/handler/index.tsx: -------------------------------------------------------------------------------- 1 | import {routerLocationAsyncLocalStorage} from '@mfng/core/router-location-async-local-storage'; 2 | import { 3 | createRscActionStream, 4 | createRscAppStream, 5 | createRscFormState, 6 | } from '@mfng/core/server/rsc'; 7 | import {createHtmlStream} from '@mfng/core/server/ssr'; 8 | import type {LambdaFunctionURLHandler} from 'aws-lambda'; 9 | import {Hono} from 'hono'; 10 | import {streamHandle} from 'hono/aws-lambda'; 11 | import * as React from 'react'; 12 | import type {ReactFormState} from 'react-dom/server'; 13 | import {App} from './app.js'; 14 | import {authMiddleware} from './auth-middleware.js'; 15 | import {loggerMiddleware} from './logger-middleware.js'; 16 | import { 17 | cssManifest, 18 | jsManifest, 19 | reactClientManifest, 20 | reactServerManifest, 21 | reactSsrManifest, 22 | } from './manifests.js'; 23 | 24 | export const app = new Hono(); 25 | 26 | app.use(authMiddleware); 27 | app.use(loggerMiddleware); 28 | app.get(`/*`, async (context) => handleGet(context.req.raw)); 29 | app.post(`/*`, async (context) => handlePost(context.req.raw)); 30 | 31 | export const handler: LambdaFunctionURLHandler = streamHandle(app); 32 | 33 | const oneDay = 60 * 60 * 24; 34 | 35 | async function renderApp( 36 | request: Request, 37 | formState?: ReactFormState, 38 | ): Promise { 39 | const {pathname, search} = new URL(request.url); 40 | 41 | return routerLocationAsyncLocalStorage.run({pathname, search}, async () => { 42 | const rscAppStream = createRscAppStream(, { 43 | reactClientManifest, 44 | mainCssHref: cssManifest[`main.css`]!, 45 | formState, 46 | }); 47 | 48 | if (request.headers.get(`accept`) === `text/x-component`) { 49 | return new Response(rscAppStream, { 50 | headers: { 51 | 'Content-Type': `text/x-component; charset=utf-8`, 52 | 'Cache-Control': `s-maxage=60, stale-while-revalidate=${oneDay}`, 53 | }, 54 | }); 55 | } 56 | 57 | const htmlStream = await createHtmlStream(rscAppStream, { 58 | reactSsrManifest, 59 | bootstrapScripts: [jsManifest[`main.js`]!], 60 | formState, 61 | }); 62 | 63 | return new Response(htmlStream, { 64 | headers: { 65 | 'Content-Type': `text/html; charset=utf-8`, 66 | 'Cache-Control': `s-maxage=60, stale-while-revalidate=${oneDay}`, 67 | }, 68 | }); 69 | }); 70 | } 71 | 72 | async function handleGet(request: Request): Promise { 73 | return renderApp(request); 74 | } 75 | 76 | async function handlePost(request: Request): Promise { 77 | const serverReferenceId = request.headers.get(`x-rsc-action`); 78 | 79 | if (serverReferenceId) { 80 | // POST via callServer: 81 | 82 | const contentType = request.headers.get(`content-type`); 83 | 84 | const body = await (contentType?.startsWith(`multipart/form-data`) 85 | ? request.formData() 86 | : request.text()); 87 | 88 | const rscActionStream = await createRscActionStream({ 89 | body, 90 | serverReferenceId, 91 | reactClientManifest, 92 | reactServerManifest, 93 | }); 94 | 95 | return new Response(rscActionStream, { 96 | status: rscActionStream ? 200 : 500, 97 | headers: {'Content-Type': `text/x-component`}, 98 | }); 99 | } else { 100 | // POST before hydration (progressive enhancement): 101 | 102 | const formData = await request.formData(); 103 | const formState = await createRscFormState(formData, reactServerManifest); 104 | 105 | return renderApp(request, formState); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /apps/aws-app/src/handler/logger-middleware.ts: -------------------------------------------------------------------------------- 1 | import type {LambdaFunctionURLEvent} from 'aws-lambda'; 2 | import type {MiddlewareHandler} from 'hono'; 3 | 4 | export const loggerMiddleware: MiddlewareHandler<{ 5 | Bindings: { 6 | // Not available in dev server. 7 | event?: LambdaFunctionURLEvent; 8 | }; 9 | }> = async ({req, env: {event}}, next) => { 10 | if (event) { 11 | console.log(`EVENT`, JSON.stringify(event)); 12 | } else { 13 | const {url, method} = req; 14 | const headers = req.header(); 15 | 16 | console.log(JSON.stringify({method, url, headers})); 17 | } 18 | 19 | return next(); 20 | }; 21 | -------------------------------------------------------------------------------- /apps/aws-app/src/handler/manifests.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | export const reactServerManifest = await import( 4 | /* webpackIgnore: true */ `./react-server-manifest.json`, 5 | {assert: {type: 'json'}} 6 | ); 7 | 8 | export const reactClientManifest = await import( 9 | /* webpackIgnore: true */ `./react-client-manifest.json`, 10 | {assert: {type: 'json'}} 11 | ); 12 | 13 | export const reactSsrManifest = await import( 14 | /* webpackIgnore: true */ `./react-ssr-manifest.json`, 15 | {assert: {type: 'json'}} 16 | ); 17 | 18 | export const cssManifest = await import( 19 | /* webpackIgnore: true */ `./css-manifest.json`, 20 | {assert: {type: 'json'}} 21 | ); 22 | 23 | export const jsManifest = await import( 24 | /* webpackIgnore: true */ `./js-manifest.json`, 25 | {assert: {type: 'json'}} 26 | ); 27 | -------------------------------------------------------------------------------- /apps/aws-app/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const config = require(`@mfng/shared-app/tailwind.config.cjs`); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | ...config, 6 | content: [`./src/**/*.{ts,tsx}`, `../shared-app/src/**/*.{ts,tsx}`], 7 | }; 8 | -------------------------------------------------------------------------------- /apps/aws-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "checkJs": true, 5 | "outDir": "lib", 6 | "resolveJsonModule": true, 7 | "types": ["react/experimental", "react-dom/experimental"] 8 | }, 9 | "include": [ 10 | "src/**/*", 11 | "../../types", 12 | "*.js", 13 | "*.cjs", 14 | "dev-server/**/*", 15 | "types/**/*", 16 | "cdk/**/*" 17 | ], 18 | "exclude": ["dist"], 19 | "references": [ 20 | {"path": "../shared-app"}, 21 | {"path": "../../packages/core"}, 22 | {"path": "../../packages/webpack-rsc"} 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /apps/aws-app/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "extends": ["//"], 4 | "pipeline": { 5 | "build": { 6 | "inputs": [ 7 | "../../packages/core/src/**/*", 8 | "../shared-app/src/**/*", 9 | "src/**/*", 10 | "package.json", 11 | "tailwind.config.cjs", 12 | "webpack.config.js" 13 | ], 14 | "outputs": ["dist/**"] 15 | }, 16 | "build:dev": { 17 | "inputs": [ 18 | "../../packages/core/src/**/*", 19 | "../shared-app/src/**/*", 20 | "src/**/*", 21 | "package.json", 22 | "tailwind.config.cjs", 23 | "webpack.config.js" 24 | ], 25 | "outputs": ["dist/**"] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/aws-app/types/aws-lambda.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace awslambda { 2 | import type {Duplex} from 'stream'; 3 | import type {Context} from 'aws-lambda'; 4 | 5 | type ResponseStreamHandler = ( 6 | event: TEvent, 7 | responseStream: ResponseStream, 8 | context: Context, 9 | ) => Promise; 10 | 11 | type ResponseStream = Duplex & { 12 | setContentType(contentType: string): void; 13 | }; 14 | 15 | interface ResponseStreamMetaData { 16 | readonly statusCode: number; 17 | readonly headers: Record; 18 | } 19 | 20 | class HttpResponseStream { 21 | static from( 22 | responseStream: ResponseStream, 23 | metadata: ResponseStreamMetaData, 24 | ): ResponseStream; 25 | } 26 | 27 | const streamifyResponse: ( 28 | handler: ResponseStreamHandler, 29 | ) => ResponseStreamHandler; 30 | } 31 | -------------------------------------------------------------------------------- /apps/aws-app/webpack.config.js: -------------------------------------------------------------------------------- 1 | import {createRequire} from 'module'; 2 | import path from 'path'; 3 | import url from 'url'; 4 | import { 5 | WebpackRscClientPlugin, 6 | WebpackRscServerPlugin, 7 | createWebpackRscClientLoader, 8 | createWebpackRscServerLoader, 9 | createWebpackRscSsrLoader, 10 | webpackRscLayerName, 11 | } from '@mfng/webpack-rsc'; 12 | import CopyPlugin from 'copy-webpack-plugin'; 13 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 14 | import ResolveTypeScriptPlugin from 'resolve-typescript-plugin'; 15 | import {WebpackManifestPlugin} from 'webpack-manifest-plugin'; 16 | 17 | const require = createRequire(import.meta.url); 18 | const currentDirname = path.dirname(url.fileURLToPath(import.meta.url)); 19 | const outputDirname = path.join(currentDirname, `dist`); 20 | const outputHandlerDirname = path.join(outputDirname, `handler`); 21 | 22 | const reactServerManifestFilename = path.join( 23 | outputHandlerDirname, 24 | `react-server-manifest.json`, 25 | ); 26 | 27 | const reactClientManifestFilename = path.join( 28 | outputHandlerDirname, 29 | `react-client-manifest.json`, 30 | ); 31 | 32 | const reactSsrManifestFilename = path.join( 33 | outputHandlerDirname, 34 | `react-ssr-manifest.json`, 35 | ); 36 | 37 | const jsManifestFilename = path.join(outputHandlerDirname, `js-manifest.json`); 38 | 39 | const cssManifestFilename = path.join( 40 | outputHandlerDirname, 41 | `css-manifest.json`, 42 | ); 43 | 44 | /** 45 | * @param {unknown} _env 46 | * @param {{readonly mode?: import('webpack').Configuration['mode']}} argv 47 | * @return {import('webpack').Configuration[]} 48 | */ 49 | export default function createConfigs(_env, argv) { 50 | const {mode} = argv; 51 | const dev = mode === `development`; 52 | 53 | /** 54 | * @type {import('webpack').StatsOptions} 55 | */ 56 | const stats = { 57 | assets: true, 58 | builtAt: true, 59 | chunks: false, 60 | colors: true, 61 | groupAssetsByEmitStatus: false, 62 | groupAssetsByExtension: true, 63 | groupAssetsByInfo: false, 64 | groupAssetsByPath: false, 65 | hash: false, 66 | modules: false, 67 | version: false, 68 | }; 69 | 70 | const cssRule = { 71 | test: /\.css$/, 72 | use: [ 73 | MiniCssExtractPlugin.loader, 74 | { 75 | loader: `css-loader`, 76 | options: { 77 | modules: { 78 | localIdentName: dev 79 | ? `[local]__[hash:base64:5]` 80 | : `[hash:base64:7]`, 81 | auto: true, 82 | }, 83 | }, 84 | }, 85 | { 86 | loader: `postcss-loader`, 87 | options: { 88 | postcssOptions: { 89 | plugins: [ 90 | `tailwindcss`, 91 | `autoprefixer`, 92 | ...(dev ? [] : [`cssnano`]), 93 | ], 94 | }, 95 | }, 96 | }, 97 | ], 98 | }; 99 | 100 | /** 101 | * @type {import('@mfng/webpack-rsc').ClientReferencesMap} 102 | */ 103 | const clientReferencesMap = new Map(); 104 | const serverReferencesMap = new Map(); 105 | 106 | const rscServerLoader = createWebpackRscServerLoader({ 107 | clientReferencesMap, 108 | serverReferencesMap, 109 | }); 110 | 111 | const rscSsrLoader = createWebpackRscSsrLoader({serverReferencesMap}); 112 | const rscClientLoader = createWebpackRscClientLoader({serverReferencesMap}); 113 | 114 | /** 115 | * @type {import('webpack').RuleSetUseItem} 116 | */ 117 | const serverSwcLoader = { 118 | loader: `swc-loader`, 119 | options: {env: {targets: {node: 18}}}, 120 | }; 121 | 122 | /** 123 | * @type {import('webpack').Configuration} 124 | */ 125 | const serverConfig = { 126 | name: `server`, 127 | entry: `./src/handler/index.tsx`, 128 | target: `node`, 129 | output: { 130 | filename: `index.js`, 131 | path: outputHandlerDirname, 132 | libraryTarget: `module`, 133 | chunkFormat: `module`, 134 | devtoolModuleFilenameTemplate: ( 135 | /** @type {{ absoluteResourcePath: string; }} */ info, 136 | ) => info.absoluteResourcePath, 137 | }, 138 | resolve: { 139 | plugins: [new ResolveTypeScriptPlugin()], 140 | conditionNames: [`@mfng:internal:node`, `@mfng:internal`, `...`], 141 | }, 142 | module: { 143 | rules: [ 144 | { 145 | resource: [/\/server\/rsc\//, /\/app\.tsx$/], 146 | layer: webpackRscLayerName, 147 | }, 148 | { 149 | resource: /\/server\/shared\//, 150 | layer: `shared`, 151 | }, 152 | { 153 | issuerLayer: webpackRscLayerName, 154 | resolve: {conditionNames: [`react-server`, `...`]}, 155 | }, 156 | { 157 | oneOf: [ 158 | { 159 | issuerLayer: webpackRscLayerName, 160 | test: /\.tsx?$/, 161 | use: [rscServerLoader, serverSwcLoader], 162 | }, 163 | { 164 | test: /\.tsx?$/, 165 | use: [rscSsrLoader, serverSwcLoader], 166 | }, 167 | ], 168 | }, 169 | cssRule, 170 | ], 171 | }, 172 | plugins: [ 173 | // server-main.css is not used, but required by MiniCssExtractPlugin. 174 | new MiniCssExtractPlugin({filename: `server-main.css`, runtime: false}), 175 | new WebpackRscServerPlugin({ 176 | clientReferencesMap, 177 | serverReferencesMap, 178 | serverManifestFilename: path.relative( 179 | outputHandlerDirname, 180 | reactServerManifestFilename, 181 | ), 182 | }), 183 | ], 184 | experiments: {outputModule: true, layers: true, topLevelAwait: true}, 185 | devtool: `source-map`, 186 | mode, 187 | stats, 188 | }; 189 | 190 | const clientOutputDirname = path.join(outputDirname, `static/client`); 191 | 192 | /** 193 | * @type {import('webpack').Configuration} 194 | */ 195 | const clientConfig = { 196 | name: `client`, 197 | dependencies: [`server`], 198 | entry: `./src/client.tsx`, 199 | output: { 200 | filename: dev ? `main.js` : `main.[contenthash:8].js`, 201 | path: clientOutputDirname, 202 | clean: !dev, 203 | publicPath: `/client/`, 204 | }, 205 | resolve: { 206 | plugins: [new ResolveTypeScriptPlugin()], 207 | conditionNames: [`@mfng:internal`, `...`], 208 | }, 209 | module: { 210 | rules: [ 211 | {test: /\.js$/, loader: `source-map-loader`, enforce: `pre`}, 212 | { 213 | test: /\.tsx?$/, 214 | use: [rscClientLoader, `swc-loader`], 215 | exclude: [/node_modules/], 216 | }, 217 | cssRule, 218 | ], 219 | }, 220 | plugins: [ 221 | new CopyPlugin({ 222 | patterns: [ 223 | { 224 | from: path.join( 225 | path.dirname(require.resolve(`@mfng/shared-app/package.json`)), 226 | `static`, 227 | ), 228 | }, 229 | ], 230 | }), 231 | new MiniCssExtractPlugin({ 232 | filename: dev ? `main.css` : `main.[contenthash:8].css`, 233 | runtime: false, 234 | }), 235 | new WebpackManifestPlugin({ 236 | fileName: cssManifestFilename, 237 | publicPath: `/client/`, 238 | filter: (file) => file.path.endsWith(`.css`), 239 | }), 240 | new WebpackManifestPlugin({ 241 | fileName: jsManifestFilename, 242 | publicPath: `/client/`, 243 | filter: (file) => file.path.endsWith(`.js`), 244 | }), 245 | new WebpackRscClientPlugin({ 246 | clientReferencesMap, 247 | clientManifestFilename: path.relative( 248 | clientOutputDirname, 249 | reactClientManifestFilename, 250 | ), 251 | ssrManifestFilename: path.relative( 252 | clientOutputDirname, 253 | reactSsrManifestFilename, 254 | ), 255 | }), 256 | ], 257 | devtool: `source-map`, 258 | mode, 259 | stats, 260 | }; 261 | 262 | return [serverConfig, clientConfig]; 263 | } 264 | -------------------------------------------------------------------------------- /apps/cloudflare-app/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript", 6 | "tsx": true 7 | } 8 | }, 9 | "module": { 10 | "type": "es6" 11 | }, 12 | "sourceMaps": true 13 | } 14 | -------------------------------------------------------------------------------- /apps/cloudflare-app/jest.config.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import url from 'url'; 4 | 5 | const currentDirname = path.dirname(url.fileURLToPath(import.meta.url)); 6 | 7 | const swcConfig = JSON.parse( 8 | fs.readFileSync(`${currentDirname}/.swcrc`, `utf-8`), 9 | ); 10 | 11 | /** 12 | * @type {import('jest').Config} 13 | */ 14 | export default { 15 | collectCoverage: true, 16 | extensionsToTreatAsEsm: [`.ts`], 17 | testMatch: [`**/src/**/*.test.ts`], 18 | transform: {'^.+\\.ts$': [`@swc/jest`, swcConfig]}, 19 | }; 20 | -------------------------------------------------------------------------------- /apps/cloudflare-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mfng/cloudflare-app", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "author": "Hendrik Liebau ", 7 | "type": "module", 8 | "scripts": { 9 | "build": "NODE_OPTIONS='--import=tsx --conditions=@mfng:internal' webpack --mode production", 10 | "build:dev": "NODE_OPTIONS='--import=tsx --conditions=@mfng:internal' webpack --mode development", 11 | "deploy": "wrangler deploy -c src/worker/wrangler.toml", 12 | "dev": "npm run start", 13 | "start": "wrangler dev -c src/worker/wrangler.toml", 14 | "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", 15 | "watch": "NODE_OPTIONS='--import=tsx --conditions=@mfng:internal' webpack --mode production --watch", 16 | "watch:dev": "NODE_OPTIONS='--import=tsx --conditions=@mfng:internal' webpack --mode development --watch" 17 | }, 18 | "dependencies": { 19 | "@mfng/core": "*", 20 | "@mfng/shared-app": "*", 21 | "react": "0.0.0-experimental-778e1ed2-20240926", 22 | "react-dom": "0.0.0-experimental-778e1ed2-20240926" 23 | }, 24 | "devDependencies": { 25 | "@cloudflare/workers-types": "^4.20240222.0", 26 | "@jest/globals": "^29.5.0", 27 | "@mfng/webpack-rsc": "*", 28 | "@swc/core": "^1.3.22", 29 | "@swc/jest": "^0.2.24", 30 | "@types/jest": "^29.4.0", 31 | "@types/react": "^18.3.2", 32 | "@types/react-dom": "^18.3.0", 33 | "autoprefixer": "^10.4.14", 34 | "copy-webpack-plugin": "^11.0.0", 35 | "css-loader": "^6.7.3", 36 | "cssnano": "^5.1.15", 37 | "jest": "^29.5.0", 38 | "mini-css-extract-plugin": "^2.7.5", 39 | "postcss": "^8.4.21", 40 | "postcss-loader": "^7.0.2", 41 | "react-server-dom-webpack": "0.0.0-experimental-778e1ed2-20240926", 42 | "resolve-typescript-plugin": "^2.0.0", 43 | "source-map-loader": "^4.0.1", 44 | "swc-loader": "^0.2.3", 45 | "tailwindcss": "^3.2.7", 46 | "tsx": "^4.1.0", 47 | "webpack": "^5.77.0", 48 | "webpack-cli": "^5.0.1", 49 | "webpack-manifest-plugin": "^5.0.0", 50 | "wrangler": "3.33.0" 51 | }, 52 | "wallaby": { 53 | "env": { 54 | "params": { 55 | "runner": "--experimental-vm-modules" 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /apps/cloudflare-app/src/client.tsx: -------------------------------------------------------------------------------- 1 | import {hydrateApp} from '@mfng/core/client/browser'; 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import 'tailwindcss/tailwind.css'; 4 | 5 | hydrateApp().catch(console.error); 6 | -------------------------------------------------------------------------------- /apps/cloudflare-app/src/worker/app.tsx: -------------------------------------------------------------------------------- 1 | // This is in a separate file so that we can configure webpack to use the 2 | // `react-server` layer for this module, and therefore the imported modules 3 | // (React and the server components) will be imported with the required 4 | // `react-server` condition. 5 | 6 | import {App as SharedApp} from '@mfng/shared-app/app.js'; 7 | import * as React from 'react'; 8 | 9 | export function App(): React.ReactNode { 10 | return ( 11 | `Cloudflare RSC/SSR demo ${pathname}`} /> 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/cloudflare-app/src/worker/index.tsx: -------------------------------------------------------------------------------- 1 | import type {Request} from '@cloudflare/workers-types'; 2 | import {routerLocationAsyncLocalStorage} from '@mfng/core/router-location-async-local-storage'; 3 | import { 4 | createRscActionStream, 5 | createRscAppStream, 6 | createRscFormState, 7 | } from '@mfng/core/server/rsc'; 8 | import {createHtmlStream} from '@mfng/core/server/ssr'; 9 | import * as React from 'react'; 10 | import type {ReactFormState} from 'react-dom/server'; 11 | import {App} from './app.js'; 12 | import { 13 | cssManifest, 14 | jsManifest, 15 | reactClientManifest, 16 | reactServerManifest, 17 | reactSsrManifest, 18 | } from './manifests.js'; 19 | 20 | async function renderApp( 21 | request: Request, 22 | formState?: ReactFormState, 23 | ): Promise { 24 | const {pathname, search} = new URL(request.url); 25 | 26 | return routerLocationAsyncLocalStorage.run({pathname, search}, async () => { 27 | const rscAppStream = createRscAppStream(, { 28 | reactClientManifest, 29 | mainCssHref: cssManifest[`main.css`]!, 30 | formState, 31 | }); 32 | 33 | if (request.headers.get(`accept`) === `text/x-component`) { 34 | return new Response(rscAppStream, { 35 | headers: {'Content-Type': `text/x-component; charset=utf-8`}, 36 | }); 37 | } 38 | 39 | const htmlStream = await createHtmlStream(rscAppStream, { 40 | reactSsrManifest, 41 | bootstrapScripts: [jsManifest[`main.js`]!], 42 | formState, 43 | }); 44 | 45 | return new Response(htmlStream, { 46 | headers: {'Content-Type': `text/html; charset=utf-8`}, 47 | }); 48 | }); 49 | } 50 | 51 | const handleGet: ExportedHandlerFetchHandler = async (request: Request) => { 52 | return renderApp(request); 53 | }; 54 | 55 | const handlePost: ExportedHandlerFetchHandler = async (request: Request) => { 56 | const serverReferenceId = request.headers.get(`x-rsc-action`); 57 | 58 | if (serverReferenceId) { 59 | // POST via callServer: 60 | 61 | const contentType = request.headers.get(`content-type`); 62 | 63 | const body = await (contentType?.startsWith(`multipart/form-data`) 64 | ? (request.formData() as Promise) 65 | : request.text()); 66 | 67 | const rscActionStream = await createRscActionStream({ 68 | body, 69 | serverReferenceId, 70 | reactClientManifest, 71 | reactServerManifest, 72 | }); 73 | 74 | return new Response(rscActionStream, { 75 | status: rscActionStream ? 200 : 500, 76 | headers: {'Content-Type': `text/x-component`}, 77 | }); 78 | } else { 79 | // POST before hydration (progressive enhancement): 80 | 81 | const formData = await (request.formData() as Promise); 82 | const formState = await createRscFormState(formData, reactServerManifest); 83 | 84 | return renderApp(request, formState); 85 | } 86 | }; 87 | 88 | const handler: ExportedHandler = { 89 | async fetch(request: Request, env, ctx) { 90 | switch (request.method) { 91 | case `GET`: 92 | return handleGet(request, env, ctx); 93 | case `POST`: 94 | return handlePost(request, env, ctx); 95 | default: 96 | return new Response(null, {status: 405}); 97 | } 98 | }, 99 | }; 100 | 101 | export default handler; 102 | -------------------------------------------------------------------------------- /apps/cloudflare-app/src/worker/manifests.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | // These json imports will be preserved by webpack, and then bundled by wrangler 4 | // into the final worker bundle. This needs to be done in this way, 5 | // because most of the manifests are only available after the client bundle has 6 | // been created, at which point webpack has already emitted the server bundle. 7 | 8 | export const reactServerManifest = require(/* webpackIgnore: true */ `./react-server-manifest.json`); 9 | export const reactClientManifest = require(/* webpackIgnore: true */ `./react-client-manifest.json`); 10 | export const reactSsrManifest = require(/* webpackIgnore: true */ `./react-ssr-manifest.json`); 11 | export const cssManifest = require(/* webpackIgnore: true */ `./css-manifest.json`); 12 | export const jsManifest = require(/* webpackIgnore: true */ `./js-manifest.json`); 13 | -------------------------------------------------------------------------------- /apps/cloudflare-app/src/worker/worker.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import url from 'url'; 3 | import {jest} from '@jest/globals'; 4 | import type {UnstableDevWorker} from 'wrangler'; 5 | import {unstable_dev} from 'wrangler'; 6 | 7 | const currentDirname = path.dirname(url.fileURLToPath(import.meta.url)); 8 | 9 | describe(`worker`, () => { 10 | let worker: UnstableDevWorker; 11 | 12 | jest.setTimeout(30000); 13 | 14 | beforeAll(async () => { 15 | worker = await unstable_dev( 16 | path.resolve(currentDirname, `../../dist/worker/index.js`), 17 | { 18 | config: path.resolve(currentDirname, `./wrangler.toml`), 19 | experimental: {disableExperimentalWarning: true}, 20 | }, 21 | ); 22 | }); 23 | 24 | afterAll(async () => { 25 | await worker.stop(); 26 | }); 27 | 28 | it(`responds with html`, async () => { 29 | const resp = await worker.fetch(); 30 | const text = await resp.text(); 31 | 32 | expect(text).toMatch( 33 | `

This is a suspended server component.

`, 34 | ); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /apps/cloudflare-app/src/worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "mfng" 2 | main = "../../dist/worker/index.js" 3 | compatibility_date = "2024-03-04" 4 | compatibility_flags = ["nodejs_als"] 5 | 6 | [dev] 7 | port = 3000 8 | 9 | # Deactivate build command to prevent wrangler from restarting the build on any 10 | # code change. Instead we start the build separately (in watch mode). 11 | [build] 12 | command = "" 13 | watch_dir = [] 14 | 15 | [assets] 16 | bucket = "../../dist/static" 17 | serve_single_page_app = true 18 | -------------------------------------------------------------------------------- /apps/cloudflare-app/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const config = require(`@mfng/shared-app/tailwind.config.cjs`); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | ...config, 6 | content: [`./src/**/*.{ts,tsx}`, `../shared-app/src/**/*.{ts,tsx}`], 7 | }; 8 | -------------------------------------------------------------------------------- /apps/cloudflare-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "checkJs": true, 5 | "outDir": "lib", 6 | "types": [ 7 | "@cloudflare/workers-types", 8 | "jest", 9 | "react/experimental", 10 | "react-dom/experimental" 11 | ] 12 | }, 13 | "include": ["src/**/*", "../../types", "*.js", "*.cjs"], 14 | "exclude": ["dist"], 15 | "references": [ 16 | {"path": "../shared-app"}, 17 | {"path": "../../packages/core"}, 18 | {"path": "../../packages/webpack-rsc"} 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /apps/cloudflare-app/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "extends": ["//"], 4 | "pipeline": { 5 | "build": { 6 | "inputs": [ 7 | "../../packages/core/src/**/*", 8 | "../shared-app/src/**/*", 9 | "src/**/*", 10 | "package.json", 11 | "tailwind.config.cjs", 12 | "webpack.config.js" 13 | ], 14 | "outputs": ["dist/**"] 15 | }, 16 | "build:dev": { 17 | "inputs": [ 18 | "../../packages/core/src/**/*", 19 | "../shared-app/src/**/*", 20 | "src/**/*", 21 | "package.json", 22 | "tailwind.config.cjs", 23 | "webpack.config.js" 24 | ], 25 | "outputs": ["dist/**"] 26 | }, 27 | "test": { 28 | "dependsOn": ["build:dev"] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/cloudflare-app/webpack.config.js: -------------------------------------------------------------------------------- 1 | import {createRequire} from 'module'; 2 | import path from 'path'; 3 | import url from 'url'; 4 | import { 5 | WebpackRscClientPlugin, 6 | WebpackRscServerPlugin, 7 | createWebpackRscClientLoader, 8 | createWebpackRscServerLoader, 9 | createWebpackRscSsrLoader, 10 | webpackRscLayerName, 11 | } from '@mfng/webpack-rsc'; 12 | import CopyPlugin from 'copy-webpack-plugin'; 13 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 14 | import ResolveTypeScriptPlugin from 'resolve-typescript-plugin'; 15 | import {WebpackManifestPlugin} from 'webpack-manifest-plugin'; 16 | 17 | const currentDirname = path.dirname(url.fileURLToPath(import.meta.url)); 18 | const require = createRequire(import.meta.url); 19 | 20 | const outputDirname = path.join(currentDirname, `dist`); 21 | const outputWorkerDirname = path.join(outputDirname, `worker`); 22 | 23 | const reactServerManifestFilename = path.join( 24 | outputWorkerDirname, 25 | `react-server-manifest.json`, 26 | ); 27 | 28 | const reactClientManifestFilename = path.join( 29 | outputWorkerDirname, 30 | `react-client-manifest.json`, 31 | ); 32 | 33 | const reactSsrManifestFilename = path.join( 34 | outputWorkerDirname, 35 | `react-ssr-manifest.json`, 36 | ); 37 | 38 | const jsManifestFilename = path.join(outputWorkerDirname, `js-manifest.json`); 39 | 40 | const cssManifestFilename = path.join(outputWorkerDirname, `css-manifest.json`); 41 | /** 42 | * @param {unknown} _env 43 | * @param {{readonly mode?: import('webpack').Configuration['mode']}} argv 44 | * @return {import('webpack').Configuration[]} 45 | */ 46 | export default function createConfigs(_env, argv) { 47 | const {mode} = argv; 48 | const dev = mode === `development`; 49 | 50 | /** 51 | * @type {import('webpack').StatsOptions} 52 | */ 53 | const stats = { 54 | assets: true, 55 | builtAt: true, 56 | chunks: false, 57 | colors: true, 58 | groupAssetsByEmitStatus: false, 59 | groupAssetsByExtension: true, 60 | groupAssetsByInfo: false, 61 | groupAssetsByPath: false, 62 | hash: false, 63 | modules: false, 64 | version: false, 65 | }; 66 | 67 | const cssRule = { 68 | test: /\.css$/, 69 | use: [ 70 | MiniCssExtractPlugin.loader, 71 | { 72 | loader: `css-loader`, 73 | options: { 74 | modules: { 75 | localIdentName: dev 76 | ? `[local]__[hash:base64:5]` 77 | : `[hash:base64:7]`, 78 | auto: true, 79 | }, 80 | }, 81 | }, 82 | { 83 | loader: `postcss-loader`, 84 | options: { 85 | postcssOptions: { 86 | plugins: [ 87 | `tailwindcss`, 88 | `autoprefixer`, 89 | ...(dev ? [] : [`cssnano`]), 90 | ], 91 | }, 92 | }, 93 | }, 94 | ], 95 | }; 96 | 97 | /** 98 | * @type {import('@mfng/webpack-rsc').ClientReferencesMap} 99 | */ 100 | const clientReferencesMap = new Map(); 101 | const serverReferencesMap = new Map(); 102 | 103 | const rscServerLoader = createWebpackRscServerLoader({ 104 | clientReferencesMap, 105 | serverReferencesMap, 106 | }); 107 | 108 | const rscSsrLoader = createWebpackRscSsrLoader({serverReferencesMap}); 109 | const rscClientLoader = createWebpackRscClientLoader({serverReferencesMap}); 110 | 111 | /** 112 | * @type {import('webpack').RuleSetUseItem} 113 | */ 114 | const serverSwcLoader = { 115 | loader: `swc-loader`, 116 | options: {env: {targets: {node: 18}}}, 117 | }; 118 | 119 | /** 120 | * @type {import('webpack').Configuration} 121 | */ 122 | const serverConfig = { 123 | name: `server`, 124 | entry: `./src/worker/index.tsx`, 125 | target: `webworker`, 126 | output: { 127 | filename: `index.js`, 128 | path: outputWorkerDirname, 129 | libraryTarget: `module`, 130 | chunkFormat: `module`, 131 | devtoolModuleFilenameTemplate: ( 132 | /** @type {{ absoluteResourcePath: string; }} */ info, 133 | ) => info.absoluteResourcePath, 134 | }, 135 | resolve: { 136 | plugins: [new ResolveTypeScriptPlugin()], 137 | conditionNames: [ 138 | `@mfng:internal:node`, 139 | `@mfng:internal`, 140 | `workerd`, 141 | `...`, 142 | ], 143 | }, 144 | module: { 145 | rules: [ 146 | { 147 | resource: [/\/server\/rsc\//, /\/app\.tsx$/], 148 | layer: webpackRscLayerName, 149 | }, 150 | { 151 | resource: /\/server\/shared\//, 152 | layer: `shared`, 153 | }, 154 | { 155 | issuerLayer: webpackRscLayerName, 156 | resolve: {conditionNames: [`react-server`, `workerd`, `...`]}, 157 | }, 158 | { 159 | oneOf: [ 160 | { 161 | issuerLayer: webpackRscLayerName, 162 | test: /\.tsx?$/, 163 | use: [rscServerLoader, serverSwcLoader], 164 | exclude: [/node_modules/], 165 | }, 166 | { 167 | test: /\.tsx?$/, 168 | use: [rscSsrLoader, serverSwcLoader], 169 | exclude: [/node_modules/], 170 | }, 171 | ], 172 | }, 173 | cssRule, 174 | ], 175 | }, 176 | plugins: [ 177 | new MiniCssExtractPlugin({filename: `server-main.css`, runtime: false}), 178 | new WebpackRscServerPlugin({ 179 | clientReferencesMap, 180 | serverReferencesMap, 181 | serverManifestFilename: path.relative( 182 | outputWorkerDirname, 183 | reactServerManifestFilename, 184 | ), 185 | }), 186 | ], 187 | experiments: {outputModule: true, layers: true}, 188 | performance: {maxAssetSize: 1_000_000, maxEntrypointSize: 1_000_000}, 189 | externals: [`__STATIC_CONTENT_MANIFEST`, `node:async_hooks`], 190 | devtool: `source-map`, 191 | mode, 192 | stats, 193 | }; 194 | 195 | const staticDirname = path.join(currentDirname, `dist/static`); 196 | const clientOutputDirname = path.join(staticDirname, `client`); 197 | 198 | /** 199 | * @type {import('webpack').Configuration} 200 | */ 201 | const clientConfig = { 202 | name: `client`, 203 | dependencies: [`server`], 204 | entry: `./src/client.tsx`, 205 | output: { 206 | filename: dev ? `main.js` : `main.[contenthash:8].js`, 207 | path: clientOutputDirname, 208 | clean: !dev, 209 | publicPath: `/client/`, 210 | }, 211 | resolve: { 212 | plugins: [new ResolveTypeScriptPlugin()], 213 | conditionNames: [`@mfng:internal`, `...`], 214 | }, 215 | module: { 216 | rules: [ 217 | {test: /\.js$/, loader: `source-map-loader`, enforce: `pre`}, 218 | { 219 | test: /\.tsx?$/, 220 | use: [rscClientLoader, `swc-loader`], 221 | exclude: [/node_modules/], 222 | }, 223 | cssRule, 224 | ], 225 | }, 226 | plugins: [ 227 | new CopyPlugin({ 228 | patterns: [ 229 | { 230 | from: path.join( 231 | path.dirname(require.resolve(`@mfng/shared-app/package.json`)), 232 | `static`, 233 | ), 234 | }, 235 | ], 236 | }), 237 | new MiniCssExtractPlugin({ 238 | filename: dev ? `main.css` : `main.[contenthash:8].css`, 239 | runtime: false, 240 | }), 241 | new WebpackManifestPlugin({ 242 | fileName: cssManifestFilename, 243 | publicPath: `/client/`, 244 | filter: (file) => file.path.endsWith(`.css`), 245 | }), 246 | new WebpackManifestPlugin({ 247 | fileName: jsManifestFilename, 248 | publicPath: `/client/`, 249 | filter: (file) => file.path.endsWith(`.js`), 250 | }), 251 | new WebpackRscClientPlugin({ 252 | clientReferencesMap, 253 | clientManifestFilename: path.relative( 254 | clientOutputDirname, 255 | reactClientManifestFilename, 256 | ), 257 | ssrManifestFilename: path.relative( 258 | clientOutputDirname, 259 | reactSsrManifestFilename, 260 | ), 261 | }), 262 | ], 263 | devtool: `source-map`, 264 | mode, 265 | stats, 266 | }; 267 | 268 | return [serverConfig, clientConfig]; 269 | } 270 | -------------------------------------------------------------------------------- /apps/shared-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mfng/shared-app", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "author": "Hendrik Liebau ", 7 | "type": "module", 8 | "exports": { 9 | "./app.js": { 10 | "@mfng:internal": "./src/server/app.tsx", 11 | "default": "./lib/src/server/app.js" 12 | }, 13 | "./package.json": "./package.json", 14 | "./tailwind.config.cjs": { 15 | "@mfng:internal": "./tailwind.config.cjs", 16 | "default": "./lib/tailwind.config.cjs" 17 | } 18 | }, 19 | "files": [ 20 | "lib", 21 | "static", 22 | "tailwind.config.cjs" 23 | ], 24 | "dependencies": { 25 | "@mfng/core": "*", 26 | "clsx": "^1.2.1", 27 | "countries-list": "^2.6.1", 28 | "fuse.js": "^6.6.2", 29 | "react": "0.0.0-experimental-778e1ed2-20240926", 30 | "react-dom": "0.0.0-experimental-778e1ed2-20240926", 31 | "react-markdown": "^8.0.5", 32 | "server-only": "^0.0.1", 33 | "zod": "^3.21.4" 34 | }, 35 | "devDependencies": { 36 | "@types/react": "^18.3.2", 37 | "@types/react-dom": "^18.3.0", 38 | "tailwindcss": "^3.2.7" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /apps/shared-app/src/client/button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | 5 | export type ButtonProps = React.PropsWithChildren<{ 6 | readonly disabled?: boolean; 7 | readonly trackClick: () => Promise; 8 | }>; 9 | 10 | export function Button({ 11 | children, 12 | disabled, 13 | trackClick, 14 | }: ButtonProps): React.ReactNode { 15 | return ( 16 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /apps/shared-app/src/client/countries-search.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {useRouter} from '@mfng/core/client'; 4 | import {useRouterLocation} from '@mfng/core/use-router-location'; 5 | import * as React from 'react'; 6 | 7 | export function CountriesSearch(): React.ReactNode { 8 | const {search} = useRouterLocation(); 9 | const {replace} = useRouter(); 10 | const [, startTransition] = React.useTransition(); 11 | 12 | const [query, setQuery] = React.useState( 13 | () => new URLSearchParams(search).get(`q`) || ``, 14 | ); 15 | 16 | const handleChange: React.ChangeEventHandler = (event) => { 17 | const newQuery = event.target.value; 18 | 19 | setQuery(newQuery); 20 | 21 | startTransition(() => { 22 | replace({ 23 | search: newQuery ? new URLSearchParams({q: newQuery}).toString() : ``, 24 | }); 25 | }); 26 | }; 27 | 28 | return ( 29 |
30 | 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /apps/shared-app/src/client/navigation-container.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {useRouter} from '@mfng/core/client'; 4 | import {clsx} from 'clsx'; 5 | import * as React from 'react'; 6 | 7 | export function NavigationContainer({ 8 | children, 9 | }: React.PropsWithChildren): React.ReactNode { 10 | const {isPending} = useRouter(); 11 | 12 | return ( 13 |
18 | {children} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/shared-app/src/client/product.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {clsx} from 'clsx'; 4 | import * as React from 'react'; 5 | import type {BuyResult} from '../server/buy.js'; 6 | import {trackClick} from '../server/track-click.js'; 7 | import {Notification} from '../shared/notification.js'; 8 | import {Button} from './button.js'; 9 | 10 | export interface ProductProps { 11 | readonly buy: ( 12 | prevResult: BuyResult | undefined, 13 | formData: FormData, 14 | ) => Promise; 15 | } 16 | 17 | export function Product({buy}: ProductProps): React.ReactNode { 18 | const [result, formAction, isPending] = React.useActionState(buy, undefined); 19 | 20 | return ( 21 |
22 |

23 | This is a client component that renders a form with a form action. On 24 | submit, a server action is called with the current form data, which in 25 | turn responds with a success or error result. 26 |

27 |

28 | The form submission also works before hydration, including server-side 29 | rendering of the result! This can be simulated by blocking the 30 | javascript files. 31 |

32 | 46 | {` `} 47 | 50 | {result && ( 51 | 52 | {result.status === `success` ? ( 53 |

54 | Bought {result.quantity} 55 | {` `} 56 | {result.quantity === 1 ? `item` : `items`}. 57 |

58 | ) : ( 59 |

{result.message}

60 | )} 61 |

62 | Total items bought: {result.totalQuantityInSession} 63 |

64 |
65 | )} 66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/app.tsx: -------------------------------------------------------------------------------- 1 | import {useRouterLocation} from '@mfng/core/use-router-location'; 2 | import * as React from 'react'; 3 | import {NavigationContainer} from '../client/navigation-container.js'; 4 | import {Navigation} from '../shared/navigation.js'; 5 | import {Routes} from './routes.js'; 6 | 7 | export interface AppProps { 8 | readonly getTitle: (pathname: string) => string; 9 | } 10 | 11 | export function App({getTitle}: AppProps): React.ReactNode { 12 | const {pathname} = useRouterLocation(); 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | {getTitle(pathname)} 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/buy.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import 'server-only'; 4 | import {z} from 'zod'; 5 | import {wait} from './wait.js'; 6 | 7 | export type BuyResult = BuySuccessResult | BuyErrorResult; 8 | 9 | export interface BuySuccessResult { 10 | readonly status: 'success'; 11 | readonly quantity: number; 12 | readonly totalQuantityInSession: number; 13 | } 14 | 15 | export interface BuyErrorResult { 16 | readonly status: 'error'; 17 | readonly message: string; 18 | readonly fieldErrors?: BuyFieldErrors; 19 | readonly totalQuantityInSession: number; 20 | } 21 | 22 | export type BuyFieldErrors = z.inferFlattenedErrors< 23 | typeof BuyFormData 24 | >['fieldErrors']; 25 | 26 | const FormDataFields = z.instanceof(FormData).transform((formData) => { 27 | const fields: Record = {}; 28 | 29 | formData.forEach((value, key) => { 30 | if (typeof value === `string`) { 31 | fields[key] = value; 32 | } 33 | }); 34 | 35 | return fields; 36 | }); 37 | 38 | const BuyFormData = z.object({ 39 | quantity: z 40 | .string() 41 | .transform((value) => parseInt(value, 10)) 42 | .refine( 43 | async (quantity) => quantity <= (await fetchAvailableProductCount()), 44 | `Not enough products in stock.`, 45 | ), 46 | }); 47 | 48 | async function fetchAvailableProductCount(): Promise { 49 | await wait(500); 50 | 51 | return Promise.resolve(2); 52 | } 53 | 54 | export async function buy( 55 | productId: string, 56 | prevResult: BuyResult | undefined, 57 | formData: FormData, 58 | ): Promise { 59 | const parsedFormData = FormDataFields.safeParse(formData); 60 | const totalQuantityInSession = prevResult?.totalQuantityInSession ?? 0; 61 | 62 | if (!parsedFormData.success) { 63 | console.error(parsedFormData.error); 64 | 65 | return { 66 | status: `error`, 67 | message: `An unexpected error occured.`, 68 | totalQuantityInSession, 69 | }; 70 | } 71 | 72 | const result = await BuyFormData.safeParseAsync(parsedFormData.data); 73 | 74 | if (!result.success) { 75 | const {fieldErrors} = result.error.formErrors; 76 | 77 | return { 78 | status: `error`, 79 | message: Object.values(fieldErrors).flat().join(` `), 80 | fieldErrors, 81 | totalQuantityInSession, 82 | }; 83 | } 84 | 85 | const {quantity} = result.data; 86 | 87 | // Buy quantity number of items ... 88 | console.log( 89 | `Buying ${quantity} ${ 90 | quantity === 1 ? `item` : `items` 91 | } of product ${productId}...`, 92 | ); 93 | 94 | return { 95 | status: `success`, 96 | quantity, 97 | totalQuantityInSession: totalQuantityInSession + quantity, 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/countries-fuse.ts: -------------------------------------------------------------------------------- 1 | import countriesList from 'countries-list'; 2 | import fusejs from 'fuse.js'; 3 | 4 | // TODO: Remove when fuse.js has type=module in package.json. 5 | const Fuse = fusejs as any as typeof fusejs.default; 6 | 7 | export const countriesFuse = new Fuse( 8 | Object.entries(countriesList.countries).map( 9 | ([code, {capital, continent, name, native, emoji, languages}]) => ({ 10 | code, 11 | capital, 12 | continent: 13 | countriesList.continents[ 14 | continent as keyof typeof countriesList.continents 15 | ], 16 | name, 17 | native, 18 | emoji, 19 | languages: languages.map( 20 | (languageCode) => 21 | countriesList.languages[ 22 | languageCode as keyof typeof countriesList.languages 23 | ].name, 24 | ), 25 | }), 26 | ), 27 | { 28 | keys: [`name`, `native`, `capital`, `continent`, `languages`], 29 | threshold: 0.2, 30 | }, 31 | ); 32 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/countries-list.tsx: -------------------------------------------------------------------------------- 1 | import {useRouterLocation} from '@mfng/core/use-router-location'; 2 | import * as React from 'react'; 3 | import {countriesFuse} from './countries-fuse.js'; 4 | 5 | export function CountriesList(): React.ReactNode { 6 | const {search} = useRouterLocation(); 7 | const query = new URLSearchParams(search).get(`q`); 8 | 9 | if (!query) { 10 | return ( 11 |

12 | Enter a query to see the list of matching countries here. 13 |

14 | ); 15 | } 16 | 17 | const matchingCountries = countriesFuse.search(query); 18 | 19 | return ( 20 |
    21 | {matchingCountries.map(({item: {code, name, emoji}}) => ( 22 |
  • 23 | {emoji} {name} 24 |
  • 25 | ))} 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/fast-page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {CountriesSearch} from '../client/countries-search.js'; 3 | import {Main} from '../shared/main.js'; 4 | import {CountriesList} from './countries-list.js'; 5 | import {Markdown} from './markdown.js'; 6 | 7 | const content = ` 8 | # This is a fast page. 9 | 10 | Try to click fast between the different navigation links. Thanks to **Suspense** 11 | and **transitions** the navigations are interruptible. 12 | 13 | The pending navigation is indicated by rendering the page contents with a 14 | reduced opacity. 15 | `; 16 | 17 | export function FastPage(): React.ReactNode { 18 | return ( 19 |
20 | 21 | 22 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/hello.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import 'server-only'; 3 | import {Markdown} from './markdown.js'; 4 | 5 | // Imagine this being a fetch that can only be executed from the server. 6 | async function fetchSubject(): Promise { 7 | return Promise.resolve(`World`); 8 | } 9 | 10 | export async function Hello(): Promise { 11 | const subject = await fetchSubject(); 12 | 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/home-page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Product} from '../client/product.js'; 3 | import {Main} from '../shared/main.js'; 4 | import {buy} from './buy.js'; 5 | import {Hello} from './hello.js'; 6 | import {Suspended} from './suspended.js'; 7 | 8 | export function HomePage(): React.ReactNode { 9 | return ( 10 |
11 | 12 | Loading...

}> 13 | 14 |
15 | 16 | 17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/markdown.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ReactMarkdown from 'react-markdown'; 3 | 4 | export interface MarkdownProps { 5 | readonly text: string; 6 | } 7 | 8 | export function Markdown({text}: MarkdownProps): React.ReactNode { 9 | return ( 10 | ( 13 |

{children}

14 | ), 15 | p: ({children}) =>

{children}

, 16 | }} 17 | > 18 | {text} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/routes.tsx: -------------------------------------------------------------------------------- 1 | import {useRouterLocation} from '@mfng/core/use-router-location'; 2 | import * as React from 'react'; 3 | import {FastPage} from './fast-page.js'; 4 | import {HomePage} from './home-page.js'; 5 | import {SlowPage} from './slow-page.js'; 6 | 7 | export function Routes(): React.ReactNode { 8 | const {pathname} = useRouterLocation(); 9 | 10 | switch (pathname) { 11 | case `/slow-page`: 12 | return ; 13 | case `/fast-page`: 14 | return ; 15 | default: 16 | return ; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/slow-page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Main} from '../shared/main.js'; 3 | import {Markdown} from './markdown.js'; 4 | import {wait} from './wait.js'; 5 | 6 | const content = ` 7 | # This is slow page. 8 | 9 | Its content is written in a markdown document that's simulated to be fetched 10 | from some kind of (slow) CMS. The content is _fetched and rendered on the 11 | server_. There is no markdown library in the client bundle. 12 | `; 13 | 14 | export async function SlowPage(): Promise { 15 | await wait(3000); 16 | 17 | return ( 18 |
19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/suspended.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import 'server-only'; 3 | import {Markdown} from './markdown.js'; 4 | import {wait} from './wait.js'; 5 | 6 | async function fetchContent(): Promise { 7 | await wait(1500); 8 | 9 | return `This is a suspended server component.`; 10 | } 11 | 12 | export async function Suspended(): Promise { 13 | const content = await fetchContent(); 14 | 15 | return ; 16 | } 17 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/track-click.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import 'server-only'; 4 | 5 | let clickCount = 0; 6 | 7 | // eslint-disable-next-line @typescript-eslint/require-await 8 | export async function trackClick(): Promise { 9 | clickCount++; 10 | 11 | console.log(`Clicked ${clickCount} ${clickCount === 1 ? `time` : `times`}.`); 12 | 13 | return clickCount; 14 | } 15 | -------------------------------------------------------------------------------- /apps/shared-app/src/server/wait.ts: -------------------------------------------------------------------------------- 1 | export async function wait(milliseconds: number): Promise { 2 | return new Promise((resolve) => setTimeout(resolve, milliseconds)); 3 | } 4 | -------------------------------------------------------------------------------- /apps/shared-app/src/shared/main.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export function Main({children}: React.PropsWithChildren): React.ReactNode { 4 | return
{children}
; 5 | } 6 | -------------------------------------------------------------------------------- /apps/shared-app/src/shared/navigation-item.tsx: -------------------------------------------------------------------------------- 1 | import {Link} from '@mfng/core/client'; 2 | import {useRouterLocation} from '@mfng/core/use-router-location'; 3 | import * as React from 'react'; 4 | 5 | export type NavigationItemProps = React.PropsWithChildren<{ 6 | readonly pathname: string; 7 | }>; 8 | 9 | export function NavigationItem({ 10 | children, 11 | pathname, 12 | }: NavigationItemProps): React.ReactNode { 13 | const {pathname: currentPathname} = useRouterLocation(); 14 | 15 | if (pathname === currentPathname) { 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | } 22 | 23 | return ( 24 | 28 | {children} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/shared-app/src/shared/navigation.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {NavigationItem} from './navigation-item.js'; 3 | 4 | export function Navigation(): React.ReactNode { 5 | return ( 6 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /apps/shared-app/src/shared/notification.tsx: -------------------------------------------------------------------------------- 1 | import {clsx} from 'clsx'; 2 | import * as React from 'react'; 3 | 4 | export type NotificationProps = React.PropsWithChildren<{ 5 | readonly status: `success` | `error`; 6 | }>; 7 | 8 | export function Notification({ 9 | children, 10 | status, 11 | }: NotificationProps): React.ReactNode { 12 | return ( 13 |
19 | {children} 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/shared-app/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unstubbable/mfng/c708f51276baacc43b1d22b8af1113af0b652f45/apps/shared-app/static/favicon.ico -------------------------------------------------------------------------------- /apps/shared-app/static/github-mark-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/shared-app/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [`./src/**/*.{ts,tsx}`], 4 | theme: { 5 | screens: { 6 | sm: `480px`, 7 | }, 8 | }, 9 | plugins: [], 10 | }; 11 | -------------------------------------------------------------------------------- /apps/shared-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "checkJs": true, 5 | "outDir": "lib", 6 | "types": ["node", "react/experimental", "react-dom/experimental"], 7 | "sourceMap": true, 8 | "inlineSources": true 9 | }, 10 | "include": ["src/**/*", "../../types", "*.js", "*.cjs"], 11 | "references": [{"path": "../../packages/core"}] 12 | } 13 | -------------------------------------------------------------------------------- /apps/vercel-app/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript", 6 | "tsx": true 7 | } 8 | }, 9 | "module": { 10 | "type": "es6" 11 | }, 12 | "sourceMaps": true 13 | } 14 | -------------------------------------------------------------------------------- /apps/vercel-app/dev-server/build-options.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import url from 'url'; 3 | import type {BuildOptions} from 'esbuild'; 4 | 5 | const currentDirname = path.dirname(url.fileURLToPath(import.meta.url)); 6 | const vercelOutputDirname = path.join(currentDirname, `../.vercel/output`); 7 | const functionDirname = path.join(vercelOutputDirname, `functions/index.func`); 8 | const entryPoint = path.join(functionDirname, `index.js`); 9 | const outfile = path.join(currentDirname, `../dist/dev-server-handler.js`); 10 | 11 | export const clientManifestFilename = path.join( 12 | functionDirname, 13 | `react-client-manifest.json`, 14 | ); 15 | 16 | export const buildOptions: BuildOptions = { 17 | bundle: true, 18 | target: [`es2022`], 19 | entryPoints: [entryPoint], 20 | outfile, 21 | format: `esm`, 22 | external: [`node:async_hooks`], 23 | logLevel: `info`, 24 | }; 25 | -------------------------------------------------------------------------------- /apps/vercel-app/dev-server/build.ts: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import {buildOptions} from './build-options.js'; 3 | 4 | console.log(`Building dev server handler`); 5 | 6 | await esbuild.build(buildOptions); 7 | -------------------------------------------------------------------------------- /apps/vercel-app/dev-server/run.ts: -------------------------------------------------------------------------------- 1 | import {serve} from '@hono/node-server'; 2 | import {serveStatic} from '@hono/node-server/serve-static'; 3 | import {Hono} from 'hono'; 4 | import type {StatusCode} from 'hono/utils/http-status'; 5 | // @ts-ignore 6 | import handler from '../dist/dev-server-handler.js'; 7 | import type {Handler} from '../src/edge-function-handler/index.js'; 8 | import {transformHeaders} from './transform-headers.js'; 9 | 10 | const app = new Hono(); 11 | 12 | app.use(`/client/*`, serveStatic({root: `.vercel/output/static`})); 13 | 14 | app.all(`*`, async (context) => { 15 | const response = await (handler as Handler)(context.req.raw); 16 | const status = response.status as StatusCode; 17 | const headers = transformHeaders(response.headers); 18 | 19 | return context.newResponse(response.body, status, headers); 20 | }); 21 | 22 | const server = serve({fetch: app.fetch, port: 3001}, ({address, port}) => { 23 | const serverUrl = `http://${address.replace(`0.0.0.0`, `localhost`)}:${port}`; 24 | 25 | return console.log(`Started dev server at ${serverUrl}`); 26 | }); 27 | 28 | process.on(`SIGINT`, () => { 29 | console.log(`Closing dev server`); 30 | 31 | server.close((error) => { 32 | if (error) { 33 | console.error(error); 34 | process.exit(1); 35 | } 36 | 37 | console.log(`Dev server closed`); 38 | process.exit(0); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /apps/vercel-app/dev-server/transform-headers.ts: -------------------------------------------------------------------------------- 1 | export function transformHeaders( 2 | headers: Headers, 3 | ): Record { 4 | const headersRecord: Record = {}; 5 | 6 | headers.forEach((value, key) => { 7 | const prevValue = headersRecord[key]; 8 | 9 | if (Array.isArray(prevValue)) { 10 | prevValue.push(value); 11 | } else if (typeof prevValue === `string`) { 12 | headersRecord[key] = [prevValue, value]; 13 | } else { 14 | headersRecord[key] = value; 15 | } 16 | }); 17 | 18 | return headersRecord; 19 | } 20 | -------------------------------------------------------------------------------- /apps/vercel-app/dev-server/watch.ts: -------------------------------------------------------------------------------- 1 | import chokidar from 'chokidar'; 2 | import esbuild from 'esbuild'; 3 | import {buildOptions, clientManifestFilename} from './build-options.js'; 4 | 5 | console.log(`Building dev server handler`); 6 | 7 | const buildContext = await esbuild.context(buildOptions); 8 | 9 | const rebuild = async () => { 10 | try { 11 | const start = Date.now(); 12 | await buildContext.rebuild(); 13 | console.log(`Built dev server handler in ${Date.now() - start} ms`); 14 | } catch (error) { 15 | console.error(error); 16 | } 17 | }; 18 | 19 | chokidar.watch(clientManifestFilename).on(`add`, rebuild).on(`change`, rebuild); 20 | 21 | process.on(`SIGINT`, async () => { 22 | console.log(`Disposing dev server build context`); 23 | 24 | try { 25 | await buildContext.dispose(); 26 | console.log(`Dev server build context disposed`); 27 | process.exit(0); 28 | } catch (error) { 29 | console.error(error); 30 | process.exit(1); 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /apps/vercel-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mfng/vercel-app", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "author": "Hendrik Liebau ", 7 | "type": "module", 8 | "scripts": { 9 | "build": "NODE_OPTIONS='--import=tsx --conditions=@mfng:internal' webpack --mode production", 10 | "build-dev-server": "tsx dev-server/build.ts", 11 | "build-dev-server:dev": "tsx dev-server/build.ts", 12 | "build:dev": "NODE_OPTIONS='--import=tsx --conditions=@mfng:internal' webpack --mode development", 13 | "deploy": "vercel deploy --prebuilt", 14 | "dev": "npm run start", 15 | "start": "tsx watch --clear-screen=false --enable-source-maps --inspect dev-server/run.ts", 16 | "watch": "NODE_OPTIONS='--import=tsx --conditions=@mfng:internal' webpack --mode production --watch", 17 | "watch-dev-server": "tsx watch --clear-screen=false dev-server/watch.ts", 18 | "watch-dev-server:dev": "tsx watch --clear-screen=false dev-server/watch.ts", 19 | "watch:dev": "NODE_OPTIONS='--import=tsx --conditions=@mfng:internal' webpack --mode development --watch" 20 | }, 21 | "dependencies": { 22 | "@mfng/core": "*", 23 | "@mfng/shared-app": "*", 24 | "@vercel/analytics": "^0.1.11", 25 | "react": "0.0.0-experimental-778e1ed2-20240926", 26 | "react-dom": "0.0.0-experimental-778e1ed2-20240926", 27 | "web-vitals": "^3.3.1" 28 | }, 29 | "devDependencies": { 30 | "@hono/node-server": "^1.8.2", 31 | "@mfng/webpack-rsc": "*", 32 | "@swc/core": "^1.3.22", 33 | "@types/react": "^18.3.2", 34 | "@types/react-dom": "^18.3.0", 35 | "autoprefixer": "^10.4.14", 36 | "chokidar": "^3.6.0", 37 | "copy-webpack-plugin": "^11.0.0", 38 | "css-loader": "^6.7.3", 39 | "cssnano": "^5.1.15", 40 | "esbuild": "^0.20.1", 41 | "hono": "^4.1.0", 42 | "mini-css-extract-plugin": "^2.7.5", 43 | "postcss": "^8.4.21", 44 | "postcss-loader": "^7.0.2", 45 | "resolve-typescript-plugin": "^2.0.0", 46 | "source-map-loader": "^4.0.1", 47 | "swc-loader": "^0.2.3", 48 | "tailwindcss": "^3.2.7", 49 | "tsx": "^4.7.1", 50 | "vercel": "^28.18.3", 51 | "webpack": "^5.77.0", 52 | "webpack-cli": "^5.0.1", 53 | "webpack-manifest-plugin": "^5.0.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /apps/vercel-app/src/client.tsx: -------------------------------------------------------------------------------- 1 | import {hydrateApp} from '@mfng/core/client/browser'; 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import 'tailwindcss/tailwind.css'; 4 | 5 | hydrateApp().catch(console.error); 6 | -------------------------------------------------------------------------------- /apps/vercel-app/src/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "routes": [ 4 | { 5 | "src": "/client/(.*)", 6 | "dest": "/client/$1", 7 | "headers": {"Cache-Control": "max-age=31536000, immutable, public"}, 8 | "check": true 9 | }, 10 | { 11 | "src": "/client/(.*)", 12 | "status": 404, 13 | "headers": {"Cache-Control": "no-store"} 14 | }, 15 | { 16 | "src": "/_vercel/insights/script.js", 17 | "dest": "https://va.vercel-scripts.com/v1/script.js" 18 | }, 19 | { 20 | "src": "/_vercel/insights/(.*)", 21 | "dest": "https://vitals.vercel-insights.com/v1/$1" 22 | }, 23 | { 24 | "src": "/.*", 25 | "dest": "/index", 26 | "has": [{"type": "header", "key": "accept", "value": "text/x-component"}], 27 | "headers": {"vary": "accept"} 28 | }, 29 | { 30 | "src": "/.*", 31 | "dest": "/", 32 | "headers": {"vary": "accept"} 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /apps/vercel-app/src/edge-function-handler/.vc-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "runtime": "edge", 3 | "entrypoint": "index.js" 4 | } 5 | -------------------------------------------------------------------------------- /apps/vercel-app/src/edge-function-handler/app.tsx: -------------------------------------------------------------------------------- 1 | // This is in a separate file so that we can configure webpack to use the 2 | // `react-server` layer for this module, and therefore the imported modules 3 | // (React and the server components) will be imported with the required 4 | // `react-server` condition. 5 | 6 | import {App as SharedApp} from '@mfng/shared-app/app.js'; 7 | import * as React from 'react'; 8 | 9 | export function App(): React.ReactNode { 10 | return ( 11 | `Vercel Edge RSC/SSR demo ${pathname}`} 13 | /> 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/vercel-app/src/edge-function-handler/index.tsx: -------------------------------------------------------------------------------- 1 | import {routerLocationAsyncLocalStorage} from '@mfng/core/router-location-async-local-storage'; 2 | import { 3 | createRscActionStream, 4 | createRscAppStream, 5 | createRscFormState, 6 | } from '@mfng/core/server/rsc'; 7 | import {createHtmlStream} from '@mfng/core/server/ssr'; 8 | import * as React from 'react'; 9 | import type {ReactFormState} from 'react-dom/server'; 10 | import {App} from './app.js'; 11 | import { 12 | cssManifest, 13 | jsManifest, 14 | reactClientManifest, 15 | reactServerManifest, 16 | reactSsrManifest, 17 | } from './manifests.js'; 18 | 19 | export type Handler = typeof handler; 20 | 21 | export default async function handler(request: Request): Promise { 22 | switch (request.method) { 23 | case `GET`: 24 | return handleGet(request); 25 | case `POST`: 26 | return handlePost(request); 27 | case `OPTIONS`: 28 | return new Response(null, { 29 | status: 204, 30 | headers: {Allow: `OPTIONS, GET, HEAD, POST`}, 31 | }); 32 | case `HEAD`: 33 | return new Response(null, {status: 200}); 34 | default: 35 | return new Response(null, {status: 405}); 36 | } 37 | } 38 | 39 | const oneDay = 60 * 60 * 24; 40 | 41 | async function renderApp( 42 | request: Request, 43 | formState?: ReactFormState, 44 | ): Promise { 45 | const {pathname, search} = new URL(request.url); 46 | 47 | return routerLocationAsyncLocalStorage.run({pathname, search}, async () => { 48 | const rscAppStream = createRscAppStream(, { 49 | reactClientManifest, 50 | mainCssHref: cssManifest[`main.css`]!, 51 | formState, 52 | }); 53 | 54 | if (request.headers.get(`accept`) === `text/x-component`) { 55 | return new Response(rscAppStream, { 56 | headers: { 57 | 'Content-Type': `text/x-component; charset=utf-8`, 58 | 'Cache-Control': `s-maxage=60, stale-while-revalidate=${oneDay}`, 59 | }, 60 | }); 61 | } 62 | 63 | const htmlStream = await createHtmlStream(rscAppStream, { 64 | reactSsrManifest, 65 | bootstrapScripts: [jsManifest[`main.js`]!], 66 | formState, 67 | }); 68 | 69 | return new Response(htmlStream, { 70 | headers: { 71 | 'Content-Type': `text/html; charset=utf-8`, 72 | 'Cache-Control': `s-maxage=60, stale-while-revalidate=${oneDay}`, 73 | }, 74 | }); 75 | }); 76 | } 77 | 78 | async function handleGet(request: Request): Promise { 79 | return renderApp(request); 80 | } 81 | 82 | async function handlePost(request: Request): Promise { 83 | const serverReferenceId = request.headers.get(`x-rsc-action`); 84 | 85 | if (serverReferenceId) { 86 | // POST via callServer: 87 | 88 | const contentType = request.headers.get(`content-type`); 89 | 90 | const body = await (contentType?.startsWith(`multipart/form-data`) 91 | ? request.formData() 92 | : request.text()); 93 | 94 | const rscActionStream = await createRscActionStream({ 95 | body, 96 | serverReferenceId, 97 | reactClientManifest, 98 | reactServerManifest, 99 | }); 100 | 101 | return new Response(rscActionStream, { 102 | status: rscActionStream ? 200 : 500, 103 | headers: {'Content-Type': `text/x-component`}, 104 | }); 105 | } else { 106 | // POST before hydration (progressive enhancement): 107 | 108 | const formData = await request.formData(); 109 | const formState = await createRscFormState(formData, reactServerManifest); 110 | 111 | return renderApp(request, formState); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /apps/vercel-app/src/edge-function-handler/manifests.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | // These json imports will be preserved by webpack, and then bundled by vercel 4 | // into the final edge function bundle. This needs to be done in this way, 5 | // because most of the manifests are only available after the client bundle has 6 | // been created, at which point webpack has already emitted the server bundle. 7 | 8 | export const reactServerManifest = require(/* webpackIgnore: true */ `./react-server-manifest.json`); 9 | export const reactClientManifest = require(/* webpackIgnore: true */ `./react-client-manifest.json`); 10 | export const reactSsrManifest = require(/* webpackIgnore: true */ `./react-ssr-manifest.json`); 11 | export const cssManifest = require(/* webpackIgnore: true */ `./css-manifest.json`); 12 | export const jsManifest = require(/* webpackIgnore: true */ `./js-manifest.json`); 13 | -------------------------------------------------------------------------------- /apps/vercel-app/src/vitals.ts: -------------------------------------------------------------------------------- 1 | import type {Metric} from 'web-vitals'; 2 | import {onCLS, onFCP, onFID, onLCP, onTTFB} from 'web-vitals'; 3 | 4 | export interface WebVitalsOptions { 5 | readonly debug?: boolean; 6 | } 7 | 8 | declare global { 9 | interface Navigator { 10 | readonly connection?: NetworkInformation; 11 | } 12 | 13 | interface NetworkInformation extends EventTarget { 14 | readonly effectiveType?: string; 15 | } 16 | } 17 | 18 | export function reportWebVitals(options: WebVitalsOptions = {}): void { 19 | try { 20 | onFID((metric) => sendToAnalytics(metric, options)); 21 | onTTFB((metric) => sendToAnalytics(metric, options)); 22 | onLCP((metric) => sendToAnalytics(metric, options)); 23 | onCLS((metric) => sendToAnalytics(metric, options)); 24 | onFCP((metric) => sendToAnalytics(metric, options)); 25 | } catch (err) { 26 | console.error(`[Vercel Analytics]`, err); 27 | } 28 | } 29 | 30 | const analyticsId = process.env.VERCEL_ANALYTICS_ID || ``; 31 | 32 | function sendToAnalytics(metric: Metric, options: WebVitalsOptions): void { 33 | const url = new URL(location.href); 34 | 35 | const body: Record = { 36 | dsn: analyticsId, 37 | id: metric.id, 38 | page: url.pathname, 39 | href: url.href, 40 | event_name: metric.name, 41 | value: metric.value.toString(), 42 | speed: navigator.connection?.effectiveType || ``, 43 | }; 44 | 45 | if (options.debug) { 46 | console.log( 47 | `%c[Vercel Analytics]%c`, 48 | `color: rgb(120, 120, 120)`, 49 | `color: inherit`, 50 | metric.name, 51 | body, 52 | ); 53 | } 54 | 55 | if (!analyticsId) { 56 | return; 57 | } 58 | 59 | const blob = new Blob([new URLSearchParams(body).toString()], { 60 | type: `application/x-www-form-urlencoded`, 61 | }); 62 | 63 | navigator.sendBeacon(`/_vercel/insights/vitals`, blob); 64 | } 65 | -------------------------------------------------------------------------------- /apps/vercel-app/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const config = require(`@mfng/shared-app/tailwind.config.cjs`); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | ...config, 6 | content: [`./src/**/*.{ts,tsx}`, `../shared-app/src/**/*.{ts,tsx}`], 7 | }; 8 | -------------------------------------------------------------------------------- /apps/vercel-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "checkJs": true, 5 | "outDir": "lib", 6 | "resolveJsonModule": true, 7 | "types": ["react/experimental", "react-dom/experimental"] 8 | }, 9 | "include": ["src/**/*", "../../types", "*.js", "*.cjs", "dev-server/**/*"], 10 | "exclude": ["dist"], 11 | "references": [ 12 | {"path": "../shared-app"}, 13 | {"path": "../../packages/core"}, 14 | {"path": "../../packages/webpack-rsc"} 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /apps/vercel-app/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "extends": ["//"], 4 | "pipeline": { 5 | "build": { 6 | "inputs": [ 7 | "../../packages/core/src/**/*", 8 | "../shared-app/src/**/*", 9 | "src/**/*", 10 | "package.json", 11 | "tailwind.config.cjs", 12 | "webpack.config.js" 13 | ], 14 | "outputs": ["dist/**", ".vercel/output/**"] 15 | }, 16 | "build:dev": { 17 | "inputs": [ 18 | "../../packages/core/src/**/*", 19 | "../shared-app/src/**/*", 20 | "src/**/*", 21 | "package.json", 22 | "tailwind.config.cjs", 23 | "webpack.config.js" 24 | ], 25 | "outputs": ["dist/**", ".vercel/output/**"] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/vercel-app/webpack.config.js: -------------------------------------------------------------------------------- 1 | import {createRequire} from 'module'; 2 | import path from 'path'; 3 | import url from 'url'; 4 | import { 5 | WebpackRscClientPlugin, 6 | WebpackRscServerPlugin, 7 | createWebpackRscClientLoader, 8 | createWebpackRscServerLoader, 9 | createWebpackRscSsrLoader, 10 | webpackRscLayerName, 11 | } from '@mfng/webpack-rsc'; 12 | import CopyPlugin from 'copy-webpack-plugin'; 13 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 14 | import ResolveTypeScriptPlugin from 'resolve-typescript-plugin'; 15 | import webpack from 'webpack'; 16 | import {WebpackManifestPlugin} from 'webpack-manifest-plugin'; 17 | 18 | const require = createRequire(import.meta.url); 19 | const currentDirname = path.dirname(url.fileURLToPath(import.meta.url)); 20 | const outputDirname = path.join(currentDirname, `.vercel/output`); 21 | const outputFunctionDirname = path.join(outputDirname, `functions/index.func`); 22 | 23 | const reactServerManifestFilename = path.join( 24 | outputFunctionDirname, 25 | `react-server-manifest.json`, 26 | ); 27 | 28 | const reactClientManifestFilename = path.join( 29 | outputFunctionDirname, 30 | `react-client-manifest.json`, 31 | ); 32 | 33 | const reactSsrManifestFilename = path.join( 34 | outputFunctionDirname, 35 | `react-ssr-manifest.json`, 36 | ); 37 | 38 | const jsManifestFilename = path.join(outputFunctionDirname, `js-manifest.json`); 39 | 40 | const cssManifestFilename = path.join( 41 | outputFunctionDirname, 42 | `css-manifest.json`, 43 | ); 44 | 45 | /** 46 | * @param {unknown} _env 47 | * @param {{readonly mode?: import('webpack').Configuration['mode']}} argv 48 | * @return {import('webpack').Configuration[]} 49 | */ 50 | export default function createConfigs(_env, argv) { 51 | const {mode} = argv; 52 | const dev = mode === `development`; 53 | 54 | /** 55 | * @type {import('webpack').StatsOptions} 56 | */ 57 | const stats = { 58 | assets: true, 59 | builtAt: true, 60 | chunks: false, 61 | colors: true, 62 | groupAssetsByEmitStatus: false, 63 | groupAssetsByExtension: true, 64 | groupAssetsByInfo: false, 65 | groupAssetsByPath: false, 66 | hash: false, 67 | modules: false, 68 | version: false, 69 | }; 70 | 71 | const cssRule = { 72 | test: /\.css$/, 73 | use: [ 74 | MiniCssExtractPlugin.loader, 75 | { 76 | loader: `css-loader`, 77 | options: { 78 | modules: { 79 | localIdentName: dev 80 | ? `[local]__[hash:base64:5]` 81 | : `[hash:base64:7]`, 82 | auto: true, 83 | }, 84 | }, 85 | }, 86 | { 87 | loader: `postcss-loader`, 88 | options: { 89 | postcssOptions: { 90 | plugins: [ 91 | `tailwindcss`, 92 | `autoprefixer`, 93 | ...(dev ? [] : [`cssnano`]), 94 | ], 95 | }, 96 | }, 97 | }, 98 | ], 99 | }; 100 | 101 | /** 102 | * @type {import('@mfng/webpack-rsc').ClientReferencesMap} 103 | */ 104 | const clientReferencesMap = new Map(); 105 | const serverReferencesMap = new Map(); 106 | 107 | const rscServerLoader = createWebpackRscServerLoader({ 108 | clientReferencesMap, 109 | serverReferencesMap, 110 | }); 111 | 112 | const rscSsrLoader = createWebpackRscSsrLoader({serverReferencesMap}); 113 | const rscClientLoader = createWebpackRscClientLoader({serverReferencesMap}); 114 | 115 | /** 116 | * @type {import('webpack').RuleSetUseItem} 117 | */ 118 | const serverSwcLoader = { 119 | loader: `swc-loader`, 120 | options: {env: {targets: {node: 18}}}, 121 | }; 122 | 123 | /** 124 | * @type {import('webpack').Configuration} 125 | */ 126 | const serverConfig = { 127 | name: `server`, 128 | entry: `./src/edge-function-handler/index.tsx`, 129 | target: `webworker`, 130 | output: { 131 | filename: `index.js`, 132 | path: outputFunctionDirname, 133 | libraryTarget: `module`, 134 | chunkFormat: `module`, 135 | devtoolModuleFilenameTemplate: ( 136 | /** @type {{ absoluteResourcePath: string; }} */ info, 137 | ) => info.absoluteResourcePath, 138 | }, 139 | resolve: { 140 | plugins: [new ResolveTypeScriptPlugin()], 141 | conditionNames: [ 142 | `@mfng:internal:node`, 143 | `@mfng:internal`, 144 | `workerd`, 145 | `...`, 146 | ], 147 | }, 148 | module: { 149 | rules: [ 150 | { 151 | resource: [/\/server\/rsc\//, /\/app\.tsx$/], 152 | layer: webpackRscLayerName, 153 | }, 154 | { 155 | resource: /\/server\/shared\//, 156 | layer: `shared`, 157 | }, 158 | { 159 | issuerLayer: webpackRscLayerName, 160 | resolve: {conditionNames: [`react-server`, `workerd`, `...`]}, 161 | }, 162 | { 163 | oneOf: [ 164 | { 165 | issuerLayer: webpackRscLayerName, 166 | test: /\.tsx?$/, 167 | use: [rscServerLoader, serverSwcLoader], 168 | exclude: [/node_modules/], 169 | }, 170 | { 171 | test: /\.tsx?$/, 172 | use: [rscSsrLoader, serverSwcLoader], 173 | // use: `swc-loader`, 174 | exclude: [/node_modules/], 175 | }, 176 | ], 177 | }, 178 | cssRule, 179 | ], 180 | }, 181 | plugins: [ 182 | // server-main.css is not used, but required by MiniCssExtractPlugin. 183 | new MiniCssExtractPlugin({filename: `server-main.css`, runtime: false}), 184 | new WebpackRscServerPlugin({ 185 | clientReferencesMap, 186 | serverReferencesMap, 187 | serverManifestFilename: path.relative( 188 | outputFunctionDirname, 189 | reactServerManifestFilename, 190 | ), 191 | }), 192 | new CopyPlugin({ 193 | patterns: [{from: `src/edge-function-handler/.vc-config.json`}], 194 | }), 195 | ], 196 | experiments: {outputModule: true, layers: true}, 197 | performance: {maxAssetSize: 1_000_000, maxEntrypointSize: 1_000_000}, 198 | externals: [`node:async_hooks`], 199 | devtool: `source-map`, 200 | mode, 201 | stats, 202 | }; 203 | 204 | const clientOutputDirname = path.join(outputDirname, `static/client`); 205 | 206 | /** 207 | * @type {import('webpack').Configuration} 208 | */ 209 | const clientConfig = { 210 | name: `client`, 211 | dependencies: [`server`], 212 | entry: `./src/client.tsx`, 213 | output: { 214 | filename: dev ? `main.js` : `main.[contenthash:8].js`, 215 | path: clientOutputDirname, 216 | clean: !dev, 217 | publicPath: `/client/`, 218 | }, 219 | resolve: { 220 | plugins: [new ResolveTypeScriptPlugin()], 221 | conditionNames: [`@mfng:internal`, `...`], 222 | }, 223 | module: { 224 | rules: [ 225 | {test: /\.js$/, loader: `source-map-loader`, enforce: `pre`}, 226 | { 227 | test: /\.tsx?$/, 228 | use: [rscClientLoader, `swc-loader`], 229 | exclude: [/node_modules/], 230 | }, 231 | cssRule, 232 | ], 233 | }, 234 | plugins: [ 235 | new webpack.EnvironmentPlugin({VERCEL_ANALYTICS_ID: ``}), 236 | new CopyPlugin({ 237 | patterns: [ 238 | { 239 | from: path.join( 240 | path.dirname(require.resolve(`@mfng/shared-app/package.json`)), 241 | `static`, 242 | ), 243 | }, 244 | {from: `src/config.json`, to: outputDirname}, 245 | ], 246 | }), 247 | new MiniCssExtractPlugin({ 248 | filename: dev ? `main.css` : `main.[contenthash:8].css`, 249 | runtime: false, 250 | }), 251 | new WebpackManifestPlugin({ 252 | fileName: cssManifestFilename, 253 | publicPath: `/client/`, 254 | filter: (file) => file.path.endsWith(`.css`), 255 | }), 256 | new WebpackManifestPlugin({ 257 | fileName: jsManifestFilename, 258 | publicPath: `/client/`, 259 | filter: (file) => file.path.endsWith(`.js`), 260 | }), 261 | new WebpackRscClientPlugin({ 262 | clientReferencesMap, 263 | clientManifestFilename: path.relative( 264 | clientOutputDirname, 265 | reactClientManifestFilename, 266 | ), 267 | ssrManifestFilename: path.relative( 268 | clientOutputDirname, 269 | reactSsrManifestFilename, 270 | ), 271 | }), 272 | ], 273 | devtool: `source-map`, 274 | mode, 275 | stats, 276 | }; 277 | 278 | return [serverConfig, clientConfig]; 279 | } 280 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mfng", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Micro Frontends Next Generation - An RSC Playground", 6 | "license": "MIT", 7 | "author": "Hendrik Liebau ", 8 | "type": "module", 9 | "workspaces": [ 10 | "apps/aws-app", 11 | "apps/cloudflare-app", 12 | "apps/shared-app", 13 | "apps/vercel-app", 14 | "packages/core", 15 | "packages/webpack-rsc" 16 | ], 17 | "scripts": { 18 | "build": "turbo build", 19 | "build:dev": "turbo build:dev", 20 | "clean": "npm run compile:clean && rimraf -v {apps,packages}/*/{dist,lib,.turbo,.vercel} node_modules/.cache/turbo", 21 | "compile": "tsc --build", 22 | "compile:clean": "tsc --build --clean", 23 | "compile:watch": "tsc --build --watch --preserveWatchOutput", 24 | "deploy": "turbo deploy", 25 | "dev": "turbo --concurrency 19 watch:dev watch-dev-server:dev dev", 26 | "format:check": "prettier --check .", 27 | "format:write": "prettier --write .", 28 | "lint": "eslint .", 29 | "prerelease": "npm run compile", 30 | "release": "changeset publish", 31 | "start": "turbo --concurrency 19 watch watch-dev-server start", 32 | "test": "turbo test", 33 | "version": "changeset version", 34 | "postversion": "npm install --package-lock-only" 35 | }, 36 | "devDependencies": { 37 | "@changesets/cli": "^2.27.1", 38 | "@types/node": "^20.11.26", 39 | "@typescript-eslint/eslint-plugin": "^7.9.0", 40 | "@typescript-eslint/parser": "^7.9.0", 41 | "cross-env": "^7.0.3", 42 | "eslint": "^8.53.0", 43 | "eslint-config-prettier": "^9.0.0", 44 | "eslint-import-resolver-typescript": "^3.6.1", 45 | "eslint-plugin-import": "npm:eslint-plugin-i@^2.29.0", 46 | "eslint-plugin-markdown": "^3.0.1", 47 | "prettier": "^2.8.7", 48 | "prettier-plugin-tailwindcss": "^0.2.4", 49 | "react-dom": "0.0.0-experimental-778e1ed2-20240926", 50 | "rimraf": "^4.4.1", 51 | "turbo": "^1.8.8", 52 | "typescript": "^5.4.5" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @mfng/core 2 | 3 | ## 4.1.4 4 | 5 | ### Patch Changes 6 | 7 | - 9d192c8: Use a simple replacement for `React.cache` 8 | 9 | ## 4.1.3 10 | 11 | ### Patch Changes 12 | 13 | - cbd5bea: Fix react peer dependency 14 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # `@mfng/core` 2 | 3 | ⚠️ **Experimental** 4 | 5 | This package contains the essential building blocks required on both the server 6 | and the client to create a streaming, server-side rendered, and properly 7 | hydrated RSC app. It also provides utilities needed for server-centric, 8 | client-side navigation. 9 | 10 | ## Getting Started 11 | 12 | To use this library in your React Server Components project, follow these 13 | high-level steps: 14 | 15 | 1. Install the library along with React Canary: 16 | 17 | ```sh 18 | npm install @mfng/core react@canary react-dom@canary react-server-dom-webpack@canary 19 | ``` 20 | 21 | 2. Create a server entry that handles GET requests to create RSC app streams and 22 | HTML streams, as well as POST requests for creating RSC action streams. 23 | Optionally, add support for progressively enhanced forms. 24 | 25 | 3. Create a client entry that hydrates the server-rendered app and fetches RSC 26 | streams during navigation or when executing server actions. 27 | 28 | 4. Set up your webpack config as described in the `@mfng/webpack-rsc` 29 | [README](https://github.com/unstubbable/mfng/blob/main/packages/webpack-rsc/README.md). 30 | 31 | 5. Create a simple dev server using [Hono](https://hono.dev) and 32 | [tsx](https://github.com/privatenumber/tsx). 33 | 34 | ## Building Blocks 35 | 36 | ### Server 37 | 38 | #### `@mfng/core/server/rsc` 39 | 40 | - `createRscAppStream` 41 | - `createRscActionStream` 42 | - `createRscFormState` 43 | 44 | #### `@mfng/core/server/ssr` 45 | 46 | - `createHtmlStream` 47 | 48 | #### `@mfng/core/router-location-async-local-storage` 49 | 50 | - `routerLocationAsyncLocalStorage` 51 | 52 | ### Client 53 | 54 | #### `@mfng/core/client/browser` 55 | 56 | - `hydrateApp` 57 | - `callServer` (usually not directly needed, encapsulated by `hydrateApp`) 58 | - `Router` (usually not directly needed, encapsulated by `hydrateApp`) 59 | 60 | #### `@mfng/core/client` 61 | 62 | - `useRouter` 63 | - `Link` 64 | - `CallServerError` 65 | 66 | ### Universal (Client & Server) 67 | 68 | #### `@mfng/core/use-router-location` 69 | 70 | - `useRouterLocation` 71 | 72 | ## Putting It All Together 73 | 74 | I would recommend taking a look at the example apps. The 75 | [AWS app](https://github.com/unstubbable/mfng/tree/main/apps/aws-app) has a 76 | particularly clean setup. 77 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mfng/core", 3 | "version": "4.1.4", 4 | "description": "Core server and client utilities for bootstrapping a React Server Components app", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/unstubbable/mfng.git", 8 | "directory": "packages/core" 9 | }, 10 | "license": "MIT", 11 | "author": "Hendrik Liebau ", 12 | "type": "module", 13 | "exports": { 14 | "./client": { 15 | "@mfng:internal": "./src/client/index.ts", 16 | "default": "./lib/client/index.js" 17 | }, 18 | "./client/browser": { 19 | "@mfng:internal": "./src/client/browser.ts", 20 | "default": "./lib/client/browser.js" 21 | }, 22 | "./server/rsc": { 23 | "@mfng:internal": "./src/server/rsc/index.ts", 24 | "default": "./lib/server/rsc/index.js" 25 | }, 26 | "./server/ssr": { 27 | "@mfng:internal": "./src/server/ssr/index.ts", 28 | "default": "./lib/server/ssr/index.js" 29 | }, 30 | "./use-router-location": { 31 | "@mfng:internal:node": "./src/server/shared/use-router-location.ts", 32 | "@mfng:internal": "./src/client/use-router-location.ts", 33 | "types": "./lib/use-router-location.d.ts", 34 | "node": "./lib/server/shared/use-router-location.js", 35 | "default": "./lib/client/use-router-location.js" 36 | }, 37 | "./router-location-async-local-storage": { 38 | "@mfng:internal": "./src/server/shared/router-location-async-local-storage.ts", 39 | "default": "./lib/server/shared/router-location-async-local-storage.js" 40 | } 41 | }, 42 | "files": [ 43 | "lib" 44 | ], 45 | "dependencies": { 46 | "htmlescape": "^1.1.1" 47 | }, 48 | "devDependencies": { 49 | "@types/htmlescape": "^1.1.1", 50 | "@types/react": "^18.3.2", 51 | "@types/react-dom": "^18.3.0", 52 | "react": "0.0.0-experimental-778e1ed2-20240926", 53 | "react-dom": "0.0.0-experimental-778e1ed2-20240926", 54 | "react-server-dom-webpack": "0.0.0-experimental-778e1ed2-20240926" 55 | }, 56 | "peerDependencies": { 57 | "react": "*", 58 | "react-dom": "*", 59 | "react-server-dom-webpack": "*" 60 | }, 61 | "publishConfig": { 62 | "access": "public" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/core/src/client/browser.ts: -------------------------------------------------------------------------------- 1 | export * from './call-server.js'; 2 | export * from './hydrate-app.js'; 3 | export * from './router.js'; 4 | -------------------------------------------------------------------------------- /packages/core/src/client/call-server-error.ts: -------------------------------------------------------------------------------- 1 | export class CallServerError extends Error { 2 | #statusCode: number; 3 | 4 | constructor(message: string, statusCode: number) { 5 | super(message); 6 | this.#statusCode = statusCode; 7 | } 8 | 9 | get statusCode(): number { 10 | return this.#statusCode; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/core/src/client/call-server.ts: -------------------------------------------------------------------------------- 1 | // Do not import CallServerError relatively. This must be the same import source 2 | // as consumers would use it when importing it into their client components. 3 | import {CallServerError} from '@mfng/core/client'; 4 | import type {ReactServerValue} from 'react-server-dom-webpack'; 5 | import ReactServerDOMClient from 'react-server-dom-webpack/client.browser'; 6 | 7 | export async function callServer( 8 | id: string, 9 | args: ReactServerValue, 10 | ): Promise { 11 | return ReactServerDOMClient.createFromFetch( 12 | fetch(`/`, { 13 | method: `POST`, 14 | headers: {'accept': `text/x-component`, 'x-rsc-action': id}, 15 | body: await ReactServerDOMClient.encodeReply(args), 16 | }).then((response) => { 17 | if (response.ok) { 18 | return response; 19 | } 20 | 21 | throw new CallServerError(response.statusText, response.status); 22 | }), 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/core/src/client/create-simple-promise-cache.ts: -------------------------------------------------------------------------------- 1 | export function createSimplePromiseCache( 2 | fn: (key: string) => Promise, 3 | initialEntries?: [string, Promise][], 4 | ): (key: string) => Promise { 5 | const cache = new Map>(initialEntries); 6 | 7 | // eslint-disable-next-line @typescript-eslint/promise-function-async 8 | return (key) => { 9 | const cachedPromise = cache.get(key); 10 | 11 | if (cachedPromise) { 12 | return cachedPromise; 13 | } 14 | 15 | const promise = fn(key); 16 | 17 | cache.set(key, promise); 18 | 19 | return promise; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/src/client/hydrate-app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type {ReactFormState} from 'react-dom/client'; 3 | import ReactDOMClient from 'react-dom/client'; 4 | import ReactServerDOMClient from 'react-server-dom-webpack/client.browser'; 5 | import {callServer} from './call-server.js'; 6 | import {createSimplePromiseCache} from './create-simple-promise-cache.js'; 7 | import {createUrlPath} from './router-location-utils.js'; 8 | import {Router} from './router.js'; 9 | 10 | export interface RscAppResult { 11 | readonly root: React.ReactElement; 12 | readonly formState?: ReactFormState; 13 | } 14 | 15 | const originRegExp = new RegExp(`^${document.location.origin}`); 16 | 17 | export function findSourceMapUrl(filename: string): string | null { 18 | return `${document.location.origin}/source-maps?filename=${encodeURIComponent( 19 | filename.replace(originRegExp, ``), 20 | )}`; 21 | } 22 | 23 | export async function hydrateApp(): Promise { 24 | const {root: initialRoot, formState} = 25 | await ReactServerDOMClient.createFromReadableStream( 26 | self.initialRscResponseStream, 27 | {callServer, findSourceMapURL: findSourceMapUrl}, 28 | ); 29 | 30 | const initialUrlPath = createUrlPath(document.location); 31 | 32 | const fetchRoot = createSimplePromiseCache( 33 | async function fetchRoot(urlPath: string): Promise { 34 | const {root} = await ReactServerDOMClient.createFromFetch( 35 | fetch(urlPath, {headers: {accept: `text/x-component`}}), 36 | {callServer, findSourceMapURL: findSourceMapUrl}, 37 | ); 38 | 39 | return root; 40 | }, 41 | [[initialUrlPath, Promise.resolve(initialRoot)]], 42 | ); 43 | 44 | React.startTransition(() => { 45 | ReactDOMClient.hydrateRoot( 46 | document, 47 | 48 | 49 | , 50 | {formState}, 51 | ); 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /packages/core/src/client/index.ts: -------------------------------------------------------------------------------- 1 | export * from './call-server-error.js'; 2 | export * from './link.js'; 3 | export * from './use-router.js'; 4 | -------------------------------------------------------------------------------- /packages/core/src/client/link.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {useRouter} from '@mfng/core/client'; 4 | import type {RouterLocation} from '@mfng/core/use-router-location'; 5 | import {useRouterLocation} from '@mfng/core/use-router-location'; 6 | import * as React from 'react'; 7 | import {createUrlPath} from './router-location-utils.js'; 8 | 9 | export type LinkProps = React.PropsWithChildren<{ 10 | readonly to: Partial; 11 | readonly action?: 'push' | 'replace'; 12 | readonly className?: string; 13 | }>; 14 | 15 | export function Link({ 16 | children, 17 | to, 18 | action = `push`, 19 | className, 20 | }: LinkProps): React.ReactNode { 21 | const router = useRouter(); 22 | const location = useRouterLocation(); 23 | 24 | const urlPath = createUrlPath({ 25 | pathname: to.pathname ?? location.pathname, 26 | search: to.search ?? ``, 27 | }); 28 | 29 | const handleClick: React.MouseEventHandler = (event) => { 30 | if (!event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) { 31 | event.preventDefault(); 32 | router[action](to); 33 | } 34 | }; 35 | 36 | return ( 37 | 38 | {children} 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /packages/core/src/client/router-location-utils.ts: -------------------------------------------------------------------------------- 1 | import type {RouterLocation} from '../use-router-location.js'; 2 | 3 | export function createUrlPath(location: RouterLocation): string { 4 | const {pathname, search} = location; 5 | 6 | return `${pathname}${normalizeSearch(search)}`; 7 | } 8 | 9 | export function createUrl(location: RouterLocation): URL { 10 | return new URL(createUrlPath(location), document.location.origin); 11 | } 12 | 13 | function normalizeSearch(search: string): string { 14 | return `${search.replace(/(^[^?].*)/, `?$1`)}`; 15 | } 16 | -------------------------------------------------------------------------------- /packages/core/src/client/router.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type {RouterLocation} from '../use-router-location.js'; 3 | import {createUrl, createUrlPath} from './router-location-utils.js'; 4 | import {RouterLocationContext} from './use-router-location.js'; 5 | import {RouterContext} from './use-router.js'; 6 | 7 | export interface RouterProps { 8 | readonly fetchRoot: (urlPath: string) => Promise; 9 | } 10 | 11 | interface RouterState { 12 | readonly location: RouterLocation; 13 | readonly action: RouterAction; 14 | } 15 | 16 | type RouterAction = 'push' | 'replace' | 'pop'; 17 | 18 | export function Router({fetchRoot}: RouterProps): React.ReactNode { 19 | const [routerState, setRouterState] = React.useState(() => { 20 | const {pathname, search} = document.location; 21 | 22 | return {location: {pathname, search}, action: `pop`}; 23 | }); 24 | 25 | const [isPending, startTransition] = React.useTransition(); 26 | 27 | const navigate = React.useCallback( 28 | (to: Partial, action: RouterAction) => { 29 | startTransition(() => 30 | setRouterState(({location}) => ({ 31 | location: { 32 | pathname: to.pathname ?? location.pathname, 33 | search: to.search ?? ``, 34 | }, 35 | action, 36 | })), 37 | ); 38 | }, 39 | [], 40 | ); 41 | 42 | const push = React.useCallback( 43 | (to: Partial) => navigate(to, `push`), 44 | [navigate], 45 | ); 46 | 47 | const replace = React.useCallback( 48 | (to: Partial) => navigate(to, `replace`), 49 | [navigate], 50 | ); 51 | 52 | React.useEffect(() => { 53 | const abortController = new AbortController(); 54 | 55 | addEventListener( 56 | `popstate`, 57 | () => { 58 | const {pathname, search} = document.location; 59 | 60 | startTransition(() => 61 | setRouterState({location: {pathname, search}, action: `pop`}), 62 | ); 63 | }, 64 | {signal: abortController.signal}, 65 | ); 66 | 67 | return () => abortController.abort(); 68 | }, []); 69 | 70 | React.useEffect(() => { 71 | const {location, action} = routerState; 72 | 73 | if (action === `push`) { 74 | history.pushState(null, ``, createUrl(location)); 75 | } else if (action === `replace`) { 76 | history.replaceState(null, ``, createUrl(location)); 77 | } 78 | }, [routerState]); 79 | 80 | const rootPromise = fetchRoot(createUrlPath(routerState.location)); 81 | 82 | return ( 83 | 84 | 85 | {React.use(rootPromise)} 86 | 87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /packages/core/src/client/use-router-location.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import type {RouterLocation} from '../use-router-location.js'; 5 | 6 | export const RouterLocationContext = React.createContext< 7 | RouterLocation | undefined 8 | >(undefined); 9 | 10 | export function useRouterLocation(): RouterLocation { 11 | const routerLocation = React.useContext(RouterLocationContext); 12 | 13 | if (!routerLocation) { 14 | throw new Error( 15 | `Called useRouterLocation() outside of a RouterLocationContext.Provider`, 16 | ); 17 | } 18 | 19 | return routerLocation; 20 | } 21 | -------------------------------------------------------------------------------- /packages/core/src/client/use-router.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import type {RouterLocation} from '../use-router-location.js'; 5 | 6 | export interface RouterContextValue { 7 | readonly isPending: boolean; 8 | readonly push: (to: Partial) => void; 9 | readonly replace: (to: Partial) => void; 10 | } 11 | 12 | export const RouterContext = React.createContext({ 13 | isPending: false, 14 | push: () => undefined, 15 | replace: () => undefined, 16 | }); 17 | 18 | export function useRouter(): RouterContextValue { 19 | return React.useContext(RouterContext); 20 | } 21 | -------------------------------------------------------------------------------- /packages/core/src/global.d.ts: -------------------------------------------------------------------------------- 1 | export {}; // ts(2669) 2 | 3 | declare global { 4 | interface Window { 5 | initialRscResponseStream: Readable; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/server/rsc/create-rsc-action-stream.ts: -------------------------------------------------------------------------------- 1 | import type {ClientManifest, ServerManifest} from 'react-server-dom-webpack'; 2 | import ReactServerDOMServer from 'react-server-dom-webpack/server.edge'; 3 | 4 | export interface CreateRscActionStreamOptions { 5 | /** 6 | * Form data file entry values are not supported yet. 7 | */ 8 | readonly body: string | FormData; 9 | readonly serverReferenceId: string; 10 | readonly reactClientManifest: ClientManifest; 11 | readonly reactServerManifest: ServerManifest; 12 | } 13 | 14 | declare var __webpack_require__: ( 15 | moduleId: string | number, 16 | ) => Record; 17 | 18 | export async function createRscActionStream( 19 | options: CreateRscActionStreamOptions, 20 | ): Promise | undefined> { 21 | const {body, serverReferenceId, reactClientManifest, reactServerManifest} = 22 | options; 23 | 24 | const serverReference = reactServerManifest[serverReferenceId]; 25 | 26 | if (!serverReference) { 27 | console.error( 28 | `Unknown server reference ID: ${JSON.stringify(serverReferenceId)}`, 29 | ); 30 | 31 | console.debug( 32 | `React server manifest:`, 33 | JSON.stringify(reactServerManifest, null, 2), 34 | ); 35 | 36 | return undefined; 37 | } 38 | 39 | const action = __webpack_require__(serverReference.id)[serverReference.name]; 40 | 41 | if (typeof action !== `function`) { 42 | console.error( 43 | `The resolved server reference ${JSON.stringify( 44 | serverReferenceId, 45 | )} is not a function.`, 46 | action, 47 | ); 48 | 49 | return undefined; 50 | } 51 | 52 | const args = await ReactServerDOMServer.decodeReply( 53 | body, 54 | reactServerManifest, 55 | ); 56 | 57 | const actionPromise = action.apply(null, args); 58 | 59 | return ReactServerDOMServer.renderToReadableStream( 60 | actionPromise, 61 | reactClientManifest, 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /packages/core/src/server/rsc/create-rsc-app-stream.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type {ReactFormState} from 'react-dom/server'; 3 | import type {ClientManifest} from 'react-server-dom-webpack'; 4 | import ReactServerDOMServer from 'react-server-dom-webpack/server.edge'; 5 | 6 | export interface CreateRscAppStreamOptions { 7 | readonly reactClientManifest: ClientManifest; 8 | readonly mainCssHref?: string; 9 | readonly formState?: ReactFormState; 10 | } 11 | 12 | export interface RscAppResult { 13 | readonly root: React.ReactElement; 14 | readonly formState?: ReactFormState; 15 | } 16 | 17 | export function createRscAppStream( 18 | app: React.ReactNode, 19 | options: CreateRscAppStreamOptions, 20 | ): ReadableStream { 21 | const {reactClientManifest, mainCssHref, formState} = options; 22 | 23 | const root = ( 24 | <> 25 | {mainCssHref && ( 26 | 32 | )} 33 | {app} 34 | 35 | ); 36 | 37 | return ReactServerDOMServer.renderToReadableStream( 38 | {root, formState: formState as (string | number)[]}, 39 | reactClientManifest, 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /packages/core/src/server/rsc/create-rsc-form-state.ts: -------------------------------------------------------------------------------- 1 | import type {ReactFormState} from 'react-dom/server'; 2 | import type {ServerManifest} from 'react-server-dom-webpack'; 3 | import ReactServerDOMServer from 'react-server-dom-webpack/server.edge'; 4 | 5 | export async function createRscFormState( 6 | formData: FormData, 7 | reactServerManifest: ServerManifest, 8 | ): Promise { 9 | const action = await ReactServerDOMServer.decodeAction( 10 | formData, 11 | reactServerManifest, 12 | ); 13 | 14 | if (!action) { 15 | return undefined; 16 | } 17 | 18 | const result = await action(); 19 | 20 | const formState = await ReactServerDOMServer.decodeFormState( 21 | result, 22 | formData, 23 | reactServerManifest, 24 | ); 25 | 26 | return formState ?? undefined; 27 | } 28 | -------------------------------------------------------------------------------- /packages/core/src/server/rsc/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-rsc-action-stream.js'; 2 | export * from './create-rsc-app-stream.js'; 3 | export * from './create-rsc-form-state.js'; 4 | -------------------------------------------------------------------------------- /packages/core/src/server/shared/router-location-async-local-storage.ts: -------------------------------------------------------------------------------- 1 | import {AsyncLocalStorage} from 'node:async_hooks'; 2 | import type {RouterLocation} from '../../use-router-location.js'; 3 | 4 | export const routerLocationAsyncLocalStorage = 5 | new AsyncLocalStorage(); 6 | -------------------------------------------------------------------------------- /packages/core/src/server/shared/use-router-location.ts: -------------------------------------------------------------------------------- 1 | import type {RouterLocation} from '../../use-router-location.js'; 2 | import {routerLocationAsyncLocalStorage} from './router-location-async-local-storage.js'; 3 | 4 | export function useRouterLocation(): RouterLocation { 5 | const routerLocation = routerLocationAsyncLocalStorage.getStore(); 6 | 7 | if (!routerLocation) { 8 | throw new Error( 9 | `useRouterLocation was called outside of an asynchronous context initialized by calling routerLocationAsyncLocalStorage.run()`, 10 | ); 11 | } 12 | 13 | return routerLocation; 14 | } 15 | -------------------------------------------------------------------------------- /packages/core/src/server/ssr/create-html-stream.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/order 2 | import './node-compat-environment.js'; 3 | 4 | import * as React from 'react'; 5 | import type {ReactFormState} from 'react-dom/server'; 6 | import ReactDOMServer from 'react-dom/server.edge'; 7 | import type {SSRManifest} from 'react-server-dom-webpack'; 8 | import ReactServerDOMClient from 'react-server-dom-webpack/client.edge'; 9 | import type {RscAppResult} from '../rsc/create-rsc-app-stream.js'; 10 | import {createInitialRscResponseTransformStream} from './create-initial-rsc-response-transform-stream.js'; 11 | 12 | export interface CreateHtmlStreamOptions { 13 | readonly reactSsrManifest: SSRManifest; 14 | readonly bootstrapScripts?: string[]; 15 | readonly formState?: ReactFormState; 16 | } 17 | 18 | const rscResponseStreamBootstrapScriptContent = `(()=>{const t=new TransformStream(),w=t.writable.getWriter(),e=new TextEncoder();self.initialRscResponseStream=t.readable;self.addInitialRscResponseChunk=(text)=>w.write(e.encode(text))})()`; 19 | 20 | export async function createHtmlStream( 21 | rscStream: ReadableStream, 22 | options: CreateHtmlStreamOptions, 23 | ): Promise> { 24 | const {reactSsrManifest, bootstrapScripts, formState} = options; 25 | const [rscStream1, rscStream2] = rscStream.tee(); 26 | 27 | let cachedRootPromise: Promise | undefined; 28 | 29 | const getRoot = async () => { 30 | const {root} = 31 | await ReactServerDOMClient.createFromReadableStream( 32 | rscStream1, 33 | {ssrManifest: reactSsrManifest}, 34 | ); 35 | 36 | return root; 37 | }; 38 | 39 | const ServerRoot = (): React.ReactNode => { 40 | // The root needs to be created during render, otherwise there will be no 41 | // current request defined that the chunk preloads can be attached to. 42 | cachedRootPromise ??= getRoot(); 43 | 44 | return React.use(cachedRootPromise); 45 | }; 46 | 47 | const htmlStream = await ReactDOMServer.renderToReadableStream( 48 | , 49 | { 50 | bootstrapScriptContent: rscResponseStreamBootstrapScriptContent, 51 | bootstrapScripts, 52 | formState, 53 | }, 54 | ); 55 | 56 | return htmlStream.pipeThrough( 57 | createInitialRscResponseTransformStream(rscStream2), 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /packages/core/src/server/ssr/create-initial-rsc-response-transform-stream.ts: -------------------------------------------------------------------------------- 1 | import {sanitize} from 'htmlescape'; 2 | import {nextMacroTask} from './next-macro-task.js'; 3 | 4 | const closingBodyHtmlText = ``; 5 | 6 | export function createInitialRscResponseTransformStream( 7 | rscStream: ReadableStream, 8 | ): ReadableWritablePair { 9 | let removedClosingBodyHtmlText = false; 10 | let insertingRscStreamScripts: Promise | undefined; 11 | let finishedInsertingRscStreamScripts = false; 12 | 13 | const textDecoder = new TextDecoder(); 14 | const textEncoder = new TextEncoder(); 15 | 16 | return new TransformStream({ 17 | transform(chunk, controller) { 18 | const text = textDecoder.decode(chunk, {stream: true}); 19 | 20 | if ( 21 | text.endsWith(closingBodyHtmlText) && 22 | !finishedInsertingRscStreamScripts 23 | ) { 24 | const [withoutClosingBodyHtmlText] = text.split(closingBodyHtmlText); 25 | 26 | controller.enqueue(textEncoder.encode(withoutClosingBodyHtmlText)); 27 | 28 | removedClosingBodyHtmlText = true; 29 | } else { 30 | controller.enqueue(chunk); 31 | } 32 | 33 | insertingRscStreamScripts ||= new Promise(async (resolve) => { 34 | const reader = rscStream.getReader(); 35 | 36 | try { 37 | while (true) { 38 | const result = await reader.read(); 39 | 40 | if (result.done) { 41 | finishedInsertingRscStreamScripts = true; 42 | 43 | if (removedClosingBodyHtmlText) { 44 | controller.enqueue(textEncoder.encode(closingBodyHtmlText)); 45 | } 46 | 47 | return resolve(); 48 | } 49 | 50 | await nextMacroTask(); 51 | 52 | // Expects `self.addInitialRscResponseChunk` to be defined in 53 | // `bootstrapScriptContent`. If we were to enqueue the 54 | // initialization script in this controller, it might be parsed and 55 | // evaluated by the browser after the bootstrap script tries to read 56 | // from `self.initialRscResponseStream`. Defining it in 57 | // `bootstrapScriptContent` avoids this race condition. 58 | controller.enqueue( 59 | textEncoder.encode( 60 | ``, 65 | ), 66 | ); 67 | } 68 | } catch (error) { 69 | controller.error(error); 70 | } 71 | }); 72 | }, 73 | 74 | async flush() { 75 | return insertingRscStreamScripts; 76 | }, 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /packages/core/src/server/ssr/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-html-stream.js'; 2 | -------------------------------------------------------------------------------- /packages/core/src/server/ssr/next-macro-task.ts: -------------------------------------------------------------------------------- 1 | export async function nextMacroTask(): Promise { 2 | return new Promise((resolve) => setTimeout(resolve, 0)); 3 | } 4 | -------------------------------------------------------------------------------- /packages/core/src/server/ssr/node-compat-environment.ts: -------------------------------------------------------------------------------- 1 | // This file should be imported before any others. It sets up the environment 2 | // for later imports to work properly. 3 | 4 | import {AsyncLocalStorage as NodeAsyncLocalStorage} from 'node:async_hooks'; 5 | 6 | declare global { 7 | var AsyncLocalStorage: typeof NodeAsyncLocalStorage; 8 | } 9 | 10 | // Expose AsyncLocalStorage as a global for react usage. 11 | globalThis.AsyncLocalStorage = NodeAsyncLocalStorage; 12 | -------------------------------------------------------------------------------- /packages/core/src/use-router-location.ts: -------------------------------------------------------------------------------- 1 | export interface RouterLocation { 2 | readonly pathname: string; 3 | readonly search: string; 4 | } 5 | 6 | export declare function useRouterLocation(): RouterLocation; 7 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src/**/*", "../../types"], 4 | "compilerOptions": { 5 | "outDir": "lib", 6 | "rootDir": "src", 7 | "types": ["node", "react/experimental", "react-dom/experimental"], 8 | "sourceMap": true, 9 | "inlineSources": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/webpack-rsc/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript" 6 | } 7 | }, 8 | "module": { 9 | "type": "es6" 10 | }, 11 | "sourceMaps": true 12 | } 13 | -------------------------------------------------------------------------------- /packages/webpack-rsc/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @mfng/webpack-rsc 2 | 3 | ## 4.2.1 4 | 5 | ### Patch Changes 6 | 7 | - 247f98c: Prevent race conditions during webpack compilation 8 | 9 | ## 4.2.0 10 | 11 | ### Minor Changes 12 | 13 | - fb39d01: Support default exports and non-function client components 14 | 15 | ## 4.1.0 16 | 17 | ### Minor Changes 18 | 19 | - faf0e5d: Add support for parsing import assertions & attributes to rsc & 20 | client loaders 21 | - 2576ed1: Add support for parsing import assertions & attributes 22 | 23 | ### Patch Changes 24 | 25 | - dce077f: Update RSC loader configs in README 26 | -------------------------------------------------------------------------------- /packages/webpack-rsc/README.md: -------------------------------------------------------------------------------- 1 | # Webpack RSC Integration 2 | 3 | ⚠️ **Experimental** 4 | 5 | This library provides a set of Webpack loaders and plugins required for building 6 | a React Server Components (RSC) application bundle for the browser, as well as 7 | for the server, with server-side rendering (SSR). 8 | 9 | The server bundle can be deployed to any serverless, edge, or Node.js-based 10 | environment. 11 | 12 | `@mfng/webpack-rsc` can be used standalone as an RSC bundling solution or in 13 | conjunction with 14 | [`@mfng/core`](https://github.com/unstubbable/mfng/blob/main/packages/core/README.md). 15 | 16 | > Disclaimer: There are many moving parts involved in creating an RSC app that 17 | > also handles SSR, without using a framework like Next.js. This library only 18 | > provides the necessary parts for bundling the app. For a fully working 19 | > integration, see https://github.com/unstubbable/mfng. 20 | 21 | ## Getting Started 22 | 23 | To use this library in your React Server Components project, follow these steps: 24 | 25 | 1. Install the library as a dev dependency: 26 | 27 | ```sh 28 | npm install --save-dev @mfng/webpack-rsc 29 | ``` 30 | 31 | 2. Update your `webpack.config.js` to include the loaders and plugins provided 32 | by this library. See the example configuration below for reference. 33 | 34 | ## Example Webpack Configuration 35 | 36 | The following example demonstrates how to use the loaders and plugins in a 37 | Webpack configuration: 38 | 39 | ```js 40 | import { 41 | WebpackRscClientPlugin, 42 | WebpackRscServerPlugin, 43 | createWebpackRscClientLoader, 44 | createWebpackRscServerLoader, 45 | createWebpackRscSsrLoader, 46 | webpackRscLayerName, 47 | } from '@mfng/webpack-rsc'; 48 | 49 | const clientReferencesMap = new Map(); 50 | const serverReferencesMap = new Map(); 51 | 52 | const rscServerLoader = createWebpackRscServerLoader({ 53 | clientReferencesMap, 54 | serverReferencesMap, 55 | }); 56 | 57 | const rscSsrLoader = createWebpackRscSsrLoader({serverReferencesMap}); 58 | const rscClientLoader = createWebpackRscClientLoader({serverReferencesMap}); 59 | 60 | const serverConfig = { 61 | name: 'server', 62 | // ... 63 | module: { 64 | rules: [ 65 | { 66 | // Match the entry modules that should end up in the RSC layer: 67 | resource: [/\/server\/rsc\//, /\/app\.tsx$/], 68 | layer: webpackRscLayerName, 69 | }, 70 | { 71 | // Match the modules that should end up in a shared layer (RSC & SSR): 72 | resource: /\/server\/shared\//, 73 | layer: 'shared', 74 | }, 75 | { 76 | issuerLayer: webpackRscLayerName, 77 | resolve: {conditionNames: ['react-server', '...']}, 78 | }, 79 | { 80 | oneOf: [ 81 | { 82 | issuerLayer: webpackRscLayerName, 83 | test: /\.tsx?$/, 84 | use: [rscServerLoader, 'swc-loader'], 85 | }, 86 | { 87 | test: /\.tsx?$/, 88 | use: [rscSsrLoader, 'swc-loader'], 89 | }, 90 | { 91 | issuerLayer: webpackRscLayerName, 92 | test: /\.m?js$/, 93 | use: rscServerLoader, 94 | }, 95 | { 96 | test: /\.m?js$/, 97 | use: rscSsrLoader, 98 | }, 99 | ], 100 | }, 101 | ], 102 | }, 103 | plugins: [ 104 | new WebpackRscServerPlugin({clientReferencesMap, serverReferencesMap}), 105 | ], 106 | experiments: {layers: true}, 107 | // ... 108 | }; 109 | 110 | const clientConfig = { 111 | name: 'client', 112 | dependencies: ['server'], 113 | // ... 114 | module: { 115 | rules: [ 116 | { 117 | test: /\.tsx?$/, 118 | use: [rscClientLoader, 'swc-loader'], 119 | }, 120 | { 121 | test: /\.m?js$/, 122 | use: rscClientLoader, 123 | }, 124 | ], 125 | }, 126 | plugins: [new WebpackRscClientPlugin({clientReferencesMap})], 127 | // ... 128 | }; 129 | 130 | export default [serverConfig, clientConfig]; 131 | ``` 132 | 133 | **Note:** It's important to specify the names and dependencies of the configs as 134 | shown above, so that the plugins work in the correct order, even in watch mode. 135 | 136 | ## Webpack Loaders and Plugins 137 | 138 | This library provides the following Webpack loaders and plugins: 139 | 140 | ### `createWebpackRscServerLoader` 141 | 142 | A function to create the RSC server loader `use` item for the server entry 143 | webpack config. It should be used if the `issuerLayer` is `webpackRscLayerName`. 144 | This loader is responsible for replacing client components in a `use client` 145 | module with client references (objects that contain meta data about the client 146 | components), and removing all other parts of the client module. It also 147 | populates the given `clientReferencesMap`. 148 | 149 | In addition, the loader handles server references for React server actions by 150 | adding meta data to all exported functions of a `use server` module, and also 151 | populates the given `serverReferencesMap`. 152 | 153 | ### `createWebpackRscSsrLoader` 154 | 155 | A function to create the RSC SSR loader `use` item for the server entry webpack 156 | config. It should be used if the `issuerLayer` is **not** `webpackRscLayerName`. 157 | This loader is responsible for replacing server actions in a `use server` module 158 | that are imported from client modules with stubs. The stubs are functions that 159 | throw an error, since React does not allow calling server actions during SSR (to 160 | avoid waterfalls). All other parts of the server module are removed. It also 161 | populates the given `serverReferencesMap`. 162 | 163 | ### `WebpackRscServerPlugin` 164 | 165 | The server plugin resolves the client references that the server loader has 166 | created, and adds them as additional includes to the bundle, so that they are 167 | available for server-side rendering (SSR). 168 | 169 | The plugin also generates the server manifest that is needed for validating the 170 | server references of server actions (also known as mutations) that are sent back 171 | from the client. It also adds module IDs to the given `serverReferencesMap`. 172 | 173 | ### `createWebpackRscClientLoader` 174 | 175 | A function to create the RSC client loader `use` item for the client entry 176 | webpack config. This loader is responsible for replacing server actions in a 177 | `use server` module with server references (based on the given 178 | `serverReferencesMap`), and removing all other parts of the server module, so 179 | that the server module can be imported from a client module. 180 | 181 | **Note:** Importing server actions from a client module requires that 182 | `callServer` can be imported from a module. Per default 183 | `@mfng/core/client/browser` is used as import source, but this can be customized 184 | with the `callServerImportSource` option. 185 | 186 | ### `WebpackRscClientPlugin` 187 | 188 | The client plugin resolves the client references that were saved by the server 189 | loader in `clientReferencesMap` into separate client chunks that can be loaded 190 | by the browser. This plugin also generates the React client manifest file that 191 | is needed for creating the RSC stream with 192 | `ReactServerDOMServer.renderToReadableStream()`, as well as the React SSR 193 | manifest that is needed for creating the HTML stream (SSR) with 194 | `ReactServerDOMClient.createFromReadableStream()`. 195 | -------------------------------------------------------------------------------- /packages/webpack-rsc/jest.config.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import url from 'url'; 4 | import {defaults} from 'jest-config'; 5 | 6 | const currentDirname = path.dirname(url.fileURLToPath(import.meta.url)); 7 | 8 | const swcConfig = JSON.parse( 9 | fs.readFileSync(`${currentDirname}/.swcrc`, `utf-8`), 10 | ); 11 | 12 | /** 13 | * @type {import('jest').Config} 14 | */ 15 | export default { 16 | extensionsToTreatAsEsm: [`.ts`], 17 | moduleFileExtensions: [...defaults.moduleFileExtensions, `cts`], 18 | moduleNameMapper: {'^(\\.{1,2}/.*)\\.c?js$': `$1`}, 19 | testMatch: [`**/src/**/*.test.ts`], 20 | transform: {'^.+\\.c?ts$': [`@swc/jest`, swcConfig]}, 21 | }; 22 | -------------------------------------------------------------------------------- /packages/webpack-rsc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mfng/webpack-rsc", 3 | "version": "4.2.1", 4 | "description": "A set of Webpack loaders and plugins for React Server Components", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/unstubbable/mfng.git", 8 | "directory": "packages/webpack-rsc" 9 | }, 10 | "license": "MIT", 11 | "author": "Hendrik Liebau ", 12 | "type": "module", 13 | "exports": { 14 | ".": { 15 | "@mfng:internal": "./src/index.ts", 16 | "default": "./lib/index.js" 17 | } 18 | }, 19 | "files": [ 20 | "lib", 21 | "!*.test.*" 22 | ], 23 | "scripts": { 24 | "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest" 25 | }, 26 | "dependencies": { 27 | "@babel/core": "^7.21.3", 28 | "@babel/generator": "^7.21.3", 29 | "@babel/parser": "^7.21.3", 30 | "@babel/traverse": "^7.21.3", 31 | "@babel/types": "^7.21.3" 32 | }, 33 | "devDependencies": { 34 | "@jest/globals": "^29.5.0", 35 | "@swc/jest": "^0.2.24", 36 | "@types/babel__generator": "^7.6.4", 37 | "@types/babel__parser": "^7.1.1", 38 | "@types/babel__traverse": "^7.18.3", 39 | "@types/jest": "^29.4.0", 40 | "@types/memory-fs": "^0.3.3", 41 | "ai": "^3.0.12", 42 | "jest": "^29.5.0", 43 | "jest-config": "^29.5.0", 44 | "memory-fs": "^0.5.0", 45 | "prettier": "^2.8.7", 46 | "react": "0.0.0-experimental-778e1ed2-20240926", 47 | "react-dom": "0.0.0-experimental-778e1ed2-20240926", 48 | "react-server-dom-webpack": "0.0.0-experimental-778e1ed2-20240926", 49 | "webpack": "^5.75.0" 50 | }, 51 | "peerDependencies": { 52 | "react-server-dom-webpack": "*", 53 | "webpack": "*" 54 | }, 55 | "publishConfig": { 56 | "access": "public" 57 | }, 58 | "wallaby": { 59 | "env": { 60 | "params": { 61 | "runner": "--experimental-vm-modules" 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/client-component-with-server-action.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 'use client'; 3 | 4 | import * as React from 'react'; 5 | import {serverFunctionImportedFromClient} from './server-function-imported-from-client.js'; 6 | 7 | export function ClientComponentWithServerAction({action1, action2}) { 8 | React.useEffect(() => { 9 | action1().then(console.log); 10 | action2().then(console.log); 11 | serverFunctionImportedFromClient().then(console.log); 12 | }, []); 13 | 14 | return null; 15 | } 16 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/client-component.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 'use client'; 3 | 4 | import * as React from 'react'; 5 | 6 | export function ClientComponent({action}) { 7 | React.useEffect(() => { 8 | action().then(console.log); 9 | }, []); 10 | 11 | return null; 12 | } 13 | 14 | export default ClientComponent; 15 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/client-components-shared-dependency/client-component-1.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 'use client'; 3 | 4 | import {useUIState} from 'ai/rsc'; 5 | 6 | export function ClientComponent1() { 7 | const [, setStuff] = useUIState(); 8 | 9 | React.useEffect(() => { 10 | setStuff([]); 11 | }, []); 12 | 13 | return null; 14 | } 15 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/client-components-shared-dependency/client-component-2.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 'use client'; 3 | 4 | import {useUIState} from 'ai/rsc'; 5 | 6 | export function ClientComponent2() { 7 | const [, setStuff] = useUIState(); 8 | 9 | React.useEffect(() => { 10 | setStuff([]); 11 | }, []); 12 | 13 | return null; 14 | } 15 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/client-components.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | 5 | export function ComponentA(arg0) { 6 | return React.createElement(`div`); 7 | } 8 | 9 | export const MemoizedComponentA = React.memo(ComponentA); 10 | 11 | export const ComponentB = function () { 12 | return React.createElement(`div`); 13 | }; 14 | 15 | export const foo = 1; 16 | 17 | export class ClassComponent extends React.Component {} 18 | 19 | export {ClientComponentWithServerAction as ComponentC} from './client-component-with-server-action.js'; 20 | 21 | const bar = 2; 22 | 23 | const ComponentF = () => React.createElement(`div`); 24 | 25 | export {D as ComponentD, bar, ComponentE, ComponentF}; 26 | 27 | function D() { 28 | return React.createElement(`div`); 29 | } 30 | 31 | function ComponentE() { 32 | return React.createElement(`div`); 33 | } 34 | 35 | export default function () { 36 | return null; 37 | } 38 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/client-entry.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom/client'; 3 | import ReactServerDOMClient from 'react-server-dom-webpack/client.browser'; 4 | 5 | const Root = () => React.use(ReactServerDOMClient.createFromFetch(fetch(`/`))); 6 | 7 | ReactDOM.hydrateRoot( 8 | document.getElementById(`main`), 9 | React.createElement(Root), 10 | ); 11 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/foo.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/import-assertions.js: -------------------------------------------------------------------------------- 1 | await import('./foo.json', {assert: {type: 'json'}}); 2 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/import-attributes.js: -------------------------------------------------------------------------------- 1 | await import('./foo.json', {with: {type: 'json'}}); 2 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/main-component.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {ClientComponentWithServerAction} from './client-component-with-server-action.js'; 3 | import {serverFunctionPassedFromServer} from './server-function-passed-from-server.js'; 4 | 5 | async function serverFunctionWithInlineDirective() { 6 | 'use server'; 7 | 8 | return Promise.resolve(`server-function-with-inline-directive`); 9 | } 10 | 11 | export function Main() { 12 | return React.createElement(ClientComponentWithServerAction, { 13 | action1: serverFunctionPassedFromServer, 14 | action2: serverFunctionWithInlineDirective, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/main.js: -------------------------------------------------------------------------------- 1 | import {pretendRscRendering} from './rsc.js'; 2 | 3 | export function pretendSsrRendering() { 4 | console.log(pretendRscRendering()); 5 | } 6 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/rsc.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Main} from './main-component.js'; 3 | 4 | export function pretendRscRendering() { 5 | console.log(React.createElement(Main)); 6 | } 7 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/server-component.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export async function ServerComponent() { 4 | return React.createElement(`div`); 5 | } 6 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/server-function-imported-from-client.js: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | export async function serverFunctionImportedFromClient() { 4 | return Promise.resolve(`server-function-imported-from-client`); 5 | } 6 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/server-function-passed-from-server.js: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | export async function serverFunctionPassedFromServer() { 4 | return Promise.resolve(`server-function-passed-from-server`); 5 | } 6 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/server-functions-inline-directive.js: -------------------------------------------------------------------------------- 1 | export async function foo() { 2 | 'use server'; 3 | 4 | return `foo`; 5 | } 6 | 7 | export async function bar() { 8 | return qux(); 9 | } 10 | 11 | const b = () => { 12 | 'use server'; 13 | 14 | return `baz`; 15 | }; 16 | 17 | export {b as baz}; 18 | 19 | async function qux() { 20 | 'use server'; 21 | 22 | return `qux`; 23 | } 24 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/__fixtures__/server-functions.js: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | export async function foo() { 4 | return Promise.resolve(`foo`); 5 | } 6 | 7 | export const bar = async () => Promise.resolve(`bar`); 8 | 9 | export const baz = function () { 10 | quux(); 11 | }; 12 | 13 | export const qux = 42; 14 | 15 | function quux() {} 16 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/index.ts: -------------------------------------------------------------------------------- 1 | import {createRequire} from 'module'; 2 | import type {RuleSetUseItem} from 'webpack'; 3 | import type {WebpackRscClientLoaderOptions} from './webpack-rsc-client-loader.cjs'; 4 | import type {WebpackRscServerLoaderOptions} from './webpack-rsc-server-loader.cjs'; 5 | import type {WebpackRscSsrLoaderOptions} from './webpack-rsc-ssr-loader.cjs'; 6 | 7 | export type {WebpackRscClientLoaderOptions} from './webpack-rsc-client-loader.cjs'; 8 | 9 | export * from './webpack-rsc-client-plugin.js'; 10 | 11 | export type { 12 | ClientReference, 13 | ClientReferencesMap, 14 | ServerReferencesMap, 15 | ServerReferencesModuleInfo, 16 | WebpackRscServerLoaderOptions, 17 | } from './webpack-rsc-server-loader.cjs'; 18 | 19 | export * from './webpack-rsc-server-plugin.js'; 20 | 21 | const require = createRequire(import.meta.url); 22 | const serverLoader = require.resolve(`./webpack-rsc-server-loader.cjs`); 23 | const ssrLoader = require.resolve(`./webpack-rsc-ssr-loader.cjs`); 24 | const clientLoader = require.resolve(`./webpack-rsc-client-loader.cjs`); 25 | 26 | export function createWebpackRscServerLoader( 27 | options: WebpackRscServerLoaderOptions, 28 | ): RuleSetUseItem { 29 | return {loader: serverLoader, options}; 30 | } 31 | 32 | export function createWebpackRscSsrLoader( 33 | options: WebpackRscSsrLoaderOptions, 34 | ): RuleSetUseItem { 35 | return {loader: ssrLoader, options}; 36 | } 37 | 38 | export function createWebpackRscClientLoader( 39 | options: WebpackRscClientLoaderOptions, 40 | ): RuleSetUseItem { 41 | return {loader: clientLoader, options}; 42 | } 43 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/webpack-rsc-client-loader.cts: -------------------------------------------------------------------------------- 1 | import generate = require('@babel/generator'); 2 | import parser = require('@babel/parser'); 3 | import traverse = require('@babel/traverse'); 4 | import t = require('@babel/types'); 5 | import type {LoaderContext, LoaderDefinitionFunction} from 'webpack'; 6 | import type webpackRscServerLoader from './webpack-rsc-server-loader.cjs'; 7 | 8 | namespace webpackRscClientLoader { 9 | export interface WebpackRscClientLoaderOptions { 10 | readonly serverReferencesMap: webpackRscServerLoader.ServerReferencesMap; 11 | readonly callServerImportSource?: string; 12 | } 13 | } 14 | 15 | type SourceMap = Parameters[1]; 16 | 17 | interface FunctionInfo { 18 | readonly exportName: string; 19 | readonly loc: t.SourceLocation | null | undefined; 20 | } 21 | 22 | function webpackRscClientLoader( 23 | this: LoaderContext, 24 | source: string, 25 | sourceMap?: SourceMap, 26 | ): void { 27 | this.cacheable(true); 28 | 29 | const { 30 | serverReferencesMap, 31 | callServerImportSource = `@mfng/core/client/browser`, 32 | } = this.getOptions(); 33 | 34 | const loaderContext = this; 35 | const resourcePath = this.resourcePath; 36 | 37 | const ast = parser.parse(source, { 38 | sourceType: `module`, 39 | sourceFilename: resourcePath, 40 | plugins: [`importAssertions`], 41 | }); 42 | 43 | let moduleId: string | number | undefined; 44 | let hasUseServerDirective = false; 45 | let addedRegisterServerReferenceCall = false; 46 | const importNodes = new Set(); 47 | 48 | traverse.default(ast, { 49 | enter(path) { 50 | const {node} = path; 51 | 52 | if (t.isProgram(node)) { 53 | if (node.directives.some(isUseServerDirective)) { 54 | hasUseServerDirective = true; 55 | 56 | const moduleInfo = serverReferencesMap.get(resourcePath); 57 | 58 | if (!moduleInfo) { 59 | loaderContext.emitError( 60 | new Error( 61 | `Could not find server references module info in \`serverReferencesMap\` for ${resourcePath}.`, 62 | ), 63 | ); 64 | 65 | path.replaceWith(t.program([])); 66 | } else if (!moduleInfo.moduleId) { 67 | loaderContext.emitError( 68 | new Error( 69 | `Could not find server references module ID in \`serverReferencesMap\` for ${resourcePath}.`, 70 | ), 71 | ); 72 | 73 | path.replaceWith(t.program([])); 74 | } else { 75 | moduleId = moduleInfo.moduleId; 76 | } 77 | } else { 78 | path.skip(); 79 | } 80 | 81 | return; 82 | } 83 | 84 | if (importNodes.has(node)) { 85 | return path.skip(); 86 | } 87 | 88 | const functionInfo = getFunctionInfo(node); 89 | 90 | if (moduleId && functionInfo) { 91 | path.replaceWith( 92 | createNamedExportedServerReference(functionInfo, moduleId), 93 | ); 94 | path.skip(); 95 | addedRegisterServerReferenceCall = true; 96 | } else { 97 | path.remove(); 98 | } 99 | }, 100 | exit(path) { 101 | if (!t.isProgram(path.node) || !addedRegisterServerReferenceCall) { 102 | path.skip(); 103 | 104 | return; 105 | } 106 | 107 | importNodes.add( 108 | t.importDeclaration( 109 | [ 110 | t.importSpecifier( 111 | t.identifier(`createServerReference`), 112 | t.identifier(`createServerReference`), 113 | ), 114 | ], 115 | t.stringLiteral(`react-server-dom-webpack/client`), 116 | ), 117 | ); 118 | 119 | importNodes.add( 120 | t.importDeclaration( 121 | [ 122 | t.importSpecifier( 123 | t.identifier(`callServer`), 124 | t.identifier(`callServer`), 125 | ), 126 | t.importSpecifier( 127 | t.identifier(`findSourceMapUrl`), 128 | t.identifier(`findSourceMapUrl`), 129 | ), 130 | ], 131 | t.stringLiteral(callServerImportSource), 132 | ), 133 | ); 134 | 135 | (path as traverse.NodePath).unshiftContainer( 136 | `body`, 137 | Array.from(importNodes), 138 | ); 139 | }, 140 | }); 141 | 142 | if (!hasUseServerDirective) { 143 | return this.callback(null, source, sourceMap); 144 | } 145 | 146 | const {code, map} = generate.default( 147 | ast, 148 | { 149 | sourceFileName: this.resourcePath, 150 | sourceMaps: this.sourceMap, 151 | // @ts-expect-error 152 | inputSourceMap: sourceMap, 153 | }, 154 | source, 155 | ); 156 | 157 | this.callback(null, code, map ?? sourceMap); 158 | } 159 | 160 | function createNamedExportedServerReference( 161 | functionInfo: FunctionInfo, 162 | moduleId: string | number, 163 | ) { 164 | const {exportName, loc} = functionInfo; 165 | const exportIdentifier = t.identifier(exportName); 166 | 167 | exportIdentifier.loc = loc; 168 | 169 | return t.exportNamedDeclaration( 170 | t.variableDeclaration(`const`, [ 171 | t.variableDeclarator( 172 | exportIdentifier, 173 | t.callExpression(t.identifier(`createServerReference`), [ 174 | t.stringLiteral(`${moduleId}#${exportName}`), 175 | t.identifier(`callServer`), 176 | t.identifier(`undefined`), // encodeFormAction 177 | t.identifier(`findSourceMapUrl`), 178 | t.stringLiteral(exportName), 179 | ]), 180 | ), 181 | ]), 182 | ); 183 | } 184 | 185 | function isUseServerDirective(directive: t.Directive): boolean { 186 | return ( 187 | t.isDirectiveLiteral(directive.value) && 188 | directive.value.value === `use server` 189 | ); 190 | } 191 | 192 | function getFunctionInfo(node: t.Node): FunctionInfo | undefined { 193 | let exportName: string | undefined; 194 | let loc: t.SourceLocation | null | undefined; 195 | 196 | if (t.isExportNamedDeclaration(node)) { 197 | if (t.isFunctionDeclaration(node.declaration)) { 198 | exportName = node.declaration.id?.name; 199 | loc = node.declaration.id?.loc; 200 | } else if (t.isVariableDeclaration(node.declaration)) { 201 | const declarator = node.declaration.declarations[0]; 202 | 203 | if (!declarator) { 204 | return undefined; 205 | } 206 | 207 | if ( 208 | (t.isFunctionExpression(declarator.init) || 209 | t.isArrowFunctionExpression(declarator.init)) && 210 | t.isIdentifier(declarator.id) 211 | ) { 212 | exportName = declarator.id.name; 213 | loc = declarator.id.loc; 214 | } 215 | } 216 | } 217 | 218 | return exportName ? {exportName, loc} : undefined; 219 | } 220 | 221 | export = webpackRscClientLoader; 222 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/webpack-rsc-client-loader.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import url from 'url'; 4 | import {jest} from '@jest/globals'; 5 | import type webpack from 'webpack'; 6 | import type {WebpackRscClientLoaderOptions} from './webpack-rsc-client-loader.cjs'; 7 | import webpackRscClientLoader from './webpack-rsc-client-loader.cjs'; 8 | import type {ServerReferencesMap} from './webpack-rsc-server-loader.cjs'; 9 | 10 | const currentDirname = path.dirname(url.fileURLToPath(import.meta.url)); 11 | 12 | async function callLoader( 13 | resourcePath: string, 14 | options: WebpackRscClientLoaderOptions, 15 | emitError?: jest.Mock<(error: Error) => void>, 16 | ): Promise { 17 | const input = await fs.readFile(resourcePath); 18 | 19 | return new Promise((resolve, reject) => { 20 | const context: Partial< 21 | webpack.LoaderContext 22 | > = { 23 | getOptions: () => options, 24 | resourcePath, 25 | cacheable: jest.fn(), 26 | emitError, 27 | callback: (error, content) => { 28 | if (error) { 29 | reject(error); 30 | } else if (content !== undefined) { 31 | resolve(content); 32 | } else { 33 | reject( 34 | new Error( 35 | `Did not receive any content from webpackRscClientLoader.`, 36 | ), 37 | ); 38 | } 39 | }, 40 | }; 41 | 42 | void webpackRscClientLoader.call( 43 | context as webpack.LoaderContext, 44 | input.toString(`utf-8`), 45 | ); 46 | }); 47 | } 48 | 49 | describe(`webpackRscClientLoader`, () => { 50 | test(`generates a server reference module based on given serverReferencesMap`, async () => { 51 | const resourcePath = path.resolve( 52 | currentDirname, 53 | `__fixtures__/server-functions.js`, 54 | ); 55 | 56 | const serverReferencesMap: ServerReferencesMap = new Map([ 57 | [resourcePath, {moduleId: `test`, exportNames: []}], 58 | ]); 59 | 60 | const output = await callLoader(resourcePath, {serverReferencesMap}); 61 | 62 | expect(output).toEqual( 63 | ` 64 | import { createServerReference } from "react-server-dom-webpack/client"; 65 | import { callServer, findSourceMapUrl } from "@mfng/core/client/browser"; 66 | export const foo = createServerReference("test#foo", callServer, undefined, findSourceMapUrl, "foo"); 67 | export const bar = createServerReference("test#bar", callServer, undefined, findSourceMapUrl, "bar"); 68 | export const baz = createServerReference("test#baz", callServer, undefined, findSourceMapUrl, "baz"); 69 | `.trim(), 70 | ); 71 | }); 72 | 73 | test(`accepts a custom callServer import source`, async () => { 74 | const resourcePath = path.resolve( 75 | currentDirname, 76 | `__fixtures__/server-functions.js`, 77 | ); 78 | 79 | const serverReferencesMap: ServerReferencesMap = new Map([ 80 | [resourcePath, {moduleId: `test`, exportNames: [`foo`]}], 81 | ]); 82 | 83 | const callServerImportSource = `some-router/call-server`; 84 | 85 | const output = await callLoader(resourcePath, { 86 | serverReferencesMap, 87 | callServerImportSource, 88 | }); 89 | 90 | expect(output).toMatch( 91 | `import { callServer, findSourceMapUrl } from "some-router/call-server";`, 92 | ); 93 | }); 94 | 95 | test(`emits an error if module info is missing in serverReferencesMap`, async () => { 96 | const resourcePath = path.resolve( 97 | currentDirname, 98 | `__fixtures__/server-functions.js`, 99 | ); 100 | 101 | const serverReferencesMap: ServerReferencesMap = new Map(); 102 | const emitError = jest.fn(); 103 | 104 | const output = await callLoader( 105 | resourcePath, 106 | {serverReferencesMap}, 107 | emitError, 108 | ); 109 | 110 | expect(emitError.mock.calls).toEqual([ 111 | [ 112 | new Error( 113 | `Could not find server references module info in \`serverReferencesMap\` for ${resourcePath}.`, 114 | ), 115 | ], 116 | ]); 117 | 118 | expect(output).toEqual(``); 119 | }); 120 | 121 | test(`does not change modules without a 'use server' directive`, async () => { 122 | const resourcePath = path.resolve( 123 | currentDirname, 124 | `__fixtures__/client-component.js`, 125 | ); 126 | 127 | const source = (await fs.readFile(resourcePath)).toString(); 128 | const serverReferencesMap = new Map(); 129 | const output = await callLoader(resourcePath, {serverReferencesMap}); 130 | 131 | expect(output).toEqual(source); 132 | }); 133 | 134 | test(`can parse import assertions`, async () => { 135 | const resourcePath = path.resolve( 136 | currentDirname, 137 | `__fixtures__/import-assertions.js`, 138 | ); 139 | 140 | const serverReferencesMap = new Map(); 141 | const output = await callLoader(resourcePath, {serverReferencesMap}); 142 | 143 | expect(output.toString().trim()).toEqual( 144 | `await import('./foo.json', {assert: {type: 'json'}});`, 145 | ); 146 | }); 147 | 148 | test(`can parse import attributes`, async () => { 149 | const resourcePath = path.resolve( 150 | currentDirname, 151 | `__fixtures__/import-attributes.js`, 152 | ); 153 | 154 | const serverReferencesMap = new Map(); 155 | const output = await callLoader(resourcePath, {serverReferencesMap}); 156 | 157 | expect(output.toString().trim()).toEqual( 158 | `await import('./foo.json', {with: {type: 'json'}});`, 159 | ); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/webpack-rsc-client-plugin.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ClientManifest, 3 | ImportManifestEntry, 4 | SSRManifest, 5 | } from 'react-server-dom-webpack'; 6 | import type Webpack from 'webpack'; 7 | import type {ClientReferencesMap} from './webpack-rsc-server-loader.cjs'; 8 | 9 | export interface WebpackRscClientPluginOptions { 10 | readonly clientReferencesMap: ClientReferencesMap; 11 | readonly clientManifestFilename?: string; 12 | readonly ssrManifestFilename?: string; 13 | } 14 | 15 | export class WebpackRscClientPlugin { 16 | private clientReferencesMap: ClientReferencesMap; 17 | private clientManifestFilename: string; 18 | private ssrManifestFilename: string; 19 | 20 | constructor(options: WebpackRscClientPluginOptions) { 21 | this.clientReferencesMap = options.clientReferencesMap; 22 | 23 | this.clientManifestFilename = 24 | options.clientManifestFilename || `react-client-manifest.json`; 25 | 26 | this.ssrManifestFilename = 27 | options?.ssrManifestFilename || `react-ssr-manifest.json`; 28 | } 29 | 30 | apply(compiler: Webpack.Compiler): void { 31 | const { 32 | AsyncDependenciesBlock, 33 | RuntimeGlobals, 34 | WebpackError, 35 | dependencies: {ModuleDependency, NullDependency}, 36 | sources: {RawSource}, 37 | } = compiler.webpack; 38 | 39 | class ClientReferenceDependency extends ModuleDependency { 40 | override get type(): string { 41 | return `client-reference`; 42 | } 43 | } 44 | 45 | const getEntryModule = (compilation: Webpack.Compilation) => { 46 | const [entryTuple, ...otherEntries] = compilation.entries.entries(); 47 | 48 | if (!entryTuple) { 49 | compilation.errors.push( 50 | new WebpackError(`Could not find an entry in the compilation.`), 51 | ); 52 | 53 | return; 54 | } 55 | 56 | if (otherEntries.length > 0) { 57 | compilation.warnings.push( 58 | new WebpackError( 59 | `Found multiple entries in the compilation, adding client reference chunks only to the first entry.`, 60 | ), 61 | ); 62 | } 63 | 64 | const [, entryValue] = entryTuple; 65 | 66 | const entryDependency = entryValue.dependencies.find( 67 | (dependency) => dependency.constructor.name === `EntryDependency`, 68 | ); 69 | 70 | if (!entryDependency) { 71 | compilation.errors.push( 72 | new WebpackError(`Could not find an entry dependency.`), 73 | ); 74 | 75 | return; 76 | } 77 | 78 | return compilation.moduleGraph.getResolvedModule(entryDependency); 79 | }; 80 | 81 | const addClientReferencesChunks = (entryModule: Webpack.Module) => { 82 | [...this.clientReferencesMap.keys()].forEach((resourcePath, index) => { 83 | const chunkName = `client${index}`; 84 | 85 | const block = new AsyncDependenciesBlock( 86 | {name: chunkName}, 87 | undefined, 88 | resourcePath, 89 | ); 90 | 91 | block.addDependency(new ClientReferenceDependency(resourcePath)); 92 | 93 | entryModule.addBlock(block); 94 | }); 95 | }; 96 | 97 | compiler.hooks.finishMake.tap( 98 | WebpackRscClientPlugin.name, 99 | (compilation) => { 100 | if (compiler.watchMode) { 101 | const entryModule = getEntryModule(compilation); 102 | 103 | if (entryModule) { 104 | // Remove stale client references. 105 | entryModule.blocks = entryModule.blocks.filter((block) => 106 | block.dependencies.some( 107 | (dependency) => 108 | !(dependency instanceof ClientReferenceDependency) || 109 | this.clientReferencesMap.has(dependency.request), 110 | ), 111 | ); 112 | 113 | addClientReferencesChunks(entryModule); 114 | } 115 | } 116 | }, 117 | ); 118 | 119 | compiler.hooks.thisCompilation.tap( 120 | WebpackRscClientPlugin.name, 121 | (compilation, {normalModuleFactory}) => { 122 | compilation.dependencyFactories.set( 123 | ClientReferenceDependency, 124 | normalModuleFactory, 125 | ); 126 | 127 | compilation.dependencyTemplates.set( 128 | ClientReferenceDependency, 129 | new NullDependency.Template(), 130 | ); 131 | 132 | const onNormalModuleFactoryParser = ( 133 | parser: Webpack.javascript.JavascriptParser, 134 | ) => { 135 | compilation.assetsInfo; 136 | parser.hooks.program.tap(WebpackRscClientPlugin.name, () => { 137 | const entryModule = getEntryModule(compilation); 138 | 139 | if (entryModule === parser.state.module) { 140 | addClientReferencesChunks(entryModule); 141 | } 142 | }); 143 | }; 144 | 145 | normalModuleFactory.hooks.parser 146 | .for(`javascript/auto`) 147 | .tap(`HarmonyModulesPlugin`, onNormalModuleFactoryParser); 148 | 149 | normalModuleFactory.hooks.parser 150 | .for(`javascript/dynamic`) 151 | .tap(`HarmonyModulesPlugin`, onNormalModuleFactoryParser); 152 | 153 | normalModuleFactory.hooks.parser 154 | .for(`javascript/esm`) 155 | .tap(`HarmonyModulesPlugin`, onNormalModuleFactoryParser); 156 | 157 | compilation.hooks.additionalTreeRuntimeRequirements.tap( 158 | WebpackRscClientPlugin.name, 159 | (_chunk, runtimeRequirements) => { 160 | runtimeRequirements.add(RuntimeGlobals.ensureChunk); 161 | runtimeRequirements.add(RuntimeGlobals.compatGetDefaultExport); 162 | }, 163 | ); 164 | 165 | compilation.hooks.processAssets.tap(WebpackRscClientPlugin.name, () => { 166 | const clientManifest: ClientManifest = {}; 167 | const ssrManifest: SSRManifest = {moduleMap: {}, moduleLoading: null}; 168 | const {chunkGraph, moduleGraph, modules} = compilation; 169 | 170 | for (const module of modules) { 171 | const resourcePath = module.nameForCondition(); 172 | 173 | const clientReferences = resourcePath 174 | ? this.clientReferencesMap.get(resourcePath) 175 | : undefined; 176 | 177 | if (clientReferences) { 178 | const moduleId = chunkGraph.getModuleId(module); 179 | 180 | const ssrModuleMetaData: Record = {}; 181 | 182 | for (const {id, exportName, ssrId} of clientReferences) { 183 | // Theoretically the used client and SSR export names should 184 | // be used here. These might differ from the original export 185 | // names that the loader has recorded. But with the current 186 | // setup (i.e. how the client entries are added on both 187 | // sides), the original export names are preserved. 188 | const clientExportName = exportName; 189 | const ssrExportName = exportName; 190 | 191 | const chunksSet = new Set(); 192 | 193 | for (const chunk of chunkGraph.getModuleChunksIterable( 194 | module, 195 | )) { 196 | chunksSet.add(chunk); 197 | } 198 | 199 | for (const connection of moduleGraph.getOutgoingConnections( 200 | module, 201 | )) { 202 | for (const chunk of chunkGraph.getModuleChunksIterable( 203 | connection.module, 204 | )) { 205 | chunksSet.add(chunk); 206 | } 207 | } 208 | 209 | // chunks is a double indexed array of chunkId / chunkFilename pairs 210 | const chunks: (string | number)[] = []; 211 | 212 | for (const chunk of chunksSet) { 213 | if (chunk.id && !chunk.isOnlyInitial()) { 214 | for (const file of chunk.files) { 215 | chunks.push(chunk.id, file); 216 | } 217 | } 218 | } 219 | 220 | clientManifest[id] = { 221 | id: moduleId, 222 | name: clientExportName, 223 | chunks, 224 | }; 225 | 226 | if (ssrId) { 227 | ssrModuleMetaData[clientExportName] = { 228 | id: ssrId, 229 | name: ssrExportName, 230 | chunks: [], 231 | }; 232 | } 233 | } 234 | 235 | ssrManifest.moduleMap[moduleId] = ssrModuleMetaData; 236 | } 237 | } 238 | 239 | compilation.emitAsset( 240 | this.clientManifestFilename, 241 | new RawSource(JSON.stringify(clientManifest, null, 2), false), 242 | ); 243 | 244 | const {crossOriginLoading, publicPath = ``} = 245 | compilation.outputOptions; 246 | 247 | ssrManifest.moduleLoading = { 248 | // https://github.com/webpack/webpack/blob/87660921808566ef3b8796f8df61bd79fc026108/lib/runtime/PublicPathRuntimeModule.js#L30-L32 249 | prefix: compilation.getPath(publicPath, { 250 | hash: compilation.hash ?? `XXXX`, 251 | }), 252 | crossOrigin: crossOriginLoading 253 | ? crossOriginLoading === `use-credentials` 254 | ? crossOriginLoading 255 | : `` 256 | : undefined, 257 | }; 258 | 259 | compilation.emitAsset( 260 | this.ssrManifestFilename, 261 | new RawSource(JSON.stringify(ssrManifest, null, 2), false), 262 | ); 263 | }); 264 | }, 265 | ); 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/webpack-rsc-server-loader.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import url from 'url'; 4 | import {jest} from '@jest/globals'; 5 | import type webpack from 'webpack'; 6 | import type { 7 | ClientReferencesMap, 8 | ServerReferencesMap, 9 | WebpackRscServerLoaderOptions, 10 | } from './webpack-rsc-server-loader.cjs'; 11 | import webpackRscServerLoader from './webpack-rsc-server-loader.cjs'; 12 | 13 | const currentDirname = path.dirname(url.fileURLToPath(import.meta.url)); 14 | 15 | async function callLoader( 16 | resourcePath: string, 17 | clientReferencesMap: ClientReferencesMap, 18 | serverReferencesMap: ServerReferencesMap, 19 | ): Promise { 20 | const input = await fs.readFile(resourcePath); 21 | 22 | return new Promise((resolve, reject) => { 23 | const context: Partial< 24 | webpack.LoaderContext 25 | > = { 26 | getOptions: () => ({clientReferencesMap, serverReferencesMap}), 27 | resourcePath, 28 | cacheable: jest.fn(), 29 | callback: (error, content) => { 30 | if (error) { 31 | reject(error); 32 | } else if (content) { 33 | resolve(content); 34 | } else { 35 | reject( 36 | new Error( 37 | `Did not receive any content from webpackRscServerLoader.`, 38 | ), 39 | ); 40 | } 41 | }, 42 | }; 43 | 44 | void webpackRscServerLoader.call( 45 | context as webpack.LoaderContext, 46 | input.toString(`utf-8`), 47 | ); 48 | }); 49 | } 50 | 51 | describe(`webpackRscServerLoader`, () => { 52 | test(`keeps only the 'use client' directive, and client references for all exports`, async () => { 53 | const resourcePath = path.resolve( 54 | currentDirname, 55 | `__fixtures__/client-components.js`, 56 | ); 57 | 58 | const output = await callLoader(resourcePath, new Map(), new Map()); 59 | const idPrefix = path.relative(process.cwd(), resourcePath); 60 | 61 | expect(output).toEqual( 62 | ` 63 | 'use client'; 64 | 65 | import { registerClientReference } from "react-server-dom-webpack/server"; 66 | function createClientReferenceProxy(exportName) { 67 | return () => { 68 | throw new Error(\`Attempted to call \${exportName}() from the server but \${exportName} is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.\`); 69 | }; 70 | } 71 | export const ComponentA = registerClientReference(createClientReferenceProxy("ComponentA"), "${idPrefix}#ComponentA", "ComponentA"); 72 | export const MemoizedComponentA = registerClientReference(createClientReferenceProxy("MemoizedComponentA"), "${idPrefix}#MemoizedComponentA", "MemoizedComponentA"); 73 | export const ComponentB = registerClientReference(createClientReferenceProxy("ComponentB"), "${idPrefix}#ComponentB", "ComponentB"); 74 | export const foo = registerClientReference(createClientReferenceProxy("foo"), "${idPrefix}#foo", "foo"); 75 | export const ClassComponent = registerClientReference(createClientReferenceProxy("ClassComponent"), "${idPrefix}#ClassComponent", "ClassComponent"); 76 | export const ComponentC = registerClientReference(createClientReferenceProxy("ComponentC"), "${idPrefix}#ComponentC", "ComponentC"); 77 | export const ComponentD = registerClientReference(createClientReferenceProxy("ComponentD"), "${idPrefix}#ComponentD", "ComponentD"); 78 | export const bar = registerClientReference(createClientReferenceProxy("bar"), "${idPrefix}#bar", "bar"); 79 | export const ComponentE = registerClientReference(createClientReferenceProxy("ComponentE"), "${idPrefix}#ComponentE", "ComponentE"); 80 | export const ComponentF = registerClientReference(createClientReferenceProxy("ComponentF"), "${idPrefix}#ComponentF", "ComponentF"); 81 | export default registerClientReference(() => { 82 | throw new Error("Attempted to call the default export of ${resourcePath} from the server but it's on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component."); 83 | }, "${idPrefix}#", ""); 84 | `.trim(), 85 | ); 86 | }); 87 | 88 | test(`populates the given client references map`, async () => { 89 | const clientReferencesMap: ClientReferencesMap = new Map(); 90 | 91 | const resourcePath = path.resolve( 92 | currentDirname, 93 | `__fixtures__/client-components.js`, 94 | ); 95 | 96 | await callLoader(resourcePath, clientReferencesMap, new Map()); 97 | 98 | expect(Object.fromEntries(clientReferencesMap.entries())).toEqual({ 99 | [resourcePath]: [ 100 | { 101 | exportName: ``, 102 | id: `src/__fixtures__/client-components.js#`, 103 | }, 104 | { 105 | exportName: `ComponentA`, 106 | id: `src/__fixtures__/client-components.js#ComponentA`, 107 | }, 108 | { 109 | exportName: `MemoizedComponentA`, 110 | id: `src/__fixtures__/client-components.js#MemoizedComponentA`, 111 | }, 112 | { 113 | exportName: `ComponentB`, 114 | id: `src/__fixtures__/client-components.js#ComponentB`, 115 | }, 116 | { 117 | exportName: `foo`, 118 | id: `src/__fixtures__/client-components.js#foo`, 119 | }, 120 | { 121 | exportName: `ClassComponent`, 122 | id: `src/__fixtures__/client-components.js#ClassComponent`, 123 | }, 124 | { 125 | exportName: `ComponentC`, 126 | id: `src/__fixtures__/client-components.js#ComponentC`, 127 | }, 128 | { 129 | exportName: `ComponentD`, 130 | id: `src/__fixtures__/client-components.js#ComponentD`, 131 | }, 132 | { 133 | exportName: `bar`, 134 | id: `src/__fixtures__/client-components.js#bar`, 135 | }, 136 | { 137 | exportName: `ComponentE`, 138 | id: `src/__fixtures__/client-components.js#ComponentE`, 139 | }, 140 | { 141 | exportName: `ComponentF`, 142 | id: `src/__fixtures__/client-components.js#ComponentF`, 143 | }, 144 | ], 145 | }); 146 | }); 147 | 148 | test(`adds 'registerServerReference' calls to all exported functions of a module with a 'use server' directive`, async () => { 149 | const resourcePath = path.resolve( 150 | currentDirname, 151 | `__fixtures__/server-functions.js`, 152 | ); 153 | 154 | const output = await callLoader(resourcePath, new Map(), new Map()); 155 | 156 | expect(output).toEqual( 157 | ` 158 | 'use server'; 159 | 160 | import { registerServerReference } from "react-server-dom-webpack/server"; 161 | export async function foo() { 162 | return Promise.resolve(\`foo\`); 163 | } 164 | registerServerReference(foo, module.id, "foo"); 165 | export const bar = async () => Promise.resolve(\`bar\`); 166 | registerServerReference(bar, module.id, "bar"); 167 | export const baz = function () { 168 | quux(); 169 | }; 170 | registerServerReference(baz, module.id, "baz"); 171 | export const qux = 42; 172 | function quux() {} 173 | `.trim(), 174 | ); 175 | }); 176 | 177 | test(`adds 'registerServerReference' calls to all functions that have a 'use server' directive`, async () => { 178 | const resourcePath = path.resolve( 179 | currentDirname, 180 | `__fixtures__/server-functions-inline-directive.js`, 181 | ); 182 | 183 | const output = await callLoader(resourcePath, new Map(), new Map()); 184 | 185 | expect(output).toEqual( 186 | ` 187 | import { registerServerReference } from "react-server-dom-webpack/server"; 188 | export async function foo() { 189 | 'use server'; 190 | 191 | return \`foo\`; 192 | } 193 | registerServerReference(foo, module.id, "foo"); 194 | export async function bar() { 195 | return qux(); 196 | } 197 | const b = () => { 198 | 'use server'; 199 | 200 | return \`baz\`; 201 | }; 202 | registerServerReference(b, module.id, "baz"); 203 | export { b as baz }; 204 | async function qux() { 205 | 'use server'; 206 | 207 | return \`qux\`; 208 | } 209 | registerServerReference(qux, module.id, "qux"); 210 | export { qux }; 211 | `.trim(), 212 | ); 213 | }); 214 | 215 | test(`populates the given server references map`, async () => { 216 | const serverReferencesMap: ServerReferencesMap = new Map(); 217 | 218 | const resourcePath = path.resolve( 219 | currentDirname, 220 | `__fixtures__/server-functions.js`, 221 | ); 222 | 223 | await callLoader(resourcePath, new Map(), serverReferencesMap); 224 | 225 | expect(Object.fromEntries(serverReferencesMap.entries())).toEqual({ 226 | [resourcePath]: {exportNames: [`foo`, `bar`, `baz`]}, 227 | }); 228 | }); 229 | 230 | test(`does not change modules without a 'use client' or 'use server' directive`, async () => { 231 | const resourcePath = path.resolve( 232 | currentDirname, 233 | `__fixtures__/server-component.js`, 234 | ); 235 | 236 | const source = (await fs.readFile(resourcePath)).toString(); 237 | const output = await callLoader(resourcePath, new Map(), new Map()); 238 | 239 | expect(output).toEqual(source); 240 | }); 241 | 242 | test(`can parse import assertions`, async () => { 243 | const resourcePath = path.resolve( 244 | currentDirname, 245 | `__fixtures__/import-assertions.js`, 246 | ); 247 | 248 | const output = await callLoader(resourcePath, new Map(), new Map()); 249 | 250 | expect(output.toString().trim()).toEqual( 251 | `await import('./foo.json', {assert: {type: 'json'}});`, 252 | ); 253 | }); 254 | 255 | test(`can parse import attributes`, async () => { 256 | const resourcePath = path.resolve( 257 | currentDirname, 258 | `__fixtures__/import-attributes.js`, 259 | ); 260 | 261 | const output = await callLoader(resourcePath, new Map(), new Map()); 262 | 263 | expect(output.toString().trim()).toEqual( 264 | `await import('./foo.json', {with: {type: 'json'}});`, 265 | ); 266 | }); 267 | }); 268 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/webpack-rsc-ssr-loader.cts: -------------------------------------------------------------------------------- 1 | import generate = require('@babel/generator'); 2 | import parser = require('@babel/parser'); 3 | import traverse = require('@babel/traverse'); 4 | import t = require('@babel/types'); 5 | import webpack = require('webpack'); 6 | import type {ServerReferencesMap} from './webpack-rsc-server-loader.cjs'; 7 | 8 | namespace webpackRscSsrLoader { 9 | export interface WebpackRscSsrLoaderOptions { 10 | readonly serverReferencesMap: ServerReferencesMap; 11 | } 12 | } 13 | 14 | interface FunctionInfo { 15 | readonly exportName: string; 16 | readonly loc: t.SourceLocation | null | undefined; 17 | } 18 | 19 | const webpackRscSsrLoader: webpack.LoaderDefinitionFunction = 20 | function (source, sourceMap) { 21 | this.cacheable(true); 22 | 23 | const {serverReferencesMap} = this.getOptions(); 24 | const serverReferenceExportNames: string[] = []; 25 | const resourcePath = this.resourcePath; 26 | 27 | const ast = parser.parse(source, { 28 | sourceType: `module`, 29 | sourceFilename: resourcePath, 30 | plugins: [`importAssertions`], 31 | }); 32 | 33 | let hasUseServerDirective = false; 34 | 35 | traverse.default(ast, { 36 | enter(path) { 37 | const {node} = path; 38 | 39 | if (t.isProgram(node)) { 40 | if (node.directives.some(isUseServerDirective)) { 41 | hasUseServerDirective = true; 42 | } else { 43 | path.skip(); 44 | } 45 | 46 | return; 47 | } 48 | 49 | if (t.isDirective(node) && isUseServerDirective(node)) { 50 | path.skip(); 51 | 52 | return; 53 | } 54 | 55 | const functionInfo = getFunctionInfo(node); 56 | 57 | if (functionInfo) { 58 | path.replaceWith(createExportedServerReferenceStub(functionInfo)); 59 | path.skip(); 60 | serverReferenceExportNames.push(functionInfo.exportName); 61 | } else { 62 | path.remove(); 63 | } 64 | }, 65 | }); 66 | 67 | if (!hasUseServerDirective) { 68 | return this.callback(null, source, sourceMap); 69 | } 70 | 71 | if (serverReferenceExportNames.length > 0) { 72 | serverReferencesMap.set(resourcePath, { 73 | exportNames: serverReferenceExportNames, 74 | }); 75 | } 76 | 77 | const {code, map} = generate.default( 78 | ast, 79 | { 80 | sourceFileName: this.resourcePath, 81 | sourceMaps: this.sourceMap, 82 | // @ts-expect-error 83 | inputSourceMap: sourceMap, 84 | }, 85 | source, 86 | ); 87 | 88 | this.callback(null, code, map ?? sourceMap); 89 | }; 90 | 91 | function isUseServerDirective(directive: t.Directive): boolean { 92 | return ( 93 | t.isDirectiveLiteral(directive.value) && 94 | directive.value.value === `use server` 95 | ); 96 | } 97 | 98 | function getFunctionInfo(node: t.Node): FunctionInfo | undefined { 99 | let localName: string | undefined; 100 | let loc: t.SourceLocation | null | undefined; 101 | 102 | if (t.isExportNamedDeclaration(node)) { 103 | if (t.isFunctionDeclaration(node.declaration)) { 104 | localName = node.declaration.id?.name; 105 | loc = node.declaration.id?.loc; 106 | } else if (t.isVariableDeclaration(node.declaration)) { 107 | const declarator = node.declaration.declarations[0]; 108 | 109 | if (!declarator) { 110 | return undefined; 111 | } 112 | 113 | if ( 114 | (t.isFunctionExpression(declarator.init) || 115 | t.isArrowFunctionExpression(declarator.init)) && 116 | t.isIdentifier(declarator.id) 117 | ) { 118 | localName = declarator.id.name; 119 | loc = declarator.id.loc; 120 | } 121 | } 122 | } 123 | 124 | return localName ? {exportName: localName, loc} : undefined; 125 | } 126 | 127 | function createExportedServerReferenceStub( 128 | functionInfo: FunctionInfo, 129 | ): t.ExportNamedDeclaration { 130 | const identifier = t.identifier(functionInfo.exportName); 131 | 132 | identifier.loc = functionInfo.loc; 133 | 134 | return t.exportNamedDeclaration( 135 | t.functionDeclaration( 136 | identifier, 137 | [], 138 | t.blockStatement([ 139 | t.throwStatement( 140 | t.newExpression(t.identifier(`Error`), [ 141 | t.stringLiteral( 142 | `Server actions must not be called during server-side rendering.`, 143 | ), 144 | ]), 145 | ), 146 | ]), 147 | ), 148 | ); 149 | } 150 | 151 | export = webpackRscSsrLoader; 152 | -------------------------------------------------------------------------------- /packages/webpack-rsc/src/webpack-rsc-ssr-loader.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import url from 'url'; 4 | import {jest} from '@jest/globals'; 5 | import type webpack from 'webpack'; 6 | import type {ServerReferencesMap} from './webpack-rsc-server-loader.cjs'; 7 | import type {WebpackRscSsrLoaderOptions} from './webpack-rsc-ssr-loader.cjs'; 8 | import webpackRscSsrLoader from './webpack-rsc-ssr-loader.cjs'; 9 | 10 | const currentDirname = path.dirname(url.fileURLToPath(import.meta.url)); 11 | 12 | async function callLoader( 13 | resourcePath: string, 14 | serverReferencesMap: ServerReferencesMap, 15 | ): Promise { 16 | const input = await fs.readFile(resourcePath); 17 | 18 | return new Promise((resolve, reject) => { 19 | const context: Partial> = 20 | { 21 | getOptions: () => ({serverReferencesMap}), 22 | resourcePath, 23 | cacheable: jest.fn(), 24 | callback: (error, content) => { 25 | if (error) { 26 | reject(error); 27 | } else if (content !== undefined) { 28 | resolve(content); 29 | } else { 30 | reject( 31 | new Error( 32 | `Did not receive any content from webpackRscSsrLoader.`, 33 | ), 34 | ); 35 | } 36 | }, 37 | }; 38 | 39 | void webpackRscSsrLoader.call( 40 | context as webpack.LoaderContext, 41 | input.toString(`utf-8`), 42 | ); 43 | }); 44 | } 45 | 46 | describe(`webpackRscSsrLoader`, () => { 47 | test(`generates stubs for all function exports of a server reference module, and removes the rest`, async () => { 48 | const resourcePath = path.resolve( 49 | currentDirname, 50 | `__fixtures__/server-functions.js`, 51 | ); 52 | 53 | const output = await callLoader(resourcePath, new Map()); 54 | 55 | expect(output).toEqual( 56 | ` 57 | 'use server'; 58 | 59 | export function foo() { 60 | throw new Error("Server actions must not be called during server-side rendering."); 61 | } 62 | export function bar() { 63 | throw new Error("Server actions must not be called during server-side rendering."); 64 | } 65 | export function baz() { 66 | throw new Error("Server actions must not be called during server-side rendering."); 67 | } 68 | `.trim(), 69 | ); 70 | }); 71 | 72 | test(`populates the given server references map`, async () => { 73 | const serverReferencesMap: ServerReferencesMap = new Map(); 74 | 75 | const resourcePath = path.resolve( 76 | currentDirname, 77 | `__fixtures__/server-functions.js`, 78 | ); 79 | 80 | await callLoader(resourcePath, serverReferencesMap); 81 | 82 | expect(Object.fromEntries(serverReferencesMap.entries())).toEqual({ 83 | [resourcePath]: {exportNames: [`foo`, `bar`, `baz`]}, 84 | }); 85 | }); 86 | 87 | test(`does not change modules without a 'use server' directive`, async () => { 88 | const resourcePath = path.resolve( 89 | currentDirname, 90 | `__fixtures__/client-component.js`, 91 | ); 92 | 93 | const output = await callLoader(resourcePath, new Map()); 94 | 95 | expect(output.toString().trim()).toEqual( 96 | ` 97 | // @ts-nocheck 98 | 'use client'; 99 | 100 | import * as React from 'react'; 101 | 102 | export function ClientComponent({action}) { 103 | React.useEffect(() => { 104 | action().then(console.log); 105 | }, []); 106 | 107 | return null; 108 | } 109 | 110 | export default ClientComponent; 111 | `.trim(), 112 | ); 113 | }); 114 | 115 | test(`can parse import assertions`, async () => { 116 | const resourcePath = path.resolve( 117 | currentDirname, 118 | `__fixtures__/import-assertions.js`, 119 | ); 120 | 121 | const output = await callLoader(resourcePath, new Map()); 122 | 123 | expect(output.toString().trim()).toEqual( 124 | `await import('./foo.json', {assert: {type: 'json'}});`, 125 | ); 126 | }); 127 | 128 | test(`can parse import attributes`, async () => { 129 | const resourcePath = path.resolve( 130 | currentDirname, 131 | `__fixtures__/import-attributes.js`, 132 | ); 133 | 134 | const output = await callLoader(resourcePath, new Map()); 135 | 136 | expect(output.toString().trim()).toEqual( 137 | `await import('./foo.json', {with: {type: 'json'}});`, 138 | ); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /packages/webpack-rsc/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src/**/*", "../../types"], 4 | "compilerOptions": { 5 | "outDir": "lib", 6 | "rootDir": "src" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowUnreachableCode": false, 4 | "allowUnusedLabels": false, 5 | "composite": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "incremental": true, 9 | "isolatedModules": true, 10 | "jsx": "react", 11 | "lib": ["dom"], 12 | "module": "node16", 13 | "moduleResolution": "node16", 14 | "noErrorTruncation": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitOverride": true, 17 | "noImplicitReturns": true, 18 | "noUncheckedIndexedAccess": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "pretty": true, 22 | "skipLibCheck": true, 23 | "strict": true, 24 | "target": "es2022", 25 | "verbatimModuleSyntax": true, 26 | "sourceMap": true, 27 | "inlineSources": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "checkJs": true, 5 | "noEmit": true 6 | }, 7 | "include": ["*.js", "*.cjs"], 8 | "references": [ 9 | {"path": "apps/aws-app"}, 10 | {"path": "apps/cloudflare-app"}, 11 | {"path": "apps/vercel-app"}, 12 | {"path": "packages/core"}, 13 | {"path": "packages/webpack-rsc"} 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": {}, 5 | "build:dev": {}, 6 | "build-dev-server": { 7 | "dependsOn": ["build"] 8 | }, 9 | "build-dev-server:dev": { 10 | "dependsOn": ["build:dev"] 11 | }, 12 | "deploy": { 13 | "dependsOn": ["build"], 14 | "cache": false 15 | }, 16 | "dev": { 17 | "dependsOn": ["build-dev-server:dev"], 18 | "cache": false, 19 | "persistent": true 20 | }, 21 | "start": { 22 | "dependsOn": ["build-dev-server"], 23 | "cache": false, 24 | "persistent": true 25 | }, 26 | "test": {}, 27 | "watch": { 28 | "dependsOn": ["build"], 29 | "cache": false, 30 | "persistent": true 31 | }, 32 | "watch:dev": { 33 | "dependsOn": ["build:dev"], 34 | "cache": false, 35 | "persistent": true 36 | }, 37 | "watch-dev-server": { 38 | "dependsOn": ["build-dev-server"], 39 | "cache": false, 40 | "persistent": true 41 | }, 42 | "watch-dev-server:dev": { 43 | "dependsOn": ["build-dev-server:dev"], 44 | "cache": false, 45 | "persistent": true 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /types/react-dom-server.d.ts: -------------------------------------------------------------------------------- 1 | import {RenderToReadableStreamOptions} from 'react-dom/server'; 2 | 3 | declare module 'react-dom/server' { 4 | export type ReactFormState = [ 5 | unknown /* actual state value */, 6 | string /* key path */, 7 | string /* Server Reference ID */, 8 | number /* number of bound arguments */, 9 | ]; 10 | 11 | export interface RenderToReadableStreamOptions { 12 | formState?: ReactFormState | null; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /types/react-dom-server.edge.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-dom/server.edge' { 2 | export { 3 | renderToNodeStream, 4 | renderToReadableStream, 5 | renderToStaticMarkup, 6 | renderToStaticNodeStream, 7 | renderToString, 8 | version, 9 | } from 'react-dom/server'; 10 | } 11 | -------------------------------------------------------------------------------- /types/react-server-dom-webpack-client.browser.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-server-dom-webpack/client.browser' { 2 | import type {Thenable} from 'react'; 3 | import type { 4 | ClientManifest, 5 | ReactServerValue, 6 | ServerReference, 7 | } from 'react-server-dom-webpack'; 8 | 9 | export interface ReactServerDomClientOptions { 10 | callServer?: CallServerCallback; 11 | temporaryReferences?: TemporaryReferenceSet; 12 | findSourceMapURL?: FindSourceMapURLCallback; 13 | replayConsoleLogs?: boolean; 14 | environmentName?: string; 15 | } 16 | 17 | type TemporaryReferenceSet = Map; 18 | 19 | export type CallServerCallback = ( 20 | id: string, 21 | args: ReactServerValue, 22 | ) => Thenable; 23 | 24 | export type EncodeFormActionCallback = ( 25 | id: any, 26 | args: Promise, 27 | ) => ReactCustomFormAction; 28 | 29 | export type ReactCustomFormAction = { 30 | name?: string; 31 | action?: string; 32 | encType?: string; 33 | method?: string; 34 | target?: string; 35 | data?: null | FormData; 36 | }; 37 | 38 | export type FindSourceMapURLCallback = ( 39 | fileName: string, 40 | environmentName: string, 41 | ) => null | string; 42 | 43 | export function createFromFetch( 44 | promiseForResponse: Promise, 45 | options?: ReactServerDomClientOptions, 46 | ): Thenable; 47 | 48 | export function createFromReadableStream( 49 | stream: ReadableStream, 50 | options?: ReactServerDomClientOptions, 51 | ): Thenable; 52 | 53 | export function encodeReply( 54 | value: ReactServerValue, 55 | ): Promise; 56 | 57 | export function createServerReference( 58 | id: string, 59 | callServer: CallServerCallback, 60 | encodeFormAction?: EncodeFormActionCallback, 61 | findSourceMapURL?: FindSourceMapURLCallback, // DEV-only 62 | functionName?: string, 63 | ): (...args: unknown[]) => Promise; 64 | } 65 | -------------------------------------------------------------------------------- /types/react-server-dom-webpack-client.edge.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-server-dom-webpack/client.edge' { 2 | import type {Thenable} from 'react'; 3 | import type {SSRManifest} from 'react-server-dom-webpack'; 4 | 5 | export interface CreateFromReadableStreamOptions { 6 | ssrManifest?: SSRManifest; 7 | } 8 | 9 | export function createFromReadableStream( 10 | stream: ReadableStream, 11 | options?: CreateFromReadableStreamOptions, 12 | ): Thenable; 13 | } 14 | -------------------------------------------------------------------------------- /types/react-server-dom-webpack-server.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-server-dom-webpack/server.edge' { 2 | import type {ReactElement, Thenable} from 'react'; 3 | import type {ReactFormState} from 'react-dom/server'; 4 | import type { 5 | ClientManifest, 6 | ReactClientValue, 7 | ReactServerValue, 8 | ServerManifest, 9 | } from 'react-server-dom-webpack'; 10 | 11 | export interface RenderToReadableStreamOptions { 12 | identifierPrefix?: string; 13 | signal?: AbortSignal; 14 | context?: [string, ServerContextJSONValue][]; 15 | onError?: (error: unknown) => void; 16 | } 17 | 18 | export type ServerContextJSONValue = 19 | | string 20 | | boolean 21 | | number 22 | | null 23 | | ReadonlyArray 24 | | {[key: string]: ServerContextJSONValue}; 25 | 26 | export function renderToReadableStream( 27 | model: ReactClientValue, 28 | webpackMap?: ClientManifest | null, 29 | options?: RenderToReadableStreamOptions, 30 | ): ReadableStream; 31 | 32 | export function decodeReply( 33 | body: string | FormData, 34 | serverManifest: ServerManifest, 35 | ): Thenable; 36 | 37 | export function decodeAction( 38 | body: FormData, 39 | serverManifest: ServerManifest, 40 | ): Promise<() => unknown> | null; 41 | 42 | export function decodeFormState( 43 | actionResult: unknown, 44 | body: FormData, 45 | serverManifest: ServerManifest, 46 | ): Promise; 47 | } 48 | -------------------------------------------------------------------------------- /types/react-server-dom-webpack.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-server-dom-webpack' { 2 | import type { 3 | Component, 4 | Context, 5 | LazyExoticComponent, 6 | ReactElement, 7 | } from 'react'; 8 | 9 | export interface ClientManifest { 10 | [id: string]: ImportManifestEntry; 11 | } 12 | 13 | export interface ServerManifest { 14 | [id: string]: ImportManifestEntry; 15 | } 16 | 17 | export interface SSRManifest { 18 | moduleMap: SSRModuleMap; 19 | moduleLoading: ModuleLoading | null; 20 | } 21 | 22 | export interface SSRModuleMap { 23 | [clientId: string]: { 24 | [clientExportName: string]: ImportManifestEntry; 25 | }; 26 | } 27 | 28 | export interface ModuleLoading { 29 | prefix: string; 30 | crossOrigin?: 'use-credentials' | ''; 31 | } 32 | 33 | export interface ImportManifestEntry { 34 | id: string | number; 35 | // chunks is a double indexed array of chunkId / chunkFilename pairs 36 | chunks: (string | number)[]; 37 | name: string; 38 | } 39 | 40 | export interface ServerReference { 41 | $$typeof: symbol; 42 | $$id: string; 43 | $$bound: null | ReactClientValue[]; 44 | } 45 | 46 | // Serializable values for the client 47 | export type ReactClientValue = 48 | // Server Elements and Lazy Components are unwrapped on the Server 49 | | ReactElement 50 | // | LazyExoticComponent // TODO: this is invalid and widens the type to any 51 | // References are passed by their value 52 | | ImportManifestEntry 53 | | ServerReference 54 | // The rest are passed as is. Sub-types can be passed in but lose their 55 | // subtype, so the receiver can only accept once of these. 56 | | ReactElement 57 | | ReactElement 58 | | Context // ServerContext 59 | | string 60 | | boolean 61 | | number 62 | | symbol 63 | | null 64 | | void 65 | | Iterable 66 | | ReactClientValue[] 67 | | ReactClientObject 68 | | Promise; // Thenable 69 | 70 | export type ReactClientObject = {[key: string]: ReactClientValue}; 71 | 72 | // Serializable values for the server 73 | export type ReactServerValue = 74 | // References are passed by their value 75 | | ServerReference 76 | // The rest are passed as is. Sub-types can be passed in but lose their 77 | // subtype, so the receiver can only accept once of these. 78 | | string 79 | | boolean 80 | | number 81 | | symbol 82 | | null 83 | | void 84 | | Iterable 85 | | ReactServerValue[] 86 | | ReactServerObject 87 | | Promise; // Thenable 88 | 89 | export type ReactServerObject = {[key: string]: ReactServerValue}; 90 | } 91 | -------------------------------------------------------------------------------- /types/wrangler.d.ts: -------------------------------------------------------------------------------- 1 | declare module '__STATIC_CONTENT_MANIFEST' { 2 | const manifest: string; 3 | export default manifest; 4 | } 5 | --------------------------------------------------------------------------------