├── .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 |
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 |
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 = `