diff --git a/package-lock.json b/package-lock.json index 7854d0c..1630ad5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "hono": "^4.11.10", "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", @@ -3201,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", @@ -3662,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", @@ -5616,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": { @@ -5714,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", @@ -8678,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": { @@ -9110,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 } } }, @@ -10365,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", @@ -10440,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": { diff --git a/package.json b/package.json index f3db2d7..baf336f 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "hono": "^4.11.10", "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' ]); + }); +});