Un serveur unique - rocambille/start-express-react GitHub Wiki

Vite fournit un serveur de développement qui intègre des fonctionnalités avancées (par exemple, le HMR ou la gestion native de JSX). L'outil fonctionne parfaitement pour notre application React.

Mais lorsque vous souhaitez inclure une application Express, les choses peuvent devenir compliquées pour gérer 2 serveurs indépendants (le serveur de développement Vite et le serveur Express) et les faire communiquer ensemble.


Dans StartER, nous avons choisi d'implémenter un serveur unique en attachant un serveur de développement Vite au serveur de notre application Express.


En "bonus", ce serveur unique nous permet de proposer du SSR. Le SSR fait référence à l'exécution d'une application dans Node.js, son pré-rendu en HTML et enfin son hydratation sur le client.

La documentation de Vite fournit un guide sur le SSR. Cette page reprend les étapes implémentées dans StartER et les complète avec des éléments spécifiques à React Router.

Structure

Si nous isolons les éléments clés du SSR dans StartER, la structure des fichiers source est la suivante :

.
├── index.html
├── server.ts             # Serveur d'application principal
└── src
    ├── entry-client.tsx  # Monte l'application sur un élément DOM
    ├── entry-server.tsx  # Rend l'application en utilisant l'API SSR de Vite
    └── react
        └── routes.tsx    # Point d'entrée du code React, indépendant de l'environnement client/server

Le fichier index.html référence entry-client et inclut un marqueur où le balisage rendu par le serveur est injecté :

<div class="container" id="root"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client"></script>

Vous pouvez utiliser n'importe quel marqueur que vous préférez à la place de <!--ssr-outlet-->, à condition qu'il puisse être détecté sans ambiguité.

Configuration du serveur de développement

Dans notre application SSR, Vite est utilisé en mode middleware. Cela nous permet d'avoir un contrôle total sur notre serveur principal et de dissocier Vite de l'environnement de production.

import express from "express";
import { createServer as createViteServer } from "vite";

const app = express();

const isProduction = process.env.NODE_ENV === "production";

if (isProduction === false) {
  // Create Vite server in middleware mode and configure the app type as
  // 'custom', disabling Vite's own HTML serving logic so parent server
  // can take control
  const vite = await createViteServer({
    server: { middlewareMode: true },
    appType: "custom",
  });

  // Use vite's connect instance as middleware. If you use your own
  // express router (express.Router()), you should use router.use
  // When the server restarts (for example after the user modifies
  // vite.config.js), `vite.middlewares` is still going to be the same
  // reference (with a new internal stack of Vite and plugin-injected
  // middlewares). The following is valid even after restarts.
  app.use(vite.middlewares);

  app.use(/(.*)/, async (req, res, next) => {
    // serve index.html - we will tackle this next
  });
}

Ici, vite est une instance de ViteDevServer. vite.middlewares est une instance de Connect qui peut être utilisée comme middleware dans notre application Express.

L'étape suivante consiste à implémenter le gestionnaire * (/(.*)/ à partir d'Express 5) pour servir le code HTML généré par le serveur :

app.use(/(.*)/, async (req, res, next) => {
  const url = req.originalUrl;

  const indexHtml = fs.readFileSync("index.html", "utf-8");

  // 1. Apply Vite HTML transforms. This injects the Vite HMR client,
  //    and also applies HTML transforms from Vite plugins, e.g. global
  //    preambles from @vitejs/plugin-react
  const template = await vite.transformIndexHtml(url, indexHtml);

  // 2. Load the server entry. ssrLoadModule automatically transforms
  //    ESM source code to be usable in Node.js! There is no bundling
  //    required, and provides efficient invalidation similar to HMR.
  const { render } = await vite.ssrLoadModule("/src/entry-server");

  // 3. render the app HTML. This assumes entry-server.js's exported
  //     `render` function calls appropriate framework SSR APIs,
  //    e.g. ReactDOMServer.renderToString()
  await render(template, req, res);
});

Le rôle de la fonction render dans entry-server.tsx est de rendre l'HTML de l'application et de l'injecter dans le template à la place du marqueur <!--ssr-outlet-->.

import type { Request, Response } from "express";

import routes from "./react/routes";

export const render = async (template: string, req: Request, res: Response) => {
  const appHtml = // render using routes imported from ./react/routes

  const html = template.replace("<!--ssr-outlet-->", () => appHtml);

  res.status(200).set("Content-Type", "text/html; charset=utf-8").end(html);
};

C'est le moment où la documentation de React Router prend le relais.

Pour obtenir un rendu serveur compatible avec React Router, nous transformons nos routes en gestionnaire de requêtes avec createStaticHandler.

import { createStaticHandler } from "react-router";

import routes from "./react/routes";

const { query, dataRoutes } = createStaticHandler(routes);

L'étape suivante est d'obtenir le contexte de routage et d'effectuer le rendu.

import type { Request, Response } from "express";
import { renderToString } from "react-dom/server";
import {
  createStaticHandler,
  createStaticRouter,
  StaticRouterProvider,
} from "react-router";

import routes from "./react/routes";

const { query, dataRoutes } = createStaticHandler(routes);

export const render = async (template: string, req: Request, res: Response) => {
  // 1. Run actions/loaders to get the routing context with `query`
  const context = await query(
    new Request(`${req.protocol}://${req.get("host")}${req.originalUrl}`),
  );

  // If `query` returns a Response, send it raw
  if (context instanceof Response) {
    for (const [key, value] of context.headers.entries()) {
      res.set(key, value);
    }

    return res.status(context.status).end(context.body);
  }

  // Setup headers from action and loaders from deepest match
  const leaf = context.matches[context.matches.length - 1];
  const actionHeaders = context.actionHeaders[leaf.route.id];
  if (actionHeaders) {
    for (const [key, value] of actionHeaders.entries()) {
      res.set(key, value);
    }
  }
  const loaderHeaders = context.loaderHeaders[leaf.route.id];
  if (loaderHeaders) {
    for (const [key, value] of loaderHeaders.entries()) {
      res.set(key, value);
    }
  }

  // 2. Create a static router for SSR
  const router = createStaticRouter(dataRoutes, context);

  // 3. Render everything with StaticRouterProvider
  const appHtml = renderToString(
    <StrictMode>
      <StaticRouterProvider router={router} context={context} />
    </StrictMode>,
  );

  const html = template.replace("<!--ssr-outlet-->", () => appHtml);

  // 4. Send a response
  res.status(200).set("Content-Type", "text/html; charset=utf-8").end(html);
}

Un point à noter sur l'utilisation de renderToString : la méthode ne prend pas en charge les <Suspense> de React. Dans StartER, nous utilisons renderToPipeableStream, qui prend en charge <Suspense> sur le serveur.

import { Transform } from "node:stream";
import type { Request, Response } from "express";
import { renderToPipeableStream } from "react-dom/server";
import {
  StaticRouterProvider,
  createStaticHandler,
  createStaticRouter,
} from "react-router";

import routes from "./react/routes";

const { query, dataRoutes } = createStaticHandler(routes);

export const render = async (template: string, req: Request, res: Response) => {
  // 1. Run actions/loaders to get the routing context with `query`

  // ...

  // 2. Create a static router for SSR
  const router = createStaticRouter(dataRoutes, context);

  // 3. Render everything with StaticRouterProvider
  const { pipe } = renderToPipeableStream(
    <StaticRouterProvider router={router} context={context} />
  );

  // 4. Send a response
  res.status(200).set("Content-Type", "text/html; charset=utf-8");

  const [htmlStart, htmlEnd] = template.split("<!--ssr-outlet-->");

  res.write(htmlStart);

  const transformStream = new Transform({
    transform(chunk, encoding, callback) {
      res.write(chunk, encoding);
      callback();
    },
  });

  pipe(transformStream);

  transformStream.on("finish", () => {
    res.end(htmlEnd);
  });
};

Déployer en production

Pour déployer un projet SSR en production, nous devons :

  • Produire un build client comme d'habitude ;
  • Produire un build SSR, qui peut être chargée directement via import() afin d'éviter de passer par le module ssrLoadModule de Vite.

Les scripts de build dans package.json ressemble à ceci :

{
  "scripts": {
    "build:client": "vite build --outDir dist/client",
    "build:server": "vite build --outDir dist/server --ssr src/entry-server"
  }
}

Notez l'option --ssr qui indique qu'il s'agit d'un build SSR, suivie de l'entrée SSR src/entry-server.

Le reste de la magie se passe dans server.ts où nous implémentons une logique spécifique à la production en vérifiant process.env.NODE_ENV.

Voir les codes complets pour les détails :

⚠️ **GitHub.com Fallback** ⚠️