2020-04-01 06:13:27 +03:00
import { asyncLock , AsyncLock } from './asyncLock' ;
import unsentRequest from './unsentRequest' ;
2020-06-09 19:03:19 +03:00
import APIError from './APIError' ;
2020-04-01 06:13:27 +03:00
2020-01-15 00:15:14 +02:00
export interface FetchError extends Error {
status : number ;
}
2020-04-01 06:13:27 +03:00
interface API {
rateLimiter? : AsyncLock ;
buildRequest : ( req : ApiRequest ) = > ApiRequest | Promise < ApiRequest > ;
requestFunction ? : ( req : ApiRequest ) = > Promise < Response > ;
}
export type ApiRequestObject = {
url : string ;
params? : Record < string , string | boolean | number > ;
2020-11-26 04:55:24 -06:00
method ? : 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'PATCH' ;
2020-04-01 06:13:27 +03:00
headers? : Record < string , string > ;
body? : string | FormData ;
cache ? : 'no-store' ;
} ;
export type ApiRequest = ApiRequestObject | string ;
class RateLimitError extends Error {
resetSeconds : number ;
constructor ( message : string , resetSeconds : number ) {
super ( message ) ;
if ( resetSeconds < 0 ) {
this . resetSeconds = 1 ;
} else if ( resetSeconds > 60 * 60 ) {
this . resetSeconds = 60 * 60 ;
} else {
this . resetSeconds = resetSeconds ;
}
}
}
2021-02-08 20:01:21 +02:00
export async function requestWithBackoff (
2020-04-01 06:13:27 +03:00
api : API ,
req : ApiRequest ,
attempt = 1 ,
2021-02-08 20:01:21 +02:00
) : Promise < Response > {
2020-04-01 06:13:27 +03:00
if ( api . rateLimiter ) {
await api . rateLimiter . acquire ( ) ;
}
try {
const builtRequest = await api . buildRequest ( req ) ;
const requestFunction = api . requestFunction || unsentRequest . performRequest ;
const response : Response = await requestFunction ( builtRequest ) ;
if ( response . status === 429 ) {
// GitLab/Bitbucket too many requests
const text = await response . text ( ) . catch ( ( ) = > 'Too many requests' ) ;
throw new Error ( text ) ;
} else if ( response . status === 403 ) {
// GitHub too many requests
2021-01-26 09:00:01 -08:00
const json = await response . json ( ) . catch ( ( ) = > ( { message : '' } ) ) ;
if ( json . message . match ( 'API rate limit exceeded' ) ) {
2020-04-01 06:13:27 +03:00
const now = new Date ( ) ;
const nextWindowInSeconds = response . headers . has ( 'X-RateLimit-Reset' )
? parseInt ( response . headers . get ( 'X-RateLimit-Reset' ) ! )
: now . getTime ( ) / 1000 + 60 ;
2021-01-26 09:00:01 -08:00
throw new RateLimitError ( json . message , nextWindowInSeconds ) ;
2020-04-01 06:13:27 +03:00
}
2021-01-26 09:00:01 -08:00
response . json = ( ) = > Promise . resolve ( json ) ;
2020-04-01 06:13:27 +03:00
}
return response ;
} catch ( err ) {
2020-09-01 08:36:26 -04:00
if ( attempt > 5 || err . message === "Can't refresh access token when using implicit auth" ) {
throw err ;
} else {
2020-04-01 06:13:27 +03:00
if ( ! api . rateLimiter ) {
const timeout = err . resetSeconds || attempt * attempt ;
console . log (
` Pausing requests for ${ timeout } ${
attempt === 1 ? 'second' : 'seconds'
} due to fetch failures : ` ,
err . message ,
) ;
api . rateLimiter = asyncLock ( ) ;
api . rateLimiter . acquire ( ) ;
setTimeout ( ( ) = > {
api . rateLimiter ? . release ( ) ;
api . rateLimiter = undefined ;
console . log ( ` Done pausing requests ` ) ;
} , 1000 * timeout ) ;
}
return requestWithBackoff ( api , req , attempt + 1 ) ;
}
}
2021-02-08 20:01:21 +02:00
}
2020-04-01 06:13:27 +03:00
2021-02-08 20:01:21 +02:00
export async function readFile (
2020-01-15 00:15:14 +02:00
id : string | null | undefined ,
fetchContent : ( ) = > Promise < string | Blob > ,
localForage : LocalForage ,
isText : boolean ,
2021-02-08 20:01:21 +02:00
) {
2020-01-15 00:15:14 +02:00
const key = id ? ( isText ? ` gh. ${ id } ` : ` gh. ${ id } .blob ` ) : null ;
const cached = key ? await localForage . getItem < string | Blob > ( key ) : null ;
if ( cached ) {
return cached ;
}
const content = await fetchContent ( ) ;
if ( key ) {
2020-02-24 23:44:10 +01:00
await localForage . setItem ( key , content ) ;
2020-01-15 00:15:14 +02:00
}
return content ;
2021-02-08 20:01:21 +02:00
}
2020-01-15 00:15:14 +02:00
2020-04-01 06:13:27 +03:00
export type FileMetadata = {
author : string ;
updatedOn : string ;
} ;
2021-02-08 20:01:21 +02:00
function getFileMetadataKey ( id : string ) {
return ` gh. ${ id } .meta ` ;
}
2020-04-01 06:13:27 +03:00
2021-02-08 20:01:21 +02:00
export async function readFileMetadata (
2020-04-21 17:46:06 +03:00
id : string | null | undefined ,
2020-04-01 06:13:27 +03:00
fetchMetadata : ( ) = > Promise < FileMetadata > ,
localForage : LocalForage ,
2021-02-08 20:01:21 +02:00
) {
2020-04-21 17:46:06 +03:00
const key = id ? getFileMetadataKey ( id ) : null ;
const cached = key && ( await localForage . getItem < FileMetadata > ( key ) ) ;
2020-04-01 06:13:27 +03:00
if ( cached ) {
return cached ;
2020-04-21 17:46:06 +03:00
}
const metadata = await fetchMetadata ( ) ;
if ( key ) {
2020-04-01 06:13:27 +03:00
await localForage . setItem < FileMetadata > ( key , metadata ) ;
}
2020-04-21 17:46:06 +03:00
return metadata ;
2021-02-08 20:01:21 +02:00
}
2020-04-01 06:13:27 +03:00
2020-01-15 00:15:14 +02:00
/ * *
* Keywords for inferring a status that will provide a deploy preview URL .
* /
const PREVIEW_CONTEXT_KEYWORDS = [ 'deploy' ] ;
/ * *
* Check a given status context string to determine if it provides a link to a
* deploy preview . Checks for an exact match against ` previewContext ` if given ,
* otherwise checks for inclusion of a value from ` PREVIEW_CONTEXT_KEYWORDS ` .
* /
2021-02-08 20:01:21 +02:00
export function isPreviewContext ( context : string , previewContext : string ) {
2020-01-15 00:15:14 +02:00
if ( previewContext ) {
return context === previewContext ;
}
return PREVIEW_CONTEXT_KEYWORDS . some ( keyword = > context . includes ( keyword ) ) ;
2021-02-08 20:01:21 +02:00
}
2020-01-15 00:15:14 +02:00
export enum PreviewState {
Other = 'other' ,
Success = 'success' ,
}
/ * *
* Retrieve a deploy preview URL from an array of statuses . By default , a
* matching status is inferred via ` isPreviewContext ` .
* /
2021-02-08 20:01:21 +02:00
export function getPreviewStatus (
2020-01-15 00:15:14 +02:00
statuses : {
context : string ;
target_url : string ;
state : PreviewState ;
} [ ] ,
previewContext : string ,
2021-02-08 20:01:21 +02:00
) {
2020-01-15 00:15:14 +02:00
return statuses . find ( ( { context } ) = > {
return isPreviewContext ( context , previewContext ) ;
} ) ;
2021-02-08 20:01:21 +02:00
}
2020-06-09 19:03:19 +03:00
2021-02-08 20:01:21 +02:00
function getConflictingBranches ( branchName : string ) {
2020-06-09 19:03:19 +03:00
// for cms/posts/post-1, conflicting branches are cms/posts, cms
const parts = branchName . split ( '/' ) ;
parts . pop ( ) ;
const conflictingBranches = parts . reduce ( ( acc , _ , index ) = > {
acc = [ . . . acc , parts . slice ( 0 , index + 1 ) . join ( '/' ) ] ;
return acc ;
} , [ ] as string [ ] ) ;
return conflictingBranches ;
2021-02-08 20:01:21 +02:00
}
2020-06-09 19:03:19 +03:00
2021-02-08 20:01:21 +02:00
export async function throwOnConflictingBranches (
2020-06-09 19:03:19 +03:00
branchName : string ,
getBranch : ( name : string ) = > Promise < { name : string } > ,
apiName : string ,
2021-02-08 20:01:21 +02:00
) {
2020-06-09 19:03:19 +03:00
const possibleConflictingBranches = getConflictingBranches ( branchName ) ;
const conflictingBranches = await Promise . all (
possibleConflictingBranches . map ( b = >
getBranch ( b )
. then ( b = > b . name )
. catch ( ( ) = > '' ) ,
) ,
) ;
const conflictingBranch = conflictingBranches . filter ( Boolean ) [ 0 ] ;
if ( conflictingBranch ) {
throw new APIError (
` Failed creating branch ' ${ branchName } ' since there is already a branch named ' ${ conflictingBranch } '. Please delete the ' ${ conflictingBranch } ' branch and try again ` ,
500 ,
apiName ,
) ;
}
2021-02-08 20:01:21 +02:00
}