From 0b4b070f24f97f2c6a21603f09dd4882c8cdbb8a Mon Sep 17 00:00:00 2001 From: johnnyreilly Date: Tue, 26 May 2026 10:46:15 +0100 Subject: [PATCH 1/3] feat: support npmjs versions redirect --- .../middleware/canonical-redirects.global.ts | 23 +++++++++++++++++-- test/e2e/url-compatibility.spec.ts | 19 +++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/server/middleware/canonical-redirects.global.ts b/server/middleware/canonical-redirects.global.ts index e9139a8493..2361fe1de1 100644 --- a/server/middleware/canonical-redirects.global.ts +++ b/server/middleware/canonical-redirects.global.ts @@ -36,13 +36,32 @@ const pages = [ const cacheControl = 's-maxage=3600, stale-while-revalidate=36000' export default defineEventHandler(async event => { + const [path = '/', query] = event.path.split('?') + + // /package/name?activeTab=versions → /package/name/versions + // /package/@scope/name?activeTab=versions → /package/@scope/name/versions + if (query) { + const params = new URLSearchParams(query) + if (params.get('activeTab') === 'versions') { + const pkgPathMatch = path.match(/^\/package\/((?:@[^/]+\/)?[^/]+)$/) + if (pkgPathMatch) { + params.delete('activeTab') + const remaining = params.toString() + setHeader(event, 'cache-control', cacheControl) + return sendRedirect( + event, + `/package/${pkgPathMatch[1]}/versions` + (remaining ? '?' + remaining : ''), + 301, + ) + } + } + } + const routeRules = getRouteRules(event) if (Object.keys(routeRules).length > 1) { return } - const [path = '/', query] = event.path.split('?') - // username if (path.startsWith('/~') || path.startsWith('/_')) { return diff --git a/test/e2e/url-compatibility.spec.ts b/test/e2e/url-compatibility.spec.ts index ce5e403bcd..f252e842db 100644 --- a/test/e2e/url-compatibility.spec.ts +++ b/test/e2e/url-compatibility.spec.ts @@ -120,6 +120,25 @@ test.describe('npmjs.com URL Compatibility', () => { }) }) + test.describe('npmjs.com activeTab=versions Compatibility', () => { + test('/package/vue?activeTab=versions → /package/vue/versions', async ({ page, goto }) => { + await goto('/package/vue?activeTab=versions', { waitUntil: 'domcontentloaded' }) + + await expect(page).toHaveURL(/\/package\/vue\/versions$/) + await expect(page.locator('h1')).toContainText('vue') + }) + + test('/package/@nuxt/kit?activeTab=versions → /package/@nuxt/kit/versions', async ({ + page, + goto, + }) => { + await goto('/package/@nuxt/kit?activeTab=versions', { waitUntil: 'domcontentloaded' }) + + await expect(page).toHaveURL(/\/package\/@nuxt\/kit\/versions$/) + await expect(page.locator('h1')).toContainText('@nuxt/kit') + }) + }) + test.describe('Edge Cases', () => { test('package name with dots: /package/lodash.merge', async ({ page, goto }) => { await goto('/package/lodash.merge', { waitUntil: 'domcontentloaded' }) From 18e62582e1fcf74951acec45b3367a7eb5f67d1f Mon Sep 17 00:00:00 2001 From: johnnyreilly Date: Tue, 26 May 2026 10:59:20 +0100 Subject: [PATCH 2/3] fix: header text in test --- test/e2e/url-compatibility.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/url-compatibility.spec.ts b/test/e2e/url-compatibility.spec.ts index f252e842db..4c66f849dc 100644 --- a/test/e2e/url-compatibility.spec.ts +++ b/test/e2e/url-compatibility.spec.ts @@ -125,7 +125,7 @@ test.describe('npmjs.com URL Compatibility', () => { await goto('/package/vue?activeTab=versions', { waitUntil: 'domcontentloaded' }) await expect(page).toHaveURL(/\/package\/vue\/versions$/) - await expect(page.locator('h1')).toContainText('vue') + await expect(page.locator('h1')).toContainText('Version History') }) test('/package/@nuxt/kit?activeTab=versions → /package/@nuxt/kit/versions', async ({ @@ -135,7 +135,7 @@ test.describe('npmjs.com URL Compatibility', () => { await goto('/package/@nuxt/kit?activeTab=versions', { waitUntil: 'domcontentloaded' }) await expect(page).toHaveURL(/\/package\/@nuxt\/kit\/versions$/) - await expect(page.locator('h1')).toContainText('@nuxt/kit') + await expect(page.locator('h1')).toContainText('Version History') }) }) From f5a29748bee0e36ee49ee3fe54c7339ff9c7d6ba Mon Sep 17 00:00:00 2001 From: johnnyreilly Date: Thu, 28 May 2026 08:42:12 +0100 Subject: [PATCH 3/3] refactor: code review comment migrate to switch --- .../middleware/canonical-redirects.global.ts | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/server/middleware/canonical-redirects.global.ts b/server/middleware/canonical-redirects.global.ts index 2361fe1de1..89f1d2a813 100644 --- a/server/middleware/canonical-redirects.global.ts +++ b/server/middleware/canonical-redirects.global.ts @@ -38,21 +38,26 @@ const cacheControl = 's-maxage=3600, stale-while-revalidate=36000' export default defineEventHandler(async event => { const [path = '/', query] = event.path.split('?') - // /package/name?activeTab=versions → /package/name/versions - // /package/@scope/name?activeTab=versions → /package/@scope/name/versions if (query) { const params = new URLSearchParams(query) - if (params.get('activeTab') === 'versions') { - const pkgPathMatch = path.match(/^\/package\/((?:@[^/]+\/)?[^/]+)$/) - if (pkgPathMatch) { - params.delete('activeTab') - const remaining = params.toString() - setHeader(event, 'cache-control', cacheControl) - return sendRedirect( - event, - `/package/${pkgPathMatch[1]}/versions` + (remaining ? '?' + remaining : ''), - 301, - ) + + switch (params.get('activeTab')) { + case 'versions': { + // /package/name?activeTab=versions → /package/name/versions + // /package/@scope/name?activeTab=versions → /package/@scope/name/versions + + const pkgPathMatch = path.match(/^\/package\/((?:@[^/]+\/)?[^/]+)$/) + if (pkgPathMatch) { + params.delete('activeTab') + const remaining = params.toString() + setHeader(event, 'cache-control', cacheControl) + return sendRedirect( + event, + `/package/${pkgPathMatch[1]}/versions` + (remaining ? '?' + remaining : ''), + 301, + ) + } + break } } }