CaiWuXiTong/scripts/build-frontend.ps1
2026-09-28 10:35:50 +08:00

386 lines
16 KiB
PowerShell

[CmdletBinding()]
param(
[string]$NodeVersion = '20.20.2',
[ValidateRange(1024, 8192)]
[int]$MaxOldSpaceSizeMB = 4096,
[ValidateRange(1, 8)]
[int]$TerserWorkers = 2,
[switch]$PublishLocal
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$toolBase = if ($IsWindows) { $env:LOCALAPPDATA } else { Join-Path $HOME '.cache' }
$toolRoot = Join-Path $toolBase 'CaiWuXiTong/tools'
$nodePlatform = if ($IsMacOS) {
if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [Runtime.InteropServices.Architecture]::Arm64) { 'darwin-arm64' } else { 'darwin-x64' }
} elseif ($IsLinux) {
if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [Runtime.InteropServices.Architecture]::Arm64) { 'linux-arm64' } else { 'linux-x64' }
} else { 'win-x64' }
$nodeFolderName = "node-v$NodeVersion-$nodePlatform"
$nodeRoot = Join-Path $toolRoot $nodeFolderName
$nodeExe = if ($IsWindows) { Join-Path $nodeRoot 'node.exe' } else { Join-Path $nodeRoot 'bin/node' }
$npmCmd = if ($IsWindows) { Join-Path $nodeRoot 'npm.cmd' } else { Join-Path $nodeRoot 'bin/npm' }
$archiveName = if ($IsWindows) { "$nodeFolderName.zip" } else { "$nodeFolderName.tar.gz" }
$archivePath = Join-Path $toolRoot $archiveName
$checksumPath = Join-Path $toolRoot 'SHASUMS256.txt'
$trackedCssPath = Join-Path $repoRoot 'public/css/app.css'
$publicJsPath = Join-Path $repoRoot 'public/js'
$publicManifestPath = Join-Path $repoRoot 'public/mix-manifest.json'
$frontendCacheRoot = Join-Path $repoRoot '.caiwu-build'
$frontendOutputRoot = Join-Path $frontendCacheRoot 'public'
$jsFingerprintPath = Join-Path $frontendCacheRoot 'frontend-js.sha'
$cssFingerprintPath = Join-Path $frontendCacheRoot 'frontend-css.sha'
$dependencyFingerprintPath = Join-Path $frontendCacheRoot 'node-modules.sha'
$jsManifestCachePath = Join-Path $frontendCacheRoot 'manifest-js.json'
$cssManifestCachePath = Join-Path $frontendCacheRoot 'manifest-css.json'
$cachedVendorPath = Join-Path $frontendOutputRoot 'vendor'
$outputFingerprintPath = Join-Path $frontendCacheRoot 'frontend-output.sha'
function Invoke-Native {
param(
[Parameter(Mandatory = $true)]
[scriptblock]$Command,
[Parameter(Mandatory = $true)]
[string]$FailureMessage
)
& $Command
if ($LASTEXITCODE -ne 0) {
throw $FailureMessage
}
}
function Get-PathFingerprint {
param([Parameter(Mandatory = $true)][string[]]$Paths)
$files = foreach ($relativePath in $Paths) {
$fullPath = Join-Path $repoRoot $relativePath
if (Test-Path -LiteralPath $fullPath -PathType Leaf) {
Get-Item -LiteralPath $fullPath
}
elseif (Test-Path -LiteralPath $fullPath -PathType Container) {
Get-ChildItem -LiteralPath $fullPath -Recurse -File
}
else {
throw "Frontend fingerprint path does not exist: $relativePath"
}
}
[string[]]$fullPaths = @($files | ForEach-Object { $_.FullName })
[Array]::Sort($fullPaths, [StringComparer]::Ordinal)
$fingerprintLines = foreach ($fullPath in $fullPaths) {
$relativePath = $fullPath.Substring($repoRoot.Length).TrimStart('\', '/').Replace('\', '/')
$hash = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant()
"$relativePath|$hash"
}
$bytes = [Text.Encoding]::UTF8.GetBytes(($fingerprintLines -join "`n"))
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
return (($sha256.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '')
}
finally {
$sha256.Dispose()
}
}
function Test-CacheFingerprint {
param(
[Parameter(Mandatory = $true)][string]$FingerprintPath,
[Parameter(Mandatory = $true)][string]$Expected,
[Parameter(Mandatory = $true)][string[]]$RequiredOutputs
)
if (-not (Test-Path -LiteralPath $FingerprintPath)) {
return $false
}
foreach ($output in $RequiredOutputs) {
if (-not (Test-Path -LiteralPath $output)) {
return $false
}
}
return (Get-Content -Raw -LiteralPath $FingerprintPath).Trim() -eq $Expected
}
function Write-Utf8NoBom {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Value
)
[IO.File]::WriteAllText($Path, $Value, [Text.UTF8Encoding]::new($false))
}
function Publish-FrontendCache {
$cachedJsPath = Join-Path $frontendOutputRoot 'js'
$cachedCssPath = Join-Path $frontendOutputRoot 'css/app.css'
$cachedManifestPath = Join-Path $frontendOutputRoot 'mix-manifest.json'
foreach ($requiredPath in @($cachedJsPath, $cachedCssPath, $cachedManifestPath)) {
if (-not (Test-Path -LiteralPath $requiredPath)) {
throw "Frontend cache output is missing: $requiredPath"
}
}
if (Test-Path -LiteralPath $publicJsPath) {
Remove-Item -LiteralPath $publicJsPath -Recurse -Force
}
Copy-Item -LiteralPath $cachedJsPath -Destination $publicJsPath -Recurse
Copy-Item -LiteralPath $cachedCssPath -Destination $trackedCssPath -Force
Copy-Item -LiteralPath $cachedManifestPath -Destination $publicManifestPath -Force
}
$packageLockHash = Get-PathFingerprint @('package-lock.json')
$jsFingerprintInputs = @(
'resources/assets/js',
'package.json',
'package-lock.json',
'scripts/build-frontend.ps1',
'webpack.mix.js'
)
$cssFingerprintInputs = @(
'resources/assets/sass',
'resources/views',
'resources/lang',
'package.json',
'package-lock.json',
'webpack.mix.js',
'tailwind.config.js',
'presets.js',
'safelist.txt',
'composer.lock'
)
$jsFingerprint = "$NodeVersion|$TerserWorkers|$(Get-PathFingerprint $jsFingerprintInputs)"
$cssFingerprint = "$NodeVersion|$(Get-PathFingerprint $cssFingerprintInputs)"
$dependencyFingerprint = "$NodeVersion|$packageLockHash"
$outputCacheValid = (Test-Path -LiteralPath $outputFingerprintPath) -and
(Test-Path -LiteralPath $frontendOutputRoot) -and
((Get-Content -Raw -LiteralPath $outputFingerprintPath).Trim() -eq
(Get-PathFingerprint @('.caiwu-build/public')))
$jsCacheValid = Test-CacheFingerprint `
-FingerprintPath $jsFingerprintPath `
-Expected $jsFingerprint `
-RequiredOutputs @(
(Join-Path $frontendOutputRoot 'js/auth/common.min.js'),
(Join-Path $frontendOutputRoot 'js/business/pages.min.js'),
(Join-Path $frontendOutputRoot 'js/business/marketing-account-bills.min.js'),
(Join-Path $frontendOutputRoot 'js/manifest.js'),
(Join-Path $frontendOutputRoot 'js/vendor.js'),
(Join-Path $cachedVendorPath 'apexcharts/apexcharts.min.js'),
(Join-Path $cachedVendorPath 'js-cookie/js.cookie.js'),
(Join-Path $cachedVendorPath 'quicksand/css/quicksand.css'),
(Join-Path $cachedVendorPath 'quicksand/files/quicksand-latin-400-normal.woff2'),
$jsManifestCachePath
)
$cssCacheValid = Test-CacheFingerprint `
-FingerprintPath $cssFingerprintPath `
-Expected $cssFingerprint `
-RequiredOutputs @(
(Join-Path $frontendOutputRoot 'css/app.css'),
$cssManifestCachePath
)
if (-not $outputCacheValid) {
$jsCacheValid = $false
$cssCacheValid = $false
Write-Host 'Frontend output integrity is missing or changed. Rebuilding assets.'
}
if ($jsCacheValid -and $cssCacheValid) {
Write-Host 'Frontend inputs unchanged. Reusing JavaScript, CSS, and dependency caches.'
if ($PublishLocal) {
Publish-FrontendCache
Write-Host 'Published cached frontend assets to the local public directory.'
}
return
}
if (-not (Test-Path -LiteralPath $nodeExe)) {
New-Item -ItemType Directory -Force -Path $toolRoot | Out-Null
try {
$downloadRoot = "https://nodejs.org/dist/v$NodeVersion"
Invoke-WebRequest -Uri "$downloadRoot/$archiveName" -OutFile $archivePath
Invoke-WebRequest -Uri "$downloadRoot/SHASUMS256.txt" -OutFile $checksumPath
$checksumLine = Select-String -Path $checksumPath -Pattern ([regex]::Escape($archiveName) + '$') |
Select-Object -First 1
if (-not $checksumLine) {
throw 'The Node archive is missing its official SHA-256 checksum.'
}
$expectedHash = ($checksumLine.Line -split '\s+')[0].ToLowerInvariant()
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archivePath).Hash.ToLowerInvariant()
if ($actualHash -ne $expectedHash) {
throw 'The Node archive SHA-256 checksum verification failed.'
}
if ($IsWindows) {
Expand-Archive -LiteralPath $archivePath -DestinationPath $toolRoot -Force
} else {
Invoke-Native { tar -xzf $archivePath -C $toolRoot } 'The Node archive could not be extracted.'
}
}
finally {
if (Test-Path -LiteralPath $archivePath) {
Remove-Item -LiteralPath $archivePath -Force
}
if (Test-Path -LiteralPath $checksumPath) {
Remove-Item -LiteralPath $checksumPath -Force
}
}
}
if (-not (Test-Path -LiteralPath $nodeExe) -or -not (Test-Path -LiteralPath $npmCmd)) {
throw "The project-local Node $NodeVersion installation is incomplete."
}
$originalPath = $env:PATH
$originalNodeOptions = $env:NODE_OPTIONS
$originalBuildTarget = $env:CAIWU_BUILD_TARGET
$originalTerserWorkers = $env:CAIWU_TERSER_WORKERS
$trackedCssBackup = if (Test-Path -LiteralPath $trackedCssPath) {
[IO.File]::ReadAllBytes($trackedCssPath)
}
else {
$null
}
Push-Location $repoRoot
try {
$env:PATH = "$(if ($IsWindows) { $nodeRoot } else { Join-Path $nodeRoot 'bin' })$([IO.Path]::PathSeparator)$originalPath"
$env:NODE_OPTIONS = "--max-old-space-size=$MaxOldSpaceSizeMB"
$env:CAIWU_TERSER_WORKERS = $TerserWorkers.ToString()
Invoke-Native { & $nodeExe --version } 'The project-local Node executable failed.'
$dependencyCacheValid = `
(Test-Path -LiteralPath (Join-Path $repoRoot 'node_modules/.package-lock.json')) -and `
(Test-Path -LiteralPath $dependencyFingerprintPath) -and `
((Get-Content -Raw -LiteralPath $dependencyFingerprintPath).Trim() -eq $dependencyFingerprint)
if ($dependencyCacheValid) {
Write-Host 'package-lock.json unchanged. Reusing node_modules.'
}
else {
Invoke-Native {
& $npmCmd ci --prefer-offline --no-audit --no-fund
} 'Frontend dependency installation failed.'
New-Item -ItemType Directory -Force -Path $frontendCacheRoot | Out-Null
Write-Utf8NoBom -Path $dependencyFingerprintPath -Value $dependencyFingerprint
}
if (-not $jsCacheValid) {
Write-Host "Building JavaScript with a ${MaxOldSpaceSizeMB}MB Node heap and $TerserWorkers minifier workers."
if (Test-Path -LiteralPath $publicJsPath) {
Remove-Item -LiteralPath $publicJsPath -Recurse -Force
}
$env:CAIWU_BUILD_TARGET = 'js'
Invoke-Native { & $npmCmd run production } 'Frontend JavaScript build failed.'
foreach ($output in @(
'public/js/auth/common.min.js',
'public/js/business/marketing-account-bills.min.js',
'public/js/manifest.js',
'public/js/vendor.js',
'public/mix-manifest.json'
)) {
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot $output))) {
throw "JavaScript build output is missing: $output"
}
}
$cachedJsPath = Join-Path $frontendOutputRoot 'js'
if (Test-Path -LiteralPath $cachedJsPath) {
Remove-Item -LiteralPath $cachedJsPath -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $frontendOutputRoot | Out-Null
Copy-Item -LiteralPath $publicJsPath -Destination $frontendOutputRoot -Recurse
if (Test-Path -LiteralPath $cachedVendorPath) {
Remove-Item -LiteralPath $cachedVendorPath -Recurse -Force
}
New-Item -ItemType Directory -Force -Path `
(Join-Path $cachedVendorPath 'apexcharts'), `
(Join-Path $cachedVendorPath 'js-cookie'), `
(Join-Path $cachedVendorPath 'quicksand/css'), `
(Join-Path $cachedVendorPath 'quicksand/files') | Out-Null
Copy-Item `
-LiteralPath (Join-Path $repoRoot 'node_modules/apexcharts/dist/apexcharts.min.js') `
-Destination (Join-Path $cachedVendorPath 'apexcharts/apexcharts.min.js') `
-Force
Copy-Item `
-LiteralPath (Join-Path $repoRoot 'node_modules/js-cookie/dist/js.cookie.min.js') `
-Destination (Join-Path $cachedVendorPath 'js-cookie/js.cookie.js') `
-Force
Copy-Item `
-LiteralPath (Join-Path $repoRoot 'node_modules/@fontsource/quicksand/files') `
-Destination (Join-Path $cachedVendorPath 'quicksand') `
-Recurse `
-Force
$quicksandCss = foreach ($weight in @(400, 500, 600, 700)) {
(Get-Content `
-Raw `
-LiteralPath (Join-Path $repoRoot "node_modules/@fontsource/quicksand/$weight.css")
).Replace('url(./files/', 'url(../files/')
}
Write-Utf8NoBom `
-Path (Join-Path $cachedVendorPath 'quicksand/css/quicksand.css') `
-Value ($quicksandCss -join "`n")
Copy-Item -LiteralPath $publicManifestPath -Destination $jsManifestCachePath -Force
Write-Utf8NoBom -Path $jsFingerprintPath -Value $jsFingerprint
}
if (-not $cssCacheValid) {
Write-Host "Building CSS with a ${MaxOldSpaceSizeMB}MB Node heap."
$env:CAIWU_BUILD_TARGET = 'css'
Invoke-Native { & $npmCmd run production } 'Frontend CSS build failed.'
foreach ($output in @('public/css/app.css', 'public/mix-manifest.json')) {
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot $output))) {
throw "CSS build output is missing: $output"
}
}
New-Item -ItemType Directory -Force -Path (Join-Path $frontendOutputRoot 'css') | Out-Null
Copy-Item -LiteralPath $trackedCssPath -Destination (Join-Path $frontendOutputRoot 'css/app.css') -Force
Copy-Item -LiteralPath $publicManifestPath -Destination $cssManifestCachePath -Force
Write-Utf8NoBom -Path $cssFingerprintPath -Value $cssFingerprint
}
$mergedManifest = [ordered]@{}
foreach ($manifestPath in @($jsManifestCachePath, $cssManifestCachePath)) {
$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json
foreach ($property in $manifest.PSObject.Properties) {
if ($manifestPath -eq $jsManifestCachePath -and $property.Name -notlike '*.js') { continue }
if ($manifestPath -eq $cssManifestCachePath -and $property.Name -notlike '*.css') { continue }
$mergedManifest[$property.Name] = $property.Value
}
}
$mergedManifestJson = $mergedManifest | ConvertTo-Json
Write-Utf8NoBom -Path (Join-Path $frontendOutputRoot 'mix-manifest.json') -Value $mergedManifestJson
Write-Utf8NoBom -Path $publicManifestPath -Value $mergedManifestJson
if ($jsFingerprint -ne "$NodeVersion|$TerserWorkers|$(Get-PathFingerprint $jsFingerprintInputs)" -or
$cssFingerprint -ne "$NodeVersion|$(Get-PathFingerprint $cssFingerprintInputs)") {
throw 'Frontend sources changed during build. Retry from a stable checkout.'
}
Write-Utf8NoBom -Path $outputFingerprintPath -Value (Get-PathFingerprint @('.caiwu-build/public'))
}
finally {
Pop-Location
$env:PATH = $originalPath
$env:NODE_OPTIONS = $originalNodeOptions
$env:CAIWU_BUILD_TARGET = $originalBuildTarget
$env:CAIWU_TERSER_WORKERS = $originalTerserWorkers
if (-not $PublishLocal -and $null -ne $trackedCssBackup) {
[IO.File]::WriteAllBytes($trackedCssPath, $trackedCssBackup)
}
}