Skip to content

Instantly share code, notes, and snippets.

@anonhostpi
Last active September 1, 2025 10:05
Show Gist options
  • Save anonhostpi/1cc0084b959a9ea9e97dca9dce414e1f to your computer and use it in GitHub Desktop.
Save anonhostpi/1cc0084b959a9ea9e97dca9dce414e1f to your computer and use it in GitHub Desktop.
Webserver Example
# iex (iwr "https://gist.githubusercontent.com/anonhostpi/1cc0084b959a9ea9e97dca9dce414e1f/raw/webserver.ps1").Content
function New-Webserver {
param(
[string] $Binding = "http://localhost:8080/",
[string] $BaseDirectory = "$(Get-Location -PSProvider FileSystem)",
[string] $Name = "PowerShell Web Server",
[System.Collections.IDictionary] $Routes = @{
Before = { param( $Server, $Command, $Listener, $Context ) return $true }
After = { param( $Server, $Command, $Listener, $Context ) return $true }
# "GET /" = { param( $Server, $Command, $request, $response )... }
# "GET /hello" = "./path/to/static/file.html"
Default = {
param( $Server, $Command, $Request, $Response )
$Command = $Command -split " ", 2
$path = $Command | Select-Object -Index 1
return $Server.Serve( $path, $Response )
}
},
[switch] $Cache,
[scriptblock] $Reader
)
If( $Routes -eq $null ) {
$Routes = [ordered]@{}
}
If( $Routes.Default -eq $null ) {
$Routes.Default = {
param( $Server, $Command, $Request, $Response )
$Response.StatusCode = 404
return "404 Not Found"
}
}
$Server = New-Object psobject -Property @{
Binding = $Binding
BaseDirectory = "$(Resolve-Path $BaseDirectory -ErrorAction SilentlyContinue)".TrimEnd('\/')
Name = $Name
Routes = $Routes
Caching = [bool]$Cache
Cached = @{}
Listener = $null
}
$Server | Add-Member -MemberType ScriptMethod -Name ConvertExtension -Value {
param( [string] $Extension )
switch( $Extension.ToLower() ) {
".html" { "text/html; charset=utf-8" }
".htm" { "text/html; charset=utf-8" }
".css" { "text/css; charset=utf-8" }
".js" { "application/javascript; charset=utf-8" }
".json" { "application/json; charset=utf-8" }
".txt" { "text/plain; charset=utf-8" }
".xml" { "application/xml; charset=utf-8" }
".csv" { "text/csv; charset=utf-8" }
".tsv" { "text/tab-separated-values; charset=utf-8" }
".md" { "text/markdown; charset=utf-8" }
".png" { "image/png" }
".jpg" { "image/jpeg" }
".jpeg" { "image/jpeg" }
".gif" { "image/gif" }
".svg" { "image/svg+xml" }
".ico" { "image/x-icon" }
".bmp" { "image/bmp" }
".avif" { "image/avif" }
".webp" { "image/webp" }
".mp4" { "video/mp4" }
".webm" { "video/webm" }
".avi" { "video/x-msvideo" }
".mov" { "video/quicktime" }
".mp3" { "audio/mpeg" }
".wav" { "audio/wav" }
".ogg" { "audio/ogg" }
".pdf" { "application/pdf" }
".zip" { "application/zip" }
".tar" { "application/x-tar" }
".gz" { "application/gzip" }
".7z" { "application/x-7z-compressed" }
".bz2" { "application/x-bzip2" }
".woff" { "font/woff" }
".woff2" { "font/woff2" }
".ttf" { "font/ttf" }
".eot" { "application/vnd.ms-fontobject" }
".otf" { "font/otf" }
default { "application/octet-stream" }
}
}
$Server | Add-Member -MemberType ScriptMethod -Name Read -Value (& {
If( $null -ne $Reader ) {
return $Reader
}
return {
param( [string] $Path )
$root = $this.BaseDirectory
$Path = $Path.TrimStart('\/')
$file = "$root\$Path".TrimEnd('\/')
$file = Try {
Resolve-Path $file -ErrorAction Stop
} Catch {
Try {
Resolve-Path "$file.html" -ErrorAction Stop
} Catch {
Resolve-Path "$file\index.html" -ErrorAction SilentlyContinue
}
}
$file = "$file"
# Throw on directory traversal attacks and invalid paths
$bad = @(
[string]::IsNullOrWhitespace($file),
-not (Test-Path $file -PathType Leaf -ErrorAction SilentlyContinue),
-not ($file -like "$root*")
)
if ( $bad -contains $true ) {
throw "Invalid path '$Path'."
}
return @{
Path = $file
Content = (Get-Content "$root\$Path" -Raw -ErrorAction SilentlyContinue)
}
}
})
$Server | Add-Member -MemberType ScriptMethod -Name Serve -Value {
param(
[string] $File,
$Response
)
If( $null -ne $this.Cached[$File] ) {
$Response.ContentType = $this.Cached[$File].Type
return $this.Cached[$File].Content
}
Try {
$result = $this.Read($File)
$content = $result.Content
$extension = [System.IO.Path]::GetExtension($result.Path)
$mimetype = $this.ConvertExtension( $extension )
If( $this.Caching -and -not [string]::IsNullOrWhitespace($content) ) {
$this.Cached[$File] = @{
Content = $content
Type = $mimetype
}
}
$Response.ContentType = $mimetype
return $content
} Catch {
$Response.StatusCode = 404
return "404 Not Found"
}
}
$Server | Add-Member -MemberType ScriptMethod -Name ParseQuery -Value {
param( $Request )
return [System.Web.HttpUtility]::ParseQueryString($Request.Url.Query)
}
$Server | Add-Member -MemberType ScriptMethod -Name ParseBody -Value {
param( $Request )
If( -not $Request.HasEntityBody -or $Request.ContentLength64 -le 0 ) {
return $null
}
$stream = $Request.InputStream
$encoding = $Request.ContentEncoding
$reader = New-Object System.IO.StreamReader( $stream, $encoding )
$body = $reader.ReadToEnd()
$reader.Close()
$stream.Close()
switch -Wildcard ( $Request.ContentType ) {
"application/x-www-form-urlencoded*" {
return [System.Web.HttpUtility]::ParseQueryString($body)
}
"application/json*" {
return $body | ConvertFrom-Json
}
"text/xml*" {
return [xml]$body
}
default {
return $body
}
}
}
$Server | Add-Member -MemberType ScriptMethod -Name Stop -Value {
If( $null -ne $this.Listener -and $this.Listener.IsListening ) {
$this.Listener.Stop()
$this.Listener.Close()
$this.Listener = $null
}
}
$Server | Add-Member -MemberType ScriptMethod -Name Start -Value {
$this.Listener = New-Object System.Net.HttpListener
$this.Listener.Prefixes.Add($this.Binding)
$this.Listener.Start()
(& {
Try {
While ( $this.Listener.IsListening ) {
<# $task = $this.Listener.GetContextAsync()
if (-not ($task.Wait(300))) {
Write-Host "Polling..."
continue
} #>
# $context = $this.Listener.GetContext() # $task.Result
$task = $this.Listener.GetContextAsync()
While( -not $task.AsyncWaitHandle.WaitOne(300) ) {
if( -not $this.Listener.IsListening ) { return }
}
$context = $task.GetAwaiter().GetResult()
$request = $context.Request
$command = "{0} {1}" -f $request.HttpMethod, $request.Url.AbsolutePath
If( $this.Routes.Before -is [scriptblock] ) {
$allow = & $this.Routes.Before $Server $command $this.Listener $context
If( -not $allow ) {
continue
}
}
$response = $context.Response
$response.ContentType = "text/plain; charset=utf-8"
$result = Try {
$route = If( $this.Routes[$command] ) {
$this.Routes[$command]
} Else {
$this.Routes.Default
}
If( $route -is [scriptblock] ) {
& $route $this $command $request $response
} Else {
$this.Serve( $route, $response )
}
} Catch {
$response.StatusCode = 500
"500 Internal Server Error`n`n$($_.Exception.Message)"
}
if( -not [string]::IsNullOrWhiteSpace($result) ) {
Try {
$buffer = [System.Text.Encoding]::UTF8.GetBytes($result)
$response.ContentLength64 = $buffer.Length
If( [string]::IsNullOrWhiteSpace($response.Headers["Last-Modified"]) ){
$response.Headers.Add("Last-Modified", (Get-Date).ToString("r"))
}
If( [string]::IsNullOrWhiteSpace($response.Headers["Server"]) ){
$response.Headers.Add("Server", $Name)
}
$response.OutputStream.Write( $buffer, 0, $buffer.Length )
} Catch {}
}
Try {
$response.Close()
} Catch {}
If( $this.Routes.After -is [scriptblock] ) {
& $this.Routes.After $Server $command $this.Listener $context
}
}
} finally {
$this.Stop()
}
}) | Out-Null
}
return $Server
}
# $server = New-Webserver
# Start "http://localhost:8080"
# $server.Start()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment