diff --git a/src/constants/limits.ts b/src/constants/limits.ts index 6d9757f..5c1f95d 100644 --- a/src/constants/limits.ts +++ b/src/constants/limits.ts @@ -1,5 +1,5 @@ /** - * Max amount of retries for S3 requests + * Max amount of retries for R2 requests */ export const R2_RETRY_LIMIT = 5; diff --git a/src/providers/provider.ts b/src/providers/provider.ts new file mode 100644 index 0000000..92b88a4 --- /dev/null +++ b/src/providers/provider.ts @@ -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; + + getFile( + path: string, + options?: GetFileOptions + ): Promise; + + readDirectory(path: string): Promise; +} + +/** + * 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; diff --git a/src/providers/r2Provider.ts b/src/providers/r2Provider.ts new file mode 100644 index 0000000..f447225 --- /dev/null +++ b/src/providers/r2Provider.ts @@ -0,0 +1,158 @@ +import { CACHE_HEADERS } from '../constants/cache'; +import { R2_RETRY_LIMIT } from '../constants/limits'; +import { Context } from '../context'; +import { objectHasBody } from '../utils/object'; +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 { + const object = await this.retryWrapper( + async () => await this.ctx.env.R2_BUCKET.head(path) + ); + + if (object === null) { + return undefined; + } + + return { + httpHeaders: r2MetadataToHeaders(object, 200), + }; + } + + async getFile( + path: string, + options?: GetFileOptions + ): Promise { + const object = await this.retryWrapper( + async () => + await this.ctx.env.R2_BUCKET.get(path, { + onlyIf: { + etagMatches: options?.conditionalHeaders?.ifMatch, + etagDoesNotMatch: options?.conditionalHeaders?.ifNoneMatch, + uploadedBefore: options?.conditionalHeaders?.ifUnmodifiedSince, + uploadedAfter: options?.conditionalHeaders?.ifModifiedSince, + }, + }) + ); + + 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 { + // We will use the S3Provider here + throw new Error('Method not implemented.'); + } + + /** + * Utility for retrying request sent to R2 + * @param request Function that performs the request to R2 + * @returns Result returned from {@link request} + */ + private async retryWrapper(request: () => Promise): Promise { + let r2Error: unknown = undefined; + for (let i = 0; i < R2_RETRY_LIMIT; i++) { + try { + const result = await request(); + return result; + } catch (err) { + console.error(`R2Provider error: ${err}`); + r2Error = err; + } + } + + this.ctx.sentry.captureException(r2Error); + + throw r2Error; + } +} + +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 +): 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; +}