# Lokaler HTTP-Server (System.Net.HttpListener) # Single-threaded request loop - reicht fuer einen Admin-Nutzer. function Start-WebServer { param([int]$Port = 8077) $listener = New-Object System.Net.HttpListener $prefix = "http://localhost:$Port/" $listener.Prefixes.Add($prefix) try { $listener.Start() } catch { Write-Host "Server konnte nicht gestartet werden auf $prefix" -ForegroundColor Red Write-Host "Moeglicherweise ist der Port belegt oder es fehlen Rechte." -ForegroundColor Red Write-Host "Tipp: anderen Port mit -Port 8088 versuchen, oder als Admin starten." -ForegroundColor Yellow Write-Host $_.Exception.Message -ForegroundColor Red return } Write-Host "Server laeuft. Warte auf Requests..." -ForegroundColor Green Write-Host "" while ($listener.IsListening) { try { $context = $listener.GetContext() # blockiert try { Invoke-Router -Context $context } catch { Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red try { Send-JsonResponse -Context $context -StatusCode 500 -Body @{ error = $_.Exception.Message stack = $_.ScriptStackTrace } } catch {} } } catch [System.Net.HttpListenerException] { break } catch { Write-Host "[FATAL] $_" -ForegroundColor Red } } $listener.Stop() $listener.Close() } function Send-JsonResponse { param( [Parameter(Mandatory=$true)]$Context, [int]$StatusCode = 200, $Body = $null ) $response = $Context.Response try { $response.StatusCode = $StatusCode $response.ContentType = "application/json; charset=utf-8" $response.Headers.Add("Cache-Control", "no-store") } catch { # Headers schon gesendet — Client wahrscheinlich schon weg return } $sw = [System.Diagnostics.Stopwatch]::StartNew() $json = if ($null -eq $Body) { "{}" } else { $Body | ConvertTo-Json -Depth 12 -Compress } $sw.Stop() if ($sw.ElapsedMilliseconds -gt 100) { Write-Host " [JSON] Serialize $($json.Length) bytes in $($sw.ElapsedMilliseconds)ms (ACHTUNG > 100ms)" -ForegroundColor Yellow } $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) try { $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.OutputStream.Close() } catch [System.Net.HttpListenerException], [System.IO.IOException], [System.ObjectDisposedException] { # Client hat die Verbindung abgebrochen (AbortController, Tab geschlossen, etc.) — irrelevant } catch { # Andere Fehler still mitloggen Write-Host " [WRITE] Response-Schreiben fehlgeschlagen: $($_.Exception.GetType().Name): $($_.Exception.Message)" -ForegroundColor DarkYellow } } function Send-FileResponse { param( [Parameter(Mandatory=$true)]$Context, [Parameter(Mandatory=$true)][string]$FilePath, [string]$ContentType = $null, [hashtable]$ExtraHeaders = $null ) $response = $Context.Response if (-not (Test-Path $FilePath)) { $response.StatusCode = 404 $response.OutputStream.Close() return } if (-not $ContentType) { $ContentType = switch ([IO.Path]::GetExtension($FilePath).ToLower()) { ".html" { "text/html; charset=utf-8" } ".js" { "application/javascript; charset=utf-8" } ".css" { "text/css; charset=utf-8" } ".json" { "application/json; charset=utf-8" } ".svg" { "image/svg+xml" } ".png" { "image/png" } ".jpg" { "image/jpeg" } ".jpeg" { "image/jpeg" } ".webp" { "image/webp" } ".gif" { "image/gif" } ".ico" { "image/x-icon" } ".woff2"{ "font/woff2" } ".md" { "text/markdown; charset=utf-8" } default { "application/octet-stream" } } } $bytes = [IO.File]::ReadAllBytes($FilePath) $response.ContentType = $ContentType # Standard-Cache-Header: HTML/JS/CSS NICHT cachen (single-user Dev-Tool, # Performance-Verlust irrelevant, dafuer immer aktueller Code im Browser). # Bilder/Fonts cachen aber muessen — sonst flackert das Logo bei jedem Klick. $ext = [IO.Path]::GetExtension($FilePath).ToLower() $cacheCtl = if ($ext -in @('.html','.htm','.js','.css','.json')) { 'no-store, no-cache, must-revalidate, max-age=0' } else { 'no-cache, must-revalidate' } if ($ExtraHeaders -and $ExtraHeaders.ContainsKey('Cache-Control')) { $cacheCtl = $ExtraHeaders['Cache-Control'] } $response.Headers.Add('Cache-Control', $cacheCtl) if ($ExtraHeaders) { foreach ($k in $ExtraHeaders.Keys) { if ($k -eq 'Cache-Control') { continue } # bereits gesetzt try { $response.Headers.Add($k, [string]$ExtraHeaders[$k]) } catch {} } } $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $response.OutputStream.Close() } function Read-RequestBody { param([Parameter(Mandatory=$true)]$Context) $request = $Context.Request if (-not $request.HasEntityBody) { return $null } $reader = New-Object System.IO.StreamReader($request.InputStream, $request.ContentEncoding) $body = $reader.ReadToEnd() $reader.Close() if ([string]::IsNullOrWhiteSpace($body)) { return $null } # -AsHashtable gibt es erst ab PowerShell 6 — Windows PowerShell 5.1 kennt es nicht. # Die Endpoints behandeln sowohl Hashtable als auch PSCustomObject korrekt. if ($PSVersionTable.PSVersion.Major -ge 6) { return ($body | ConvertFrom-Json -AsHashtable) } return ($body | ConvertFrom-Json) }