Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Your Cloudflare account tag.
#
# Needed for:
# - Directory cache scripts
CLOUDFLARE_ACCOUNT_ID=

# Cloudflare V4 API token.
#
# Needed for:
# - Directory cache scripts
#
# Required permissions:
# - `Workers KV Storage`: Edit
# - `Workers R2 Storage`: Read
#
# See https://developers.cloudflare.com/fundamentals/api/get-started/create-token/
CLOUDFLARE_API_TOKEN=

# S3 credentials for your R2 bucket.
#
# Needed for:
# - Directory listings in the worker.
# - Directory cache scripts
#
# Required permissions:
# - `Object Read Only`
#
# See https://dash.cloudflare.com/?account=/r2/api-tokens
S3_ACCESS_KEY_ID=
S3_ACCESS_KEY_SECRET=
15 changes: 11 additions & 4 deletions .github/workflows/update-links.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ permissions:
on:
# Triggered by https://github.com/nodejs/node/blob/main/.github/workflows/update-release-links.yml
workflow_dispatch:
inputs:
version:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking till node core pr passing this input in gets made + merged

description: 'Node.js version (ex/ `v20.0.0`)'
required: true
type: string

concurrency:
group: update-redirect-links
Expand Down Expand Up @@ -49,11 +54,13 @@ jobs:
- name: Install dependencies
run: npm ci && npm update nodejs-latest-linker --save

- name: Update Redirect Links
run: node scripts/build-r2-symlinks.mjs && node --run format
- name: Update Directory Cache
run: node scripts/update-directory-cache.mjs "$VERSION_INPUT" && node --run format
env:
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
VERSION_INPUT: '${{ inputs.version }}'
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
S3_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
S3_ACCESS_KEY_SECRET: ${{ secrets.CF_SECRET_ACCESS_KEY }}

- name: Commit Changes
id: git_auto_commit
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ node_modules/
dist/
.dev.vars
.sentryclirc
.env
3 changes: 3 additions & 0 deletions common/README.md
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this readme?

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Common

Utilities used in local scripts and in the deployed worker.
7 changes: 6 additions & 1 deletion src/constants/limits.ts → common/limits.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
/**
* Max amount of retries for R2 requests
* Max amount of retries for requests to R2
*/
export const R2_RETRY_LIMIT = 5;

/**
* Max amount of retries for requests to KV
*/
export const KV_RETRY_LIMIT = 5;

/**
* Max amount of keys to be returned in a S3 request
*/
Expand Down
111 changes: 111 additions & 0 deletions common/listR2Directory.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
import { R2_RETRY_LIMIT, S3_MAX_KEYS } from './limits.mjs';

/**
* List the contents of a directory in R2.
*
* @param {import('@aws-sdk/client-s3').S3Client} client
* @param {string} bucket
* @param {string | undefined} [directory=undefined]
* @param {number} retryCount
* @returns {Promise<import('../src/providers/provider.js').ReadDirectoryResult | undefined>}
*/
export async function listR2Directory(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this mega function could be broken down into smaller chunks to make it easier to read. Also should this function and file be named listR2Directory do we list directories from other sources? Otherwise just call it listDirectory?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, why are these inside a folder called "common" instead of "src"?

client,
bucket,
directory = undefined,
retryCount = R2_RETRY_LIMIT
) {
/**
* @type {Set<string>}
*/
const subdirectories = new Set();

/**
* @type {Set<import('../src/providers/provider.js').File>}
*/
const files = new Set();

let hasIndexHtmlFile = false;
let directoryLastModified = new Date(0);

let isTruncated;
let continuationToken;
do {
/**
* @type {import('@aws-sdk/client-s3').ListObjectsV2Output | undefined}
*/
let data = undefined;

let retriesLeft = retryCount;
while (retriesLeft) {
try {
data = await client.send(
new ListObjectsV2Command({
Bucket: bucket,
Delimiter: '/',
Prefix: directory,
ContinuationToken: continuationToken,
MaxKeys: S3_MAX_KEYS,
})
);

break;
} catch (err) {
retriesLeft--;

if (retriesLeft === 0) {
throw new Error('exhausted R2 retries', { cause: err });
}
}
}

if (!data) {
return undefined;
}

isTruncated = data.IsTruncated;
continuationToken = data.NextContinuationToken;

data.CommonPrefixes?.forEach(subdirectory => {
if (subdirectory.Prefix) {
subdirectories.add(
subdirectory.Prefix.substring(directory?.length ?? 0)
);
}
});

data.Contents?.forEach(file => {
if (!file.Key) {
return;
}

if (!hasIndexHtmlFile && file.Key.match(/index.htm(?:l)$/)) {
hasIndexHtmlFile = true;
}

files.add({
name: file.Key.substring(directory?.length ?? 0),
lastModified: file.LastModified,
size: file.Size,
});

// Set the directory's last modified date to be the same as the most
// recently updated file
if (file.LastModified > directoryLastModified) {
directoryLastModified = file.LastModified;
}
});
} while (isTruncated);

if (subdirectories.size === 0 && files.size === 0) {
return undefined;
}

return {
subdirectories: Array.from(subdirectories),
hasIndexHtmlFile,
files: Array.from(files),
lastModified: directoryLastModified,
};
}
177 changes: 177 additions & 0 deletions common/listR2Directory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { test, expect } from 'vitest';
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
import { listR2Directory } from './listR2Directory.mjs';
import { R2_RETRY_LIMIT } from './limits.mjs';

test('adds subdirectories and files properly', async () => {
const now = new Date();

// Add a second so we can check the directory's last modified is determined properly
const directoryLastModified = new Date(now.getTime() + 1000);

const client = {
async send(cmd: ListObjectsV2Command) {
expect(cmd.input.Bucket).toStrictEqual('dist-prod');
expect(cmd.input.Prefix).toStrictEqual('some/directory/');

return {
IsTruncated: false,
NextContinuationToken: undefined,
CommonPrefixes: [
{ Prefix: 'some/directory/subdirectory1/' },
{ Prefix: 'some/directory/subdirectory2/' },
{ Prefix: 'some/directory/subdirectory3/' },
],
Contents: [
{
Key: 'some/directory/file.txt',
LastModified: now,
Size: 1,
},
{
Key: 'some/directory/file2.txt',
LastModified: directoryLastModified,
Size: 2,
},
{
Key: 'some/directory/file3.txt',
LastModified: now,
Size: 3,
},
],
};
},
};

// @ts-expect-error don't need full client
const result = await listR2Directory(client, 'dist-prod', 'some/directory/');

expect(result).toStrictEqual({
subdirectories: ['subdirectory1/', 'subdirectory2/', 'subdirectory3/'],
hasIndexHtmlFile: false,
files: [
{
name: 'file.txt',
lastModified: now,
size: 1,
},
{
name: 'file2.txt',
lastModified: directoryLastModified,
size: 2,
},
{
name: 'file3.txt',
lastModified: now,
size: 3,
},
],
lastModified: directoryLastModified,
});
});

test('handles truncation properly', async () => {
const now = new Date();

const client = {
async send(cmd: ListObjectsV2Command) {
expect(cmd.input.Bucket).toStrictEqual('dist-prod');
expect(cmd.input.Prefix).toStrictEqual('some/directory/');

switch (cmd.input.ContinuationToken) {
case undefined: {
return {
IsTruncated: true,
NextContinuationToken: '1',
CommonPrefixes: [{ Prefix: 'some/directory/subdirectory1/' }],
Contents: [
{
Key: 'some/directory/file.txt',
LastModified: now,
Size: 1,
},
],
};
}
case '1': {
return {
IsTruncated: true,
NextContinuationToken: '2',
CommonPrefixes: [{ Prefix: 'some/directory/subdirectory2/' }],
Contents: [
{
Key: 'some/directory/file2.txt',
LastModified: now,
Size: 2,
},
],
};
}
case '2': {
return {
IsTruncated: false,
NextContinuationToken: undefined,
CommonPrefixes: [{ Prefix: 'some/directory/subdirectory3/' }],
Contents: [
{
Key: 'some/directory/file3.txt',
LastModified: now,
Size: 3,
},
],
};
}
}
},
};

// @ts-expect-error don't need full client
const result = await listR2Directory(client, 'dist-prod', 'some/directory/');

expect(result).toStrictEqual({
subdirectories: ['subdirectory1/', 'subdirectory2/', 'subdirectory3/'],
hasIndexHtmlFile: false,
files: [
{
name: 'file.txt',
lastModified: now,
size: 1,
},
{
name: 'file2.txt',
lastModified: now,
size: 2,
},
{
name: 'file3.txt',
lastModified: now,
size: 3,
},
],
lastModified: now,
});
});

test('retries properly', async () => {
let retries = R2_RETRY_LIMIT;

let requestsSent = 0;
const client = {
async send() {
requestsSent++;

throw new TypeError('dummy');
},
};

const result = listR2Directory(
// @ts-expect-error don't need full client
client,
'dist-prod',
'some/directory/',
retries
);

await expect(result).rejects.toThrow('exhausted R2 retries');
expect(requestsSent).toBe(retries);
});
Loading
Loading