@@ -75,6 +75,12 @@ function Get-ModuleFastPlan {
7575 # This should enable HTTP/3 on Win11 22H2+ (or linux with http3 library) and PS 7.2+
7676 [void ][AppContext ]::SetSwitch(' System.Net.SocketsHttpHandler.Http3Support' , $true )
7777 }
78+
79+ # We pass this splat to all our HTTP requests to cut down on boilerplate
80+ $httpContext = @ {
81+ HttpClient = $httpClient
82+ CancellationToken = $cancelToken.Token
83+ }
7884 # Write-Progress -Id 1 -Activity 'Get-ModuleFast' -CurrentOperation 'Fetching module information from Powershell Gallery'
7985 }
8086 PROCESS {
@@ -85,6 +91,7 @@ function Get-ModuleFastPlan {
8591 }
8692 }
8793 END {
94+ # A deduplicated list of modules to install
8895 [HashSet [ComparableModuleSpecification ]]$modulesToInstall = @ {}
8996
9097 # We use this as a fast lookup table for the context of the request
@@ -103,7 +110,7 @@ function Get-ModuleFastPlan {
103110 # TODO: Capture this somewhere that we can use it to report in the deploy plan
104111 continue
105112 }
106- $task = Get-ModuleInfoAsync - Name $moduleSpec - HttpClient $httpclient - Uri $ Source - CancellationToken $cancelToken .Token
113+ $task = Get-ModuleInfoAsync @httpContext - Endpoint $ Source - Name $moduleSpec .Name
107114 $resolveTasks [$task ] = $moduleSpec
108115 $currentTasks.Add ($task )
109116 }
@@ -119,53 +126,112 @@ function Get-ModuleFastPlan {
119126 # TODO: Perform a HEAD query to see if something has changed
120127
121128 [Task [string ]]$completedTask = $currentTasks [$thisTaskIndex ]
122- [ComparableModuleSpecification ]$moduleSpec = $resolveTasks [$completedTask ]
129+ [ComparableModuleSpecification ]$currentModuleSpec = $resolveTasks [$completedTask ]
123130
124- Write-Debug " $moduleSpec `: Processing Response"
131+ Write-Debug " $currentModuleSpec `: Processing Response"
125132 # We use GetAwaiter so we get proper error messages back, as things such as network errors might occur here.
126133 # TODO: TryCatch logic for GetResult
127134 try {
128135 $response = $completedTask.GetAwaiter ().GetResult()
129136 | ConvertFrom-Json
137+ Write-Debug " $currentModuleSpec `: Received Response with $ ( $response.Count ) pages"
130138 } catch {
131139 $taskException = $PSItem.Exception.InnerException
132140 # TODO: Rewrite this as a handle filter
133141 if ($taskException -isnot [HttpRequestException ]) { throw }
134142 [HttpRequestException ]$err = $taskException
135143 if ($err.StatusCode -eq [HttpStatusCode ]::NotFound) {
136- throw [InvalidOperationException ]" $moduleSpec `: module was not found in the $Source repository. Check the spelling and try again."
144+ throw [InvalidOperationException ]" $currentModuleSpec `: module was not found in the $Source repository. Check the spelling and try again."
137145 }
138146
139147 # All other cases
140- $PSItem.ErrorDetails = " $moduleSpec `: Failed to fetch module $moduleSpec from $Source . Error: $PSItem "
148+ $PSItem.ErrorDetails = " $currentModuleSpec `: Failed to fetch module $currentModuleSpec from $Source . Error: $PSItem "
141149 throw $PSItem
142150 }
143151
144- # HACK: Need to add @type to make this more discriminate between a direct version query and an individual item
145- $responseItems = $response.catalogEntry ? $response.catalogEntry : $response.items.items.catalogEntry
152+ if (-not $response.count ) {
153+ throw [InvalidDataException ]" $currentModuleSpec `: invalid result received from $Source . This is probably a bug. Content: $response "
154+ }
146155
147- # TODO: This should be a separate function with a pipeline
156+ # If what we are looking for exists in the response, we can stop looking
157+ # HACK: We are making a big assumption that the v3 API will always return the latest version(s) in the index
158+ # TODO: Check every version range that our selected item might not have a higher candidate in a non-inlined index
159+ # TODO: Type the responses and check on the type, not the existence of a property.
160+ $entries = $response.items.items.catalogEntry
161+ [version []]$inlinedVersions = $entries.version
162+ | Where-Object {
163+ $PSItem -and ! $PSItem.contains (' -' )
164+ }
165+
166+ [Version ]$versionMatch = $moduleSpec.FindHighestMatch ($inlinedVersions )
167+ if ($versionMatch ) {
168+ Write-Debug " $currentModuleSpec `: Found satisfying version $versionMatch in the inlined index. TODO: Resolve dependencies"
169+ $selectedEntry = $entries | Where-Object version -EQ $versionMatch
170+ # TODO: Resolve dependencies in separate function
171+ } else {
172+ # Do a more detailed resolution
173+ Write-Debug " $currentModuleSpec `: not found in inlined index. Determining appropriate page(s) to query"
174+ throw [NotImplementedException ]' TODO: Find and fetch additional info from the paging'
175+ # If not inlined, we need to find what page(s) might have the candidate info we are looking for.
176+ # While this may seem inefficient, all pages but latest are static and have a long lifetime so we trade a
177+ # longer cold start to a nearly infinite requery, which will handle all subsequent dependency lookups.
178+ # stats show most modules have a few common dependencies, so caching all versions of those dependencies is
179+ # very helpful for fast performance
180+
181+ # HACK: Need to add @type to make this more discriminate between a direct version query and an individual item
182+ # TODO: Should probably typesafe and validate this using classes
183+
184+ # Lookup that ties ranges to their page leaf ID
185+ [Dictionary [ComparableModuleSpecification , String ]]$ranges = @ {}
186+ foreach ($range in $response.items ) {
187+ $spec = @ {
188+ Name = $currentModuleSpec.Name
189+ }
190+ if ($range.lower -eq $range.upper ) {
191+ $spec.RequiredVersion = $range.lower
192+ } else {
193+ $spec.MinimumVersion = $range.lower
194+ $spec.MaximumVersion = $range.upper
195+ }
196+ $ranges [$spec ] = $range .' @id'
197+ }
198+ $pages = $moduleSpec.FindMatches ($ranges.Keys ).foreach {
199+ $ranges [$PSItem ]
200+ }
201+ if (-not $pages ) {
202+ throw [InvalidOperationException ]" $currentModuleSpec `: a matching module was not found in the $Source repository that satisfies the version constraints. If this happens during dependency lookup, it is a bug in ModuleFast."
203+ }
204+ Write-Debug " $currentModuleSpec `: Found $ ( $pages.Count ) pages that might match the query."
205+
206+ # TODO: This is relatively slow and blocking, but we would need complicated logic to process it in the main task handler loop.
207+ # I really should make a pipeline that breaks off tasks based on the type of the response.
208+ # Good news is these pages should basically cache forever
209+ foreach ($page in $pages ) {
210+ $task = Get-ModuleInfoAsync @httpContext - Endpoint $Source - Uri $page
211+ $resolveTasks [$task ] = $currentModuleSpec
212+ $currentTasks.Add ($task )
213+ }
214+ # [Version[]]$candidateVersions = $responseItems.Version
215+ # | Where-Object { -not $PSItem.contains('-') } #TODO: Support Prerelease
148216
149- [Version []]$candidateVersions = $responseItems.Version
150- | Where-Object { -not $PSItem.contains (' -' ) } # TODO: Support Prerelease
217+ # $selectedVersion = Find-HighestSatisfiesVersion $moduleSpec $candidateVersions
218+ # if (-not $selectedVersion) {
219+ # throw "No module that satisfies $moduleSpec was found in $Source"
220+ # }
151221
152- $selectedVersion = Find-HighestSatisfiesVersion $moduleSpec $candidateVersions
153- if (-not $selectedVersion ) {
154- throw " No module that satisfies $moduleSpec was found in $Source "
222+ # $selectedEntry = $responseItems | Where-Object Version -EQ $selectedVersion
155223 }
156224
157- $selectedModule = $responseItems | Where-Object Version -EQ $selectedVersion
158- if ($selectedModule.count -ne 1 ) {
159- throw ' More than one selectedModule was specified. '
225+ if ($selectedEntry.count -ne 1 ) {
226+ throw ' Something other than exactly 1 selectedModule was specified. This should never happen and is a bug'
160227 }
161228
162229 $moduleInfo = [ComparableModuleSpecification ]@ {
163- ModuleName = $selectedModule .id
164- RequiredVersion = $selectedModule .version
230+ ModuleName = $selectedEntry .id
231+ RequiredVersion = $selectedEntry .version
165232 # TODO: Fix in Server API GUID = $moduleInfo.Guid
166233 }
167234
168-
169235 # Check if we have already processed this item and move on if we have
170236 if (-not $modulesToInstall.Add ($moduleInfo )) {
171237 Write-Debug " $moduleInfo ModulesToInstall already exists. Skipping..."
@@ -175,22 +241,22 @@ function Get-ModuleFastPlan {
175241 continue
176242 }
177243
178- Write-Verbose " $moduleInfo Added to ModulesToInstall."
244+ Write-Debug " $moduleInfo Added to ModulesToInstall."
179245
180246 # HACK: Pwsh doesn't care about target framework as of today so we can skip that evaluation
181247 # TODO: Should it? Should we check for the target framework and only install if it matches?
182- $dependencyInfo = $selectedModule .dependencyGroups.dependencies
248+ $dependencyInfo = $selectedEntry .dependencyGroups.dependencies
183249
184250 # Determine dependencies and add them to the pending tasks
185251 if ($dependencyInfo ) {
186252 # HACK: I should be using the Id provided by the server, for now I'm just guessing because
187253 # I need to add it to the ComparableModuleSpec class
188- Write-Debug " $moduleSpec `: Processing dependencies"
254+ Write-Debug " $currentModuleSpec `: Processing dependencies"
189255 try {
190256 [List [ComparableModuleSpecification ]]$dependencies = $dependencyInfo | Parse- NugetDependency
191257
192258 } catch { Wait-Debugger }
193- Write-Debug " $moduleSpec has $ ( $dependencies.count ) dependencies"
259+ Write-Debug " $currentModuleSpec has $ ( $dependencies.count ) dependencies"
194260
195261 # TODO: Where loop filter maybe
196262 [ComparableModuleSpecification []]$dependenciesToResolve = $dependencies | Where-Object {
@@ -249,8 +315,8 @@ function Get-ModuleFastPlan {
249315 # TODO: Deduplicate in-flight queries (az.accounts is a good example)
250316 # Write-Debug "$moduleSpec`: Checking if $dependencySpec already has an in-flight request that satisfies the requirement"
251317
252- Write-Debug " $moduleSpec `: Fetching dependency $dependencySpec "
253- $task = Get-ModuleInfoAsync - Name $dependencySpec - HttpClient $httpclient - Uri $ Source - CancellationToken $cancelToken .Token
318+ Write-Debug " $currentModuleSpec `: Fetching dependency $dependencySpec "
319+ $task = Get-ModuleInfoAsync @httpContext - Endpoint $ Source - Name $dependencySpec .Name
254320 $resolveTasks [$task ] = $dependencySpec
255321 $currentTasks.Add ($task )
256322 }
@@ -277,6 +343,12 @@ function Get-ModuleFastPlan {
277343}
278344# #endregion Main
279345
346+ # region PlanHelpers
347+ # endregion PlanHelpers
348+
349+
350+
351+
280352# region Classes
281353<#
282354A custom version of ModuleSpecification that is comparable on its values, and will deduplicate in a HashSet if all
@@ -295,6 +367,7 @@ class ComparableModuleSpecification : ModuleSpecification {
295367 MaximumVersion = $spec.MaximumVersion
296368 }
297369 }
370+
298371 [ModuleSpecification ] ToModuleSpecification() {
299372 return [ModuleSpecification ]@ {
300373 Name = $this.Name
@@ -305,6 +378,51 @@ class ComparableModuleSpecification : ModuleSpecification {
305378 }
306379 }
307380
381+ # Return only module specifications that match this specification. Good for determining which ranges will satisfy this module
382+ [ComparableModuleSpecification []] FindMatch([ComparableModuleSpecification []]$candidates ) {
383+ $matchSpecs = foreach ($candidate in $candidates ) {
384+ if ($this.Name -and ($this.Name -ne $candidate.Name )) {
385+ Write-Error (' The names of the module specifications you are trying to compare do not match ({0} vs {1})' -f $this.Name , $candidate.Name )
386+ continue
387+ }
388+ if ($this.Guid -and ($this.Guid -ne $candidate.Guid )) {
389+ Write-Error (' The GUIDs of the module specifications you are trying to compare do not match ({0} vs {1})' -f $this.Name , $candidate.Name )
390+ continue
391+ }
392+ if ($this.RequiredVersion -and ($this.RequiredVersion -ne $candidate.Version )) {
393+ continue
394+ }
395+ if ($this.Version -and ($candidate.Version -gt $this.Version )) {
396+ continue
397+ }
398+ if ($this.MaximumVersion -and ($this.MaximumVersion -lt $candidate.Version )) {
399+ continue
400+ }
401+ $candidate
402+ }
403+
404+ return $matchSpecs
405+ }
406+
407+ [Version []] FindMatch([Version []]$versions ) {
408+ $candidates = foreach ($version in $versions ) {
409+ [ComparableModuleSpecification ]@ {
410+ ModuleName = $this.Name
411+ RequiredVersion = $version
412+ }
413+ }
414+ return $this.FindMatch ($candidates ).RequiredVersion
415+ }
416+
417+ # Return the highest version that satisfies this module specification
418+ [ComparableModuleSpecification ] FindHighestMatch([ComparableModuleSpecification []]$candidates ) {
419+ return $this.findMatch ($candidates ) | Sort-Object Version - Descending | Select-Object - First 1
420+ }
421+
422+ [Version ] FindHighestMatch([Version []]$versions ) {
423+ return $this.findMatch ($versions ) | Sort-Object - Descending | Select-Object - First 1
424+ }
425+
308426 # Concatenate the properties into a string to generate a hashcode
309427 [int ] GetHashCode() {
310428 return ($this.ToString ()).GetHashCode()
@@ -315,6 +433,7 @@ class ComparableModuleSpecification : ModuleSpecification {
315433 [string ] ToString() {
316434 return ($this.Name , $this.Guid , $this.Version , $this.MaximumVersion , $this.RequiredVersion -join ' :' )
317435 }
436+
318437}
319438# endRegion Classes
320439
@@ -509,39 +628,42 @@ function Install-Modulefast {
509628 }
510629}
511630
512- filter Get-ModuleInfoAsync {
631+ function Get-ModuleInfoAsync {
513632 [CmdletBinding ()]
514633 [OutputType ([Task [String ]])]
515634 param (
516- [Parameter (Mandatory )][ComparableModuleSpecification ]$Name ,
517- [Parameter (Mandatory )][string ]$Uri ,
635+ # The name of the module to search for
636+ [Parameter (Mandatory , ParameterSetName = ' endpoint' )][string ]$Name ,
637+ # The URI of the nuget v3 repository base, e.g. https://pwsh.gallery/index.json
638+ [Parameter (Mandatory , ParameterSetName = ' endpoint' )]$Endpoint ,
639+ # The path we are calling for the registration
640+ [Parameter (ParameterSetName = ' endpoint' )][string ]$Path = ' index.json' ,
641+
642+ # The direct URI to the registration endpoint
643+ [Parameter (Mandatory , ParameterSetName = ' uri' )][string ]$Uri ,
644+
518645 [Parameter (Mandatory )][HttpClient ]$HttpClient ,
519- [CancellationToken ]$CancellationToken
646+ [Parameter ( Mandatory )][ CancellationToken ]$CancellationToken
520647 )
521- end {
648+
649+ if (-not $Uri ) {
522650 $moduleSpec = $Name
523- $ModuleId = $ModuleSpec . Name
651+ $ModuleId = $Name
524652
653+ # TODO: Call index.json and get the correct service. For now we are shortcutting this (bad behavior)
525654 # Strip any *.json path which might be at the end of the uri
526- $endpoint = $Uri -replace ' /\w+\.json$'
527655
528656 # HACK: We are making a *big* assumption here about the structure of the nuget repository to save an API call
529657 # TODO: Error handling if we are wrong by checking the main index.json manifest
530658
531- if ($Name.MaximumVersion ) {
532- throw [NotImplementedException ]" $Name has a maximum version. This is not implemented yet."
533- }
534- $moduleInfoUriBase = " $endpoint /registration/$ModuleId "
535- if ($moduleSpec.RequiredVersion ) {
536- $moduleInfoUri = " $moduleInfoUriBase /$ ( $moduleSpec.RequiredVersion ) .json"
537- } else {
538- $moduleInfoUri = " $moduleInfoUriBase /index.json"
539- }
540-
541- # TODO: System.Text.JSON serialize this with fancy generic methods in 7.3?
542- Write-Debug " $ModuleId `: fetch info from $moduleInfoUri "
543- return $HttpClient.GetStringAsync ($moduleInfoUri , $CancellationToken )
659+ $endpointBase = $endpoint -replace ' /\w+\.json$'
660+ $moduleInfoUriBase = " $endpointBase /registration/$ModuleId "
661+ $uri = " $moduleInfoUriBase /$Path "
544662 }
663+
664+ # TODO: System.Text.JSON serialize this with fancy generic methods in 7.3?
665+ Write-Debug (' {0}fetch info from {1}' -f ($ModuleId ? " $ModuleId `: " : ' ' ), $uri )
666+ return $HttpClient.GetStringAsync ($uri , $CancellationToken )
545667}
546668
547669filter Parse-NugetDependency ([Parameter (Mandatory , ValueFromPipeline )]$Dependency ) {
0 commit comments