UNPKG

vite-ssr-vue

Version:

Vite utility for vue3 server side rendering

269 lines (259 loc) 8.4 kB
'use strict'; 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;