From ce967bb9b1e71f64760777c7f3b84e0072fb9386 Mon Sep 17 00:00:00 2001 From: qixuan <58852732+GiveMe-A-Name@users.noreply.github.com> Date: Tue, 24 Oct 2023 14:26:28 +0800 Subject: [PATCH] feat(plugin-runtime): support ssr inline assets (#4735) --- .changeset/kind-stingrays-knock.md | 6 + .../plugin-runtime/src/ssr/cli/index.ts | 9 + .../ssr/serverRender/renderToString/entry.ts | 10 +- .../serverRender/renderToString/loadable.ts | 186 +++++++++++++----- .../ssr/serverRender/renderToString/render.ts | 8 +- .../src/ssr/serverRender/types.ts | 2 + .../src/ssr/serverRender/utils.ts | 21 -- .../renderToString/render.test.ts | 4 +- pnpm-lock.yaml | 15 ++ .../ssr/fixtures/inline/modern.config.ts | 15 ++ .../ssr/fixtures/inline/package.json | 16 ++ .../ssr/fixtures/inline/src/routes/index.css | 117 +++++++++++ .../ssr/fixtures/inline/src/routes/layout.jsx | 11 ++ .../ssr/fixtures/inline/src/routes/page.jsx | 84 ++++++++ tests/integration/ssr/tests/inline.test.ts | 51 +++++ 15 files changed, 476 insertions(+), 79 deletions(-) create mode 100644 .changeset/kind-stingrays-knock.md create mode 100644 tests/integration/ssr/fixtures/inline/modern.config.ts create mode 100644 tests/integration/ssr/fixtures/inline/package.json create mode 100644 tests/integration/ssr/fixtures/inline/src/routes/index.css create mode 100644 tests/integration/ssr/fixtures/inline/src/routes/layout.jsx create mode 100644 tests/integration/ssr/fixtures/inline/src/routes/page.jsx create mode 100644 tests/integration/ssr/tests/inline.test.ts diff --git a/.changeset/kind-stingrays-knock.md b/.changeset/kind-stingrays-knock.md new file mode 100644 index 000000000000..1aa0d7db3822 --- /dev/null +++ b/.changeset/kind-stingrays-knock.md @@ -0,0 +1,6 @@ +--- +'@modern-js/runtime': minor +--- + +feat(plugin-runtime): support ssr inline assets +feat(plugin-runtime): 支持 SSR 内联 css, scripts 资源 diff --git a/packages/runtime/plugin-runtime/src/ssr/cli/index.ts b/packages/runtime/plugin-runtime/src/ssr/cli/index.ts index 95e053e98625..8c6080a0001b 100644 --- a/packages/runtime/plugin-runtime/src/ssr/cli/index.ts +++ b/packages/runtime/plugin-runtime/src/ssr/cli/index.ts @@ -201,6 +201,7 @@ export const ssrPlugin = (): CliPlugin => ({ config => config.name === 'client', )?.output?.chunkLoadingGlobal; const config = api.useResolvedConfigContext(); + const { enableInlineScripts, enableInlineStyles } = config.output; const { crossorigin, scriptLoading } = config.html; const disablePrerender = typeof config.server?.ssr === 'object' @@ -215,6 +216,14 @@ export const ssrPlugin = (): CliPlugin => ({ scriptLoading, chunkLoadingGlobal, disablePrerender, + enableInlineScripts: + typeof enableInlineScripts === 'function' + ? undefined + : enableInlineScripts, + enableInlineStyles: + typeof enableInlineStyles === 'function' + ? undefined + : enableInlineStyles, }), }); } diff --git a/packages/runtime/plugin-runtime/src/ssr/serverRender/renderToString/entry.ts b/packages/runtime/plugin-runtime/src/ssr/serverRender/renderToString/entry.ts index b835b26c7087..ee78cb8112aa 100644 --- a/packages/runtime/plugin-runtime/src/ssr/serverRender/renderToString/entry.ts +++ b/packages/runtime/plugin-runtime/src/ssr/serverRender/renderToString/entry.ts @@ -86,6 +86,8 @@ export default class Entry { private readonly nonce?: string; + private readonly routeManifest?: Record; + constructor(options: EntryOptions) { const { ctx, config } = options; const { entryName, template, nonce } = ctx; @@ -95,6 +97,7 @@ export default class Entry { this.App = options.App; this.pluginConfig = config; + this.routeManifest = ctx.routeManifest; this.tracker = ctx.tracker; this.metrics = ctx.metrics; this.htmlModifiers = ctx.htmlModifiers; @@ -123,7 +126,7 @@ export default class Entry { } if (this.result.renderLevel >= RenderLevel.SERVER_PREFETCH) { - this.result.html = this.renderToString(context); + this.result.html = await this.renderToString(context); } if (ssrContext.redirection?.url) { return ''; @@ -175,7 +178,7 @@ export default class Entry { return prefetchData || {}; } - private renderToString(context: RuntimeContext): string { + private async renderToString(context: RuntimeContext): Promise { let html = ''; const end = time(); const { ssrContext } = context; @@ -185,7 +188,7 @@ export default class Entry { context: Object.assign(context, { ssr: true }), }); - html = createRender(App) + html = await createRender(App) .addCollector(createStyledCollector(this.result)) .addCollector( createLoadableCollector({ @@ -195,6 +198,7 @@ export default class Entry { config: this.pluginConfig, nonce: this.nonce, template: this.template, + routeManifest: this.routeManifest, }), ) .finish(); diff --git a/packages/runtime/plugin-runtime/src/ssr/serverRender/renderToString/loadable.ts b/packages/runtime/plugin-runtime/src/ssr/serverRender/renderToString/loadable.ts index 845f466c6924..f19e8636a81e 100644 --- a/packages/runtime/plugin-runtime/src/ssr/serverRender/renderToString/loadable.ts +++ b/packages/runtime/plugin-runtime/src/ssr/serverRender/renderToString/loadable.ts @@ -1,6 +1,7 @@ -import { ChunkExtractor } from '@loadable/server'; +import { type ChunkAsset, ChunkExtractor } from '@loadable/server'; +import { fs } from '@modern-js/utils'; import { ReactElement } from 'react'; -import { attributesToString, getLoadableScripts } from '../utils'; +import { attributesToString } from '../utils'; import { SSRPluginConfig } from '../types'; import { RenderResult } from './type'; import type { Collector } from './render'; @@ -12,6 +13,27 @@ const extname = (uri: string): string => { return `.${uri?.split('.').pop()}` || ''; }; +const generateChunks = (chunks: ChunkAsset[], ext: string) => + chunks + .filter(chunk => Boolean(chunk.url)) + .filter(chunk => extname(chunk.url!).slice(1) === ext); + +const checkIsInline = ( + chunk: ChunkAsset, + enableInline: boolean | RegExp | undefined, +) => { + // only production apply the inline config + if (process.env.NODE_ENV === 'production') { + if (enableInline instanceof RegExp) { + return enableInline.test(chunk.url!); + } else { + return Boolean(enableInline); + } + } else { + return false; + } +}; + class LoadableCollector implements Collector { private options: LoadableCollectorOptions; @@ -36,62 +58,126 @@ class LoadableCollector implements Collector { return this.extractor.collectChunks(comopnent); } - effect() { + async effect() { if (!this.extractor) { return; } + const { extractor } = this; + + const chunks = extractor.getChunkAssets(extractor.chunks); + + const scriptChunks = generateChunks(chunks, 'js'); + const styleChunks = generateChunks(chunks, 'css'); + + this.emitLoadableScripts(extractor); + await this.emitScriptAssets(scriptChunks); + await this.emitStyleAssets(styleChunks); + } + + private emitLoadableScripts(extractor: ChunkExtractor) { + const check = (scripts: string) => + (scripts || '').includes('__LOADABLE_REQUIRED_CHUNKS___ext'); + + const scripts = extractor.getScriptTags(); + + if (!check(scripts)) { + return; + } + const { result: { chunksMap }, - config, + } = this.options; + + const s = scripts + .split('') + // The first two scripts are essential for Loadable. + .slice(0, 2) + .map(i => `${i}`) + .join(''); + + chunksMap.js += s; + } + + private async emitScriptAssets(chunks: ChunkAsset[]) { + const { template, config, nonce, result, routeManifest, entryName } = + this.options; + const { chunksMap } = result; + const { scriptLoading = 'defer', enableInlineScripts } = config; + + const scriptLoadingAtr = { + defer: scriptLoading === 'defer' ? true : undefined, + type: scriptLoading === 'module' ? 'module' : undefined, + }; + + const attributes = attributesToString( + this.generateAttributes({ + nonce, + ...scriptLoadingAtr, + }), + ); + + const scripts = await Promise.all( + chunks + .filter(chunk => { + const jsChunkReg = new RegExp(``); + } else { + return ``; + } + }), + ); + chunksMap.js += scripts.join(''); + } + + private async emitStyleAssets(chunks: ChunkAsset[]) { + const { template, - nonce, + result: { chunksMap }, + config: { enableInlineStyles }, + entryName, + routeManifest, } = this.options; - const { extractor } = this; - const chunks = extractor.getChunkAssets(extractor.chunks); - chunksMap.js = (chunksMap.js || '') + getLoadableScripts(extractor); - - const attributes = this.generateAttributes(); - - for (const v of chunks) { - if (!v.url) { - continue; - } - const fileType = extname(v.url).slice(1); - - if (fileType === 'js') { - const jsChunkReg = new RegExp(``; - } - } else if (fileType === 'css') { - const cssChunkReg = new RegExp(``); - if (!cssChunkReg.test(template)) { - const attrsStr = attributesToString(attributes); - chunksMap[ - fileType - ] += ``; - } - } - } + }), + ); + + chunksMap.css += css.join(''); } - private generateAttributes(): Record { + private generateAttributes( + extraAtr: Record = {}, + ): Record { const { config } = this.options; const { crossorigin } = config; @@ -101,12 +187,16 @@ class LoadableCollector implements Collector { attributes.crossorigin = crossorigin === true ? 'anonymous' : crossorigin; } - return attributes; + return { + ...attributes, + ...extraAtr, + }; } } export interface LoadableCollectorOptions { nonce?: string; stats?: Record; + routeManifest?: Record; template: string; config: SSRPluginConfig; entryName: string; diff --git a/packages/runtime/plugin-runtime/src/ssr/serverRender/renderToString/render.ts b/packages/runtime/plugin-runtime/src/ssr/serverRender/renderToString/render.ts index 2fd9bbd46d58..95f9d9230925 100644 --- a/packages/runtime/plugin-runtime/src/ssr/serverRender/renderToString/render.ts +++ b/packages/runtime/plugin-runtime/src/ssr/serverRender/renderToString/render.ts @@ -3,7 +3,7 @@ import type { ReactElement } from 'react'; export interface Collector { collect: (comopnent: ReactElement) => ReactElement; - effect: () => void; + effect: () => void | Promise; } class Render { @@ -20,7 +20,7 @@ class Render { return this; } - finish(): string { + async finish(): Promise { // collectors do collect const App = this.collectors.reduce( (pre, collector) => collector.collect(pre), @@ -31,9 +31,7 @@ class Render { const html = ReactDomServer.renderToString(App); // collectors do effect - this.collectors.forEach(component => { - component.effect(); - }); + await Promise.all(this.collectors.map(component => component.effect())); return html; } diff --git a/packages/runtime/plugin-runtime/src/ssr/serverRender/types.ts b/packages/runtime/plugin-runtime/src/ssr/serverRender/types.ts index 245a82dc2835..3b0ad8818d57 100644 --- a/packages/runtime/plugin-runtime/src/ssr/serverRender/types.ts +++ b/packages/runtime/plugin-runtime/src/ssr/serverRender/types.ts @@ -20,6 +20,8 @@ export { RuntimeContext, RenderLevel }; export type SSRPluginConfig = { crossorigin?: boolean | 'anonymous' | 'use-credentials'; scriptLoading?: 'defer' | 'blocking' | 'module'; + enableInlineStyles?: boolean | RegExp; + enableInlineScripts?: boolean | RegExp; disablePrerender?: boolean; chunkLoadingGlobal?: string; } & Exclude; diff --git a/packages/runtime/plugin-runtime/src/ssr/serverRender/utils.ts b/packages/runtime/plugin-runtime/src/ssr/serverRender/utils.ts index e47a2468b03c..8c1f4f49829d 100644 --- a/packages/runtime/plugin-runtime/src/ssr/serverRender/utils.ts +++ b/packages/runtime/plugin-runtime/src/ssr/serverRender/utils.ts @@ -1,30 +1,9 @@ -import type { ChunkExtractor } from '@loadable/server'; - export const CSS_CHUNKS_PLACEHOLDER = ''; export const SSR_DATA_JSON_ID = '__MODERN_SSR_DATA__'; export const ROUTER_DATA_JSON_ID = '__MODERN_ROUTER_DATA__'; -export function getLoadableScripts(extractor: ChunkExtractor) { - const check = (scripts: string) => - (scripts || '').includes('__LOADABLE_REQUIRED_CHUNKS___ext'); - - const scripts = extractor.getScriptTags(); - - if (!check(scripts)) { - return ''; - } - - return ( - scripts - .split('') - // The first two scripts are essential for Loadable. - .slice(0, 2) - .map(i => `${i}`) - .join('') - ); -} export function attributesToString(attributes: Record) { // Iterate through the properties and convert them into a string, only including properties that are not undefined. return Object.entries(attributes).reduce((str, [key, value]) => { diff --git a/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToString/render.test.ts b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToString/render.test.ts index f19209210313..b8a8678d6576 100644 --- a/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToString/render.test.ts +++ b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToString/render.test.ts @@ -4,7 +4,7 @@ import { createStyledCollector } from '../../../../src/ssr/serverRender/renderTo import App from '../../fixtures/string-ssr/App'; describe('test render', () => { - it('should render styledComponent correctly', () => { + it('should render styledComponent correctly', async () => { const result = { chunksMap: { js: '', @@ -13,7 +13,7 @@ describe('test render', () => { renderLevel: 2, }; const Apps = createElement(App); - const html = createRender(Apps) + const html = await createRender(Apps) .addCollector(createStyledCollector(result)) .finish(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec5d64ce8cb0..2cbc48b25065 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7906,6 +7906,21 @@ importers: specifier: ^18 version: 18.2.0(react@18.2.0) + tests/integration/ssr/fixtures/inline: + dependencies: + '@modern-js/app-tools': + specifier: workspace:* + version: link:../../../../../packages/solutions/app-tools + '@modern-js/runtime': + specifier: workspace:* + version: link:../../../../../packages/runtime/plugin-runtime + react: + specifier: ^18 + version: 18.2.0 + react-dom: + specifier: ^18 + version: 18.2.0(react@18.2.0) + tests/integration/ssr/fixtures/preload: dependencies: '@modern-js/app-tools': diff --git a/tests/integration/ssr/fixtures/inline/modern.config.ts b/tests/integration/ssr/fixtures/inline/modern.config.ts new file mode 100644 index 000000000000..56f2015d409c --- /dev/null +++ b/tests/integration/ssr/fixtures/inline/modern.config.ts @@ -0,0 +1,15 @@ +import { applyBaseConfig } from '../../../../utils/applyBaseConfig'; + +process.env.BUNDLER = 'webpack'; +export default applyBaseConfig({ + server: { + ssr: true, + }, + output: { + enableInlineStyles: true, + enableInlineScripts: /page\./, + }, + runtime: { + router: true, + }, +}); diff --git a/tests/integration/ssr/fixtures/inline/package.json b/tests/integration/ssr/fixtures/inline/package.json new file mode 100644 index 000000000000..d0babf17f93f --- /dev/null +++ b/tests/integration/ssr/fixtures/inline/package.json @@ -0,0 +1,16 @@ +{ + "private": true, + "name": "ssr-inline", + "version": "2.9.0", + "scripts": { + "dev": "modern dev", + "build": "modern build", + "serve": "modern serve" + }, + "dependencies": { + "react": "^18", + "react-dom": "^18", + "@modern-js/runtime": "workspace:*", + "@modern-js/app-tools": "workspace:*" + } +} diff --git a/tests/integration/ssr/fixtures/inline/src/routes/index.css b/tests/integration/ssr/fixtures/inline/src/routes/index.css new file mode 100644 index 000000000000..ea5fde430946 --- /dev/null +++ b/tests/integration/ssr/fixtures/inline/src/routes/index.css @@ -0,0 +1,117 @@ +html, +body { + padding: 0; + margin: 0; + font-family: PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif; + background: linear-gradient(to bottom, transparent, #fff) #eceeef; +} + +p { + margin: 0; +} + +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + box-sizing: border-box; +} + +.container-box { + min-height: 100vh; + max-width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-top: 10px; +} + +main { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.title { + display: flex; + margin: 4rem 0 4rem; + align-items: center; + font-size: 4rem; + font-weight: 600; +} + +.logo { + width: 3.6rem; + margin: 0 1.6rem; +} + +.name { + background: -webkit-linear-gradient(315deg, #788ec9 25%, #5978c0); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.description { + text-align: center; + line-height: 1.5; + font-size: 1.3rem; + color: #1b3a42; + margin-bottom: 5rem; +} + +.code { + background: #fafafa; + border-radius: 12px; + padding: 0.6rem 0.9rem; + font-size: 1.05rem; + font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, + Bitstream Vera Sans Mono, Courier New, monospace; +} + +.container-box .grid { + display: flex; + align-items: center; + justify-content: center; + width: 1100px; + margin-top: 3rem; +} + +.card { + padding: 1.5rem; + display: flex; + flex-direction: column; + justify-content: center; + height: 100px; + color: inherit; + text-decoration: none; + transition: 0.15s ease; + width: 45%; +} + +.card:hover, +.card:focus { + transform: scale(1.05); +} + +.card h2 { + display: flex; + align-items: center; + font-size: 1.5rem; + margin: 0; + padding: 0; +} + +.card p { + opacity: 0.6; + font-size: 0.9rem; + line-height: 1.5; + margin-top: 1rem; +} + +.arrow-right { + width: 1.3rem; + margin-left: 0.5rem; + margin-top: 3px; +} diff --git a/tests/integration/ssr/fixtures/inline/src/routes/layout.jsx b/tests/integration/ssr/fixtures/inline/src/routes/layout.jsx new file mode 100644 index 000000000000..b9a57e527a20 --- /dev/null +++ b/tests/integration/ssr/fixtures/inline/src/routes/layout.jsx @@ -0,0 +1,11 @@ +// eslint-disable-next-line no-unused-vars +import { Outlet } from '@modern-js/runtime/router'; + +export default function Layout() { + return ( +
+ Root layout + +
+ ); +} diff --git a/tests/integration/ssr/fixtures/inline/src/routes/page.jsx b/tests/integration/ssr/fixtures/inline/src/routes/page.jsx new file mode 100644 index 000000000000..0e1a3eb3d1d7 --- /dev/null +++ b/tests/integration/ssr/fixtures/inline/src/routes/page.jsx @@ -0,0 +1,84 @@ +import './index.css'; + +const Index = (): JSX.Element => ( + +); + +export default Index; diff --git a/tests/integration/ssr/tests/inline.test.ts b/tests/integration/ssr/tests/inline.test.ts new file mode 100644 index 000000000000..930c545b0288 --- /dev/null +++ b/tests/integration/ssr/tests/inline.test.ts @@ -0,0 +1,51 @@ +import dns from 'node:dns'; +import path, { join } from 'path'; +import puppeteer, { Browser, Page } from 'puppeteer'; +import { + getPort, + killApp, + launchOptions, + modernBuild, + modernServe, +} from '../../../utils/modernTestUtils'; + +const fixtureDir = path.resolve(__dirname, '../fixtures'); + +dns.setDefaultResultOrder('ipv4first'); + +describe('Inline SSR', () => { + let app: any; + let appPort: number; + let page: Page; + let browser: Browser; + + beforeAll(async () => { + const appDir = join(fixtureDir, 'inline'); + appPort = await getPort(); + + await modernBuild(appDir); + app = await modernServe(appDir, appPort); + + browser = await puppeteer.launch(launchOptions as any); + page = await browser.newPage(); + }); + + test('should inline style & js', async () => { + await page.goto(`http://localhost:${appPort}`); + + const content = await page.content(); + + expect(content).toMatch('.card:hover'); + + expect(content).toMatch('t.jsx)("code",'); + }); + + afterAll(async () => { + if (browser) { + browser.close(); + } + if (app) { + await killApp(app); + } + }); +});