param( [Parameter(Mandatory = $true)][string]$WebRoot, [Parameter(Mandatory = $true)][string]$ListenHost, [Parameter(Mandatory = $true)][int]$ListenPort, [Parameter(Mandatory = $true)][string]$BackendUrl ) Set-StrictMode -Version 3.0 $ErrorActionPreference = 'Stop' $root = [IO.Path]::GetFullPath($WebRoot).TrimEnd('\') + '\' $listener = [Net.HttpListener]::new() $listener.Prefixes.Add("http://$ListenHost`:$ListenPort/") $handler = [Net.Http.HttpClientHandler]::new() $handler.UseProxy = $false $client = [Net.Http.HttpClient]::new($handler) $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'; '.svg'='image/svg+xml'; '.png'='image/png'; '.jpg'='image/jpeg'; '.jpeg'='image/jpeg'; '.gif'='image/gif'; '.ico'='image/x-icon'; '.woff'='font/woff'; '.woff2'='font/woff2'; '.ttf'='font/ttf'; '.eot'='application/vnd.ms-fontobject' } try { $listener.Start() Write-Host "Bell web listening at http://$ListenHost`:$ListenPort/" while ($listener.IsListening) { $context = $listener.GetContext() try { $request = $context.Request $response = $context.Response $path = $request.Url.AbsolutePath if ($path -eq '/healthz' -or $path.StartsWith('/api/')) { $target = "$BackendUrl$($request.Url.PathAndQuery)" $message = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($request.HttpMethod), $target) if ($request.HasEntityBody) { $memory = [IO.MemoryStream]::new() $request.InputStream.CopyTo($memory) $message.Content = [Net.Http.ByteArrayContent]::new($memory.ToArray()) $memory.Dispose() } foreach ($key in $request.Headers.AllKeys) { if ($key -in @('Host','Content-Length')) { continue } $values = $request.Headers.GetValues($key) if (-not $message.Headers.TryAddWithoutValidation($key, $values) -and $null -ne $message.Content) { [void]$message.Content.Headers.TryAddWithoutValidation($key, $values) } } $upstream = $client.SendAsync($message).GetAwaiter().GetResult() $bytes = $upstream.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() $response.StatusCode = [int]$upstream.StatusCode if ($upstream.Content.Headers.ContentType) { $response.ContentType = $upstream.Content.Headers.ContentType.ToString() } $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) $message.Dispose(); $upstream.Dispose() } else { $relative = [Uri]::UnescapeDataString($path.TrimStart('/')).Replace('/', '\') if ([string]::IsNullOrWhiteSpace($relative)) { $relative = 'index.html' } $file = [IO.Path]::GetFullPath((Join-Path $root $relative)) if (-not $file.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) { $response.StatusCode = 403 } elseif (-not (Test-Path -LiteralPath $file -PathType Leaf)) { $file = Join-Path $root 'index.html' } if ($response.StatusCode -ne 403) { $bytes = [IO.File]::ReadAllBytes($file) $extension = [IO.Path]::GetExtension($file).ToLowerInvariant() $response.ContentType = $(if ($mime.ContainsKey($extension)) { $mime[$extension] } else { 'application/octet-stream' }) $response.ContentLength64 = $bytes.Length $response.OutputStream.Write($bytes, 0, $bytes.Length) } } } catch { try { $context.Response.StatusCode = 502; $bytes = [Text.Encoding]::UTF8.GetBytes('Bell web gateway error'); $context.Response.ContentLength64 = $bytes.Length; $context.Response.OutputStream.Write($bytes,0,$bytes.Length) } catch {} } finally { try { $context.Response.OutputStream.Close() } catch {} } } } finally { $client.Dispose(); $handler.Dispose(); try { $listener.Stop() } catch {}; $listener.Close() }