Skip to content

Instantly share code, notes, and snippets.

@StartAutomating
Created March 22, 2025 16:13
Show Gist options
  • Save StartAutomating/b13e986966d9751bcce4a671bd02c4bb to your computer and use it in GitHub Desktop.
Save StartAutomating/b13e986966d9751bcce4a671bd02c4bb to your computer and use it in GitHub Desktop.
Gist a web server without a route table
<#
.SYNOPSIS
A pure PowerShell web server
.DESCRIPTION
A pure PowerShell web server proof of concept.
This creates a simple web server that routes commands directly by their local path
That is `/hello` would run the function `/hello` if it exists.
Then, we're going to take this idea and do as much cool stuff as we can in a short amount of space.
.NOTES
In broad strokes, this works by turning the HTTP requests into events, and then using those events to trigger the functions.
Instead of having a traditional routing table, the command names _are_ the routing table.
#>
# Step 1: Create a server:
#region Event Server
# We're going to create a job on a random port
$JobName = "http://localhost:$(Get-Random -Min 4200 -Max 42000)/"
$listener = [Net.HttpListener]::new()
$listener.Prefixes.Add($JobName)
$listener.Start()
# Now we start our server in a thread job.
# This lets us get requests in a background thread, and turn them into events.
Start-ThreadJob -ScriptBlock {
param($MainRunspace, $listener, $eventId = 'http')
while ($listener.IsListening) {
$nextRequest = $listener.GetContextAsync()
while (-not ($nextRequest.IsCompleted -or $nextRequest.IsFaulted -or $nextRequest.IsCanceled)) {
}
if ($nextRequest.IsFaulted) {
Write-Error -Exception $nextRequest.Exception -Category ProtocolError
continue
}
$context = $(try { $nextRequest.Result } catch { $_ })
if ($context.Request.Url -match '/favicon.ico$') {
$context.Response.StatusCode = 404
$context.Response.Close()
continue
}
$MainRunspace.Events.GenerateEvent(
$eventId, $listener, @($context, $context.Request, $context.Response),
[Ordered]@{Url = $context.Request.Url;Context = $context;Request = $context.Request;Response = $context.Response}
)
}
} -Name $JobName -ArgumentList ([Runspace]::DefaultRunspace, $listener) -ThrottleLimit 50 |
Add-Member -NotePropertyMembers ([Ordered]@{HttpListener = $listener}) -PassThru
Write-Host "Now Serving @ $jobName" -ForegroundColor Green
#endregion Event Server
#region Server functions
# Step 2: Define the functions that serve our website
#region Root
function / {
# This demo will be a lot of randomly generated content, so we'll set a random refresh rate
# variable context is shared between functions, so other animations can know the ideal timeframe to use.
# The refresh interval is the only dynamic part of this page.
$RefreshIn = $(Get-Random -Min 1kb -Max 2kb)
@(
"<html>","<head>"
"<title>There is no route table</title>"
"<link rel='stylesheet' href='/css' />" # link to a dynamically generated CSS file.
'<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">'
"<script>setTimeout(() => { window.location.reload() }, $RefreshIn )</script>"
"</head>","<body>"
"<h1 style='text-align:center'> Responded in $(([DateTime]::Now - $event.TimeGenerated))</h1>"
"<h2 style='text-align:center'> Switching in $([TimeSpan]::FromMilliseconds($refreshIn))</h2>"
"<div style='text-align:center'>
<img src='/svg' width='25%' style='align:center' />
<iframe height='25%' width='100%' src='/3D' frameBorder='0' scrolling='no'></iframe>
</div>"
"</body>","</html>"
) -join [Environment]::NewLine
}
function /HelloWorld { "Hello World" }
function /RandomNumber { Get-Random }
Set-Alias /RNG /RandomNumber
function /RequestInfo { $request }
function /myProc { Get-Process -Id $pid | Select-Object Name, Id, Path, StartTime }
Set-Alias /MyProcess /myProc
function /CSS {
# Pick a random background color
$bgColor = Get-Random -Max 0xffffff
# and xor it with white to get a contrasting foreground color
$fgColor = $bgColor -bxor 0xffffff
# make them into hex strings
$randomBackground = "#{0:x6}" -f $bgColor
$randomColor = "#{0:x6}" -f $fgColor
# Declare a little filter to make things CSS
filter toCss {
$cssString =
@(if ($_ -is [string]) {
$_
} elseif ($_ -is [Collections.IDictionary]) {
@(
foreach ($key in $_.Keys) {
$value = $_[$key]
if ($value -is [Collections.IDictionary]) {
"$key { $($value | toCss) }"
} else {
"${key}: $value;"
}
}) -join ' '
}) -join [Environment]::NewLine
$cssString = [PSObject]::new($cssString)
$cssString.pstypenames.insert(0,'text/css')
$cssString
}
[Ordered]@{
body = [Ordered]@{
background = $randomBackground
color = $randomColor
a = [Ordered]@{
color = $randomColor
}
height = '100vh'
width = '100vw'
}
fontFamily = 'Arial, sans-serif'
} |
toCss
}
function /pattern {
$response.ContentType = 'image/svg+xml'
@"
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="SimplePattern" width="0.1" height="0.1">
<circle cx="2.5" cy="2.5" r="0.5" fill="#4488ff" />
<line x1="0" x2="5" y1="2.5" y2="2.5" stroke="#4488ff" stroke-width="0.1" />
<line y1="0" y2="5" x1="2.5" x2="2.5" stroke="#4488ff" stroke-width="0.1" />
</pattern>
</defs>
<rect fill="url(#SimplePattern)" width="100%" height="100%" opacity="0.3" />
</svg>
"@
}
function /svg {
$response.ContentType = 'image/svg+xml'
$bgColor = Get-Random -Max 0xffffff
$fgColor = $bgColor -bxor 0xffffff
$randomFill = "#{0:x6}" -f $bgColor
$randomStroke = "#{0:x6}" -f $fgColor
$SideCount = 3..6 | Get-Random
$anglePerPoint = 360 / $SideCount
$InitialRotation = Get-Random -Max 360
$fromPoints = @(
'M'
foreach ($n in 1..$SideCount) {
$x = 50 + (Get-Random -Min -25 -Max 25)
$y = 50 + (Get-Random -Min -25 -Max 25)
"$x,$y"
}
'Z'
) -join ' '
$toPoints = @(
'M'
foreach ($n in 1..$SideCount) {
$x = 50 + (Get-Random -Min -25 -Max 25)
$y = 50 + (Get-Random -Min -25 -Max 25)
"$x,$y"
}
'Z'
) -join ' '
$colorAnimation = @(
"<animate attributeName='fill' dur='$($RefreshIn / 1000)s' values='$($RandomFill, $randomStroke, $randomFill -join ';')' repeatCount='indefinite' />"
"<animate attributeName='stroke' dur='$($RefreshIn / 1000)s' values='$($randomStroke, $randomFill, $randomStroke -join ';')' repeatCount='indefinite' />"
)
@(
"<svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%' viewBox='0 0 100 100'>"
"<circle cx='50' cy='50' r='40' stroke='$RandomStroke' stroke-width='3' fill='$randomFill'>"
$colorAnimation
"</circle>"
"<path d='$fromPoints' fill='$randomFill' stroke='$randomStroke' stroke-width='1%'>"
"<animate attributeName='d' dur='$($RefreshIn / 1000)s' values='$($FromPoints, $toPoints, $fromPoints -join ';')' repeatCount='indefinite' />"
$colorAnimation
"</path>"
"</svg>"
) -join [Environment]::newLine
}
function /Media {
if (-not $request.Url.Query) { return 404 }
$parsedQueryString = [Web.HttpUtility]::ParseQueryString($request.Url.Query)
$mediaFile = $parsedQueryString['file']
if (-not $mediaFile) { return 404 }
$mediaFileExists = Get-Item $mediaFile
if (-not $mediaFileExists) { return 404 }
switch ($mediaFileExists.Extension) {
'.mp3' { $response.ContentType = 'audio/mpeg' }
'.wav' { $response.ContentType = 'audio/wav' }
'.ogg' { $response.ContentType = 'audio/ogg' }
'.avi' { $response.ContentType = 'video/x-msvideo' }
'.mkv' { $response.ContentType = 'video/x-matroska' }
'.mpg' { $response.ContentType = 'video/mpeg' }
'.mp4' { $response.ContentType = 'video/mp4' }
'.webm' { $response.ContentType = 'video/webm' }
default {
return 415
}
}
return $mediaFileExists
}
Set-Alias /Audio /Media
Set-Alias /Video /Media
function /3D {
$Random3dScene = @(
"let geometry = null"
"let material = null"
"let newshape = null"
"let shapes = []"
foreach ($n in 1..(Get-Random -Min 1 -Max 16)) {
@"
geometry = $(
switch ('Box', 'Sphere', 'Cylinder','Cone','Torus','TorusKnot','Ring' | Get-Random) {
Box {
"new THREE.BoxGeometry( $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 1 -Max 24) );"
}
Sphere {
"new THREE.SphereGeometry( $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 1 -Max 24) );"
}
Cylinder {
"new THREE.CylinderGeometry( $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 3 -Max 12) );"
}
Cone {
"new THREE.ConeGeometry( $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 3 -Max 12) );"
}
Torus {
"new THREE.TorusGeometry( $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 3 -Max 12), $(Get-Random -Min 3 -Max 12) );"
}
TorusKnot {
"new THREE.TorusKnotGeometry( $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 3 -Max 12), $(Get-Random -Min 3 -Max 12), $(Get-Random -Min 3 -Max 12) );"
}
Ring {
"new THREE.RingGeometry( $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 1 -Max 24), $(Get-Random -Min 3 -Max 12) );"
}
}
)
material = $(
switch ('MeshBasicMaterial', 'LineBasicMaterial', 'LineDashedMaterial' | Get-Random) {
MeshBasicMaterial {
"new THREE.MeshBasicMaterial( { color: 0x$("{0:x6}" -f (Get-Random -Max 0xffffff)), wireframe: $('true', 'false' | Get-Random) } );"
}
LineBasicMaterial {
"new THREE.LineBasicMaterial( { color: 0x$("{0:x6}" -f (Get-Random -Max 0xffffff)), linewidth: $(Get-Random -Min 1 -Max 3) } );"
}
LineDashedMaterial {
"new THREE.LineDashedMaterial( { color: 0x$("{0:x6}" -f (Get-Random -Max 0xffffff)), linewidth: $(Get-Random -Min 1 -Max 3), dashSize: $(Get-Random -Min 1 -Max 10) } );"
}
}
)
newshape = new THREE.Mesh( geometry, material );
newshape.position.x = $(Get-Random -Min -100 -Max 100);
newshape.position.y = $(Get-Random -Min -100 -Max 100);
newshape.position.z = $(Get-Random -Min -100 -Max 100);
newshape.rotation.x = $(Get-Random -Min 0 -Max 180);
newshape.rotation.y = $(Get-Random -Min 0 -Max 180);
newshape.rotation.z = $(Get-Random -Min 0 -Max 180);
scene.add(newshape);
shapes.push(newshape);
"@
}
) -join [Environment]::NewLine
$OrbitSpeed = (Get-Random -Min 1 -Max 100)*.01
$Random3dControls = @(
"
let controls = new OrbitControls( camera, renderer.domElement );
controls.minDistance = $(Get-Random -Min 1 -Max 10);
controls.maxDistance = $(Get-Random -Min 1 -Max 10);
controls.autoRotate = true;
controls.autoRotateSpeed = $OrbitSpeed;
controls.listenToKeyEvents( window );
controls.enableDamping = true;
controls.addEventListener( 'change', renderer.render( scene, camera ) );
"
)
$sceneAnimation = @(
@"
for (let i = 0; i < shapes.length; i++) {
let cube = shapes[i];
cube.rotation.x += $((Get-Random -Min 1 -Max 100) / 1000);
cube.rotation.y += $((Get-Random -Min 1 -Max 100) / 1000);
}
"@
) -join [Environment]::NewLine
$3dScene = @"
import * as THREE from 'three';
import { CSS3DRenderer, CSS3DObject } from 'three/addons/renderers/CSS3DRenderer.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TrackballControls } from 'three/addons/controls/TrackballControls.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
$Random3dScene
$(
if ($CssRenderer) {
"
const renderer = new CSS3DRenderer();
document.getElementById( 'container-3d' ).appendChild( renderer.domElement );
"
} else {
"
const renderer = new THREE.WebGLRenderer({alpha: true});
renderer.setClearColor( 0xffffff, 0 );
renderer.setAnimationLoop( animate );
document.body.appendChild( renderer.domElement );
"
}
)
renderer.setSize( window.innerWidth, window.innerHeight );
camera.position.z = $(Get-Random -Min 100 -Max 200);
window.addEventListener( 'resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.render( scene, camera );
} );
$Random3dControls
function animate() {
$sceneAnimation
renderer.render( scene, camera );
}
"@
@"
<html lang='$(Get-Culture)'>
<head>
<meta charset="utf-8">
<title>There is no route table</title>
<style>body { margin: 0; }</style>
</head>
<script type="importmap">$(
ConvertTo-JSON -InputObject ([Ordered]@{
"imports" = [Ordered]@{
"three" = "https://cdn.jsdelivr.net/npm/three@latest/build/three.module.js"
"three/addons/" = "https://cdn.jsdelivr.net/npm/three@latest/examples/jsm/"
}
})
)</script>
</head>
<body>
<div id="container-3d"></div>
<script type="module">$3dScene</script>
</body>
"@
}
#endregion Server functions
#region Watch for events
# While the listener is listening:
while ($listener.IsListening) {
# Get every http* event
foreach ($event in @(Get-Event HTTP*)) {
# Try to get the context, request, and response from the event
$context, $request, $response = $event.SourceArgs
# and if there is no output stream, continue
if (-not $response.OutputStream) {
continue
}
# If we haven't already, cache a pointer to possible routes.
if (-not $script:PossibleRoutes) {
# (in this case, we'll presume any command with a slash in it could be a route)
$script:PossibleRoutes = $ExecutionContext.SessionState.InvokeCommand.GetCommands('*/*','Alias,Function', $true)
}
$mappedCommand = $null
$schemeAndHostSegment = $request.Url.Scheme,
'://',
$request.Url.DnsSafeHost -join ''
$portSegment =
if ($request.Url.Port -notin '80', '443') {
':' + $request.Url.Port
}
# Now let's create a list of possible route names for this request, in the order we'd prefer them
$possibleRouteNames = @(
# $schemeAndHostSegment, $portSegment, $request.Url.LocalPath -join ''
# $schemeAndHostSegment, $request.Url.LocalPath -join ''
# "$schemeAndHostSegment/"
# $schemeAndHostSegment
$request.Url.LocalPath
# For this example, we'll just use the local path.
# (this will work for a single server, for multitenant hosting, you'd need to include the host)
)
# Now we'll loop through the possible route names
foreach ($possibleRouteName in $possibleRouteNames ) {
# and see if a command exists for that route
$commandExists = @($script:PossibleRoutes -match "^$([Regex]::Escape($possibleRouteName))$")[0]
if ($commandExists) {
$mappedCommand = $commandExists
break
}
}
# If we've mapped a command
if ($mappedCommand) {
# Run it, and capture all of the streams
$result = . $mappedCommand $request *>&1
# The result can tell us it is a content type by giving itself a content type as a type name
$ContentTypePattern = '^(?>audio|application|font|image|message|model|text|video)/.+?'
$resultIsContentType = @($result.pstypenames -match $ContentTypePattern)[0]
# If the result was a content type
if ($resultIsContentType) {
# set that header
$response.ContentType = $resultIsContentType
}
# If the result was a string
if ($result -is [int] -and $result -ge 300 -and $result -lt 600) {
# set the status code
$response.StatusCode = $result
$response.Close()
}
elseif ($result -is [string]) {
# encode it using $OutputEncoding and close the response
$response.Close($outputEncoding.GetBytes($result), $false)
}
# If the result was a byte[]
elseif ($result -is [byte[]]) {
# respond with the bytes
$response.Close($result, $false)
}
elseif ($result -is [IO.FileInfo]) {
$BufferSize = 1mb
$serveFileJob = Start-ThreadJob -Name ($Request.Url -replace '^https?', 'file') -ScriptBlock {
param($result, $Request, $response, $BufferSize = 1mb)
if ($request.Method -eq 'HEAD') {
$response.ContentLength64 = $result.Length
$response.Close()
return
}
$response.Headers["Accept-Ranges"] = "bytes";
$range = $request.Headers['Range']
$rangeStart, $rangeEnd = 0, 0
$fileStream = [IO.File]::OpenRead($result.Fullname)
if ($range) {
$null = $range -match 'bytes=(?<Start>\d{1,})(-(?<End>\d{1,})){0,1}'
$rangeStart, $rangeEnd = ($matches.Start -as [long]), ($matches.End -as [long])
}
if ($rangeStart -gt 0 -and $rangeEnd -gt 0) {
$buffer = [byte[]]::new($BufferSize)
$fileStream.Seek($rangeStart, 'Begin')
$bytesRead = $fileStream.Read($buffer, 0, $BufferSize)
$contentRange = "$RangeStart-$($RangeStart + $bytesRead - 1)/$($fileStream.Length)"
$response.StatusCode = 206;
$response.ContentLength64 = $bytesRead;
$response.Headers["Content-Range"] = $contentRange
$response.OutputStream.Write($buffer, 0, $bytesRead)
$response.OutputStream.Close()
} else {
# if that stream has a content length
if ($result.ContentLength64 -gt 0) {
# set the content length
$response.ContentLength64 = $result.ContentLength64
}
# Then copy the stream to the response.
$fileStream.CopyTo($response.OutputStream)
}
$response.Close()
$fileStream.Close()
$fileStream.Dispose()
} -ThrottleLimit 100 -ArgumentList $result, $request, $response
}
else {
# otherwise, convert the result to JSON
# and set the content type to application/json if it is not already set
if (-not $response.ContentType) {
$response.ContentType = 'application/json'
}
$response.Close($outputEncoding.GetBytes((ConvertTo-Json -InputObject $result)), $false)
}
Write-Host "Responded to $($request.Url) in $([DateTime]::Now - $event.TimeGenerated)" -ForegroundColor Cyan
}
else {
$response.StatusCode = 404
$response.Close()
}
$event | Remove-Event
}
}
#endregion Watch for events
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment