hypertwist/website/scripts/render-same-origin-bundle-lib.mjs
2026-06-22 07:53:11 +00:00

337 lines
13 KiB
JavaScript

import fs from 'node:fs'
import path from 'node:path'
import { buildRuntimeReadinessReport, formatRuntimeReadinessReport } from './runtime-readiness-lib.mjs'
import {
renderNginxConfig,
renderSystemdService,
resolveDeploymentRenderOptions,
} from './render-same-origin-deployment-lib.mjs'
function normalizeTrimmed(value) {
return String(value || '').trim()
}
function normalizeBoolean(value, fallback = false) {
if (typeof value === 'boolean') {
return value
}
const normalized = normalizeTrimmed(value).toLowerCase()
if (!normalized) {
return fallback
}
return normalized === 'true' || normalized === '1' || normalized === 'yes'
}
function normalizeServerNames(serverNames) {
const names = Array.isArray(serverNames)
? serverNames
: String(serverNames || '').split(',')
return names
.map((value) => normalizeTrimmed(value))
.filter(Boolean)
}
function requireNonEmpty(value, label) {
const normalized = normalizeTrimmed(value)
if (!normalized) {
throw new Error(`${label} is required.`)
}
return normalized
}
function resolvePublicOrigin(manifest = {}, serverNames = []) {
const explicitOrigin = normalizeTrimmed(manifest.publicOrigin || '')
if (explicitOrigin) {
return requireNonEmpty(explicitOrigin, 'publicOrigin')
}
const primaryServerName = serverNames[0] || 'hypertwist.app'
return `https://${primaryServerName}`
}
function normalizeDeploymentTier(value, fallback = 'launch') {
const normalized = normalizeTrimmed(value).toLowerCase()
if (normalized === 'preview' || normalized === 'launch') {
return normalized
}
return fallback
}
function requireNonEmptyForTier(value, label, deploymentTier) {
const normalized = normalizeTrimmed(value)
if (!normalized && deploymentTier !== 'preview') {
throw new Error(`${label} is required.`)
}
return normalized
}
function quoteEnvValue(value) {
const normalized = String(value ?? '')
if (!normalized) {
return ''
}
if (/[ \t#{}[\]"']/.test(normalized)) {
return `'${normalized.replace(/'/g, "'\\''")}'`
}
return normalized
}
function serializeEnvEntries(entries) {
return Object.entries(entries)
.map(([key, value]) => `${key}=${quoteEnvValue(value)}`)
.join('\n')
.concat('\n')
}
export function loadBundleManifest(filePath) {
const absolutePath = path.resolve(filePath)
const raw = fs.readFileSync(absolutePath, 'utf8')
return {
path: absolutePath,
manifest: JSON.parse(raw),
}
}
export function resolveBundleManifest(manifest = {}) {
const server = manifest.server || {}
const windowsRelease = manifest.windowsRelease || {}
const serviceUser = normalizeTrimmed(manifest.serviceUser || 'hypertwist')
const serviceGroup = normalizeTrimmed(manifest.serviceGroup || serviceUser)
const serverNames = normalizeServerNames(manifest.serverNames)
const deploymentTier = normalizeDeploymentTier(manifest.deploymentTier || server.deploymentTier, 'launch')
return {
checkoutRoot: requireNonEmpty(manifest.checkoutRoot, 'checkoutRoot'),
serviceUser,
serviceGroup,
deploymentTier,
publicOrigin: resolvePublicOrigin(manifest, serverNames),
serverNames: serverNames.length > 0 ? serverNames : undefined,
certificateName: normalizeTrimmed(manifest.certificateName || ''),
supportEmail: requireNonEmpty(manifest.supportEmail, 'supportEmail'),
publicDocsUrl: requireNonEmpty(manifest.publicDocsUrl, 'publicDocsUrl'),
releaseNotesUrl: requireNonEmpty(manifest.releaseNotesUrl, 'releaseNotesUrl'),
correspondingSourceUrl: requireNonEmpty(manifest.correspondingSourceUrl, 'correspondingSourceUrl'),
openSourceRepoUrl: requireNonEmpty(manifest.openSourceRepoUrl, 'openSourceRepoUrl'),
operatorCheckoutUrl: requireNonEmptyForTier(manifest.operatorCheckoutUrl, 'operatorCheckoutUrl', deploymentTier),
studioCheckoutUrl: normalizeTrimmed(manifest.studioCheckoutUrl || ''),
planPriceOperator: normalizeTrimmed(manifest.planPriceOperator || 'Launch pricing via Paddle'),
planPriceStudio: normalizeTrimmed(manifest.planPriceStudio || 'Contact for launch readiness'),
windowsDownloadUrl: requireNonEmptyForTier(manifest.windowsDownloadUrl, 'windowsDownloadUrl', deploymentTier),
releaseManifestVersion: requireNonEmptyForTier(manifest.releaseManifestVersion, 'releaseManifestVersion', deploymentTier),
releaseManifestChannel: normalizeTrimmed(manifest.releaseManifestChannel || 'preview'),
windowsRelease: {
buildId: requireNonEmptyForTier(windowsRelease.buildId, 'windowsRelease.buildId', deploymentTier),
sha256: requireNonEmptyForTier(windowsRelease.sha256, 'windowsRelease.sha256', deploymentTier),
fileSizeBytes: String(windowsRelease.fileSizeBytes ?? ''),
publishedAt: requireNonEmptyForTier(windowsRelease.publishedAt, 'windowsRelease.publishedAt', deploymentTier),
},
server: {
port: normalizeTrimmed(server.port || '3011'),
deploymentTier,
superTokensCoreUri: normalizeTrimmed(server.superTokensCoreUri || 'http://127.0.0.1:3567'),
apiBasePath: normalizeTrimmed(server.apiBasePath || '/auth'),
websiteBasePath: normalizeTrimmed(server.websiteBasePath || '/auth'),
cookieSecure: normalizeBoolean(server.cookieSecure, true),
serveStaticWebsite: normalizeBoolean(server.serveStaticWebsite, true),
websiteDistPath: normalizeTrimmed(server.websiteDistPath || ''),
githubClientId: normalizeTrimmed(server.githubClientId || ''),
githubClientSecret: normalizeTrimmed(server.githubClientSecret || ''),
googleClientId: normalizeTrimmed(server.googleClientId || ''),
googleClientSecret: normalizeTrimmed(server.googleClientSecret || ''),
defaultPlan: normalizeTrimmed(server.defaultPlan || 'free'),
defaultRole: normalizeTrimmed(server.defaultRole || 'operator'),
healthTimeoutMs: normalizeTrimmed(server.healthTimeoutMs || '2000'),
desktopLinkRateLimitMax: normalizeTrimmed(server.desktopLinkRateLimitMax || '5'),
desktopLinkRateLimitWindowMs: normalizeTrimmed(server.desktopLinkRateLimitWindowMs || '900000'),
paddleWebhookSecret: requireNonEmptyForTier(server.paddleWebhookSecret, 'server.paddleWebhookSecret', deploymentTier),
paddleWebhookToleranceMs: normalizeTrimmed(server.paddleWebhookToleranceMs || '5000'),
billingStatePath: normalizeTrimmed(server.billingStatePath || '/var/lib/hypertwist/auth/hypertwist-billing-state.json'),
paddleProductPlanMap: server.paddleProductPlanMap || {},
paddlePricePlanMap: server.paddlePricePlanMap || {},
},
}
}
export function buildFrontendEnvEntries(manifest = {}) {
const resolved = resolveBundleManifest(manifest)
return {
VITE_PUBLIC_DEPLOYMENT_TIER: resolved.deploymentTier,
VITE_SUPERTOKENS_API_DOMAIN: resolved.publicOrigin,
VITE_SUPERTOKENS_WEBSITE_DOMAIN: resolved.publicOrigin,
VITE_SUPERTOKENS_API_BASE_PATH: resolved.server.apiBasePath,
VITE_SUPERTOKENS_WEBSITE_BASE_PATH: resolved.server.websiteBasePath,
VITE_AUTH_API_BASE_URL: resolved.publicOrigin,
VITE_AUTH_API_TIMEOUT_MS: '8000',
VITE_GITHUB_OAUTH_ENABLED: String(Boolean(resolved.server.githubClientId && resolved.server.githubClientSecret)),
VITE_GOOGLE_OAUTH_ENABLED: String(Boolean(resolved.server.googleClientId && resolved.server.googleClientSecret)),
VITE_ORCID_OAUTH_ENABLED: 'false',
VITE_PUBLIC_DOCS_URL: resolved.publicDocsUrl,
VITE_RELEASE_NOTES_URL: resolved.releaseNotesUrl,
VITE_SUPPORT_EMAIL: resolved.supportEmail,
VITE_WINDOWS_DOWNLOAD_URL: resolved.windowsDownloadUrl,
VITE_MAC_DOWNLOAD_URL: '',
VITE_LINUX_DOWNLOAD_URL: '',
VITE_PADDLE_CHECKOUT_URL_OPERATOR: resolved.operatorCheckoutUrl,
VITE_PADDLE_CHECKOUT_URL_STUDIO: resolved.studioCheckoutUrl,
VITE_PLAN_PRICE_OPERATOR: resolved.planPriceOperator,
VITE_PLAN_PRICE_STUDIO: resolved.planPriceStudio,
VITE_MPL_SOURCE_URL: resolved.correspondingSourceUrl,
VITE_OPEN_SOURCE_REPO_URL: resolved.openSourceRepoUrl,
}
}
export function buildServerEnvEntries(manifest = {}) {
const resolved = resolveBundleManifest(manifest)
return {
DEPLOYMENT_TIER: resolved.deploymentTier,
PORT: resolved.server.port,
SUPERTOKENS_CORE_URI: resolved.server.superTokensCoreUri,
API_DOMAIN: resolved.publicOrigin,
WEBSITE_DOMAIN: resolved.publicOrigin,
API_BASE_PATH: resolved.server.apiBasePath,
WEBSITE_BASE_PATH: resolved.server.websiteBasePath,
COOKIE_SECURE: String(resolved.server.cookieSecure),
SERVE_STATIC_WEBSITE: String(resolved.server.serveStaticWebsite),
WEBSITE_DIST_PATH: resolved.server.websiteDistPath,
GITHUB_CLIENT_ID: resolved.server.githubClientId,
GITHUB_CLIENT_SECRET: resolved.server.githubClientSecret,
GOOGLE_CLIENT_ID: resolved.server.googleClientId,
GOOGLE_CLIENT_SECRET: resolved.server.googleClientSecret,
DEFAULT_PLAN: resolved.server.defaultPlan,
DEFAULT_ROLE: resolved.server.defaultRole,
SUPERTOKENS_HEALTH_TIMEOUT_MS: resolved.server.healthTimeoutMs,
DESKTOP_LINK_RATE_LIMIT_MAX: resolved.server.desktopLinkRateLimitMax,
DESKTOP_LINK_RATE_LIMIT_WINDOW_MS: resolved.server.desktopLinkRateLimitWindowMs,
PADDLE_WEBHOOK_SECRET: resolved.server.paddleWebhookSecret,
PADDLE_WEBHOOK_TOLERANCE_MS: resolved.server.paddleWebhookToleranceMs,
BILLING_STATE_PATH: resolved.server.billingStatePath,
PADDLE_PRODUCT_PLAN_MAP: JSON.stringify(resolved.server.paddleProductPlanMap),
PADDLE_PRICE_PLAN_MAP: JSON.stringify(resolved.server.paddlePricePlanMap),
SUPPORT_EMAIL: resolved.supportEmail,
PUBLIC_DOCS_URL: resolved.publicDocsUrl,
RELEASE_NOTES_URL: resolved.releaseNotesUrl,
MPL_SOURCE_URL: resolved.correspondingSourceUrl,
OPEN_SOURCE_REPO_URL: resolved.openSourceRepoUrl,
RELEASE_MANIFEST_VERSION: resolved.releaseManifestVersion,
RELEASE_MANIFEST_CHANNEL: resolved.releaseManifestChannel,
WINDOWS_DOWNLOAD_URL: resolved.windowsDownloadUrl,
WINDOWS_RELEASE_BUILD_ID: resolved.windowsRelease.buildId,
WINDOWS_RELEASE_SHA256: resolved.windowsRelease.sha256,
WINDOWS_RELEASE_FILE_SIZE_BYTES: resolved.windowsRelease.fileSizeBytes,
WINDOWS_RELEASE_PUBLISHED_AT: resolved.windowsRelease.publishedAt,
MACOS_DOWNLOAD_URL: '',
MACOS_RELEASE_BUILD_ID: '',
MACOS_RELEASE_SHA256: '',
MACOS_RELEASE_FILE_SIZE_BYTES: '',
MACOS_RELEASE_PUBLISHED_AT: '',
LINUX_DOWNLOAD_URL: '',
LINUX_RELEASE_BUILD_ID: '',
LINUX_RELEASE_SHA256: '',
LINUX_RELEASE_FILE_SIZE_BYTES: '',
LINUX_RELEASE_PUBLISHED_AT: '',
}
}
export function buildBundleValidationReport(manifest = {}) {
const resolved = resolveBundleManifest(manifest)
const frontendEnv = buildFrontendEnvEntries(resolved)
const serverEnv = buildServerEnvEntries(resolved)
return buildRuntimeReadinessReport({
frontendEnv,
serverEnv,
deploymentTier: resolved.deploymentTier,
liveHealth: null,
liveHealthAttempted: false,
liveReleaseManifest: null,
liveReleaseManifestAttempted: false,
liveWebsiteShell: null,
liveWebsiteShellAttempted: false,
})
}
export function renderSameOriginBundle(manifest = {}) {
const resolved = resolveBundleManifest(manifest)
const frontendEnv = buildFrontendEnvEntries(resolved)
const serverEnv = buildServerEnvEntries(resolved)
const deploymentOptions = resolveDeploymentRenderOptions({
checkoutRoot: resolved.checkoutRoot,
serviceUser: resolved.serviceUser,
serviceGroup: resolved.serviceGroup,
serverNames: resolved.serverNames,
certificateName: resolved.certificateName,
upstreamPort: resolved.server.port,
})
const validationReport = buildRuntimeReadinessReport({
frontendEnv,
serverEnv,
deploymentTier: resolved.deploymentTier,
liveHealth: null,
liveHealthAttempted: false,
liveReleaseManifest: null,
liveReleaseManifestAttempted: false,
liveWebsiteShell: null,
liveWebsiteShellAttempted: false,
})
if (!validationReport.ok) {
throw new Error(`Rendered deployment bundle is not runtime-ready.\n${formatRuntimeReadinessReport(validationReport)}`)
}
return {
deploymentOptions,
frontendEnv,
serverEnv,
frontendEnvContent: serializeEnvEntries(frontendEnv),
serverEnvContent: serializeEnvEntries(serverEnv),
systemdContent: renderSystemdService(deploymentOptions),
nginxContent: renderNginxConfig(deploymentOptions),
validationReport,
}
}
export function writeSameOriginBundle({
manifest,
outDir,
}) {
const rendered = renderSameOriginBundle(manifest)
const outputDir = path.resolve(outDir)
fs.mkdirSync(outputDir, { recursive: true })
const frontendEnvPath = path.join(outputDir, 'website.env')
const serverEnvPath = path.join(outputDir, 'server.env')
const systemdPath = path.join(outputDir, 'hypertwist-website-auth-server.service')
const nginxPath = path.join(outputDir, 'hypertwist.app.conf')
const summaryPath = path.join(outputDir, 'bundle-summary.json')
fs.writeFileSync(frontendEnvPath, rendered.frontendEnvContent, 'utf8')
fs.writeFileSync(serverEnvPath, rendered.serverEnvContent, 'utf8')
fs.writeFileSync(systemdPath, rendered.systemdContent, 'utf8')
fs.writeFileSync(nginxPath, rendered.nginxContent, 'utf8')
fs.writeFileSync(summaryPath, `${JSON.stringify({
frontendEnvPath,
serverEnvPath,
systemdPath,
nginxPath,
validationReport: rendered.validationReport,
}, null, 2)}\n`, 'utf8')
return {
...rendered,
outputDir,
frontendEnvPath,
serverEnvPath,
systemdPath,
nginxPath,
summaryPath,
}
}