pwshweb.psm1

#requires -Version 5.1

<#
.SYNOPSIS
    Starts a lightweight PowerShell web server.

.DESCRIPTION
    Start-PwshWeb starts a lightweight HTTP web server similar to Python's http.server.
    It serves files from the current directory or a specified directory.

.PARAMETER Port
    The port number to listen on. Defaults to 8000.

.PARAMETER Path
    The directory to serve files from. Defaults to the current directory.

.PARAMETER AsJob
    Run the web server as a background job.

.PARAMETER WhatIf
    Shows what would happen if the cmdlet runs. The cmdlet is not run.

.PARAMETER Confirm
    Prompts you for confirmation before running the cmdlet.

.EXAMPLE
    Start-PwshWeb
    Starts a web server on port 8000 serving the current directory.

.EXAMPLE
    Start-PwshWeb -Port 8080 -Path C:\Website
    Starts a web server on port 8080 serving C:\Website.

.EXAMPLE
    Start-PwshWeb -Port 9000 -AsJob
    Starts a web server, servering the current dictory as a background job on port 9000.
#>

function Start-PwshWeb {
    [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = 'Default')]
    param(
        [Parameter(Position = 0, ParameterSetName = 'Default')]
        [Parameter(Position = 0, ParameterSetName = 'AsJob')]
        [ValidateRange(1, 65535)]
        [int]$Port = 8000,

        [Parameter(Position = 1, ParameterSetName = 'Default')]
        [Parameter(Position = 1, ParameterSetName = 'AsJob')]
        [ValidateScript({
            if (-not (Test-Path -Path $_ -PathType Container)) {
                throw "Path '$_' does not exist or is not a directory."
            }
            $true
        })]
        [string]$Path = (Get-Location).Path,

        [Parameter(ParameterSetName = 'AsJob')]
        [switch]$AsJob
    )

    begin {
        $resolvedPath = (Resolve-Path -Path $Path).Path
        $uri = "http://localhost:$Port/"
    }

    process {
        if (-not $PSCmdlet.ShouldProcess("web server on port $Port serving '$resolvedPath'", 'Start')) {
            return
        }

        # Define helper functions as strings to inject into the job scriptblock
        $getDirectoryListingFunction = @'
function Get-DirectoryListing {
    param($Path, $RequestPath, $RootPath)

    $displayPath = if ([string]::IsNullOrEmpty($RequestPath)) { '/' } else { "/$RequestPath/" }
    $escapedPath = [System.Net.WebUtility]::HtmlEncode($displayPath)

    $html = @"
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Directory listing for $escapedPath</title>
    <style>
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; }
        h1 { border-bottom: 1px solid #ccc; padding-bottom: 10px; }
        table { border-collapse: collapse; width: 100%; max-width: 800px; }
        th, td { text-align: left; padding: 8px; }
        th { border-bottom: 2px solid #ddd; }
        tr:nth-child(even) { background-color: #f9f9f9; }
        a { text-decoration: none; color: #0066cc; }
        a:hover { text-decoration: underline; }
        .size { text-align: right; }
    </style>
</head>
<body>
    <h1>Directory listing for $escapedPath</h1>
    <table>
        <tr><th>Name</th><th>Size</th><th>Modified</th></tr>
"@

    # Parent directory link
    if ($Path -ne $RootPath) {
        $parentUrl = if ($RequestPath -contains '/') {
            $RequestPath.Substring(0, $RequestPath.LastIndexOf('/'))
        } else { '' }
        $html += " <tr><td><a href='/$parentUrl'>..</a></td><td>-</td><td>-</td></tr>`n"
    }

    # List directories first
    Get-ChildItem -Path $Path -Directory -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object {
        $name = [System.Net.WebUtility]::HtmlEncode($_.Name)
        $url = if ([string]::IsNullOrEmpty($RequestPath)) { $_.Name } else { "$RequestPath/$($_.Name)" }
        $modified = $_.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss')
        $html += " <tr><td><a href='/$url/'>$name/</a></td><td>-</td><td>$modified</td></tr>`n"
    }

    # Then list files
    Get-ChildItem -Path $Path -File -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object {
        $name = [System.Net.WebUtility]::HtmlEncode($_.Name)
        $url = if ([string]::IsNullOrEmpty($RequestPath)) { $_.Name } else { "$RequestPath/$($_.Name)" }
        $size = Format-FileSize -Size $_.Length
        $modified = $_.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss')
        $html += " <tr><td><a href='/$url'>$name</a></td><td class='size'>$size</td><td>$modified</td></tr>`n"
    }

    $html += @"
    </table>
    <hr>
    <p><em>PwshWeb Server</em></p>
</body>
</html>
"@
    return $html
}
'@


        $formatFileSizeFunction = @'
function Format-FileSize {
    param([long]$Size)
    if ($Size -ge 1GB) { return '{0:N2} GB' -f ($Size / 1GB) }
    if ($Size -ge 1MB) { return '{0:N2} MB' -f ($Size / 1MB) }
    if ($Size -ge 1KB) { return '{0:N2} KB' -f ($Size / 1KB) }
    return "$Size bytes"
}
'@


        $getContentTypeFunction = @'
function Get-ContentType {
    param([string]$Extension)
    $mimeTypes = @{
        '.html' = 'text/html'
        '.htm' = 'text/html'
        '.css' = 'text/css'
        '.js' = 'application/javascript'
        '.json' = 'application/json'
        '.xml' = 'application/xml'
        '.txt' = 'text/plain'
        '.md' = 'text/markdown'
        '.jpg' = 'image/jpeg'
        '.jpeg' = 'image/jpeg'
        '.png' = 'image/png'
        '.gif' = 'image/gif'
        '.svg' = 'image/svg+xml'
        '.ico' = 'image/x-icon'
        '.pdf' = 'application/pdf'
        '.zip' = 'application/zip'
        '.mp3' = 'audio/mpeg'
        '.mp4' = 'video/mp4'
        '.webm' = 'video/webm'
    }
    $ext = $Extension.ToLower()
    if ($mimeTypes.ContainsKey($ext)) {
        return $mimeTypes[$ext]
    }
    return 'application/octet-stream'
}
'@


        $scriptBlock = {
            param($Port, $RootPath, $VerbosePreference, $GetDirectoryListingFunction, $FormatFileSizeFunction, $GetContentTypeFunction)

            # Import required types inside the job
            Add-Type -AssemblyName System.Net.HttpListener -ErrorAction SilentlyContinue
            Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue

            # Define helper functions
            Invoke-Expression $GetDirectoryListingFunction
            Invoke-Expression $FormatFileSizeFunction
            Invoke-Expression $GetContentTypeFunction

            $listener = $null
            $runspaceId = [System.Guid]::NewGuid().ToString()

            try {
                $listener = New-Object System.Net.HttpListener
                $listener.Prefixes.Add("http://+:$Port/")
                $listener.Start()

                # Create server info object
                $serverInfo = [PSCustomObject]@{
                    PSTypeName   = 'PwshWeb.ServerInfo'
                    Port         = $Port
                    Path         = $RootPath
                    Uri          = "http://localhost:$Port/"
                    State        = 'Running'
                    StartTime    = Get-Date
                    RunspaceId   = $runspaceId
                    Listener     = $listener
                }

                # Add custom type for formatting
                if (-not ($serverInfo.PSTypeNames -contains 'PwshWeb.ServerInfo')) {
                    $serverInfo.PSTypeNames.Insert(0, 'PwshWeb.ServerInfo')
                }

                Write-Verbose "PwshWeb server started on port $Port serving '$RootPath'" -Verbose:$($VerbosePreference -eq 'Continue')

                # Output server info once at start
                $serverInfo

                while ($listener.IsListening) {
                    $context = $null
                    try {
                        $contextTask = $listener.GetContextAsync()
                        while (-not $contextTask.Wait(100)) {
                            Start-Sleep -Milliseconds 10
                        }
                        $context = $contextTask.Result
                    }
                    catch {
                        break
                    }

                    if ($null -eq $context) { continue }

                    $request = $context.Request
                    $response = $context.Response
                    $requestPath = $request.Url.LocalPath.TrimStart('/')
                    $localPath = Join-Path -Path $RootPath -ChildPath $requestPath

                    Write-Verbose "[$runspaceId] $($request.HttpMethod) $($request.Url)" -Verbose:$($VerbosePreference -eq 'Continue')

                    try {
                        if (Test-Path -Path $localPath -PathType Container) {
                            # Generate directory listing
                            $content = Get-DirectoryListing -Path $localPath -RequestPath $requestPath -RootPath $RootPath
                            $buffer = [System.Text.Encoding]::UTF8.GetBytes($content)
                            $response.ContentType = 'text/html; charset=utf-8'
                            $response.ContentLength64 = $buffer.Length
                            $response.OutputStream.Write($buffer, 0, $buffer.Length)
                        }
                        elseif (Test-Path -Path $localPath -PathType Leaf) {
                            # Serve file
                            $fileInfo = Get-Item -Path $localPath
                            $contentType = Get-ContentType -Extension $fileInfo.Extension
                            $fileBytes = [System.IO.File]::ReadAllBytes($localPath)
                            $response.ContentType = $contentType
                            $response.ContentLength64 = $fileBytes.Length
                            $response.OutputStream.Write($fileBytes, 0, $fileBytes.Length)
                        }
                        else {
                            # 404 Not Found
                            $response.StatusCode = 404
                            $content = '<html><body><h1>404 Not Found</h1></body></html>'
                            $buffer = [System.Text.Encoding]::UTF8.GetBytes($content)
                            $response.ContentLength64 = $buffer.Length
                            $response.OutputStream.Write($buffer, 0, $buffer.Length)
                        }
                    }
                    catch {
                        Write-Verbose "[$runspaceId] Error: $_" -Verbose:$($VerbosePreference -eq 'Continue')
                        $response.StatusCode = 500
                        $errorMessage = $_.Exception.Message -replace '<', '<' -replace '>', '>'
                        $content = "<html><body><h1>500 Internal Server Error</h1><p>$errorMessage</p></body></html>"
                        $buffer = [System.Text.Encoding]::UTF8.GetBytes($content)
                        $response.ContentLength64 = $buffer.Length
                        $response.OutputStream.Write($buffer, 0, $buffer.Length)
                    }
                    finally {
                        $response.Close()
                    }
                }
            }
            catch {
                Write-Error "PwshWeb server error: $_"
            }
            finally {
                if ($null -ne $listener) {
                    $listener.Stop()
                    $listener.Close()
                }
                Write-Verbose "[$runspaceId] PwshWeb server stopped" -Verbose:$($VerbosePreference -eq 'Continue')
            }
        }

        if ($AsJob) {
            Write-Verbose "Starting PwshWeb server as background job..."

            # Create the job wrapper first with pending state
            $jobWrapper = [PSCustomObject]@{
                PSTypeName = 'PwshWeb.ServerJob'
                Job        = $null
                Port       = $Port
                Path       = $resolvedPath
                Uri        = $uri
                State      = 'Starting'
                StartTime  = Get-Date
            }

            # Add custom type for formatting
            if (-not ($jobWrapper.PSTypeNames -contains 'PwshWeb.ServerJob')) {
                $jobWrapper.PSTypeNames.Insert(0, 'PwshWeb.ServerJob')
            }

            $job = Start-Job -ScriptBlock $scriptBlock -ArgumentList $Port, $resolvedPath, $VerbosePreference, $getDirectoryListingFunction, $formatFileSizeFunction, $getContentTypeFunction
            $jobWrapper.Job = $job

            # Wait for the job to start and verify it's running
            $timeout = [DateTime]::Now.AddSeconds(10)
            $started = $false
            while ([DateTime]::Now -lt $timeout -and -not $started) {
                Start-Sleep -Milliseconds 200
                $jobState = $job.State
                if ($jobState -eq 'Running' -or $jobState -eq 'Blocked') {
                    # Give the listener time to actually bind to the port
                    Start-Sleep -Milliseconds 500
                    $started = $true
                }
            }

            if (-not $started) {
                $job | Stop-Job -ErrorAction SilentlyContinue
                $job | Remove-Job -ErrorAction SilentlyContinue
                throw "Failed to start PwshWeb server as background job. Job state: $($job.State)"
            }

            $jobWrapper.State = 'Running'
            Write-Verbose "PwshWeb server started as job $($job.Id) on port $Port"
            return $jobWrapper
        }
        else {
            # Run in current session
            Write-Verbose "Starting PwshWeb server in current session..."
            & $scriptBlock -Port $Port -RootPath $resolvedPath -VerbosePreference $VerbosePreference -GetDirectoryListingFunction $getDirectoryListingFunction -FormatFileSizeFunction $formatFileSizeFunction -GetContentTypeFunction $getContentTypeFunction
        }
    }
}

# Export the function
Export-ModuleMember -Function Start-PwshWeb