Skip to content

Commit

Permalink
feat(plugin-runtime): support ssr inline assets (#4735)
Browse files Browse the repository at this point in the history
  • Loading branch information
GiveMe-A-Name authored Oct 24, 2023
1 parent 8f43163 commit ce967bb
Show file tree
Hide file tree
Showing 15 changed files with 476 additions and 79 deletions.
6 changes: 6 additions & 0 deletions .changeset/kind-stingrays-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modern-js/runtime': minor
---

feat(plugin-runtime): support ssr inline assets
feat(plugin-runtime): 支持 SSR 内联 css, scripts 资源
9 changes: 9 additions & 0 deletions packages/runtime/plugin-runtime/src/ssr/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export const ssrPlugin = (): CliPlugin<AppTools> => ({
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'
Expand All @@ -215,6 +216,14 @@ export const ssrPlugin = (): CliPlugin<AppTools> => ({
scriptLoading,
chunkLoadingGlobal,
disablePrerender,
enableInlineScripts:
typeof enableInlineScripts === 'function'
? undefined
: enableInlineScripts,
enableInlineStyles:
typeof enableInlineStyles === 'function'
? undefined
: enableInlineStyles,
}),
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export default class Entry {

private readonly nonce?: string;

private readonly routeManifest?: Record<string, any>;

constructor(options: EntryOptions) {
const { ctx, config } = options;
const { entryName, template, nonce } = ctx;
Expand All @@ -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;
Expand Down Expand Up @@ -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 '';
Expand Down Expand Up @@ -175,7 +178,7 @@ export default class Entry {
return prefetchData || {};
}

private renderToString(context: RuntimeContext): string {
private async renderToString(context: RuntimeContext): Promise<string> {
let html = '';
const end = time();
const { ssrContext } = context;
Expand All @@ -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({
Expand All @@ -195,6 +198,7 @@ export default class Entry {
config: this.pluginConfig,
nonce: this.nonce,
template: this.template,
routeManifest: this.routeManifest,
}),
)
.finish();
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;

Expand All @@ -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('</script>')
// The first two scripts are essential for Loadable.
.slice(0, 2)
.map(i => `${i}</script>`)
.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(`<script .*src="${chunk.url!}".*>`);
const existsAssets = routeManifest?.routeAssets?.[entryName]
?.assets as string[];
return (
!jsChunkReg.test(template) && !existsAssets.includes(chunk.path!)
);
})
.map(chunk => {
if (checkIsInline(chunk, enableInlineScripts)) {
const filepath = chunk.path!;
return fs
.readFile(filepath, 'utf-8')
.then(content => `<script>${content}</script>`);
} else {
return `<script${attributes} src="${chunk.url}"></script>`;
}
}),
);
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(`<script .*src="${v.url}".*>`);
if (!jsChunkReg.test(template)) {
// scriptLoading just apply for script tag.
const { scriptLoading = 'defer' } = config;
switch (scriptLoading) {
case 'defer':
attributes.defer = true;
break;
case 'module':
attributes.type = 'module';
break;
default:
const atrributes = attributesToString(this.generateAttributes());

const css = await Promise.all(
chunks
.filter(chunk => {
const cssChunkReg = new RegExp(`<link .*href="${chunk.url!}".*>`);
const existsAssets = routeManifest?.routeAssets?.[entryName]
?.assets as string[];
return (
!cssChunkReg.test(template) && !existsAssets.includes(chunk.path!)
);
})
.map(chunk => {
if (checkIsInline(chunk, enableInlineStyles)) {
return fs
.readFile(chunk.path!)
.then(content => `<style>${content}</style>`);
} else {
return `<link${atrributes} href="${chunk.url!}" rel="stylesheet" />`;
}
// we should't repeatly registe the script, if template already has it.
// `nonce` attrs just for script tag
attributes.nonce = nonce;
const attrsStr = attributesToString(attributes);
chunksMap[fileType] += `<script${attrsStr} src="${v.url}"></script>`;
}
} else if (fileType === 'css') {
const cssChunkReg = new RegExp(`<link .*href="${v.url}".*>`);
if (!cssChunkReg.test(template)) {
const attrsStr = attributesToString(attributes);
chunksMap[
fileType
] += `<link${attrsStr} href="${v.url}" rel="stylesheet" />`;
}
}
}
}),
);

chunksMap.css += css.join('');
}

private generateAttributes(): Record<string, any> {
private generateAttributes(
extraAtr: Record<string, any> = {},
): Record<string, any> {
const { config } = this.options;
const { crossorigin } = config;

Expand All @@ -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<string, any>;
routeManifest?: Record<string, any>;
template: string;
config: SSRPluginConfig;
entryName: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ReactElement } from 'react';

export interface Collector {
collect: (comopnent: ReactElement) => ReactElement;
effect: () => void;
effect: () => void | Promise<void>;
}

class Render {
Expand All @@ -20,7 +20,7 @@ class Render {
return this;
}

finish(): string {
async finish(): Promise<string> {
// collectors do collect
const App = this.collectors.reduce(
(pre, collector) => collector.collect(pre),
Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime/plugin-runtime/src/ssr/serverRender/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerUserConfig['ssr'], boolean>;
Expand Down
21 changes: 0 additions & 21 deletions packages/runtime/plugin-runtime/src/ssr/serverRender/utils.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,9 @@
import type { ChunkExtractor } from '@loadable/server';

export const CSS_CHUNKS_PLACEHOLDER = '<!--<?- chunksMap.css ?>-->';

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('</script>')
// The first two scripts are essential for Loadable.
.slice(0, 2)
.map(i => `${i}</script>`)
.join('')
);
}
export function attributesToString(attributes: Record<string, any>) {
// 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]) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -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();

Expand Down
Loading

0 comments on commit ce967bb

Please sign in to comment.