@underpostnet/underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
1,093 lines (979 loc) • 42.1 kB
JavaScript
/**
* Manages the client-side build process, including full builds and incremental builds.
* @module src/client-builder/client-build.js
* @namespace clientBuild
*/
;
import fs from 'fs-extra';
import { transformClientJs, JSONweb } from './client-formatted.js';
import { loggerFactory } from '../server/logger.js';
import {
getCapVariableName,
newInstance,
orderArrayFromAttrInt,
uniqueArray,
} from '../client/components/core/CommonJs.js';
import { readConfJson } from '../server/conf.js';
import { minify } from 'html-minifier-terser';
import AdmZip from 'adm-zip';
import * as dir from 'path';
import { shellExec } from '../server/process.js';
import { SitemapStream, streamToPromise } from 'sitemap';
import { Readable } from 'stream';
import { buildIcons } from './client-icons.js';
import Underpost from '../index.js';
import { buildDocs } from './client-build-docs.js';
import { ssrFactory } from './ssr.js';
// Static Site Generation (SSG)
/**
* Recursively copies files from source to destination, but only files that don't exist in destination.
* @function copyNonExistingFiles
* @param {string} src - Source directory path
* @param {string} dest - Destination directory path
* @returns {void}
* @memberof clientBuild
*/
const copyNonExistingFiles = (src, dest) => {
if (dir.basename(src) === '.git') return;
// Ensure source exists
if (!fs.existsSync(src)) {
throw new Error(`Source directory does not exist: ${src}`);
}
// Get stats for source
const srcStats = fs.statSync(src);
// If source is a file, copy only if it doesn't exist in destination
if (srcStats.isFile()) {
if (!fs.existsSync(dest)) {
const destDir = dir.dirname(dest);
fs.mkdirSync(destDir, { recursive: true });
fs.copyFileSync(src, dest);
}
return;
}
// If source is a directory, create destination if it doesn't exist
if (srcStats.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
// Read all items in source directory
const items = fs.readdirSync(src);
// Recursively process each item
for (const item of items) {
const srcPath = dir.join(src, item);
const destPath = dir.join(dest, item);
copyNonExistingFiles(srcPath, destPath);
}
}
};
const splitFileByMb = ({ filePath, partSizeMb, logger }) => {
const partSizeBytes = Math.floor(Number(partSizeMb) * 1024 * 1024);
if (!Number.isFinite(partSizeBytes) || partSizeBytes <= 0) {
throw new Error(`Invalid --split value: ${partSizeMb}`);
}
// Clean ALL stale part files (any naming variant) before writing new ones
const zipDir = dir.dirname(filePath);
const zipBase = dir.basename(filePath);
if (fs.existsSync(zipDir)) {
fs.readdirSync(zipDir)
.filter((name) => name.startsWith(`${zipBase}.part`) || name.startsWith(`${zipBase}-part`))
.forEach((name) => fs.removeSync(dir.join(zipDir, name)));
}
const fileBuffer = fs.readFileSync(filePath);
const partPaths = [];
for (let offset = 0, partIndex = 0; offset < fileBuffer.length; offset += partSizeBytes, partIndex++) {
const partBuffer = fileBuffer.subarray(offset, offset + partSizeBytes);
const partPath = `${filePath}.part${String(partIndex + 1).padStart(3, '0')}`;
fs.writeFileSync(partPath, partBuffer);
partPaths.push(partPath);
}
logger.warn('split zip', {
filePath,
partSizeMb: Number(partSizeMb),
parts: partPaths.length,
});
return partPaths;
};
const getZipPartPaths = (zipPath) => {
const zipDir = dir.dirname(zipPath);
const zipBase = dir.basename(zipPath);
const partPrefixDot = `${zipBase}.part`;
const partPrefixDash = `${zipBase}-part`;
const parsePartIndex = (rawSuffix) => {
// Strip optional .zip suffix added by pull/download (e.g. '001.zip' → '001')
const digits = rawSuffix.replace(/\.zip$/i, '');
return /^\d+$/.test(digits) ? Number(digits) : NaN;
};
const getPartIndex = (name) => {
if (name.startsWith(partPrefixDot)) return parsePartIndex(name.slice(partPrefixDot.length));
if (name.startsWith(partPrefixDash)) return parsePartIndex(name.slice(partPrefixDash.length));
return NaN;
};
return fs
.readdirSync(zipDir)
.filter((name) => Number.isFinite(getPartIndex(name)))
.sort((a, b) => getPartIndex(a) - getPartIndex(b))
.map((name) => dir.join(zipDir, name));
};
const resolveClientBuildZip = (buildPrefix) => {
const normalizedPrefix = buildPrefix.replace(/\.zip(?:[.-]part\d+|[.-]part\*)?$/, '').replace(/[.-]part\*$/, '');
const candidatePrefixes = uniqueArray([
normalizedPrefix,
normalizedPrefix.endsWith('-') ? normalizedPrefix : `${normalizedPrefix}-`,
]);
for (const prefix of candidatePrefixes) {
const zipPath = `${prefix}.zip`;
if (fs.existsSync(zipPath)) {
return {
buildPrefix: prefix,
zipPath,
partPaths: [],
};
}
const partPaths = fs.existsSync(dir.dirname(zipPath)) ? getZipPartPaths(zipPath) : [];
if (partPaths.length > 0) {
return {
buildPrefix: prefix,
zipPath,
partPaths,
};
}
}
const searchDir = dir.dirname(normalizedPrefix);
const prefixBase = dir.basename(normalizedPrefix);
if (!fs.existsSync(searchDir)) {
throw new Error(`Build directory not found: ${searchDir}`);
}
const matches = uniqueArray(
fs
.readdirSync(searchDir)
.filter((name) => name.startsWith(prefixBase) && /\.zip(?:[.-]part\d+)?$/.test(name))
.map((name) => name.replace(/[.-]part\d+$/, '')),
);
if (matches.length === 1) {
const zipPath = dir.join(searchDir, matches[0]);
const partPaths = getZipPartPaths(zipPath);
return {
buildPrefix: zipPath.replace(/\.zip$/, ''),
zipPath,
partPaths,
};
}
if (matches.length > 1) {
throw new Error(
`Multiple build zip matches found for '${buildPrefix}': ${matches.join(', ')}. Use a more specific --unzip path.`,
);
}
throw new Error(`No build zip or split parts found for: ${buildPrefix}`);
};
/**
* Merges split ZIP parts back into a single ZIP file.
* @param {object} options
* @param {string} options.buildPrefix - The build prefix path (e.g. build/underpost.net/underpost.net-).
* @param {object} options.logger - Logger instance.
* @returns {{ zipPath: string, partPaths: string[], mergedBytes: number }}
*/
const mergeClientBuildZip = ({ buildPrefix, logger }) => {
// Normalize to get the zip path, then look for parts directly (bypassing resolveClientBuildZip
// which prefers an existing monolithic zip over parts).
const normalizedPrefix = buildPrefix.replace(/\.zip(?:[.-]part\d+)?$/, '').replace(/[-.]$/, '') + '-';
const candidatePrefixes = uniqueArray([buildPrefix, buildPrefix.endsWith('-') ? buildPrefix : `${buildPrefix}-`]);
let zipPath;
let partPaths = [];
for (const prefix of candidatePrefixes) {
const candidate = prefix.endsWith('.zip') ? prefix : `${prefix}.zip`;
const parts = getZipPartPaths(candidate);
if (parts.length > 0) {
zipPath = candidate;
partPaths = parts;
break;
}
}
if (partPaths.length === 0) {
// Fall back to resolveClientBuildZip for the zipPath
const resolved = resolveClientBuildZip(buildPrefix);
zipPath = resolved.zipPath;
logger.warn('merge-zip: no split parts found, nothing to merge', { buildPrefix, zipPath });
return { zipPath, partPaths, mergedBytes: 0 };
}
// For each part, extract raw bytes: if the part file is a Cloudinary wrapper zip
// (downloaded via pull without --omit-unzip or with --omit-unzip keeping the .zip),
// extract the inner entry rather than using the wrapper bytes.
const readPartBytes = (partPath) => {
const rawBytes = fs.readFileSync(partPath);
// Check for ZIP magic bytes (PK\x03\x04)
if (rawBytes[0] === 0x50 && rawBytes[1] === 0x4b && rawBytes[2] === 0x03 && rawBytes[3] === 0x04) {
try {
const wrapperZip = new AdmZip(rawBytes);
const entries = wrapperZip.getEntries();
// The inner entry is the original part file (without the outer .zip wrapper)
const partBase = dir.basename(partPath).replace(/\.zip$/i, '');
const entry = entries.find((e) => e.entryName === partBase || e.entryName.endsWith('/' + partBase));
if (entry) {
return entry.getData();
}
// Fallback: single-entry archive
if (entries.length === 1) {
return entries[0].getData();
}
} catch (_) {
// Not a valid zip or extraction failed — use raw bytes
}
}
return rawBytes;
};
const mergedBuffer = Buffer.concat(partPaths.map(readPartBytes));
fs.writeFileSync(zipPath, mergedBuffer);
logger.warn('merge-zip: merged split parts into zip', {
zipPath,
parts: partPaths.length,
mergedBytes: mergedBuffer.length,
});
return { zipPath, partPaths, mergedBytes: mergedBuffer.length };
};
const unzipClientBuild = ({ buildPrefix, logger }) => {
const { zipPath, partPaths, buildPrefix: resolvedBuildPrefix } = resolveClientBuildZip(buildPrefix);
const outputPath = resolvedBuildPrefix.replace(/-$/, '');
fs.removeSync(outputPath);
fs.mkdirSync(outputPath, { recursive: true });
const zip =
partPaths.length > 0
? new AdmZip(Buffer.concat(partPaths.map((partPath) => fs.readFileSync(partPath))))
: new AdmZip(zipPath);
zip.extractAllTo(outputPath, true);
logger.warn('unzip build', {
source: partPaths.length > 0 ? partPaths : [zipPath],
outputPath,
splitParts: partPaths.length,
});
return {
outputPath,
zipPath,
partPaths,
};
};
/** @type {string} Default XSL sitemap template used when no `sitemap` source file exists in the public directory. */
const defaultSitemapXsl = `<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
xmlns:html="http://www.w3.org/TR/REC-html40"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
xmlns:sitemap="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
<xsl:template match="/">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>XML Sitemap</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style type="text/css">
body {
font-family: sans-serif;
font-size: 16px;
color: #242628;
}
a {
color: #000;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
table {
border: none;
border-collapse: collapse;
width: 100%
}
th {
text-align: left;
padding-right: 30px;
font-size: 11px;
}
thead th {
border-bottom: 1px solid #7d878a;
cursor: pointer;
}
td {
font-size:11px;
padding: 5px;
}
tr:nth-child(odd) td {
background-color: rgba(0,0,0,0.04);
}
tr:hover td {
background-color: #e2edf2;
}
#content {
margin: 0 auto;
padding: 2% 5%;
max-width: 800px;
}
.desc {
margin: 18px 3px;
line-height: 1.2em;
}
.desc a {
color: #5ba4e5;
}
</style>
</head>
<body>
<div id="content">
<h1>XML Sitemap</h1>
<p class="desc"> This is a sitemap generated by <a
href="{{web-url}}">{{web-url}}</a>
</p>
<xsl:if test="count(sitemap:sitemapindex/sitemap:sitemap) > 0">
<table id="sitemap" cellpadding="3">
<thead>
<tr>
<th width="75%">Sitemap</th>
<th width="25%">Last Modified</th>
</tr>
</thead>
<tbody>
<xsl:for-each select="sitemap:sitemapindex/sitemap:sitemap">
<xsl:variable name="sitemapURL">
<xsl:value-of select="sitemap:loc" />
</xsl:variable>
<tr>
<td>
<a href="{$sitemapURL}">
<xsl:value-of select="sitemap:loc" />
</a>
</td>
<td>
<xsl:value-of
select="concat(substring(sitemap:lastmod,0,11),concat(' ', substring(sitemap:lastmod,12,5)))" />
</td>
</tr>
</xsl:for-each>
</tbody>
</table>
</xsl:if>
<xsl:if test="count(sitemap:sitemapindex/sitemap:sitemap) < 1">
<p class="desc">
<a href="{{web-url}}sitemap.xml" class="back-link">← Back to index</a>
</p>
<table
id="sitemap" cellpadding="3">
<thead>
<tr>
<th width="70%">URL (<xsl:value-of
select="count(sitemap:urlset/sitemap:url)" /> total)</th>
<th width="15%">Images</th>
<th title="Last Modification Time" width="15%">Last Modified</th>
</tr>
</thead>
<tbody>
<xsl:variable name="lower" select="'abcdefghijklmnopqrstuvwxyz'" />
<xsl:variable name="upper" select="'ABCDEFGHIJKLMNOPQRSTUVWXYZ'" />
<xsl:for-each select="sitemap:urlset/sitemap:url">
<tr>
<td>
<xsl:variable name="itemURL">
<xsl:value-of select="sitemap:loc" />
</xsl:variable>
<a href="{$itemURL}">
<xsl:value-of select="sitemap:loc" />
</a>
</td>
<td>
<xsl:value-of select="count(image:image)" />
</td>
<td>
<xsl:value-of
select="concat(substring(sitemap:lastmod,0,11),concat(' ', substring(sitemap:lastmod,12,5)))" />
</td>
</tr>
</xsl:for-each>
</tbody>
</table>
<p
class="desc">
<a href="{{web-url}}sitemap.xml" class="back-link">← Back to index</a>
</p>
</xsl:if>
</div>
</body>
</html>
</xsl:template>
</xsl:stylesheet>`;
/**
* @async
* @function buildClient
* @memberof clientBuild
* @param {Object} options - Options for the build process.
* @param {string} options.deployId - The deployment ID for which to build the client.
* @param {Array} options.liveClientBuildPaths - List of paths to build incrementally.
* @param {Array} options.instances - List of instances to build.
* @param {boolean} options.buildZip - Whether to create zip files of the builds.
* @param {string|number} options.split - Optional zip split size in MB.
* @param {boolean} options.fullBuild - Whether to perform a full build.
* @param {boolean} options.iconsBuild - Whether to build icons.
* @returns {Promise<void>} - Promise that resolves when the build is complete.
* @throws {Error} - If the build fails.
* @memberof clientBuild
*/
const buildClient = async (
options = {
deployId: '',
liveClientBuildPaths: [],
instances: [],
buildZip: false,
split: '',
fullBuild: false,
iconsBuild: false,
},
) => {
const logger = loggerFactory(import.meta);
const deployId = options.deployId || process.env.DEPLOY_ID;
const confClient = readConfJson(deployId, 'client');
const confServer = readConfJson(deployId, 'server', { loadReplicas: true });
const confSSR = readConfJson(deployId, 'ssr');
const packageData = JSON.parse(fs.readFileSync(`./package.json`, 'utf8'));
const acmeChallengePath = `/.well-known/acme-challenge`;
const publicPath = `./public`;
/**
* @async
* @function buildAcmeChallengePath
* @memberof clientBuild
* @param {string} acmeChallengeFullPath - Full path to the acme-challenge directory.
* @returns {void}
* @throws {Error} - If the directory cannot be created.
* @memberof clientBuild
*/
const buildAcmeChallengePath = (acmeChallengeFullPath = '') => {
fs.mkdirSync(acmeChallengeFullPath, {
recursive: true,
});
fs.writeFileSync(`${acmeChallengeFullPath}/.gitkeep`, '', 'utf8');
};
/**
* @async
* @function fullBuild
* @memberof clientBuild
* @param {Object} options - Options for the full build process.
* @param {string} options.path - Path to the client directory.
* @param {Object} options.logger - Logger instance.
* @param {string} options.client - Client name.
* @param {Object} options.db - Database configuration.
* @param {Array} options.dists - List of distributions to build.
* @param {string} options.rootClientPath - Full path to the client directory.
* @param {string} options.acmeChallengeFullPath - Full path to the acme-challenge directory.
* @param {string} options.publicClientId - Public client ID.
* @param {boolean} options.iconsBuild - Whether to build icons.
* @param {Object} options.metadata - Metadata for the client.
* @param {boolean} options.publicCopyNonExistingFiles - Whether to copy non-existing files from public directory.
* @returns {Promise<void>} - Promise that resolves when the full build is complete.
* @throws {Error} - If the full build fails.
* @memberof clientBuild
*/
const fullBuild = async ({
path,
logger,
client,
db,
dists,
rootClientPath,
acmeChallengeFullPath,
publicClientId,
iconsBuild,
metadata,
publicCopyNonExistingFiles,
}) => {
logger.warn('Full build', rootClientPath);
buildAcmeChallengePath(acmeChallengeFullPath);
fs.removeSync(rootClientPath);
if (fs.existsSync(`./src/client/public/${publicClientId}`)) {
if (iconsBuild === true) await buildIcons({ publicClientId, metadata });
fs.copySync(`./src/client/public/${publicClientId}`, rootClientPath, {
filter: (sourcePath) => !sourcePath.split(dir.sep).includes('.git'),
});
} else if (fs.existsSync(`./engine-private/src/client/public/${publicClientId}`)) {
fs.copySync(`./engine-private/src/client/public/${publicClientId}`, rootClientPath, {
filter: (sourcePath) => !sourcePath.split(dir.sep).includes('.git'),
});
}
if (dists)
for (const dist of dists) {
if ('folder' in dist) {
if (fs.statSync(dist.folder).isDirectory()) {
fs.mkdirSync(`${rootClientPath}${dist.public_folder}`, { recursive: true });
fs.copySync(dist.folder, `${rootClientPath}${dist.public_folder}`);
} else {
const folder = dist.public_folder.split('/');
folder.pop();
fs.mkdirSync(`${rootClientPath}${folder.join('/')}`, { recursive: true });
fs.copyFileSync(dist.folder, `${rootClientPath}${dist.public_folder}`);
}
}
if ('styles' in dist) {
fs.mkdirSync(`${rootClientPath}${dist.public_styles_folder}`, { recursive: true });
fs.copySync(dist.styles, `${rootClientPath}${dist.public_styles_folder}`);
}
}
if (publicCopyNonExistingFiles)
copyNonExistingFiles(`./src/client/public/${publicCopyNonExistingFiles}`, rootClientPath);
};
// { srcBuildPath, publicBuildPath }
const enableLiveRebuild =
options && options.liveClientBuildPaths && options.liveClientBuildPaths.length > 0 ? true : false;
const isDevelopment = process.env.NODE_ENV === 'development';
let currentPort = parseInt(process.env.PORT) + 1;
for (const host of Object.keys(confServer)) {
const paths = orderArrayFromAttrInt(Object.keys(confServer[host]), 'length', 'asc');
for (const path of paths) {
if (
options &&
options.instances &&
options.instances.length > 0 &&
!options.instances.find((i) => i.path === path && i.host === host)
)
continue;
const {
runtime,
client,
directory,
disabledRebuild,
db,
redirect,
apis,
apiBaseProxyPath,
apiBaseHost,
ttiLoadTimeLimit,
singleReplica,
docs,
} = confServer[host][path];
if (singleReplica) continue;
if (!confClient[client]) confClient[client] = {};
const { components, dists, views, services, metadata, publicRef, publicCopyNonExistingFiles } =
confClient[client];
let backgroundImage;
if (metadata) {
backgroundImage = metadata.backgroundImage;
if (metadata.thumbnail) metadata.thumbnail = `${path === '/' ? path : `${path}/`}${metadata.thumbnail}`;
}
const rootClientPath = directory ? directory : `${publicPath}/${host}${path}`;
const port = newInstance(currentPort);
const publicClientId = publicRef ? publicRef : client;
const fullBuildEnabled = options.fullBuild && !enableLiveRebuild;
// const baseHost = process.env.NODE_ENV === 'production' ? `https://${host}` : `http://localhost:${port}`;
const baseHost = process.env.NODE_ENV === 'production' ? `https://${host}` : ``;
const minifyBuild = process.env.NODE_ENV === 'production';
// ''; // process.env.NODE_ENV === 'production' ? `https://${host}` : ``;
currentPort++;
const acmeChallengeFullPath = directory
? `${directory}${acmeChallengePath}`
: `${publicPath}/${host}${acmeChallengePath}`;
if (!enableLiveRebuild) buildAcmeChallengePath(acmeChallengeFullPath);
if (redirect || disabledRebuild) continue;
if (fullBuildEnabled)
await fullBuild({
path,
logger,
client,
db,
dists,
rootClientPath,
acmeChallengeFullPath,
publicClientId,
iconsBuild: options.iconsBuild,
metadata,
publicCopyNonExistingFiles,
});
if (components)
for (const module of Object.keys(components)) {
if (!fs.existsSync(`${rootClientPath}/components/${module}`))
fs.mkdirSync(`${rootClientPath}/components/${module}`, { recursive: true });
for (const component of components[module]) {
const jsSrcPath = `./src/client/components/${module}/${component}.js`;
const jsPublicPath = `${rootClientPath}/components/${module}/${component}.js`;
if (enableLiveRebuild && !options.liveClientBuildPaths.find((p) => p.srcBuildPath === jsSrcPath)) continue;
const jsSrc = await transformClientJs(jsSrcPath, {
dists,
proxyPath: path,
basePath: 'components',
module,
baseHost,
minify: minifyBuild,
});
fs.writeFileSync(jsPublicPath, jsSrc, 'utf8');
}
}
if (services) {
for (const module of services) {
if (!fs.existsSync(`${rootClientPath}/services/${module}`))
fs.mkdirSync(`${rootClientPath}/services/${module}`, { recursive: true });
const moduleDir = `./src/client/services/${module}`;
if (!fs.existsSync(moduleDir)) continue;
const serviceFiles = fs
.readdirSync(moduleDir)
.filter((name) => name.endsWith('.service.js') || name.endsWith('.management.js'))
.sort();
for (const serviceFile of serviceFiles) {
const jsSrcPath = `${moduleDir}/${serviceFile}`;
const jsPublicPath = `${rootClientPath}/services/${module}/${serviceFile}`;
if (enableLiveRebuild && !options.liveClientBuildPaths.find((p) => p.srcBuildPath === jsSrcPath)) continue;
const jsSrc = await transformClientJs(jsSrcPath, {
dists,
proxyPath: path,
basePath: 'services',
module,
baseHost,
minify: minifyBuild,
});
fs.writeFileSync(jsPublicPath, jsSrc, 'utf8');
}
// Auto-build guest module files when user module is processed
if (module === 'user') {
const guestModuleDir = './src/client/services/user';
const guestServicePath = `${guestModuleDir}/guest.service.js`;
if (fs.existsSync(guestServicePath)) {
if (!fs.existsSync(`${rootClientPath}/services/user`))
fs.mkdirSync(`${rootClientPath}/services/user`, { recursive: true });
const guestJsPublicPath = `${rootClientPath}/services/user/guest.service.js`;
if (!enableLiveRebuild || options.liveClientBuildPaths.find((p) => p.srcBuildPath === guestServicePath)) {
const guestJsSrc = await transformClientJs(guestServicePath, {
dists,
proxyPath: path,
basePath: 'services',
module: 'user',
baseHost,
minify: minifyBuild,
});
fs.writeFileSync(guestJsPublicPath, guestJsSrc, 'utf8');
}
}
}
}
}
const buildId = `${client}.index`;
const siteMapLinks = [];
const ssrPath = path === '/' ? path : `${path}/`;
const Render = await ssrFactory();
const swSrcPath = `./src/client/sw/core.sw.js`;
const swPublicPath = `${rootClientPath}/sw.js`;
const swShouldRebuild =
views && !(enableLiveRebuild && !options.liveClientBuildPaths.find((p) => p.srcBuildPath === swSrcPath));
// Transformed SW JS is held in memory; it gets prepended with renderPayload
// and written once below, after PRE_CACHED_RESOURCES are known.
let swTransformedJs = '';
if (swShouldRebuild) {
swTransformedJs = await transformClientJs(swSrcPath, {
dists,
proxyPath: path,
baseHost,
minify: minifyBuild,
externalizeBareImports: false,
});
}
if (views) {
if (
!(
enableLiveRebuild &&
!options.liveClientBuildPaths.find(
(p) => p.srcBuildPath.startsWith(`./src/client/ssr`) || p.srcBuildPath.slice(-9) === '.index.js',
)
)
)
for (const view of views) {
const buildPath = `${
rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath
}${view.path === '/' ? view.path : `${view.path}/`}`;
if (!fs.existsSync(buildPath)) fs.mkdirSync(buildPath, { recursive: true });
logger.info('View build', buildPath);
const jsSrc = await transformClientJs(`./src/client/${view.client}.index.js`, {
dists,
proxyPath: path,
baseHost,
minify: minifyBuild,
});
fs.writeFileSync(`${buildPath}${buildId}.js`, jsSrc, 'utf8');
const title = metadata.title ? metadata.title : title;
const canonicalURL = `https://${host}${path}${
view.path === '/' ? (path === '/' ? '' : '/') : path === '/' ? `${view.path.slice(1)}/` : `${view.path}/`
}`;
let ssrHeadComponents = ``;
let ssrBodyComponents = ``;
if ('ssr' in view) {
// https://metatags.io/
if (process.env.NODE_ENV === 'production' && !confSSR[view.ssr].head.includes('Production'))
confSSR[view.ssr].head.unshift('Production');
for (const ssrHeadComponent of confSSR[view.ssr].head) {
const SrrComponent = await ssrFactory(`./src/client/ssr/head/${ssrHeadComponent}.js`);
switch (ssrHeadComponent) {
case 'Pwa':
const validPwaBuild =
metadata &&
fs.existsSync(`./src/client/public/${publicClientId}/browserconfig.xml`) &&
fs.existsSync(`./src/client/public/${publicClientId}/site.webmanifest`);
if (validPwaBuild) {
// build webmanifest
const webmanifestJson = JSON.parse(
fs.readFileSync(`./src/client/public/${publicClientId}/site.webmanifest`, 'utf8'),
);
if (metadata.title) {
webmanifestJson.name = metadata.title;
webmanifestJson.short_name = metadata.title;
}
if (metadata.description) {
webmanifestJson.description = metadata.description;
}
if (metadata.themeColor) {
webmanifestJson.theme_color = metadata.themeColor;
webmanifestJson.background_color = metadata.themeColor;
}
fs.writeFileSync(
`${buildPath}site.webmanifest`,
JSON.stringify(webmanifestJson, null, 4).replaceAll(`: "/`, `: "${ssrPath}`),
'utf8',
);
// build browserconfig
fs.writeFileSync(
`${buildPath}browserconfig.xml`,
fs
.readFileSync(`./src/client/public/${publicClientId}/browserconfig.xml`, 'utf8')
.replaceAll(
`<TileColor></TileColor>`,
metadata.themeColor
? `<TileColor>${metadata.themeColor}</TileColor>`
: `<TileColor>#e0e0e0</TileColor>`,
)
.replaceAll(`src="/`, `src="${ssrPath}`),
'utf8',
);
// Android play store example:
//
// "related_applications": [
// {
// "platform": "play",
// "url": "https://play.google.com/store/apps/details?id=cheeaun.hackerweb"
// }
// ],
// "prefer_related_applications": true
}
if (validPwaBuild) ssrHeadComponents += SrrComponent({ title, ssrPath, canonicalURL, ...metadata });
break;
case 'Seo':
if (metadata) {
ssrHeadComponents += SrrComponent({ title, ssrPath, canonicalURL, ...metadata });
}
break;
case 'Microdata':
if (
fs.existsSync(`./src/client/public/${publicClientId}/microdata.json`) // &&
// path === '/' &&
// view.path === '/'
) {
const microdata = JSON.parse(
fs.readFileSync(`./src/client/public/${publicClientId}/microdata.json`, 'utf8'),
);
ssrHeadComponents += SrrComponent({ microdata });
}
break;
default:
ssrHeadComponents += SrrComponent({ ssrPath, host, path });
break;
}
}
for (const ssrBodyComponent of confSSR[view.ssr].body) {
const SrrComponent = await ssrFactory(`./src/client/ssr/body/${ssrBodyComponent}.js`);
ssrBodyComponents += SrrComponent({
...metadata,
ssrPath,
host,
path,
ttiLoadTimeLimit,
version: Underpost.version,
backgroundImage: backgroundImage ? (path === '/' ? path : `${path}/`) + backgroundImage : undefined,
});
}
}
/** @type {import('sitemap').SitemapItem} */
const siteMapLink = {
url: `${path === '/' ? '' : path}${view.path}`,
changefreq: 'daily',
priority: 0.8,
};
siteMapLinks.push(siteMapLink);
const htmlSrc = Render({
title,
buildId,
ssrPath,
ssrHeadComponents,
ssrBodyComponents,
renderPayload: {
apiBaseProxyPath,
apiBaseHost,
apiBasePath: process.env.BASE_API,
version: Underpost.version,
...(isDevelopment ? { dev: true } : undefined),
},
renderApi: {
JSONweb,
},
});
fs.writeFileSync(
`${buildPath}index.html`,
minifyBuild
? await minify(htmlSrc, {
minifyCSS: true,
minifyJS: true,
collapseBooleanAttributes: true,
collapseInlineTagWhitespace: true,
collapseWhitespace: true,
})
: htmlSrc,
'utf8',
);
}
}
if (!enableLiveRebuild && siteMapLinks.length > 0) {
const hasSitemapTemplate = fs.existsSync(`${rootClientPath}/sitemap`);
const sitemapBaseUrl = `https://${host}${path === '/' ? '' : path}`;
// Create a stream to write to — omit xslUrl so we can inject a relative href below
/** @type {import('sitemap').SitemapStreamOptions} */
const sitemapOptions = { hostname: `https://${host}` };
const siteMapStream = new SitemapStream(sitemapOptions);
let siteMapSrc = await new Promise((resolve) =>
streamToPromise(Readable.from(siteMapLinks).pipe(siteMapStream)).then((data) => resolve(data.toString())),
);
// Inject a relative xml-stylesheet PI so the XSL loads from the same origin
// (works on both http://localhost:<port> and https://production-host)
siteMapSrc = siteMapSrc.replace(
'<?xml version="1.0" encoding="UTF-8"?>',
'<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet type="text/xsl" href="sitemap.xsl"?>',
);
// Return a promise that resolves with your XML string
fs.writeFileSync(`${rootClientPath}/sitemap.xml`, siteMapSrc, 'utf8');
// Generate XSL stylesheet from source template or default fallback
const xslTemplate = hasSitemapTemplate
? fs.readFileSync(`${rootClientPath}/sitemap`, 'utf8')
: defaultSitemapXsl;
const webUrl = `https://${host}${path === '/' ? '/' : `${path}/`}`;
fs.writeFileSync(`${rootClientPath}/sitemap.xsl`, xslTemplate.replaceAll('{{web-url}}', webUrl), 'utf8');
fs.writeFileSync(
`${rootClientPath}/robots.txt`,
`User-agent: *
Sitemap: ${sitemapBaseUrl}/sitemap.xml`,
'utf8',
);
}
if (fullBuildEnabled && docs) {
await buildDocs({
host,
path,
port,
metadata,
apis,
publicClientId,
rootClientPath,
packageData,
docs,
});
}
if (client) {
const proxyPrefix = path === '/' ? '' : path;
const buildIndexUrl = (routePath) => `${proxyPrefix}${routePath === '/' ? '' : routePath}/index.html`;
// SSR views: a single declarative array. The role of each view (regular
// page vs. offline/maintenance fallback) is expressed by per-entry flags;
// fallback-flagged views are also precached so the SW can serve them
// when the network is unreachable.
const ssrClientConf = confSSR[getCapVariableName(client)] || {};
const ssrViews = Array.isArray(ssrClientConf.views) ? ssrClientConf.views : [];
const PRE_CACHED_RESOURCES = [];
let offlineFallbackUrl = null;
let maintenanceFallbackUrl = null;
for (const view of ssrViews) {
const SsrComponent = await ssrFactory(`./src/client/ssr/views/${view.client}.js`);
const htmlSrc = Render({
title: view.title,
ssrPath,
ssrHeadComponents: '<base target="_top">',
ssrBodyComponents: SsrComponent(),
renderPayload: {
apiBaseProxyPath,
apiBaseHost,
apiBasePath: process.env.BASE_API,
version: Underpost.version,
...(isDevelopment ? { dev: true } : undefined),
},
renderApi: { JSONweb },
});
const buildPath = `${
rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath
}${view.path === '/' ? view.path : `${view.path}/`}`;
const indexUrl = buildIndexUrl(view.path);
if (view.offlineDefault) {
offlineFallbackUrl = indexUrl;
PRE_CACHED_RESOURCES.push(indexUrl);
}
if (view.maintenanceDefault) {
maintenanceFallbackUrl = indexUrl;
PRE_CACHED_RESOURCES.push(indexUrl);
}
if (!fs.existsSync(buildPath)) fs.mkdirSync(buildPath, { recursive: true });
const buildHtmlPath = `${buildPath}index.html`;
logger.info('ssr view build', buildHtmlPath);
fs.writeFileSync(
buildHtmlPath,
minifyBuild
? await minify(htmlSrc, {
minifyCSS: true,
minifyJS: true,
collapseBooleanAttributes: true,
collapseInlineTagWhitespace: true,
collapseWhitespace: true,
})
: htmlSrc,
'utf8',
);
}
if (swShouldRebuild) {
const cacheScope = path === '/' ? 'root' : path.replaceAll('/', '_');
const renderPayload = {
PRE_CACHED_RESOURCES: uniqueArray(PRE_CACHED_RESOURCES),
PROXY_PATH: path,
CACHE_PREFIX: `engine-core-${cacheScope}`,
OFFLINE_URL: offlineFallbackUrl || buildIndexUrl('/offline'),
MAINTENANCE_URL: maintenanceFallbackUrl || buildIndexUrl('/maintenance'),
};
// Single write: prepend the payload prelude to the transformed SW JS.
fs.writeFileSync(
swPublicPath,
`self.renderPayload = ${JSONweb(renderPayload)};
self.__WB_DISABLE_DEV_LOGS = true;
${swTransformedJs}`,
'utf8',
);
}
}
if (!enableLiveRebuild && options.buildZip) {
logger.warn('build zip', rootClientPath);
if (!fs.existsSync('./build')) fs.mkdirSync('./build');
const zip = new AdmZip();
const files = await fs.readdir(rootClientPath, { recursive: true });
for (const relativePath of files) {
const filePath = dir.resolve(`${rootClientPath}/${relativePath}`);
if (!fs.lstatSync(filePath).isDirectory()) {
const folder = dir.relative(`public/${host}${path}`, dir.dirname(filePath));
zip.addLocalFile(filePath, folder);
}
}
const buildId = `${host}-${path.replaceAll('/', '')}`;
const zipPath = `./build/${buildId}.zip`;
logger.warn('write zip', zipPath);
zip.writeZip(zipPath);
if (options.split) {
splitFileByMb({
filePath: zipPath,
partSizeMb: options.split,
logger,
});
fs.removeSync(zipPath);
logger.warn('removed original zip after split', { zipPath });
}
}
}
}
};
export { buildClient, copyNonExistingFiles, unzipClientBuild, mergeClientBuildZip };