# 本地预览服务器 (零依赖 TcpListener + Runspace 池并发): 服务在线站点静态目录 # 用法: powershell -NoProfile -ExecutionPolicy Bypass -File tools\serve-web.ps1 [-Root release\web_2026.08] [-Port 8080] # 并发: 每个请求一个 Runspace (池上限 16), 大文件 (wasm/图片) 不再阻塞小文件 (step 下载/页面) param( [string]$Root = (Join-Path $PSScriptRoot '..\release\web_2026.08'), [int]$Port = 8080, [string]$ViewerRoot = (Join-Path $PSScriptRoot '..\viewer') # /viewer/ 前缀 → 新迪查看器本地副本 (可选; 原项目 Z:\...\上海新迪3D\dev 只读) ) if (-not (Test-Path $Root)) { Write-Output "目录不存在: $Root"; exit 1 } $viewerRootFull = '' if ($ViewerRoot -and (Test-Path $ViewerRoot)) { $viewerRootFull = [System.IO.Path]::GetFullPath($ViewerRoot) Write-Output ("查看器目录: " + $viewerRootFull) } $mime = @{ '.html' = 'text/html; charset=utf-8'; '.js' = 'application/javascript; charset=utf-8' '.css' = 'text/css; charset=utf-8'; '.json' = 'application/json; charset=utf-8' '.png' = 'image/png'; '.jpg' = 'image/jpeg'; '.pdf' = 'application/pdf' '.step' = 'application/octet-stream'; '.ico' = 'image/x-icon'; '.txt' = 'text/plain; charset=utf-8' '.wasm' = 'application/wasm'; '.stp' = 'application/octet-stream' } $rootFull = [System.IO.Path]::GetFullPath($Root) # 单请求处理逻辑 (自包含, 在 Runspace 中执行) $handlerText = @' param($client, $rootFull, $viewerRootFull, $mime) try { $stream = $client.GetStream() $stream.ReadTimeout = 2000 # 预连接/空连接快速失败, 不阻塞后续请求 $stream.WriteTimeout = 30000 $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::ASCII, $false, 4096, $true) $reqLine = $reader.ReadLine() if ($null -eq $reqLine) { $client.Close(); return } # 客户端连上不发数据, 直接断开 # 读掉请求头 (EOF 即 null 时必须退出, 否则死循环) $line = $reader.ReadLine() $guard = 0 while ($line -ne $null -and $line -ne '') { $line = $reader.ReadLine() $guard++ if ($guard -gt 100) { break } } $path = '/' if ($reqLine) { $parts = $reqLine -split ' ' if ($parts.Count -ge 2) { $path = $parts[1] } } # 去掉查询串 (?v= / ?file= 等), 否则会进文件路径导致非法字符异常 $qi = $path.IndexOf('?') if ($qi -ge 0) { $path = $path.Substring(0, $qi) } if ($path -eq '/') { $path = '/index.html' } $full = [System.IO.Path]::GetFullPath((Join-Path $rootFull ($path -replace '/', '\').TrimStart('\'))) $bytes = $null; $status = '200 OK'; $ctype = 'application/octet-stream' # /viewer/ 前缀 → 新迪 3D 查看器根目录 (各自防目录穿越) $rootNow = $rootFull $rel = ($path -replace '/', '\').TrimStart('\') if ($viewerRootFull -and $rel.StartsWith('viewer\', [System.StringComparison]::OrdinalIgnoreCase)) { $rootNow = $viewerRootFull $rel = $rel.Substring('viewer\'.Length) $full = [System.IO.Path]::GetFullPath((Join-Path $rootNow $rel)) } if ($full.StartsWith($rootNow, [System.StringComparison]::OrdinalIgnoreCase) -and (Test-Path $full -PathType Leaf)) { $bytes = [System.IO.File]::ReadAllBytes($full) $ext = [System.IO.Path]::GetExtension($full).ToLowerInvariant() if ($mime.ContainsKey($ext)) { $ctype = $mime[$ext] } } else { $status = '404 Not Found' $bytes = [System.Text.Encoding]::UTF8.GetBytes('404 Not Found') $ctype = 'text/plain; charset=utf-8' } $head = "HTTP/1.1 $status`r`nContent-Type: $ctype`r`nCache-Control: no-cache`r`nContent-Length: $($bytes.Length)`r`nConnection: close`r`n`r`n" $headBytes = [System.Text.Encoding]::ASCII.GetBytes($head) $stream.Write($headBytes, 0, $headBytes.Length) $stream.Write($bytes, 0, $bytes.Length) $stream.Flush() } catch { } $client.Close() '@ # Runspace 池 (并发上限 16) $pool = [runspacefactory]::CreateRunspacePool(1, 16) $pool.Open() $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port) $listener.Start() Write-Output ("服务已启动: http://localhost:" + $Port + " (停止: 关闭本窗口)") while ($true) { $client = $null try { $client = $listener.AcceptTcpClient() } catch { # 监听异常不退出进程 (端口占用等), 2 秒后重试 Start-Sleep -Seconds 2 continue } try { $ps = [powershell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($handlerText) [void]$ps.AddArgument($client) [void]$ps.AddArgument($rootFull) [void]$ps.AddArgument($viewerRootFull) [void]$ps.AddArgument($mime) [void]$ps.BeginInvoke() } catch { $client.Close() } }