CaiWuXiTong/scripts/build-frontend.ps1
2026-07-22 15:24:44 +08:00

315 lines
12 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
$toolRoot = Join-Path $env:LOCALAPPDATA 'CaiWuXiTong\tools'
$nodeFolderName = "node-v$NodeVersion-win-x64"
$nodeRoot = Join-Path $toolRoot $nodeFolderName
$nodeExe = Join-Path $nodeRoot 'node.exe'
$npmCmd = Join-Path $nodeRoot 'npm.cmd'
$archiveName = "$nodeFolderName.zip"
$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'
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',
'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"
$jsCacheValid = Test-CacheFingerprint `
-FingerprintPath $jsFingerprintPath `
-Expected $jsFingerprint `
-RequiredOutputs @(
(Join-Path $frontendOutputRoot 'js/auth/common.min.js'),
(Join-Path $frontendOutputRoot 'js/manifest.js'),
(Join-Path $frontendOutputRoot 'js/vendor.js'),
$jsManifestCachePath
)
$cssCacheValid = Test-CacheFingerprint `
-FingerprintPath $cssFingerprintPath `
-Expected $cssFingerprint `
-RequiredOutputs @(
(Join-Path $frontendOutputRoot 'css/app.css'),
$cssManifestCachePath
)
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.'
}
Expand-Archive -LiteralPath $archivePath -DestinationPath $toolRoot -Force
}
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 = "$nodeRoot;$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/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
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) {
$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
}
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)
}
}