Skip to content

Commit 0ade6ef

Browse files
committed
src, scripts: use kv to cache directories
Signed-off-by: flakey5 <73616808+flakey5@users.noreply.github.com>
1 parent 20bcac4 commit 0ade6ef

30 files changed

+1807
-533
lines changed

.env.example

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Your Cloudflare account tag.
2+
#
3+
# Needed for:
4+
# - Directory cache scripts
5+
CLOUDFLARE_ACCOUNT_ID=
6+
7+
# Cloudflare V4 API token.
8+
#
9+
# Needed for:
10+
# - Directory cache scripts
11+
#
12+
# Required permissions:
13+
# - `Workers KV Storage`: Edit
14+
# - `Workers R2 Storage`: Read
15+
#
16+
# See https://developers.cloudflare.com/fundamentals/api/get-started/create-token/
17+
CLOUDFLARE_API_TOKEN=
18+
19+
# S3 credentials for your R2 bucket.
20+
#
21+
# Needed for:
22+
# - Directory listings in the worker.
23+
# - Directory cache scripts
24+
#
25+
# Required permissions:
26+
# - `Object Read Only`
27+
#
28+
# See https://dash.cloudflare.com/?account=/r2/api-tokens
29+
S3_ACCESS_KEY_ID=
30+
S3_ACCESS_KEY_SECRET=

.github/workflows/update-links.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ permissions:
66
on:
77
# Triggered by https://github.com/nodejs/node/blob/main/.github/workflows/update-release-links.yml
88
workflow_dispatch:
9+
inputs:
10+
version:
11+
description: 'Node.js version (ex/ `v20.0.0`)'
12+
required: true
13+
type: string
914
schedule:
1015
- cron: '0 0 * * *'
1116

@@ -81,6 +86,15 @@ jobs:
8186
env:
8287
GITHUB_TOKEN: ${{ secrets.GH_BOT_TOKEN }}
8388

89+
# Do this last for now so we avoid breaking releases if anything breaks
90+
- name: Update Directory Cache
91+
run: node scripts/update-directory-cache.mjs "$VERSION_INPUT"
92+
env:
93+
VERSION_INPUT: '${{ inputs.version }}'
94+
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
95+
S3_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
96+
S3_ACCESS_KEY_SECRET: ${{ secrets.CF_SECRET_ACCESS_KEY }}
97+
8498
- name: Alert on Failure
8599
if: failure() && github.repository == 'nodejs/release-cloudflare-worker'
86100
uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # 2.3.3

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ node_modules/
33
dist/
44
.dev.vars
55
.sentryclirc
6+
.env

common/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Common
2+
3+
Utilities used in local scripts and in the deployed worker.
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
/**
2-
* Max amount of retries for R2 requests
2+
* Max amount of retries for requests to R2
33
*/
44
export const R2_RETRY_LIMIT = 5;
55

6+
/**
7+
* Max amount of retries for requests to KV
8+
*/
9+
export const KV_RETRY_LIMIT = 5;
10+
611
/**
712
* Max amount of keys to be returned in a S3 request
813
*/

common/listR2Directory.mjs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
2+
import { R2_RETRY_LIMIT, S3_MAX_KEYS } from './limits';
3+
4+
/**
5+
* List the contents of a directory in R2.
6+
*
7+
* @param {import('@aws-sdk/client-s3').S3Client} client
8+
* @param {string} bucket
9+
* @param {string | undefined} [directory=undefined]
10+
* @param {number} retryCount
11+
* @returns {Promise<import('../src/providers/provider.js').ReadDirectoryResult | undefined>}
12+
*/
13+
export async function listR2Directory(
14+
client,
15+
bucket,
16+
directory = undefined,
17+
retryCount = R2_RETRY_LIMIT
18+
) {
19+
/**
20+
* @type {Set<string>}
21+
*/
22+
const subdirectories = new Set();
23+
24+
/**
25+
* @type {Set<import('../src/providers/provider.js').File>}
26+
*/
27+
const files = new Set();
28+
29+
let hasIndexHtmlFile = false;
30+
let directoryLastModified = new Date(0);
31+
32+
let isTruncated;
33+
let continuationToken;
34+
do {
35+
/**
36+
* @type {import('@aws-sdk/client-s3').ListObjectsV2Output | undefined}
37+
*/
38+
let data = undefined;
39+
40+
let retriesLeft = retryCount;
41+
while (retriesLeft) {
42+
try {
43+
data = await client.send(
44+
new ListObjectsV2Command({
45+
Bucket: bucket,
46+
Delimiter: '/',
47+
Prefix: directory,
48+
ContinuationToken: continuationToken,
49+
MaxKeys: S3_MAX_KEYS,
50+
})
51+
);
52+
53+
break;
54+
} catch (err) {
55+
retriesLeft--;
56+
57+
if (retriesLeft === 0) {
58+
throw new Error('exhausted R2 retries', { cause: err });
59+
}
60+
}
61+
}
62+
63+
if (!data) {
64+
return undefined;
65+
}
66+
67+
isTruncated = data.IsTruncated;
68+
continuationToken = data.NextContinuationToken;
69+
70+
data.CommonPrefixes?.forEach(subdirectory => {
71+
if (subdirectory.Prefix) {
72+
subdirectories.add(
73+
subdirectory.Prefix.substring(directory?.length ?? 0)
74+
);
75+
}
76+
});
77+
78+
data.Contents?.forEach(file => {
79+
if (!file.Key) {
80+
return;
81+
}
82+
83+
if (!hasIndexHtmlFile && file.Key.match(/index.htm(?:l)$/)) {
84+
hasIndexHtmlFile = true;
85+
}
86+
87+
files.add({
88+
name: file.Key.substring(directory?.length ?? 0),
89+
lastModified: file.LastModified,
90+
size: file.Size,
91+
});
92+
93+
// Set the directory's last modified date to be the same as the most
94+
// recently updated file
95+
if (file.LastModified > directoryLastModified) {
96+
directoryLastModified = file.LastModified;
97+
}
98+
});
99+
} while (isTruncated);
100+
101+
return {
102+
subdirectories: Array.from(subdirectories),
103+
hasIndexHtmlFile,
104+
files: Array.from(files),
105+
lastModified: directoryLastModified,
106+
};
107+
}

common/listR2Directory.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { test, expect } from 'vitest';
2+
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
3+
import { listR2Directory } from './listR2Directory.mjs';
4+
import { R2_RETRY_LIMIT } from './limits.mjs';
5+
6+
test('adds subdirectories and files properly', async () => {
7+
const now = new Date();
8+
9+
// Add a second so we can check the directory's last modified is determined properly
10+
const directoryLastModified = new Date(now.getTime() + 1000);
11+
12+
const client = {
13+
async send(cmd: ListObjectsV2Command) {
14+
expect(cmd.input.Bucket).toStrictEqual('dist-prod');
15+
expect(cmd.input.Prefix).toStrictEqual('some/directory/');
16+
17+
return {
18+
IsTruncated: false,
19+
NextContinuationToken: undefined,
20+
CommonPrefixes: [
21+
{ Prefix: 'some/directory/subdirectory1/' },
22+
{ Prefix: 'some/directory/subdirectory2/' },
23+
{ Prefix: 'some/directory/subdirectory3/' },
24+
],
25+
Contents: [
26+
{
27+
Key: 'some/directory/file.txt',
28+
LastModified: now,
29+
Size: 1,
30+
},
31+
{
32+
Key: 'some/directory/file2.txt',
33+
LastModified: directoryLastModified,
34+
Size: 2,
35+
},
36+
{
37+
Key: 'some/directory/file3.txt',
38+
LastModified: now,
39+
Size: 3,
40+
},
41+
],
42+
};
43+
},
44+
};
45+
46+
// @ts-expect-error don't need full client
47+
const result = await listR2Directory(client, 'dist-prod', 'some/directory/');
48+
49+
expect(result).toStrictEqual({
50+
subdirectories: ['subdirectory1/', 'subdirectory2/', 'subdirectory3/'],
51+
hasIndexHtmlFile: false,
52+
files: [
53+
{
54+
name: 'file.txt',
55+
lastModified: now,
56+
size: 1,
57+
},
58+
{
59+
name: 'file2.txt',
60+
lastModified: directoryLastModified,
61+
size: 2,
62+
},
63+
{
64+
name: 'file3.txt',
65+
lastModified: now,
66+
size: 3,
67+
},
68+
],
69+
lastModified: directoryLastModified,
70+
});
71+
});
72+
73+
test('handles truncation properly', async () => {
74+
const now = new Date();
75+
76+
const client = {
77+
async send(cmd: ListObjectsV2Command) {
78+
expect(cmd.input.Bucket).toStrictEqual('dist-prod');
79+
expect(cmd.input.Prefix).toStrictEqual('some/directory/');
80+
81+
switch (cmd.input.ContinuationToken) {
82+
case undefined: {
83+
return {
84+
IsTruncated: true,
85+
NextContinuationToken: '1',
86+
CommonPrefixes: [{ Prefix: 'some/directory/subdirectory1/' }],
87+
Contents: [
88+
{
89+
Key: 'some/directory/file.txt',
90+
LastModified: now,
91+
Size: 1,
92+
},
93+
],
94+
};
95+
}
96+
case '1': {
97+
return {
98+
IsTruncated: true,
99+
NextContinuationToken: '2',
100+
CommonPrefixes: [{ Prefix: 'some/directory/subdirectory2/' }],
101+
Contents: [
102+
{
103+
Key: 'some/directory/file2.txt',
104+
LastModified: now,
105+
Size: 2,
106+
},
107+
],
108+
};
109+
}
110+
case '2': {
111+
return {
112+
IsTruncated: false,
113+
NextContinuationToken: undefined,
114+
CommonPrefixes: [{ Prefix: 'some/directory/subdirectory3/' }],
115+
Contents: [
116+
{
117+
Key: 'some/directory/file3.txt',
118+
LastModified: now,
119+
Size: 3,
120+
},
121+
],
122+
};
123+
}
124+
}
125+
},
126+
};
127+
128+
// @ts-expect-error don't need full client
129+
const result = await listR2Directory(client, 'dist-prod', 'some/directory/');
130+
131+
expect(result).toStrictEqual({
132+
subdirectories: ['subdirectory1/', 'subdirectory2/', 'subdirectory3/'],
133+
hasIndexHtmlFile: false,
134+
files: [
135+
{
136+
name: 'file.txt',
137+
lastModified: now,
138+
size: 1,
139+
},
140+
{
141+
name: 'file2.txt',
142+
lastModified: now,
143+
size: 2,
144+
},
145+
{
146+
name: 'file3.txt',
147+
lastModified: now,
148+
size: 3,
149+
},
150+
],
151+
lastModified: now,
152+
});
153+
});
154+
155+
test('retries properly', async () => {
156+
let retries = R2_RETRY_LIMIT;
157+
158+
let requestsSent = 0;
159+
const client = {
160+
async send() {
161+
requestsSent++;
162+
163+
throw new TypeError('dummy');
164+
},
165+
};
166+
167+
const result = listR2Directory(
168+
// @ts-expect-error don't need full client
169+
client,
170+
'dist-prod',
171+
'some/directory/',
172+
retries
173+
);
174+
175+
await expect(result).rejects.toThrow('exhausted R2 retries');
176+
expect(requestsSent).toBe(retries);
177+
});

0 commit comments

Comments
 (0)