Skip to content

Commit adc816e

Browse files
authored
src: fix symlink issue, subtitution middleware hack (#223)
Signed-off-by: flakey5 <[email protected]>
1 parent 9ca242d commit adc816e

File tree

19 files changed

+2642
-255
lines changed

19 files changed

+2642
-255
lines changed

.github/workflows/update-links.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ jobs:
3737
run: npm install && npm update nodejs-latest-linker --save
3838

3939
- name: Update Redirect Links
40-
run: node scripts/update-latest-versions.js && npm run format
40+
run: node scripts/build-r2-symlinks.mjs && npm run format
4141
env:
4242
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
4343
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}

scripts/build-r2-symlinks.mjs

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
4+
import { join } from 'node:path';
5+
import { writeFile } from 'node:fs/promises';
6+
import {
7+
HeadObjectCommand,
8+
ListObjectsV2Command,
9+
S3Client,
10+
} from '@aws-sdk/client-s3';
11+
import { Linker } from 'nodejs-latest-linker/common.js';
12+
import { DOCS_DIR, ENDPOINT, PROD_BUCKET, RELEASE_DIR } from './constants.mjs';
13+
14+
const DOCS_DIRECTORY_OUT = join(
15+
import.meta.dirname,
16+
'..',
17+
'src',
18+
'constants',
19+
'docsDirectory.json'
20+
);
21+
22+
const LATEST_VERSIONS_OUT = join(
23+
import.meta.dirname,
24+
'..',
25+
'src',
26+
'constants',
27+
'latestVersions.json'
28+
);
29+
30+
const CACHED_DIRECTORIES_OUT = join(
31+
import.meta.dirname,
32+
'..',
33+
'src',
34+
'constants',
35+
'cachedDirectories.json'
36+
);
37+
38+
if (!process.env.CF_ACCESS_KEY_ID) {
39+
throw new TypeError('CF_ACCESS_KEY_ID missing');
40+
}
41+
42+
if (!process.env.CF_SECRET_ACCESS_KEY) {
43+
throw new TypeError('CF_SECRET_ACCESS_KEY missing');
44+
}
45+
46+
const client = new S3Client({
47+
endpoint: ENDPOINT,
48+
region: 'auto',
49+
credentials: {
50+
accessKeyId: process.env.CF_ACCESS_KEY_ID,
51+
secretAccessKey: process.env.CF_SECRET_ACCESS_KEY,
52+
},
53+
});
54+
55+
// Cache the contents of `nodejs/docs/` so we can reference it in the worker
56+
await writeDocsDirectoryFile(client);
57+
58+
// Grab all of the files & directories in `nodejs/release/`
59+
const releases = await listDirectory(client, RELEASE_DIR);
60+
61+
// Create the latest version mapping with the contents of `nodejs/release/`
62+
const latestVersions = await getLatestVersionMapping(client, releases);
63+
64+
// Write it so we can use it in the worker
65+
await writeFile(LATEST_VERSIONS_OUT, JSON.stringify(latestVersions));
66+
67+
// Filter the latest version map so we only have the `latest-*` directories
68+
const latestVersionDirectories = Object.keys(latestVersions).map(version =>
69+
version === 'node-latest.tar.gz' ? version : `${version}/`
70+
);
71+
72+
// Create the complete listing of `nodejs/release/` by adding what R2 returned
73+
// and the latest version directories (which are the symlinks)
74+
const releaseDirectorySubdirectories = releases.subdirectories
75+
.concat(latestVersionDirectories)
76+
.sort();
77+
78+
// This is the path in R2 for the latest tar archive of Node.
79+
const nodeLatestPath = `nodejs/release/${latestVersions['node-latest.tar.gz'].replaceAll('latest', latestVersions['latest'])}`;
80+
81+
// Stat the file that `node-latest.tar.gz` points to so we can have accurate
82+
// size & last modified info for the directory listing
83+
const nodeLatest = await headFile(client, nodeLatestPath);
84+
if (!nodeLatest) {
85+
throw new TypeError(
86+
`node-latest.tar.gz points to ${latestVersions['node-latest.tar.gz']} which doesn't exist in the prod bucket`
87+
);
88+
}
89+
90+
/**
91+
* Preprocess these directories since they have symlinks in them that don't
92+
* actually exist in R2 but need to be present when we give a directory listing
93+
* result
94+
* @type {Record<string, import('../src/providers/provider.ts').ReadDirectoryResult>}
95+
*/
96+
const cachedDirectories = {
97+
'nodejs/release/': {
98+
subdirectories: releaseDirectorySubdirectories,
99+
hasIndexHtmlFile: false,
100+
files: [
101+
...releases.files,
102+
{
103+
name: 'node-latest.tar.gz',
104+
lastModified: nodeLatest.lastModified,
105+
size: nodeLatest.size,
106+
},
107+
],
108+
lastModified: releases.lastModified,
109+
},
110+
'nodejs/docs/': {
111+
// We reuse the releases listing result here instead of listing the docs
112+
// directory since it's more complete. The docs folder does have some actual
113+
// directories in it, but most of it is symlinks and aren't present in R2.
114+
subdirectories: releaseDirectorySubdirectories,
115+
hasIndexHtmlFile: false,
116+
files: [],
117+
lastModified: releases.lastModified,
118+
},
119+
};
120+
121+
await writeFile(CACHED_DIRECTORIES_OUT, JSON.stringify(cachedDirectories));
122+
123+
/**
124+
* @param {S3Client} client
125+
* @param {string} directory
126+
* @returns {Promise<import('../src/providers/provider.js').ReadDirectoryResult>}
127+
*/
128+
async function listDirectory(client, directory) {
129+
/**
130+
* @type {Array<string>}
131+
*/
132+
const subdirectories = [];
133+
134+
/**
135+
* @type {Array<import('../src/providers/provider.js').File>}
136+
*/
137+
const files = [];
138+
139+
let hasIndexHtmlFile = false;
140+
141+
let truncated = true;
142+
let continuationToken;
143+
let lastModified = new Date(0);
144+
while (truncated) {
145+
const data = await client.send(
146+
new ListObjectsV2Command({
147+
Bucket: PROD_BUCKET,
148+
Delimiter: '/',
149+
Prefix: directory,
150+
ContinuationToken: continuationToken,
151+
})
152+
);
153+
154+
if (data.CommonPrefixes) {
155+
data.CommonPrefixes.forEach(value => {
156+
if (value.Prefix) {
157+
subdirectories.push(value.Prefix.substring(directory.length));
158+
}
159+
});
160+
}
161+
162+
if (data.Contents) {
163+
data.Contents.forEach(value => {
164+
if (value.Key) {
165+
if (value.Key.match(/index.htm(?:l)$/)) {
166+
hasIndexHtmlFile = true;
167+
}
168+
169+
files.push({
170+
name: value.Key.substring(directory.length),
171+
lastModified: value.LastModified,
172+
size: value.Size,
173+
});
174+
175+
if (value.LastModified > lastModified) {
176+
lastModified = value.LastModified;
177+
}
178+
}
179+
});
180+
}
181+
182+
truncated = data.IsTruncated;
183+
continuationToken = data.NextContinuationToken;
184+
}
185+
186+
return { subdirectories, hasIndexHtmlFile, files, lastModified };
187+
}
188+
189+
/**
190+
* @param {S3Client} client
191+
* @param {string} path
192+
* @returns {Promise<import('../src/providers/provider.js').File | undefined>}
193+
*/
194+
async function headFile(client, path) {
195+
const data = await client.send(
196+
new HeadObjectCommand({
197+
Bucket: PROD_BUCKET,
198+
Key: path,
199+
})
200+
);
201+
202+
if (!data.LastModified || !data.ContentLength) {
203+
return undefined;
204+
}
205+
206+
return {
207+
name: path,
208+
lastModified: data.LastModified,
209+
size: data.ContentLength,
210+
};
211+
}
212+
213+
/**
214+
* @param {S3Client} client
215+
*/
216+
async function writeDocsDirectoryFile(client) {
217+
// Grab all of the directories in `nodejs/docs/`
218+
const docs = await listDirectory(client, DOCS_DIR);
219+
220+
// Cache the contents of `nodejs/docs/` so we can refer to it in the worker w/o
221+
// making a call to R2.
222+
await writeFile(
223+
DOCS_DIRECTORY_OUT,
224+
JSON.stringify(
225+
docs.subdirectories.map(subdirectory =>
226+
subdirectory.substring(0, subdirectory.length - 1)
227+
)
228+
)
229+
);
230+
}
231+
232+
/**
233+
* @param {S3Client} client
234+
* @param {import('../src/providers/provider.js').ReadDirectoryResult} releases
235+
* @returns {Promise<Record<string, string>>}
236+
*/
237+
async function getLatestVersionMapping(client, releases) {
238+
const linker = new Linker({ baseDir: RELEASE_DIR, docs: DOCS_DIR });
239+
240+
/**
241+
* Creates mappings to the latest versions of Node
242+
* @type {Map<string, string>}
243+
* @example { 'nodejs/release/latest-v20.x': 'nodejs/release/v20.x.x' }
244+
*/
245+
const links = await linker.getLinks(
246+
[...releases.subdirectories, ...releases.files.map(file => file.name)],
247+
async directory => {
248+
const { subdirectories, files } = await listDirectory(
249+
client,
250+
`${directory}/`
251+
);
252+
return [...subdirectories, ...files.map(file => file.name)];
253+
}
254+
);
255+
256+
/**
257+
* @type {Record<string, string>}
258+
* @example {'latest-v20.x': 'v20.x.x'}
259+
*/
260+
const latestVersions = {};
261+
262+
for (const [key, value] of links) {
263+
const trimmedKey = key.substring(RELEASE_DIR.length);
264+
const trimmedValue = value.substring(RELEASE_DIR.length);
265+
266+
latestVersions[trimmedKey] = trimmedValue;
267+
}
268+
269+
return latestVersions;
270+
}

scripts/constants.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ export const PROD_BUCKET = process.env.PROD_BUCKET ?? 'dist-prod';
99
export const STAGING_BUCKET = process.env.STAGING_BUCKET ?? 'dist-staging';
1010

1111
export const R2_RETRY_COUNT = 3;
12+
13+
export const RELEASE_DIR = 'nodejs/release/';
14+
15+
export const DOCS_DIR = 'nodejs/docs/';

0 commit comments

Comments
 (0)