mirror of https://github.com/alibaba/ice.git
290 lines
7.0 KiB
TypeScript
290 lines
7.0 KiB
TypeScript
import type { ServerResponse } from 'http';
|
|
import * as React from 'react';
|
|
import * as ReactDOMServer from 'react-dom/server';
|
|
import { Action, parsePath } from 'history';
|
|
import type { Location } from 'history';
|
|
import { createSearchParams } from './utils/createSearchParams.js';
|
|
import Runtime from './runtime.js';
|
|
import App from './App.js';
|
|
import { AppContextProvider } from './AppContext.js';
|
|
import { AppDataProvider } from './AppData.js';
|
|
import { loadRouteModules, loadRoutesData, getRoutesConfig, matchRoutes } from './routes.js';
|
|
import { piperToString, renderToNodeStream } from './server/streamRender.js';
|
|
import { createStaticNavigator } from './server/navigator.js';
|
|
import type { NodeWritablePiper } from './server/streamRender.js';
|
|
import type {
|
|
AppContext, InitialContext, RouteItem, ServerContext,
|
|
AppConfig, RuntimePlugin, CommonJsRuntime, AssetsManifest,
|
|
ComponentWithChildren,
|
|
} from './types';
|
|
|
|
interface RenderOptions {
|
|
appConfig: AppConfig;
|
|
assetsManifest: AssetsManifest;
|
|
routes: RouteItem[];
|
|
runtimeModules: (RuntimePlugin | CommonJsRuntime)[];
|
|
Document: ComponentWithChildren<{}>;
|
|
documentOnly?: boolean;
|
|
}
|
|
|
|
interface Piper {
|
|
pipe: NodeWritablePiper;
|
|
fallback: Function;
|
|
}
|
|
interface RenderResult {
|
|
statusCode?: number;
|
|
value?: string | Piper;
|
|
}
|
|
|
|
/**
|
|
* Render and return the result as html string.
|
|
*/
|
|
export async function renderToHTML(requestContext: ServerContext, options: RenderOptions): Promise<RenderResult> {
|
|
const result = await doRender(requestContext, options);
|
|
|
|
const { value } = result;
|
|
|
|
if (typeof value === 'string') {
|
|
return result;
|
|
}
|
|
|
|
const { pipe, fallback } = value;
|
|
|
|
try {
|
|
const html = await piperToString(pipe);
|
|
|
|
return {
|
|
value: html,
|
|
statusCode: 200,
|
|
};
|
|
} catch (error) {
|
|
console.error('Warning: piperToString 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, options: RenderOptions) {
|
|
const { res } = requestContext;
|
|
const result = await doRender(requestContext, options);
|
|
|
|
const { value } = result;
|
|
|
|
if (typeof value === 'string') {
|
|
sendResult(res, result);
|
|
} else {
|
|
const { pipe, fallback } = value;
|
|
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
|
|
try {
|
|
await pipeToResponse(res, pipe);
|
|
} catch (error) {
|
|
console.error('Warning: piperToResponse error, downgrade to csr.', error);
|
|
// downgrade to csr.
|
|
const result = await fallback();
|
|
sendResult(res, result);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send string result to ServerResponse.
|
|
*/
|
|
async function sendResult(res: ServerResponse, result: RenderResult) {
|
|
res.statusCode = result.statusCode;
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.end(result.value);
|
|
}
|
|
|
|
/**
|
|
* Send stream result to ServerResponse.
|
|
*/
|
|
function pipeToResponse(res, pipe: NodeWritablePiper) {
|
|
return new Promise((resolve, reject) => {
|
|
pipe(res, (err) => (err ? reject(err) : resolve(null)));
|
|
});
|
|
}
|
|
|
|
async function doRender(requestContext: ServerContext, options: RenderOptions): Promise<RenderResult> {
|
|
const { req } = requestContext;
|
|
|
|
const {
|
|
routes,
|
|
documentOnly,
|
|
} = options;
|
|
|
|
const location = getLocation(req.url);
|
|
const matches = matchRoutes(routes, location);
|
|
|
|
if (!matches.length) {
|
|
return render404();
|
|
}
|
|
|
|
await loadRouteModules(matches.map(({ route: { id, load } }) => ({ id, load })));
|
|
|
|
if (documentOnly) {
|
|
return renderDocument(matches, options);
|
|
}
|
|
|
|
try {
|
|
return await renderServerEntry(requestContext, options, matches, location);
|
|
} catch (err) {
|
|
console.error('Warning: render server entry error, downgrade to csr.', err);
|
|
return renderDocument(matches, options);
|
|
}
|
|
}
|
|
|
|
// https://github.com/ice-lab/ice-next/issues/133
|
|
function render404(): RenderResult {
|
|
return {
|
|
value: 'Page is Not Found',
|
|
statusCode: 404,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Render App by SSR.
|
|
*/
|
|
export async function renderServerEntry(
|
|
requestContext: ServerContext, options: RenderOptions, matches, location,
|
|
): Promise<RenderResult> {
|
|
const { req } = requestContext;
|
|
|
|
const {
|
|
assetsManifest,
|
|
appConfig,
|
|
runtimeModules,
|
|
routes,
|
|
Document,
|
|
} = options;
|
|
|
|
const initialContext: InitialContext = {
|
|
...requestContext,
|
|
pathname: location.pathname,
|
|
query: Object.fromEntries(createSearchParams(location.search)),
|
|
path: req.url,
|
|
};
|
|
|
|
let appData;
|
|
if (appConfig.app?.getData) {
|
|
appData = await appConfig.app.getData(initialContext);
|
|
}
|
|
|
|
const routesData = await loadRoutesData(matches, initialContext);
|
|
const routesConfig = getRoutesConfig(matches, routesData);
|
|
|
|
const appContext: AppContext = {
|
|
appConfig,
|
|
assetsManifest,
|
|
appData,
|
|
routesData,
|
|
routesConfig,
|
|
matches,
|
|
routes,
|
|
};
|
|
|
|
const runtime = new Runtime(appContext);
|
|
runtimeModules.forEach(m => {
|
|
runtime.loadModule(m);
|
|
});
|
|
|
|
const staticNavigator = createStaticNavigator();
|
|
const AppProvider = runtime.composeAppProvider() || React.Fragment;
|
|
const RouteWrappers = runtime.getWrappers();
|
|
const AppRouter = runtime.getAppRouter();
|
|
|
|
const element = (
|
|
<AppContextProvider value={appContext}>
|
|
<AppDataProvider value={appData}>
|
|
<Document>
|
|
<App
|
|
action={Action.Pop}
|
|
location={location}
|
|
navigator={staticNavigator}
|
|
static
|
|
AppProvider={AppProvider}
|
|
RouteWrappers={RouteWrappers}
|
|
AppRouter={AppRouter}
|
|
/>
|
|
</Document>
|
|
</AppDataProvider>
|
|
</AppContextProvider>
|
|
);
|
|
|
|
const pipe = await renderToNodeStream(element, false);
|
|
|
|
const fallback = () => {
|
|
renderDocument(matches, options);
|
|
};
|
|
|
|
return {
|
|
value: {
|
|
pipe,
|
|
fallback,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Render Document for CSR.
|
|
*/
|
|
export function renderDocument(matches, options: RenderOptions): RenderResult {
|
|
const {
|
|
routes,
|
|
assetsManifest,
|
|
appConfig,
|
|
Document,
|
|
} = options;
|
|
|
|
// renderDocument needn't to load routesData and appData.
|
|
const appData = {};
|
|
const routesData = {};
|
|
const routesConfig = getRoutesConfig(matches, {});
|
|
|
|
const appContext: AppContext = {
|
|
assetsManifest,
|
|
appConfig,
|
|
appData,
|
|
routesData,
|
|
routesConfig,
|
|
matches,
|
|
routes,
|
|
documentOnly: true,
|
|
};
|
|
|
|
const html = ReactDOMServer.renderToString(
|
|
<AppContextProvider value={appContext}>
|
|
<AppDataProvider value={appData}>
|
|
<Document />
|
|
</AppDataProvider>
|
|
</AppContextProvider>,
|
|
);
|
|
|
|
return {
|
|
value: html,
|
|
statusCode: 200,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* ref: https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/server.tsx
|
|
*/
|
|
function getLocation(url: string) {
|
|
const locationProps = parsePath(url);
|
|
|
|
const location: Location = {
|
|
pathname: locationProps.pathname || '/',
|
|
search: locationProps.search || '',
|
|
hash: locationProps.hash || '',
|
|
state: null,
|
|
key: 'default',
|
|
};
|
|
|
|
return location;
|
|
} |