import assert = require ( "assert" ) ; import { ensureFile , pathExists } from "fs-extra" ; import RegClient = require ( "npm-registry-client" ) ; import { resolve as resolveUrl } from "url" ; import { Fetcher , readFile , readJson , sleep , writeJson } from "../util/io" ; import { Logger , loggerWithErrors } from "../util/logging" ; import { createTgz } from "../util/tgz" ; import { assertNever , identity , joinPaths , mapToRecord , recordToMap } from "../util/util" ; import { Registry } from "./common" ; import { getSecret , Secret } from "./secrets" ; import { githubRegistry , npmApi , npmRegistry , npmRegistryHostName } from "./settings" ; const cacheFile = joinPaths ( __dirname , ".." , ".." , "cache" , "npmInfo.json" ) ; export type NpmInfoCache = ReadonlyMap < string , NpmInfo > ; export interface NpmInfoRaw { readonly "dist-tags" : { readonly [ tag ] : string ; } ; readonly versions : NpmInfoRawVersions ; readonly time : { readonly [ s ] : string ; } ; } export interface NpmInfoRawVersions { readonly [ version ] : NpmInfoVersion ; } export interface NpmInfo { readonly distTags : Map < string , string > ; readonly versions : Map < string , NpmInfoVersion > ; readonly time : Map < string , string > ; } export interface NpmInfoVersion { readonly typesPublisherContentHash ? : string ; readonly deprecated ? : string ; } export interface CachedNpmInfoClient { getNpmInfoFromCache ( escapedPackageName ) : NpmInfo | undefined ; fetchAndCacheNpmInfo ( escapedPackageName ) : Promise < NpmInfo | undefined > ; } export async function withNpmCache < T > ( uncachedClient , cb ) { const log = loggerWithErrors ( ) [ 0 ] ; let unroll ; log . info ( `Checking for cache file at ${ cacheFile }...` ) ; const cacheFileExists = await pathExists ( cacheFile ) ; if ( cacheFileExists ) { log . info ( "Reading cache file..." ) ; const cachedJson = await readJson ( cacheFile ) as Record < string , NpmInfoRaw > ; log . info ( `Cache file ${ cacheFile } exists, copying to map...` ) ; unroll = recordToMap ( cachedJson , npmInfoFromJson ) ; } else { log . info ( "Cache file doesn't exist, using empty map." ) ; unroll = new Map ( ) ; } const res = await cb ( { getNpmInfoFromCache , fetchAndCacheNpmInfo } ) ; log . info ( "Writing npm cache." ) ; await ensureFile ( cacheFile ) ; await writeJson ( cacheFile , mapToRecord ( unroll , jsonFromNpmInfo ) ) ; return res ; /** May return old info -- caller should check that this looks up-to-date. */ function getNpmInfoFromCache ( escapedPackageName ) { return unroll . get ( escapedPackageName ) ; } /** Call this when the result of getNpmInfoFromCache looks potentially out-of-date. */ async function fetchAndCacheNpmInfo ( escapedPackageName ) { const info = await uncachedClient . fetchNpmInfo ( escapedPackageName ) ; if ( info ) { unroll . set ( escapedPackageName , info ) ; } return info ; } } export class UncachedNpmInfoClient { private readonly fetcher = new Fetcher ( ) ; async fetchNpmInfo ( escapedPackageName ) { const raw = await this . fetchRawNpmInfo ( escapedPackageName ) ; await sleep ( 0.01 ) ; return raw === undefined ? undefined : npmInfoFromJson ( raw ) ; } async fetchRawNpmInfo ( escapedPackageName ) { const info = await this . fetcher . fetchJson ( { hostname : npmRegistryHostName , path : escapedPackageName , retries : true , } ) as { readonly error : string ; } | NpmInfoRaw ; if ( "error" in info ) { if ( info . error === "Not found" ) { return undefined ; } throw new Error ( `Error getting version at ${ escapedPackageName }: ${ info . error }` ) ; } if ( ! info [ "dist-tags" ] && ! info . versions ) { return undefined ; } return info ; } async getDownloads ( packageNames ) { const names = ( packageNames . length % 128 ) === 1 ? [ ... packageNames , "dummy" ] : packageNames ; const nameGroups = Array . from ( splitToFixedSizeGroups ( names , 128 ) ) ; const out = [ ] ; for ( const nameGroup of nameGroups ) { const data = await this . fetcher . fetchJson ( { hostname : npmApi , path : `/downloads/point/last-month/${ nameGroup . join ( "," ) }` , retries : true , } ) as { readonly error : string ; } | { readonly [ key ] : { readonly downloads : number ; } ; } ; if ( "error" in data ) { throw new Error ( data . error as string ) ; } for ( const key of Object . keys ( data ) ) { assert ( key === names [ out . length ] , `at index ${ out . length } of ${ Object . keys ( data ) . toString ( ) } : ${ key } !== ${ names [ out . length ] }` ) ; out . push ( data [ key ] ? data [ key ] . downloads : 0 ) ; } } return out ; } } function splitToFixedSizeGroups ( names , chunkSize ) { const out = [ ] ; for ( let i = 0 ; i < names . length ; i += chunkSize ) { out . push ( names . slice ( i , i + chunkSize ) ) ; } return out ; } export class NpmPublishClient { static async create ( config ? , registry = Registry . NPM ) { switch ( registry ) { case Registry . NPM : return new NpmPublishClient ( new RegClient ( config ) , { token : await getSecret ( Secret . NPM_TOKEN ) } , npmRegistry ) ; case Registry . Github : return new NpmPublishClient ( new RegClient ( config ) , { token : await getSecret ( Secret . GITHUB_PUBLISH_ACCESS_TOKEN ) } , githubRegistry ) ; default : assertNever ( registry ) ; } } private constructor ( private readonly client , private readonly auth , private readonly registry ) { } async publish ( publishedDirectory , packageJson , dry , log ) { const readme = await readFile ( joinPaths ( publishedDirectory , "README.md" ) ) ; return new Promise < void > ( ( resolve , reject ) => { const body = createTgz ( publishedDirectory , reject ) ; const metadata = { readme , ... packageJson } ; if ( dry ) { log ( `(dry) Skip publish of ${ publishedDirectory } to ${ this . registry }` ) ; } resolve ( dry ? undefined : promisifyVoid ( cb => { this . client . publish ( this . registry , { access : "public" , auth : this . auth , metadata , body } , cb ) ; } ) ) ; } ) ; } tag ( packageName , version , distTag , dry , log ) { if ( dry ) { log ( `(dry) Skip tag of ${ packageName }@${ distTag } as ${ version }` ) ; return Promise . resolve ( ) ; } return promisifyVoid ( cb => { this . client . distTags . add ( this . registry , { package : packageName , version , distTag , auth : this . auth } , cb ) ; } ) ; } deprecate ( packageName , version , message ) { const url = resolveUrl ( npmRegistry , packageName . replace ( "/" , "%2f" ) ) ; const params = { message , version , auth : this . auth , } ; return promisifyVoid ( cb => { this . client . deprecate ( url , params , cb ) ; } ) ; } } function npmInfoFromJson ( n ) { return { distTags : recordToMap ( n [ "dist-tags" ] , identity ) , versions : recordToMap ( n . versions , ( { typesPublisherContentHash , deprecated } ) => ( { typesPublisherContentHash , deprecated } ) ) , time : recordToMap ( n . time ) , } ; } function jsonFromNpmInfo ( n ) { return { "dist-tags" : mapToRecord ( n . distTags ) , versions : mapToRecord ( n . versions ) , time : mapToRecord ( n . time ) , } ; } function promisifyVoid ( callsBack ) { return new Promise < void > ( ( resolve , reject ) => { callsBack ( error => { if ( error ) { reject ( error ) ; } else { resolve ( ) ; } } ) ; } ) ; }