315 lines
12 KiB
PowerShell
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)
|
|
}
|
|
}
|