From a1e5ceece539f22a288a1d1ee802c30eef99d4b5 Mon Sep 17 00:00:00 2001 From: Peach Date: Thu, 28 Mar 2024 10:29:05 +0800 Subject: [PATCH 01/32] feat(preset-umi): unify request handler for ssr and always use stream (#12229) * refactor(preset): improve types for ssr request handler * refactor(preset-umi): provide unified request handler for ssr * refactor: add stream response header * refactor: correct ts lib usage * chore: update comment * refactor: warn for deprecated ssr exports * refactor: async-able for worker ssr request handler * refactor: update worker mode condition * refactor: type correct --- packages/preset-umi/templates/server.tpl | 6 + packages/server/src/ssr.ts | 216 +++++++++++++++++++---- 2 files changed, 184 insertions(+), 38 deletions(-) diff --git a/packages/preset-umi/templates/server.tpl b/packages/preset-umi/templates/server.tpl index 13be23d98838..e01021b9836a 100644 --- a/packages/preset-umi/templates/server.tpl +++ b/packages/preset-umi/templates/server.tpl @@ -53,7 +53,13 @@ const createOpts = { ServerInsertedHTMLContext, }; const requestHandler = createRequestHandler(createOpts); +/** + * @deprecated Please use `requestHandler` instead. + */ export const renderRoot = createUmiHandler(createOpts); +/** + * @deprecated Please use `requestHandler` instead. + */ export const serverLoader = createUmiServerLoader(createOpts); export const _markupGenerator = createMarkupGenerator(createOpts); diff --git a/packages/server/src/ssr.ts b/packages/server/src/ssr.ts index ba26f6f55ccb..d25f0c7ceb30 100644 --- a/packages/server/src/ssr.ts +++ b/packages/server/src/ssr.ts @@ -1,3 +1,5 @@ +/// +import type { RequestHandler } from '@umijs/bundler-utils/compiled/express'; import React, { ReactElement } from 'react'; import * as ReactDomServer from 'react-dom/server'; import { matchRoutes } from 'react-router-dom'; @@ -237,17 +239,156 @@ export function createMarkupGenerator(opts: CreateRequestHandlerOptions) { }; } +type IExpressRequestHandlerArgs = Parameters; +type IWorkerRequestHandlerArgs = [ + ev: FetchEvent, + opts?: { modifyResponse?: (res: Response) => Promise | Response }, +]; + export default function createRequestHandler( opts: CreateRequestHandlerOptions, ) { const jsxGeneratorDeferrer = createJSXGenerator(opts); + const normalizeHandlerArgs = ( + ...args: IExpressRequestHandlerArgs | IWorkerRequestHandlerArgs + ) => { + let ret: { + req: { + url: string; + pathname: string; + headers: HeadersInit; + query: { route?: string | null; url?: string | null }; + }; + sendServerLoader(data: any): Promise | void; + sendPage( + jsx: NonNullable>>, + ): Promise | void; + otherwise(): Promise | void; + }; - return async function (req: any, res: any, next: any) { - // 切换路由场景下,会通过此 API 执行 server loader - if (req.url.startsWith('/__serverLoader') && req.query.route) { - // 在浏览器中触发的__serverLoader请求的request应该和SSR时拿到的request一致,都是当前页面的URL - // 否则会导致serverLoader中的request.url和SSR时拿到的request.url不一致 - // 进而导致浏览器中触发的__serverLoader请求传入的参数和SSR时拿到的参数不一致,导致数据不一致 + if (typeof FetchEvent !== 'undefined' && args[0] instanceof FetchEvent) { + // worker mode + const [ev, opts] = args as IWorkerRequestHandlerArgs; + const { pathname, searchParams } = new URL(ev.request.url); + + ret = { + req: { + url: ev.request.url, + pathname, + headers: ev.request.headers, + query: { + route: searchParams.get('route'), + url: searchParams.get('url'), + }, + }, + async sendServerLoader(data) { + let res = new Response(JSON.stringify(data), { + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + status: 200, + }); + + // allow modify response + if (opts?.modifyResponse) { + res = await opts.modifyResponse(res); + } + + ev.respondWith(res); + }, + async sendPage(jsx) { + // handle route path request + const stream = await ReactDomServer.renderToReadableStream( + jsx.element, + { + bootstrapScripts: [jsx.manifest.assets['umi.js'] || '/umi.js'], + onError(x: any) { + console.error(x); + }, + }, + ); + let res = new Response(stream, { + headers: { + 'content-type': 'text/html; charset=utf-8', + }, + status: 200, + }); + + // allow modify response + if (opts?.modifyResponse) { + res = await opts.modifyResponse(res); + } + + ev.respondWith(res); + }, + otherwise() { + throw new Error('no page resource'); + }, + }; + } else { + // express mode + const [req, res, next] = args as IExpressRequestHandlerArgs; + + ret = { + req: { + url: `${req.protocol}://${req.get('host')}${req.originalUrl}`, + pathname: req.url, + headers: req.headers as HeadersInit, + query: { + route: req.query.route?.toString(), + url: req.query.url?.toString(), + }, + }, + sendServerLoader(data) { + res.status(200).json(data); + }, + async sendPage(jsx) { + const writable = new Writable(); + + res.type('html'); + + writable._write = (chunk, _encoding, cb) => { + res.write(chunk); + cb(); + }; + + writable.on('finish', async () => { + res.write(getGenerateStaticHTML()); + res.end(); + }); + + const stream = ReactDomServer.renderToPipeableStream(jsx.element, { + bootstrapScripts: [jsx.manifest.assets['umi.js'] || '/umi.js'], + onShellReady() { + stream.pipe(writable); + }, + onError(x: any) { + console.error(x); + }, + }); + }, + otherwise: next, + }; + } + + return ret; + }; + + return async function unifiedRequestHandler( + ...args: IExpressRequestHandlerArgs | IWorkerRequestHandlerArgs + ) { + let jsx; + const { req, sendServerLoader, sendPage, otherwise } = normalizeHandlerArgs( + ...args, + ); + + if ( + req.pathname.startsWith('/__serverLoader') && + req.query.route && + req.query.url + ) { + // handle server loader request when route change or csr fallback + // provide the same request as real SSR, so that the server loader can get the same data const serverLoaderRequest = new Request(req.query.url, { headers: req.headers, }); @@ -256,48 +397,38 @@ export default function createRequestHandler( routesWithServerLoader: opts.routesWithServerLoader, serverLoaderArgs: { request: serverLoaderRequest }, }); - res.status(200).json(data); - return; - } - - const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`; - const request = new Request(fullUrl, { - headers: req.headers, - }); - const jsx = await jsxGeneratorDeferrer(req.url, { request }); - - if (!jsx) return next(); - - const writable = new Writable(); - writable._write = (chunk, _encoding, next) => { - res.write(chunk); - next(); - }; - - writable.on('finish', async () => { - res.write(await getGenerateStaticHTML()); - res.end(); - }); - - const stream = await ReactDomServer.renderToPipeableStream(jsx.element, { - bootstrapScripts: [jsx.manifest.assets['umi.js'] || '/umi.js'], - onShellReady() { - stream.pipe(writable); - }, - onError(x: any) { - console.error(x); - }, - }); + await sendServerLoader(data); + } else if ( + (jsx = await jsxGeneratorDeferrer(req.pathname, { + request: new Request(req.url, { + headers: req.headers, + }), + })) + ) { + // response route page + await sendPage(jsx); + } else { + await otherwise(); + } }; } // 新增的给CDN worker用的SSR请求handle export function createUmiHandler(opts: CreateRequestHandlerOptions) { + let isWarned = false; + return async function ( req: UmiRequest, params?: CreateRequestHandlerOptions, ) { + if (!isWarned) { + console.warn( + '[umi] `renderRoot` is deprecated, please use `requestHandler` instead', + ); + isWarned = true; + } + const jsxGeneratorDeferrer = createJSXGenerator({ ...opts, ...params, @@ -319,7 +450,16 @@ export function createUmiHandler(opts: CreateRequestHandlerOptions) { } export function createUmiServerLoader(opts: CreateRequestHandlerOptions) { + let isWarned = false; + return async function (req: UmiRequest) { + if (!isWarned) { + console.warn( + '[umi] `serverLoader` is deprecated, please use `requestHandler` instead', + ); + isWarned = true; + } + const query = Object.fromEntries(new URL(req.url).searchParams); // 切换路由场景下,会通过此 API 执行 server loader const serverLoaderRequest = new Request(query.url, { From ae9dc254306b6cedec336f6a0b8faf5a44bae026 Mon Sep 17 00:00:00 2001 From: MadCcc <1075746765@qq.com> Date: Mon, 1 Apr 2024 14:24:08 +0800 Subject: [PATCH 02/32] feat: SSR support useServerInsertedHTML (#12247) * feat: SSR support useServerInsertedHTML * feat: ssr insert html * fix: string template * chore: update lock --- examples/ssr-demo/package.json | 2 + examples/ssr-demo/src/layouts/index.tsx | 23 ++++ examples/ssr-demo/src/pages/index.tsx | 14 ++- .../routePreloadOnLoad/routePreloadOnLoad.ts | 2 +- packages/server/src/ssr.ts | 106 +++++++++++++----- pnpm-lock.yaml | 38 ++++--- 6 files changed, 137 insertions(+), 48 deletions(-) create mode 100644 examples/ssr-demo/src/layouts/index.tsx diff --git a/examples/ssr-demo/package.json b/examples/ssr-demo/package.json index 564ea9a69734..3b81a184639b 100644 --- a/examples/ssr-demo/package.json +++ b/examples/ssr-demo/package.json @@ -9,6 +9,8 @@ "start:prod": "node ./production-server.js" }, "dependencies": { + "@ant-design/cssinjs": "^1.18.5", + "antd": "^5", "express": "4.18.2", "umi": "workspace:*" } diff --git a/examples/ssr-demo/src/layouts/index.tsx b/examples/ssr-demo/src/layouts/index.tsx new file mode 100644 index 000000000000..06c6f7ea086c --- /dev/null +++ b/examples/ssr-demo/src/layouts/index.tsx @@ -0,0 +1,23 @@ +import { createCache, extractStyle, StyleProvider } from '@ant-design/cssinjs'; +import { useState } from 'react'; +import { Outlet, useServerInsertedHTML } from 'umi'; + +export default function Layout() { + const [cssCache] = useState(() => createCache()); + + useServerInsertedHTML(() => { + const style = extractStyle(cssCache, { plain: true }); + return ( + + ); + }); + + return ( + + + + ); +} diff --git a/examples/ssr-demo/src/pages/index.tsx b/examples/ssr-demo/src/pages/index.tsx index 279b00b9d5c1..c12ea05b1ed6 100644 --- a/examples/ssr-demo/src/pages/index.tsx +++ b/examples/ssr-demo/src/pages/index.tsx @@ -1,3 +1,5 @@ +import { Input } from 'antd'; +import { useId } from 'react'; import { Link, MetadataLoader, @@ -22,16 +24,26 @@ export default function HomePage() { const serverLoaderData = useServerLoaderData(); useServerInsertedHTML(() => { - return
inserted html
; + return ( + + ); }); + const id = useId(); + return (

Hello~

+

id: {id}

This is index.tsx

I should be pink

I should be cyan