Feat: unnecessary to compile routes config for server render (#6856)

* fix: unnecessary to compile routes config for server render

* chore: format ejs template

* feat: support build fallback entry

* chore: add test case for fallback entry

* chore: update lock

* chore: changeset

* chore: lint

* chore: lint

* chore: remove console log

* fix: merge conflict

* fix: undefined assign
This commit is contained in:
ClarkXia 2024-06-17 16:24:57 +08:00 committed by GitHub
parent d073ee5ade
commit 15c8200f60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 429 additions and 504 deletions

View File

@ -0,0 +1,7 @@
---
'@ice/shared-config': patch
'@ice/runtime': patch
'@ice/app': patch
---
feat: support build additional server entry for fallback

View File

@ -29,4 +29,4 @@
},
"include": ["src", ".ice", "ice.config.*"],
"exclude": ["build", "public"]
}
}

View File

@ -1,18 +0,0 @@
import { defineConfig } from '@ice/app';
export default defineConfig({
plugins: [],
server: {
onDemand: true,
format: 'esm',
},
output: {
distType: 'javascript'
},
sourceMap: true,
routes: {
defineRoutes: (route) => {
route('/custom', 'Custom/index.tsx');
},
},
});

View File

@ -1,42 +0,0 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { Meta, Title, Links, Main, Scripts } from 'ice';
import fse from 'fs-extra';
let dirname;
if (typeof __dirname === 'string') {
dirname = __dirname;
} else {
dirname = path.dirname(fileURLToPath(import.meta.url));
}
function Document() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="description" content="ICE Demo" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Title />
<Links />
</head>
<body>
<Main />
<Scripts ScriptElement={(props) => {
if (props.src && !props.src.startsWith('http')) {
const filePath = path.join(dirname, `..${props.src}`);
const sourceMapFilePath = path.join(dirname, `..${props.src}.map`);
const res = fse.readFileSync(filePath, 'utf-8');
return <script data-sourcemap={sourceMapFilePath} dangerouslySetInnerHTML={{ __html: res }} {...props} />;
} else {
return <script {...props} />;
}
}}
/>
</body>
</html>
);
}
export default Document;

View File

@ -1,19 +0,0 @@
import { Link, useData } from 'ice';
export default function Custom() {
const data = useData();
return (
<>
<h2>Custom Page</h2>
<Link to="/home">home</Link>
{data}
</>
);
}
export function pageConfig() {
return {
title: 'Custom',
};
}

View File

@ -1,30 +0,0 @@
import { Link } from 'ice';
export default function About() {
return (
<>
<h2>About Page</h2>
<Link to="/">home</Link>
<span className="mark">new</span>
</>
);
}
export function pageConfig() {
return {
title: 'About',
meta: [
{
name: 'theme-color',
content: '#eee',
},
],
links: [{
href: 'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css',
rel: 'stylesheet',
}],
scripts: [{
src: 'https://cdn.jsdelivr.net/npm/lodash@2.4.1/dist/lodash.min.js',
}],
};
}

View File

@ -1,21 +0,0 @@
import { Link, useData, useConfig } from 'ice';
export default function Blog() {
const data = useData();
const config = useConfig();
console.log('render Blog', 'data', data, 'config', config);
return (
<>
<h2>Blog Page</h2>
<Link to="/home">home</Link>
</>
);
}
export function pageConfig() {
return {
title: 'Blog',
};
}

View File

@ -1,20 +0,0 @@
import { definePageConfig } from 'ice';
export default function Home() {
return (
<>
<h2>Home Page</h2>
</>
);
}
export const pageConfig = definePageConfig(() => {
return {
queryParamsPassKeys: [
'questionId',
'source',
'disableNav',
],
title: 'Home',
};
});

View File

@ -1,25 +0,0 @@
.title {
color: red;
margin-left: 10rpx;
}
.data {
margin-top: 10px;
}
.homeContainer {
align-items: center;
margin-top: 200rpx;
}
.homeTitle {
font-size: 45rpx;
font-weight: bold;
margin: 20rpx 0;
}
.homeInfo {
font-size: 36rpx;
margin: 8rpx 0;
color: #555;
}

View File

@ -1,22 +0,0 @@
import { Outlet } from 'ice';
export default () => {
return (
<div>
<h1>ICE 3.0 Layout</h1>
<Outlet />
</div>
);
};
export function pageConfig() {
return {
title: 'Layout',
meta: [
{
name: 'layout-color',
content: '#f00',
},
],
};
}

View File

@ -0,0 +1,10 @@
import { defineConfig } from '@ice/app';
import plugin from './plugin';
export default defineConfig(() => ({
plugins: [plugin()],
ssr: true,
server: {
format: 'cjs',
}
}));

View File

@ -1,7 +1,7 @@
{
"name": "@examples/with-entry-type",
"private": true,
"name": "@examples/with-fallback-entry",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "ice start",
"build": "ice build"
@ -12,11 +12,10 @@
"dependencies": {
"@ice/app": "workspace:*",
"@ice/runtime": "workspace:*",
"react": "^18.0.0",
"react-dom": "^18.0.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"fs-extra": "^10.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.2",
"webpack": "^5.88.0"

View File

@ -0,0 +1,12 @@
export default function createPlugin() {
return {
name: 'custom-plugin',
setup({ onGetConfig }) {
onGetConfig((config) => {
config.server = {
fallbackEntry: true,
};
});
},
};
}

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,22 @@
import { Meta, Title, Links, Main, Scripts } from 'ice';
function Document() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="description" content="ICE Demo" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Title />
<Links />
</head>
<body>
<Main />
<Scripts />
</body>
</html>
);
}
export default Document;

View File

@ -0,0 +1,3 @@
body {
font-size: 14px;
}

View File

@ -0,0 +1,3 @@
export default function Home() {
return <h1>home</h1>;
}

View File

@ -1,5 +1,7 @@
import path from 'path';
import type { CommandName } from 'build-scripts';
import ServerRunnerPlugin from '../../webpack/ServerRunnerPlugin.js';
import { IMPORT_META_RENDERER, IMPORT_META_TARGET, WEB } from '../../constant.js';
import { IMPORT_META_RENDERER, IMPORT_META_TARGET, WEB, FALLBACK_ENTRY, RUNTIME_TMP_DIR } from '../../constant.js';
import getServerCompilerPlugin from '../../utils/getServerCompilerPlugin.js';
import ReCompilePlugin from '../../webpack/ReCompilePlugin.js';
import getEntryPoints from '../../utils/getEntryPoints.js';
@ -20,6 +22,18 @@ export const getSpinnerPlugin = (spinner, name?: string) => {
};
};
export const getFallbackEntry = (options: {
rootDir: string;
command: CommandName;
fallbackEntry: boolean;
}): string => {
const { command, fallbackEntry, rootDir } = options;
if (command === 'build' && fallbackEntry) {
return path.join(rootDir, RUNTIME_TMP_DIR, FALLBACK_ENTRY);
}
return '';
};
interface ServerPluginOptions {
serverRunner?: ServerRunner;
serverCompiler?: ServerCompiler;
@ -30,6 +44,7 @@ interface ServerPluginOptions {
serverEntry?: string;
ensureRoutesConfig: () => Promise<void>;
userConfig?: UserConfig;
fallbackEntry?: string;
getFlattenRoutes?: () => string[];
command?: string;
}
@ -43,6 +58,7 @@ export const getServerPlugin = ({
outputDir,
serverCompileTask,
userConfig,
fallbackEntry,
getFlattenRoutes,
command,
}: ServerPluginOptions) => {
@ -53,6 +69,7 @@ export const getServerPlugin = ({
return getServerCompilerPlugin(serverCompiler, {
rootDir,
serverEntry,
fallbackEntry,
outputDir,
serverCompileTask,
userConfig,

View File

@ -11,7 +11,7 @@ import {
CSS_MODULES_LOCAL_IDENT_NAME,
CSS_MODULES_LOCAL_IDENT_NAME_DEV,
} from '../../constant.js';
import { getReCompilePlugin, getServerPlugin, getSpinnerPlugin } from '../config/plugins.js';
import { getFallbackEntry, getReCompilePlugin, getServerPlugin, getSpinnerPlugin } from '../config/plugins.js';
import { getExpandedEnvs } from '../../utils/runtimeEnv.js';
import type { BundlerOptions, Context } from '../types.js';
import type { PluginData } from '../../types/plugin.js';
@ -34,8 +34,8 @@ const getConfig: GetConfig = async (context, options, rspack) => {
} = options;
const {
rootDir,
userConfig,
command,
userConfig,
extendsPluginAPI: {
serverCompileTask,
getRoutesFile,
@ -50,6 +50,11 @@ const getConfig: GetConfig = async (context, options, rspack) => {
getSpinnerPlugin(spinner),
// Add Server runner plugin.
getServerPlugin({
fallbackEntry: getFallbackEntry({
rootDir,
command,
fallbackEntry: server?.fallbackEntry,
}),
serverRunner,
ensureRoutesConfig,
serverCompiler,

View File

@ -7,7 +7,7 @@ import { getRouteExportConfig } from '../../service/config.js';
import { getFileHash } from '../../utils/hash.js';
import DataLoaderPlugin from '../../webpack/DataLoaderPlugin.js';
import { IMPORT_META_RENDERER, IMPORT_META_TARGET, RUNTIME_TMP_DIR, WEB } from '../../constant.js';
import { getReCompilePlugin, getServerPlugin, getSpinnerPlugin } from '../config/plugins.js';
import { getFallbackEntry, getReCompilePlugin, getServerPlugin, getSpinnerPlugin } from '../config/plugins.js';
import type RouteManifest from '../../utils/routeManifest.js';
import type ServerRunnerPlugin from '../../webpack/ServerRunnerPlugin.js';
import type ServerCompilerPlugin from '../../webpack/ServerCompilerPlugin.js';
@ -93,6 +93,11 @@ const getWebpackConfig: GetWebpackConfig = async (context, options) => {
serverCompiler,
target,
rootDir,
fallbackEntry: getFallbackEntry({
rootDir,
command,
fallbackEntry: server?.fallbackEntry,
}),
serverEntry: server?.entry,
outputDir,
serverCompileTask,

View File

@ -5,6 +5,7 @@ export const DEFAULT_HOST = '0.0.0.0';
export const RUNTIME_TMP_DIR = '.ice';
export const SERVER_ENTRY = path.join(RUNTIME_TMP_DIR, 'entry.server.ts');
export const FALLBACK_ENTRY = 'entry.document.ts';
export const DATA_LOADER_ENTRY = path.join(RUNTIME_TMP_DIR, 'data-loader.ts');
export const SERVER_OUTPUT_DIR = 'server';
export const IMPORT_META_TARGET = 'import.meta.target';

View File

@ -4,7 +4,7 @@ import * as path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import { Context } from 'build-scripts';
import type { CommandArgs, CommandName } from 'build-scripts';
import type { CommandArgs, CommandName, TaskConfig } from 'build-scripts';
import type { Config } from '@ice/shared-config/types';
import type { AppConfig } from '@ice/runtime/types';
import webpack from '@ice/bundles/compiled/webpack/index.js';
@ -23,7 +23,7 @@ import { setEnv, updateRuntimeEnv, getCoreEnvKeys } from './utils/runtimeEnv.js'
import getRuntimeModules from './utils/getRuntimeModules.js';
import { generateRoutesInfo, getRoutesDefinition } from './routes.js';
import * as config from './config.js';
import { RUNTIME_TMP_DIR, WEB, RUNTIME_EXPORTS, SERVER_ENTRY } from './constant.js';
import { RUNTIME_TMP_DIR, WEB, RUNTIME_EXPORTS, SERVER_ENTRY, FALLBACK_ENTRY } from './constant.js';
import createSpinner from './utils/createSpinner.js';
import ServerCompileTask from './utils/ServerCompileTask.js';
import { getAppExportConfig, getRouteExportConfig } from './service/config.js';
@ -215,7 +215,7 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt
ctx.registerConfig(configType, configData);
});
let taskConfigs = await ctx.setup();
let taskConfigs: TaskConfig<Config>[] = await ctx.setup();
// get userConfig after setup because of userConfig maybe modified by plugins
const { userConfig } = ctx;
@ -296,6 +296,11 @@ async function createService({ rootDir, command, commandArgs }: CreateServiceOpt
},
);
if (platformTaskConfig.config.server?.fallbackEntry) {
// Add fallback entry for server side rendering.
generator.addRenderFile('core/entry.server.ts.ejs', FALLBACK_ENTRY, { hydrate: false });
}
if (typeof userConfig.dataLoader === 'object' && userConfig.dataLoader.fetcher) {
const {
packageName,

View File

@ -10,6 +10,7 @@ interface Options {
userConfig: UserConfig;
outputDir: string;
serverEntry: string;
fallbackEntry?: string;
serverCompileTask: ExtendsPluginAPI['serverCompileTask'];
ensureRoutesConfig: () => Promise<void>;
runtimeDefineVars: Record<string, string>;
@ -25,16 +26,24 @@ function getServerCompilerPlugin(serverCompiler: ServerCompiler, options: Option
serverCompileTask,
ensureRoutesConfig,
runtimeDefineVars,
fallbackEntry,
entryPoints,
} = options;
const { ssg, ssr, server: { format } } = userConfig;
const isEsm = userConfig?.server?.format === 'esm';
const defaultEntryPoints = { index: getServerEntry(rootDir, serverEntry) };
if (fallbackEntry) {
if (entryPoints) {
entryPoints['index.fallback'] = fallbackEntry;
}
defaultEntryPoints['index.fallback'] = fallbackEntry;
}
return new ServerCompilerPlugin(
serverCompiler,
[
{
entryPoints: entryPoints || { index: getServerEntry(rootDir, serverEntry) },
entryPoints: entryPoints || defaultEntryPoints,
outdir: path.join(outputDir, SERVER_OUTPUT_DIR),
splitting: isEsm,
format,

View File

@ -36,7 +36,6 @@ export default class ServerCompilerPlugin {
public compileTask = async (compilation?: Compilation) => {
const [buildOptions] = this.serverCompilerOptions;
if (!this.isCompiling) {
await this.ensureRoutesConfig();
if (compilation) {
// Option of compilationInfo need to be object, while it may changed during multi-time compilation.
this.compilerOptions.compilationInfo.assetsManifest =

View File

@ -19,7 +19,6 @@ export default class ServerRunnerPlugin {
public compileTask = async (compilation?: Compilation) => {
if (!this.isCompiling) {
await this.ensureRoutesConfig();
if (compilation) {
// Option of compilationInfo need to be object, while it may changed during multi-time compilation.
this.serverRunner.addCompileData({

View File

@ -1,6 +1,10 @@
import './env.server';
<% if (hydrate) {-%>
import { getAppConfig, renderToHTML as renderAppToHTML, renderToResponse as renderAppToResponse } from '@ice/runtime/server';
<% } else { -%>
import { getAppConfig, getDocumentResponse as renderAppToHTML, renderDocumentToResponse as renderAppToResponse } from '@ice/runtime/server';
<% }-%>
<%- entryServer.imports %>
import * as runtime from '@ice/runtime/server';
<% if (hydrate) {-%>
import { commons, statics } from './runtime-modules';
<% }-%>
@ -19,7 +23,6 @@ import createRoutes from '<%- routesFile %>';
<% } else { -%>
import routesManifest from './route-manifest.json';
<% } -%>
import routesConfig from './routes-config.bundle.mjs';
<% if (dataLoaderImport.imports) {-%><%-dataLoaderImport.imports%><% } -%>
<% if (hydrate) {-%><%- runtimeOptions.imports %><% } -%>
@ -32,10 +35,11 @@ const createRoutes = () => routesManifest;
const runtimeModules = { commons, statics };
const getRouterBasename = () => {
const appConfig = runtime.getAppConfig(app);
const appConfig = getAppConfig(app);
return appConfig?.router?.basename ?? <%- basename %> ?? '';
}
<% if (hydrate) {-%>
const setRuntimeEnv = (renderMode) => {
if (renderMode === 'SSG') {
process.env.ICE_CORE_SSG = 'true';
@ -43,6 +47,7 @@ const setRuntimeEnv = (renderMode) => {
process.env.ICE_CORE_SSR = 'true';
}
}
<% } -%>
interface RenderOptions {
documentOnly?: boolean;
@ -57,19 +62,21 @@ interface RenderOptions {
}
export async function renderToHTML(requestContext, options: RenderOptions = {}) {
<% if (hydrate) {-%>
const { renderMode = 'SSR' } = options;
setRuntimeEnv(renderMode);
<% }-%>
const mergedOptions = mergeOptions(options);
return await runtime.renderToHTML(requestContext, mergedOptions);
return await renderAppToHTML(requestContext, mergedOptions);
}
export async function renderToResponse(requestContext, options: RenderOptions = {}) {
<% if (hydrate) {-%>
const { renderMode = 'SSR' } = options;
setRuntimeEnv(renderMode);
<% }-%>
const mergedOptions = mergeOptions(options);
return runtime.renderToResponse(requestContext, mergedOptions);
return renderAppToResponse(requestContext, mergedOptions);
}
function mergeOptions(options) {
@ -89,16 +96,13 @@ function mergeOptions(options) {
Document: Document.default,
basename: basename || getRouterBasename(),
renderMode,
routesConfig,
<% if (hydrate) {-%>
runtimeOptions: {
<% if (runtimeOptions.exports) { -%>
<% if (hydrate) {-%>runtimeOptions: {
<% if (runtimeOptions.exports) { -%>
<%- runtimeOptions.exports %>
<% } -%>
<% if (locals.customRuntimeOptions) { _%>
...<%- JSON.stringify(customRuntimeOptions) %>,
<% } _%>
<% } -%>
<% if (locals.customRuntimeOptions) { -%>
...<%- JSON.stringify(customRuntimeOptions) %>,
<% } -%>
},
<% } -%>
};
<% } -%>};
}

1
packages/runtime/server.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export * from './esm/index.server';

View File

@ -10,7 +10,7 @@ function useAppContext() {
return value;
}
function useAppData() {
function useAppData<T = any>(): T {
const value = React.useContext(Context);
return value.appData;
}

View File

@ -1,2 +1,3 @@
export { renderToResponse, renderToHTML } from './runServerApp.js';
export { renderToResponse as renderDocumentToResponse, getDocumentResponse } from './renderDocument.js';
export * from './index.js';

View File

@ -0,0 +1,140 @@
import * as React from 'react';
import * as ReactDOMServer from 'react-dom/server';
import getAppConfig from './appConfig.js';
import { AppContextProvider } from './AppContext.js';
import { DocumentContextProvider } from './Document.js';
import addLeadingSlash from './utils/addLeadingSlash.js';
import getRequestContext from './requestContext.js';
import matchRoutes from './matchRoutes.js';
import getDocumentData from './server/getDocumentData.js';
import getCurrentRoutePath from './utils/getCurrentRoutePath.js';
import { sendResponse, getLocation } from './server/response.js';
import type {
AppContext,
RouteItem,
RouteMatch,
RenderOptions,
Response,
ServerContext,
} from './types.js';
interface RenderDocumentOptions {
matches: RouteMatch[];
renderOptions: RenderOptions;
routes: RouteItem[];
documentData: any;
routePath?: string;
downgrade?: boolean;
}
export function renderDocument(options: RenderDocumentOptions): Response {
const {
matches,
renderOptions,
routePath = '',
downgrade,
routes,
documentData,
}: RenderDocumentOptions = options;
const {
assetsManifest,
app,
Document,
basename,
routesConfig = {},
serverData,
} = renderOptions;
const appData = null;
const appConfig = getAppConfig(app);
const loaderData = {};
matches.forEach(async (match) => {
const { id } = match.route;
const pageConfig = routesConfig[id];
loaderData[id] = {
pageConfig: pageConfig ? pageConfig({}) : {},
};
});
const appContext: AppContext = {
assetsManifest,
appConfig,
appData,
loaderData,
matches,
routes,
documentOnly: true,
renderMode: 'CSR',
routePath,
basename,
downgrade,
serverData,
documentData,
};
const documentContext = {
main: null,
};
const htmlStr = ReactDOMServer.renderToString(
<AppContextProvider value={appContext}>
<DocumentContextProvider value={documentContext}>
{
Document && <Document pagePath={routePath} />
}
</DocumentContextProvider>
</AppContextProvider>,
);
return {
value: `<!DOCTYPE html>${htmlStr}`,
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
statusCode: 200,
};
}
export async function getDocumentResponse(
serverContext: ServerContext,
renderOptions: RenderOptions,
): Promise<Response> {
const { req } = serverContext;
const {
app,
basename,
serverOnlyBasename,
createRoutes,
documentOnly,
renderMode,
} = 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,
});
const documentData = await getDocumentData({
loaderConfig: renderOptions.documentDataLoader,
requestContext,
documentOnly,
});
const matches = appConfig?.router?.type === 'hash' ? [] : matchRoutes(routes, location, finalBasename);
const routePath = getCurrentRoutePath(matches);
return renderDocument({ matches, routePath, routes, renderOptions, documentData });
}
export async function renderToResponse(
requestContext: ServerContext,
renderOptions: RenderOptions,
) {
const { req, res } = requestContext;
const documentResoponse = await getDocumentResponse(requestContext, renderOptions);
sendResponse(req, res, documentResoponse);
}

View File

@ -1,22 +1,14 @@
import type { ServerResponse, IncomingMessage } from 'http';
import * as React from 'react';
import * as ReactDOMServer from 'react-dom/server';
import type { Location } from 'history';
import { parsePath } from 'history';
import { isFunction } from '@ice/shared';
import type { RenderToPipeableStreamOptions, OnAllReadyParams, NodeWritablePiper } from './server/streamRender.js';
import type { OnAllReadyParams } from './server/streamRender.js';
import type {
AppContext, RouteItem, ServerContext,
AppExport,
AssetsManifest,
AppContext,
ServerContext,
RouteMatch,
PageConfig,
RenderMode,
DocumentComponent,
RuntimeModules,
AppData,
ServerAppRouterProps,
DocumentDataLoaderConfig,
RenderOptions,
Response,
} from './types.js';
import Runtime from './runtime.js';
import { AppContextProvider } from './AppContext.js';
@ -24,48 +16,15 @@ import { getAppData } from './appData.js';
import getAppConfig from './appConfig.js';
import { DocumentContextProvider } from './Document.js';
import { loadRouteModules } from './routes.js';
import type { RouteLoaderOptions } 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';
export interface RenderOptions {
app: AppExport;
assetsManifest: AssetsManifest;
createRoutes: (options: Pick<RouteLoaderOptions, 'requestContext' | 'renderMode'>) => RouteItem[];
runtimeModules: RuntimeModules;
documentDataLoader?: DocumentDataLoaderConfig;
Document?: DocumentComponent;
documentOnly?: boolean;
preRender?: boolean;
renderMode?: RenderMode;
// basename is used both for server and client, once set, it will be sync to client.
basename?: string;
// serverOnlyBasename is used when just want to change basename for server.
serverOnlyBasename?: string;
routePath?: string;
disableFallback?: boolean;
routesConfig: {
[key: string]: PageConfig;
};
runtimeOptions?: Record<string, any>;
serverData?: any;
streamOptions?: RenderToPipeableStreamOptions;
}
interface Piper {
pipe: NodeWritablePiper;
fallback: Function;
}
interface Response {
statusCode?: number;
statusText?: string;
value?: string | Piper;
headers?: Record<string, string>;
}
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.
@ -161,23 +120,6 @@ export async function renderToResponse(requestContext: ServerContext, renderOpti
}
}
async function sendResponse(
req: IncomingMessage,
res: ServerResponse,
response: Response,
) {
res.statusCode = response.statusCode;
res.statusMessage = response.statusText;
Object.entries(response.headers || {}).forEach(([name, value]) => {
res.setHeader(name, value);
});
if (response.value && req.method !== 'HEAD') {
res.end(response.value);
} else {
res.end();
}
}
function needRevalidate(matchedRoutes: RouteMatch[]) {
return matchedRoutes.some(({ route }) => route.exports.includes('dataLoader') && route.exports.includes('staticDataLoader'));
}
@ -227,18 +169,13 @@ async function doRender(serverContext: ServerContext, renderOptions: RenderOptio
await Promise.all(runtimeModules.statics.map(m => runtime.loadModule(m)).filter(Boolean));
}
// Execute document dataLoader.
let documentData: any;
if (renderOptions.documentDataLoader) {
const { loader } = renderOptions.documentDataLoader;
if (isFunction(loader)) {
documentData = await loader(requestContext, { documentOnly });
// @TODO: document should have it's own context, not shared with app.
appContext.documentData = documentData;
} else {
console.warn('Document dataLoader only accepts function.');
}
}
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) {
@ -400,105 +337,3 @@ async function renderServerEntry(
},
};
}
interface RenderDocumentOptions {
matches: RouteMatch[];
renderOptions: RenderOptions;
routes: RouteItem[];
documentData: any;
routePath?: string;
downgrade?: boolean;
}
/**
* Render Document for CSR.
*/
function renderDocument(options: RenderDocumentOptions): Response {
const {
matches,
renderOptions,
routePath = '',
downgrade,
routes,
documentData,
}: RenderDocumentOptions = options;
const {
assetsManifest,
app,
Document,
basename,
routesConfig = {},
serverData,
} = renderOptions;
const appData = null;
const appConfig = getAppConfig(app);
const loaderData = {};
matches.forEach(async (match) => {
const { id } = match.route;
const pageConfig = routesConfig[id];
loaderData[id] = {
pageConfig: pageConfig ? pageConfig({}) : {},
};
});
const appContext: AppContext = {
assetsManifest,
appConfig,
appData,
loaderData,
matches,
routes,
documentOnly: true,
renderMode: 'CSR',
routePath,
basename,
downgrade,
serverData,
documentData,
};
const documentContext = {
main: null,
};
const htmlStr = ReactDOMServer.renderToString(
<AppContextProvider value={appContext}>
<DocumentContextProvider value={documentContext}>
{
Document && <Document pagePath={routePath} />
}
</DocumentContextProvider>
</AppContextProvider>,
);
return {
value: `<!DOCTYPE html>${htmlStr}`,
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
statusCode: 200,
};
}
/**
* ref: https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/server.tsx
*/
const REGEXP_WITH_HOSTNAME = /^https?:\/\/[^/]+/i;
function getLocation(url: string) {
// In case of invalid URL, provide a default base url.
const locationPath = url.replace(REGEXP_WITH_HOSTNAME, '') || '/';
const locationProps = parsePath(locationPath);
const location: Location = {
pathname: locationProps.pathname || '/',
search: locationProps.search || '',
hash: locationProps.hash || '',
state: null,
key: 'default',
};
return location;
}

View File

@ -0,0 +1,19 @@
import type { DocumentDataLoaderConfig, RequestContext } from '../types.js';
interface Options {
loaderConfig: DocumentDataLoaderConfig;
requestContext: RequestContext;
documentOnly: boolean;
}
export default async function getDocumentData(options: Options) {
const { loaderConfig, requestContext, documentOnly } = options;
if (loaderConfig) {
const { loader } = loaderConfig;
if (typeof loader === 'function') {
return await loader(requestContext, { documentOnly });
} else {
console.warn('Document dataLoader only accepts function.');
}
}
}

View File

@ -1,50 +0,0 @@
import { createPath } from 'history';
import type { To } from 'history';
export function createStaticNavigator() {
return {
createHref(to: To) {
return typeof to === 'string' ? to : createPath(to);
},
push(to: To) {
throw new Error(
'You cannot use navigator.push() on the server because it is a stateless ' +
'environment. This error was probably triggered when you did a ' +
`\`navigate(${JSON.stringify(to)})\` somewhere in your app.`,
);
},
replace(to: To) {
throw new Error(
'You cannot use navigator.replace() on the server because it is a stateless ' +
'environment. This error was probably triggered when you did a ' +
`\`navigate(${JSON.stringify(to)}, { replace: true })\` somewhere ` +
'in your app.',
);
},
go(delta: number) {
throw new Error(
'You cannot use navigator.go() on the server because it is a stateless ' +
'environment. This error was probably triggered when you did a ' +
`\`navigate(${delta})\` somewhere in your app.`,
);
},
back() {
throw new Error(
'You cannot use navigator.back() on the server because it is a stateless ' +
'environment.',
);
},
forward() {
throw new Error(
'You cannot use navigator.forward() on the server because it is a stateless ' +
'environment.',
);
},
block() {
throw new Error(
'You cannot use navigator.block() on the server because it is a stateless ' +
'environment.',
);
},
};
}

View File

@ -0,0 +1,43 @@
import type { ServerResponse, IncomingMessage } from 'http';
import { parsePath } from 'history';
import type { Location } from 'history';
import type {
Response,
} from '../types.js';
export async function sendResponse(
req: IncomingMessage,
res: ServerResponse,
response: Response,
) {
res.statusCode = response.statusCode;
res.statusMessage = response.statusText;
Object.entries(response.headers || {}).forEach(([name, value]) => {
res.setHeader(name, value);
});
if (response.value && req.method !== 'HEAD') {
res.end(response.value);
} else {
res.end();
}
}
/**
* ref: https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/server.tsx
*/
const REGEXP_WITH_HOSTNAME = /^https?:\/\/[^/]+/i;
export function getLocation(url: string) {
// In case of invalid URL, provide a default base url.
const locationPath = url.replace(REGEXP_WITH_HOSTNAME, '') || '/';
const locationProps = parsePath(locationPath);
const location: Location = {
pathname: locationProps.pathname || '/',
search: locationProps.search || '',
hash: locationProps.hash || '',
state: null,
key: 'default',
};
return location;
}

View File

@ -2,8 +2,7 @@ import * as Stream from 'stream';
import type * as StreamType from 'stream';
import * as ReactDOMServer from 'react-dom/server';
import { getAllAssets } from '../Document.js';
import type { RenderOptions } from '../runServerApp.js';
import type { ServerAppRouterProps } from '../types.js';
import type { ServerAppRouterProps, RenderOptions } from '../types.js';
const { Writable } = Stream;
@ -117,4 +116,4 @@ export function pipeToString(input): Promise<string> {
},
});
});
}
}

View File

@ -3,6 +3,8 @@ import type { InitialEntry, AgnosticRouteObject, Location, History, RouterInit,
import type { ComponentType, PropsWithChildren } from 'react';
import type { HydrationOptions, Root } from 'react-dom/client';
import type { Params, RouteObject } from 'react-router-dom';
import type { RouteLoaderOptions } from './routes.js';
import type { RenderToPipeableStreamOptions, NodeWritablePiper } from './server/streamRender.js';
type UseConfig = () => RouteConfig<Record<string, any>>;
type UseData = () => RouteData;
@ -300,6 +302,42 @@ export interface RouteMatch {
export type RenderMode = 'SSR' | 'SSG' | 'CSR';
interface Piper {
pipe: NodeWritablePiper;
fallback: Function;
}
export interface Response {
statusCode?: number;
statusText?: string;
value?: string | Piper;
headers?: Record<string, string>;
}
export interface RenderOptions {
app: AppExport;
assetsManifest: AssetsManifest;
createRoutes: (options: Pick<RouteLoaderOptions, 'requestContext' | 'renderMode'>) => RouteItem[];
runtimeModules: RuntimeModules;
documentDataLoader?: DocumentDataLoaderConfig;
Document?: DocumentComponent;
documentOnly?: boolean;
preRender?: boolean;
renderMode?: RenderMode;
// basename is used both for server and client, once set, it will be sync to client.
basename?: string;
// serverOnlyBasename is used when just want to change basename for server.
serverOnlyBasename?: string;
routePath?: string;
disableFallback?: boolean;
routesConfig?: {
[key: string]: PageConfig;
};
runtimeOptions?: Record<string, any>;
serverData?: any;
streamOptions?: RenderToPipeableStreamOptions;
}
declare global {
interface ImportMeta {
// The build target for ice.js

View File

@ -1,33 +0,0 @@
import { expect, it, describe } from 'vitest';
import { createStaticNavigator } from '../src/server/navigator';
describe('mock server navigator', () => {
const staticNavigator = createStaticNavigator();
it('createHref', () => {
expect(staticNavigator.createHref('/')).toBe('/');
});
it('push', () => {
expect(() => staticNavigator.push('/')).toThrow();
});
it('replace', () => {
expect(() => staticNavigator.replace('/')).toThrow();
});
it('go', () => {
expect(() => staticNavigator.go(1)).toThrow();
});
it('back', () => {
expect(() => staticNavigator.back()).toThrow();
});
it('forward', () => {
expect(() => staticNavigator.forward()).toThrow();
});
it('block', () => {
expect(() => staticNavigator.block()).toThrow();
});
});

View File

@ -192,6 +192,12 @@ export interface Config {
memoryRouter?: boolean;
server?: {
/**
* Generate sperate bundle for fallback,
* it only outputs document content.
*/
fallbackEntry?: boolean;
entry?: string;
buildOptions?: (options: BuildOptions) => BuildOptions;

View File

@ -802,7 +802,7 @@ importers:
specifier: ^18.0.6
version: 18.0.11
examples/with-entry-type:
examples/with-fallback-entry:
dependencies:
'@ice/app':
specifier: workspace:*
@ -811,10 +811,10 @@ importers:
specifier: workspace:*
version: link:../../packages/runtime
react:
specifier: ^18.0.0
specifier: ^18.2.0
version: 18.2.0
react-dom:
specifier: ^18.0.0
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
devDependencies:
'@types/react':
@ -823,9 +823,6 @@ importers:
'@types/react-dom':
specifier: ^18.0.2
version: 18.0.11
fs-extra:
specifier: ^10.0.0
version: 10.1.0
webpack:
specifier: ^5.88.0
version: 5.88.2

View File

@ -65,7 +65,7 @@ describe(`build ${example}`, () => {
test('render route config when downgrade to CSR.', async () => {
await page.push('/downgrade.html');
expect(await page.$$text('title')).toStrictEqual(['hello']);
expect(await page.$$text('title')).toStrictEqual(['']);
expect((await page.$$text('h2')).length).toEqual(0);
});

View File

@ -0,0 +1,26 @@
import * as path from 'path';
import * as fs from 'fs';
import { fileURLToPath } from 'url';
import { expect, test, describe } from 'vitest';
import { buildFixture } from '../utils/build';
// @ts-ignore
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const example = 'with-fallback-entry';
describe(`build ${example}`, () => {
let sizeServer = 0;
let sizeFallback = 0;
test('build fallback entry', async () => {
await buildFixture(example);
const serverPath = path.join(__dirname, `../../examples/${example}/build/server/index.cjs`);
sizeServer = fs.statSync(serverPath).size;
const fallbackPath = path.join(__dirname, `../../examples/${example}/build/server/index.fallback.cjs`);
sizeFallback = fs.statSync(fallbackPath).size;
expect(sizeFallback).toBeLessThan(sizeServer);
// The Stat size of fallback entry will reduce more than 50kb.
expect(sizeServer - sizeFallback).toBeGreaterThan(50 * 1024);
});
});