From 6dd1cb8aa6aa59741ca83e0ba925b59d078ebbda Mon Sep 17 00:00:00 2001 From: Mridul Tailor Date: Wed, 18 Feb 2026 00:31:00 -0700 Subject: [PATCH] Implement a version sorting utility (sort versions in descending order) and apply it to library version retrieval --- package-lock.json | 105 +++++++++++++--------------------------- package.json | 3 +- src/utils/kvMetadata.js | 3 +- src/utils/sort.js | 47 ++++++++++++++++++ src/utils/sort.spec.js | 76 +++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 74 deletions(-) create mode 100644 src/utils/sort.js create mode 100644 src/utils/sort.spec.js diff --git a/package-lock.json b/package-lock.json index 74a4ba5..6fc1088 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "hono": "^4.11.7", "is-deflate": "^1.0.0", "is-gzip": "^2.0.0", - "pako": "^2.1.0" + "pako": "^2.1.0", + "semver": "^7.7.4" }, "devDependencies": { "@babel/core": "^7.29.0", @@ -183,7 +184,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2420,7 +2420,6 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -2436,7 +2435,6 @@ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -2479,7 +2477,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2768,7 +2765,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3206,6 +3202,16 @@ "editorconfig": "bin/editorconfig" } }, + "node_modules/editorconfig/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -3439,7 +3445,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3668,18 +3673,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -5278,7 +5271,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5623,12 +5615,15 @@ } }, "node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", "bin": { - "semver": "bin/semver" + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/set-function-length": { @@ -5721,18 +5716,6 @@ "@img/sharp-win32-x64": "0.34.5" } }, - "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6266,7 +6249,6 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -6316,7 +6298,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6415,7 +6396,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -6606,7 +6586,6 @@ "integrity": "sha512-EhLJGptSGFi8AEErLiamO3PoGpbRqL+v4Ve36H2B38VxmDgFOSmDhfepBnA14sCQzGf1AEaoZX2DCwZsmO74yQ==", "dev": true, "hasInstallScript": true, - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -6894,7 +6873,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, - "peer": true, "requires": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -8151,7 +8129,6 @@ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, - "peer": true, "requires": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -8163,7 +8140,6 @@ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, - "peer": true, "requires": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -8194,8 +8170,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "peer": true + "dev": true }, "acorn-jsx": { "version": "5.3.2", @@ -8398,7 +8373,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, - "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8694,6 +8668,14 @@ "lru-cache": "^4.1.5", "semver": "^5.6.0", "sigmund": "^1.0.1" + }, + "dependencies": { + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } } }, "electron-to-chromium": { @@ -8880,7 +8862,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, - "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9127,12 +9108,6 @@ "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.0" } - }, - "semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true } } }, @@ -10169,8 +10144,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "peer": true + "dev": true }, "pkg-config": { "version": "1.1.1", @@ -10383,10 +10357,9 @@ } }, "semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==" }, "set-function-length": { "version": "1.2.2", @@ -10458,14 +10431,6 @@ "@img/sharp-win32-x64": "0.34.5", "detect-libc": "^2.1.2", "semver": "^7.7.3" - }, - "dependencies": { - "semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true - } } }, "shebang-command": { @@ -10850,7 +10815,6 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, - "peer": true, "requires": { "pathe": "^2.0.3" } @@ -10879,7 +10843,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, - "peer": true, "requires": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10908,7 +10871,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, - "peer": true, "requires": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -11020,7 +10982,6 @@ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260128.0.tgz", "integrity": "sha512-EhLJGptSGFi8AEErLiamO3PoGpbRqL+v4Ve36H2B38VxmDgFOSmDhfepBnA14sCQzGf1AEaoZX2DCwZsmO74yQ==", "dev": true, - "peer": true, "requires": { "@cloudflare/workerd-darwin-64": "1.20260128.0", "@cloudflare/workerd-darwin-arm64": "1.20260128.0", diff --git a/package.json b/package.json index c7ae361..2f2755b 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "hono": "^4.11.7", "is-deflate": "^1.0.0", "is-gzip": "^2.0.0", - "pako": "^2.1.0" + "pako": "^2.1.0", + "semver": "^7.7.4" }, "devDependencies": { "@babel/core": "^7.29.0", diff --git a/src/utils/kvMetadata.js b/src/utils/kvMetadata.js index 62ed194..7f3cbab 100644 --- a/src/utils/kvMetadata.js +++ b/src/utils/kvMetadata.js @@ -2,6 +2,7 @@ import * as Sentry from '@sentry/cloudflare'; import { env } from 'cloudflare:workers'; import fetchJson from './fetchJson.js'; +import sortVersions from './sort.js'; const kvBase = env.METADATA_BASE || 'https://metadata.speedcdnjs.com'; @@ -59,7 +60,7 @@ export const library = name => fetchJson(`${kvBase}/packages/${encodeURIComponen * @param {string} name Name of the library to fetch. * @return {Promise} */ -export const libraryVersions = name => fetchJson(`${kvBase}/packages/${encodeURIComponent(name)}/versions`); +export const libraryVersions = name => fetchJson(`${kvBase}/packages/${encodeURIComponent(name)}/versions`).then(sortVersions); /** * Get the assets for a library version. diff --git a/src/utils/sort.js b/src/utils/sort.js new file mode 100644 index 0000000..237dcd5 --- /dev/null +++ b/src/utils/sort.js @@ -0,0 +1,47 @@ +import semver from 'semver'; + +/** + * Sort a list of versions in descending order (newest first). + * Handles both valid semver and non-semver version strings. + * + * @param {string[]} versions Array of version strings. + * @return {string[]} Sorted array of version strings. + */ +export default versions => [ ...versions ].sort((a, b) => { + // Attempt to parse properly as semver + // semver.coerce returns a SemVer object or null. semver.clean expects a string. + // We need to be careful. + // Check if original strings are valid semver + const aValid = semver.valid(a); + const bValid = semver.valid(b); + + // If both are valid semver, compare them + if (aValid && bValid) { + return semver.rcompare(a, b); + } + + // If one is valid and the other isn't, valid wins + if (aValid && !bValid) return -1; + if (!aValid && bValid) return 1; + + // Both are invalid semver strings, but maybe they can be coerced? + const aSem = semver.coerce(a); + const bSem = semver.coerce(b); + + if (aSem && bSem) { + // Both coerced successfully, compare coerced versions. + // This allows "2.0rc1" (coerced to 2.0.0) to sort before "1.0.0a" (coerced to 1.0.0). + // Note: We deliberately prioritize VALID semver over COERCED/INVALID semver above. + // So '1.0.0' (valid) comes before '2.0rc1' (invalid/coerced), even though 2.0.0 > 1.0.0. + return semver.rcompare(aSem, bSem); + } + + // If one coerces and the other doesn't, prioritize the coerced one + // e.g. 2.0rc1 vs "latest" + if (aSem && !bSem) return -1; + if (!aSem && bSem) return 1; + + // Fallback to string comparison for non-semver or mixed cases + // Use numeric collation so 1.10 > 1.9 + return b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }); +}); diff --git a/src/utils/sort.spec.js b/src/utils/sort.spec.js new file mode 100644 index 0000000..f9686d7 --- /dev/null +++ b/src/utils/sort.spec.js @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; + +import sortVersions from './sort.js'; + +describe('utils/sort', () => { + it('does not mutate the input array', () => { + const versions = [ '1.0.0', '2.1.3', '0.5.0' ]; + const input = [ ...versions ]; + sortVersions(input); + expect(input).toEqual(versions); + }); + + it('sorts semver versions in descending order', () => { + const versions = [ '1.0.0', '2.1.3', '0.5.0' ]; + const sorted = sortVersions([ ...versions ]); + expect(sorted).toEqual([ '2.1.3', '1.0.0', '0.5.0' ]); + }); + + it('sorts pre-release versions correctly', () => { + const versions = [ '1.0.0', '1.0.0-beta', '1.0.0-alpha' ]; + const sorted = sortVersions([ ...versions ]); + expect(sorted).toEqual([ '1.0.0', '1.0.0-beta', '1.0.0-alpha' ]); + }); + + it('sorts complex pre-release versions correctly', () => { + // This relies on string comparison for pre-release tags like "beta2" vs "beta1". + // "beta10" would incorrectly come before "beta2" because it's a string compare. + const versions = [ '11.0.1', '11.0.0', '11.0.0-beta2', '11.0.0-beta1' ]; + const sorted = sortVersions([ ...versions ]); + expect(sorted).toEqual([ '11.0.1', '11.0.0', '11.0.0-beta2', '11.0.0-beta1' ]); + }); + + + it('sorts non-semver versions gracefully', () => { + const versions = [ '1.0.0a', '2.0rc1', '1.0.0' ]; + const sorted = sortVersions([ ...versions ]); + // 1.0.0 is valid semver. The others are not. + // Logic: Valid semver > Coerced semver. + // 1.0.0 comes first (valid). + // 2.0rc1 (coerces to 2.0.0) comes before 1.0.0a (coerces to 1.0.0). + expect(sorted).toEqual([ '1.0.0', '2.0rc1', '1.0.0a' ]); + }); + + it('sorts mixed semver and non-semver versions', () => { + const versions = [ '1.0.0', 'not-a-version', '2.0.0' ]; + const sorted = sortVersions([ ...versions ]); + expect(sorted).toEqual([ '2.0.0', '1.0.0', 'not-a-version' ]); // Assuming valid semver takes precedence or falls back safely + }); + + it('handles empty arrays', () => { + const versions = []; + const sorted = sortVersions([ ...versions ]); + expect(sorted).toEqual([]); + }); + + it('handles single item arrays', () => { + const versions = [ '1.0.0' ]; + const sorted = sortVersions([ ...versions ]); + expect(sorted).toEqual([ '1.0.0' ]); + }); + it('sorts numeric-like strings that are not strict semver', () => { + // These strings are coerced to valid semver (1.9.0, 1.10.0) + // so they are sorted by semver.rcompare, NOT localeCompare. + const versions = [ '1.9', '1.10' ]; + const sorted = sortVersions([ ...versions ]); + expect(sorted).toEqual([ '1.10', '1.9' ]); + }); + + it('sorts truly non-semver strings using numeric localeCompare', () => { + // These strings cannot be coerced to semver, so they strictly use localeCompare + const versions = [ 'release-1.9', 'release-1.10' ]; + const sorted = sortVersions([ ...versions ]); + // numeric: true should make release-1.10 > release-1.9 + expect(sorted).toEqual([ 'release-1.10', 'release-1.9' ]); + }); +});