Bempus-WebServer.psm1
|
$webservers = [System.Collections.ArrayList]::new() class ContentType { hidden [string]$value static [string]$TextPlain = "text/plain" static [string]$TextHTML = "text/html" static [string]$TextCSS = "text/css" static [string]$TextCSV = "text/csv" static [string]$TextJavascript = "text/javascript" static [string]$TextXML = "text/xml" static [string]$TextMarkdown = "text/markdown" static [string]$ApplicationJSON = "application/json" static [string]$ApplicationXML = "application/xml" static [string]$ApplicationPDF = "application/pdf" static [string]$ApplicationZip = "application/zip" static [string]$ApplicationGzip = "application/gzip" static [string]$ApplicationOctetStream = "application/octet-stream" static [string]$ApplicationXhtml = "application/xhtml+xml" static [string]$ApplicationSql = "application/sql" static [string]$ApplicationWasm = "application/wasm" static [string]$ImgPng = "image/png" static [string]$ImgJpeg = "image/jpeg" static [string]$ImgGif = "image/gif" static [string]$ImgWebp = "image/webp" static [string]$ImgSvg = "image/svg+xml" static [string]$ImgBmp = "image/bmp" static [string]$ImgIco = "image/vnd.microsoft.icon" static [string]$ImgTiff = "image/tiff" static [string]$AudioMpeg = "audio/mpeg" static [string]$AudioOgg = "audio/ogg" static [string]$AudioWav = "audio/wav" static [string]$AudioWebm = "audio/webm" static [string]$AudioAac = "audio/aac" static [string]$VideoMp4 = "video/mp4" static [string]$VideoWebm = "video/webm" static [string]$VideoOgg = "video/ogg" static [string]$VideoMpeg = "video/mpeg" static [string]$VideoQuicktime = "video/quicktime" static [string]$MsWord = "application/msword" static [string]$MsWordDocx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" static [string]$MsExcel = "application/vnd.ms-excel" static [string]$MsExcelXlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" static [string]$MsPowerpoint = "application/vnd.ms-powerpoint" static [string]$MsPowerpointPptx = "application/vnd.openxmlformats-officedocument.presentationml.presentation" static [string]$FontWoff = "font/woff" static [string]$FontWoff2 = "font/woff2" static [string]$FontTtf = "font/ttf" static [string]$FontOtf = "font/otf" ContentType([string] $value) { $this.Value = $value } static [ContentType] FromExt([string]$ext, [bool]$allowDefault) { $ct = switch ($ext.TrimStart('.')) { 'html' { [ContentType]::TextHTML } 'htm' { [ContentType]::TextHTML } 'txt' { [ContentType]::TextPlain } 'css' { [ContentType]::TextCSS } 'js' { [ContentType]::TextJavascript } 'mjs' { [ContentType]::TextJavascript } 'csv' { [ContentType]::TextCSV } 'xml' { [ContentType]::TextXML } 'md' { [ContentType]::TextMarkdown } 'json' { [ContentType]::AppJSON } 'pdf' { [ContentType]::AppPDF } 'zip' { [ContentType]::AppZip } 'gz' { [ContentType]::AppGzip } 'xhtml' { [ContentType]::AppXhtml } 'sql' { [ContentType]::AppSql } 'wasm' { [ContentType]::AppWasm } 'png' { [ContentType]::ImgPng } { $_ -match 'jpe?g' } { [ContentType]::ImgJpeg } 'gif' { [ContentType]::ImgGif } 'webp' { [ContentType]::ImgWebp } 'svg' { [ContentType]::ImgSvg } 'ico' { [ContentType]::ImgIco } 'bmp' { [ContentType]::ImgBmp } { $_ -match 'tiff?' } { [ContentType]::ImgTiff } 'mp3' { [ContentType]::AudioMpeg } 'wav' { [ContentType]::AudioWav } { $_ -match 'og[ag]' } { [ContentType]::AudioOgg } 'weba' { [ContentType]::AudioWebm } 'aac' { [ContentType]::AudioAac } 'mp4' { [ContentType]::VideoMp4 } 'webm' { [ContentType]::VideoWebm } 'ogv' { [ContentType]::VideoOgg } 'mpeg' { [ContentType]::VideoMpeg } 'mov' { [ContentType]::VideoQuicktime } 'doc' { [ContentType]::MsWord } 'docx' { [ContentType]::MsWordDocx } 'xls' { [ContentType]::MsExcel } 'xlsx' { [ContentType]::MsExcelXlsx } 'ppt' { [ContentType]::MsPowerpoint } 'pptx' { [ContentType]::MsPowerpointPptx } 'woff' { [ContentType]::FontWoff } 'woff2' { [ContentType]::FontWoff2 } 'ttf' { [ContentType]::FontTtf } 'otf' { [ContentType]::FontOtf } default { if (-not $allowDefault) { throw "Unknown file extension: $ext" } [ContentType]::AppOctetStream } } return $ct } static [ContentType] FromExt([string]$ext) { return [ContentType]::FromExt($ext, $false) } static [ContentType] FromUrl([string]$url) { $ext = $url | Select-String -Pattern '\..{2,4}$' | Select-Object -ExpandProperty Matches -First 1 if (-not $ext.value) { return [ContentType]::textHTML } return [ContentType]::FromExt($ext.value, $true) } [string] ToString() { $allowedList = [ContentType] | Get-Member -Static -MemberType Property | ForEach-Object { [ContentType]::($_.Name) } if ($this.value -notin $allowedList) { throw "'$($this.Value)' is not a valid ContentType." } return $allowedList | Where-Object { $_ -eq $this.value } } } enum Method { GET POST PUT PATCH DELETE } class WebServer { # Core Infrastructure hidden [bool] $h_isDisposed = $false hidden [System.Net.HttpListener] $h_listener = [System.Net.HttpListener]::new() hidden [int16] $h_port = 9001 hidden [string] $h_basePath = ((Get-Location).ProviderPath) hidden [System.Management.Automation.Runspaces.RunspacePool] $h_pool = $null hidden [System.Collections.Generic.List[scriptblock]] $h_backgroundScripts = [System.Collections.Generic.List[scriptblock]]::new() # State & Data Collections hidden [hashtable] $h_endpoints = [hashtable]::new() hidden [hashtable] $h_store = [hashtable]::new() hidden [System.Collections.Generic.List[object]] $h_jobs = $null # File System & Temp Storage hidden [string] $h_pswsTempDir = $null WebServer([int16]$port, [string]$basePath) { $this.SetWebserver($port, $basePath) } WebServer([string]$basePath) { $this.SetWebserver($this.h_port, $basePath) } WebServer([int16]$port) { $this.SetWebserver($port, $this.h_basePath) } WebServer() { $this.SetWebserver($this.h_port, $this.h_basePath) } [void]SetWebserver([int16]$port, [string]$basePath) { if (-not ($basePath | Test-Path -PathType Container)) { throw "'$basePath' is not a directory, make sure the path exists" } $this.h_port = $port if ($basePath) { $this.setRoot($basePath) } $this.h_pool = [runspacefactory]::CreateRunspacePool(1, 10) $this.h_pool.Open() $this.h_jobs = [System.Collections.Generic.List[object]]::new() $this.GetClosestPort($this.h_port) $this.CreateListener() $this.PSObject.Properties.Add(([psscriptproperty]::new('AppRoot', { $this.h_basePath }))) $this.PSObject.Properties.Add(([psscriptproperty]::new('Port', { $this.h_port }))) $this.PSObject.Properties.Add(([psscriptproperty]::new('BackgroundScripts', { $this.h_backgroundScripts }))) $this.PSObject.Properties.Add(([psscriptproperty]::new('Disposed', { $this.h_isDisposed }))) } [void] setRoot([string]$path) { if (-not (Test-Path $path -PathType Container)) { Write-Error -Message "No folder with the name of '$($path)'" return } $this.h_basePath = (Get-Item -Path $path).FullName } [void]setPort([int16]$port) { if ($this.h_port -eq $port) { Write-Host "Port is already set to $port, skipping" return } $this.h_port = $port } [void]RegisterBackgroundScript([scriptblock]$scriptBlock) { $this.h_backgroundScripts.Add($scriptBlock) } [void] hidden GetClosestPort([int16]$port) { if (Get-NetTCPConnection | Where-Object LocalPort -EQ $port) { Write-Warning "Port $port is already in use, finding the next available" while (Get-NetTCPConnection | Where-Object LocalPort -EQ $port) { $port++ } } if ($this.h_port -ne $port) { $this.h_port = $port Write-Host "New port: $port" } } [void] hidden GetClosestPort() { $this.GetClosestPort($this.h_port) } [int16]FindAndSetClosestPort() { $this.GetClosestPort() return $this.h_port } [hashtable] GetEndpoint([string]$name) { $name = $name -replace '^/?', '/' if (-not $this.h_endpoints.$name) { return $null } return $this.h_endpoints.$name } [void] SetEndpoint([string]$name, [scriptblock]$callBack, [Method[]]$methods, [string]$contentType) { $name = $name -replace '^/?', '/' $this.h_endpoints.$name = @{ callback = $callBack methods = $methods ContentType = $contentType } } [void] ListEndpoints([string]$type, [bool]$includeStatic) { $files = Get-ChildItem -Path $this.h_basePath -Recurse -File $staticFiles = @() $pages = @() $api = @() foreach ($file in $files) { $relativePath = $file.FullName -replace [Regex]::Escape($this.h_basePath) -replace '\\', '/' if ($relativePath -match "^/Pages/.*\.html?$") { $pages += $relativePath -replace '/Pages(.*?)(?:index)?\.html?$', '$1' continue } if ($relativePath -match "^/API/.*\.ps[me]?1$") { $api += $relativePath -replace "\.ps[me]?1$" continue } $staticFiles += $relativePath } foreach ($key in $this.h_endpoints.Keys) { if ($key -match '^/API/.*ps[me]?1') { $api += $key continue } $pages += $key } if ($type -in ('All', 'Pages')) { $pagesText = if ($pages.Count) { $pages -join "`n" } else { "No pages found" } Write-Host "Pages:`n$($pagesText)`n" -ForegroundColor Magenta } if ($type -in ('All', 'API')) { $apiText = if ($api.Count) { $api -join "`n" } else { "No API's found" } Write-Host "APIs:`n$($apiText -join "`n")`n" -ForegroundColor Cyan } if ($includeStatic) { $staticText = if ($staticFiles.Count) { $staticFiles -join "`n" } else { "No static files found" } Write-Host "Static files:`n$staticText" -ForegroundColor DarkYellow } } [void] ListEndpoints([string]$type) { $this.ListEndpoints($type, $false) } [void] ListEndpoints([bool]$includeStatic) { $this.ListEndpoints('All', $includeStatic) } [void] ListEndpoints() { $this.ListEndpoints('All', $false) } [void] GetPagesEndpoints() { $this.GetEndpoints('Pages', 'html', [System.ConsoleColor]::Magenta, 'Page') } [void] GetAPIEndpoints() { $this.GetEndpoints('API', 'ps[me]?1', [System.ConsoleColor]::Cyan, 'API') } [hashtable] NewRequest([System.Net.HttpListenerContext]$context) { $req = $context.Request $newRequest = @{ context = $context store = $this.h_store query = @{} body = @{} endpoints = $this.h_endpoints basePath = (Get-Item $this.h_basePath).FullName #ContentType = [ContentType]::FromUrl($context.Request.Url.AbsolutePath).ToString() } $newRequest.context.Response.ContentType = [ContentType]::FromUrl($context.Request.Url.AbsolutePath) $req.Url.Query -replace '^\?' -split '&' | ForEach-Object { $key, $value = $_ -split '=' $newRequest.query.$key = [System.Net.WebUtility]::UrlDecode(($value -join '=')) } if ($req.HasEntityBody) { try { $newRequest.body = [System.IO.StreamReader]::new($context.Request.InputStream).ReadLine() | ConvertFrom-Json }catch {} } return $newRequest } [void]CreateListener() { if (!$this.h_listener.IsListening) { $this.h_listener.Prefixes.Add("http://localhost:$($this.h_port)/") } } [void] hidden InvokeRequest($requestInfo) { $requestJob = . "$PSScriptRoot/requestJob.ps1" $ps = [powershell]::Create() $ps.RunspacePool = $this.h_pool $ps.AddScript($requestJob).AddParameter('requestInfo', $requestInfo) | Out-Null $async = $ps.BeginInvoke() $this.h_jobs.Add([PSCustomObject]@{ PS = $ps Async = $async }) } [void]Start() { try { $this.h_listener.Start() if ($this.h_isDisposed) { throw [ObjectDisposedException]::new([WebServer].Name) return } $TreatControlCAsInput = [System.Console]::TreatControlCAsInput $_Host = Get-Host [System.Console]::TreatControlCAsInput = $true Write-Host "Server started on http://localhost:$($this.h_port)/" -ForegroundColor Green while ($this.h_listener.IsListening) { $contextTask = $this.h_listener.GetContextAsync() while (-not $contextTask.AsyncWaitHandle.WaitOne(200)) { if ($_Host.UI.RawUI.KeyAvailable) { $key = $_Host.UI.RawUI.ReadKey("AllowCtrlC,NoEcho,IncludeKeyUp") switch ($key.VirtualKeyCode) { 67 { $this.h_listener.stop() } 76 { $this.ListEndpoints() } } } foreach ($script in $this.h_backgroundScripts) { try { . $script -store $this.h_store -root $this.h_basePath } catch {} } foreach ($job in $this.h_jobs | Where-Object { $_.Async.IsCompleted } ) { $result = $job.ps.EndInvoke($job.Async) if ($result -eq 'STOP') { $this.Dispose() return } if ($result) { Write-Host $result } [void]$this.h_jobs.Remove($job) } } if (-not $this.h_listener.IsListening) { return } $context = $contextTask.GetAwaiter().GetResult() $newRequest = $this.NewRequest($context) $this.InvokeRequest($newRequest) } } finally { [System.Console]::TreatControlCAsInput = $TreatControlCAsInput } } [void]Close() { $this.h_listener.Close() } [void]Dispose() { $this.close() $this.h_listener.Dispose() foreach ($job in $this.h_jobs) { $job.Dispose() } $this.h_pool.Dispose() $this.h_isDisposed = $true } } <# .SYNOPSIS Exposed function to create and start a new WebServer .DESCRIPTION Exposed function to create and start a new WebServer, allowing setting port number, initial path, autostart with or without headless browser (Edge) .NOTES Works only on Windows for now .LINK https://github.com/bempus/PowerShell-WebServer .EXAMPLE New-WebServer Returns a new [WebServer] with port 3000 .Example New-WebServer -port 9001 Returns a new [WebServer] with port 9001 .Example New-WebServer -path 'C:\Temp\WebServer' Returns a new [WebServer] with port 3000 and Path 'C:\Temp\WebServer' #> function New-WebServer { param( [int16]$port = 3000, [string]$path, [scriptblock[]]$backgroundScripts, [hashtable]$store, [switch]$autoStart, [switch]$autostartAsApp, [switch]$disposeOnClose ) $ws = [WebServer]::new($port) [void]$webservers.Add($ws) if ($path) { $ws.SetRoot($path) } if ($store) { $ws.store = $store } foreach ($bgScript in $backgroundScripts) { $ws | Register-backgroundScript -scriptblock $bgScript } if ($autoStart -or $autostartAsApp) { $ws | Start-WebServer -asApp:$autostartAsApp -autoDispose:$disposeOnClose } return $ws } <# .SYNOPSIS Returns a list of all WebServer instances .DESCRIPTION Returns a list of all WebServer instances, optionally filtered by disposed state #> function Get-WebServers { param([switch]$Disposed) $webservers | Where-Object { $_.Disposed -like $Disposed } } <# .SYNOPSIS Starts a WebServer instance .DESCRIPTION Starts a WebServer instance, optionally running it as an application with a headless browser .EXAMPLE $ws | Start-WebServer -asApp -autoDispose Starts the WebServer instance as an application with a headless browser and disposes the WebServer when the Edge window is closed or the process is terminated .EXAMPLE $ws | Start-WebServer -autoDispose Starts the WebServer instance on the specified port, and disposes the WebServer when the process is terminated .EXAMPLE $ws | Start-WebServer Starts the WebServer instance on the specified port, and keeps it running until disposed via $ws.Dispose() #> function Start-WebServer { param ( [Parameter(ValueFromPipeline)] [WebServer]$webserver, [switch]$asApp, [switch]$autoDispose ) $TreatControlCAsInput = [System.Console]::TreatControlCAsInput [System.Console]::TreatControlCAsInput = $true [System.Collections.ArrayList]$appModePids = [System.Collections.ArrayList]::new() [string]$pswsTempDir = $null if ($asApp) { $pswsTempDir = Join-Path -Path $env:TEMP -ChildPath "PSWS_$((New-Guid).Guid)" $port = $webserver.FindAndSetClosestPort() $appPid = (Start-Process -FilePath (Get-ItemProperty -ErrorAction Stop -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe\')."(default)" -ArgumentList "--app=http://localhost:$port", "--user-data-dir=$($pswsTempDir)", '--disable-extensions' -PassThru).Id [void]$appModePids.Add($appPid) while ($appModePids.Count -le 1) { foreach ($id in ((Get-CimInstance Win32_Process -Filter "Name = 'msedge.exe'" | Where-Object { $_.CommandLine -like "*--user-data-dir=`"$pswsTempDir`"*" }).ProcessId)) { [void]$appModePids.Add($id) } Start-Sleep -Milliseconds 100 } if ($autoDispose) { $webserver | Register-backgroundScript -ScriptBlock { $isAlive = $false foreach ($appModePid in $appModePids) { if (Get-Process -Id $appModePid -ErrorAction SilentlyContinue) { $isAlive = $true } } if (-not $isAlive) { $webserver.Close() } } } } $webserver.Start() if ($autoDispose) { $webserver.dispose() } if ($appModePids) { foreach ($appModePid in $appModePids) { $process = (Stop-Process $appModePid -ErrorAction SilentlyContinue -PassThru ) | Get-Process if ($process) { $process.WaitForExit() } } } if ($pswsTempDir) { Remove-Item $pswsTempDir -Force -Recurse -erroraction SilentlyContinue } [System.Console]::TreatControlCAsInput = $TreatControlCAsInput return $webserver } <# .SYNOPSIS Removes a WebServer instance .DESCRIPTION Removes a WebServer instance and disposes it .EXAMPLE $ws | Remove-WebServer Removes the WebServer instance and disposes it #> function Remove-WebServer { param( [Parameter(ValueFromPipeline)] [WebServer[]]$webserver ) process { foreach ($server in $webserver) { $server.Dispose() } } } <# .SYNOPSIS Adds an endpoint to the WebServer .DESCRIPTION Adds an endpoint to the WebServer with the specified name and callback .EXAMPLE $ws | Add-Endpoint -name "test" -callBack { param($query, $body, $method, $store, $request, $basePath) Write-Host "Query: $($query.test), Method used: $($method)" } Adds a new endpoint named "test" with the specified callback that writes the query value of "test" and the method used to the console .EXAMPLE $ws | Add-Endpoint -name "test" -callBack { return "<html><body><h1>Test Endpoint</h1></body></html>" } -methods "GET", "POST" Adds a new endpoint named "test" with the specified callback that returns a simple HTML response, and allows only GET and POST methods .EXAMPLE $ws | Add-Endpoint -name "test" -callBack { return "<html><body><h1>Test Endpoint</h1></body></html>" } -methods "GET", "POST" -ContentType [ContentType]::TextHTML Adds a new endpoint named "test" with the specified callback that returns a simple HTML response, allows only GET and POST methods, and sets the Content-Type header to text/html #> function Add-Endpoint { param( [Parameter(ValueFromPipeline)] [WebServer]$webserver, [string]$name, [scriptblock]$callBack = {}, [Method[]]$methods = "GET", [ContentType]$ContentType = [ContentType]::textHTML ) if ($webserver.GetEndpoint($name)) { return Write-Warning "Endpoint with name '$name' already exists on this WebServer use Update-Endpoint instead" } return $webserver.SetEndpoint($name, $callBack, $methods, $ContentType) } <# .SYNOPSIS Updates an existing endpoint on the WebServer .DESCRIPTION Updates an existing endpoint on the WebServer with the specified name and callback .EXAMPLE $ws | Update-Endpoint -name "test" -callBack { return "Updated test endpoint called" } Updates the endpoint named "test" with the specified callback #> function Update-Endpoint { param( [Parameter(ValueFromPipeline)] [WebServer]$webserver, [string]$name, [scriptblock]$callBack = {}, [Method[]]$methods, [ContentType]$ContentType ) $endpoint = $webserver.GetEndpoint($name) if (-not $endpoint) { Write-Warning "Endpoint with name '$name' does not exist on this WebServer, use Add-Endpoint instead" } if ($callBack) { $endpoint.callback = $callBack } if ($methods) { $endpoint.methods = $methods } if ($contentType) { $endpoint.ContentType = $ContentType } } <# .SYNOPSIS Lists all endpoints on the WebServer .DESCRIPTION Lists all endpoints on the WebServer, optionally filtering by type (API, Pages, All) and including static files (e.g. images, CSS, JS) .EXAMPLE $ws | Show-Endpoints Shows all endpoints on the WebServer .EXAMPLE $ws | Show-Endpoints -type "API" Shows only API endpoints on the WebServer .EXAMPLE $ws | Show-Endpoints -includeStaticFiles Shows all endpoints on the WebServer, including static files #> function Show-Endpoints { param ( [Parameter(ValueFromPipeline, Mandatory)] [WebServer]$WebServer, [ValidateNotNullOrEmpty()] [ValidateSet('API', 'Pages', 'All')]$type = 'All', [switch]$includeStaticFiles ) $WebServer.ListEndpoints($type, [bool]$includeStaticFiles) } <# .SYNOPSIS Registers a background script to be executed on the WebServer .DESCRIPTION Registers a background script that will be executed on the WebServer .EXAMPLE $ws | Register-BackgroundScript -scriptBlock { return "Background script executed" } Registers a background script that will be executed on the WebServer #> function Register-BackgroundScript { param ( [Parameter(ValueFromPipeline)] [WebServer]$webserver, [scriptblock]$scriptBlock ) $webserver.RegisterBackgroundScript($scriptBlock) } |