[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' $cachedVendorPath = Join-Path $frontendOutputRoot 'vendor' 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" $jsCacheValid = Test-CacheFingerprint ` -FingerprintPath $jsFingerprintPath ` -Expected $jsFingerprint ` -RequiredOutputs @( (Join-Path $frontendOutputRoot 'js/auth/common.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 ($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/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) { $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) } }