22// See LICENSE in the project root for license information.
33
44import * as crypto from 'node:crypto' ;
5+ import type { Readable } from 'node:stream' ;
56
67import { Async } from '@rushstack/node-core-library' ;
78import { Colorize , type ITerminal } from '@rushstack/terminal' ;
89import {
910 type IGetFetchOptions ,
1011 type IFetchOptionsWithBody ,
1112 type IWebClientResponse ,
13+ type IWebClientStreamResponse ,
1214 type WebClient ,
1315 AUTHORIZATION_HEADER_NAME
1416} from '@rushstack/rush-sdk/lib/utilities/WebClient' ;
@@ -21,6 +23,13 @@ const DATE_HEADER_NAME: 'x-amz-date' = 'x-amz-date';
2123const HOST_HEADER_NAME : 'host' = 'host' ;
2224const SECURITY_TOKEN_HEADER_NAME : 'x-amz-security-token' = 'x-amz-security-token' ;
2325
26+ /**
27+ * AWS Signature V4 allows using this sentinel value as the content hash when the request
28+ * payload is not signed. This is used for streaming uploads where the body cannot be hashed
29+ * upfront.
30+ */
31+ const UNSIGNED_PAYLOAD : 'UNSIGNED-PAYLOAD' = 'UNSIGNED-PAYLOAD' ;
32+
2433interface IIsoDateString {
2534 date : string ;
2635 dateTime : string ;
@@ -178,6 +187,73 @@ export class AmazonS3Client {
178187 } ) ;
179188 }
180189
190+ public async getObjectStreamAsync ( objectName : string ) : Promise < Readable | undefined > {
191+ this . _writeDebugLine ( 'Reading object stream from S3' ) ;
192+ return await this . _sendCacheRequestWithRetriesAsync ( async ( ) => {
193+ const response : IWebClientStreamResponse = await this . _makeStreamRequestAsync ( 'GET' , objectName ) ;
194+ if ( response . ok ) {
195+ return {
196+ hasNetworkError : false ,
197+ response : response . stream
198+ } ;
199+ } else if ( response . status === 404 ) {
200+ response . stream . resume ( ) ;
201+ return {
202+ hasNetworkError : false ,
203+ response : undefined
204+ } ;
205+ } else if (
206+ ( response . status === 400 || response . status === 401 || response . status === 403 ) &&
207+ ! this . _credentials
208+ ) {
209+ response . stream . resume ( ) ;
210+ this . _writeWarningLine (
211+ `No credentials found and received a ${ response . status } ` ,
212+ ' response code from the cloud storage.' ,
213+ ' Maybe run rush update-cloud-credentials' ,
214+ ' or set the RUSH_BUILD_CACHE_CREDENTIAL env'
215+ ) ;
216+ return {
217+ hasNetworkError : false ,
218+ response : undefined
219+ } ;
220+ } else if ( response . status === 400 || response . status === 401 || response . status === 403 ) {
221+ response . stream . resume ( ) ;
222+ throw new Error (
223+ `Amazon S3 responded with status code ${ response . status } (${ response . statusText } )`
224+ ) ;
225+ } else {
226+ response . stream . resume ( ) ;
227+ return {
228+ hasNetworkError : true ,
229+ error : new Error (
230+ `Amazon S3 responded with status code ${ response . status } (${ response . statusText } )`
231+ )
232+ } ;
233+ }
234+ } ) ;
235+ }
236+
237+ public async uploadObjectStreamAsync ( objectName : string , objectStream : Readable ) : Promise < void > {
238+ if ( ! this . _credentials ) {
239+ throw new Error ( 'Credentials are required to upload objects to S3.' ) ;
240+ }
241+
242+ // Streaming uploads cannot be retried because the stream is consumed after the first attempt.
243+ const response : IWebClientStreamResponse = await this . _makeStreamRequestAsync (
244+ 'PUT' ,
245+ objectName ,
246+ objectStream
247+ ) ;
248+ if ( ! response . ok ) {
249+ response . stream . resume ( ) ;
250+ throw new Error (
251+ `Amazon S3 responded with status code ${ response . status } (${ response . statusText } )`
252+ ) ;
253+ }
254+ response . stream . resume ( ) ;
255+ }
256+
181257 private _writeDebugLine ( ...messageParts : string [ ] ) : void {
182258 // if the terminal has been closed then don't bother sending a debug message
183259 try {
@@ -201,8 +277,51 @@ export class AmazonS3Client {
201277 objectName : string ,
202278 body ?: Buffer
203279 ) : Promise < IWebClientResponse > {
204- const isoDateString : IIsoDateString = this . _getIsoDateString ( ) ;
205280 const bodyHash : string = this . _getSha256 ( body ) ;
281+ const { url, headers } = this . _buildSignedRequest ( verb , objectName , bodyHash ) ;
282+
283+ const webFetchOptions : IGetFetchOptions | IFetchOptionsWithBody = {
284+ verb,
285+ headers
286+ } ;
287+ if ( verb === 'PUT' ) {
288+ ( webFetchOptions as IFetchOptionsWithBody ) . body = body ;
289+ }
290+
291+ const response : IWebClientResponse = await this . _webClient . fetchAsync ( url , webFetchOptions ) ;
292+
293+ return response ;
294+ }
295+
296+ private async _makeStreamRequestAsync (
297+ verb : 'GET' | 'PUT' ,
298+ objectName : string ,
299+ body ?: Readable
300+ ) : Promise < IWebClientStreamResponse > {
301+ // For streaming uploads, the body cannot be hashed upfront, so we use UNSIGNED-PAYLOAD.
302+ const bodyHash : string = body ? UNSIGNED_PAYLOAD : this . _getSha256 ( undefined ) ;
303+ const { url, headers } = this . _buildSignedRequest ( verb , objectName , bodyHash ) ;
304+
305+ const webFetchOptions : IGetFetchOptions | IFetchOptionsWithBody = {
306+ verb,
307+ headers
308+ } ;
309+ if ( verb === 'PUT' && body ) {
310+ ( webFetchOptions as IFetchOptionsWithBody ) . body = body ;
311+ }
312+
313+ return await this . _webClient . fetchStreamAsync ( url , webFetchOptions ) ;
314+ }
315+
316+ /**
317+ * Builds an AWS Signature V4 signed request, returning the URL and signed headers.
318+ */
319+ private _buildSignedRequest (
320+ verb : 'GET' | 'PUT' ,
321+ objectName : string ,
322+ bodyHash : string
323+ ) : { url : string ; headers : Record < string , string > } {
324+ const isoDateString : IIsoDateString = this . _getIsoDateString ( ) ;
206325 const headers : Record < string , string > = { } ;
207326 headers [ DATE_HEADER_NAME ] = isoDateString . dateTime ;
208327 headers [ CONTENT_HASH_HEADER_NAME ] = bodyHash ;
@@ -299,14 +418,6 @@ export class AmazonS3Client {
299418 }
300419 }
301420
302- const webFetchOptions : IGetFetchOptions | IFetchOptionsWithBody = {
303- verb,
304- headers
305- } ;
306- if ( verb === 'PUT' ) {
307- ( webFetchOptions as IFetchOptionsWithBody ) . body = body ;
308- }
309-
310421 const url : string = `${ this . _s3Endpoint } ${ canonicalUri } ` ;
311422
312423 this . _writeDebugLine ( Colorize . bold ( Colorize . underline ( 'Sending request to S3' ) ) ) ;
@@ -316,9 +427,7 @@ export class AmazonS3Client {
316427 this . _writeDebugLine ( Colorize . cyan ( `\t${ name } : ${ value } ` ) ) ;
317428 }
318429
319- const response : IWebClientResponse = await this . _webClient . fetchAsync ( url , webFetchOptions ) ;
320-
321- return response ;
430+ return { url, headers } ;
322431 }
323432
324433 public _getSha256Hmac ( key : string | Buffer , data : string ) : Buffer ;
0 commit comments