Commands/Start-OpenPackage.ps1

function Start-OpenPackage {
    <#
    .SYNOPSIS
        Starts a OpenPackage Server
    .DESCRIPTION
        Starts a server, using one or more archive packages as the storage.
    .NOTES
        If a URI in the package is requested, that URI will be returned.

        If a path does not have an extension, it will search for an .index.html.

        If the file was not found, a 404 code will be returned.
        
        If the package contains a `/404.html`, the content in this file will be returned with the 404

        If another method than GET or HEAD is used, a 405 code will be returned.

        If the package contains a `/405.html`, then content in this file will be returned with the 405.
    .LINK
        Get-OpenPackage
    #>

    [Alias('Start-OP','stOpenPackage')]
    [CmdletBinding(PositionalBinding=$false)]
    param(
    # The path to an Open Package file, or a glob that matches multiple Open Package files.
    [Parameter(ValueFromRemainingArguments)]
    [Alias('Arguments','Args','At','Url', 'AtUri', 'FilePath','Repository','Nuget')]
    [PSObject[]]
    $ArgumentList,    

    # The root url.
    # By default, this will be automatically to a random local port.
    # If running elevated, can be any valid http listener prefix, including `http://*/`
    [string]
    $RootUrl = "http://127.0.0.1:$(Get-Random -Minimum 4200 -Maximum 42000)/",

    # The input object. This can be provided to avoid loading a file from disk.
    [Parameter(ValueFromPipeline)]
    [Alias('Package')]
    [PSObject[]]
    $InputObject,
        
    # The allowed http verbs.
    [string[]]
    $Allow = @('get', 'head'),
    
    # The content type map
    [Collections.IDictionary]
    $TypeMap = $(
        ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap
    ),

    # A Route Table
    [Collections.IDictionary]
    $Route = [Ordered]@{},

    # The throttle limit.
    # This is the number of concurrent jobs that can be running at once.
    [uint16]$ThrottleLimit = .5kb,

    # The buffer size.
    # If parts are smaller than this size, they will be streamed.
    # If parts are larger than this size, they will be handled in the background
    # (and may use a buffer of this size when accepting range requests)
    [uint]$BufferSize = 16mb,

    # The lifespan of the server.
    # If provided, will automatically stop the server after it's life is over.
    [TimeSpan]$Lifespan,
    
    # The number of nodes to run.
    # Each node can handle incoming requests.
    [byte]$NodeCount = 2
    )

    begin {
        # Requires Start-ThreadJob
        $startThreadJob = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Start-ThreadJob', 'Cmdlet,Function')
        if (-not $startThreadJob) {
            Write-Error (@(
                "This feature requires Start-ThreadJob,"
                "which is included in more recent versions of PowerShell."
            ) -join [Environment]::NewLine)
            return
        }        
        $InitializationScript = { 
            filter serverStatus {
                $errorCode = $_
                $response.StatusCode = $errorCode
                foreach ($pack in $package) {
                    if ($pack.PartExists("/$errorCode.html")) {
                        "/$errorCode.html" | servePart
                        continue nextRequest
                    }
                    if ($pack.PartExists("/$errorCode.md")) {
                        "/$errorCode.md" | servePart
                        continue nextRequest
                    }
                }
                $response.Close()
            }


            filter findPart {
                foreach ($pack in $package) {
                    try {
                        if ($pack.PartExists($request.url.localPath)) {
                            return $request.url.localPath
                        }                        
                    } catch {

                    }
                }

                $potentialUris = 
                    if ($request.url.LocalPath -and 
                        $request.url.LocalPath -notmatch '\.[^\./]+?$') {
                        
                        if ($request.url.localPath -ne '/') {
                            [IO.Packaging.PackUriHelper]::CreatePartUri($request.url.localPath)
                        }
                        if ($Route -and $route[$request.Url.LocalPath]) {
                            [IO.Packaging.PackUriHelper]::CreatePartUri("$($route[$request.Url.LocalPath])")
                        }
                        $noTrailingSlash = ($request.url.LocalPath -replace '/$')
                        if ($noTrailingSlash) {                            
                            try {
                                [IO.Packaging.PackUriHelper]::CreatePartUri($noTrailingSlash)
                            } catch {
                                Write-Warning "$_ - $($request.Url) - $($request.Url.LocalPath)" 
                            }
                        }                                                
                        $noTrailingSlash + '/index.html' 
                        $noTrailingSlash + '/README.html'
                        $noTrailingSlash + '/README.md'                        
                        $noTrailingSlash + '/index.json' 
                        $noTrailingSlash + '/index.xml'
                        $noTrailingSlash + '.html'
                        $noTrailingSlash + '.md'
                    } elseif ($request.url.LocalPath) {                        
                        if ($Route -and $route[$request.Url.LocalPath]) {
                            [IO.Packaging.PackUriHelper]::CreatePartUri("$($route[$request.Url.LocalPath])")
                        }                        
                        try {
                            [IO.Packaging.PackUriHelper]::CreatePartUri($request.url.LocalPath)
                        } catch {
                            Write-Warning "$_ - $($request.Url) - $($request.Url.LocalPath)" 
                        }                                                
                    } else {
                        @()
                    }

                :nextPotential foreach ($potentialUri in $potentialUris) {
                    :nextPack foreach ($pack in $package) {
                        if ($pack.PartExists($potentialUri)) {
                            return $potentialUri
                        }
                        :nextPart foreach ($part in $pack.GetParts()) {                            
                            if ($part.Uri -eq $potentialUri) {
                                return $part.Uri
                            }
                        }
                    }
                }                
            }

            # declare a little filter to serve a part
            filter servePart {
                $uriPart = $_                
                $packagePart = 
                    foreach ($pack in $package) {
                        if ($pack.PartExists -and $pack.PartExists($uriPart)) {
                            $pack.GetPart($uriPart)
                            break
                        }
                    }
                if ($uriPart -match '\.[^.]+?$' -and 
                    $TypeMap[$matches.0]
                ) {
                    $response.ContentType = $TypeMap[$matches.0]
                } else {
                    $response.ContentType = $packagePart.ContentType
                }

                # If we are invokable and are dealing with a script file
                if ($Route -and 
                    @($route.Values) -contains $packagePart.Uri -and
                    $packagePart.Uri -match '\.ps1$' -and 
                    $packagePart.Reader
                ) {
                    if ($packagePart.Uri -match '\.ps1$') {
                        
                        $packageScript = $packagePart.Read()
                        $packageParameterNames = [Ordered]@{}                        
                        :nextParameter foreach ($packageScriptParameter in $packageScript.Ast.ParamBlock.Parameters) {                            
                            if ($packageScriptParameter.Attributes.typename -match 'hidden') {
                                continue nextParameter
                            }
                            $parameterName = "$($packageScriptParameter.Name.VariablePath.UserPath)"
                            foreach ($attr in $packageScriptParameter.Attributes) {                                
                                if ($attr.typename -ne 'alias') { continue }
                                foreach ($parameterAlias in $attr.PositionalArguments.Value) {
                                    $packageParameterNames[$parameterAlias] = $parameterName
                                }                                
                            }
                            $packageParameterNames[$parameterName] = $parameterName                            
                        }
                        
                        $packageScriptParameters = [Ordered]@{}
                        if ($request.Url.Query) {
                            $parsedQuery = [Web.HttpUtility]::ParseQueryString($request.Url.Query)
                            foreach ($queryKey in $parsedQuery.Keys) {
                                if (-not $packageParameterNames[$queryKey]) {
                                    continue
                                }
                                $parameterName = $packageParameterNames[$queryKey]
                                if ($null -eq $packageScriptParameters[$parameterName]) {
                                    $packageScriptParameters[$parameterName] = $parsedQuery[$queryKey]
                                } else {
                                    $packageScriptParameters[$parameterName] = @(
                                        $packageScriptParameters[$parameterName]
                                    ) + $parsedQuery[$queryKey]
                                }
                            }
                        }

                        Write-Warning "Invoking $($packagePart.Uri) from $($request.Url)"

                        $streamOutput = {
                            param($reply)

                            begin {
                                if (-not $reply.OutputStream) { throw "no output stream" ; return }
                                $reply.ProtocolVersion = '1.1'
                                $reply.SendChunked = $true
                            }

                            process {                    
                                $in = $_
                                
                                if ($in.OuterXml) {                                                
                                    $buffer = $OutputEncoding.GetBytes("$($in.OuterXml)")
                                    $reply.OutputStream.Write($buffer, 0, $buffer.Length)
                                    $reply.OutputStream.Flush()
                                } 
                                elseif ($in.html) {                        
                                    $buffer = $OutputEncoding.GetBytes("$($in.html)")
                                    $reply.OutputStream.Write($buffer, 0, $buffer.Length)
                                    $reply.OutputStream.Flush()
                                }
                                else {
                                    # or the stringification of the result.
                                    $buffer = $OutputEncoding.GetBytes("$in")
                                    $reply.OutputStream.Write($buffer, 0, $buffer.Length)
                                    $reply.OutputStream.Flush()
                                }
                            }

                            end {
                                if ($reply.Close) {
                                    $reply.Close()
                                }
                            }
                        }

                        try {
                            & $packageScript @packageScriptParameters |
                                . $streamOutput $response
                        } catch {
                            $response.StatusCode = 500
                            if ($response.OutputStream.CanWrite) {
                                $response.Close(
                                    [Text.Encoding]::UTF8.GetBytes("$_"), $false
                                )
                            } else {
                                $response.Close()
                            }                                                       
                        }                    
                    }
                    continue nextRequest
                }

                $acceptableTypes = @($request.Headers['Accept'] -split ',') 
                Write-Host "Accepts $($request.Headers['Accept'])" -ForegroundColor Cyan                
                if (
                    (
                        $packagePart.ContentType -eq 'text/markdown' -or 
                        $packagePart.Uri -match '(?>\.md|\.markdown)$'
                    ) -and 
                    (
                        $acceptableTypes[0] -ne 'text/markdown' -and
                        $request.Headers['Content-Type'] -ne 'text/markdown'
                    )
                ) {                                                            
                    $response.ContentType = 'text/html'
                    $response.Close([Text.Encoding]::UTF8.GetBytes("$(
                        @(
                            $packagePart | Format-OpenPackage -View Markdown.html
                        ) -join [Environment]::NewLine
                    )"
), $false)                    
                    return
                }

                $partStream = $packagePart.GetStream('Open', 'Read')

                Write-Host "$($request.HttpMethod) $uriPart $($response.ContentType)" -ForegroundColor Cyan
                
                if ($partStream.Length -lt $BufferSize) {
                    $partStream.CopyTo($response.OutputStream)
                    $partStream.Close()
                    $partStream.Dispose()
                    $response.Close()
                    return 
                }                

                Start-ThreadJob -Name ($Request.Url -replace '^https?', 'part://') -ScriptBlock {
                    param($partStream, $Request, $response, $BufferSize = 1mb)
                    if (-not $partStream) {
                        if ($response.Close) {$response.Close()}
                        return
                    }
                    if ($request.Method -eq 'HEAD') {                        
                        $response.ContentLength64 = $partStream.Length
                        Write-Verbose "Serving HEAD request $($Request.url) - $partStreamLength"
                        $partStream.Close()
                        $null = $partStream.DisposeAsync()
                        $response.Close()
                        return
                    }
                                        
                    $response.Headers["Accept-Ranges"] = "bytes";
                    $range = $request.Headers['Range']
                    $rangeStart, $rangeEnd = -1, 0                    
                    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 -ge 0 -and $rangeEnd -gt 0) {                        
                        Write-Verbose -Verbose "Serving Request Range $($Request.url) : $($rangeStart)-$($rangeEnd)"                        
                        $buffer = [byte[]]::new($BufferSize)
                        $null = $partStream.Seek($rangeStart, 'Begin')
                        $bytesRead = $partStream.Read($buffer, 0, $BufferSize)                        
                        $contentRange = "$RangeStart-$($RangeStart + $bytesRead - 1)/$($partStream.Length)"
                        $response.StatusCode = 206
                        $response.ContentLength64 = $bytesRead
                        $response.Headers["Content-Range"] = $contentRange
                        $response.OutputStream.Write($buffer, 0, $bytesRead)
                        $response.OutputStream.Close()
                    } else { 
                        Write-Verbose -Verbose "Serving Request without range $($Request.url)"                        
                        # if that stream has a content length
                        if ($partStream.Length -gt 0) {
                            # set the content length
                            $response.ContentLength64 = $partStream.Length
                        }
                        # Then copy the stream to the response.
                        try {
                            $partStream.CopyTo($response.OutputStream)
                        } catch {
                            Write-Warning "$_"
                        }                        
                    }                       
                    $response.Close()                    
                    $partStream.Close()
                    $null = $partStream.DisposeAsync()
                } -ThrottleLimit 1kb -ArgumentList $partStream, $request, $response, $BufferSize

                return
            }
        }
        $JobDefinition = {
            param([Collections.IDictionary]$IO)
            
            # unpack our IO into local variables
            foreach ($variableName in $IO.Keys) {
                $ExecutionContext.SessionState.PSVariable.Set($variableName, $IO[$variableName])
            }

            if ($ImportModule) {
                Write-Warning "Importing Modules $importModule"
                $imported = Import-Module -Name $ImportModule -PassThru
                Write-Warning "Imported Modules $imported"
            }

            # declare some inner functions to help serve

            $ServerStartTime = [DateTime]::Now

            # and start listening
            :nextRequest while ($httpListener.IsListening) {                
                $getContextAsync = $httpListener.GetContextAsync()
                # wait in short increments to minimize CPU impact and stay snappy
                while (-not $getContextAsync.Wait(23)) {
                    # while we're waiting, check our lifespan
                    if ($Lifespan -and (
                        ($ServerStartTime + $Lifespan) -ge [DateTime]::Now
                    )) {
                        $httpListener.Stop()
                    }
                }

                # If the counter is a long
                if ($IO.Counter -is [long]) {
                    $IO.Counter += 1 # increment the counter.
                }
                
                # Get our listener context
                $context = $getContextAsync.Result                

                # and break that into a result and response
                $request, $response = $context.Request, $context.Response

                if ($request.Url.LocalPath -eq '/favicon.ico') {
                    $response.StatusCode = 404
                    $response.Close()
                    continue nextRequest
                }

                $MessageData = [Ordered]@{
                    Url = $request.Url
                    Context = $context
                    Request = $request
                    Response = $response
                    Package = $package
                    Handled = $false
                }
                if ($parentRunspace) {
                    $requestEvent = $parentRunspace.Events.GenerateEvent(
                        $request.Url.Scheme,
                        $httpListener,
                        @($request, $response, $context),
                        $MessageData,
                        $false,
                        $true
                    )                    
                }

                # If the request has no output stream or it was handled by an event
                if (-not $response.OutputStream -or $MessageData.Handled) {
                    # continue to the next request
                    continue nextRequest
                }

                $requestTime = $requestEvent.TimeGenerated
                Write-Host -ForegroundColor Cyan "[$($requestTime.ToString('o'))] $($request.HttpMethod) $($request.Url)"
                # If they asked for an inappropriate method
                if ($request.HttpMethod -notin $Allow) {
                    # use the appropriate status code
                    
                    Write-Host -ForegroundColor Red "[$($requestTime.ToString('o'))] 405 $($request.HttpMethod) $($request.Url)"
                    405 | serverStatus
                    # and continue to the next request
                    continue nextRequest
                }


                # If we're allowing additional methods, we can easily do CRUD operations
                switch -regex ($request.HttpMethod) {
                    # Put or Post changes file content.
                    '(?>put|post)' {
                        $anythingChanged = $false                        
                        $memoryStream = [IO.MemoryStream]::new()
                        if ($request.InputStream.CanRead) {
                            $request.InputStream.CopyTo($memoryStream)
                        }
                        
                        :packageWrite foreach ($pack in $package) {
                            if ($pack.FileOpenAccess -ne 'ReadWrite') {
                                continue
                            }
                            
                            $partStream =
                                if ($pack.PartExists($request.Url.LocalPath)) {
                                    $pack.GetPart($request.Url.LocalPath).GetStream()
                                } else {
                                    $newPart = $pack.CreatePart($request.Url.LocalPath, $request.ContentType, 'Superfast')
                                    $newPart.GetStream()
                                }

                            $null = $memoryStream.Seek(0, 'begin')
                            $partStream.SetLength($memoryStream.Length)
                            $memoryStream.CopyTo($partStream)
                            $partStream.Close()
                            $partStream.Dispose()
                            $anythingChanged = $true
                            break packageWrite
                        }

                        if ($anythingChanged -and
                            $request.HttpMethod -eq 'put') {
                            201 | serverStatus 
                            continue nextRequest
                        }
                    }
                    'delete' {
                        $anythingDeleted = $false
                        :packageDelete foreach ($pack in $package) {
                            if ($pack.FileOpenAccess -eq 'ReadWrite' -and $pack.PartExists(
                                $request.Url.LocalPath
                            )) {
                                $pack.DeletePart($request.Url.LocalPath)
                                $anythingDeleted = $true
                                break packageDelete
                            }
                        }
                        if ($anythingDeleted) {
                            204 | serverStatus 
                            continue nextRequest
                        }
                    }
                }                                                

                $foundPart = . findPart

                if ($foundPart) {
                    $foundPart | servePart
                    continue nextRequest
                }
                
                $uriPart = ($request.Url.LocalPath -replace '/$') + '/'
                
                if ($uriPart -match '/$') {                    
                    Write-Host -ForegroundColor Cyan "[$($requestTime.ToString('o'))] $($request.HttpMethod) $($request.Url) Missing index, generating"
                    
                    $response.ContentType = 'text/html'
                    $response.Close(
                        $OutputEncoding.GetBytes("$(
                            $pack | Format-OpenPackage -View Tree.html -Option @{
                                FilePattern = [regex]::Escape($request.Url.LocalPath)
                            }
                        )"
), $false
                    )
                    
                    continue nextRequest
                }
                else {
                    Write-Host -ForegroundColor Cyan "[$($requestTime.ToString('o'))] Marco $($request.HttpMethod) $($request.Url)"
                }
                
                # If we did not find a part, set the appropriate status code
                404 | serverStatus
            } 
        }
        $generateEvent = [Runspace]::DefaultRunspace.Events.GenerateEvent 
    }

    end {
        # Rapidly collect all pipeline input
        $allInput = @($input)

        # Get our packages
        # Each server can have any number of packages
        # The order packages are defined is the order they are resolved
        # This allows us to have any number of layers, in any order we want.
        $packages = @(
            # First up, lets process our input objects
            # (piped in objects come first)
            $remainingInput = @()                        
            foreach ($in in $allInput) {
                # Anything that is a package works
                if ($in -is [IO.Packaging.Package]) {
                    $in                    
                } 
                # so does anything that has a .Package property
                elseif (
                    $in.Package -is [IO.Packaging.Package]
                ) {
                    $in.Package
                }
                # anything else we will pipe to Get-OpenPackage
                else
                {
                    $remainingInput += $in
                }
            }
            # Now lets check a bound -InputObject
            # If piped in, this will potentially be a duplicated
            # (because `$InputObject` will contain the last bound value)
            foreach ($in in $InputObject) {
                # Skip any input we already have
                if ($allInput -contains $in) {
                    continue
                } 
                # If the -InputObject was a package
                if ($in -is [IO.Packaging.Package]) {
                    $in # this works
                }
                # Otherwise, if the -InputObject has a .Package
                elseif (
                    $in.Package -is [IO.Packaging.Package] -and 
                    # and it is not a package we already have collected
                    ($allInput.Package -notcontains $in.Package)
                ) {
                    # then .Package works.
                    $in.Package
                }
                # Otherwise, we will pipe remaining input to Get-OpenPackage
                elseif ($remainingInput -notcontains $in) {
                    $remainingInput += $in
                }
            }
            # If there was remaining input
            if ($remainingInput) {
                # pipe it to Get-OpenPackage
                $remainingInput | Get-OpenPackage @ArgumentList
            }
            # If we had arguments,
            elseif ($ArgumentList) {            
                # call Get-OpenPackage.
                Get-OpenPackage @ArgumentList
            }
        )

        # Now we have a list of all of of potential packages
        # Let's make one last pass thru for safety and sanity
        $package = @(
            # and include only the packages
            foreach ($pack in $packages) {
                if ($pack -is [IO.Packaging.Package]) {
                    $pack
                }
            }
        )

        # If we have no actual packages, return.
        if (-not $package) { return }        

        # Now that we know _what_ we're serving,
        # create a server by creating an http listener.
        $httpListener = [Net.HttpListener]::new()
        # and adding the root url prefix
        $httpListener.Prefixes.Add($RootUrl)

        # Create an IO object to populate the background runspace
        # By using an IO object, we can more easily communicate between runspaces.
        $IO = [Ordered]@{
            HttpListener = $httpListener
            Package = $package
            ParentRunspace = [Runspace]::DefaultRunspace
            Counter = [long]0
        } + $PSBoundParameters

        # If the IO does not have a typemap
        if (-not $io.TypeMap) {
            # copy the typemap
            $io.TypeMap = $TypeMap
        }

        # If the IO does not have an -Allow
        if (-not $io.Allow) {
            # use the default values for Allow.
            $io.Allow = $Allow
        }
        # If the IO does not have a bufferSize
        if (-not $io.BufferSize) {
            # use the default buffer size
            $io.BufferSize = $BufferSize
        }

        # If the IO does not have a route table
        if (-not $io.Route) {
            # use the default route table (this should be empty)
            $io.Route = $Route
        }

        # Now we're almost ready to serve,
        # it's time to send an event.
        # We need to prepare our message data with the relevant info
        $messageData = [Ordered]@{
            RootUrl = $RootUrl
            Package = $package
            HttpListener = $httpListener
            InvocationInfo = $MyInvocation
            Command = $MyInvocation.MyCommand
            IO = $IO
        }
    
        # Generate an event
        $StartOpenPackageEvent = $generateEvent.Invoke(
            'Start-OpenPackage', # for Start-OpenPackage
            $MyInvocation.MyCommand, # sent by this command
            @(
                # containing MyInvocation and MessageData
                $MyInvocation, $messageData
            ),
            # And sending the message data dictionary along
            $messageData,
            # process in the current thread
            $true, 
            # and wait for completion.
            $true
        )

        # If the event was processed, and they said any form of "no"
        if ($StartOpenPackageEvent.MessageData.Rejected -or
            $StartOpenPackageEvent.MessageData.Reject -or
            $StartOpenPackageEvent.MessageData.No -or
            $StartOpenPackageEvent.MessageData.Deny
        ) {
            Write-Warning "Will not $($MyInvocation.Line)"
            return
        }        

        # Now let's start our listener
        try {
            $IO.HttpListener.Start()
        } catch {
            # if we could not, return
            $PSCmdlet.WriteError($_)
            return
        }
        
        # If that worked,
        if ($?) {
            # write a warning.
            # This serves two purposes:
            # 1. It lets people know that a server is running
            # 2. It gives people something a link to click.
            Write-Warning "Listening on $rootUrl"
        }        
        
        # If there was no package identifier
        if (-not $package.PackageProperties.Identifier) {
            # write another warning.
            Write-Warning "No Package Identifier"            
        }
        
        # Get ready to import our own module.
        $IO.ImportModule = @(
            if ($myInvocation.MyCommand.Module) {
                "$($MyInvocation.MyCommand.Module | Split-Path)"
            } else {
                Get-Module OP | Split-Path
            }            
        )

        # Remove the trailing slash from the root url
        # (prefixes require it, but it makes adding paths more annoying)
        $RootUrl = $RootUrl -replace '/$'
        # Prepare our parameters for Start-ThreadJob
        $JobParameters = [Ordered]@{
            ScriptBlock=$JobDefinition
            ArgumentList=$IO
            Name=$RootUrl
            ThrottleLimit = $ThrottleLimit
            InitializationScript = $InitializationScript
        }

        foreach ($nodeNumber in 1..$NodeCount) {
            # Start a thread job and add our properties
            $startedJob = Start-ThreadJob @JobParameters |
                Add-Member NoteProperty IO $IO -Force -PassThru |
                Add-Member NoteProperty HttpListener $httpListener -Force -PassThru |
                Add-Member NoteProperty Package $package -Force -PassThru |
                Add-Member NoteProperty Url $RootUrl -Force -PassThru

            # Decorate our return
            $startedJob.pstypenames.add('OpenPackage.Server')
            # and output our server
            $startedJob
        }                
    }
}