Skip to content

Commit

Permalink
src: implement R2Provider
Browse files Browse the repository at this point in the history
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
flakey5 committed Apr 14, 2024
1 parent c4c494c commit 694e775
Show file tree
Hide file tree
Showing 3 changed files with 251 additions and 1 deletion.
2 changes: 1 addition & 1 deletion src/constants/limits.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Max amount of retries for S3 requests
* Max amount of retries for R2 requests
*/
export const R2_RETRY_LIMIT = 5;

Expand Down
92 changes: 92 additions & 0 deletions src/providers/provider.ts
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;
158 changes: 158 additions & 0 deletions src/providers/r2Provider.ts
Original file line number Diff line number Diff line change
@@ -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<HeadFileResult | undefined> {
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<GetFileResult | undefined> {
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<ReadDirectoryResult | undefined> {
// 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<T>(request: () => Promise<T>): Promise<T> {
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<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;
}

0 comments on commit 694e775

Please sign in to comment.