├── .dockerignore ├── .editorconfig ├── .eslintrc.json ├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── examples ├── express │ ├── README.md │ ├── compose.yaml │ ├── default.vcl │ ├── esbuild.config.mjs │ ├── package.json │ ├── src │ │ ├── components │ │ │ └── MyFragment.tsx │ │ ├── entry-client.tsx │ │ ├── entry-server.tsx │ │ ├── pages │ │ │ └── App.tsx │ │ └── server.tsx │ ├── tsconfig.client.json │ ├── tsconfig.json │ └── tsconfig.server.json └── next │ ├── .env │ ├── .eslintrc.json │ ├── README.md │ ├── components │ └── MyFragment.tsx │ ├── compose.yaml │ ├── default.vcl │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── pages │ └── index.tsx │ ├── public │ ├── favicon.ico │ ├── next.svg │ └── vercel.svg │ ├── server.tsx │ ├── tsconfig.client.json │ ├── tsconfig.json │ └── tsconfig.server.json ├── lib ├── jest.config.ts ├── package.json ├── src │ ├── __tests__ │ │ ├── client │ │ │ ├── __snapshots__ │ │ │ │ └── withESI.test.tsx.snap │ │ │ └── withESI.test.tsx │ │ └── server │ │ │ ├── __snapshots__ │ │ │ ├── server.test.tsx.snap │ │ │ └── withESI.test.tsx.snap │ │ │ ├── server.test.tsx │ │ │ └── withESI.test.tsx │ ├── server.tsx │ └── withESI.tsx ├── tsconfig.build.json └── tsconfig.json ├── package.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | Dockerfile 3 | compose.yaml 4 | .git 5 | .gitignore 6 | *.md 7 | dist 8 | .github 9 | .vscode 10 | .next 11 | lib/lib -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2021": true, 6 | "node": true, 7 | "jest": true 8 | }, 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:react/recommended", 12 | "plugin:react/jsx-runtime", 13 | "plugin:@typescript-eslint/recommended", 14 | "prettier" 15 | ], 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { 18 | "ecmaVersion": "latest", 19 | "ecmaFeatures": { 20 | "jsx": true 21 | }, 22 | "sourceType": "module" 23 | }, 24 | "plugins": ["@typescript-eslint"], 25 | "rules": { 26 | "react/display-name": "off" 27 | }, 28 | "settings": { 29 | "react": { 30 | "version": "detect" 31 | } 32 | }, 33 | "ignorePatterns": ["node_modules", "dist", "lib/lib", ".next"] 34 | } 35 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: dunglas 2 | tidelift: "npm/react-esi" 3 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests / Lint 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [16, 18, 20] 11 | name: Run tests with Node.js ${{ matrix.node-version }} 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v3 19 | with: 20 | version: 8 21 | 22 | - name: Setup node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: "pnpm" 27 | 28 | - run: pnpm config --global set dedupe-peer-dependents=false 29 | - run: pnpm --filter=react-esi install 30 | - run: pnpm --filter=react-esi run lint 31 | - run: pnpm --filter=react-esi run typecheck 32 | - run: pnpm --filter=react-esi run build 33 | - run: pnpm --filter=react-esi run test 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | .pnpm-store 8 | 9 | # testing 10 | coverage 11 | 12 | # next.js 13 | .next/ 14 | out/ 15 | 16 | # production 17 | build 18 | dist 19 | lib/lib 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | link-workspace-packages=false -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/.git 2 | **/.svn 3 | **/.hg 4 | **/node_modules 5 | **/.next 6 | **/dist 7 | /lib/lib 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "orta.vscode-jest" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "jest.outputConfig": { 3 | "clearOnRun": "none", 4 | "revealOn": "demand", 5 | "revealWithFocus": "none" 6 | }, 7 | "testing.openTesting": "neverOpen", 8 | "eslint.workingDirectories": ["lib/", "examples/express/", "examples/next/"], 9 | "editor.codeActionsOnSave": { 10 | "source.eslint.fixAll": "explicit" 11 | }, 12 | "editor.formatOnSave": true, 13 | "editor.defaultFormatter": "esbenp.prettier-vscode", 14 | "eslint.validate": [ 15 | "javascript", 16 | "javascriptreact", 17 | "typescript", 18 | "typescriptreact" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-slim AS base 2 | ENV PNPM_HOME="/pnpm" 3 | ENV PATH="$PNPM_HOME:$PATH" 4 | RUN corepack enable pnpm 5 | 6 | FROM base AS base-deps 7 | WORKDIR /home/node/repo 8 | 9 | COPY ./package.json ./pnpm-*.yaml ./ 10 | COPY ./lib/package.json ./lib/ 11 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 12 | 13 | 14 | 15 | # Express 16 | FROM base AS express-deps 17 | WORKDIR /home/node/repo 18 | COPY --from=base-deps /home/node/repo ./ 19 | 20 | COPY ./examples/express/package.json ./examples/express/ 21 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 22 | 23 | 24 | FROM base AS express-build 25 | WORKDIR /home/node/repo 26 | COPY --from=express-deps /home/node/repo ./ 27 | COPY . /home/node/repo 28 | 29 | RUN pnpm --filter !esi-next run build 30 | RUN pnpm deploy --filter esi-express --prod /home/node/esi-express 31 | 32 | FROM base AS express-prod 33 | COPY --from=express-build /home/node/esi-express /home/node/esi-express 34 | WORKDIR /home/node/esi-express 35 | USER node 36 | EXPOSE 3000 37 | CMD [ "node", "dist/server.js" ] 38 | 39 | 40 | 41 | # Nextjs 42 | FROM base AS next-deps 43 | WORKDIR /home/node/repo 44 | COPY --from=base-deps /home/node/repo ./ 45 | 46 | COPY ./examples/next/package.json ./examples/next/ 47 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 48 | 49 | 50 | FROM base AS next-build 51 | WORKDIR /home/node/repo 52 | COPY --from=next-deps /home/node/repo ./ 53 | COPY . /home/node/repo 54 | 55 | ENV NEXT_TELEMETRY_DISABLED 1 56 | RUN pnpm --filter !esi-express run build 57 | RUN pnpm deploy --filter esi-next --prod /home/node/esi-next 58 | 59 | 60 | FROM base AS next-prod 61 | WORKDIR /home/node/esi-next 62 | 63 | ENV NODE_ENV production 64 | ENV NEXT_TELEMETRY_DISABLED 1 65 | 66 | # Set the correct permission for prerender cache 67 | RUN mkdir .next 68 | RUN chown node:node .next 69 | COPY --from=next-build --chown=node:node /home/node/esi-next/public ./public 70 | COPY --from=next-build --chown=node:node /home/node/esi-next/node_modules ./node_modules 71 | COPY --from=next-build --chown=node:node /home/node/esi-next/.next/standalone ./ 72 | COPY --from=next-build --chown=node:node /home/node/esi-next/dist ./dist 73 | COPY --from=next-build --chown=node:node /home/node/esi-next/.next/static ./.next/static 74 | 75 | USER node 76 | EXPOSE 3000 77 | CMD [ "node", "dist/server.js" ] 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-present Kévin Dunglas 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React ESI: Blazing-fast Server-Side Rendering for React and Next.js 2 | 3 | ![CI status](https://github.com/github/docs/actions/workflows/test.yml/badge.svg) 4 | [![Coverage Status](https://coveralls.io/repos/github/dunglas/react-esi/badge.svg?branch=master)](https://coveralls.io/github/dunglas/react-esi?branch=master) 5 | [![npm version](https://badge.fury.io/js/react-esi.svg)](https://badge.fury.io/js/react-esi) 6 | [![MIT Licence](https://badges.frapsoft.com/os/mit/mit.svg?v=103)](https://opensource.org/licenses/mit-license.php) 7 | 8 | React ESI is a super powerful cache library for vanilla [React](https://reactjs.org/) and [Next.js](https://nextjs.org/) applications, that can make highly dynamic applications as fast as static sites. 9 | 10 | It provides a straightforward way to boost your application's performance by storing **fragments** of server-side rendered pages in **edge cache servers**. 11 | It means that after the first rendering, fragments of your pages will be served in a few milliseconds by servers close to your end users! 12 | It's a very efficient way to improve the performance and the SEO of your websites and to dramatically reduce both your hosting costs and the energy consumption of these applications. Help the planet, use React ESI! 13 | 14 | Because it is built on top of the [Edge Side Includes (ESI)](https://www.w3.org/TR/esi-lang) W3C specification, 15 | React ESI natively supports most of the well-known cloud cache providers including [Cloudflare Workers](https://blog.cloudflare.com/edge-side-includes-with-cloudflare-workers/), [Akamai](https://www.akamai.com/us/en/support/esi.jsp) and [Fastly](https://docs.fastly.com/guides/performance-tuning/using-edge-side-includes). 16 | Of course, React ESI also supports the open source [Varnish cache server](https://varnish-cache.org/intro/index.html#intro) that you can use in your own infrastructure for free ([configuration example](https://github.com/zeit/next.js/blob/canary/examples/with-react-esi/docker/varnish/default.vcl)). 17 | 18 | Also, React ESI allows the specification of different Time To Live (TTL) per React component and generates the corresponding HTML asynchronously using a secure (signed) URL. 19 | The cache server fetches and stores in the cache all the needed fragments (the HTML corresponding to every React component), builds the final page and sends it to the browser. 20 | React ESI also allows components to (re-)render client-side without any specific configuration. 21 | 22 | ![ESI example](https://raw.githubusercontent.com/varnish/Varnish-Book/3bd8894181f5e42f628967d04f40116498d1f7f2/ui/img/esi.png) 23 | 24 | > Schema from [The Varnish Book](https://info.varnish-software.com/resources/varnish-6-by-example-book) 25 | 26 | **[Discover React ESI in depth with this presentation](https://dunglas.fr/2019/04/react-esi-blazing-fast-ssr/)** 27 | 28 | ## Examples 29 | 30 | - [Next.js, Express, and Varnish](https://github.com/dunglas/react-esi/tree/main/examples/next) 31 | - [React, Express, and Varnish](https://github.com/dunglas/react-esi/tree/main/examples/express) 32 | 33 | ## Install 34 | 35 | Using NPM: 36 | 37 | $ npm install react-esi 38 | 39 | Or using Yarn: 40 | 41 | $ yarn add react-esi 42 | 43 | Or using PNPM: 44 | 45 | $ pnpm add react-esi 46 | 47 | ## Usage 48 | 49 | React ESI provides a convenient [Higher Order Component](https://reactjs.org/docs/higher-order-components.html) that will: 50 | 51 | - replace the wrapped component with an ESI tag server-side (don't worry React ESI also provides the tooling to generate the corresponding fragment); 52 | - render the wrapped component client-side, and feed it with the server-side computed props (if any). 53 | 54 | React ESI automatically calls a `static async` method named `getInitialProps()` to populate the initial props of the component. Server-side, this method can access to the HTTP request and response, for instance, to set the `Cache-Control` header, or some [cache tags](https://api-platform.com/docs/core/performance/#enabling-the-built-in-http-cache-invalidation-system). 55 | 56 | These props returned by `getInitialProps()` will also be injected in the server-side generated HTML (in a ` 24 | 25 | 26 |
${app}
27 | 28 | 29 | `; 30 | 31 | res.send(html); 32 | }); 33 | 34 | // "path" default to /_fragment, change it using the REACT_ESI_PATH env var 35 | server.get(path, (req, res) => { 36 | return serveFragment( 37 | req, 38 | res, 39 | // "fragmentID" is the second parameter passed to the "WithESI" HOC, 40 | // the root component used for this fragment must be returned 41 | // eslint-disable-next-line @typescript-eslint/no-var-requires 42 | (fragmentID) => require(`./components/${fragmentID}`).default 43 | ); 44 | }); 45 | 46 | // ... 47 | // Other Express routes come here 48 | 49 | server.use(express.static(join(__dirname, "../dist"))); 50 | 51 | server 52 | .listen(port, () => { 53 | console.log( 54 | `> Server listening at http://localhost:${port} as ${ 55 | dev ? "development" : process.env.NODE_ENV 56 | }` 57 | ); 58 | }) 59 | .once("error", (err) => { 60 | console.error(err); 61 | process.exit(1); 62 | }); 63 | -------------------------------------------------------------------------------- /examples/express/tsconfig.client.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Type Checking */ 4 | "strict": true, 5 | 6 | /* Modules */ 7 | "rootDir": "./src/", 8 | "moduleResolution": "Bundler", 9 | "module": "ESNext", 10 | "types": ["node"], 11 | 12 | /* Emit */ 13 | "declaration": true, 14 | "sourceMap": true, 15 | "outDir": "./dist/", 16 | 17 | /* Interop Constraints */ 18 | "esModuleInterop": true, 19 | "isolatedModules": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "allowSyntheticDefaultImports": true, 22 | 23 | /* Language and Environment */ 24 | "jsx": "react-jsx", 25 | "target": "ES6", 26 | "lib": ["DOM", "ESNext"] 27 | }, 28 | "include": ["src/entry-client.tsx"], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /examples/express/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Type Checking */ 4 | "strict": true, 5 | 6 | /* Modules */ 7 | "moduleResolution": "Node10", 8 | "module": "CommonJS", 9 | 10 | /* Emit */ 11 | "sourceMap": true, 12 | "outDir": "dist", 13 | 14 | /* Interop Constraints */ 15 | "esModuleInterop": true, 16 | "isolatedModules": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "allowSyntheticDefaultImports": true, 19 | 20 | /* Language and Environment */ 21 | "jsx": "react-jsx", 22 | "target": "ES6", 23 | "lib": ["DOM", "ESNext"] 24 | }, 25 | "include": ["**/*.tsx"] 26 | } 27 | -------------------------------------------------------------------------------- /examples/express/tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Type Checking */ 4 | "strict": true, 5 | 6 | /* Modules */ 7 | "rootDir": "./src/", 8 | "moduleResolution": "Node10", 9 | "module": "CommonJS", 10 | "types": ["node"], 11 | 12 | /* Emit */ 13 | "sourceMap": true, 14 | "outDir": "./dist/", 15 | 16 | /* Language and Environment */ 17 | "jsx": "react-jsx", 18 | "lib": ["DOM", "ESNext"], 19 | "target": "ES5", 20 | 21 | /* Interop Constraints */ 22 | "esModuleInterop": true, 23 | "isolatedModules": true, 24 | "forceConsistentCasingInFileNames": true, 25 | "allowSyntheticDefaultImports": true 26 | }, 27 | "include": ["src/server.tsx"] 28 | } 29 | -------------------------------------------------------------------------------- /examples/next/.env: -------------------------------------------------------------------------------- 1 | REACT_ESI_SECRET=secret -------------------------------------------------------------------------------- /examples/next/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:@next/next/recommended"], 3 | "settings": { 4 | "next": { 5 | "rootDir": "./examples/next/" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/next/README.md: -------------------------------------------------------------------------------- 1 | # Custom Server Example with TypeScript, Next, Varnish, and react-esi 2 | 3 | This example showcases how you can use [TypeScript](https://typescriptlang.com) on both the server and client side. It demonstrates the integration of [Next.js](https://nextjs.org/) for universal application development. The integration of [Varnish](https://varnish-cache.org/intro/) server and the `react-esi` library provides robust caching capabilities using `esi:include` tags, enhancing your application's performance and scalability. 4 | 5 | Server entry point is `server.ts` in development and `dist/server.js` in production. 6 | 7 | ## How to use 8 | 9 | 1. Clone this repository. 10 | 2. Navigate to the examples/next folder. 11 | 3. Use `docker compose up -d` to run the example. 12 | -------------------------------------------------------------------------------- /examples/next/components/MyFragment.tsx: -------------------------------------------------------------------------------- 1 | import type { Response } from "express"; 2 | import PropTypes from "prop-types"; 3 | import React from "react"; 4 | 5 | interface MyFragmentProps { 6 | greeting: string; 7 | dataFromAnAPI?: string; 8 | } 9 | 10 | interface GetInitialProps { 11 | props: MyFragmentProps; 12 | res?: Response; 13 | } 14 | 15 | export default class MyFragment extends React.Component { 16 | public static propTypes: PropTypes.InferProps; 17 | render() { 18 | return ( 19 |
20 |

A fragment that can have its own TTL

21 | 22 |

{this.props.greeting /* access to the props as usual */}

23 |

{this.props.dataFromAnAPI}

24 |
25 | ); 26 | } 27 | 28 | static async getInitialProps({ props, res }: GetInitialProps) { 29 | return new Promise((resolve) => { 30 | if (res) { 31 | // Set a TTL for this fragment 32 | res.set("Cache-Control", "s-maxage=60, max-age=30"); 33 | } 34 | 35 | // Simulate a delay (call to a remote service such as a web API) 36 | setTimeout( 37 | () => 38 | resolve({ 39 | ...props, // Props coming from index.js, passed through the internal URL 40 | dataFromAnAPI: "Hello there", 41 | }), 42 | 2000 43 | ); 44 | }); 45 | } 46 | } 47 | 48 | MyFragment.propTypes = { 49 | greeting: PropTypes.string, 50 | dataFromAnAPI: PropTypes.string, 51 | }; 52 | -------------------------------------------------------------------------------- /examples/next/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | varnish: 3 | image: varnish:7.4 4 | volumes: 5 | - ./default.vcl:/etc/varnish/default.vcl:ro 6 | tmpfs: 7 | - /var/lib/varnish/varnishd:exec 8 | ports: 9 | - "8090:80" 10 | restart: unless-stopped 11 | depends_on: 12 | - node 13 | 14 | node: 15 | image: next-esi 16 | build: 17 | context: ../../ 18 | target: next-prod 19 | user: node 20 | init: true 21 | # volumes: 22 | # - ../../:/home/node/app 23 | expose: 24 | - "3000" 25 | environment: 26 | - NODE_ENV=production 27 | - REACT_ESI_SECRET=${REACT_ESI_SECRET:-secret} 28 | restart: unless-stopped 29 | -------------------------------------------------------------------------------- /examples/next/default.vcl: -------------------------------------------------------------------------------- 1 | vcl 4.1; 2 | 3 | backend node { 4 | .host = "node"; 5 | .port = "3000"; 6 | } 7 | 8 | sub vcl_recv { 9 | if (req.http.upgrade ~ "(?i)websocket") { 10 | return (pipe); 11 | } 12 | 13 | # Announce ESI support to Node (optional) 14 | set req.http.Surrogate-Capability = "key=ESI/1.0"; 15 | } 16 | 17 | sub vcl_backend_response { 18 | # Enable ESI support 19 | if (beresp.http.Surrogate-Control ~ "ESI/1.0") { 20 | unset beresp.http.Surrogate-Control; 21 | set beresp.do_esi = true; 22 | } 23 | } 24 | 25 | sub vcl_pipe { 26 | if (req.http.upgrade) { 27 | set bereq.http.upgrade = req.http.upgrade; 28 | set bereq.http.connection = req.http.connection; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/next/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /examples/next/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | output: "standalone", 5 | experimental: { 6 | outputFileTracingRoot: __dirname, 7 | }, 8 | }; 9 | 10 | module.exports = nextConfig; 11 | -------------------------------------------------------------------------------- /examples/next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esi-next", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "An example project to demonstrate usage of ESI with Next.js and Express.js", 6 | "repository": "https://github.com/dunglas/react-esi/examples/next", 7 | "license": "MIT", 8 | "author": "Kévin Dunglas", 9 | "main": "dist/server.js", 10 | "files": [ 11 | "dist", 12 | ".next", 13 | "components", 14 | "public" 15 | ], 16 | "scripts": { 17 | "build-next": "NODE_ENV=production next build", 18 | "build-server": "tsc --project tsconfig.server.json", 19 | "build-client": "tsc --project tsconfig.client.json", 20 | "build": "pnpm build-next && pnpm build-server", 21 | "dev": "tsx watch --tsconfig ./tsconfig.server.json ./server.tsx", 22 | "start": "cross-env NODE_ENV=production node dist/server.js" 23 | }, 24 | "dependencies": { 25 | "express": "^4.18.3", 26 | "next": "^14.1.3", 27 | "prop-types": "^15.8.1", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "react-esi": "workspace:^" 31 | }, 32 | "devDependencies": { 33 | "@next/eslint-plugin-next": "^14.1.3", 34 | "@types/express": "^4.17.21", 35 | "@types/node": "^20.11.25", 36 | "@types/prop-types": "^15.7.11", 37 | "@types/react": "18.2.64", 38 | "@types/react-dom": "^18.2.21", 39 | "cross-env": "^7.0.3", 40 | "eslint": "^8.57.0", 41 | "nodemon": "^3.1.0", 42 | "tsx": "^4.7.1", 43 | "typescript": "^5.3.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /examples/next/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import withESI from "react-esi/lib/withESI"; 2 | import MyFragment from "../components/MyFragment"; 3 | 4 | const MyFragmentESI = withESI(MyFragment, "MyFragment"); 5 | // The second parameter is an unique ID identifying this fragment. 6 | // If you use different instances of the same component, use a different ID per instance. 7 | 8 | const App = () => ( 9 |
10 |

React ESI demo app

11 | 12 |
13 | ); 14 | 15 | export default App; 16 | -------------------------------------------------------------------------------- /examples/next/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dunglas/react-esi/b3901888a5182864d9fcd4c38da679f726e30703/examples/next/public/favicon.ico -------------------------------------------------------------------------------- /examples/next/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/next/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/next/server.tsx: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import next from "next"; 3 | import { parse } from "url"; 4 | import { path, serveFragment } from "react-esi/lib/server"; 5 | 6 | const port = Number.parseInt(process.env.PORT || "3000", 10); 7 | const dev = process.env.NODE_ENV !== "production"; 8 | const hostname = "localhost"; 9 | const app = next({ dev, hostname, port }); 10 | const handle = app.getRequestHandler(); 11 | 12 | app.prepare().then(() => { 13 | const server = express(); 14 | 15 | server.use((req, res, next) => { 16 | // Send the Surrogate-Control header to announce ESI support to proxies (optional with Varnish) 17 | res.set("Surrogate-Control", 'content="ESI/1.0"'); 18 | next(); 19 | }); 20 | 21 | server.get(path, (req, res) => { 22 | try { 23 | return serveFragment(req, res, (fragmentID) => { 24 | // eslint-disable-next-line @typescript-eslint/no-var-requires 25 | return require(`./components/${fragmentID}`).default; 26 | }); 27 | } catch (error) { 28 | console.error({ error }); 29 | 30 | res.status(500); 31 | res.send((error as Error).message); 32 | } 33 | }); 34 | 35 | // Next.js routes 36 | server.get("*", async (req, res) => { 37 | try { 38 | const parsedUrl = parse(req.url, true); 39 | 40 | return handle(req, res, parsedUrl); 41 | } catch (error) { 42 | console.error("Error occurred handling", req.url, error); 43 | res.statusCode = 500; 44 | res.end("internal server error"); 45 | } 46 | }); 47 | 48 | server 49 | .listen(port, () => { 50 | console.log( 51 | `> Server listening at http://localhost:${port} as ${ 52 | dev ? "development" : process.env.NODE_ENV 53 | }` 54 | ); 55 | }) 56 | .once("error", (err) => { 57 | console.error(err); 58 | process.exit(1); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /examples/next/tsconfig.client.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["./components/*.tsx"] 4 | } 5 | -------------------------------------------------------------------------------- /examples/next/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Type Checking */ 4 | "strict": true, 5 | /* Modules */ 6 | "moduleResolution": "Node10", 7 | "module": "ESNext", 8 | "resolveJsonModule": true, 9 | "paths": { 10 | "@/*": ["./*"] 11 | }, 12 | /* Emit */ 13 | "noEmit": true, 14 | /* JavaScript Support */ 15 | "allowJs": true, 16 | /* Interop Constraints */ 17 | "esModuleInterop": true, 18 | "isolatedModules": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "allowSyntheticDefaultImports": true, 21 | /* Language and Environment */ 22 | "jsx": "preserve", 23 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 24 | "target": "ES6", 25 | /* Projects */ 26 | "composite": true, 27 | /* Completeness */ 28 | "skipLibCheck": true, 29 | "incremental": true 30 | }, 31 | "include": ["next-env.d.ts", "**/*.ts*"], 32 | "exclude": ["node_modules", "server.tsx"] 33 | } 34 | -------------------------------------------------------------------------------- /examples/next/tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Type Checking */ 4 | "strict": true, 5 | 6 | /* Modules */ 7 | "moduleResolution": "Node10", 8 | "module": "CommonJS", 9 | 10 | /* Emit */ 11 | "declaration": true, 12 | "sourceMap": true, 13 | "outDir": "./dist/", 14 | 15 | /* Language and Environment */ 16 | "jsx": "react-jsx", 17 | "lib": ["DOM", "ES2019"], 18 | "target": "ES2019", 19 | 20 | /* Interop Constraints */ 21 | "esModuleInterop": true, 22 | "isolatedModules": false, 23 | "forceConsistentCasingInFileNames": true, 24 | "allowSyntheticDefaultImports": true 25 | }, 26 | "include": ["server.tsx", "components/*.tsx"] 27 | } 28 | -------------------------------------------------------------------------------- /lib/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest"; 2 | 3 | const commonConfig: Config = { 4 | roots: ["/src"], 5 | transform: { 6 | "^.+\\.tsx?$": "ts-jest", 7 | }, 8 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 9 | }; 10 | 11 | const config: Config = { 12 | projects: [ 13 | { 14 | ...commonConfig, 15 | displayName: "server", 16 | testRegex: "/__tests__/server/.*\\.[jt]sx?$", 17 | testEnvironment: "node", 18 | }, 19 | { 20 | ...commonConfig, 21 | displayName: "client", 22 | testRegex: "/__tests__/client/.*\\.[jt]sx?$", 23 | testEnvironment: "jsdom", 24 | }, 25 | ], 26 | }; 27 | 28 | export default config; 29 | -------------------------------------------------------------------------------- /lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-esi", 3 | "version": "0.3.1", 4 | "description": "React ESI: Blazing-fast Server-Side Rendering for React and Next.js", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/dunglas/react-esi.git" 8 | }, 9 | "keywords": [ 10 | "react", 11 | "react-esi", 12 | "esi" 13 | ], 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/dunglas/react-esi/issues" 17 | }, 18 | "author": "Kévin Dunglas", 19 | "homepage": "https://github.com/dunglas/react-esi#readme", 20 | "funding": [ 21 | { 22 | "type": "github", 23 | "url": "https://github.com/sponsors/dunglas" 24 | }, 25 | { 26 | "type": "tidelift", 27 | "url": "https://tidelift.com/subscription/pkg/npm-react-esi" 28 | } 29 | ], 30 | "main": "lib/withESI.js", 31 | "typings": "lib/withESI.d.ts", 32 | "scripts": { 33 | "coveralls": "jest --coverage && cat ./coverage/lcov.info | coveralls", 34 | "cs": "prettier --write '**/*.{js,jsx,ts,tsx,json,css,scss,md}'", 35 | "lint": "eslint --ext js,jsx,ts,tsx", 36 | "lint:fix": "eslint --fix --ext js,jsx,ts,tsx src", 37 | "test": "jest", 38 | "clean": "rm -rf ./lib", 39 | "typecheck": "tsc --noEmit", 40 | "build": "pnpm clean && tsc -p tsconfig.build.json", 41 | "build:watch": "tsc -w -p tsconfig.build.json" 42 | }, 43 | "devDependencies": { 44 | "@types/express": "^4.17.21", 45 | "@types/jest": "^29.5.12", 46 | "@types/node": "^20.11.25", 47 | "@types/react-test-renderer": "^18.0.7", 48 | "@types/supertest": "^6.0.2", 49 | "coveralls": "^3.1.1", 50 | "eslint": "^8.57.0", 51 | "express": "^4.18.3", 52 | "jest": "^29.7.0", 53 | "jest-environment-jsdom": "^29.7.0", 54 | "prettier": "^3.2.5", 55 | "react-test-renderer": "^18.2.0", 56 | "supertest": "^6.3.4", 57 | "ts-jest": "^29.1.2", 58 | "ts-node": "^10.9.2", 59 | "typescript": "^5.3.3" 60 | }, 61 | "peerDependencies": { 62 | "@types/prop-types": "^15.7.11", 63 | "@types/react": "^16.7.0 || ^17.0.0 || ^18.0.0", 64 | "@types/react-dom": "^16.7.0 || ^17.0.0 || ^18.0.0", 65 | "prop-types": "^15.8.1", 66 | "react": "^16.7.0 || ^17.0.0 || ^18.0.0", 67 | "react-dom": "^16.7.0 || ^17.0.0 || ^18.0.0" 68 | } 69 | } -------------------------------------------------------------------------------- /lib/src/__tests__/client/__snapshots__/withESI.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`client-side 1`] = ` 4 |
5 | Hello 6 | Kévin 7 |
8 | `; 9 | 10 | exports[`client-side with serialized props 1`] = ` 11 |
12 | Hello 13 | Anne 14 |
15 | `; 16 | -------------------------------------------------------------------------------- /lib/src/__tests__/client/withESI.test.tsx: -------------------------------------------------------------------------------- 1 | import renderer from "react-test-renderer"; 2 | import withESI from "../../withESI"; 3 | 4 | declare let global: typeof globalThis & { 5 | __REACT_ESI__: { [s: string]: object }; 6 | }; 7 | 8 | const Dummy = (props: { name?: string }) =>
Hello {props.name}
; 9 | 10 | test("exposes WrappedComponent", () => { 11 | const DummyESI = withESI(Dummy, "id"); 12 | expect(DummyESI).toHaveProperty("WrappedComponent", Dummy); 13 | }); 14 | 15 | test("client-side", () => { 16 | const DummyESI = withESI(Dummy, "id"); 17 | expect(DummyESI.displayName).toBe("WithESI(Dummy)"); 18 | 19 | const component = renderer.create(); 20 | expect(component).toMatchSnapshot(); 21 | }); 22 | 23 | test("client-side with serialized props", () => { 24 | const DummyESI = withESI(Dummy, "id"); 25 | expect(DummyESI.displayName).toBe("WithESI(Dummy)"); 26 | 27 | global.__REACT_ESI__ = { id: { name: "Anne" } }; 28 | const component = renderer.create(); 29 | expect(component).toMatchSnapshot(); 30 | }); 31 | 32 | test("client-side call getInitialProps", async () => { 33 | let called = false; 34 | 35 | const Component = (props: { name?: string }) =>
Hello {props.name}
; 36 | Component.getInitialProps = async () => { 37 | called = true; 38 | return { name: "Kévin" }; 39 | }; 40 | 41 | const ComponentESI = withESI(Component, "initial-props"); 42 | 43 | renderer.create(); 44 | expect(called).toBe(true); 45 | }); 46 | -------------------------------------------------------------------------------- /lib/src/__tests__/server/__snapshots__/server.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`createIncludeElement 1`] = ` 4 | '"" 6 | src="/_custom?fragment=fragmentID&props=%7B%22name%22%3A%22K%C3%A9vin%22%7D&sign=f7ddf06659aadbcba0cdad4c927ac5bf38167d714e1a15cad13115e7e9d21a9d" 7 | /> 8 | `; 9 | 10 | exports[`initial props 1`] = `"
Hello Anne
"`; 11 | 12 | exports[`invalid signature 1`] = `"Bad signature"`; 13 | 14 | exports[`serveFragment 1`] = `"
Hello Kévin
"`; 15 | -------------------------------------------------------------------------------- /lib/src/__tests__/server/__snapshots__/withESI.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`server-side 1`] = ` 4 | 8 | `; 9 | -------------------------------------------------------------------------------- /lib/src/__tests__/server/server.test.tsx: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import Stream from "stream"; 3 | import request from "supertest"; 4 | 5 | process.env.REACT_ESI_SECRET = "dummy"; 6 | process.env.REACT_ESI_PATH = "/_custom"; 7 | 8 | import { createIncludeElement, path, serveFragment } from "../../server"; 9 | 10 | test("path", () => { 11 | expect(path).toBe("/_custom"); 12 | }); 13 | 14 | test("createIncludeElement", () => { 15 | const elem = createIncludeElement( 16 | "fragmentID", 17 | { name: "Kévin" }, 18 | { attrs: { alt: `"'<&Alt>'"` } } 19 | ); 20 | expect(elem).toMatchSnapshot(); 21 | }); 22 | 23 | const fragmentURL = 24 | "/_custom?fragment=fragmentID&props=%7B%22name%22%3A%22K%C3%A9vin%22%7D&sign=f7ddf06659aadbcba0cdad4c927ac5bf38167d714e1a15cad13115e7e9d21a9d"; 25 | test("serveFragment", async () => { 26 | const app = express(); 27 | const resolver = ( 28 | fragmentID: string, 29 | props: object, 30 | req: express.Request, 31 | res: express.Response 32 | ) => { 33 | expect(fragmentID).toBe("fragmentID"); 34 | expect(props).toMatchObject({ name: "Kévin" }); 35 | expect(req.header("user-agent")).toBe("test"); 36 | expect(res).toBeDefined(); 37 | 38 | return (p: { name: string }) =>
Hello {p.name}
; 39 | }; 40 | 41 | app.get(path, (req: express.Request, res: express.Response) => 42 | serveFragment(req, res, resolver) 43 | ); 44 | 45 | const response = await request(app) 46 | .get(fragmentURL) 47 | .set("user-agent", "test") 48 | .expect(200); 49 | expect(response.text).toMatchSnapshot(); 50 | }); 51 | 52 | test("serveFragment with pipeStream option", async () => { 53 | const app = express(); 54 | const resolver = ( 55 | fragmentID: string, 56 | props: object, 57 | req: express.Request, 58 | res: express.Response 59 | ) => { 60 | expect(fragmentID).toBe("fragmentID"); 61 | expect(props).toMatchObject({ name: "Kévin" }); 62 | expect(req.header("user-agent")).toBe("test"); 63 | expect(res).toBeDefined(); 64 | 65 | return (p: { name: string }) =>
Hello {p.name}
; 66 | }; 67 | 68 | app.get(path, (req: express.Request, res: express.Response) => 69 | serveFragment(req, res, resolver, { 70 | pipeStream: (input) => { 71 | const transformer = new Stream.Transform({ 72 | transform: (chunk, encoding, callback) => { 73 | callback(); 74 | }, 75 | flush: (callback) => { 76 | callback(null, "
hi there
"); 77 | }, 78 | }); 79 | input.pipe(transformer); 80 | return transformer; 81 | }, 82 | }) 83 | ); 84 | 85 | const response = await request(app) 86 | .get(fragmentURL) 87 | .set("user-agent", "test") 88 | .expect(200); 89 | const addedScript = 90 | ''; 91 | expect(response.text).toEqual(`${addedScript}
hi there
`); 92 | }); 93 | 94 | test("initial props", async () => { 95 | const app = express(); 96 | const resolver = ( 97 | fragmentID: string, 98 | props: object, 99 | req: express.Request, 100 | res: express.Response 101 | ) => { 102 | expect(fragmentID).toBe("fragmentID"); 103 | expect(props).toMatchObject({ name: "Kévin" }); 104 | expect(req.header("user-agent")).toBe("test"); 105 | expect(res).toBeDefined(); 106 | 107 | interface IPropsType { 108 | name: string; 109 | } 110 | const Component = (p: IPropsType) =>
Hello {p.name}
; 111 | Component.getInitialProps = async () => { 112 | return { name: "Anne" }; 113 | }; 114 | 115 | return Component; 116 | }; 117 | 118 | app.get(path, (req: express.Request, res: express.Response) => 119 | serveFragment(req, res, resolver) 120 | ); 121 | 122 | const response = await request(app) 123 | .get(fragmentURL) 124 | .set("user-agent", "test") 125 | .expect(200); 126 | expect(response.text).toMatchSnapshot(); 127 | }); 128 | 129 | test("invalid signature", async () => { 130 | const app = express(); 131 | const resolver = () => () =>
; 132 | 133 | app.get(path, (req: express.Request, res: express.Response) => 134 | serveFragment(req, res, resolver) 135 | ); 136 | 137 | const response = await request(app) 138 | .get( 139 | "/_custom?fragment=fragmentID&props=%7B%22foo%22%3A%22bar%22%7D&sign=invalid" 140 | ) 141 | .expect(400); 142 | expect(response.text).toMatchSnapshot(); 143 | }); 144 | -------------------------------------------------------------------------------- /lib/src/__tests__/server/withESI.test.tsx: -------------------------------------------------------------------------------- 1 | import renderer from "react-test-renderer"; 2 | import withESI from "../../withESI"; 3 | 4 | const Dummy = (props: { name?: string }) =>
Hello {props.name}
; 5 | 6 | test("server-side", () => { 7 | const DummyESI = withESI(Dummy, "id"); 8 | expect(DummyESI.displayName).toBe("WithESI(Dummy)"); 9 | 10 | process.env.REACT_ESI_SECRET = "dummy"; 11 | const component = renderer.create( 12 | 13 | ); 14 | expect(component).toMatchSnapshot(); 15 | }); 16 | -------------------------------------------------------------------------------- /lib/src/server.tsx: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import crypto from "crypto"; 3 | import type { Transform } from "stream"; 4 | import { Readable } from "stream"; 5 | import type { ComponentType } from "react"; 6 | import React from "react"; 7 | import type { PipeableStream } from "react-dom/server"; 8 | import { renderToPipeableStream } from "react-dom/server"; 9 | 10 | export const path = process.env.REACT_ESI_PATH || "/_fragment"; 11 | const secret = 12 | process.env.REACT_ESI_SECRET || crypto.randomBytes(64).toString("hex"); 13 | 14 | /** 15 | * Signs the ESI URL with a secret key using the HMAC-SHA256 algorithm. 16 | */ 17 | function sign(url: URL) { 18 | const hmac = crypto.createHmac("sha256", secret); 19 | hmac.update(url.pathname + url.search); 20 | return hmac.digest("hex"); 21 | } 22 | 23 | interface IEsiAttrs { 24 | src?: string; 25 | alt?: string; 26 | onerror?: string; 27 | } 28 | 29 | interface IEsiProps { 30 | attrs?: IEsiAttrs; 31 | } 32 | 33 | /** 34 | * Creates the tag. 35 | */ 36 | export const createIncludeElement = ( 37 | fragmentID: string, 38 | props: object, 39 | esi: IEsiProps 40 | ) => { 41 | const esiAt = esi.attrs || {}; 42 | 43 | const url = new URL(path, "http://example.com"); 44 | url.searchParams.append("fragment", fragmentID); 45 | url.searchParams.append("props", JSON.stringify(props)); 46 | url.searchParams.append("sign", sign(url)); 47 | 48 | esiAt.src = url.pathname + url.search; 49 | 50 | return React.createElement("esi:include", esiAt); 51 | }; 52 | 53 | interface IServeFragmentOptions { 54 | pipeStream?: (stream: PipeableStream) => InstanceType; 55 | } 56 | 57 | type Resolver< 58 | TProps = 59 | | Record 60 | | Promise 61 | | Promise>, 62 | > = ( 63 | fragmentID: string, 64 | props: object, 65 | req: Request, 66 | res: Response 67 | ) => ComponentType; 68 | 69 | /** 70 | * Checks the signature, renders the given fragment as HTML 71 | * and injects the initial props in a `; 113 | const scriptStream = Readable.from(script); 114 | scriptStream.pipe(res, { end: false }); 115 | 116 | const stream = renderToPipeableStream(); 117 | 118 | const lastStream = options.pipeStream ? options.pipeStream(stream) : stream; 119 | 120 | lastStream.pipe(res); 121 | } 122 | -------------------------------------------------------------------------------- /lib/src/withESI.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | import type { WeakValidationMap, ComponentType, ComponentClass } from "react"; 3 | import React from "react"; 4 | 5 | declare global { 6 | interface Window { 7 | __REACT_ESI__: { [s: string]: object }; 8 | } 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-namespace 11 | namespace NodeJS { 12 | interface Process { 13 | browser?: boolean; 14 | } 15 | } 16 | } 17 | 18 | interface IWithESIProps { 19 | esi?: { 20 | attrs?: { 21 | [key: string]: string | null; 22 | }; 23 | }; 24 | } 25 | 26 | // Prevent bundlers to bundle server.js 27 | const safeRequireServer = () => { 28 | try { 29 | // Necessary for NextJS 30 | return eval("require('react-esi/lib/server')"); 31 | } catch (error) { 32 | // Necessary for Express and others 33 | return eval("require('./server')"); 34 | } 35 | }; 36 | 37 | const isClient = () => { 38 | return ( 39 | (typeof process !== "undefined" && process?.browser) || 40 | typeof window !== "undefined" 41 | ); 42 | }; 43 | 44 | const isServer = () => !isClient(); 45 | 46 | interface State { 47 | childProps: object; 48 | initialChildPropsLoaded: boolean; 49 | } 50 | /** 51 | * Higher Order Component generating a tag server-side, 52 | * and rendering the wrapped component client-side. 53 | */ 54 | export default function withESI

( 55 | WrappedComponent: ComponentType

, 56 | fragmentID: string 57 | ): ComponentClass { 58 | return class WithESI extends React.Component

{ 59 | public static WrappedComponent = WrappedComponent; 60 | public static displayName = `WithESI(${ 61 | WrappedComponent.displayName || WrappedComponent.name || "Component" 62 | })`; 63 | public static propTypes = { 64 | esi: PropTypes.shape({ 65 | attrs: PropTypes.objectOf(PropTypes.string), // extra attributes to add to the tag 66 | }), 67 | } as unknown as WeakValidationMap; 68 | public state: State = { 69 | childProps: {}, 70 | initialChildPropsLoaded: true, 71 | }; 72 | private esi = {}; 73 | 74 | constructor(props: P & IWithESIProps) { 75 | super(props); 76 | const { esi, ...childProps } = props; 77 | this.esi = esi || {}; 78 | this.state.childProps = childProps; 79 | 80 | if (isServer()) { 81 | return; 82 | } 83 | 84 | if (window.__REACT_ESI__?.[fragmentID]) { 85 | // Inject server-side computed initial props 86 | this.state.childProps = { 87 | ...window.__REACT_ESI__[fragmentID], 88 | ...this.state.childProps, 89 | }; 90 | return; 91 | } 92 | 93 | // TODO: add support for getServerSideProps 94 | if ("getInitialProps" in WrappedComponent) { 95 | // No server-side rendering for this component, getInitialProps will be called during componentDidMount 96 | this.state.initialChildPropsLoaded = false; 97 | } 98 | } 99 | 100 | public componentDidMount() { 101 | if (this.state.initialChildPropsLoaded) { 102 | return; 103 | } 104 | 105 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 106 | (WrappedComponent as any) 107 | .getInitialProps({ props: this.state.childProps }) 108 | .then((initialProps: object) => 109 | this.setState({ 110 | childProps: initialProps, 111 | initialChildPropsLoaded: true, 112 | }) 113 | ); 114 | } 115 | 116 | public render() { 117 | if (isClient()) { 118 | return ( 119 | 122 | ); 123 | } 124 | 125 | const server = safeRequireServer(); 126 | 127 | return server.createIncludeElement(fragmentID, this.props, this.esi); 128 | } 129 | }; 130 | } 131 | -------------------------------------------------------------------------------- /lib/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/__tests__"] 4 | } 5 | -------------------------------------------------------------------------------- /lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | /* Type Checking */ 5 | "strict": true, 6 | 7 | /* Modules */ 8 | "moduleResolution": "Node10", 9 | "module": "CommonJS", 10 | "rootDir": "./src/", 11 | 12 | /* Emit */ 13 | "declaration": true, 14 | "declarationMap": true, 15 | "sourceMap": true, 16 | "outDir": "./lib/", 17 | 18 | /* Interop Constraints */ 19 | "esModuleInterop": true, 20 | "isolatedModules": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "allowSyntheticDefaultImports": true, 23 | 24 | /* Language and Environment */ 25 | "jsx": "react-jsx", 26 | "lib": ["DOM", "ESNext"], 27 | "target": "ES6" 28 | }, 29 | "include": ["./src/"] 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.3.1", 3 | "private": true, 4 | "description": "React ESI: Blazing-fast Server-Side Rendering for React and Next.js", 5 | "repository": "https://github.com/dunglas/react-esi", 6 | "license": "MIT", 7 | "author": "Kévin Dunglas", 8 | "scripts": { 9 | "lint": "eslint .", 10 | "lint:fix": "eslint . --fix", 11 | "format": "prettier --write ." 12 | }, 13 | "packageManager": "pnpm@8.15.4", 14 | "engines": { 15 | "node": ">=20.11.0" 16 | }, 17 | "pnpm": { 18 | "overrides": { 19 | "@types/react": "18.2.64", 20 | "@types/react-dom": "18.2.21", 21 | "typescript": "5.3.3" 22 | } 23 | }, 24 | "devDependencies": { 25 | "@types/node": "^20.11.25", 26 | "eslint": "^8.57.0", 27 | "eslint-config-prettier": "^9.1.0", 28 | "eslint-plugin-react": "^7.34.0", 29 | "prettier": "^3.2.5", 30 | "typescript": "~5.3.3", 31 | "typescript-eslint": "^7.1.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "lib" 3 | - "examples/*" 4 | --------------------------------------------------------------------------------