import Ejs from 'ejs';
import fs from 'fs';
import { IncomingMessage, ServerResponse } from 'http';
import path from 'path';
import serialize from 'serialize-javascript';
import Vue from 'vue';
import { createRenderer, Renderer as VueRenderer } from 'vue-server-renderer';
import write from 'write';
import { Genesis } from './';
// @ts-ignore
// eslint-disable-next-line import/no-unresolved
import { NodeVM } from './node-vm.cjs';
import { md5 } from './util';
const defaultTemplate = `
Vue SSR for Genesis<%-style%><%-html%><%-scriptState%><%-script%>`;
const modes: Genesis.RenderMode[] = [
'ssr-html',
'csr-html',
'ssr-json',
'csr-json'
];
async function createDefaultApp(renderContext: Genesis.RenderContext) {
return new Vue({
render(h) {
return h('div');
}
});
}
function createRootNodeAttr(context: Genesis.RenderContext) {
const { data, ssr } = context;
const name = ssr.name;
return `data-ssr-genesis-id="${data.id}" data-ssr-genesis-name="${name}"`;
}
async function template(strHtml: string, ctx: Genesis.RenderContext) {
const { ssr } = ctx;
const html = strHtml.replace(
/^(<[A-z]([A-z]|[0-9])+)/,
`$1 ${createRootNodeAttr(ctx)}`
);
const vueCtx: any = ctx;
const resource = vueCtx
.getPreloadFiles()
.map((item: any): Genesis.RenderContextResource => {
return {
file: `${ssr.publicPath}${item.file}`,
extension: item.extension
};
});
const { data } = ctx;
if (html === '') {
data.html += ``;
} else {
data.html += html;
}
const baseUrl = serialize(ssr.cdnPublicPath + ssr.publicPath, {
isJSON: false,
ignoreFunction: true
});
data.script =
`` +
data.script +
vueCtx.renderScripts();
data.style += vueCtx.renderStyles();
data.resource = [...data.resource, ...resource];
(ctx as any)._subs.forEach((fn: Function) => fn(ctx));
(ctx as any)._subs = [];
return ctx.data;
}
export class Renderer {
public ssr: Genesis.SSR;
public clientManifest: Genesis.ClientManifest = {
publicPath: '',
all: [],
initial: [],
async: [],
modules: {}
};
private renderer!: VueRenderer;
/**
* Render template functions
*/
private compile!: Ejs.TemplateFunction;
private _createApp = createDefaultApp;
public constructor(ssr: Genesis.SSR) {
this.ssr = ssr;
Object.defineProperty(ssr.sandboxGlobal, ssr.publicPathVarName, {
enumerable: true,
get: () => ssr.cdnPublicPath + ssr.publicPath
});
this._load();
const bindArr = [
'renderJson',
'renderHtml',
'render',
'renderMiddleware'
];
bindArr.forEach((k) => {
this[k] = this[k].bind(this);
Object.defineProperty(this, k, {
enumerable: false
});
});
}
/**
* Reload the renderer
*/
public reload() {
this._load();
}
/**
* Render JSON
*/
public async renderJson(
options: Genesis.RenderOptions = {
mode: 'ssr-json'
}
): Promise {
options = { ...options };
if (
!options.mode ||
['ssr-json', 'csr-json'].indexOf(options.mode) === -1
) {
options.mode = 'ssr-json';
}
return this.render(options) as Promise;
}
/**
* Render HTML
*/
public async renderHtml(
options: Genesis.RenderOptions = {
mode: 'ssr-html'
}
): Promise {
options = { ...options };
if (
!options.mode ||
['ssr-html', 'csr-html'].indexOf(options.mode) === -1
) {
options.mode = 'ssr-html';
}
return this.render(options) as Promise;
}
/**
* General basic rendering function
*/
public async render(
options: Genesis.RenderOptions = {}
): Promise> {
const { ssr } = this;
const context = this._createContext(options);
await ssr.plugin.callHook('renderBefore', context);
switch (context.mode) {
case 'ssr-html':
case 'csr-html':
return this._renderHtml(context, context.mode) as any;
case 'ssr-json':
case 'csr-json':
return this._renderJson(context, context.mode) as any;
}
}
/**
* Rendering Middleware
*/
public async renderMiddleware(
req: IncomingMessage,
res: ServerResponse,
next: (err: any) => void
): Promise {
try {
const result = await this.render({ req, res });
res.setHeader('cache-control', 'max-age=0');
switch (result.type) {
case 'html':
res.setHeader('content-type', 'text/html; charset=utf-8');
res.write(result.data);
break;
case 'json':
res.setHeader(
'content-type',
'application/json; charset=utf-8'
);
res.write(JSON.stringify(result.data));
break;
}
res.end();
} catch (err) {
next(err);
}
}
private _createContext(
options: Genesis.RenderOptions
): Genesis.RenderContext {
const context: Genesis.RenderContext = {
env: 'server',
data: {
id: '',
name: this.ssr.name,
url: '/',
html: '',
style: '',
script: '',
scriptState: '',
state: {},
resource: [],
autoMount: true
},
styleTagExtractCSS: options.styleTagExtractCSS ?? false,
mode: 'ssr-html',
renderHtml: () => this.compile(context.data),
ssr: this.ssr,
renderer: this,
beforeRender: (cb) => {
(context as any)._subs.push(cb);
}
};
Object.defineProperty(context, '_subs', {
enumerable: false,
value: [],
writable: true
});
Object.defineProperty(context.data, 'scriptState', {
enumerable: false,
get() {
const data = context.data;
const script = { ...data, env: 'client' };
const arr = [
'style',
'html',
'scriptState',
'script',
'resource'
];
arr.forEach((k) => {
Object.defineProperty(script, k, {
enumerable: false
});
});
const scriptJSON: string = serialize(script, {
isJSON: true
});
return ``;
}
});
// set context
if (options.req instanceof IncomingMessage) {
context.req = options.req;
if (typeof context.req.url === 'string') {
context.data.url = context.req.url;
}
}
if (options.res instanceof ServerResponse) {
context.res = options.res;
}
if (options.mode && modes.indexOf(options.mode) > -1) {
context.mode = options.mode;
}
if (
options.state &&
Object.prototype.toString.call(options.state) === '[object Object]'
) {
context.data.state = options.state || {};
}
// set context data
if (typeof options.url === 'string') {
context.data.url = options.url;
}
if (typeof options.id === 'string') {
context.data.id = options.id;
} else {
context.data.id = md5(`${context.data.name}-${context.data.url}`);
}
if (typeof options.name === 'string') {
context.data.name = options.name;
}
if (typeof options.autoMount === 'boolean') {
context.data.autoMount = options.autoMount;
}
return context;
}
private async _renderJson(
context: Genesis.RenderContext,
mode: Genesis.RenderModeJson
): Promise {
switch (mode) {
case 'ssr-json':
return {
type: 'json',
data: await this._ssrToJson(context),
context
};
case 'csr-json':
return {
type: 'json',
data: await this._csrToJson(context),
context
};
}
}
/**
* Render HTML
*/
private async _renderHtml(
context: Genesis.RenderContext,
mode: Genesis.RenderModeHtml
): Promise {
switch (mode) {
case 'ssr-html':
return {
type: 'html',
data: await this._ssrToString(context),
context
};
case 'csr-html':
return {
type: 'html',
data: await this._csrToString(context),
context
};
}
}
/**
* Static file public path
*/
public get staticPublicPath() {
return this.ssr.publicPath;
}
/**
* Static file directory
*/
public get staticDir() {
return this.ssr.outputDirInClient;
}
/**
* The server renders a JSON
*/
private async _ssrToJson(
context: Genesis.RenderContext
): Promise {
const vm = await this._createApp(context);
await new Promise((resolve, reject) => {
this.renderer.renderToString(vm, context, (err, data: any) => {
if (err) {
return reject(err);
} else if (typeof data !== 'object') {
reject(new Error('Vue no rendering results'));
}
resolve(data);
});
});
await this.ssr.plugin.callHook('renderCompleted', context);
this._styleTagExtractCSS(context);
return context.data;
}
/**
* The server renders a HTML
*/
private async _ssrToString(
context: Genesis.RenderContext
): Promise {
// #12426 https://github.com/vuejs/vue/pull/12426
(context as any)._registeredComponents = new Set();
await this._ssrToJson(context);
return context.renderHtml();
}
/**
* The client renders a JSON
*/
private async _csrToJson(
context: Genesis.RenderContext
): Promise {
const vm = await createDefaultApp(context);
const data: Genesis.RenderData = (await this.renderer.renderToString(
vm,
context
)) as any;
data.html = ``;
await this.ssr.plugin.callHook('renderCompleted', context);
this._styleTagExtractCSS(context);
return context.data;
}
/**
* The client renders a HTML
*/
private async _csrToString(
context: Genesis.RenderContext
): Promise {
await this._csrToJson(context);
return context.renderHtml();
}
private _styleTagExtractCSS(context: Genesis.RenderContext) {
const { cdnPublicPath, publicPath, isProd } = this.ssr;
if (!context.styleTagExtractCSS || !isProd) return;
const info = styleTagExtractCSS(context.data.style);
const filename = `first-screen-style/${md5(info.cssRules)}.css`;
const url = `${cdnPublicPath}${publicPath}${filename}`;
context.data.style =
info.value +
``;
const fullFilename = path.resolve(this.ssr.outputDirInClient, filename);
if (fs.existsSync(fullFilename)) {
return;
}
write.sync(fullFilename, info.cssRules);
}
private _load() {
const { ssr } = this;
const renderOptions: any = {
template: template as any,
inject: false
};
this._createApp = createDefaultApp;
if (fs.existsSync(ssr.outputServeAppFile)) {
const vm = new NodeVM(ssr.outputServeAppFile, ssr.sandboxGlobal);
this._createApp = (...args) => {
const createApp = vm.require();
return createApp['default'](...args);
};
}
if (fs.existsSync(ssr.outputClientManifestFile)) {
const text = fs.readFileSync(ssr.outputClientManifestFile, 'utf-8');
if (text) {
const clientManifest: Genesis.ClientManifest = JSON.parse(text);
clientManifest.publicPath = ssr.cdnPublicPath + ssr.publicPath;
if (!ssr.isProd) {
// fix https://github.com/fmfe/genesis/issues/71
removeHotUpdateFiles(clientManifest);
}
this.clientManifest = clientManifest;
}
}
renderOptions.clientManifest = this.clientManifest;
const ejsTemplate = fs.existsSync(this.ssr.outputTemplateFile)
? fs.readFileSync(this.ssr.outputTemplateFile, 'utf-8')
: defaultTemplate;
this.renderer = createRenderer(renderOptions);
this.compile = Ejs.compile(ejsTemplate);
}
}
function testHotUpdate(file: string) {
return !/\.hot-update.js(on)?$/.test(file);
}
function removeHotUpdateFiles(manifest: Genesis.ClientManifest) {
manifest.all = manifest.all.filter(testHotUpdate);
manifest.async = manifest.async.filter(testHotUpdate);
manifest.initial = manifest.initial.filter(testHotUpdate);
}
export function styleTagExtractCSS(value: string) {
let cssRules = '';
const newValue = value.replace(
/