-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds the Provider interface and most of the implementation of R2Provider as discussed in #111. Note that as of right now the provider isn't being used, this will happen in future prs.
- Loading branch information
Showing
8 changed files
with
318 additions
and
67 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
/** | ||
* A Provider is essentially an abstracted API client. This is the interface | ||
* we interact with to head files, get files, and listing directories. | ||
*/ | ||
export interface Provider { | ||
headFile(path: string): Promise<HeadFileResult | undefined>; | ||
|
||
getFile( | ||
path: string, | ||
options?: GetFileOptions | ||
): Promise<GetFileResult | undefined>; | ||
|
||
readDirectory(path: string): Promise<ReadDirectoryResult | undefined>; | ||
} | ||
|
||
/** | ||
* Headers returned by the http request made by the Provider to its data source. | ||
* Can be be forwarded to the client. | ||
*/ | ||
export type HttpResponseHeaders = { | ||
etag: string; | ||
'accept-range': string; | ||
'access-control-allow-origin'?: string; | ||
'cache-control': string; | ||
expires?: string; | ||
'last-modified': string; | ||
'content-encoding'?: string; | ||
'content-type'?: string; | ||
'content-language'?: string; | ||
'content-disposition'?: string; | ||
'content-length': string; | ||
}; | ||
|
||
export type HeadFileResult = { | ||
/** | ||
* Headers to send the client | ||
*/ | ||
httpHeaders: HttpResponseHeaders; | ||
}; | ||
|
||
export type GetFileOptions = { | ||
/** | ||
* R2 supports every conditional header except `If-Range` | ||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests#conditional_headers | ||
* @see https://developers.cloudflare.com/r2/api/workers/workers-api-reference/#conditional-operations | ||
*/ | ||
conditionalHeaders?: { | ||
ifMatch?: string; | ||
ifNoneMatch?: string; | ||
ifModifiedSince?: Date; | ||
ifUnmodifiedSince?: Date; | ||
}; | ||
rangeHeader?: string; | ||
}; | ||
export type GetFileResult = { | ||
contents?: ReadableStream | null; | ||
/** | ||
* Status code to send the client | ||
*/ | ||
httpStatusCode: number; | ||
/** | ||
* Headers to send the client | ||
*/ | ||
httpHeaders: HttpResponseHeaders; | ||
}; | ||
|
||
export type File = { | ||
name: string; | ||
lastModified: Date; | ||
size: number; | ||
}; | ||
|
||
export type R2ReadDirectoryResult = { | ||
subdirectories: string[]; | ||
files: File[]; | ||
}; | ||
|
||
export type OriginReadDirectoryResult = { | ||
body: ReadableStream | null; | ||
/** | ||
* Status code to send the client | ||
*/ | ||
httpStatusCode: number; | ||
/** | ||
* Headers to send the client | ||
*/ | ||
httpHeaders: HttpResponseHeaders; | ||
}; | ||
|
||
export type ReadDirectoryResult = | ||
| R2ReadDirectoryResult | ||
| OriginReadDirectoryResult; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
import { CACHE_HEADERS } from '../constants/cache'; | ||
import { R2_RETRY_LIMIT } from '../constants/limits'; | ||
import { Context } from '../context'; | ||
import { objectHasBody } from '../utils/object'; | ||
import { mapUrlPathToBucketPath } from '../utils/path'; | ||
import { retryWrapper } from '../utils/provider'; | ||
import { | ||
GetFileOptions, | ||
GetFileResult, | ||
HeadFileResult, | ||
HttpResponseHeaders, | ||
Provider, | ||
ReadDirectoryResult, | ||
} from './provider'; | ||
|
||
type R2ProviderCtorOptions = { | ||
ctx: Context; | ||
}; | ||
|
||
export class R2Provider implements Provider { | ||
private ctx: Context; | ||
|
||
constructor({ ctx }: R2ProviderCtorOptions) { | ||
this.ctx = ctx; | ||
} | ||
|
||
async headFile(path: string): Promise<HeadFileResult | undefined> { | ||
const r2Path = mapUrlPathToBucketPath(path, this.ctx.env); | ||
if (r2Path === undefined) { | ||
return undefined; | ||
} | ||
|
||
const object = await retryWrapper( | ||
async () => await this.ctx.env.R2_BUCKET.head(r2Path), | ||
R2_RETRY_LIMIT, | ||
this.ctx.sentry | ||
); | ||
|
||
if (object === null) { | ||
return undefined; | ||
} | ||
|
||
return { | ||
httpHeaders: r2MetadataToHeaders(object, 200), | ||
}; | ||
} | ||
|
||
async getFile( | ||
path: string, | ||
options?: GetFileOptions | ||
): Promise<GetFileResult | undefined> { | ||
const r2Path = mapUrlPathToBucketPath(path, this.ctx.env); | ||
if (r2Path === undefined) { | ||
return undefined; | ||
} | ||
|
||
const object = await retryWrapper( | ||
async () => { | ||
return await this.ctx.env.R2_BUCKET.get(r2Path, { | ||
onlyIf: { | ||
etagMatches: options?.conditionalHeaders?.ifMatch, | ||
etagDoesNotMatch: options?.conditionalHeaders?.ifNoneMatch, | ||
uploadedBefore: options?.conditionalHeaders?.ifUnmodifiedSince, | ||
uploadedAfter: options?.conditionalHeaders?.ifModifiedSince, | ||
}, | ||
}); | ||
}, | ||
R2_RETRY_LIMIT, | ||
this.ctx.sentry | ||
); | ||
|
||
if (object === null) { | ||
return undefined; | ||
} | ||
|
||
const doesHaveBody = objectHasBody(object); | ||
const httpStatusCode = determineHttpStatusCode(doesHaveBody, options); | ||
|
||
return { | ||
contents: doesHaveBody ? (object as R2ObjectBody).body : undefined, | ||
httpStatusCode, | ||
httpHeaders: r2MetadataToHeaders(object, httpStatusCode), | ||
}; | ||
} | ||
|
||
readDirectory(_: string): Promise<ReadDirectoryResult | undefined> { | ||
// We will use the S3Provider here | ||
throw new Error('Method not implemented.'); | ||
} | ||
} | ||
|
||
function r2MetadataToHeaders( | ||
object: R2Object, | ||
httpStatusCode: number | ||
): HttpResponseHeaders { | ||
const { httpMetadata } = object; | ||
|
||
return { | ||
etag: object.httpEtag, | ||
'accept-range': 'bytes', | ||
// https://github.com/nodejs/build/blob/e3df25d6a23f033db317a53ab1e904c953ba1f00/ansible/www-standalone/resources/config/nodejs.org?plain=1#L194-L196 | ||
'access-control-allow-origin': object.key.endsWith('.json') | ||
? '*' | ||
: undefined, | ||
'cache-control': | ||
httpStatusCode === 200 ? CACHE_HEADERS.success : CACHE_HEADERS.failure, | ||
expires: httpMetadata?.cacheExpiry?.toUTCString(), | ||
'last-modified': object.uploaded.toUTCString(), | ||
'content-language': httpMetadata?.contentLanguage, | ||
'content-disposition': httpMetadata?.contentDisposition, | ||
'content-length': object.size.toString(), | ||
}; | ||
} | ||
|
||
function areConditionalHeadersPresent( | ||
options?: Pick<GetFileOptions, 'conditionalHeaders'> | ||
): boolean { | ||
if (options === undefined || options.conditionalHeaders === undefined) { | ||
return false; | ||
} | ||
|
||
const { conditionalHeaders } = options; | ||
|
||
return ( | ||
conditionalHeaders.ifMatch !== undefined || | ||
conditionalHeaders.ifNoneMatch !== undefined || | ||
conditionalHeaders.ifModifiedSince !== undefined || | ||
conditionalHeaders.ifUnmodifiedSince !== undefined | ||
); | ||
} | ||
|
||
function determineHttpStatusCode( | ||
objectHasBody: boolean, | ||
options?: GetFileOptions | ||
): number { | ||
if (objectHasBody) { | ||
if (options?.rangeHeader !== undefined) { | ||
// Range header is present and we have a body, most likely partial | ||
return 206; | ||
} | ||
|
||
// We have the full object body | ||
return 200; | ||
} | ||
|
||
if (areConditionalHeadersPresent(options)) { | ||
// No body due to precondition failure | ||
return 412; | ||
} | ||
|
||
// We weren't given a body and preconditions succeeded. | ||
return 304; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { Toucan } from 'toucan-js'; | ||
|
||
/** | ||
* Utility for retrying request sent to a provider's data source | ||
* @param request Function that performs the request | ||
* @returns Result returned from {@link request} | ||
*/ | ||
export async function retryWrapper<T>( | ||
request: () => Promise<T>, | ||
retryLimit: number, | ||
sentry?: Toucan | ||
): Promise<T> { | ||
let r2Error: unknown = undefined; | ||
for (let i = 0; i < retryLimit; i++) { | ||
try { | ||
const result = await request(); | ||
return result; | ||
} catch (err) { | ||
console.error(`R2Provider error: ${err}`); | ||
r2Error = err; | ||
} | ||
} | ||
|
||
if (sentry !== undefined) { | ||
sentry.captureException(r2Error); | ||
} | ||
|
||
throw r2Error; | ||
} |
Oops, something went wrong.