1
- import { Configuration , Ident , formatUtils , httpUtils , nodeUtils , StreamReport } from '@yarnpkg/core' ;
2
- import { MessageName , ReportError } from '@yarnpkg/core' ;
3
- import { prompt } from 'enquirer' ;
4
- import { URL } from 'url' ;
1
+ import { Configuration , Ident , formatUtils , httpUtils , nodeUtils , StreamReport , structUtils , IdentHash , hashUtils , Project , miscUtils } from '@yarnpkg/core' ;
2
+ import { MessageName , ReportError } from '@yarnpkg/core' ;
3
+ import { Filename , PortablePath , ppath , toFilename , xfs } from '@yarnpkg/fslib' ;
4
+ import { prompt } from 'enquirer' ;
5
+ import pick from 'lodash/pick' ;
6
+ import { URL } from 'url' ;
5
7
6
- import { Hooks } from './index' ;
7
- import * as npmConfigUtils from './npmConfigUtils' ;
8
- import { MapLike } from './npmConfigUtils' ;
8
+ import { Hooks } from './index' ;
9
+ import * as npmConfigUtils from './npmConfigUtils' ;
10
+ import { MapLike } from './npmConfigUtils' ;
9
11
10
12
export enum AuthType {
11
13
NO_AUTH ,
@@ -33,7 +35,7 @@ export type Options = httpUtils.Options & RegistryOptions & {
33
35
* It doesn't handle 403 Forbidden, as the npm registry uses it when the user attempts
34
36
* a prohibited action, such as publishing a package with a similar name to an existing package.
35
37
*/
36
- export async function handleInvalidAuthenticationError ( error : any , { attemptedAs, registry, headers, configuration} : { attemptedAs ?: string , registry : string , headers : { [ key : string ] : string } | undefined , configuration : Configuration } ) {
38
+ export async function handleInvalidAuthenticationError ( error : any , { attemptedAs, registry, headers, configuration} : { attemptedAs ?: string , registry : string , headers : { [ key : string ] : string | undefined } | undefined , configuration : Configuration } ) {
37
39
if ( isOtpError ( error ) )
38
40
throw new ReportError ( MessageName . AUTHENTICATION_INVALID , `Invalid OTP token` ) ;
39
41
@@ -64,15 +66,169 @@ export function getIdentUrl(ident: Ident) {
64
66
}
65
67
}
66
68
69
+ export type GetPackageMetadataOptions = Omit < Options , 'ident' | 'configuration' > & {
70
+ project : Project ;
71
+
72
+ /**
73
+ * Warning: This option will return all cached metadata if the version is found, but the rest of the metadata can be stale.
74
+ */
75
+ version ?: string ;
76
+ } ;
77
+
78
+ // We use 2 different caches:
79
+ // - an in-memory cache, to avoid hitting the disk and the network more than once per process for each package
80
+ // - an on-disk cache, for exact version matches and to avoid refetching the metadata if the resource hasn't changed on the server
81
+
82
+ const PACKAGE_METADATA_CACHE = new Map < IdentHash , Promise < PackageMetadata > | PackageMetadata > ( ) ;
83
+
84
+ /**
85
+ * Caches and returns the package metadata for the given ident.
86
+ *
87
+ * Note: This function only caches and returns specific fields from the metadata.
88
+ * If you need other fields, use the uncached {@link get} or consider whether it would make more sense to extract
89
+ * the fields from the on-disk packages using the linkers or from the fetch results using the fetchers.
90
+ */
91
+ export async function getPackageMetadata ( ident : Ident , { project, registry, headers, version, ...rest } : GetPackageMetadataOptions ) : Promise < PackageMetadata > {
92
+ return await miscUtils . getFactoryWithDefault ( PACKAGE_METADATA_CACHE , ident . identHash , async ( ) => {
93
+ const { configuration} = project ;
94
+
95
+ registry = normalizeRegistry ( configuration , { ident, registry} ) ;
96
+
97
+ const registryFolder = getRegistryFolder ( configuration , registry ) ;
98
+ const identPath = ppath . join ( registryFolder , `${ structUtils . slugifyIdent ( ident ) } .json` ) ;
99
+
100
+ let cached : CachedMetadata | null = null ;
101
+
102
+ // We bypass the on-disk cache for security reasons if the lockfile needs to be refreshed,
103
+ // since most likely the user is trying to validate the metadata using hardened mode.
104
+ if ( ! project . lockfileNeedsRefresh ) {
105
+ try {
106
+ cached = await xfs . readJsonPromise ( identPath ) as CachedMetadata ;
107
+
108
+ if ( typeof version !== `undefined` && typeof cached . metadata . versions [ version ] !== `undefined` ) {
109
+ return cached . metadata ;
110
+ }
111
+ } catch { }
112
+ }
113
+
114
+ return await get ( getIdentUrl ( ident ) , {
115
+ ...rest ,
116
+ customErrorMessage : customPackageError ,
117
+ configuration,
118
+ registry,
119
+ ident,
120
+ headers : {
121
+ ...headers ,
122
+ // We set both headers in case a registry doesn't support ETags
123
+ [ `If-None-Match` ] : cached ?. etag ,
124
+ [ `If-Modified-Since` ] : cached ?. lastModified ,
125
+ } ,
126
+ wrapNetworkRequest : async executor => async ( ) => {
127
+ const response = await executor ( ) ;
128
+
129
+ if ( response . statusCode === 304 ) {
130
+ if ( cached === null )
131
+ throw new Error ( `Assertion failed: cachedMetadata should not be null` ) ;
132
+
133
+ return {
134
+ ...response ,
135
+ body : cached . metadata ,
136
+ } ;
137
+ }
138
+
139
+ const packageMetadata = pickPackageMetadata ( JSON . parse ( response . body . toString ( ) ) ) ;
140
+
141
+ PACKAGE_METADATA_CACHE . set ( ident . identHash , packageMetadata ) ;
142
+
143
+ const metadata : CachedMetadata = {
144
+ metadata : packageMetadata ,
145
+ etag : response . headers . etag ,
146
+ lastModified : response . headers [ `last-modified` ] ,
147
+ } ;
148
+
149
+ // We append the PID because it is guaranteed that this code is only run once per process for a given ident
150
+ const identPathTemp = `${ identPath } -${ process . pid } .tmp` as PortablePath ;
151
+
152
+ await xfs . mkdirPromise ( registryFolder , { recursive : true } ) ;
153
+ await xfs . writeJsonPromise ( identPathTemp , metadata , { compact : true } ) ;
154
+
155
+ // Doing a rename is important to ensure the cache is atomic
156
+ await xfs . renamePromise ( identPathTemp , identPath ) ;
157
+
158
+ return {
159
+ ...response ,
160
+ body : packageMetadata ,
161
+ } ;
162
+ } ,
163
+ } ) ;
164
+ } ) ;
165
+ }
166
+
167
+ type CachedMetadata = {
168
+ metadata : PackageMetadata ;
169
+ etag ?: string ;
170
+ lastModified ?: string ;
171
+ } ;
172
+
173
+ export type PackageMetadata = {
174
+ 'dist-tags' : Record < string , string > ;
175
+ versions : Record < string , any > ;
176
+ } ;
177
+
178
+ const CACHED_FIELDS = [
179
+ `name` ,
180
+
181
+ `dist.tarball` ,
182
+
183
+ `bin` ,
184
+ `scripts` ,
185
+
186
+ `os` ,
187
+ `cpu` ,
188
+ `libc` ,
189
+
190
+ `dependencies` ,
191
+ `dependenciesMeta` ,
192
+ `optionalDependencies` ,
193
+
194
+ `peerDependencies` ,
195
+ `peerDependenciesMeta` ,
196
+ ] ;
197
+
198
+ function pickPackageMetadata ( metadata : PackageMetadata ) : PackageMetadata {
199
+ return {
200
+ 'dist-tags' : metadata [ `dist-tags` ] ,
201
+ versions : Object . fromEntries ( Object . entries ( metadata . versions ) . map ( ( [ key , value ] ) => [
202
+ key ,
203
+ pick ( value , CACHED_FIELDS ) ,
204
+ ] ) ) ,
205
+ } ;
206
+ }
207
+
208
+ /**
209
+ * Used to invalidate the on-disk cache when the format changes.
210
+ */
211
+ const CACHE_KEY = hashUtils . makeHash ( ...CACHED_FIELDS ) . slice ( 0 , 6 ) ;
212
+
213
+ function getRegistryFolder ( configuration : Configuration , registry : string ) {
214
+ const metadataFolder = getMetadataFolder ( configuration ) ;
215
+
216
+ const parsed = new URL ( registry ) ;
217
+ const registryFilename = toFilename ( parsed . hostname ) ;
218
+
219
+ return ppath . join ( metadataFolder , CACHE_KEY as Filename , registryFilename ) ;
220
+ }
221
+
222
+ function getMetadataFolder ( configuration : Configuration ) {
223
+ return ppath . join ( configuration . get ( `globalFolder` ) , `metadata/npm` ) ;
224
+ }
225
+
67
226
export async function get ( path : string , { configuration, headers, ident, authType, registry, ...rest } : Options ) {
68
- if ( ident && typeof registry === `undefined` )
69
- registry = npmConfigUtils . getScopeRegistry ( ident . scope , { configuration } ) ;
227
+ registry = normalizeRegistry ( configuration , { ident, registry} ) ;
228
+
70
229
if ( ident && ident . scope && typeof authType === `undefined` )
71
230
authType = AuthType . BEST_EFFORT ;
72
231
73
- if ( typeof registry !== `string` )
74
- throw new Error ( `Assertion failed: The registry should be a string` ) ;
75
-
76
232
const auth = await getAuthenticationHeader ( registry , { authType, configuration, ident} ) ;
77
233
if ( auth )
78
234
headers = { ...headers , authorization : auth } ;
@@ -87,11 +243,7 @@ export async function get(path: string, {configuration, headers, ident, authType
87
243
}
88
244
89
245
export async function post ( path : string , body : httpUtils . Body , { attemptedAs, configuration, headers, ident, authType = AuthType . ALWAYS_AUTH , registry, otp, ...rest } : Options & { attemptedAs ?: string } ) {
90
- if ( ident && typeof registry === `undefined` )
91
- registry = npmConfigUtils . getScopeRegistry ( ident . scope , { configuration} ) ;
92
-
93
- if ( typeof registry !== `string` )
94
- throw new Error ( `Assertion failed: The registry should be a string` ) ;
246
+ registry = normalizeRegistry ( configuration , { ident, registry} ) ;
95
247
96
248
const auth = await getAuthenticationHeader ( registry , { authType, configuration, ident} ) ;
97
249
if ( auth )
@@ -123,11 +275,7 @@ export async function post(path: string, body: httpUtils.Body, {attemptedAs, con
123
275
}
124
276
125
277
export async function put ( path : string , body : httpUtils . Body , { attemptedAs, configuration, headers, ident, authType = AuthType . ALWAYS_AUTH , registry, otp, ...rest } : Options & { attemptedAs ?: string } ) {
126
- if ( ident && typeof registry === `undefined` )
127
- registry = npmConfigUtils . getScopeRegistry ( ident . scope , { configuration} ) ;
128
-
129
- if ( typeof registry !== `string` )
130
- throw new Error ( `Assertion failed: The registry should be a string` ) ;
278
+ registry = normalizeRegistry ( configuration , { ident, registry} ) ;
131
279
132
280
const auth = await getAuthenticationHeader ( registry , { authType, configuration, ident} ) ;
133
281
if ( auth )
@@ -159,11 +307,7 @@ export async function put(path: string, body: httpUtils.Body, {attemptedAs, conf
159
307
}
160
308
161
309
export async function del ( path : string , { attemptedAs, configuration, headers, ident, authType = AuthType . ALWAYS_AUTH , registry, otp, ...rest } : Options & { attemptedAs ?: string } ) {
162
- if ( ident && typeof registry === `undefined` )
163
- registry = npmConfigUtils . getScopeRegistry ( ident . scope , { configuration} ) ;
164
-
165
- if ( typeof registry !== `string` )
166
- throw new Error ( `Assertion failed: The registry should be a string` ) ;
310
+ registry = normalizeRegistry ( configuration , { ident, registry} ) ;
167
311
168
312
const auth = await getAuthenticationHeader ( registry , { authType, configuration, ident} ) ;
169
313
if ( auth )
@@ -194,6 +338,16 @@ export async function del(path: string, {attemptedAs, configuration, headers, id
194
338
}
195
339
}
196
340
341
+ function normalizeRegistry ( configuration : Configuration , { ident, registry} : Partial < RegistryOptions > ) : string {
342
+ if ( typeof registry === `undefined` && ident )
343
+ return npmConfigUtils . getScopeRegistry ( ident . scope , { configuration} ) ;
344
+
345
+ if ( typeof registry !== `string` )
346
+ throw new Error ( `Assertion failed: The registry should be a string` ) ;
347
+
348
+ return registry ;
349
+ }
350
+
197
351
async function getAuthenticationHeader ( registry : string , { authType = AuthType . CONFIGURATION , configuration, ident} : { authType ?: AuthType , configuration : Configuration , ident : RegistryOptions [ 'ident' ] } ) {
198
352
const effectiveConfiguration = npmConfigUtils . getAuthConfiguration ( registry , { configuration, ident} ) ;
199
353
const mustAuthenticate = shouldAuthenticate ( effectiveConfiguration , authType ) ;
@@ -242,7 +396,7 @@ function shouldAuthenticate(authConfiguration: MapLike, authType: AuthType) {
242
396
}
243
397
}
244
398
245
- async function whoami ( registry : string , headers : { [ key : string ] : string } | undefined , { configuration} : { configuration : Configuration } ) {
399
+ async function whoami ( registry : string , headers : { [ key : string ] : string | undefined } | undefined , { configuration} : { configuration : Configuration } ) {
246
400
if ( typeof headers === `undefined` || typeof headers . authorization === `undefined` )
247
401
return `an anonymous user` ;
248
402
0 commit comments