Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 31 additions & 48 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/utils/kvMetadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -59,7 +60,7 @@ export const library = name => fetchJson(`${kvBase}/packages/${encodeURIComponen
* @param {string} name Name of the library to fetch.
* @return {Promise<string[]>}
*/
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.
Expand Down
47 changes: 47 additions & 0 deletions src/utils/sort.js
Original file line number Diff line number Diff line change
@@ -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' });
});
76 changes: 76 additions & 0 deletions src/utils/sort.spec.js
Original file line number Diff line number Diff line change
@@ -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' ]);
});
});
Loading