-
-
Notifications
You must be signed in to change notification settings - Fork 14
src, scripts: use kv to cache directories #756
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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= |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,3 +3,4 @@ node_modules/ | |
| dist/ | ||
| .dev.vars | ||
| .sentryclirc | ||
| .env | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
| 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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| }; | ||
| } | ||
| 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); | ||
| }); |
There was a problem hiding this comment.
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