From 7f20585277e50b371dee6f3123e36671d86ca4bb Mon Sep 17 00:00:00 2001 From: flakey5 <73616808+flakey5@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:49:50 -0500 Subject: [PATCH] src: use itty-router for routing requests Closes #122 Signed-off-by: flakey5 <73616808+flakey5@users.noreply.github.com> --- .github/workflows/update-links.yml | 2 +- package-lock.json | 6 + package.json | 5 +- ...ect-links.js => update-latest-versions.js} | 17 +- src/constants/cache.ts | 2 - src/constants/latestVersions.json | 33 + src/constants/r2Prefixes.ts | 34 +- src/constants/redirectLinks.json | 784 ------------------ src/env.ts | 22 - src/handlers/get.ts | 109 --- src/handlers/handler.ts | 8 - src/handlers/index.ts | 9 - src/handlers/options.ts | 11 - src/handlers/post.ts | 23 - src/handlers/strategies/cachePurge.ts | 92 -- src/handlers/strategies/directoryListing.ts | 281 ------- src/handlers/strategies/serveFile.ts | 219 ----- src/middleware/cacheMiddleware.ts | 58 ++ src/middleware/middleware.ts | 17 + src/middleware/notFoundMiddleware.ts | 9 + src/middleware/optionsMiddleware.ts | 15 + src/middleware/originMiddleware.ts | 24 + src/middleware/r2Middleware.ts | 150 ++++ src/middleware/subtituteMiddleware.ts | 35 + src/providers/originProvider.ts | 105 --- src/providers/provider.ts | 42 +- src/providers/r2Provider.ts | 42 +- src/providers/s3Provider.ts | 28 +- src/responses/directoryNotFound.ts | 2 +- src/responses/fileNotFound.ts | 2 +- src/routes/index.ts | 56 ++ src/routes/request.ts | 9 + src/routes/router.ts | 111 +++ src/utils/cache.ts | 2 +- src/utils/directoryListing.ts | 118 +++ src/utils/memo.ts | 19 + src/utils/path.ts | 159 ---- src/utils/provider.ts | 1 - src/utils/request.ts | 97 +++ src/worker.ts | 19 +- tests/e2e/cachePurge.test.ts | 116 --- tests/e2e/directory.test.ts | 12 - tests/e2e/fallback.test.ts | 4 +- tests/e2e/file.test.ts | 3 +- tests/e2e/index.test.ts | 1 - tests/unit/index.test.ts | 3 + tests/unit/router/router.test.ts | 68 ++ tests/unit/utils/memo.test.ts | 20 + tests/unit/utils/path.test.ts | 175 +--- tests/unit/utils/request.test.ts | 51 ++ wrangler.toml | 15 - 51 files changed, 981 insertions(+), 2264 deletions(-) rename scripts/{update-redirect-links.js => update-latest-versions.js} (73%) create mode 100644 src/constants/latestVersions.json delete mode 100644 src/constants/redirectLinks.json delete mode 100644 src/handlers/get.ts delete mode 100644 src/handlers/handler.ts delete mode 100644 src/handlers/index.ts delete mode 100644 src/handlers/options.ts delete mode 100644 src/handlers/post.ts delete mode 100644 src/handlers/strategies/cachePurge.ts delete mode 100644 src/handlers/strategies/directoryListing.ts delete mode 100644 src/handlers/strategies/serveFile.ts create mode 100644 src/middleware/cacheMiddleware.ts create mode 100644 src/middleware/middleware.ts create mode 100644 src/middleware/notFoundMiddleware.ts create mode 100644 src/middleware/optionsMiddleware.ts create mode 100644 src/middleware/originMiddleware.ts create mode 100644 src/middleware/r2Middleware.ts create mode 100644 src/middleware/subtituteMiddleware.ts delete mode 100644 src/providers/originProvider.ts create mode 100644 src/routes/index.ts create mode 100644 src/routes/request.ts create mode 100644 src/routes/router.ts create mode 100644 src/utils/directoryListing.ts create mode 100644 src/utils/memo.ts delete mode 100644 tests/e2e/cachePurge.test.ts create mode 100644 tests/unit/router/router.test.ts create mode 100644 tests/unit/utils/memo.test.ts create mode 100644 tests/unit/utils/request.test.ts diff --git a/.github/workflows/update-links.yml b/.github/workflows/update-links.yml index 2fd6356..8eeca82 100644 --- a/.github/workflows/update-links.yml +++ b/.github/workflows/update-links.yml @@ -28,7 +28,7 @@ jobs: run: npm install - name: Update Redirect Links - run: node scripts/update-redirect-links.js && npm run format + run: node scripts/update-latest-versions.js && npm run format env: CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }} CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }} diff --git a/package-lock.json b/package-lock.json index ca756d2..6e289b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.423.0", "handlebars": "^4.7.8", + "itty-router": "^5.0.17", "toucan-js": "^3.3.1", "zod": "^3.22.3" }, @@ -3696,6 +3697,11 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/itty-router": { + "version": "5.0.17", + "resolved": "https://registry.npmjs.org/itty-router/-/itty-router-5.0.17.tgz", + "integrity": "sha512-ZHnPI0OOyTTLuNp2FdciejYaK4Wl3ZV3O0yEm8njOGggh/k/ek3BL7X2I5YsCOfc5vLhIJgj3Z4pUtLs6k9Ucg==" + }, "node_modules/jackspeak": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", diff --git a/package.json b/package.json index 828d340..b7cb0a6 100644 --- a/package.json +++ b/package.json @@ -23,16 +23,17 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "glob": "^10.3.10", + "nodejs-latest-linker": "^1.7.0", "prettier": "^3.0.3", "terser": "^5.20.0", "tsx": "^4.7.2", "typescript": "^5.2.2", - "wrangler": "^3.22.1", - "nodejs-latest-linker": "^1.7.0" + "wrangler": "^3.22.1" }, "dependencies": { "@aws-sdk/client-s3": "^3.423.0", "handlebars": "^4.7.8", + "itty-router": "^5.0.17", "toucan-js": "^3.3.1", "zod": "^3.22.3" } diff --git a/scripts/update-redirect-links.js b/scripts/update-latest-versions.js similarity index 73% rename from scripts/update-redirect-links.js rename to scripts/update-latest-versions.js index c47c9fc..4ae3520 100644 --- a/scripts/update-redirect-links.js +++ b/scripts/update-latest-versions.js @@ -22,10 +22,21 @@ const client = new S3Client({ (async function main() { const allDirs = await listDirectory(RELEASE_DIR); const linker = new Linker({ baseDir: RELEASE_DIR, docsDir: DOCS_DIR }); - const links = await linker.getLinks(allDirs, dir => listDirectory(`${dir}/`)); + const allLinks = await linker.getLinks(allDirs, dir => + listDirectory(`${dir}/`) + ); + + const latestLinks = Array.from(allLinks).filter(link => + link[0].startsWith('nodejs/release') + ); + latestLinks.forEach(link => { + link[0] = link[0].substring('nodejs/release/'.length); + link[1] = link[1].substring('nodejs/release/'.length); + }); + await writeFile( - './src/constants/redirectLinks.json', - JSON.stringify(Array.from(links), null, 2) + '\n' + './src/constants/latestVersions.json', + JSON.stringify(Object.fromEntries(latestLinks), null, 2) + '\n' ); })(); diff --git a/src/constants/cache.ts b/src/constants/cache.ts index de9897c..7a326c7 100644 --- a/src/constants/cache.ts +++ b/src/constants/cache.ts @@ -1,5 +1,3 @@ -export const CACHE = caches.default; - export const CACHE_HEADERS = { success: 'public, max-age=3600, s-maxage=14400', failure: 'private, no-cache, no-store, max-age=0, must-revalidate', diff --git a/src/constants/latestVersions.json b/src/constants/latestVersions.json new file mode 100644 index 0000000..92ceece --- /dev/null +++ b/src/constants/latestVersions.json @@ -0,0 +1,33 @@ +{ + "latest-v0.10.x": "v0.10.48", + "latest-v0.12.x": "v0.12.18", + "latest-v4.x": "v4.9.1", + "latest-argon": "v4.9.1", + "latest-v5.x": "v5.12.0", + "latest-v6.x": "v6.17.1", + "latest-boron": "v6.17.1", + "latest-v7.x": "v7.10.1", + "latest-v8.x": "v8.17.0", + "latest-carbon": "v8.17.0", + "latest-v9.x": "v9.11.2", + "latest-v10.x": "v10.24.1", + "latest-dubnium": "v10.24.1", + "latest-v11.x": "v11.15.0", + "latest-v12.x": "v12.22.12", + "latest-erbium": "v12.22.12", + "latest-v13.x": "v13.14.0", + "latest-v14.x": "v14.21.3", + "latest-fermium": "v14.21.3", + "latest-v15.x": "v15.14.0", + "latest-v16.x": "v16.20.2", + "latest-gallium": "v16.20.2", + "latest-v17.x": "v17.9.1", + "latest-v18.x": "v18.19.0", + "latest-hydrogen": "v18.19.0", + "latest-v19.x": "v19.9.0", + "latest-v20.x": "v20.10.0", + "latest-iron": "v20.10.0", + "latest-v21.x": "v21.2.0", + "latest": "v21.2.0", + "node-latest.tar.gz": "v21.2.0/node-v21.2.0.tar.gz" +} diff --git a/src/constants/r2Prefixes.ts b/src/constants/r2Prefixes.ts index 4d4fd4d..5be2ca0 100644 --- a/src/constants/r2Prefixes.ts +++ b/src/constants/r2Prefixes.ts @@ -3,36 +3,6 @@ // later on. // (e.g. url path `/dist` points to R2 path `nodejs/release`) // See https://raw.githubusercontent.com/nodejs/build/main/ansible/www-standalone/resources/config/nodejs.org -import map from './redirectLinks.json' assert { type: 'json' }; +import latestVersions from './latestVersions.json' assert { type: 'json' }; -export const REDIRECT_MAP = new Map(map as [string, string][]); - -export const DIST_PATH_PREFIX = 'nodejs/release'; - -export const DOWNLOAD_PATH_PREFIX = 'nodejs'; - -export const DOCS_PATH_PREFIX = 'nodejs/docs'; - -export const API_PATH_PREFIX = `${REDIRECT_MAP.get('nodejs/docs/latest')}/api`; - -export const VIRTUAL_DIRS: Record> = { - 'docs/': new Set( - [...REDIRECT_MAP] - .filter(([key]) => key.startsWith('nodejs/docs/')) - .reverse() - .map(([key]) => key.substring('nodejs/docs/'.length) + '/') - ), -}; - -export const URL_TO_BUCKET_PATH_MAP: Record 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 / - }; +export const LATEST_RELEASES: Record = latestVersions; diff --git a/src/constants/redirectLinks.json b/src/constants/redirectLinks.json deleted file mode 100644 index 1097c13..0000000 --- a/src/constants/redirectLinks.json +++ /dev/null @@ -1,784 +0,0 @@ -[ - ["nodejs/docs/v0.1.100", "nodejs/release/v0.1.100/docs"], - ["nodejs/docs/v0.1.101", "nodejs/release/v0.1.101/docs"], - ["nodejs/docs/v0.1.102", "nodejs/release/v0.1.102/docs"], - ["nodejs/docs/v0.1.103", "nodejs/release/v0.1.103/docs"], - ["nodejs/docs/v0.1.104", "nodejs/release/v0.1.104/docs"], - ["nodejs/docs/v0.1.14", "nodejs/release/v0.1.14/docs"], - ["nodejs/docs/v0.1.15", "nodejs/release/v0.1.15/docs"], - ["nodejs/docs/v0.1.16", "nodejs/release/v0.1.16/docs"], - ["nodejs/docs/v0.1.17", "nodejs/release/v0.1.17/docs"], - ["nodejs/docs/v0.1.18", "nodejs/release/v0.1.18/docs"], - ["nodejs/docs/v0.1.19", "nodejs/release/v0.1.19/docs"], - ["nodejs/docs/v0.1.20", "nodejs/release/v0.1.20/docs"], - ["nodejs/docs/v0.1.21", "nodejs/release/v0.1.21/docs"], - ["nodejs/docs/v0.1.22", "nodejs/release/v0.1.22/docs"], - ["nodejs/docs/v0.1.23", "nodejs/release/v0.1.23/docs"], - ["nodejs/docs/v0.1.24", "nodejs/release/v0.1.24/docs"], - ["nodejs/docs/v0.1.25", "nodejs/release/v0.1.25/docs"], - ["nodejs/docs/v0.1.26", "nodejs/release/v0.1.26/docs"], - ["nodejs/docs/v0.1.27", "nodejs/release/v0.1.27/docs"], - ["nodejs/docs/v0.1.28", "nodejs/release/v0.1.28/docs"], - ["nodejs/docs/v0.1.29", "nodejs/release/v0.1.29/docs"], - ["nodejs/docs/v0.1.30", "nodejs/release/v0.1.30/docs"], - ["nodejs/docs/v0.1.31", "nodejs/release/v0.1.31/docs"], - ["nodejs/docs/v0.1.32", "nodejs/release/v0.1.32/docs"], - ["nodejs/docs/v0.1.33", "nodejs/release/v0.1.33/docs"], - ["nodejs/docs/v0.1.90", "nodejs/release/v0.1.90/docs"], - ["nodejs/docs/v0.1.91", "nodejs/release/v0.1.91/docs"], - ["nodejs/docs/v0.1.92", "nodejs/release/v0.1.92/docs"], - ["nodejs/docs/v0.1.93", "nodejs/release/v0.1.93/docs"], - ["nodejs/docs/v0.1.94", "nodejs/release/v0.1.94/docs"], - ["nodejs/docs/v0.1.95", "nodejs/release/v0.1.95/docs"], - ["nodejs/docs/v0.1.96", "nodejs/release/v0.1.96/docs"], - ["nodejs/docs/v0.1.97", "nodejs/release/v0.1.97/docs"], - ["nodejs/docs/v0.1.98", "nodejs/release/v0.1.98/docs"], - ["nodejs/docs/v0.1.99", "nodejs/release/v0.1.99/docs"], - ["nodejs/docs/v0.10.0", "nodejs/release/v0.10.0/docs"], - ["nodejs/docs/v0.10.1", "nodejs/release/v0.10.1/docs"], - ["nodejs/docs/v0.10.10", "nodejs/release/v0.10.10/docs"], - ["nodejs/docs/v0.10.11", "nodejs/release/v0.10.11/docs"], - ["nodejs/docs/v0.10.12", "nodejs/release/v0.10.12/docs"], - ["nodejs/docs/v0.10.13", "nodejs/release/v0.10.13/docs"], - ["nodejs/docs/v0.10.14", "nodejs/release/v0.10.14/docs"], - ["nodejs/docs/v0.10.15", "nodejs/release/v0.10.15/docs"], - [ - "nodejs/docs/v0.10.16-isaacs-manual", - "nodejs/release/v0.10.16-isaacs-manual/docs" - ], - ["nodejs/docs/v0.10.16", "nodejs/release/v0.10.16/docs"], - ["nodejs/docs/v0.10.17", "nodejs/release/v0.10.17/docs"], - ["nodejs/docs/v0.10.18", "nodejs/release/v0.10.18/docs"], - ["nodejs/docs/v0.10.19", "nodejs/release/v0.10.19/docs"], - ["nodejs/docs/v0.10.2", "nodejs/release/v0.10.2/docs"], - ["nodejs/docs/v0.10.20", "nodejs/release/v0.10.20/docs"], - ["nodejs/docs/v0.10.21", "nodejs/release/v0.10.21/docs"], - ["nodejs/docs/v0.10.22", "nodejs/release/v0.10.22/docs"], - ["nodejs/docs/v0.10.23", "nodejs/release/v0.10.23/docs"], - ["nodejs/docs/v0.10.24", "nodejs/release/v0.10.24/docs"], - ["nodejs/docs/v0.10.25", "nodejs/release/v0.10.25/docs"], - ["nodejs/docs/v0.10.26", "nodejs/release/v0.10.26/docs"], - ["nodejs/docs/v0.10.27", "nodejs/release/v0.10.27/docs"], - ["nodejs/docs/v0.10.28", "nodejs/release/v0.10.28/docs"], - ["nodejs/docs/v0.10.29", "nodejs/release/v0.10.29/docs"], - ["nodejs/docs/v0.10.3", "nodejs/release/v0.10.3/docs"], - ["nodejs/docs/v0.10.30", "nodejs/release/v0.10.30/docs"], - ["nodejs/docs/v0.10.31", "nodejs/release/v0.10.31/docs"], - ["nodejs/docs/v0.10.32", "nodejs/release/v0.10.32/docs"], - ["nodejs/docs/v0.10.33", "nodejs/release/v0.10.33/docs"], - ["nodejs/docs/v0.10.34", "nodejs/release/v0.10.34/docs"], - ["nodejs/docs/v0.10.35", "nodejs/release/v0.10.35/docs"], - ["nodejs/docs/v0.10.36", "nodejs/release/v0.10.36/docs"], - ["nodejs/docs/v0.10.37", "nodejs/release/v0.10.37/docs"], - ["nodejs/docs/v0.10.38", "nodejs/release/v0.10.38/docs"], - ["nodejs/docs/v0.10.39", "nodejs/release/v0.10.39/docs"], - ["nodejs/docs/v0.10.4", "nodejs/release/v0.10.4/docs"], - ["nodejs/docs/v0.10.40", "nodejs/release/v0.10.40/docs"], - ["nodejs/docs/v0.10.41", "nodejs/release/v0.10.41/docs"], - ["nodejs/docs/v0.10.42", "nodejs/release/v0.10.42/docs"], - ["nodejs/docs/v0.10.43", "nodejs/release/v0.10.43/docs"], - ["nodejs/docs/v0.10.44", "nodejs/release/v0.10.44/docs"], - ["nodejs/docs/v0.10.45", "nodejs/release/v0.10.45/docs"], - ["nodejs/docs/v0.10.46", "nodejs/release/v0.10.46/docs"], - ["nodejs/docs/v0.10.47", "nodejs/release/v0.10.47/docs"], - ["nodejs/docs/v0.10.48", "nodejs/release/v0.10.48/docs"], - ["nodejs/docs/v0.10.5", "nodejs/release/v0.10.5/docs"], - ["nodejs/docs/v0.10.6", "nodejs/release/v0.10.6/docs"], - ["nodejs/docs/v0.10.7", "nodejs/release/v0.10.7/docs"], - ["nodejs/docs/v0.10.8", "nodejs/release/v0.10.8/docs"], - ["nodejs/docs/v0.10.9", "nodejs/release/v0.10.9/docs"], - ["nodejs/docs/v0.11.0", "nodejs/release/v0.11.0/docs"], - ["nodejs/docs/v0.11.1", "nodejs/release/v0.11.1/docs"], - ["nodejs/docs/v0.11.10", "nodejs/release/v0.11.10/docs"], - ["nodejs/docs/v0.11.11", "nodejs/release/v0.11.11/docs"], - ["nodejs/docs/v0.11.12", "nodejs/release/v0.11.12/docs"], - ["nodejs/docs/v0.11.13", "nodejs/release/v0.11.13/docs"], - ["nodejs/docs/v0.11.14", "nodejs/release/v0.11.14/docs"], - ["nodejs/docs/v0.11.15", "nodejs/release/v0.11.15/docs"], - ["nodejs/docs/v0.11.16", "nodejs/release/v0.11.16/docs"], - ["nodejs/docs/v0.11.2", "nodejs/release/v0.11.2/docs"], - ["nodejs/docs/v0.11.3", "nodejs/release/v0.11.3/docs"], - ["nodejs/docs/v0.11.4", "nodejs/release/v0.11.4/docs"], - ["nodejs/docs/v0.11.5", "nodejs/release/v0.11.5/docs"], - ["nodejs/docs/v0.11.6", "nodejs/release/v0.11.6/docs"], - ["nodejs/docs/v0.11.7", "nodejs/release/v0.11.7/docs"], - ["nodejs/docs/v0.11.8", "nodejs/release/v0.11.8/docs"], - ["nodejs/docs/v0.11.9", "nodejs/release/v0.11.9/docs"], - ["nodejs/docs/v0.12.0", "nodejs/release/v0.12.0/docs"], - ["nodejs/docs/v0.12.1", "nodejs/release/v0.12.1/docs"], - ["nodejs/docs/v0.12.10", "nodejs/release/v0.12.10/docs"], - ["nodejs/docs/v0.12.11", "nodejs/release/v0.12.11/docs"], - ["nodejs/docs/v0.12.12", "nodejs/release/v0.12.12/docs"], - ["nodejs/docs/v0.12.13", "nodejs/release/v0.12.13/docs"], - ["nodejs/docs/v0.12.14", "nodejs/release/v0.12.14/docs"], - ["nodejs/docs/v0.12.15", "nodejs/release/v0.12.15/docs"], - ["nodejs/docs/v0.12.16", "nodejs/release/v0.12.16/docs"], - ["nodejs/docs/v0.12.17", "nodejs/release/v0.12.17/docs"], - ["nodejs/docs/v0.12.18", "nodejs/release/v0.12.18/docs"], - ["nodejs/docs/v0.12.2", "nodejs/release/v0.12.2/docs"], - ["nodejs/docs/v0.12.3", "nodejs/release/v0.12.3/docs"], - ["nodejs/docs/v0.12.4", "nodejs/release/v0.12.4/docs"], - ["nodejs/docs/v0.12.5", "nodejs/release/v0.12.5/docs"], - ["nodejs/docs/v0.12.6", "nodejs/release/v0.12.6/docs"], - ["nodejs/docs/v0.12.7", "nodejs/release/v0.12.7/docs"], - ["nodejs/docs/v0.12.8", "nodejs/release/v0.12.8/docs"], - ["nodejs/docs/v0.12.9", "nodejs/release/v0.12.9/docs"], - ["nodejs/docs/v0.2.0", "nodejs/release/v0.2.0/docs"], - ["nodejs/docs/v0.2.1", "nodejs/release/v0.2.1/docs"], - ["nodejs/docs/v0.2.2", "nodejs/release/v0.2.2/docs"], - ["nodejs/docs/v0.2.3", "nodejs/release/v0.2.3/docs"], - ["nodejs/docs/v0.2.4", "nodejs/release/v0.2.4/docs"], - ["nodejs/docs/v0.2.5", "nodejs/release/v0.2.5/docs"], - ["nodejs/docs/v0.2.6", "nodejs/release/v0.2.6/docs"], - ["nodejs/docs/v0.3.0", "nodejs/release/v0.3.0/docs"], - ["nodejs/docs/v0.3.1", "nodejs/release/v0.3.1/docs"], - ["nodejs/docs/v0.3.2", "nodejs/release/v0.3.2/docs"], - ["nodejs/docs/v0.3.3", "nodejs/release/v0.3.3/docs"], - ["nodejs/docs/v0.3.4", "nodejs/release/v0.3.4/docs"], - ["nodejs/docs/v0.3.5", "nodejs/release/v0.3.5/docs"], - ["nodejs/docs/v0.3.6", "nodejs/release/v0.3.6/docs"], - ["nodejs/docs/v0.3.7", "nodejs/release/v0.3.7/docs"], - ["nodejs/docs/v0.3.8", "nodejs/release/v0.3.8/docs"], - ["nodejs/docs/v0.4.0", "nodejs/release/v0.4.0/docs"], - ["nodejs/docs/v0.4.1", "nodejs/release/v0.4.1/docs"], - ["nodejs/docs/v0.4.10", "nodejs/release/v0.4.10/docs"], - ["nodejs/docs/v0.4.11", "nodejs/release/v0.4.11/docs"], - ["nodejs/docs/v0.4.12", "nodejs/release/v0.4.12/docs"], - ["nodejs/docs/v0.4.2", "nodejs/release/v0.4.2/docs"], - ["nodejs/docs/v0.4.3", "nodejs/release/v0.4.3/docs"], - ["nodejs/docs/v0.4.4", "nodejs/release/v0.4.4/docs"], - ["nodejs/docs/v0.4.5", "nodejs/release/v0.4.5/docs"], - ["nodejs/docs/v0.4.6", "nodejs/release/v0.4.6/docs"], - ["nodejs/docs/v0.4.7", "nodejs/release/v0.4.7/docs"], - ["nodejs/docs/v0.4.8", "nodejs/release/v0.4.8/docs"], - ["nodejs/docs/v0.4.9", "nodejs/release/v0.4.9/docs"], - ["nodejs/docs/v0.5.0", "nodejs/release/v0.5.0/docs"], - ["nodejs/docs/v0.5.1", "nodejs/release/v0.5.1/docs"], - ["nodejs/docs/v0.5.10", "nodejs/release/v0.5.10/docs"], - ["nodejs/docs/v0.5.2", "nodejs/release/v0.5.2/docs"], - ["nodejs/docs/v0.5.3", "nodejs/release/v0.5.3/docs"], - ["nodejs/docs/v0.5.4", "nodejs/release/v0.5.4/docs"], - ["nodejs/docs/v0.5.5", "nodejs/release/v0.5.5/docs"], - ["nodejs/docs/v0.5.6", "nodejs/release/v0.5.6/docs"], - ["nodejs/docs/v0.5.7", "nodejs/release/v0.5.7/docs"], - ["nodejs/docs/v0.5.8", "nodejs/release/v0.5.8/docs"], - ["nodejs/docs/v0.5.9", "nodejs/release/v0.5.9/docs"], - ["nodejs/docs/v0.6.0", "nodejs/release/v0.6.0/docs"], - ["nodejs/docs/v0.6.1", "nodejs/release/v0.6.1/docs"], - ["nodejs/docs/v0.6.10", "nodejs/release/v0.6.10/docs"], - ["nodejs/docs/v0.6.11", "nodejs/release/v0.6.11/docs"], - ["nodejs/docs/v0.6.12", "nodejs/release/v0.6.12/docs"], - ["nodejs/docs/v0.6.13", "nodejs/release/v0.6.13/docs"], - ["nodejs/docs/v0.6.14", "nodejs/release/v0.6.14/docs"], - ["nodejs/docs/v0.6.15", "nodejs/release/v0.6.15/docs"], - ["nodejs/docs/v0.6.16", "nodejs/release/v0.6.16/docs"], - ["nodejs/docs/v0.6.17", "nodejs/release/v0.6.17/docs"], - ["nodejs/docs/v0.6.18", "nodejs/release/v0.6.18/docs"], - ["nodejs/docs/v0.6.19", "nodejs/release/v0.6.19/docs"], - ["nodejs/docs/v0.6.2", "nodejs/release/v0.6.2/docs"], - ["nodejs/docs/v0.6.20", "nodejs/release/v0.6.20/docs"], - ["nodejs/docs/v0.6.21", "nodejs/release/v0.6.21/docs"], - ["nodejs/docs/v0.6.3", "nodejs/release/v0.6.3/docs"], - ["nodejs/docs/v0.6.4", "nodejs/release/v0.6.4/docs"], - ["nodejs/docs/v0.6.5", "nodejs/release/v0.6.5/docs"], - ["nodejs/docs/v0.6.6", "nodejs/release/v0.6.6/docs"], - ["nodejs/docs/v0.6.7", "nodejs/release/v0.6.7/docs"], - ["nodejs/docs/v0.6.8", "nodejs/release/v0.6.8/docs"], - ["nodejs/docs/v0.6.9", "nodejs/release/v0.6.9/docs"], - ["nodejs/docs/v0.7.0", "nodejs/release/v0.7.0/docs"], - ["nodejs/docs/v0.7.1", "nodejs/release/v0.7.1/docs"], - ["nodejs/docs/v0.7.10", "nodejs/release/v0.7.10/docs"], - ["nodejs/docs/v0.7.11", "nodejs/release/v0.7.11/docs"], - ["nodejs/docs/v0.7.12", "nodejs/release/v0.7.12/docs"], - ["nodejs/docs/v0.7.2", "nodejs/release/v0.7.2/docs"], - ["nodejs/docs/v0.7.3", "nodejs/release/v0.7.3/docs"], - ["nodejs/docs/v0.7.4", "nodejs/release/v0.7.4/docs"], - ["nodejs/docs/v0.7.5", "nodejs/release/v0.7.5/docs"], - ["nodejs/docs/v0.7.6", "nodejs/release/v0.7.6/docs"], - ["nodejs/docs/v0.7.7", "nodejs/release/v0.7.7/docs"], - ["nodejs/docs/v0.7.8", "nodejs/release/v0.7.8/docs"], - ["nodejs/docs/v0.7.9", "nodejs/release/v0.7.9/docs"], - ["nodejs/docs/v0.8.0", "nodejs/release/v0.8.0/docs"], - ["nodejs/docs/v0.8.1", "nodejs/release/v0.8.1/docs"], - ["nodejs/docs/v0.8.10", "nodejs/release/v0.8.10/docs"], - ["nodejs/docs/v0.8.11", "nodejs/release/v0.8.11/docs"], - ["nodejs/docs/v0.8.12", "nodejs/release/v0.8.12/docs"], - ["nodejs/docs/v0.8.13", "nodejs/release/v0.8.13/docs"], - ["nodejs/docs/v0.8.14", "nodejs/release/v0.8.14/docs"], - ["nodejs/docs/v0.8.15", "nodejs/release/v0.8.15/docs"], - ["nodejs/docs/v0.8.16", "nodejs/release/v0.8.16/docs"], - ["nodejs/docs/v0.8.17", "nodejs/release/v0.8.17/docs"], - ["nodejs/docs/v0.8.18", "nodejs/release/v0.8.18/docs"], - ["nodejs/docs/v0.8.19", "nodejs/release/v0.8.19/docs"], - ["nodejs/docs/v0.8.2", "nodejs/release/v0.8.2/docs"], - ["nodejs/docs/v0.8.20", "nodejs/release/v0.8.20/docs"], - ["nodejs/docs/v0.8.21", "nodejs/release/v0.8.21/docs"], - ["nodejs/docs/v0.8.22", "nodejs/release/v0.8.22/docs"], - ["nodejs/docs/v0.8.23", "nodejs/release/v0.8.23/docs"], - ["nodejs/docs/v0.8.24", "nodejs/release/v0.8.24/docs"], - ["nodejs/docs/v0.8.25", "nodejs/release/v0.8.25/docs"], - ["nodejs/docs/v0.8.26", "nodejs/release/v0.8.26/docs"], - ["nodejs/docs/v0.8.27", "nodejs/release/v0.8.27/docs"], - ["nodejs/docs/v0.8.28", "nodejs/release/v0.8.28/docs"], - ["nodejs/docs/v0.8.3", "nodejs/release/v0.8.3/docs"], - ["nodejs/docs/v0.8.4", "nodejs/release/v0.8.4/docs"], - ["nodejs/docs/v0.8.5", "nodejs/release/v0.8.5/docs"], - ["nodejs/docs/v0.8.6", "nodejs/release/v0.8.6/docs"], - ["nodejs/docs/v0.8.7", "nodejs/release/v0.8.7/docs"], - ["nodejs/docs/v0.8.8", "nodejs/release/v0.8.8/docs"], - ["nodejs/docs/v0.8.9", "nodejs/release/v0.8.9/docs"], - ["nodejs/docs/v0.9.0", "nodejs/release/v0.9.0/docs"], - ["nodejs/docs/v0.9.1", "nodejs/release/v0.9.1/docs"], - ["nodejs/docs/v0.9.10", "nodejs/release/v0.9.10/docs"], - ["nodejs/docs/v0.9.11", "nodejs/release/v0.9.11/docs"], - ["nodejs/docs/v0.9.12", "nodejs/release/v0.9.12/docs"], - ["nodejs/docs/v0.9.2", "nodejs/release/v0.9.2/docs"], - ["nodejs/docs/v0.9.3", "nodejs/release/v0.9.3/docs"], - ["nodejs/docs/v0.9.4", "nodejs/release/v0.9.4/docs"], - ["nodejs/docs/v0.9.5", "nodejs/release/v0.9.5/docs"], - ["nodejs/docs/v0.9.6", "nodejs/release/v0.9.6/docs"], - ["nodejs/docs/v0.9.7", "nodejs/release/v0.9.7/docs"], - ["nodejs/docs/v0.9.8", "nodejs/release/v0.9.8/docs"], - ["nodejs/docs/v0.9.9", "nodejs/release/v0.9.9/docs"], - ["nodejs/docs/v10.0.0", "nodejs/release/v10.0.0/docs"], - ["nodejs/docs/v10.1.0", "nodejs/release/v10.1.0/docs"], - ["nodejs/docs/v10.10.0", "nodejs/release/v10.10.0/docs"], - ["nodejs/docs/v10.11.0", "nodejs/release/v10.11.0/docs"], - ["nodejs/docs/v10.12.0", "nodejs/release/v10.12.0/docs"], - ["nodejs/docs/v10.13.0", "nodejs/release/v10.13.0/docs"], - ["nodejs/docs/v10.14.0", "nodejs/release/v10.14.0/docs"], - ["nodejs/docs/v10.14.1", "nodejs/release/v10.14.1/docs"], - ["nodejs/docs/v10.14.2", "nodejs/release/v10.14.2/docs"], - ["nodejs/docs/v10.15.0", "nodejs/release/v10.15.0/docs"], - ["nodejs/docs/v10.15.1", "nodejs/release/v10.15.1/docs"], - ["nodejs/docs/v10.15.2", "nodejs/release/v10.15.2/docs"], - ["nodejs/docs/v10.15.3", "nodejs/release/v10.15.3/docs"], - ["nodejs/docs/v10.16.0", "nodejs/release/v10.16.0/docs"], - ["nodejs/docs/v10.16.1", "nodejs/release/v10.16.1/docs"], - ["nodejs/docs/v10.16.2", "nodejs/release/v10.16.2/docs"], - ["nodejs/docs/v10.16.3", "nodejs/release/v10.16.3/docs"], - ["nodejs/docs/v10.17.0", "nodejs/release/v10.17.0/docs"], - ["nodejs/docs/v10.18.0", "nodejs/release/v10.18.0/docs"], - ["nodejs/docs/v10.18.1", "nodejs/release/v10.18.1/docs"], - ["nodejs/docs/v10.19.0", "nodejs/release/v10.19.0/docs"], - ["nodejs/docs/v10.2.0", "nodejs/release/v10.2.0/docs"], - ["nodejs/docs/v10.2.1", "nodejs/release/v10.2.1/docs"], - ["nodejs/docs/v10.20.0", "nodejs/release/v10.20.0/docs"], - ["nodejs/docs/v10.20.1", "nodejs/release/v10.20.1/docs"], - ["nodejs/docs/v10.21.0", "nodejs/release/v10.21.0/docs"], - ["nodejs/docs/v10.22.0", "nodejs/release/v10.22.0/docs"], - ["nodejs/docs/v10.22.1", "nodejs/release/v10.22.1/docs"], - ["nodejs/docs/v10.23.0", "nodejs/release/v10.23.0/docs"], - ["nodejs/docs/v10.23.1", "nodejs/release/v10.23.1/docs"], - ["nodejs/docs/v10.23.2", "nodejs/release/v10.23.2/docs"], - ["nodejs/docs/v10.23.3", "nodejs/release/v10.23.3/docs"], - ["nodejs/docs/v10.24.0", "nodejs/release/v10.24.0/docs"], - ["nodejs/docs/v10.24.1", "nodejs/release/v10.24.1/docs"], - ["nodejs/docs/v10.3.0", "nodejs/release/v10.3.0/docs"], - ["nodejs/docs/v10.4.0", "nodejs/release/v10.4.0/docs"], - ["nodejs/docs/v10.4.1", "nodejs/release/v10.4.1/docs"], - ["nodejs/docs/v10.5.0", "nodejs/release/v10.5.0/docs"], - ["nodejs/docs/v10.6.0", "nodejs/release/v10.6.0/docs"], - ["nodejs/docs/v10.7.0", "nodejs/release/v10.7.0/docs"], - ["nodejs/docs/v10.8.0", "nodejs/release/v10.8.0/docs"], - ["nodejs/docs/v10.9.0", "nodejs/release/v10.9.0/docs"], - ["nodejs/docs/v11.0.0", "nodejs/release/v11.0.0/docs"], - ["nodejs/docs/v11.1.0", "nodejs/release/v11.1.0/docs"], - ["nodejs/docs/v11.10.0", "nodejs/release/v11.10.0/docs"], - ["nodejs/docs/v11.10.1", "nodejs/release/v11.10.1/docs"], - ["nodejs/docs/v11.11.0", "nodejs/release/v11.11.0/docs"], - ["nodejs/docs/v11.12.0", "nodejs/release/v11.12.0/docs"], - ["nodejs/docs/v11.13.0", "nodejs/release/v11.13.0/docs"], - ["nodejs/docs/v11.14.0", "nodejs/release/v11.14.0/docs"], - ["nodejs/docs/v11.15.0", "nodejs/release/v11.15.0/docs"], - ["nodejs/docs/v11.2.0", "nodejs/release/v11.2.0/docs"], - ["nodejs/docs/v11.3.0", "nodejs/release/v11.3.0/docs"], - ["nodejs/docs/v11.4.0", "nodejs/release/v11.4.0/docs"], - ["nodejs/docs/v11.5.0", "nodejs/release/v11.5.0/docs"], - ["nodejs/docs/v11.6.0", "nodejs/release/v11.6.0/docs"], - ["nodejs/docs/v11.7.0", "nodejs/release/v11.7.0/docs"], - ["nodejs/docs/v11.8.0", "nodejs/release/v11.8.0/docs"], - ["nodejs/docs/v11.9.0", "nodejs/release/v11.9.0/docs"], - ["nodejs/docs/v12.0.0", "nodejs/release/v12.0.0/docs"], - ["nodejs/docs/v12.1.0", "nodejs/release/v12.1.0/docs"], - ["nodejs/docs/v12.10.0", "nodejs/release/v12.10.0/docs"], - ["nodejs/docs/v12.11.0", "nodejs/release/v12.11.0/docs"], - ["nodejs/docs/v12.11.1", "nodejs/release/v12.11.1/docs"], - ["nodejs/docs/v12.12.0", "nodejs/release/v12.12.0/docs"], - ["nodejs/docs/v12.13.0", "nodejs/release/v12.13.0/docs"], - ["nodejs/docs/v12.13.1", "nodejs/release/v12.13.1/docs"], - ["nodejs/docs/v12.14.0", "nodejs/release/v12.14.0/docs"], - ["nodejs/docs/v12.14.1", "nodejs/release/v12.14.1/docs"], - ["nodejs/docs/v12.15.0", "nodejs/release/v12.15.0/docs"], - ["nodejs/docs/v12.16.0", "nodejs/release/v12.16.0/docs"], - ["nodejs/docs/v12.16.1", "nodejs/release/v12.16.1/docs"], - ["nodejs/docs/v12.16.2", "nodejs/release/v12.16.2/docs"], - ["nodejs/docs/v12.16.3", "nodejs/release/v12.16.3/docs"], - ["nodejs/docs/v12.17.0", "nodejs/release/v12.17.0/docs"], - ["nodejs/docs/v12.18.0", "nodejs/release/v12.18.0/docs"], - ["nodejs/docs/v12.18.1", "nodejs/release/v12.18.1/docs"], - ["nodejs/docs/v12.18.2", "nodejs/release/v12.18.2/docs"], - ["nodejs/docs/v12.18.3", "nodejs/release/v12.18.3/docs"], - ["nodejs/docs/v12.18.4", "nodejs/release/v12.18.4/docs"], - ["nodejs/docs/v12.19.0", "nodejs/release/v12.19.0/docs"], - ["nodejs/docs/v12.19.1", "nodejs/release/v12.19.1/docs"], - ["nodejs/docs/v12.2.0", "nodejs/release/v12.2.0/docs"], - ["nodejs/docs/v12.20.0", "nodejs/release/v12.20.0/docs"], - ["nodejs/docs/v12.20.1", "nodejs/release/v12.20.1/docs"], - ["nodejs/docs/v12.20.2", "nodejs/release/v12.20.2/docs"], - ["nodejs/docs/v12.21.0", "nodejs/release/v12.21.0/docs"], - ["nodejs/docs/v12.22.0", "nodejs/release/v12.22.0/docs"], - ["nodejs/docs/v12.22.1", "nodejs/release/v12.22.1/docs"], - ["nodejs/docs/v12.22.10", "nodejs/release/v12.22.10/docs"], - ["nodejs/docs/v12.22.11", "nodejs/release/v12.22.11/docs"], - ["nodejs/docs/v12.22.12", "nodejs/release/v12.22.12/docs"], - ["nodejs/docs/v12.22.2", "nodejs/release/v12.22.2/docs"], - ["nodejs/docs/v12.22.3", "nodejs/release/v12.22.3/docs"], - ["nodejs/docs/v12.22.4", "nodejs/release/v12.22.4/docs"], - ["nodejs/docs/v12.22.5", "nodejs/release/v12.22.5/docs"], - ["nodejs/docs/v12.22.6", "nodejs/release/v12.22.6/docs"], - ["nodejs/docs/v12.22.7", "nodejs/release/v12.22.7/docs"], - ["nodejs/docs/v12.22.8", "nodejs/release/v12.22.8/docs"], - ["nodejs/docs/v12.22.9", "nodejs/release/v12.22.9/docs"], - ["nodejs/docs/v12.3.0", "nodejs/release/v12.3.0/docs"], - ["nodejs/docs/v12.3.1", "nodejs/release/v12.3.1/docs"], - ["nodejs/docs/v12.4.0", "nodejs/release/v12.4.0/docs"], - ["nodejs/docs/v12.5.0", "nodejs/release/v12.5.0/docs"], - ["nodejs/docs/v12.6.0", "nodejs/release/v12.6.0/docs"], - ["nodejs/docs/v12.7.0", "nodejs/release/v12.7.0/docs"], - ["nodejs/docs/v12.8.0", "nodejs/release/v12.8.0/docs"], - ["nodejs/docs/v12.8.1", "nodejs/release/v12.8.1/docs"], - ["nodejs/docs/v12.9.0", "nodejs/release/v12.9.0/docs"], - ["nodejs/docs/v12.9.1", "nodejs/release/v12.9.1/docs"], - ["nodejs/docs/v13.0.0", "nodejs/release/v13.0.0/docs"], - ["nodejs/docs/v13.0.1", "nodejs/release/v13.0.1/docs"], - ["nodejs/docs/v13.1.0", "nodejs/release/v13.1.0/docs"], - ["nodejs/docs/v13.10.0", "nodejs/release/v13.10.0/docs"], - ["nodejs/docs/v13.10.1", "nodejs/release/v13.10.1/docs"], - ["nodejs/docs/v13.11.0", "nodejs/release/v13.11.0/docs"], - ["nodejs/docs/v13.12.0", "nodejs/release/v13.12.0/docs"], - ["nodejs/docs/v13.13.0", "nodejs/release/v13.13.0/docs"], - ["nodejs/docs/v13.14.0", "nodejs/release/v13.14.0/docs"], - ["nodejs/docs/v13.2.0", "nodejs/release/v13.2.0/docs"], - ["nodejs/docs/v13.3.0", "nodejs/release/v13.3.0/docs"], - ["nodejs/docs/v13.4.0", "nodejs/release/v13.4.0/docs"], - ["nodejs/docs/v13.5.0", "nodejs/release/v13.5.0/docs"], - ["nodejs/docs/v13.6.0", "nodejs/release/v13.6.0/docs"], - ["nodejs/docs/v13.7.0", "nodejs/release/v13.7.0/docs"], - ["nodejs/docs/v13.8.0", "nodejs/release/v13.8.0/docs"], - ["nodejs/docs/v13.9.0", "nodejs/release/v13.9.0/docs"], - ["nodejs/docs/v14.0.0", "nodejs/release/v14.0.0/docs"], - ["nodejs/docs/v14.1.0", "nodejs/release/v14.1.0/docs"], - ["nodejs/docs/v14.10.0", "nodejs/release/v14.10.0/docs"], - ["nodejs/docs/v14.10.1", "nodejs/release/v14.10.1/docs"], - ["nodejs/docs/v14.11.0", "nodejs/release/v14.11.0/docs"], - ["nodejs/docs/v14.12.0", "nodejs/release/v14.12.0/docs"], - ["nodejs/docs/v14.13.0", "nodejs/release/v14.13.0/docs"], - ["nodejs/docs/v14.13.1", "nodejs/release/v14.13.1/docs"], - ["nodejs/docs/v14.14.0", "nodejs/release/v14.14.0/docs"], - ["nodejs/docs/v14.15.0", "nodejs/release/v14.15.0/docs"], - ["nodejs/docs/v14.15.1", "nodejs/release/v14.15.1/docs"], - ["nodejs/docs/v14.15.2", "nodejs/release/v14.15.2/docs"], - ["nodejs/docs/v14.15.3", "nodejs/release/v14.15.3/docs"], - ["nodejs/docs/v14.15.4", "nodejs/release/v14.15.4/docs"], - ["nodejs/docs/v14.15.5", "nodejs/release/v14.15.5/docs"], - ["nodejs/docs/v14.16.0", "nodejs/release/v14.16.0/docs"], - ["nodejs/docs/v14.16.1", "nodejs/release/v14.16.1/docs"], - ["nodejs/docs/v14.17.0", "nodejs/release/v14.17.0/docs"], - ["nodejs/docs/v14.17.1", "nodejs/release/v14.17.1/docs"], - ["nodejs/docs/v14.17.2", "nodejs/release/v14.17.2/docs"], - ["nodejs/docs/v14.17.3", "nodejs/release/v14.17.3/docs"], - ["nodejs/docs/v14.17.4", "nodejs/release/v14.17.4/docs"], - ["nodejs/docs/v14.17.5", "nodejs/release/v14.17.5/docs"], - ["nodejs/docs/v14.17.6", "nodejs/release/v14.17.6/docs"], - ["nodejs/docs/v14.18.0", "nodejs/release/v14.18.0/docs"], - ["nodejs/docs/v14.18.1", "nodejs/release/v14.18.1/docs"], - ["nodejs/docs/v14.18.2", "nodejs/release/v14.18.2/docs"], - ["nodejs/docs/v14.18.3", "nodejs/release/v14.18.3/docs"], - ["nodejs/docs/v14.19.0", "nodejs/release/v14.19.0/docs"], - ["nodejs/docs/v14.19.1", "nodejs/release/v14.19.1/docs"], - ["nodejs/docs/v14.19.2", "nodejs/release/v14.19.2/docs"], - ["nodejs/docs/v14.19.3", "nodejs/release/v14.19.3/docs"], - ["nodejs/docs/v14.2.0", "nodejs/release/v14.2.0/docs"], - ["nodejs/docs/v14.20.0", "nodejs/release/v14.20.0/docs"], - ["nodejs/docs/v14.20.1", "nodejs/release/v14.20.1/docs"], - ["nodejs/docs/v14.21.0", "nodejs/release/v14.21.0/docs"], - ["nodejs/docs/v14.21.1", "nodejs/release/v14.21.1/docs"], - ["nodejs/docs/v14.21.2", "nodejs/release/v14.21.2/docs"], - ["nodejs/docs/v14.21.3", "nodejs/release/v14.21.3/docs"], - ["nodejs/docs/v14.3.0", "nodejs/release/v14.3.0/docs"], - ["nodejs/docs/v14.4.0", "nodejs/release/v14.4.0/docs"], - ["nodejs/docs/v14.5.0", "nodejs/release/v14.5.0/docs"], - ["nodejs/docs/v14.6.0", "nodejs/release/v14.6.0/docs"], - ["nodejs/docs/v14.7.0", "nodejs/release/v14.7.0/docs"], - ["nodejs/docs/v14.8.0", "nodejs/release/v14.8.0/docs"], - ["nodejs/docs/v14.9.0", "nodejs/release/v14.9.0/docs"], - ["nodejs/docs/v15.0.0", "nodejs/release/v15.0.0/docs"], - ["nodejs/docs/v15.0.1", "nodejs/release/v15.0.1/docs"], - ["nodejs/docs/v15.1.0", "nodejs/release/v15.1.0/docs"], - ["nodejs/docs/v15.10.0", "nodejs/release/v15.10.0/docs"], - ["nodejs/docs/v15.11.0", "nodejs/release/v15.11.0/docs"], - ["nodejs/docs/v15.12.0", "nodejs/release/v15.12.0/docs"], - ["nodejs/docs/v15.13.0", "nodejs/release/v15.13.0/docs"], - ["nodejs/docs/v15.14.0", "nodejs/release/v15.14.0/docs"], - ["nodejs/docs/v15.2.0", "nodejs/release/v15.2.0/docs"], - ["nodejs/docs/v15.2.1", "nodejs/release/v15.2.1/docs"], - ["nodejs/docs/v15.3.0", "nodejs/release/v15.3.0/docs"], - ["nodejs/docs/v15.4.0", "nodejs/release/v15.4.0/docs"], - ["nodejs/docs/v15.5.0", "nodejs/release/v15.5.0/docs"], - ["nodejs/docs/v15.5.1", "nodejs/release/v15.5.1/docs"], - ["nodejs/docs/v15.6.0", "nodejs/release/v15.6.0/docs"], - ["nodejs/docs/v15.7.0", "nodejs/release/v15.7.0/docs"], - ["nodejs/docs/v15.8.0", "nodejs/release/v15.8.0/docs"], - ["nodejs/docs/v15.9.0", "nodejs/release/v15.9.0/docs"], - ["nodejs/docs/v16.0.0", "nodejs/release/v16.0.0/docs"], - ["nodejs/docs/v16.1.0", "nodejs/release/v16.1.0/docs"], - ["nodejs/docs/v16.10.0", "nodejs/release/v16.10.0/docs"], - ["nodejs/docs/v16.11.0", "nodejs/release/v16.11.0/docs"], - ["nodejs/docs/v16.11.1", "nodejs/release/v16.11.1/docs"], - ["nodejs/docs/v16.12.0", "nodejs/release/v16.12.0/docs"], - ["nodejs/docs/v16.13.0", "nodejs/release/v16.13.0/docs"], - ["nodejs/docs/v16.13.1", "nodejs/release/v16.13.1/docs"], - ["nodejs/docs/v16.13.2", "nodejs/release/v16.13.2/docs"], - ["nodejs/docs/v16.14.0", "nodejs/release/v16.14.0/docs"], - ["nodejs/docs/v16.14.1", "nodejs/release/v16.14.1/docs"], - ["nodejs/docs/v16.14.2", "nodejs/release/v16.14.2/docs"], - ["nodejs/docs/v16.15.0", "nodejs/release/v16.15.0/docs"], - ["nodejs/docs/v16.15.1", "nodejs/release/v16.15.1/docs"], - ["nodejs/docs/v16.16.0", "nodejs/release/v16.16.0/docs"], - ["nodejs/docs/v16.17.0", "nodejs/release/v16.17.0/docs"], - ["nodejs/docs/v16.17.1", "nodejs/release/v16.17.1/docs"], - ["nodejs/docs/v16.18.0", "nodejs/release/v16.18.0/docs"], - ["nodejs/docs/v16.18.1", "nodejs/release/v16.18.1/docs"], - ["nodejs/docs/v16.19.0", "nodejs/release/v16.19.0/docs"], - ["nodejs/docs/v16.19.1", "nodejs/release/v16.19.1/docs"], - ["nodejs/docs/v16.2.0", "nodejs/release/v16.2.0/docs"], - ["nodejs/docs/v16.20.0", "nodejs/release/v16.20.0/docs"], - ["nodejs/docs/v16.20.1", "nodejs/release/v16.20.1/docs"], - ["nodejs/docs/v16.20.2", "nodejs/release/v16.20.2/docs"], - ["nodejs/docs/v16.3.0", "nodejs/release/v16.3.0/docs"], - ["nodejs/docs/v16.4.0", "nodejs/release/v16.4.0/docs"], - ["nodejs/docs/v16.4.1", "nodejs/release/v16.4.1/docs"], - ["nodejs/docs/v16.4.2", "nodejs/release/v16.4.2/docs"], - ["nodejs/docs/v16.5.0", "nodejs/release/v16.5.0/docs"], - ["nodejs/docs/v16.6.0", "nodejs/release/v16.6.0/docs"], - ["nodejs/docs/v16.6.1", "nodejs/release/v16.6.1/docs"], - ["nodejs/docs/v16.6.2", "nodejs/release/v16.6.2/docs"], - ["nodejs/docs/v16.7.0", "nodejs/release/v16.7.0/docs"], - ["nodejs/docs/v16.8.0", "nodejs/release/v16.8.0/docs"], - ["nodejs/docs/v16.9.0", "nodejs/release/v16.9.0/docs"], - ["nodejs/docs/v16.9.1", "nodejs/release/v16.9.1/docs"], - ["nodejs/docs/v17.0.0", "nodejs/release/v17.0.0/docs"], - ["nodejs/docs/v17.0.1", "nodejs/release/v17.0.1/docs"], - ["nodejs/docs/v17.1.0", "nodejs/release/v17.1.0/docs"], - ["nodejs/docs/v17.2.0", "nodejs/release/v17.2.0/docs"], - ["nodejs/docs/v17.3.0", "nodejs/release/v17.3.0/docs"], - ["nodejs/docs/v17.3.1", "nodejs/release/v17.3.1/docs"], - ["nodejs/docs/v17.4.0", "nodejs/release/v17.4.0/docs"], - ["nodejs/docs/v17.5.0", "nodejs/release/v17.5.0/docs"], - ["nodejs/docs/v17.6.0", "nodejs/release/v17.6.0/docs"], - ["nodejs/docs/v17.7.0", "nodejs/release/v17.7.0/docs"], - ["nodejs/docs/v17.7.1", "nodejs/release/v17.7.1/docs"], - ["nodejs/docs/v17.7.2", "nodejs/release/v17.7.2/docs"], - ["nodejs/docs/v17.8.0", "nodejs/release/v17.8.0/docs"], - ["nodejs/docs/v17.9.0", "nodejs/release/v17.9.0/docs"], - ["nodejs/docs/v17.9.1", "nodejs/release/v17.9.1/docs"], - ["nodejs/docs/v18.0.0", "nodejs/release/v18.0.0/docs"], - ["nodejs/docs/v18.1.0", "nodejs/release/v18.1.0/docs"], - ["nodejs/docs/v18.10.0", "nodejs/release/v18.10.0/docs"], - ["nodejs/docs/v18.11.0", "nodejs/release/v18.11.0/docs"], - ["nodejs/docs/v18.12.0", "nodejs/release/v18.12.0/docs"], - ["nodejs/docs/v18.12.1", "nodejs/release/v18.12.1/docs"], - ["nodejs/docs/v18.13.0", "nodejs/release/v18.13.0/docs"], - ["nodejs/docs/v18.14.0", "nodejs/release/v18.14.0/docs"], - ["nodejs/docs/v18.14.1", "nodejs/release/v18.14.1/docs"], - ["nodejs/docs/v18.14.2", "nodejs/release/v18.14.2/docs"], - ["nodejs/docs/v18.15.0", "nodejs/release/v18.15.0/docs"], - ["nodejs/docs/v18.16.0", "nodejs/release/v18.16.0/docs"], - ["nodejs/docs/v18.16.1", "nodejs/release/v18.16.1/docs"], - ["nodejs/docs/v18.17.0", "nodejs/release/v18.17.0/docs"], - ["nodejs/docs/v18.17.1", "nodejs/release/v18.17.1/docs"], - ["nodejs/docs/v18.18.0", "nodejs/release/v18.18.0/docs"], - ["nodejs/docs/v18.18.1", "nodejs/release/v18.18.1/docs"], - ["nodejs/docs/v18.18.2", "nodejs/release/v18.18.2/docs"], - ["nodejs/docs/v18.19.0", "nodejs/release/v18.19.0/docs"], - ["nodejs/docs/v18.2.0", "nodejs/release/v18.2.0/docs"], - ["nodejs/docs/v18.3.0", "nodejs/release/v18.3.0/docs"], - ["nodejs/docs/v18.4.0", "nodejs/release/v18.4.0/docs"], - ["nodejs/docs/v18.5.0", "nodejs/release/v18.5.0/docs"], - ["nodejs/docs/v18.6.0", "nodejs/release/v18.6.0/docs"], - ["nodejs/docs/v18.7.0", "nodejs/release/v18.7.0/docs"], - ["nodejs/docs/v18.8.0", "nodejs/release/v18.8.0/docs"], - ["nodejs/docs/v18.9.0", "nodejs/release/v18.9.0/docs"], - ["nodejs/docs/v18.9.1", "nodejs/release/v18.9.1/docs"], - ["nodejs/docs/v19.0.0", "nodejs/release/v19.0.0/docs"], - ["nodejs/docs/v19.0.1", "nodejs/release/v19.0.1/docs"], - ["nodejs/docs/v19.1.0", "nodejs/release/v19.1.0/docs"], - ["nodejs/docs/v19.2.0", "nodejs/release/v19.2.0/docs"], - ["nodejs/docs/v19.3.0", "nodejs/release/v19.3.0/docs"], - ["nodejs/docs/v19.4.0", "nodejs/release/v19.4.0/docs"], - ["nodejs/docs/v19.5.0", "nodejs/release/v19.5.0/docs"], - ["nodejs/docs/v19.6.0", "nodejs/release/v19.6.0/docs"], - ["nodejs/docs/v19.6.1", "nodejs/release/v19.6.1/docs"], - ["nodejs/docs/v19.7.0", "nodejs/release/v19.7.0/docs"], - ["nodejs/docs/v19.8.0", "nodejs/release/v19.8.0/docs"], - ["nodejs/docs/v19.8.1", "nodejs/release/v19.8.1/docs"], - ["nodejs/docs/v19.9.0", "nodejs/release/v19.9.0/docs"], - ["nodejs/docs/v20.0.0", "nodejs/release/v20.0.0/docs"], - ["nodejs/docs/v20.1.0", "nodejs/release/v20.1.0/docs"], - ["nodejs/docs/v20.10.0", "nodejs/release/v20.10.0/docs"], - ["nodejs/docs/v20.2.0", "nodejs/release/v20.2.0/docs"], - ["nodejs/docs/v20.3.0", "nodejs/release/v20.3.0/docs"], - ["nodejs/docs/v20.3.1", "nodejs/release/v20.3.1/docs"], - ["nodejs/docs/v20.4.0", "nodejs/release/v20.4.0/docs"], - ["nodejs/docs/v20.5.0", "nodejs/release/v20.5.0/docs"], - ["nodejs/docs/v20.5.1", "nodejs/release/v20.5.1/docs"], - ["nodejs/docs/v20.6.0", "nodejs/release/v20.6.0/docs"], - ["nodejs/docs/v20.6.1", "nodejs/release/v20.6.1/docs"], - ["nodejs/docs/v20.7.0", "nodejs/release/v20.7.0/docs"], - ["nodejs/docs/v20.8.0", "nodejs/release/v20.8.0/docs"], - ["nodejs/docs/v20.8.1", "nodejs/release/v20.8.1/docs"], - ["nodejs/docs/v20.9.0", "nodejs/release/v20.9.0/docs"], - ["nodejs/docs/v21.0.0", "nodejs/release/v21.0.0/docs"], - ["nodejs/docs/v21.1.0", "nodejs/release/v21.1.0/docs"], - ["nodejs/docs/v21.2.0", "nodejs/release/v21.2.0/docs"], - ["nodejs/docs/v4.0.0", "nodejs/release/v4.0.0/docs"], - ["nodejs/docs/v4.1.0", "nodejs/release/v4.1.0/docs"], - ["nodejs/docs/v4.1.1", "nodejs/release/v4.1.1/docs"], - ["nodejs/docs/v4.1.2", "nodejs/release/v4.1.2/docs"], - ["nodejs/docs/v4.2.0", "nodejs/release/v4.2.0/docs"], - ["nodejs/docs/v4.2.1", "nodejs/release/v4.2.1/docs"], - ["nodejs/docs/v4.2.2", "nodejs/release/v4.2.2/docs"], - ["nodejs/docs/v4.2.3", "nodejs/release/v4.2.3/docs"], - ["nodejs/docs/v4.2.4", "nodejs/release/v4.2.4/docs"], - ["nodejs/docs/v4.2.5", "nodejs/release/v4.2.5/docs"], - ["nodejs/docs/v4.2.6", "nodejs/release/v4.2.6/docs"], - ["nodejs/docs/v4.3.0", "nodejs/release/v4.3.0/docs"], - ["nodejs/docs/v4.3.1", "nodejs/release/v4.3.1/docs"], - ["nodejs/docs/v4.3.2", "nodejs/release/v4.3.2/docs"], - ["nodejs/docs/v4.4.0", "nodejs/release/v4.4.0/docs"], - ["nodejs/docs/v4.4.1", "nodejs/release/v4.4.1/docs"], - ["nodejs/docs/v4.4.2", "nodejs/release/v4.4.2/docs"], - ["nodejs/docs/v4.4.3", "nodejs/release/v4.4.3/docs"], - ["nodejs/docs/v4.4.4", "nodejs/release/v4.4.4/docs"], - ["nodejs/docs/v4.4.5", "nodejs/release/v4.4.5/docs"], - ["nodejs/docs/v4.4.6", "nodejs/release/v4.4.6/docs"], - ["nodejs/docs/v4.4.7", "nodejs/release/v4.4.7/docs"], - ["nodejs/docs/v4.5.0", "nodejs/release/v4.5.0/docs"], - ["nodejs/docs/v4.6.0", "nodejs/release/v4.6.0/docs"], - ["nodejs/docs/v4.6.1", "nodejs/release/v4.6.1/docs"], - ["nodejs/docs/v4.6.2", "nodejs/release/v4.6.2/docs"], - ["nodejs/docs/v4.7.0", "nodejs/release/v4.7.0/docs"], - ["nodejs/docs/v4.7.1", "nodejs/release/v4.7.1/docs"], - ["nodejs/docs/v4.7.2", "nodejs/release/v4.7.2/docs"], - ["nodejs/docs/v4.7.3", "nodejs/release/v4.7.3/docs"], - ["nodejs/docs/v4.8.0", "nodejs/release/v4.8.0/docs"], - ["nodejs/docs/v4.8.1", "nodejs/release/v4.8.1/docs"], - ["nodejs/docs/v4.8.2", "nodejs/release/v4.8.2/docs"], - ["nodejs/docs/v4.8.3", "nodejs/release/v4.8.3/docs"], - ["nodejs/docs/v4.8.4", "nodejs/release/v4.8.4/docs"], - ["nodejs/docs/v4.8.5", "nodejs/release/v4.8.5/docs"], - ["nodejs/docs/v4.8.6", "nodejs/release/v4.8.6/docs"], - ["nodejs/docs/v4.8.7", "nodejs/release/v4.8.7/docs"], - ["nodejs/docs/v4.9.0", "nodejs/release/v4.9.0/docs"], - ["nodejs/docs/v4.9.1", "nodejs/release/v4.9.1/docs"], - ["nodejs/docs/v5.0.0", "nodejs/release/v5.0.0/docs"], - ["nodejs/docs/v5.1.0", "nodejs/release/v5.1.0/docs"], - ["nodejs/docs/v5.1.1", "nodejs/release/v5.1.1/docs"], - ["nodejs/docs/v5.10.0", "nodejs/release/v5.10.0/docs"], - ["nodejs/docs/v5.10.1", "nodejs/release/v5.10.1/docs"], - ["nodejs/docs/v5.11.0", "nodejs/release/v5.11.0/docs"], - ["nodejs/docs/v5.11.1", "nodejs/release/v5.11.1/docs"], - ["nodejs/docs/v5.12.0", "nodejs/release/v5.12.0/docs"], - ["nodejs/docs/v5.2.0", "nodejs/release/v5.2.0/docs"], - ["nodejs/docs/v5.3.0", "nodejs/release/v5.3.0/docs"], - ["nodejs/docs/v5.4.0", "nodejs/release/v5.4.0/docs"], - ["nodejs/docs/v5.4.1", "nodejs/release/v5.4.1/docs"], - ["nodejs/docs/v5.5.0", "nodejs/release/v5.5.0/docs"], - ["nodejs/docs/v5.6.0", "nodejs/release/v5.6.0/docs"], - ["nodejs/docs/v5.7.0", "nodejs/release/v5.7.0/docs"], - ["nodejs/docs/v5.7.1", "nodejs/release/v5.7.1/docs"], - ["nodejs/docs/v5.8.0", "nodejs/release/v5.8.0/docs"], - ["nodejs/docs/v5.9.0", "nodejs/release/v5.9.0/docs"], - ["nodejs/docs/v5.9.1", "nodejs/release/v5.9.1/docs"], - ["nodejs/docs/v6.0.0", "nodejs/release/v6.0.0/docs"], - ["nodejs/docs/v6.1.0", "nodejs/release/v6.1.0/docs"], - ["nodejs/docs/v6.10.0", "nodejs/release/v6.10.0/docs"], - ["nodejs/docs/v6.10.1", "nodejs/release/v6.10.1/docs"], - ["nodejs/docs/v6.10.2", "nodejs/release/v6.10.2/docs"], - ["nodejs/docs/v6.10.3", "nodejs/release/v6.10.3/docs"], - ["nodejs/docs/v6.11.0", "nodejs/release/v6.11.0/docs"], - ["nodejs/docs/v6.11.1", "nodejs/release/v6.11.1/docs"], - ["nodejs/docs/v6.11.2", "nodejs/release/v6.11.2/docs"], - ["nodejs/docs/v6.11.3", "nodejs/release/v6.11.3/docs"], - ["nodejs/docs/v6.11.4", "nodejs/release/v6.11.4/docs"], - ["nodejs/docs/v6.11.5", "nodejs/release/v6.11.5/docs"], - ["nodejs/docs/v6.12.0", "nodejs/release/v6.12.0/docs"], - ["nodejs/docs/v6.12.1", "nodejs/release/v6.12.1/docs"], - ["nodejs/docs/v6.12.2", "nodejs/release/v6.12.2/docs"], - ["nodejs/docs/v6.12.3", "nodejs/release/v6.12.3/docs"], - ["nodejs/docs/v6.13.0", "nodejs/release/v6.13.0/docs"], - ["nodejs/docs/v6.13.1", "nodejs/release/v6.13.1/docs"], - ["nodejs/docs/v6.14.0", "nodejs/release/v6.14.0/docs"], - ["nodejs/docs/v6.14.1", "nodejs/release/v6.14.1/docs"], - ["nodejs/docs/v6.14.2", "nodejs/release/v6.14.2/docs"], - ["nodejs/docs/v6.14.3", "nodejs/release/v6.14.3/docs"], - ["nodejs/docs/v6.14.4", "nodejs/release/v6.14.4/docs"], - ["nodejs/docs/v6.15.0", "nodejs/release/v6.15.0/docs"], - ["nodejs/docs/v6.15.1", "nodejs/release/v6.15.1/docs"], - ["nodejs/docs/v6.16.0", "nodejs/release/v6.16.0/docs"], - ["nodejs/docs/v6.17.0", "nodejs/release/v6.17.0/docs"], - ["nodejs/docs/v6.17.1", "nodejs/release/v6.17.1/docs"], - ["nodejs/docs/v6.2.0", "nodejs/release/v6.2.0/docs"], - ["nodejs/docs/v6.2.1", "nodejs/release/v6.2.1/docs"], - ["nodejs/docs/v6.2.2", "nodejs/release/v6.2.2/docs"], - ["nodejs/docs/v6.3.0", "nodejs/release/v6.3.0/docs"], - ["nodejs/docs/v6.3.1", "nodejs/release/v6.3.1/docs"], - ["nodejs/docs/v6.4.0", "nodejs/release/v6.4.0/docs"], - ["nodejs/docs/v6.5.0", "nodejs/release/v6.5.0/docs"], - ["nodejs/docs/v6.6.0", "nodejs/release/v6.6.0/docs"], - ["nodejs/docs/v6.7.0", "nodejs/release/v6.7.0/docs"], - ["nodejs/docs/v6.8.0", "nodejs/release/v6.8.0/docs"], - ["nodejs/docs/v6.8.1", "nodejs/release/v6.8.1/docs"], - ["nodejs/docs/v6.9.0", "nodejs/release/v6.9.0/docs"], - ["nodejs/docs/v6.9.1", "nodejs/release/v6.9.1/docs"], - ["nodejs/docs/v6.9.2", "nodejs/release/v6.9.2/docs"], - ["nodejs/docs/v6.9.3", "nodejs/release/v6.9.3/docs"], - ["nodejs/docs/v6.9.4", "nodejs/release/v6.9.4/docs"], - ["nodejs/docs/v6.9.5", "nodejs/release/v6.9.5/docs"], - ["nodejs/docs/v7.0.0", "nodejs/release/v7.0.0/docs"], - ["nodejs/docs/v7.1.0", "nodejs/release/v7.1.0/docs"], - ["nodejs/docs/v7.10.0", "nodejs/release/v7.10.0/docs"], - ["nodejs/docs/v7.10.1", "nodejs/release/v7.10.1/docs"], - ["nodejs/docs/v7.2.0", "nodejs/release/v7.2.0/docs"], - ["nodejs/docs/v7.2.1", "nodejs/release/v7.2.1/docs"], - ["nodejs/docs/v7.3.0", "nodejs/release/v7.3.0/docs"], - ["nodejs/docs/v7.4.0", "nodejs/release/v7.4.0/docs"], - ["nodejs/docs/v7.5.0", "nodejs/release/v7.5.0/docs"], - ["nodejs/docs/v7.6.0", "nodejs/release/v7.6.0/docs"], - ["nodejs/docs/v7.7.0", "nodejs/release/v7.7.0/docs"], - ["nodejs/docs/v7.7.1", "nodejs/release/v7.7.1/docs"], - ["nodejs/docs/v7.7.2", "nodejs/release/v7.7.2/docs"], - ["nodejs/docs/v7.7.3", "nodejs/release/v7.7.3/docs"], - ["nodejs/docs/v7.7.4", "nodejs/release/v7.7.4/docs"], - ["nodejs/docs/v7.8.0", "nodejs/release/v7.8.0/docs"], - ["nodejs/docs/v7.9.0", "nodejs/release/v7.9.0/docs"], - ["nodejs/docs/v8.0.0", "nodejs/release/v8.0.0/docs"], - ["nodejs/docs/v8.1.0", "nodejs/release/v8.1.0/docs"], - ["nodejs/docs/v8.1.1", "nodejs/release/v8.1.1/docs"], - ["nodejs/docs/v8.1.2", "nodejs/release/v8.1.2/docs"], - ["nodejs/docs/v8.1.3", "nodejs/release/v8.1.3/docs"], - ["nodejs/docs/v8.1.4", "nodejs/release/v8.1.4/docs"], - ["nodejs/docs/v8.10.0", "nodejs/release/v8.10.0/docs"], - ["nodejs/docs/v8.11.0", "nodejs/release/v8.11.0/docs"], - ["nodejs/docs/v8.11.1", "nodejs/release/v8.11.1/docs"], - ["nodejs/docs/v8.11.2", "nodejs/release/v8.11.2/docs"], - ["nodejs/docs/v8.11.3", "nodejs/release/v8.11.3/docs"], - ["nodejs/docs/v8.11.4", "nodejs/release/v8.11.4/docs"], - ["nodejs/docs/v8.12.0", "nodejs/release/v8.12.0/docs"], - ["nodejs/docs/v8.13.0", "nodejs/release/v8.13.0/docs"], - ["nodejs/docs/v8.14.0", "nodejs/release/v8.14.0/docs"], - ["nodejs/docs/v8.14.1", "nodejs/release/v8.14.1/docs"], - ["nodejs/docs/v8.15.0", "nodejs/release/v8.15.0/docs"], - ["nodejs/docs/v8.15.1", "nodejs/release/v8.15.1/docs"], - ["nodejs/docs/v8.16.0", "nodejs/release/v8.16.0/docs"], - ["nodejs/docs/v8.16.1", "nodejs/release/v8.16.1/docs"], - ["nodejs/docs/v8.16.2", "nodejs/release/v8.16.2/docs"], - ["nodejs/docs/v8.17.0", "nodejs/release/v8.17.0/docs"], - ["nodejs/docs/v8.2.0", "nodejs/release/v8.2.0/docs"], - ["nodejs/docs/v8.2.1", "nodejs/release/v8.2.1/docs"], - ["nodejs/docs/v8.3.0", "nodejs/release/v8.3.0/docs"], - ["nodejs/docs/v8.4.0", "nodejs/release/v8.4.0/docs"], - ["nodejs/docs/v8.5.0", "nodejs/release/v8.5.0/docs"], - ["nodejs/docs/v8.6.0", "nodejs/release/v8.6.0/docs"], - ["nodejs/docs/v8.7.0", "nodejs/release/v8.7.0/docs"], - ["nodejs/docs/v8.8.0", "nodejs/release/v8.8.0/docs"], - ["nodejs/docs/v8.8.1", "nodejs/release/v8.8.1/docs"], - ["nodejs/docs/v8.9.0", "nodejs/release/v8.9.0/docs"], - ["nodejs/docs/v8.9.1", "nodejs/release/v8.9.1/docs"], - ["nodejs/docs/v8.9.2", "nodejs/release/v8.9.2/docs"], - ["nodejs/docs/v8.9.3", "nodejs/release/v8.9.3/docs"], - ["nodejs/docs/v8.9.4", "nodejs/release/v8.9.4/docs"], - ["nodejs/docs/v9.0.0", "nodejs/release/v9.0.0/docs"], - ["nodejs/docs/v9.1.0", "nodejs/release/v9.1.0/docs"], - ["nodejs/docs/v9.10.0", "nodejs/release/v9.10.0/docs"], - ["nodejs/docs/v9.10.1", "nodejs/release/v9.10.1/docs"], - ["nodejs/docs/v9.11.0", "nodejs/release/v9.11.0/docs"], - ["nodejs/docs/v9.11.1", "nodejs/release/v9.11.1/docs"], - ["nodejs/docs/v9.11.2", "nodejs/release/v9.11.2/docs"], - ["nodejs/docs/v9.2.0", "nodejs/release/v9.2.0/docs"], - ["nodejs/docs/v9.2.1", "nodejs/release/v9.2.1/docs"], - ["nodejs/docs/v9.3.0", "nodejs/release/v9.3.0/docs"], - ["nodejs/docs/v9.4.0", "nodejs/release/v9.4.0/docs"], - ["nodejs/docs/v9.5.0", "nodejs/release/v9.5.0/docs"], - ["nodejs/docs/v9.6.0", "nodejs/release/v9.6.0/docs"], - ["nodejs/docs/v9.6.1", "nodejs/release/v9.6.1/docs"], - ["nodejs/docs/v9.7.0", "nodejs/release/v9.7.0/docs"], - ["nodejs/docs/v9.7.1", "nodejs/release/v9.7.1/docs"], - ["nodejs/docs/v9.8.0", "nodejs/release/v9.8.0/docs"], - ["nodejs/docs/v9.9.0", "nodejs/release/v9.9.0/docs"], - ["nodejs/release/latest-v0.10.x", "nodejs/release/v0.10.48"], - ["nodejs/docs/latest-v0.10.x", "nodejs/release/v0.10.48/docs"], - ["nodejs/release/latest-v0.12.x", "nodejs/release/v0.12.18"], - ["nodejs/docs/latest-v0.12.x", "nodejs/release/v0.12.18/docs"], - ["nodejs/release/latest-v4.x", "nodejs/release/v4.9.1"], - ["nodejs/docs/latest-v4.x", "nodejs/release/v4.9.1/docs"], - ["nodejs/release/latest-argon", "nodejs/release/v4.9.1"], - ["nodejs/docs/latest-argon", "nodejs/release/v4.9.1/docs"], - ["nodejs/release/latest-v5.x", "nodejs/release/v5.12.0"], - ["nodejs/docs/latest-v5.x", "nodejs/release/v5.12.0/docs"], - ["nodejs/release/latest-v6.x", "nodejs/release/v6.17.1"], - ["nodejs/docs/latest-v6.x", "nodejs/release/v6.17.1/docs"], - ["nodejs/release/latest-boron", "nodejs/release/v6.17.1"], - ["nodejs/docs/latest-boron", "nodejs/release/v6.17.1/docs"], - ["nodejs/release/latest-v7.x", "nodejs/release/v7.10.1"], - ["nodejs/docs/latest-v7.x", "nodejs/release/v7.10.1/docs"], - ["nodejs/release/latest-v8.x", "nodejs/release/v8.17.0"], - ["nodejs/docs/latest-v8.x", "nodejs/release/v8.17.0/docs"], - ["nodejs/release/latest-carbon", "nodejs/release/v8.17.0"], - ["nodejs/docs/latest-carbon", "nodejs/release/v8.17.0/docs"], - ["nodejs/release/latest-v9.x", "nodejs/release/v9.11.2"], - ["nodejs/docs/latest-v9.x", "nodejs/release/v9.11.2/docs"], - ["nodejs/release/latest-v10.x", "nodejs/release/v10.24.1"], - ["nodejs/docs/latest-v10.x", "nodejs/release/v10.24.1/docs"], - ["nodejs/release/latest-dubnium", "nodejs/release/v10.24.1"], - ["nodejs/docs/latest-dubnium", "nodejs/release/v10.24.1/docs"], - ["nodejs/release/latest-v11.x", "nodejs/release/v11.15.0"], - ["nodejs/docs/latest-v11.x", "nodejs/release/v11.15.0/docs"], - ["nodejs/release/latest-v12.x", "nodejs/release/v12.22.12"], - ["nodejs/docs/latest-v12.x", "nodejs/release/v12.22.12/docs"], - ["nodejs/release/latest-erbium", "nodejs/release/v12.22.12"], - ["nodejs/docs/latest-erbium", "nodejs/release/v12.22.12/docs"], - ["nodejs/release/latest-v13.x", "nodejs/release/v13.14.0"], - ["nodejs/docs/latest-v13.x", "nodejs/release/v13.14.0/docs"], - ["nodejs/release/latest-v14.x", "nodejs/release/v14.21.3"], - ["nodejs/docs/latest-v14.x", "nodejs/release/v14.21.3/docs"], - ["nodejs/release/latest-fermium", "nodejs/release/v14.21.3"], - ["nodejs/docs/latest-fermium", "nodejs/release/v14.21.3/docs"], - ["nodejs/release/latest-v15.x", "nodejs/release/v15.14.0"], - ["nodejs/docs/latest-v15.x", "nodejs/release/v15.14.0/docs"], - ["nodejs/release/latest-v16.x", "nodejs/release/v16.20.2"], - ["nodejs/docs/latest-v16.x", "nodejs/release/v16.20.2/docs"], - ["nodejs/release/latest-gallium", "nodejs/release/v16.20.2"], - ["nodejs/docs/latest-gallium", "nodejs/release/v16.20.2/docs"], - ["nodejs/release/latest-v17.x", "nodejs/release/v17.9.1"], - ["nodejs/docs/latest-v17.x", "nodejs/release/v17.9.1/docs"], - ["nodejs/release/latest-v18.x", "nodejs/release/v18.19.0"], - ["nodejs/docs/latest-v18.x", "nodejs/release/v18.19.0/docs"], - ["nodejs/release/latest-hydrogen", "nodejs/release/v18.19.0"], - ["nodejs/docs/latest-hydrogen", "nodejs/release/v18.19.0/docs"], - ["nodejs/release/latest-v19.x", "nodejs/release/v19.9.0"], - ["nodejs/docs/latest-v19.x", "nodejs/release/v19.9.0/docs"], - ["nodejs/release/latest-v20.x", "nodejs/release/v20.10.0"], - ["nodejs/docs/latest-v20.x", "nodejs/release/v20.10.0/docs"], - ["nodejs/release/latest-iron", "nodejs/release/v20.10.0"], - ["nodejs/docs/latest-iron", "nodejs/release/v20.10.0/docs"], - ["nodejs/release/latest-v21.x", "nodejs/release/v21.2.0"], - ["nodejs/docs/latest-v21.x", "nodejs/release/v21.2.0/docs"], - ["nodejs/release/latest", "nodejs/release/v21.2.0"], - ["nodejs/docs/latest", "nodejs/release/v21.2.0/docs"], - [ - "nodejs/release/node-latest.tar.gz", - "nodejs/release/latest/nodejs/release/v21.2.0/node-v21.2.0.tar.gz" - ] -] diff --git a/src/env.ts b/src/env.ts index 95af60e..c4eaff7 100644 --- a/src/env.ts +++ b/src/env.ts @@ -24,32 +24,10 @@ export interface Env { * Bucket name */ BUCKET_NAME: string; - /** - * Directory listing toggle - * on - Enabled for all paths - * restricted - Directory listing enabled only for paths we want to be listed - * off - No directory - * In prod, this should *always* be restricted - */ - DIRECTORY_LISTING: 'on' | 'restricted' | 'off'; - /** - * Api key for /_cf/cache-purge. If undefined, the endpoint is disabled. - */ - CACHE_PURGE_API_KEY?: string; /** * Sentry DSN, used for error monitoring * If missing, Sentry isn't used */ SENTRY_DSN?: string; - /** - * If true and all retries to R2 fail, we will rewrite the request to - * https://direct.nodejs.org - */ - USE_FALLBACK_WHEN_R2_FAILS: boolean; - /** - * Host for the www/Digital Ocean/origin server - */ - FALLBACK_HOST: string; - ORIGIN_HOST: string; } diff --git a/src/handlers/get.ts b/src/handlers/get.ts deleted file mode 100644 index dcdff0f..0000000 --- a/src/handlers/get.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { CACHE } from '../constants/cache'; -import responses from '../responses'; -import { VIRTUAL_DIRS } from '../constants/r2Prefixes'; -import { isCacheEnabled } from '../utils/cache'; -import { - isDirectoryPath, - hasTrailingSlash, - mapUrlPathToBucketPath, -} from '../utils/path'; -import { parseUrl } from '../utils/request'; -import { Handler } from './handler'; -import { - listDirectory, - renderDirectoryListing, -} from './strategies/directoryListing'; -import { getFile } from './strategies/serveFile'; - -const getHandler: Handler = async (request, ctx) => { - const shouldServeCache = isCacheEnabled(ctx.env); - - if (shouldServeCache) { - // Caching is enabled, let's see if the request is cached - const response = await CACHE.match(request); - - if (typeof response !== 'undefined') { - return response; - } - } - - const requestUrl = parseUrl(request); - - if (requestUrl === undefined) { - return responses.badRequest(); - } - - const bucketPath = mapUrlPathToBucketPath(requestUrl.pathname, ctx.env); - - if (typeof bucketPath === 'undefined') { - // Directory listing is restricted and we're not on - // a supported path, block request - return new Response('Unauthorized', { status: 401 }); - } - - const isPathADirectory = isDirectoryPath(bucketPath); - - if (isPathADirectory) { - if (ctx.env.DIRECTORY_LISTING === 'off') { - // File not found since we should only be allowing - // file paths if directory listing is off - return responses.fileNotFound(request); - } - - if (bucketPath && !hasTrailingSlash(requestUrl.pathname)) { - // We always want to add trailing slashes to a directory URL - requestUrl.pathname += '/'; - - return Response.redirect(requestUrl.toString(), 301); - } - } - - let response: Response; - if (bucketPath in VIRTUAL_DIRS) { - // Path requested is to be treated as a symlink to a directory, - // list the directory it points to - response = renderDirectoryListing( - requestUrl, - request, - VIRTUAL_DIRS[bucketPath], - [] - ); - } else if (isPathADirectory) { - // List the directory - response = await listDirectory(requestUrl, request, bucketPath, ctx); - } else { - // Fetch the file - response = await getFile(requestUrl, request, bucketPath, ctx); - } - - if (request.method === 'HEAD') { - return response; - } - - // Responses from fetch() are immutable + we don't want to cache these anyways - const didRequestFallback = response.url.startsWith(ctx.env.FALLBACK_HOST); - - if (didRequestFallback) { - return response; - } - - if (requestUrl.host !== 'nodejs.org') { - // *.workers.dev, r2.nodejs.org, etc. - response.headers.append('x-robots-tag', 'noindex, nofollow'); - } - - // Cache response if cache is enabled - if (shouldServeCache && response.status === 200) { - const cachedResponse = response.clone(); - - cachedResponse.headers.append('x-cache-status', 'hit'); - - ctx.execution.waitUntil(CACHE.put(request, cachedResponse)); - } - - response.headers.append('x-cache-status', 'miss'); - - return response; -}; - -export default getHandler; diff --git a/src/handlers/handler.ts b/src/handlers/handler.ts deleted file mode 100644 index 8a9cdcb..0000000 --- a/src/handlers/handler.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Context } from '../context'; - -/** - * @param request Request object itself - * @param ctx Worker context - * @param cache Cache to use if applicable - */ -export type Handler = (request: Request, ctx: Context) => Promise; diff --git a/src/handlers/index.ts b/src/handlers/index.ts deleted file mode 100644 index 1bc083c..0000000 --- a/src/handlers/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import get from './get'; -import post from './post'; -import options from './options'; - -export default { - get, - post, - options, -}; diff --git a/src/handlers/options.ts b/src/handlers/options.ts deleted file mode 100644 index 5616bd5..0000000 --- a/src/handlers/options.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Handler } from './handler'; - -const optionsHandler: Handler = async () => { - return new Response(undefined, { - headers: { - Allow: 'GET, HEAD, OPTIONS', - }, - }); -}; - -export default optionsHandler; diff --git a/src/handlers/post.ts b/src/handlers/post.ts deleted file mode 100644 index 4750e97..0000000 --- a/src/handlers/post.ts +++ /dev/null @@ -1,23 +0,0 @@ -import responses from '../responses'; -import { parseUrl } from '../utils/request'; -import { Handler } from './handler'; -import { cachePurge } from './strategies/cachePurge'; - -const postHandler: Handler = async (request, ctx) => { - const url = parseUrl(request); - - if (url === undefined) { - return responses.badRequest(); - } - - // This endpoint is called from the sync script to purge - // directories that are commonly updated so we don't need to - // wait for the cache to expire - if (url.pathname === '/_cf/cache-purge') { - return cachePurge(url, request, ctx); - } - - return new Response(url.pathname, { status: 404 }); -}; - -export default postHandler; diff --git a/src/handlers/strategies/cachePurge.ts b/src/handlers/strategies/cachePurge.ts deleted file mode 100644 index c559301..0000000 --- a/src/handlers/strategies/cachePurge.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { z } from 'zod'; -import { mapBucketPathToUrlPath } from '../../utils/path'; -import { CACHE } from '../../constants/cache'; -import responses from '../../responses'; -import { Context } from '../../context'; - -const CachePurgeBodySchema = z.object({ - paths: z.array(z.string()), -}); - -type CachePurgeBody = z.infer; - -/** - * Parses the body for the cache purge endpoint - * @param request Request object itself - * @returns Instance of {@link CachePurgeBody} if successful, or - * a {@link Response} otherwise - */ -async function parseBody(request: Request): Promise { - // Check to see if we should be receiving json in the first place - if (request.headers.get('content-type') !== 'application/json') { - return new Response(undefined, { status: 415 }); - } - - let bodyObject: object; - - try { - // Parse body to an object - bodyObject = await request.json(); - } catch (e) { - // content-type header lied to us - return responses.badRequest(); - } - - // Validate the body's contents - const parseResult = CachePurgeBodySchema.safeParse(bodyObject); - - if (!parseResult.success) { - return responses.badRequest(); - } - - return parseResult.data; -} - -/** - * Cache purge - * Purges commonly updated directories from cache so - * we don't need to wait for cache to expire - * @param request Request object itself - * @param cache Cache to purge - * @param env Worker env - */ -export async function cachePurge( - url: URL, - request: Request, - ctx: Context -): Promise { - const providedApiKey = request.headers.get('x-api-key'); - - if (providedApiKey !== ctx.env.CACHE_PURGE_API_KEY) { - return new Response(undefined, { status: 403 }); - } - - const body = await parseBody(request); - - if (body instanceof Response) { - return body; - } - - // Construct a base url from what this worker - // is being hosted on. For prod, it'll be - // https://nodejs.org. For dev, it might be - // http://localhost:8787 - const baseUrl = `${url.protocol}//${url.host}`; - const promises = new Array>(); - - for (const path of body.paths) { - const urlPaths = mapBucketPathToUrlPath(path, ctx.env); - - if (typeof urlPaths === 'undefined') { - continue; - } - - for (const urlPath of urlPaths) { - promises.push(CACHE.delete(new Request(`${baseUrl}/${urlPath}`))); - } - } - - await Promise.allSettled(promises); - - return new Response(undefined, { status: 204 }); -} diff --git a/src/handlers/strategies/directoryListing.ts b/src/handlers/strategies/directoryListing.ts deleted file mode 100644 index 56c679c..0000000 --- a/src/handlers/strategies/directoryListing.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { - ListObjectsV2Command, - ListObjectsV2CommandOutput, - S3Client, - _Object, -} from '@aws-sdk/client-s3'; -import Handlebars from 'handlebars'; -import { toReadableBytes } from '../../utils/object'; -import { getFile } from './serveFile'; - -// Imports the Precompiled Handlebars Template -import htmlTemplate from '../../templates/directoryListing.out.js'; -import { S3_MAX_KEYS, R2_RETRY_LIMIT } from '../../constants/limits'; -import { CACHE_HEADERS } from '../../constants/cache'; -import responses from '../../responses'; -import { Context } from '../../context'; - -// Applies the Template into a Handlebars Template Function -const handleBarsTemplate = Handlebars.template(htmlTemplate); - -const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', -]; - -/** - * @TODO: Simplify the iteration logic or make it more readable - * - * Renders the html for a directory listing response - * @param url Parsed url of the request - * @param request Request object itself - * @param delimitedPrefixes Directories in the bucket - * @param objects Objects in the bucket - * @returns {@link Response} instance - */ -export function renderDirectoryListing( - url: URL, - request: Request, - delimitedPrefixes: Set, - objects: _Object[] -): Response { - // Holds the contents of the listing (directories and files) - const tableElements: object[] = []; - - const urlPathname = `${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}`; - - // Renders all the subdirectories within the Directory - delimitedPrefixes.forEach(name => { - const extra = encodeURIComponent(name.substring(0, name.length - 1)); - - let displayName: string; - let displayNamePaddingRight: string = ''; // hate this - if (name.length > 50) { - displayName = name.substring(0, 49) + '>'; - } else { - displayName = name; - displayNamePaddingRight = ' '.repeat(50 - name.length); - } - - tableElements.push({ - href: `${extra}/`, - displayNamePaddingRight, - name: displayName, - lastModified: ' -', - size: ' -', - }); - }); - - // Last time any of the files within the directory got modified - let directoryLastModified: Date | undefined = undefined; - - // Renders all the Files within the Directory - objects.forEach(object => { - const name = object.Key; - - // Find the most recent date a file in this - // directory was modified, we'll use it - // in the `Last-Modified` header - if ( - directoryLastModified === undefined || - object.LastModified! > directoryLastModified - ) { - directoryLastModified = object.LastModified!; - } - - const lastModified = object.LastModified!; - const dateStr = `${lastModified.getUTCDay()}-${months.at( - lastModified.getUTCMonth() - )}-${lastModified.getUTCFullYear()} ${lastModified.getUTCHours()}:${lastModified.getUTCMinutes()}`; - - let displayName: string = ''; - let displayNamePaddingRight: string = ''; // hate this - if (name!.length > 50) { - displayName = name!.substring(0, 47) + '..>'; - } else { - displayName = name!; - displayNamePaddingRight = ' '.repeat(50 - name!.length); - } - - const bytes = toReadableBytes(object.Size!); - - tableElements.push({ - href: `${urlPathname}${encodeURIComponent(name ?? '')}`, - name: displayName, - displayNamePaddingRight, - lastModified: dateStr, - size: ' '.repeat(20 - bytes.length) + bytes, - }); - }); - - // Renders the Handlebars Template with the populated data - const renderedListing = handleBarsTemplate({ - pathname: url.pathname, - entries: tableElements, - }); - - // Gets an UTC-string on the ISO-8901 format of last modified date - const directoryLastModifiedUtc = ( - directoryLastModified ?? new Date() - ).toUTCString(); - - return new Response(request.method === 'GET' ? renderedListing : null, { - headers: { - 'last-modified': directoryLastModifiedUtc, - 'content-type': 'text/html', - 'cache-control': CACHE_HEADERS.success, - }, - }); -} - -/** - * Send a request to R2 to get the objects & paths in a directory - * @param client {@link S3Client} to use for the request - * @param bucketPath Path in R2 bucket - * @param cursor Where to begin the request from, for pagination - * @param env Worker env - * @returns A {@link ListObjectsV2CommandOutput} - * @throws When all retries are exhausted and no response was returned - */ -async function fetchR2Result( - url: URL, - client: S3Client, - bucketPath: string, - cursor: string | undefined, - ctx: Context -): Promise { - let r2Error: unknown = undefined; - for (let i = 0; i < R2_RETRY_LIMIT; i++) { - try { - // Send request to R2 - const result = await client.send( - new ListObjectsV2Command({ - Bucket: ctx.env.BUCKET_NAME, - Prefix: bucketPath, - Delimiter: '/', - MaxKeys: S3_MAX_KEYS, - ContinuationToken: cursor, - }) - ); - - // Request succeeded, no need for any retries - return result; - } catch (err) { - // Got an error, let's log it and retry - console.error(`R2 ListObjectsV2 error: ${err}`); - - r2Error = err; - } - } - - // R2 isn't having a good day, log to sentry & rewrite to direct.nodejs.org - const error = new Error(`R2 failed listing path ${bucketPath}: ${r2Error}`); - if (ctx.env.USE_FALLBACK_WHEN_R2_FAILS) { - ctx.sentry.captureException(error); - const res = await fetch(ctx.env.FALLBACK_HOST + url.pathname, { - method: 'GET', - }); - return res; - } else { - // Return 500 - throw error; - } -} - -/** - * Directory listing - * @param url Parsed url of the request - * @param request Request object itself - * @param bucketPath Path in R2 bucket - * @param env Worker env - */ -export async function listDirectory( - url: URL, - request: Request, - bucketPath: string, - ctx: Context -): Promise { - const delimitedPrefixes = new Set(); - const objects: _Object[] = []; // s3 sdk types are weird - - // Create an S3 client instance to interact with the bucket. - // There is a limit in the size of the response that - // a binding can return. We kept hitting it due to the - // size of our paths, causing us to send a lot of requests - // to R2 which in turn added a lot of latency. The S3 api - // doesn't have that response body size constraint so we're - // using it for now. - const client = new S3Client({ - region: 'auto', - endpoint: ctx.env.S3_ENDPOINT, - credentials: { - accessKeyId: ctx.env.S3_ACCESS_KEY_ID, - secretAccessKey: ctx.env.S3_ACCESS_KEY_SECRET, - }, - }); - - let truncated = true; - let cursor: string | undefined; - - while (truncated) { - const result: ListObjectsV2CommandOutput | Response = await fetchR2Result( - url, - client, - bucketPath, - cursor, - ctx - ); - - // Fell back to direct.nodejs.org, return the response - if (result instanceof Response) { - return result; - } - - // R2 sends us back the absolute path of the object, cut it - result.CommonPrefixes?.forEach(path => { - if (path.Prefix !== undefined) - delimitedPrefixes.add(path.Prefix.substring(bucketPath.length)); - }); - - const hasIndexFile = result.Contents - ? result.Contents.some(object => object.Key?.endsWith('index.html')) - : false; - - if (hasIndexFile) { - return getFile(url, request, `${bucketPath}index.html`, ctx); - } - - // R2 sends us back the absolute path of the object, cut it - result.Contents?.forEach(object => - objects.push({ - ...object, - Key: object.Key?.substring(bucketPath.length), - }) - ); - - // Default this to false just so we don't end up in a never ending - // loop if they don't send this back for whatever reason - truncated = result.IsTruncated ?? false; - cursor = truncated ? result.NextContinuationToken : undefined; - } - - // Directory needs either subdirectories or files in it cannot be empty - if (delimitedPrefixes.size === 0 && objects.length === 0) { - return responses.directoryNotFound(request); - } - - if (request.method === 'HEAD') { - return new Response(undefined, { status: 200 }); - } - - return renderDirectoryListing(url, request, delimitedPrefixes, objects); -} diff --git a/src/handlers/strategies/serveFile.ts b/src/handlers/strategies/serveFile.ts deleted file mode 100644 index 8b32382..0000000 --- a/src/handlers/strategies/serveFile.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { objectHasBody } from '../../utils/object'; -import { CACHE_HEADERS } from '../../constants/cache'; -import responses from '../../responses'; -import { R2_RETRY_LIMIT } from '../../constants/limits'; -import { Context } from '../../context'; - -/** - * Decides on what status code to return to - * the client. At the time that this is called, - * we know the object exists in R2 and we just - * need to return the right information about - * it to the client. - * @param request Request object - * @param objectHasBody Whether or not there is a - * `body` property in the R2 response - * @returns Http status code - */ -function getStatusCode(request: Request, objectHasBody: boolean): number { - // Don't return 304 for HEAD requests - if (request.method === 'HEAD') { - return 200; - } - - if (objectHasBody) { - if (request.headers.has('range')) { - // Range header was sent, this is - // only part of the object - return 206; - } - - // We have the full object body - return 200; - } - - if ( - request.headers.has('if-match') || - request.headers.has('if-unmodified-since') - ) { - // No body due to precondition failure - return 412; - } - - // We weren't given a body and preconditions succeeded - return 304; -} - -/** - * Fetch an object from a R2 bucket with retries - * The bindings _might_ have retries, if so this just adds - * a little bit more resiliency - * @param bucket The {@link R2Bucket} to read from - * @param key Object key - * @param options Conditional headers, etc. - */ -async function r2GetWithRetries( - url: URL, - bucket: R2Bucket, - ctx: Context, - key: string, - options?: R2GetOptions -): Promise { - let r2Error: unknown = undefined; - for (let i = 0; i < R2_RETRY_LIMIT; i++) { - try { - return await bucket.get(key, options); - } catch (err) { - // Log error & retry - console.error(`R2 GetObject error: ${err}`); - r2Error = err; - } - } - - // R2 isn't having a good day, log to sentry & rewrite to origin.nodejs.org - const error = new Error( - `R2 GetObject failed after ${R2_RETRY_LIMIT} retries: ${r2Error}` - ); - if (ctx.env.USE_FALLBACK_WHEN_R2_FAILS) { - ctx.sentry.captureException(error); - const res = await fetch(ctx.env.FALLBACK_HOST + url.pathname, { - method: 'GET', - }); - return res; - } else { - throw error; - } -} - -/** - * Fetch an object from a R2 bucket with retries - * The bindings _might_ have retries, if so this just adds - * a little bit more resiliency - * @param bucket The {@link R2Bucket} to read from - * @param key Object key - */ -async function r2HeadWithRetries( - url: URL, - bucket: R2Bucket, - ctx: Context, - key: string -): Promise { - let r2Error: unknown = undefined; - for (let i = 0; i < R2_RETRY_LIMIT; i++) { - try { - return await bucket.head(key); - } catch (err) { - // Log error & retry - console.error(`R2 HeadObject error: ${err}`); - r2Error = err; - } - } - - // R2 isn't having a good day, log to sentry & rewrite to direct.nodejs.org - const error = new Error( - `R2 HeadObject failed after ${R2_RETRY_LIMIT} retries: ${r2Error}` - ); - if (ctx.env.USE_FALLBACK_WHEN_R2_FAILS) { - ctx.sentry.captureException(error); - const res = await fetch(ctx.env.FALLBACK_HOST + url.pathname, { - method: 'HEAD', - }); - return res; - } else { - throw error; - } -} - -/** - * File handler - * @param url Parsed url of the request - * @param request Request object itself - * @param bucketPath Path in R2 bucket - * @param env Worker env - */ -export async function getFile( - url: URL, - request: Request, - bucketPath: string, - ctx: Context -): Promise { - let file: R2Object | null = null; - - switch (request.method) { - case 'GET': { - const getResponse = await r2GetWithRetries( - url, - ctx.env.R2_BUCKET, - ctx, - bucketPath, - { - onlyIf: request.headers, - range: request.headers, - } - ); - - if (getResponse instanceof Response) { - // Fell back to direct.nodejs.org, return the response - return getResponse; - } - - file = getResponse; - - break; - } - case 'HEAD': { - const headResponse = await r2HeadWithRetries( - url, - ctx.env.R2_BUCKET, - ctx, - bucketPath - ); - - if (headResponse instanceof Response) { - // Fell back to direct.nodejs.org, return the response - return headResponse; - } - - file = headResponse; - - break; - } - default: - return responses.methodNotAllowed(); - } - - if (file === null) { - return responses.fileNotFound(request); - } - - const hasBody = objectHasBody(file); - - const statusCode = getStatusCode(request, hasBody); - - return new Response( - hasBody && file.size != 0 ? (file as R2ObjectBody).body : null, - { - status: statusCode, - headers: { - etag: file.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': url.pathname.endsWith('.json') - ? '*' - : '', - 'cache-control': - statusCode === 200 - ? file.httpMetadata?.cacheControl ?? CACHE_HEADERS.success - : CACHE_HEADERS.failure, - expires: file.httpMetadata?.cacheExpiry?.toUTCString() ?? '', - 'last-modified': file.uploaded.toUTCString(), - 'content-encoding': file.httpMetadata?.contentEncoding ?? '', - 'content-type': - file.httpMetadata?.contentType ?? 'application/octet-stream', - 'content-language': file.httpMetadata?.contentLanguage ?? '', - 'content-disposition': file.httpMetadata?.contentDisposition ?? '', - 'content-length': file.size.toString(), - }, - } - ); -} diff --git a/src/middleware/cacheMiddleware.ts b/src/middleware/cacheMiddleware.ts new file mode 100644 index 0000000..2a006b1 --- /dev/null +++ b/src/middleware/cacheMiddleware.ts @@ -0,0 +1,58 @@ +import { isCacheEnabled } from '../utils/cache'; +import { Middleware } from './middleware'; + +/** + * Caches the response of a {@link Middleware} given that, + * 1. Caching is enabled + * 2. The middleware's next() callback wasn't called (we only want to cache + * responses for this specific middleware) + * 3. The response is successful (HTTP 200) + * + * Don't use this for non-GET requests + */ +export function cached(middleware: Middleware): Middleware { + // Cache specifically for this middleware + let cache: Cache; + + const wrapper: Middleware = { + async handle(request, ctx, next) { + if (!isCacheEnabled(ctx.env)) { + return middleware.handle(request, ctx, next); + } + + if (cache === undefined) { + cache = await caches.open(middleware.constructor.name); + } + + let response = await cache.match(request); + if (response !== undefined) { + // Request is cached + return response; + } + + // Set to true when the middleware this wraps calls next(). + // We only want to cache the result for this middleware, + // not the one after this. + let wasDeferred = false; + + response = await middleware.handle(request, ctx, () => { + wasDeferred = true; + return next(); + }); + + if (!wasDeferred && response.status === 200) { + // Successful request, let's cache it for next time + const cachedResponse = response.clone(); + cachedResponse.headers.append('x-cache-status', 'hit'); + + ctx.execution.waitUntil(cache.put(request, cachedResponse)); + } + + response.headers.append('x-cache-status', 'miss'); + + return response; + }, + }; + + return wrapper; +} diff --git a/src/middleware/middleware.ts b/src/middleware/middleware.ts new file mode 100644 index 0000000..4dd3dd7 --- /dev/null +++ b/src/middleware/middleware.ts @@ -0,0 +1,17 @@ +import { Context } from '../context'; +import { Request } from '../routes/request'; + +export type MiddlewareNext = () => Promise; + +export interface Middleware { + /** + * Handle an incoming request + * @param next Calls the next middleware in the chain. This may also call + * other middlewares before returning. + */ + handle( + request: Request, + ctx: Context, + next: MiddlewareNext + ): Promise; +} diff --git a/src/middleware/notFoundMiddleware.ts b/src/middleware/notFoundMiddleware.ts new file mode 100644 index 0000000..32ef516 --- /dev/null +++ b/src/middleware/notFoundMiddleware.ts @@ -0,0 +1,9 @@ +import responses from '../responses'; +import { Request } from '../routes/request'; +import { Middleware } from './middleware'; + +export class NotFoundMiddleware implements Middleware { + handle(request: Request): Promise { + return Promise.resolve(responses.fileNotFound(request)); + } +} diff --git a/src/middleware/optionsMiddleware.ts b/src/middleware/optionsMiddleware.ts new file mode 100644 index 0000000..9f36fe8 --- /dev/null +++ b/src/middleware/optionsMiddleware.ts @@ -0,0 +1,15 @@ +import { CACHE_HEADERS } from '../constants/cache'; +import { Middleware } from './middleware'; + +export class OptionsMiddleware implements Middleware { + handle(): Promise { + return Promise.resolve( + new Response(undefined, { + headers: { + allow: 'GET, HEAD, POST, OPTIONS', + 'cache-control': CACHE_HEADERS.failure, + }, + }) + ); + } +} diff --git a/src/middleware/originMiddleware.ts b/src/middleware/originMiddleware.ts new file mode 100644 index 0000000..7f6237b --- /dev/null +++ b/src/middleware/originMiddleware.ts @@ -0,0 +1,24 @@ +import { Context } from '../context'; +import { Request } from '../routes/request'; +import { Middleware } from './middleware'; + +/** + * Rewrites request to go to the DO server + */ +export class OriginMiddleware implements Middleware { + handle(request: Request, ctx: Context): Promise { + const res = fetch(`${ctx.env.ORIGIN_HOST}${request.urlObj.pathname}`, { + method: request.method, + headers: { + 'user-agent': 'release-cloudflare-worker', + 'if-match': request.headers.get('if-match') ?? '', + 'if-none-match': request.headers.get('if-none-match') ?? '', + 'if-modified-since': request.headers.get('if-modified-since') ?? '', + 'if-unmodified-since': request.headers.get('if-unmodified-since') ?? '', + range: request.headers.get('range') ?? '', + }, + }); + + return res; + } +} diff --git a/src/middleware/r2Middleware.ts b/src/middleware/r2Middleware.ts new file mode 100644 index 0000000..ffad5d1 --- /dev/null +++ b/src/middleware/r2Middleware.ts @@ -0,0 +1,150 @@ +import { CACHE_HEADERS } from '../constants/cache'; +import { Context } from '../context'; +import { R2Provider } from '../providers/r2Provider'; +import responses from '../responses'; +import { hasTrailingSlash, isDirectoryPath } from '../utils/path'; +import type { Middleware } from './middleware'; +import { LATEST_RELEASES } from '../constants/r2Prefixes'; +import { Request } from '../routes/request'; +import { renderDirectoryListing } from '../utils/directoryListing'; +import { parseConditionalHeaders } from '../utils/request'; +import { once } from '../utils/memo'; + +const getProvider = once((ctx: Context) => new R2Provider({ ctx })); + +export class R2Middleware implements Middleware { + async handle(request: Request, ctx: Context): Promise { + const path = getR2Path(request); + const isPathADirectory = isDirectoryPath(path); + + return isPathADirectory + ? handleDirectory(request, path, ctx) + : handleFile(request, path, ctx); + } +} + +async function handleDirectory( + request: Request, + r2Path: string, + ctx: Context +): Promise { + if (!hasTrailingSlash(request.urlObj.pathname)) { + const url = request.unsubtitutedUrl ?? request.urlObj; + return Response.redirect(url + '/', 301); + } + + const result = await getProvider(ctx).readDirectory(r2Path, { + // /docs lists the nodejs/release directory - don't want to include the + // files in there for that path + listFiles: !request.urlObj.pathname.startsWith('/docs'), + }); + if (result === undefined) { + return responses.directoryNotFound(request); + } + + if (result.hasIndexHtmlFile) { + return handleFile(request, r2Path + 'index.html', ctx); + } + + let lastModified: Date | undefined = undefined; + for (const file of result.files) { + if (lastModified === undefined || file.lastModified > lastModified) { + lastModified = file.lastModified; + } + } + + let responseBody: string | undefined = undefined; + if (request.method === 'GET') { + responseBody = renderDirectoryListing( + request.unsubtitutedUrl ?? request.urlObj, + result + ); + } + + return new Response(responseBody, { + headers: { + 'last-modified': (lastModified ?? new Date()).toUTCString(), + 'content-type': 'text/html', + 'cache-control': CACHE_HEADERS.success, + }, + }); +} + +function handleFile( + request: Request, + r2Path: string, + ctx: Context +): Promise { + switch (request.method) { + case 'HEAD': + return headFile(request, r2Path, ctx); + case 'GET': + return getFile(request, r2Path, ctx); + } + + throw new Error('R2Middleware handleFile unsupported method'); +} + +async function headFile( + request: Request, + r2Path: string, + ctx: Context +): Promise { + const result = await getProvider(ctx).headFile(r2Path); + if (result === undefined) { + return responses.fileNotFound(request); + } + + return new Response(undefined, { + status: result.httpStatusCode, + headers: result.httpHeaders, + }); +} + +async function getFile( + request: Request, + r2Path: string, + ctx: Context +): Promise { + const result = await getProvider(ctx).getFile(r2Path, { + conditionalHeaders: parseConditionalHeaders(request.headers), + }); + + if (result === undefined) { + return responses.fileNotFound(request); + } + + return new Response(result.contents, { + status: result.httpStatusCode, + headers: result.httpHeaders, + }); +} + +function getR2Path({ + urlObj, + params, +}: Pick): string { + const { pathname } = urlObj; + const filePath = params.filePath ?? ''; + + if (pathname.startsWith('/dist')) { + return `nodejs/release/${filePath}`; + } else if (pathname.startsWith('/download')) { + return `nodejs/${filePath}`; + } else if (pathname.startsWith('/api')) { + return `nodejs/release/${LATEST_RELEASES['latest']}/docs/api/${filePath}`; + } else if (pathname.startsWith('/docs')) { + if (params.version !== undefined) { + // /docs/vX.X.X at minimum + return `nodejs/release/${params.version}/docs/${filePath}`; + } else { + // Just /docs + return `nodejs/release/`; + } + } else if (pathname.startsWith('/metrics')) { + // Substring to cut off the leading / + return pathname.substring(1); + } + + throw new Error(`unhandled path case: ${pathname}`); +} diff --git a/src/middleware/subtituteMiddleware.ts b/src/middleware/subtituteMiddleware.ts new file mode 100644 index 0000000..214a364 --- /dev/null +++ b/src/middleware/subtituteMiddleware.ts @@ -0,0 +1,35 @@ +import { Context } from '../context'; +import { Router } from '../routes'; +import { Request } from '../routes/request'; +import { Middleware } from './middleware'; + +/** + * Subtitutes a string in a request's url to a different value and sends it + * back to the router to be handled again. + * + * This is useful for paths like /dist/latest/, where we look for `latest` and + * replace it with whatever the latest version is and send it back to be + * handled by the /dist route. + */ +export class SubtitutionMiddleware implements Middleware { + router: Router; + searchValue: string; + replaceValue: string; + + constructor(router: Router, searchValue: string, replaceValue: string) { + this.router = router; + this.searchValue = searchValue; + this.replaceValue = replaceValue; + } + + handle(request: Request, ctx: Context): Promise { + request.unsubtitutedUrl = request.urlObj; + + // router will take care of setting request.urlObj + Object.defineProperty(request, 'url', { + value: request.url.replaceAll(this.searchValue, this.replaceValue), + }); + + return this.router.handle(request, ctx); + } +} diff --git a/src/providers/originProvider.ts b/src/providers/originProvider.ts deleted file mode 100644 index 4ec2514..0000000 --- a/src/providers/originProvider.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { CACHE_HEADERS } from '../constants/cache'; -import { Context } from '../context'; -import { - GetFileOptions, - GetFileResult, - HeadFileResult, - HttpResponseHeaders, - Provider, - ReadDirectoryResult, -} from './provider'; - -type OriginProviderCtorOptions = { - ctx: Context; -}; - -/** - * Serves assets from origin.nodejs.org, used as a fallback for if R2 fails. - */ -export class OriginProvider implements Provider { - private ctx: Context; - - constructor({ ctx }: OriginProviderCtorOptions) { - this.ctx = ctx; - } - - async headFile(path: string): Promise { - const res = await fetch(this.ctx.env.ORIGIN_HOST + path, { - method: 'HEAD', - headers: { - 'user-agent': 'release-cloudflare-worker', - }, - }); - - if (res.status === 404) { - return undefined; - } - - return { - httpStatusCode: res.status, - httpHeaders: originHeadersToOurHeadersObject(res.headers), - }; - } - - async getFile( - path: string, - options?: GetFileOptions | undefined - ): Promise { - const res = await fetch(this.ctx.env.ORIGIN_HOST + path, { - headers: { - 'user-agent': 'release-cloudflare-worker', - 'if-match': options?.conditionalHeaders?.ifMatch ?? '', - 'if-none-match': options?.conditionalHeaders?.ifMatch ?? '', - 'if-modified-since': - options?.conditionalHeaders?.ifModifiedSince?.toUTCString() ?? '', - 'if-unmodified-since': - options?.conditionalHeaders?.ifUnmodifiedSince?.toUTCString() ?? '', - range: options?.rangeHeader ?? '', - }, - }); - - if (res.status === 404) { - return undefined; - } - - return { - contents: res.body, - httpStatusCode: res.status, - httpHeaders: originHeadersToOurHeadersObject(res.headers), - }; - } - - async readDirectory(path: string): Promise { - const res = await fetch(this.ctx.env.ORIGIN_HOST + path, { - headers: { - 'user-agent': 'release-cloudflare-worker', - }, - }); - - if (res.status === 404) { - return undefined; - } - - return { - body: res.body, - httpStatusCode: res.status, - httpHeaders: originHeadersToOurHeadersObject(res.headers), - }; - } -} - -function originHeadersToOurHeadersObject( - headers: Headers -): HttpResponseHeaders { - return { - etag: headers.get('etag') ?? '', - 'accept-range': headers.get('accept-range') ?? 'bytes', - 'access-control-allow-origin': - headers.get('access-control-allow-origin') ?? '', - 'cache-control': CACHE_HEADERS.failure, // We don't want to cache these responses - 'last-modified': headers.get('last-modified') ?? '', - 'content-language': headers.get('content-language') ?? '', - 'content-disposition': headers.get('content-disposition') ?? '', - 'content-length': headers.get('content-length') ?? '0', - }; -} diff --git a/src/providers/provider.ts b/src/providers/provider.ts index 4bc2e66..9f4c029 100644 --- a/src/providers/provider.ts +++ b/src/providers/provider.ts @@ -1,3 +1,5 @@ +import { ConditionalHeaders } from '../utils/request'; + /** * A Provider is essentially an abstracted API client. This is the interface * we interact with to head files, get files, and listing directories. @@ -10,7 +12,10 @@ export interface Provider { options?: GetFileOptions ): Promise; - readDirectory(path: string): Promise; + readDirectory( + path: string, + options?: ReadDirectoryOptions + ): Promise; } /** @@ -43,18 +48,7 @@ export type HeadFileResult = { }; 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; + conditionalHeaders?: ConditionalHeaders; }; export type GetFileResult = { contents?: ReadableStream | null; @@ -74,24 +68,12 @@ export type File = { size: number; }; -export type R2ReadDirectoryResult = { +export type ReadDirectoryOptions = { + listFiles: boolean; +}; + +export type ReadDirectoryResult = { subdirectories: string[]; hasIndexHtmlFile: boolean; 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; diff --git a/src/providers/r2Provider.ts b/src/providers/r2Provider.ts index df95dd8..2f47418 100644 --- a/src/providers/r2Provider.ts +++ b/src/providers/r2Provider.ts @@ -2,7 +2,6 @@ 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, @@ -10,13 +9,13 @@ import { HeadFileResult, HttpResponseHeaders, Provider, + ReadDirectoryOptions, ReadDirectoryResult, } from './provider'; import { S3Provider } from './s3Provider'; type R2ProviderCtorOptions = { ctx: Context; - fallbackProvider?: Provider; }; export class R2Provider implements Provider { @@ -27,13 +26,8 @@ export class R2Provider implements Provider { } async headFile(path: string): Promise { - 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), + async () => await this.ctx.env.R2_BUCKET.head(path), R2_RETRY_LIMIT, this.ctx.sentry ); @@ -52,20 +46,16 @@ export class R2Provider implements Provider { path: string, options?: GetFileOptions ): Promise { - 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, { + return await this.ctx.env.R2_BUCKET.get(path, { onlyIf: { etagMatches: options?.conditionalHeaders?.ifMatch, etagDoesNotMatch: options?.conditionalHeaders?.ifNoneMatch, uploadedBefore: options?.conditionalHeaders?.ifUnmodifiedSince, uploadedAfter: options?.conditionalHeaders?.ifModifiedSince, }, + range: options?.conditionalHeaders?.range, }); }, R2_RETRY_LIMIT, @@ -86,12 +76,14 @@ export class R2Provider implements Provider { }; } - readDirectory(path: string): Promise { + readDirectory( + path: string, + options?: ReadDirectoryOptions + ): Promise { const s3Provider = new S3Provider({ ctx: this.ctx, - fallbackProvider: this.fallbackProvider, }); - return s3Provider.readDirectory(path); + return s3Provider.readDirectory(path, options); } } @@ -103,6 +95,8 @@ function r2MetadataToHeaders( return { etag: object.httpEtag, + 'content-type': + object.httpMetadata?.contentType ?? 'application/octet-stream', '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') @@ -110,11 +104,12 @@ function r2MetadataToHeaders( : undefined, 'cache-control': httpStatusCode === 200 ? CACHE_HEADERS.success : CACHE_HEADERS.failure, - expires: httpMetadata?.cacheExpiry?.toUTCString(), + expires: httpMetadata?.cacheExpiry?.toUTCString() ?? '', 'last-modified': object.uploaded.toUTCString(), - 'content-language': httpMetadata?.contentLanguage, - 'content-disposition': httpMetadata?.contentDisposition, + 'content-language': httpMetadata?.contentLanguage ?? '', + 'content-disposition': httpMetadata?.contentDisposition ?? '', 'content-length': object.size.toString(), + 'content-encoding': object.httpMetadata?.contentEncoding ?? '', }; } @@ -127,10 +122,11 @@ function areConditionalHeadersPresent( const { conditionalHeaders } = options; + // Only check for if-none-match and if-unmodified-since because the docs said + // so, also what nginx does from my experiments + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/412 return ( - conditionalHeaders.ifMatch !== undefined || conditionalHeaders.ifNoneMatch !== undefined || - conditionalHeaders.ifModifiedSince !== undefined || conditionalHeaders.ifUnmodifiedSince !== undefined ); } @@ -140,7 +136,7 @@ function determineHttpStatusCode( options?: GetFileOptions ): number { if (objectHasBody) { - if (options?.rangeHeader !== undefined) { + if (options?.conditionalHeaders?.range !== undefined) { // Range header is present and we have a body, most likely partial return 206; } diff --git a/src/providers/s3Provider.ts b/src/providers/s3Provider.ts index a685355..81ba83e 100644 --- a/src/providers/s3Provider.ts +++ b/src/providers/s3Provider.ts @@ -6,6 +6,7 @@ import { GetFileResult, HeadFileResult, Provider, + ReadDirectoryOptions, ReadDirectoryResult, } from './provider'; import { retryWrapper } from '../utils/provider'; @@ -50,8 +51,13 @@ export class S3Provider implements Provider { throw new Error('Method not implemented.'); } - async readDirectory(path: string): Promise { + async readDirectory( + path: string, + options?: ReadDirectoryOptions + ): Promise { const directories = new Set(); + + const listFiles = options !== undefined ? options.listFiles : true; let hasIndexHtmlFile = false; const files: File[] = []; @@ -78,17 +84,19 @@ export class S3Provider implements Provider { directories.add(directory.Prefix!.substring(path.length)); }); - result.Contents?.forEach(object => { - if (object.Key!.endsWith('index.html')) { - hasIndexHtmlFile = true; - } + if (listFiles) { + result.Contents?.forEach(object => { + if (object.Key!.endsWith('index.html')) { + hasIndexHtmlFile = true; + } - files.push({ - name: object.Key!.substring(path.length), - size: object.Size!, - lastModified: object.LastModified!, + files.push({ + name: object.Key!.substring(path.length), + size: object.Size!, + lastModified: object.LastModified!, + }); }); - }); + } isTruncated = result.IsTruncated ?? false; cursor = result.NextContinuationToken; diff --git a/src/responses/directoryNotFound.ts b/src/responses/directoryNotFound.ts index 4e4de15..6bb338b 100644 --- a/src/responses/directoryNotFound.ts +++ b/src/responses/directoryNotFound.ts @@ -1,6 +1,6 @@ import { CACHE_HEADERS } from '../constants/cache'; -export default (request: Request): Response => { +export default (request: Pick): Response => { return new Response( request.method !== 'HEAD' ? 'Directory not found' : undefined, { diff --git a/src/responses/fileNotFound.ts b/src/responses/fileNotFound.ts index 95e45b3..c935afc 100644 --- a/src/responses/fileNotFound.ts +++ b/src/responses/fileNotFound.ts @@ -1,6 +1,6 @@ import { CACHE_HEADERS } from '../constants/cache'; -export default (request: Request): Response => { +export default (request: Pick): Response => { return new Response( request.method !== 'HEAD' ? 'File not found' : undefined, { diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..38b7fdc --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,56 @@ +import { LATEST_RELEASES } from '../constants/r2Prefixes'; +import { cached } from '../middleware/cacheMiddleware'; +import { NotFoundMiddleware } from '../middleware/notFoundMiddleware'; +import { OptionsMiddleware } from '../middleware/optionsMiddleware'; +import { OriginMiddleware } from '../middleware/originMiddleware'; +import { R2Middleware } from '../middleware/r2Middleware'; +import { SubtitutionMiddleware } from '../middleware/subtituteMiddleware'; +import { Router } from './router'; + +export function registerRoutes(router: Router): void { + const r2Middleware = new R2Middleware(); + const originMiddleware = new OriginMiddleware(); + + router.options('*', [new OptionsMiddleware()]); + + router.head('/metrics/?:filePath+', [r2Middleware, originMiddleware]); + router.get('/metrics/?:filePath+', [cached(r2Middleware), originMiddleware]); + + // Register routes for latest releases (e.g. `/dist/latest/`) + for (const branch in LATEST_RELEASES) { + const latestVersion = LATEST_RELEASES[branch]; + const subtitutionMiddleware = new SubtitutionMiddleware( + router, + branch, + latestVersion + ); + + router.head(`/dist/${branch}*`, [subtitutionMiddleware]); + router.get(`/dist/${branch}*`, [subtitutionMiddleware]); + + router.head(`/download/release/${branch}*`, [subtitutionMiddleware]); + router.get(`/download/release/${branch}*`, [subtitutionMiddleware]); + + router.head(`/docs/${branch}*`, [subtitutionMiddleware]); + router.get(`/docs/${branch}*`, [subtitutionMiddleware]); + } + + router.head('/dist/?:filePath+', [r2Middleware, originMiddleware]); + router.get('/dist/?:filePath+', [cached(r2Middleware), originMiddleware]); + + router.head('/download/?:filePath+', [r2Middleware, originMiddleware]); + router.get('/download/?:filePath+', [cached(r2Middleware), originMiddleware]); + + router.head('/api/?:filePath+', [r2Middleware, originMiddleware]); + router.get('/api/?:filePath+', [cached(r2Middleware), originMiddleware]); + + router.head('/docs/?:version?/:filePath+?', [r2Middleware, originMiddleware]); + router.get('/docs/?:version?/:filePath+?', [ + cached(r2Middleware), + originMiddleware, + ]); + + router.get('*', [new NotFoundMiddleware()]); +} + +export * from './router'; diff --git a/src/routes/request.ts b/src/routes/request.ts new file mode 100644 index 0000000..1710a97 --- /dev/null +++ b/src/routes/request.ts @@ -0,0 +1,9 @@ +import { IRequest } from 'itty-router'; + +export interface Request extends IRequest { + urlObj: URL; + /** + * Set by {@link SubtitutionMiddleware} if it's used + */ + unsubtitutedUrl?: URL; +} diff --git a/src/routes/router.ts b/src/routes/router.ts new file mode 100644 index 0000000..35eed0d --- /dev/null +++ b/src/routes/router.ts @@ -0,0 +1,111 @@ +import { IttyRouter } from 'itty-router'; +import { Middleware } from '../middleware/middleware'; +import { Context } from '../context'; +import { parseUrl } from '../utils/request'; +import responses from '../responses'; +import { Request as WorkerRequest } from './request'; + +/** + * Simple wrapper around {@link IttyRouter} that allows us to do a middleware + * approach with our routing. + * @see {Middleware} + */ +export class Router { + private itty = IttyRouter(); + + handle(request: Request, ctx: Context): Promise { + return this.itty.fetch(request, ctx); + } + + options(endpoint: string, middlewares: Middleware[]): void { + const middlewareChain = buildMiddlewareChain(middlewares); + + this.itty.options(endpoint, (req, ctx) => { + return callMiddlewareChain(middlewareChain, req, ctx); + }); + } + + head(endpoint: string, middlewares: Middleware[]): void { + const middlewareChain = buildMiddlewareChain(middlewares); + + this.itty.head(endpoint, (req, ctx) => { + return callMiddlewareChain(middlewareChain, req, ctx); + }); + } + + get(endpoint: string, middlewares: Middleware[]): void { + const middlewareChain = buildMiddlewareChain(middlewares); + + this.itty.get(endpoint, (req, ctx) => { + return callMiddlewareChain(middlewareChain, req, ctx); + }); + } +} + +type MiddlewareChain = ( + request: WorkerRequest, + ctx: Context +) => Promise; + +/** + * Builds a chain of middlewares to call. Chains them in the same order as they + * are in the `middlewares` array. + */ +function buildMiddlewareChain(middlewares: Middleware[]): MiddlewareChain { + // root will be the very first middleware in the chain + let root: MiddlewareChain = () => { + throw new Error('reached the end of the middleware chain'); + }; + + // Link the middlewares in reverse order for simplicity sakes + for (let i = middlewares.length - 1; i >= 0; i--) { + const middleware = errorHandled(middlewares[i]); + + // Store the previous + const previous = root; + + root = (request, ctx): Promise => { + return middleware.handle(request, ctx, () => { + return previous(request, ctx); + }); + }; + } + + return root; +} + +async function callMiddlewareChain( + chain: MiddlewareChain, + request: WorkerRequest, + ctx: Context +): Promise { + // Parse url here so we don't have to do it multiple times later on + const url = parseUrl(request); + if (url === undefined) { + return responses.badRequest(); + } + + request.urlObj = url; + + return chain(request, ctx); +} + +/** + * Wraps a {@link Middleware} to add basic error reporting and handling to it. + * If an error is thrown, it will log it and skip to the next middleware in + * the chain. + */ +function errorHandled(middleware: Middleware): Middleware { + const wrapper: Middleware = { + async handle(request: WorkerRequest, ctx: Context, next) { + try { + return await middleware.handle(request, ctx, next); + } catch (err) { + ctx.sentry.captureException(err); + return next(); + } + }, + }; + + return wrapper; +} diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 2b187c3..4706232 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -5,6 +5,6 @@ import { Env } from '../env'; * @returns True if we want to either cache files or * directory listings */ -export function isCacheEnabled(env: Env): boolean { +export function isCacheEnabled(env: Pick): boolean { return env.ENVIRONMENT !== 'e2e-tests'; } diff --git a/src/utils/directoryListing.ts b/src/utils/directoryListing.ts new file mode 100644 index 0000000..396838d --- /dev/null +++ b/src/utils/directoryListing.ts @@ -0,0 +1,118 @@ +import Handlebars from 'handlebars'; +import { File, ReadDirectoryResult } from '../providers/provider'; +import htmlTemplate from '../templates/directoryListing.out'; +import { toReadableBytes } from '../utils/object'; + +const handlebarsTemplate = Handlebars.template(htmlTemplate); + +const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +/** + * Render a directory listing + * + * TODO: Work with other teams on removing their dependency on nginx's listing + * result so we don't need to emulate it + */ +export function renderDirectoryListing( + url: URL, + result: ReadDirectoryResult +): string { + const tableElements: TableElement[] = []; + + for (const name of result.subdirectories) { + tableElements.push(renderSubdirectory(name)); + } + + let directoryLastModified: Date | undefined = undefined; + const urlPathname = `${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}`; + for (const file of result.files) { + if ( + directoryLastModified === undefined || + file.lastModified > directoryLastModified + ) { + directoryLastModified = file.lastModified; + } + + tableElements.push(renderFile(urlPathname, file)); + } + + const html = handlebarsTemplate({ + pathname: url.pathname, + entries: tableElements, + }); + + return html; +} + +/** + * Element to be displayed on the directory listing page + */ +type TableElement = { + href: string; + name: string; + displayNamePaddingRight: string; + lastModified: string; + size: string; +}; + +function renderSubdirectory(name: string): TableElement { + const href = encodeURIComponent(name.substring(0, name.length - 1)); + + let displayName: string; + let displayNamePaddingRight: string; + if (name.length > 50) { + displayName = name.substring(0, 49) + '>'; + displayNamePaddingRight = ''; + } else { + displayName = name; + displayNamePaddingRight = ' '.repeat(50 - name.length); + } + + return { + href: `${href}/`, + name: displayName, + displayNamePaddingRight, + lastModified: ' -', + size: ' -', + }; +} + +function renderFile(pathPrefix: string, file: File): TableElement { + const { name, lastModified } = file; + + let displayName: string; + let displayNamePaddingRight: string; + if (name!.length > 50) { + displayName = name.substring(0, 47) + '..>'; + displayNamePaddingRight = ''; + } else { + displayName = name; + displayNamePaddingRight = ' '.repeat(50 - name!.length); + } + + const dateString = `${lastModified.getUTCDay()}-${months.at( + lastModified.getUTCMonth() + )}-${lastModified.getUTCFullYear()} ${lastModified.getUTCHours()}:${lastModified.getUTCMinutes()}`; + + const bytes = toReadableBytes(file.size); + + return { + href: pathPrefix + encodeURIComponent(name), + name: displayName, + displayNamePaddingRight, + lastModified: dateString, + size: ' '.repeat(20 - bytes.length) + bytes, + }; +} diff --git a/src/utils/memo.ts b/src/utils/memo.ts new file mode 100644 index 0000000..c756eb5 --- /dev/null +++ b/src/utils/memo.ts @@ -0,0 +1,19 @@ +/** + * For running something once + * @example + * const getProvider = once((ctx) => new R2Provider({ ctx })); + * // later... + * getProvider(ctx).something(); + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function once>( + fn: (...args: Args) => T +): (...args: Args) => T { + let value: T | undefined = undefined; + return (...args: Args) => { + if (value === undefined) { + value = fn(...args); + } + return value; + }; +} diff --git a/src/utils/path.ts b/src/utils/path.ts index 6c9e348..b1dc3e2 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,162 +1,3 @@ -import { - DIST_PATH_PREFIX, - DOCS_PATH_PREFIX, - DOWNLOAD_PATH_PREFIX, - REDIRECT_MAP, - URL_TO_BUCKET_PATH_MAP, -} from '../constants/r2Prefixes'; -import { Env } from '../env'; - -/** - * Maps a path in a url to the path to the resource - * in the R2 bucket - * @param url Url to map - * @param env Worker env - * @returns Mapped path if the resource is accessible, undefined - * if the eyeball should not be trying to access the resource - */ -export function mapUrlPathToBucketPath( - path: string, - env: Pick -): string | undefined { - const [, basePath, ...pathPieces] = path.split('/'); // 'docs', ['asd', '123'] - - const mappedDist = `${DIST_PATH_PREFIX}/${pathPieces[0]}`; - - if (basePath === 'dist' && REDIRECT_MAP.has(mappedDist)) { - // All items in REDIRECT_MAP are three levels deep, that is asserted in tests - return `${REDIRECT_MAP.get(mappedDist)}/${pathPieces.slice(1).join('/')}`; - } - - const mappedDocs = `${DOCS_PATH_PREFIX}/${pathPieces[0]}`; - - if (basePath === 'docs' && REDIRECT_MAP.has(mappedDocs)) { - // All items in REDIRECT_MAP are three levels deep, that is asserted in tests - return `${REDIRECT_MAP.get(mappedDocs)}/${pathPieces.slice(1).join('/')}`; - } - - const mappedRelease = `${DIST_PATH_PREFIX}/${pathPieces[1]}`; - - if (pathPieces[0] === 'release' && REDIRECT_MAP.has(mappedRelease)) { - return `${REDIRECT_MAP.get(mappedRelease)}/${pathPieces - .slice(2) - .join('/')}`; - } - - if (basePath in URL_TO_BUCKET_PATH_MAP) { - return URL_TO_BUCKET_PATH_MAP[basePath](path); - } - - if (env.DIRECTORY_LISTING !== 'restricted') { - return path.substring(1); - } - - return undefined; -} - -/** - * Get all of the directories beginning with 'latest' in a - * directory - * @param prefix Directory to look through - */ -function getAllLatestDirectories(prefix: string): Set { - const paths = new Set(); - for (const [k] of REDIRECT_MAP) { - if (k.startsWith(`${prefix}/latest`)) { - paths.add(k.substring(prefix.length) + '/'); - } - } - return paths; -} - -/** - * Maps a path in the R2 bucket to the urls used to access it - * @param bucketPath Path to map - * @param env Worker env - * @returns All possible url paths that lead to that resource, - * or undefined if it's inaccessible from a url path - */ -export function mapBucketPathToUrlPath( - bucketPath: string, - env: Pick -): string[] | undefined { - // @TODO: Use a switch statement or a Design Pattern here - if (bucketPath.startsWith(DIST_PATH_PREFIX)) { - // Main release folder, accessible at `/dist/` or `/download/release/` - const path = bucketPath.substring(DIST_PATH_PREFIX.length); - - const possibleUrlPaths = new Set(); - - // Purge directory listing of /dist/ and /download/release/ - possibleUrlPaths.add('/dist/'); - possibleUrlPaths.add('/download/release/'); - - // Purge whatever the paths we're updating - possibleUrlPaths.add(`/dist${path}`); - possibleUrlPaths.add(`/download/release${path}`); - - // Purge all of the directory listings of folders starting with 'latest' - // (e.g. `/dist/latest-hydrogen`) - // Bit of a hack, but I think this is the best we can do. The redirects - // we have in `src/constants/redirectLinks.json` will be out of date since - // a new version was uploaded and thus the latest has changed. We can't - // really determine the new latest here unless we run something - // similar to the `scripts/update-redirect-links.js` script here. - const latestDirectories = getAllLatestDirectories('nodejs/release'); - - for (const directory of latestDirectories) { - possibleUrlPaths.add(`/dist${directory}`); - possibleUrlPaths.add(`/download/release${directory}`); - } - - return [...possibleUrlPaths]; - } else if (bucketPath.startsWith(DOCS_PATH_PREFIX)) { - // Docs for main releases, accessible at `/docs/` or `/api/` for latest docs - const path = bucketPath.substring(DOCS_PATH_PREFIX.length); - - const possibleUrlPaths = new Set(); - - // Purge directory listings for /docs/ and /download/docs/ - possibleUrlPaths.add('/docs/'); - possibleUrlPaths.add('/download/docs/'); - - possibleUrlPaths.add(`/docs${path}`); - possibleUrlPaths.add(`/download/docs${path}`); - - if (bucketPath.includes('/api')) { - // Html file, purge it - - // /latest/api/assert.html - let apiPath = path.substring(1); // latest/api/assert.html - apiPath = apiPath.substring(apiPath.indexOf('/')); // /api/assert.html - - possibleUrlPaths.add(apiPath); - } - - // Purge all of the directory listings of folders starting with 'latest' - // (e.g. `/docs/latest`) - // Refer to previous call for explanation - const latestDirectories = getAllLatestDirectories('nodejs/docs'); - - for (const directory of latestDirectories) { - possibleUrlPaths.add(`/docs${directory}`); - possibleUrlPaths.add(`/download/docs${directory}`); - } - - return [...possibleUrlPaths]; - } else if (bucketPath.startsWith(DOWNLOAD_PATH_PREFIX)) { - // Rest of the `/download/...` paths (e.g. `/download/nightly/`) - return [`/download${bucketPath.substring(DOWNLOAD_PATH_PREFIX.length)}`]; - } else if (bucketPath.startsWith('metrics')) { - // Metrics doesn't need any redirects - return ['/' + bucketPath]; - } - - return env.DIRECTORY_LISTING === 'restricted' - ? undefined - : ['/' + bucketPath]; -} - export function hasTrailingSlash(path: string): boolean { return path[path.length - 1] === '/'; } diff --git a/src/utils/provider.ts b/src/utils/provider.ts index 3a16bf0..c97fbcc 100644 --- a/src/utils/provider.ts +++ b/src/utils/provider.ts @@ -16,7 +16,6 @@ export async function retryWrapper( const result = await request(); return result; } catch (err) { - console.error(`R2Provider error: ${err}`); r2Error = err; } } diff --git a/src/utils/request.ts b/src/utils/request.ts index 186032d..2256e28 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -1,3 +1,17 @@ +/** + * Etags will have quotes removed from them + * 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 + */ +export type ConditionalHeaders = { + ifMatch?: string; + ifNoneMatch?: string; + ifModifiedSince?: Date; + ifUnmodifiedSince?: Date; + range?: R2Range; +}; + /** * @param request Request object * @returns {@link URL} instance if url is valid, a 400 @@ -14,3 +28,86 @@ export function parseUrl(request: Request): URL | undefined { return url; } + +export function parseConditionalHeaders(headers: Headers): ConditionalHeaders { + const ifModifiedSince = headers.has('if-modified-since') + ? new Date(headers.get('if-modified-since')!) + : undefined; + if (ifModifiedSince !== undefined) { + ifModifiedSince.setSeconds(ifModifiedSince.getSeconds() + 1); + } + + return { + ifMatch: headers.has('if-match') + ? headers.get('if-match')!.replaceAll('"', '') + : undefined, + ifNoneMatch: headers.has('if-none-match') + ? headers.get('if-none-match')!.replaceAll('"', '') + : undefined, + ifModifiedSince, + ifUnmodifiedSince: headers.has('if-unmodified-since') + ? new Date(headers.get('if-unmodified-since')!) + : undefined, + range: headers.has('range') + ? parseRangeHeader(headers.get('range')!) + : undefined, + }; +} + +/** + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range + * @returns undefined if header is invalid + */ +export function parseRangeHeader(header: string): R2Range | undefined { + // header: bytes=0-5 + const split = header.split('='); // ['bytes', '0-5'] + + // Has no =, multiple =, or the unit isn't bytes + if (split.length !== 2 || split[0] !== 'bytes') { + return undefined; + } + + let [, range] = split; + + const multipleRangeDelimiter = range.indexOf(','); + if (multipleRangeDelimiter !== -1) { + // Multiple ranges provided in the header (e.g. bytes=0-5, 10-15). + // R2 doesn't support this, so just go with the first + range = range.substring(0, multipleRangeDelimiter); + } + + const [start, end] = range.split('-'); // start=0, end=5 + + // start will be the offset, end-start the length + const startInt = parseInt(start); + const endInt = parseInt(end); + + if (isNaN(startInt)) { + if (isNaN(endInt)) { + // bytes=- + return undefined; + } + + // bytes=-nnn + return { + suffix: endInt, + }; + } else { + if (isNaN(endInt)) { + // bytes=nnn- + return { + offset: startInt, + }; + } + + // bytes=nnn-nnn + if (startInt >= endInt) { + return undefined; + } + + return { + offset: startInt, + length: endInt - startInt + 1, // +1 since it's inclusive + }; + } +} diff --git a/src/worker.ts b/src/worker.ts index 001c8f7..999c891 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,8 +1,12 @@ import { Env } from './env'; -import handlers from './handlers'; import { Toucan } from 'toucan-js'; import responses from './responses'; import { Context } from './context'; +import { Router } from './routes/router'; +import { registerRoutes } from './routes'; + +const router: Router = new Router(); +registerRoutes(router); interface Worker { /** @@ -30,17 +34,8 @@ const cloudflareWorker: Worker = { env, execution: ctx, }; - switch (request.method) { - case 'HEAD': - case 'GET': - return await handlers.get(request, context); - case 'POST': - return await handlers.post(request, context); - case 'OPTIONS': - return await handlers.options(request, context); - default: - return responses.methodNotAllowed(); - } + + return await router.handle(request, context); } catch (e) { // Send to sentry, if it's disabled this will just noop sentry.captureException(e); diff --git a/tests/e2e/cachePurge.test.ts b/tests/e2e/cachePurge.test.ts deleted file mode 100644 index 6464f26..0000000 --- a/tests/e2e/cachePurge.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { after, before, describe, it } from 'node:test'; -import assert from 'node:assert'; -import { Miniflare } from 'miniflare'; - -describe('Cache Purge Tests', () => { - const API_KEY = 'asd123'; - let mf: Miniflare; - let url: URL; - before(async () => { - // Setup miniflare - mf = new Miniflare({ - scriptPath: './dist/worker.js', - modules: true, - bindings: { - DIRECTORY_LISTING: 'restricted', - CACHE_PURGE_API_KEY: API_KEY, - }, - }); - - // Wait for it Miniflare to start - url = await mf.ready; - }); - - it('returns a 403 when missing api key', async () => { - const res = await mf.dispatchFetch(`${url}_cf/cache-purge`, { - method: 'POST', - }); - assert.strictEqual(res.status, 403); - }); - - it('returns a 403 when api key is invalid', async () => { - const res = await mf.dispatchFetch(`${url}_cf/cache-purge`, { - method: 'POST', - headers: { - 'x-api-key': 'something-something', - }, - }); - assert.strictEqual(res.status, 403); - }); - - it("returns a 415 when `content-type` header is missing or isn't `application/json`", async () => { - let res = await mf.dispatchFetch(`${url}_cf/cache-purge`, { - method: 'POST', - headers: { - 'x-api-key': API_KEY, - }, - }); - assert.strictEqual(res.status, 415); - - res = await mf.dispatchFetch(`${url}_cf/cache-purge`, { - method: 'POST', - headers: { - 'x-api-key': API_KEY, - 'content-type': 'text/plain', - }, - }); - assert.strictEqual(res.status, 415); - }); - - it('returns a 400 when body parsing fails', async () => { - let res = await mf.dispatchFetch(`${url}_cf/cache-purge`, { - method: 'POST', - headers: { - 'x-api-key': API_KEY, - 'content-type': 'application/json', - }, - body: JSON.stringify({ - something: 'asd', - }), - }); - assert.strictEqual(res.status, 400); - - res = await mf.dispatchFetch(`${url}_cf/cache-purge`, { - method: 'POST', - headers: { - 'x-api-key': API_KEY, - 'content-type': 'application/json', - }, - body: 'something', - }); - assert.strictEqual(res.status, 400); - - res = await mf.dispatchFetch(`${url}_cf/cache-purge`, { - method: 'POST', - headers: { - 'x-api-key': API_KEY, - 'content-type': 'application/json', - }, - body: JSON.stringify({ - paths: 'not an array', - }), - }); - assert.strictEqual(res.status, 400); - }); - - it('returns 204 and purges the cache properly', async () => { - const res = await mf.dispatchFetch(`${url}_cf/cache-purge`, { - method: 'POST', - headers: { - 'x-api-key': API_KEY, - 'content-type': 'application/json', - }, - body: JSON.stringify({ - paths: ['nodejs/release/index.json'], - }), - }); - assert.strictEqual(res.status, 204); - - // As of now we can't really test that this makes a call - // to the cache api to delete the necessary paths. - // Unfortunate, but hopefully we'll be able to later on - }); - - // Cleanup Miniflare - after(async () => mf.dispose()); -}); diff --git a/tests/e2e/directory.test.ts b/tests/e2e/directory.test.ts index 6d07794..1bbecfd 100644 --- a/tests/e2e/directory.test.ts +++ b/tests/e2e/directory.test.ts @@ -66,7 +66,6 @@ describe('Directory Tests (Restricted Directory Listing)', () => { S3_ACCESS_KEY_ID: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', S3_ACCESS_KEY_SECRET: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - DIRECTORY_LISTING: 'restricted', }, r2Persist: './tests/e2e/test-data', r2Buckets: ['R2_BUCKET'], @@ -157,17 +156,6 @@ describe('Directory Tests (Restricted Directory Listing)', () => { assert.strictEqual(res.status, 200); }); - it('returns 401 for unrecognized base paths', async () => { - let res = await mf.dispatchFetch(url); - assert.strictEqual(res.status, 401); - - res = await mf.dispatchFetch(`${url}/asd/`); - assert.strictEqual(res.status, 401); - - res = await mf.dispatchFetch(`${url}/asd/123/`); - assert.strictEqual(res.status, 401); - }); - it('returns 404 for unknown directory', async () => { const res = await mf.dispatchFetch(`${url}/dist/asd123/`); assert.strictEqual(res.status, 404); diff --git a/tests/e2e/fallback.test.ts b/tests/e2e/fallback.test.ts index 958ce79..8f8580e 100644 --- a/tests/e2e/fallback.test.ts +++ b/tests/e2e/fallback.test.ts @@ -42,9 +42,7 @@ describe('Fallback tests', () => { modules: true, bindings: { ENVIRONMENT: 'e2e-tests', - DIRECTORY_LISTING: 'restricted', - USE_FALLBACK_WHEN_R2_FAILS: true, - FALLBACK_HOST: `http://127.0.0.1:8081`, + ORIGIN_HOST: `http://127.0.0.1:8081`, }, }); diff --git a/tests/e2e/file.test.ts b/tests/e2e/file.test.ts index 535bc19..2dea723 100644 --- a/tests/e2e/file.test.ts +++ b/tests/e2e/file.test.ts @@ -12,7 +12,6 @@ describe('File Tests', () => { modules: true, bindings: { ENVIRONMENT: 'e2e-tests', - DIRECTORY_LISTING: 'restricted', }, r2Persist: './tests/e2e/test-data', r2Buckets: ['R2_BUCKET'], @@ -123,7 +122,7 @@ describe('File Tests', () => { 'if-match': '"asd"', }, }); - assert.strictEqual(res.status, 412); + assert.strictEqual(res.status, 304); assert.strictEqual( res.headers.get('cache-control'), 'private, no-cache, no-store, max-age=0, must-revalidate' diff --git a/tests/e2e/index.test.ts b/tests/e2e/index.test.ts index d00e305..99d5418 100644 --- a/tests/e2e/index.test.ts +++ b/tests/e2e/index.test.ts @@ -1,4 +1,3 @@ import './directory.test'; import './file.test'; -import './cachePurge.test'; import './fallback.test'; diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index 96970a4..b6ac98d 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -1,2 +1,5 @@ import './utils/object.test'; import './utils/path.test'; +import './utils/request.test'; +import './utils/memo.test'; +import './router/router.test'; diff --git a/tests/unit/router/router.test.ts b/tests/unit/router/router.test.ts new file mode 100644 index 0000000..eeaf643 --- /dev/null +++ b/tests/unit/router/router.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { Router } from '../../../src/routes/router'; +import { Middleware } from '../../../src/middleware/middleware'; + +describe('Router', () => { + it('middleware chains properly', async () => { + const callOrdered: string[] = []; + + const firstMiddleware: Middleware = { + handle: (_, _2, next) => { + callOrdered.push('first'); + return next(); + }, + }; + const secondMiddleware: Middleware = { + handle: (_, _2, next) => { + callOrdered.push('second'); + return next(); + }, + }; + const thirdMiddleware: Middleware = { + handle: () => { + callOrdered.push('third'); + return Promise.resolve(new Response('cool response')); + }, + }; + + const router = new Router(); + router.get('/', [firstMiddleware, secondMiddleware, thirdMiddleware]); + + // @ts-expect-error context + const response = await router.handle(new Request('http://localhost/'), {}); + assert.strictEqual(await response.text(), 'cool response'); + assert.deepStrictEqual(callOrdered, ['first', 'second', 'third']); + }); + + it('middlewares errors get skipped & reported', async () => { + const errorToThrow = new Error('error from first middleware'); + const firstMiddleware: Middleware = { + handle: () => { + throw errorToThrow; + }, + }; + const secondMiddleware: Middleware = { + handle: (_, _2, next) => { + return Promise.resolve(new Response('response from second middleware')); + }, + }; + + const router = new Router(); + router.get('/', [firstMiddleware, secondMiddleware]); + + const response = await router.handle(new Request('http://localhost/'), { + sentry: { + // @ts-expect-error incorrect signature but it's fine + captureException(exception) { + // Make sure sentry gets the error + assert.strictEqual(exception, errorToThrow); + }, + }, + }); + assert.strictEqual( + await response.text(), + 'response from second middleware' + ); + }); +}); diff --git a/tests/unit/utils/memo.test.ts b/tests/unit/utils/memo.test.ts new file mode 100644 index 0000000..6cdc2ab --- /dev/null +++ b/tests/unit/utils/memo.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert'; +import { it } from 'node:test'; +import { once } from '../../../src/utils/memo'; + +it('once works', () => { + let callCount = 0; + const getString = once(() => { + callCount++; + return 'asd123'; + }); + + const str = getString(); + assert.strictEqual(str, 'asd123'); + assert.strictEqual(callCount, 1); + + const str2 = getString(); + assert.equal(str, str2); + assert.strictEqual(str2, 'asd123'); + assert.strictEqual(callCount, 1); +}); diff --git a/tests/unit/utils/path.test.ts b/tests/unit/utils/path.test.ts index 0b8a0ba..887280d 100644 --- a/tests/unit/utils/path.test.ts +++ b/tests/unit/utils/path.test.ts @@ -1,179 +1,6 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { - mapUrlPathToBucketPath, - mapBucketPathToUrlPath, - isDirectoryPath, -} from '../../../src/utils/path'; -import { REDIRECT_MAP } from '../../../src/constants/r2Prefixes'; - -describe('mapUrlPathToBucketPath', () => { - it('expects all items in REDIRECT_MAP to be pathes in the length of 3', () => { - // If this test breaks, the code will and we'll need to fix the code - REDIRECT_MAP.forEach((val, key) => { - assert.strictEqual( - key.split('/').length, - 3, - `expected ${key} to be a path with 3 slashes` - ); - }); - }); - - it('converts `/unknown-base-path` to undefined when DIRECTORY_LISTING=restricted', () => { - const result = mapUrlPathToBucketPath('/unknown-base-path', { - DIRECTORY_LISTING: 'restricted', - }); - assert.strictEqual(result, undefined); - }); - - it('converts `/unknown-base-path` to `unknown-base-path` when DIRECTORY_LISTING=on', () => { - const result = mapUrlPathToBucketPath('/unknown-base-path', { - DIRECTORY_LISTING: 'on', - }); - assert.strictEqual(result, 'unknown-base-path'); - }); - - it('converts `/dist` to `nodejs/release`', () => { - const result = mapUrlPathToBucketPath('/dist', { - DIRECTORY_LISTING: 'restricted', - }); - assert.strictEqual(result, 'nodejs/release/'); - }); - - it('converts `/dist/latest` to `nodejs/release/v.X.X.X`', () => { - const result = mapUrlPathToBucketPath('/dist/latest', { - DIRECTORY_LISTING: 'restricted', - }); - assert.match(result ?? '', /^nodejs\/release\/v.\d+\.\d+\.\d+\/$/); - }); - - it('converts `/download` to `nodejs`', () => { - const result = mapUrlPathToBucketPath('/download', { - DIRECTORY_LISTING: 'restricted', - }); - assert.strictEqual(result, 'nodejs/'); - }); - - it('converts `/download/release` to `nodejs/release`', () => { - const result = mapUrlPathToBucketPath('/download/release', { - DIRECTORY_LISTING: 'restricted', - }); - assert.strictEqual(result, 'nodejs/release'); - }); - - it('converts `/download/release/latest` to `nodejs/release/v.X.X.X`', () => { - const result = mapUrlPathToBucketPath('/download/release/latest', { - DIRECTORY_LISTING: 'restricted', - }); - assert.match(result ?? '', /^nodejs\/release\/v.\d+\.\d+\.\d+\/$/); - }); - - it('converts `/docs/latest` to `nodejs/release/v.X.X.X/docs/`', () => { - const result = mapUrlPathToBucketPath('/docs/latest', { - DIRECTORY_LISTING: 'restricted', - }); - assert.match(result ?? '', /^nodejs\/release\/v.\d+\.\d+\.\d+\/docs\/$/); - }); - - it('converts `/api` to `nodejs/release/v.X.X.X/docs/api/`', () => { - const result = mapUrlPathToBucketPath('/api', { - DIRECTORY_LISTING: 'restricted', - }); - assert.match( - result ?? '', - /^nodejs\/release\/v.\d+\.\d+\.\d+\/docs\/api\/$/ - ); - }); - - it('converts `/api/assert.html` to `nodejs/release/v.X.X.X/docs/api/assert.html`', () => { - const result = mapUrlPathToBucketPath('/api/assert.html', { - DIRECTORY_LISTING: 'restricted', - }); - assert.match( - result ?? '', - /^nodejs\/release\/v.\d+\.\d+\.\d+\/docs\/api\/assert\.html$/ - ); - }); -}); - -describe('mapBucketPathToUrlPath', () => { - it('converts `unknown-base-path` to `/unknown-base-path` when DIRECTORY_LISTING=on', () => { - const result = mapBucketPathToUrlPath('unknown-base-path', { - DIRECTORY_LISTING: 'on', - }); - assert.notStrictEqual(result, undefined); - - assert(result!.includes('/unknown-base-path')); - }); - - it('converts `nodejs/release` to `/dist` and `/download/release`', () => { - const result = mapBucketPathToUrlPath('nodejs/release', { - DIRECTORY_LISTING: 'restricted', - }); - assert.notStrictEqual(result, undefined); - - assert(result!.includes('/dist/')); - assert(result!.includes('/download/release/')); - }); - - it('converts `nodejs/release/latest` to `/dist/latest` and `/download/release/latest`', () => { - const result = mapBucketPathToUrlPath('nodejs/release/latest', { - DIRECTORY_LISTING: 'restricted', - }); - assert.notStrictEqual(result, undefined); - - assert(result!.includes('/dist/latest/')); - assert(result!.includes('/download/release/latest/')); - }); - - it('converts `nodejs` to `/download`', () => { - const result = mapBucketPathToUrlPath('nodejs', { - DIRECTORY_LISTING: 'restricted', - }); - assert.notStrictEqual(result, undefined); - - assert(result!.includes('/download')); - }); - - it('converts `nodejs/docs` to `/docs`', () => { - const result = mapBucketPathToUrlPath('nodejs/docs', { - DIRECTORY_LISTING: 'restricted', - }); - assert.notStrictEqual(result, undefined); - - assert(result!.includes('/docs/')); - }); - - it('converts `nodejs/docs/latest` to `/docs/latest`', () => { - const result = mapBucketPathToUrlPath('nodejs/docs/latest', { - DIRECTORY_LISTING: 'restricted', - }); - assert.notStrictEqual(result, undefined); - - assert(result!.includes('/docs/latest/')); - }); - - it('converts `nodejs/docs/latest/api` to `/api` and `/docs/latest/api`', () => { - const result = mapBucketPathToUrlPath('nodejs/docs/latest/api/', { - DIRECTORY_LISTING: 'restricted', - }); - assert.notStrictEqual(result, undefined); - - assert(result!.includes('/api/')); - assert(result!.includes('/docs/latest/api/')); - }); - - it('converts `nodejs/docs/latest/api/assert.html` to `/api/assert.html` and `/docs/latest/api/assert.html`', () => { - const result = mapBucketPathToUrlPath( - 'nodejs/docs/latest/api/assert.html', - { DIRECTORY_LISTING: 'restricted' } - ); - assert.notStrictEqual(result, undefined); - - assert(result!.includes('/api/assert.html')); - assert(result!.includes('/docs/latest/api/assert.html')); - }); -}); +import { isDirectoryPath } from '../../../src/utils/path'; describe('isDirectoryPath', () => { it('returns true for `/dist/`', () => { diff --git a/tests/unit/utils/request.test.ts b/tests/unit/utils/request.test.ts new file mode 100644 index 0000000..bab0398 --- /dev/null +++ b/tests/unit/utils/request.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { parseRangeHeader } from '../../../src/utils/request'; + +describe('parseRangeHeader', () => { + it('`bytes=0-10`', () => { + const result = parseRangeHeader('bytes=0-10'); + assert.notStrictEqual(result, undefined); + + assert.strictEqual(result.offset, 0); + assert.strictEqual(result.length, 11); + }); + + it('`bytes=0-10, 15-20, 20-30`', () => { + const result = parseRangeHeader('bytes=0-10, 15-20, 20-30'); + assert.notStrictEqual(result, undefined); + + assert.strictEqual(result.offset, 0); + assert.strictEqual(result.length, 11); + }); + + it('`bytes=0-`', () => { + const result = parseRangeHeader('bytes=0-'); + assert.notStrictEqual(result, undefined); + + assert.strictEqual(result.offset, 0); + assert.strictEqual(result.length, undefined); + }); + + it('`bytes=-10`', () => { + const result = parseRangeHeader('bytes=-10'); + assert.notStrictEqual(result, undefined); + + assert.strictEqual(result.suffix, 10); + }); + + it('`bytes=-`', () => { + const result = parseRangeHeader('bytes=-'); + assert.strictEqual(result, undefined); + }); + + it('`some-other-unit=-`', () => { + const result = parseRangeHeader('some-other-unit=-'); + assert.strictEqual(result, undefined); + }); + + it('`bytes=10-0`', () => { + const result = parseRangeHeader('bytes=10-0'); + assert.strictEqual(result, undefined); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index 3b61b80..45cb9aa 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -9,12 +9,7 @@ logpush = true workers_dev = true ENVIRONMENT = 'dev' S3_ENDPOINT = 'https://07be8d2fbc940503ca1be344714cb0d1.r2.cloudflarestorage.com' -DIRECTORY_LISTING = 'on' -FILE_CACHE_CONTROL = 'public, max-age=3600, s-maxage=14400' -DIRECTORY_CACHE_CONTROL = 'public, max-age=3600, s-maxage=14400' BUCKET_NAME = 'dist-prod' -USE_FALLBACK_WHEN_R2_FAILS = false -FALLBACK_HOST = 'https://origin.nodejs.org' ORIGIN_HOST = 'https://origin.nodejs.org' [[r2_buckets]] @@ -28,12 +23,7 @@ bucket_name = 'dist-prod' workers_dev = true ENVIRONMENT = 'staging' S3_ENDPOINT = 'https://07be8d2fbc940503ca1be344714cb0d1.r2.cloudflarestorage.com' -DIRECTORY_LISTING = 'restricted' -FILE_CACHE_CONTROL = 'public, max-age=3600, s-maxage=14400' -DIRECTORY_CACHE_CONTROL = 'public, max-age=3600, s-maxage=14400' BUCKET_NAME = 'dist-prod' -USE_FALLBACK_WHEN_R2_FAILS = true -FALLBACK_HOST = 'https://origin.nodejs.org' ORIGIN_HOST = 'https://origin.nodejs.org' [[env.staging.r2_buckets]] @@ -47,12 +37,7 @@ bucket_name = 'dist-prod' workers_dev = false ENVIRONMENT = 'prod' S3_ENDPOINT = 'https://07be8d2fbc940503ca1be344714cb0d1.r2.cloudflarestorage.com' -DIRECTORY_LISTING = 'restricted' -FILE_CACHE_CONTROL = 'public, max-age=3600, s-maxage=14400' -DIRECTORY_CACHE_CONTROL = 'public, max-age=3600, s-maxage=14400' BUCKET_NAME='dist-prod' -USE_FALLBACK_WHEN_R2_FAILS = true -FALLBACK_HOST = 'https://origin.nodejs.org' ORIGIN_HOST = 'https://origin.nodejs.org' [[env.prod.r2_buckets]]