packages/runtime/src/runServerApp.tsx (252 lines of code) (raw):

import * as React from 'react'; import type { Location } from 'history'; import type { OnAllReadyParams, OnShellReadyParams } from './server/streamRender.js'; import type { AppContext, ServerContext, RouteMatch, AppData, ServerAppRouterProps, RenderOptions, Response, } from './types.js'; import Runtime from './runtime.js'; import { AppContextProvider } from './AppContext.js'; import { getAppData } from './appData.js'; import getAppConfig from './appConfig.js'; import { DocumentContextProvider } from './Document.js'; import { loadRouteModules } from './routes.js'; import { pipeToString, renderToNodeStream } from './server/streamRender.js'; import getRequestContext from './requestContext.js'; import matchRoutes from './matchRoutes.js'; import getCurrentRoutePath from './utils/getCurrentRoutePath.js'; import ServerRouter from './ServerRouter.js'; import addLeadingSlash from './utils/addLeadingSlash.js'; import { renderDocument } from './renderDocument.js'; import { sendResponse, getLocation } from './server/response.js'; import getDocumentData from './server/getDocumentData.js'; /** * Render and return the result as html string. */ export async function renderToHTML( requestContext: ServerContext, renderOptions: RenderOptions, ): Promise<Response> { const result = await doRender(requestContext, renderOptions); const { value } = result; if (typeof value === 'string' || typeof value === 'undefined') { return result; } const { pipe, fallback } = value; try { const entryStr = await pipeToString(pipe); return { value: entryStr, headers: { 'Content-Type': 'text/html; charset=utf-8', }, statusCode: 200, }; } catch (error) { if (renderOptions.disableFallback) { throw error; } console.error('PipeToString error, downgrade to CSR.', error); // downgrade to CSR. const result = fallback(); return result; } } /** * Render and send the result to ServerResponse. */ export async function renderToResponse(requestContext: ServerContext, renderOptions: RenderOptions) { const { req, res } = requestContext; const result = await doRender(requestContext, renderOptions); const { value } = result; if (typeof value === 'string' || typeof value === 'undefined') { sendResponse(req, res, result); } else { const { pipe, fallback } = value; res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8'); const { streamOptions = {} } = renderOptions; const { onShellReady, onShellError, onError, onAllReady } = streamOptions; return new Promise<void>((resolve, reject) => { // Send stream result to ServerResponse. pipe(res, { onShellReady: (params: OnShellReadyParams) => { onShellReady && onShellReady(params); }, onShellError: async (err) => { onShellError && onShellError(err); if (renderOptions.disableFallback) { reject(err); return; } // downgrade to CSR. console.error('PipeToResponse onShellError, downgrade to CSR.'); console.error(err); const result = await fallback(); sendResponse(req, res, result); resolve(); }, onError: (err) => { onError && onError(err); // onError triggered after shell ready, should not downgrade to csr // and should not be throw to break the render process console.error('PipeToResponse error.'); console.error(err); }, onAllReady: (params: OnAllReadyParams) => { onAllReady && onAllReady(params); resolve(); }, }); }); } } function needRevalidate(matchedRoutes: RouteMatch[]) { return matchedRoutes.some(({ route }) => route.exports.includes('dataLoader') && route.exports.includes('staticDataLoader')); } async function doRender(serverContext: ServerContext, renderOptions: RenderOptions): Promise<Response> { const { req, res } = serverContext; const { app, basename, serverOnlyBasename, createRoutes, documentOnly, disableFallback, assetsManifest, runtimeModules, renderMode, runtimeOptions, serverData, } = renderOptions; const finalBasename = addLeadingSlash(serverOnlyBasename || basename); const location = getLocation(req.url); const requestContext = getRequestContext(location, serverContext); const appConfig = getAppConfig(app); const routes = createRoutes({ requestContext, renderMode, }); let appData: AppData; const appContext: AppContext = { appExport: app, routes, appConfig, appData, loaderData: {}, renderMode, assetsManifest, basename: finalBasename, matches: [], requestContext, serverData, }; const runtime = new Runtime(appContext, runtimeOptions); runtime.setAppRouter<ServerAppRouterProps>(ServerRouter); // Load static module before getAppData. if (runtimeModules.statics) { await Promise.all(runtimeModules.statics.map(m => runtime.loadModule(m)).filter(Boolean)); } const documentData = await getDocumentData({ loaderConfig: renderOptions.documentDataLoader, requestContext, documentOnly, }); // @TODO: document should have it's own context, not shared with app. appContext.documentData = documentData; // Not to execute [getAppData] when CSR. if (!documentOnly) { try { appData = await getAppData(app, requestContext); } catch (err) { console.error('Error: get app data error when SSR.', err); } } // HashRouter loads route modules by the CSR. if (appConfig?.router?.type === 'hash') { return renderDocument({ matches: [], routes, renderOptions, documentData }); } const matches = matchRoutes(routes, location, finalBasename); const routePath = getCurrentRoutePath(matches); if (documentOnly) { return renderDocument({ matches, routePath, routes, renderOptions, documentData }); } else if (!matches.length) { return handleNotFoundResponse(); } try { const routeModules = await loadRouteModules(matches.map(({ route: { id, lazy } }) => ({ id, lazy }))); const loaderData = {}; for (const routeId in routeModules) { const { loader } = routeModules[routeId]; if (loader) { const { data, pageConfig } = await loader(); loaderData[routeId] = { data, pageConfig, }; } } const revalidate = renderMode === 'SSG' && needRevalidate(matches); runtime.setAppContext({ ...appContext, revalidate, routeModules, loaderData, routePath, matches, appData }); if (runtimeModules.commons) { await Promise.all(runtimeModules.commons.map(m => runtime.loadModule(m)).filter(Boolean)); } /** Plugin may register response handlers, for example: ``` addResponseHandler((req) => { if (redirect) { return { statusCode: 302, statusText: 'Found', headers: { location: '/redirect', }, }; } }); ``` */ const responseHandlers = runtime.getResponseHandlers(); for (const responseHandler of responseHandlers) { if (typeof responseHandler === 'function') { const response = await responseHandler(req, res); if (response) { return response as Response; } } } return await renderServerEntry({ runtime, matches, location, renderOptions, }); } catch (err) { if (disableFallback) { throw err; } console.error('Warning: render server entry error, downgrade to csr.', err); return renderDocument({ matches, routePath, renderOptions, routes, downgrade: true, documentData }); } } // https://github.com/ice-lab/ice-next/issues/133 function handleNotFoundResponse(): Response { return { statusText: 'Not Found', statusCode: 404, }; } interface RenderServerEntry { runtime: Runtime; matches: RouteMatch[]; location: Location; renderOptions: RenderOptions; } /** * Render App by SSR. */ async function renderServerEntry( { runtime, matches, location, renderOptions, }: RenderServerEntry, ): Promise<Response> { const { Document } = renderOptions; const appContext = runtime.getAppContext(); const { routes, routePath, loaderData, basename } = appContext; const AppRuntimeProvider = runtime.composeAppProvider() || React.Fragment; const AppRouter = runtime.getAppRouter<ServerAppRouterProps>(); const routerContext: ServerAppRouterProps['routerContext'] = { // @ts-expect-error matches type should be use `AgnosticDataRouteMatch[]` matches, basename, loaderData, location, }; const documentContext = { main: ( <AppRouter routes={routes} routerContext={routerContext} /> ), }; const element = ( <AppContextProvider value={appContext}> <AppRuntimeProvider> <DocumentContextProvider value={documentContext}> { Document && <Document pagePath={routePath} /> } </DocumentContextProvider> </AppRuntimeProvider> </AppContextProvider> ); const pipe = renderToNodeStream(element, { renderOptions, routerContext, }); const fallback = () => { return renderDocument({ matches, routePath, renderOptions, routes, downgrade: true, documentData: appContext.documentData, }); }; return { value: { pipe, fallback, }, }; }