Search notes:

PowerShell: Multithreaded Web Server

For a project running in a Windows environment, I needed a web server.
Because the web server needed to be multithreaded and I wanted to start the threads with start-threadJob, PowerShell 7 is required to run the web server.
For future reference, the non-project related code is put to github.

Source code

The functionality fits into one file:
#Requires -Version 7.0
# vim: foldmethod=marker foldmarker={{{,}}}

[cmdletBinding()]
param (
   [int                             ] $port = 9999,
   [System.Net.AuthenticationSchemes] $auth = [System.Net.AuthenticationSchemes]::None # Try IntegratedWindowsAuthentication etc.
)

set-strictMode -version 3

$httpEndPoint          = "http://+:$port/"
$errorActionPreference = "stop"

$listener = new-object System.Net.HttpListener

$listener.Prefixes.Add($httpEndPoint)

if ($auth) {
   $listener.AuthenticationSchemes = $auth
}

$listener.Start()

write-verbose "Listening at $httpEndPoint‥"

function start-thread { # {{{
  [cmdletBinding()]
  param ($listener, $tid)

  $thread = start-threadJob -scriptBlock { # {{{

     param($listener_, $tid, $verbosePreference)

     set-strictMode -version 3

     function send-httpResponse_( # {{{
         [System.Net.HttpListenerContext ]         $ctx,
         [byte[]]                                  $body,
         [String]                                  $contentType
         ) {

     $httpResp     = $ctx.Response
     $httpReq      = $ctx.Request

     try {

        $reqHdrAcceptedEncodings = $request.Headers['Accept-Encoding']
        $gzippedResponse = $false
        if ($reqHdrAcceptedEncodings) {
            $acceptedEncodings = $reqHdrAcceptedEncodings -split ',' | forEach { $_.Trim() }
            $gzippedResponse = 'gzip' -in $acceptedEncodings
        }
        write-verbose "gzippedResponse = $gzippedResponse"

        $httpResp.Headers['Content-Type'] = $contentType
     #
     #  We're optimistic!
     #
        $httpResp.StatusCode = 200

        if ($gzippedResponse) { # {{{
            $body = gzip-byteArray $body
            $httpResp.Headers.Add('Content-Encoding','gzip')
        } # }}}

        $httpResp.ContentLength64 = $body.Length
        $httpResp.OutputStream.Write($body, 0, $body.Length)

        $httpResp.OutputStream.Close()
        $httpResp.Close()
     }
     catch {
          write-host "exception in send-stringArray $_"
     }} # }}}

     function send-stringArray( # {{{
         [System.Net.HttpListenerContext ]         $ctx,
         [System.Collections.Generic.List[String]] $text,    # TODO: should be body
         [String]                                  $contentType = 'text/html; charset=utf-8'
         ) {
     try {
        $textUtf8Bytes = [System.Text.Encoding]::UTF8.GetBytes($text.ToArray() -join "`n")
        send-httpResponse_ $ctx $textUtf8Bytes $contentType
     }
     catch {
          write-host "exception in send-stringArray $_"
     }} # }}}

     function send-file( # {{{
         [System.Net.HttpListenerContext ]  $ctx,
         [String                         ]  $filename,
         [String                         ]  $contentType
     ) {
     try {
        [byte[]] $bytes = [System.IO.File]::ReadAllBytes($filename)
        send-httpResponse_ $ctx $bytes $contentType
     }
     catch {
          write-host "exception in send-stringArray $_"
     }} # }}}

     function index($text) { # {{{
        $text.add('<!doctype html>')
        $text.add('<style>')
        $text.add('body {font-family: system-ui}')
        $text.add('</style>')
        $text.add('<title>Simple multithreaded webserver for Powershell Core</title>')
        $text.add('<h1>Simple multithreaded webserver for Powershell Core</h1>')
        $text.add("Thread ID = $tid")
        $text.add("<p><a href='/show-request'>Show request details</a>")
     } # }}}

     function show-request($text, $req, $user) { # {{{
        $text.add('<!doctype html>')
        $text.add('<style>')
        $text.add('ul {margin-top: 0}')
        $text.add('body {font-family: system-ui}')
        $text.add('</style>')
        $text.add('<title>Request details</title>')
        $text.add('<h1>Request details</h1>')

        $text.add('Accept Types: <ul><li>')
        $text.add($req.AcceptTypes -join '<li>')
        $text.add('</ul>')

        $text.add('User Languages: <ul><li>')
        $text.add($req.UserLanguages -join '<li>')
        $text.add('</ul><p>')

        if ($req.IsAuthenticated) {
           $text.add("User is authenticated (user = <i>$($user.identity.name)</i>)")
        }
        else {
           $text.add('User is not authenticated')
        }

        $text.add('<p><table>')
        $text.add("<tr><td>ContentEncoding.GetType().FullName</td><td>$($req.ContentEncoding.GetType().FullName)</td></tr>")
        $text.add("<tr><td>ContentEncoding.WebName</td><td>$($req.ContentEncoding.WebName)</td></tr>")
        $text.add("<tr><td>ContentLength64</td><td>$($req.ContentLength64)</td></tr>")
        $text.add("<tr><td>ContentType</td><td>$($req.ContentType)</td></tr>")
        $text.add("<tr><td>Cookies.Count</td><td>$($req.Cookies.Count)</td></tr>")
        $text.add("<tr><td>HasEntityBody</td><td>$($req.HasEntityBody)</td></tr>")
        $text.add("<tr><td>HttpMethod</td><td>$($req.HttpMethod)</td></tr>")
        $text.add("<tr><td>IsLocal</td><td>$($req.IsLocal)</td></tr>")
        $text.add("<tr><td>IsSecureConnection</td><td>$($req.IsSecureConnection)</td></tr>")
        $text.add("<tr><td>IsWebSocketRequest</td><td>$($req.IsWebSocketRequest)</td></tr>")
        $text.add("<tr><td>LocalEndPoint</td><td>$($req.LocalEndPoint)</td></tr>")
        $text.add("<tr><td>RemoteEndPoint</td><td>$($req.RemoteEndPoint)</td></tr>")
        $text.add("<tr><td>KeepAlive</td><td>$($req.KeepAlive)</td></tr>")
        $text.add("<tr><td>RawUrl</td><td>$($req.RawUrl)</td></tr>")
        $text.add("<tr><td>ProtocolVersion</td><td>$($req.ProtocolVersion)</td></tr>")
        $text.add("<tr><td>ServiceName</td><td>$($req.ServiceName)</td></tr>")
        $text.add('</table>')


        $text.add("<h2 id='QueryString'>QueryString</h2>")

        $text.add("Use <a href='#enter-value'>this form</a> to enter values")

        $qry = $req.QueryString
        $text.add("<table style='border: 1px solid'>")
        foreach ($qryNam in $qry) {
           $text.add("<tr><td>$qryNam</td><td>$([System.Net.WebUtility]::HtmlEncode($qry[$qryNam]))</td></tr>")
        }
        $text.add('</table>')

        $text.add("<p>See also the <code>Query</code> value of the request's <code>Url</code> property.")

        $uri = $req.Url
        $text.add('<h1>Url</h1>')
        $text.add('<table>')
        $text.add("<tr><td>AbsolutePath</td><td>$($uri.AbsolutePath)</td></tr>")
        $text.add("<tr><td>AbsoluteUri</td><td>$($uri.AbsoluteUri)</td></tr>")
        $text.add("<tr><td>Authority</td><td>$($uri.Authority)</td></tr>")
        $text.add("<tr><td>DnsSafeHost</td><td>$($uri.DnsSafeHost)</td></tr>")
        $text.add("<tr><td>Fragment</td><td>$($uri.Fragment)</td></tr>")
        $text.add("<tr><td>Query</td><td>$($uri.Query)</td></tr>")
        $text.add("<tr><td>Scheme</td><td>$($uri.Scheme)</td></tr>")
        $text.add("<tr><td>UserEscaped</td><td>$($uri.UserEscaped)</td></tr>")
        $text.add('</table>')


        $text.add('<h1>HTTP Request Headers</h1>')
        $text.add('<table>')
        foreach ($hdr in $req.Headers) {
           $text.add("<tr><td>$hdr</td><td>$($req.Headers[$hdr])</td></tr>")
        }
        $text.add('</table>')

        $text.add("<h1 id='enter-value'>GET Parameters</h1>")
        $text.add('Enter values for <code>QueryString</code>')

        $text.add("<form method='get' action='/show-request#QueryString'>")
        $text.add(    "foo: <input name='foo' value='$([System.Net.WebUtility]::HtmlEncode($qry['foo']))'>")
        $text.add("<br>bar: <input name='bar' value='$([System.Net.WebUtility]::HtmlEncode($qry['bar']))'>")
        $text.add("<br>baz: <input name='baz' value='$([System.Net.WebUtility]::HtmlEncode($qry['baz']))'>")
        $text.add("<p><input type='submit' value='Go!'>")
        $text.add('</form>')

        $text.add("<div style='height:2000px'></div>")

     } # }}}

     function gzip-byteArray([byte[]] $b) { # {{{

         $outputStream = new-object System.IO.MemoryStream
         $gzip = new-object System.IO.Compression.GZipStream($outputStream, [IO.Compression.CompressionMode]::Compress)

         $gzip.Write($b, 0, $b.Length)
         $gzip.Close()

         return ,$outputStream.ToArray()
     } # }}}

     while ($listener_.IsListening) { try { # {{{

       [System.Net.HttpListenerContext ] $context    = $listener_.GetContext()
       [System.Net.HttpListenerResponse] $response   = $context.Response
       [System.Net.HttpListenerRequest ] $request    = $context.Request

        $uri = $request.Url

       [String] $path = $uri.AbsolutePath

        write-verbose "new connection in thread $tid, path = $path"

       [System.Collections.Generic.List[String]] $text = [System.Collections.Generic.List[String]]::new()

        if     ($path -eq '/'            ) { index        $text                       }
        elseif ($path -eq '/show-request') { show-request $text $request $context.User}

        if ($text.count -gt 0) {
           send-stringArray $context  $text
           continue
        }

        if ($path -eq '/favicon.ico') {
           send-file $context "$pwd/favicon.ico" 'image/x-icon'
           continue
        }

        $text.add("<h1>404: $path</h1>")
        send-stringArray $context $text 'text/plain'

     }
     catch {
         write-host "Exception in main loop: $_"
     }} # }}}

  }  -streamingHost $host -argumentList $listener, $tid, $verbosePreference <# -eq 'continue' )  #> # }}}

} # }}}

start-thread $listener 1
start-thread $listener 2
start-thread $listener 3
start-thread $listener 4

[console]::treatControlCAsInput = $true
while ($true) { # {{{ Run until ctrl-c is pressed

  $Key = [Console]::ReadKey($true) # Pass $true so that the pressed key is not displayed.

  if (($Key.Modifiers -band [ConsoleModifiers]'control') -and ($Key.Key -eq 'C')) {
      write-verbose "Ctrl-C -> ending web server"
      $listener.Stop()
      $listener.Close()
      exit

   # TODO: Do threads need be cleaned up?
  }
  else {
     write-verbose "! ctrl-c"
  }
} # }}}
Github repository ps7-multithreaded-webserver, path: /webserver.ps1

Starting the web server when the machine boots up

The following commands can be used to start the webserver when the machine boots up:
$wdr = 'C:/development/ps/webserver'
$act = new-scheduledTaskAction      -execute C:/development/ps/7.5.4/pwsh.exe -argument "-file $wdr/webserver.ps1" -workingDirectory $wdr
$trg = new-scheduledTaskTrigger     -atStartup
$prc = new-scheduledTaskPrincipal   -userId "NT AUTHORITY\SYSTEM" -logonType ServiceAccount
# register-scheduledTask -taskName 'dashboard webserver' -taskPath \TQ84\Prj  -action $act -trigger $trg -settings $set -principal $prc
register-scheduledTask   -taskName 'dashboard webserver' -taskPath \TQ84\Prj  -action $act -trigger $trg                -principal $prc

See also

System.Net.HttpListener etc.

Index

Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 8 attempt to write a readonly database in /home/httpd/vhosts/renenyffenegger.ch/php/web-request-database.php:51 Stack trace: #0 /home/httpd/vhosts/renenyffenegger.ch/php/web-request-database.php(51): PDOStatement->execute(Array) #1 /home/httpd/vhosts/renenyffenegger.ch/php/web-request-database.php(66): id_of(Object(PDO), 'uri', '/notes/web/webs...') #2 /home/httpd/vhosts/renenyffenegger.ch/php/web-request-database.php(30): insert_webrequest_('/notes/web/webs...', 1788413103, '216.73.217.21', 'Mozilla/5.0 App...', NULL) #3 /home/httpd/vhosts/renenyffenegger.ch/httpsdocs/notes/web/webserver/ps/multithreaded(336): insert_webrequest() #4 {main} thrown in /home/httpd/vhosts/renenyffenegger.ch/php/web-request-database.php on line 51