vite-ssr-vue
Version:
Vite utility for vue3 server side rendering
269 lines (259 loc) • 8.4 kB
JavaScript
;
var fs = require('fs');
var path = require('path');
var nodeHtmlParser = require('node-html-parser');
var vite = require('vite');
var replace = require('@rollup/plugin-replace');
const defaultHtmlParts = [
"headTags",
"body",
"bodyAttrs",
"htmlAttrs",
"initialState"
].reduce(
(acc, item) => ({ ...acc, [item]: `\${${item}}` }),
{}
);
const buildHtml = (template, parts = defaultHtmlParts) => {
return template.replace("<html", `<html${parts.htmlAttrs}`).replace("<body", `<body${parts.bodyAttrs}`).replace("</head>", `${parts.headTags ? `${parts.headTags}
` : ""}</head>`).replace(
'<div id="app"></div>',
// eslint-disable-next-line max-len
`<div id="app" data-server-rendered="true">${parts.body}</div><script>window.__INITIAL_STATE__=${parts.initialState}<\/script>`
);
};
const teleportsInject = (body, teleports = {}) => {
const teleportsKeys = Object.keys(teleports);
if (teleportsKeys.length) {
const root = nodeHtmlParser.parse(body, { comment: true });
teleportsKeys.map((key) => {
const el = root.querySelector(key);
if (el) {
if (el.childNodes) {
el.childNodes.unshift(nodeHtmlParser.parse(teleports[key], { comment: true }));
} else {
el.appendChild(nodeHtmlParser.parse(teleports[key], { comment: true }));
}
}
});
return root.toString();
}
return body;
};
const entryFromTemplate = (template) => {
const matches = template.substr(template.lastIndexOf('script type="module"')).match(/src="(.*)">/i);
return matches?.[1];
};
const cookieParse = (str) => {
if (!str) {
return {};
}
return str.split(/; */).reduce((obj, str2) => {
if (str2 === "") {
return obj;
}
const eq = str2.indexOf("=");
const key = eq > 0 ? str2.slice(0, eq) : str2;
let val = eq > 0 ? str2.slice(eq + 1) : null;
if (val != null) {
try {
val = decodeURIComponent(val);
} catch (ex) {
}
}
obj[key] = val;
return obj;
}, {});
};
const readIndexTemplate = async (server, url) => await server.transformIndexHtml(
url,
await fs.promises.readFile(path.resolve(server.config.root, "index.html"), "utf-8")
);
const replaceEnteryPoint = (server, name, wrapper) => {
const alias = server.config.resolve.alias.find(
(item) => typeof item.replacement === "string" && item.replacement.indexOf(name) === 0
);
if (alias) {
alias.replacement = wrapper;
}
};
const createHandler = (server, options) => {
return async (req, res, next) => {
const response = res;
if (req.method !== "GET" || !req.originalUrl) {
return next();
}
response.redirect = (url, statusCode = 307) => {
response.statusCode = statusCode;
response.setHeader("location", url);
response.end();
};
try {
replaceEnteryPoint(server, options.name, options.wrappers.server);
const template = await readIndexTemplate(server, req.originalUrl);
const entry = options.ssr || entryFromTemplate(template);
if (!entry) {
throw new Error("Entry point for ssr not found");
}
const entryResolve = path.join(server.config.root, entry);
const ssrMoudile = await server.ssrLoadModule(entryResolve);
const render = ssrMoudile.default || ssrMoudile;
const headers = req.headers;
const protocol = server.config?.server?.https ? "https" : "";
const context = {
hostname: headers.host,
protocol: headers["x-forwarded-proto"] || protocol || "http",
url: req.originalUrl || "/",
cookies: cookieParse(headers["cookie"]),
ip: headers["x-forwarded-for"]?.split(/, /)?.[0] || req.socket.remoteAddress,
memcache: null,
statusCode: 200,
headers: req.headers,
responseHeaders: { "content-type": "text/html; charset=utf-8" }
};
const htmlParts = await render(req.originalUrl, { req, res: response, context });
const html = teleportsInject(buildHtml(template, htmlParts), htmlParts.teleports);
response.statusCode = context.statusCode;
Object.keys(context.responseHeaders).map((key) => response.setHeader(key, context.responseHeaders[key]));
response.end(html);
} catch (e) {
server.ssrFixStacktrace(e);
} finally {
replaceEnteryPoint(server, options.name, options.wrappers.client);
}
};
};
const rollupBuild = async (config, options, {
clientOptions = {},
serverOptions = {}
} = {}) => {
const clientBuildOptions = vite.mergeConfig(
{
build: {
isBuild: true,
outDir: path.resolve(config.root, "dist/client"),
ssrManifest: true
}
},
clientOptions
);
const clientResult = await vite.build(clientBuildOptions);
const indexHtml = clientResult.output.find(
(file) => file.type === "asset" && file.fileName === "index.html"
);
const entry = options.ssr || entryFromTemplate(
await fs.promises.readFile(path.resolve(config.root, "index.html"), "utf-8")
);
if (!entry) {
throw new Error("Entry point not found");
}
const entryResolved = path.join(
config.root,
entry
);
const html = buildHtml(
indexHtml.source
);
const serverBuildOptions = vite.mergeConfig(
{
build: {
isBuild: true,
outDir: path.resolve(config.root, "dist/server"),
ssr: entryResolved,
rollupOptions: {
plugins: [
replace({
preventAssignment: true,
values: {
__VITE_SSR_VUE_HTML__: html
}
})
]
}
}
},
serverOptions
);
await vite.build(serverBuildOptions);
if (options.custom) {
const chunks = Object.keys(options.custom);
for (const chunk of chunks) {
const entryResolved2 = path.join(config.root, options.custom[chunk]);
const buildOptions = vite.mergeConfig({
build: {
isBuild: true,
outDir: path.resolve(config.root, `dist/${chunk}`),
ssr: entryResolved2
}
}, serverOptions);
await vite.build(buildOptions);
}
}
await fs.promises.unlink(path.join(clientBuildOptions.build?.outDir, "index.html")).catch(() => null);
const type = serverBuildOptions.build?.rollupOptions?.output?.format === "es" ? "module" : "commonjs";
let pkg = {};
try {
pkg = JSON.parse((await fs.promises.readFile(path.resolve(config.root, "./package.json"))).toString());
} catch (e) {
}
const packageJson = {
type,
version: pkg.version || "",
main: path.parse(serverBuildOptions.build?.ssr).name + ".js",
ssr: {
// This can be used later to serve static assets
assets: (await fs.promises.readdir(clientBuildOptions.build?.outDir)).filter((file) => !/(index\.html|manifest\.json)$/i.test(file))
},
...serverBuildOptions.packageJson || {}
};
await fs.promises.writeFile(
path.join(serverBuildOptions.build?.outDir, "package.json"),
JSON.stringify(packageJson, null, 2)
);
};
var plugin = (opt = {}) => {
const options = opt;
options.name = options.name || "vite-ssr-vue";
options.wrappers = {
client: `${options.name}/client`,
server: `${options.name}/server`
};
return {
name: options.name,
config() {
return {
ssr: {
noExternal: [options.name]
}
};
},
async configResolved(config) {
config.optimizeDeps.include = config.optimizeDeps.include || [];
config.optimizeDeps.include.push(
options.wrappers.client,
options.wrappers.server
);
if (config.command === "build") {
config.resolve.alias.push({
find: new RegExp(`^${options.name}$`),
replacement: config.build.ssr ? options.wrappers.server : options.wrappers.client
});
if (!config.build.isBuild) {
await rollupBuild(config, options);
process.exit(0);
}
} else {
config.resolve.alias.push({
find: new RegExp(`^${options.name}$`),
replacement: options.wrappers.client
});
config.logger.info("\n --- SSR ---\n");
}
},
async configureServer(server) {
const handler = opt.serve ? opt.serve(server, options) : createHandler(server, options);
return () => server.middlewares.use(handler);
}
};
};
module.exports = plugin;