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 31ccaa6
Show file tree
Hide file tree
Showing 8 changed files with 318 additions and 67 deletions.
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
23 changes: 12 additions & 11 deletions src/constants/r2Prefixes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@ export const VIRTUAL_DIRS: Record<string, Set<string>> = {
),
};

export const URL_TO_BUCKET_PATH_MAP: Record<string, (url: URL) => string> = {
dist: (url): string =>
DIST_PATH_PREFIX + (url.pathname.substring('/dist'.length) || '/'),
download: (url): string =>
DOWNLOAD_PATH_PREFIX + (url.pathname.substring('/download'.length) || '/'),
docs: (url): string =>
DOCS_PATH_PREFIX + (url.pathname.substring('/docs'.length) || '/'),
api: (url): string =>
API_PATH_PREFIX + (url.pathname.substring('/api'.length) || '/'),
metrics: (url): string => url.pathname.substring(1), // substring to cut off the /
};
export const URL_TO_BUCKET_PATH_MAP: Record<string, (path: string) => string> =
{
dist: (path): string =>
DIST_PATH_PREFIX + (path.substring('/dist'.length) || '/'),
download: (path): string =>
DOWNLOAD_PATH_PREFIX + (path.substring('/download'.length) || '/'),
docs: (path): string =>
DOCS_PATH_PREFIX + (path.substring('/docs'.length) || '/'),
api: (path): string =>
API_PATH_PREFIX + (path.substring('/api'.length) || '/'),
metrics: (path): string => path.substring(1), // substring to cut off the /
};
2 changes: 1 addition & 1 deletion src/handlers/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const getHandler: Handler = async (request, ctx) => {
return responses.badRequest();
}

const bucketPath = mapUrlPathToBucketPath(requestUrl, ctx.env);
const bucketPath = mapUrlPathToBucketPath(requestUrl.pathname, ctx.env);

if (typeof bucketPath === 'undefined') {
// Directory listing is restricted and we're not on
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;
153 changes: 153 additions & 0 deletions src/providers/r2Provider.ts
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;
}
8 changes: 4 additions & 4 deletions src/utils/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import { Env } from '../env';
* if the eyeball should not be trying to access the resource
*/
export function mapUrlPathToBucketPath(
url: URL,
path: string,
env: Pick<Env, 'DIRECTORY_LISTING'>
): string | undefined {
const [, basePath, ...pathPieces] = url.pathname.split('/'); // 'docs', ['asd', '123']
const [, basePath, ...pathPieces] = path.split('/'); // 'docs', ['asd', '123']

const mappedDist = `${DIST_PATH_PREFIX}/${pathPieces[0]}`;

Expand All @@ -44,11 +44,11 @@ export function mapUrlPathToBucketPath(
}

if (basePath in URL_TO_BUCKET_PATH_MAP) {
return URL_TO_BUCKET_PATH_MAP[basePath](url);
return URL_TO_BUCKET_PATH_MAP[basePath](path);
}

if (env.DIRECTORY_LISTING !== 'restricted') {
return url.pathname.substring(1);
return path.substring(1);
}

return undefined;
Expand Down
29 changes: 29 additions & 0 deletions src/utils/provider.ts
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;
}
Loading

0 comments on commit 31ccaa6

Please sign in to comment.