Skip to content

Commit 76478b8

Browse files
committed
Automatically redirect unresolved versions in unpkg demo
1 parent a030481 commit 76478b8

File tree

7 files changed

+219
-30
lines changed

7 files changed

+219
-30
lines changed

demos/unpkg/README.md

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,12 @@ pnpm start
1212

1313
Then visit http://localhost:44100
1414

15-
Try browsing packages:
16-
17-
- http://localhost:44100/lodash
18-
- http://localhost:44100/react@18
19-
- http://localhost:44100/@remix-run/cookie
20-
- http://localhost:44100/express/package.json
15+
Semver ranges are supported (via the [semver](https://www.npmjs.com/package/semver) package). URLs without a version, or with partial/range versions, automatically redirect to the fully resolved version.
2116

2217
## Code Highlights
2318

2419
- [`app/routes.ts`](app/routes.ts) uses a single `/*path` route to handle all package URLs. This one pattern handles package names, versions, scoped packages, and file paths.
2520
- [`app/lib/npm.ts`](app/lib/npm.ts) fetches package tarballs from npm, decompresses them with `node:zlib`, and parses them using `@remix-run/tar-parser`. The `parsePackagePath()` function handles the tricky parsing of URLs like `/@remix-run/[email protected]/src/index.ts`.
26-
- Also in [`app/lib/npm.ts`](app/lib/npm.ts), `resolveVersion()` handles dist-tags like `latest`, exact versions, and partial versions (e.g., `18` resolves to `18.3.1`).
21+
- Also in [`app/lib/npm.ts`](app/lib/npm.ts), `resolveVersion()` handles dist-tags like `latest`, exact versions, partial versions (e.g., `18` resolves to `18.3.1`), and semver ranges (e.g., `^18.2` or `~1.0.0`).
2722
- [`app/lib/cache.ts`](app/lib/cache.ts) caches decompressed tarballs to the temp directory using `@remix-run/file-storage`. This avoids re-downloading packages on repeated requests.
2823
- [`app/lib/render.ts`](app/lib/render.ts) uses the `html` template tag from `@remix-run/html-template` for safe HTML generation with automatic XSS escaping.

demos/unpkg/app/lib/npm.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, it } from 'node:test'
44
import {
55
parsePackagePath,
66
resolveVersion,
7+
isFullyResolvedVersion,
78
getFilesAtPath,
89
PackageNotFoundError,
910
VersionNotFoundError,
@@ -76,6 +77,21 @@ describe('parsePackagePath', () => {
7677
name: 'InvalidPathError',
7778
})
7879
})
80+
81+
it('parses URL-encoded caret semver range', () => {
82+
let result = parsePackagePath('react@%5E18.2')
83+
assert.deepEqual(result, { name: 'react', version: '^18.2', filePath: '' })
84+
})
85+
86+
it('parses URL-encoded tilde semver range', () => {
87+
let result = parsePackagePath('lodash@%7E4.17')
88+
assert.deepEqual(result, { name: 'lodash', version: '~4.17', filePath: '' })
89+
})
90+
91+
it('parses unencoded semver range', () => {
92+
let result = parsePackagePath('react@^18.2')
93+
assert.deepEqual(result, { name: 'react', version: '^18.2', filePath: '' })
94+
})
7995
})
8096

8197
describe('resolveVersion', () => {
@@ -150,6 +166,73 @@ describe('resolveVersion', () => {
150166
name: 'VersionNotFoundError',
151167
})
152168
})
169+
170+
it('resolves caret range to highest matching version', () => {
171+
assert.equal(resolveVersion(mockMetadata, '^1.0.0'), '1.1.0')
172+
})
173+
174+
it('resolves tilde range to highest matching version', () => {
175+
assert.equal(resolveVersion(mockMetadata, '~1.0.0'), '1.0.1')
176+
})
177+
178+
it('resolves greater-than-or-equal range', () => {
179+
assert.equal(resolveVersion(mockMetadata, '>=1.0.0'), '2.0.0')
180+
})
181+
182+
it('resolves complex semver range', () => {
183+
assert.equal(resolveVersion(mockMetadata, '>=1.0.0 <2.0.0'), '1.1.0')
184+
})
185+
186+
it('throws for semver range with no matching version', () => {
187+
assert.throws(() => resolveVersion(mockMetadata, '^5.0.0'), {
188+
name: 'VersionNotFoundError',
189+
})
190+
})
191+
})
192+
193+
describe('isFullyResolvedVersion', () => {
194+
let mockMetadata: PackageMetadata = {
195+
name: 'test-package',
196+
'dist-tags': {
197+
latest: '2.0.0',
198+
},
199+
versions: {
200+
'1.0.0': {
201+
name: 'test-package',
202+
version: '1.0.0',
203+
dist: { tarball: 'http://example.com/1.0.0.tgz', shasum: 'abc' },
204+
},
205+
'2.0.0': {
206+
name: 'test-package',
207+
version: '2.0.0',
208+
dist: { tarball: 'http://example.com/2.0.0.tgz', shasum: 'def' },
209+
},
210+
},
211+
}
212+
213+
it('returns true for exact version in versions', () => {
214+
assert.equal(isFullyResolvedVersion(mockMetadata, '1.0.0'), true)
215+
assert.equal(isFullyResolvedVersion(mockMetadata, '2.0.0'), true)
216+
})
217+
218+
it('returns false for dist-tag', () => {
219+
assert.equal(isFullyResolvedVersion(mockMetadata, 'latest'), false)
220+
})
221+
222+
it('returns false for partial version', () => {
223+
assert.equal(isFullyResolvedVersion(mockMetadata, '1'), false)
224+
assert.equal(isFullyResolvedVersion(mockMetadata, '1.0'), false)
225+
})
226+
227+
it('returns false for semver range', () => {
228+
assert.equal(isFullyResolvedVersion(mockMetadata, '^1.0.0'), false)
229+
assert.equal(isFullyResolvedVersion(mockMetadata, '~1.0.0'), false)
230+
assert.equal(isFullyResolvedVersion(mockMetadata, '>=1.0.0'), false)
231+
})
232+
233+
it('returns false for non-existent version', () => {
234+
assert.equal(isFullyResolvedVersion(mockMetadata, '3.0.0'), false)
235+
})
153236
})
154237

155238
describe('getFilesAtPath', () => {

demos/unpkg/app/lib/npm.ts

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as zlib from 'node:zlib'
22
import { parseTar, type TarEntry } from '@remix-run/tar-parser'
3+
import * as semver from 'semver'
34

45
import { tarballCache, getTarballCacheKey } from './cache.ts'
56

@@ -52,11 +53,19 @@ export async function fetchPackageMetadata(packageName: string): Promise<Package
5253
return response.json()
5354
}
5455

56+
/**
57+
* Check if a version specifier is fully resolved (an exact version that exists).
58+
*/
59+
export function isFullyResolvedVersion(metadata: PackageMetadata, specifier: string): boolean {
60+
return specifier in metadata.versions
61+
}
62+
5563
/**
5664
* Resolve a version specifier to a concrete version.
5765
* Supports:
5866
* - Exact versions: "1.2.3"
5967
* - Partial versions: "1", "1.2" -> matches highest in range
68+
* - Semver ranges: "^1.2.0", "~1.2.0", ">=1.0.0 <2.0.0"
6069
* - Dist tags: "latest", "beta"
6170
*/
6271
export function resolveVersion(metadata: PackageMetadata, specifier: string): string {
@@ -70,33 +79,27 @@ export function resolveVersion(metadata: PackageMetadata, specifier: string): st
7079
return specifier
7180
}
7281

73-
// Try to match as a partial version (e.g., "18" matches "18.3.1")
7482
let versions = Object.keys(metadata.versions)
75-
let matching = versions.filter((v) => v.startsWith(specifier + '.') || v === specifier)
7683

77-
if (matching.length > 0) {
78-
// Sort by semver and return highest
79-
matching.sort(compareSemver)
80-
return matching[matching.length - 1]
84+
// Try to match as a semver range (^1.2.0, ~1.0.0, >=1.0.0, etc.)
85+
if (semver.validRange(specifier)) {
86+
let match = semver.maxSatisfying(versions, specifier)
87+
if (match) {
88+
return match
89+
}
8190
}
8291

83-
throw new VersionNotFoundError(metadata.name, specifier)
84-
}
85-
86-
/**
87-
* Compare two semver strings for sorting.
88-
*/
89-
function compareSemver(a: string, b: string): number {
90-
let partsA = a.split('.').map((p) => parseInt(p, 10) || 0)
91-
let partsB = b.split('.').map((p) => parseInt(p, 10) || 0)
92-
93-
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
94-
let numA = partsA[i] || 0
95-
let numB = partsB[i] || 0
96-
if (numA !== numB) return numA - numB
92+
// Try to match as a partial version (e.g., "18" matches "18.3.1")
93+
// Convert partial version to a range: "18" -> "18.x", "18.2" -> "18.2.x"
94+
let partialRange = specifier + '.x'
95+
if (semver.validRange(partialRange)) {
96+
let match = semver.maxSatisfying(versions, partialRange)
97+
if (match) {
98+
return match
99+
}
97100
}
98101

99-
return 0
102+
throw new VersionNotFoundError(metadata.name, specifier)
100103
}
101104

102105
/**
@@ -282,13 +285,16 @@ export function getFilesAtPath(files: Map<string, PackageFile>, dirPath: string)
282285
* "lodash@4/package.json" -> { name: "lodash", version: "4", filePath: "package.json" }
283286
* "@remix-run/cookie" -> { name: "@remix-run/cookie", version: "latest", filePath: "" }
284287
* "@remix-run/[email protected]/src/index.ts" -> { name: "@remix-run/cookie", version: "1.0.0", filePath: "src/index.ts" }
288+
* "react@^18.2" -> { name: "react", version: "^18.2", filePath: "" } (semver range)
285289
*/
286290
export function parsePackagePath(path: string): {
287291
name: string
288292
version: string
289293
filePath: string
290294
} {
291-
let parts = path.split('/')
295+
// Decode URL-encoded characters (e.g., %5E -> ^, %7E -> ~)
296+
let decodedPath = decodeURIComponent(path)
297+
let parts = decodedPath.split('/')
292298
let name: string
293299
let rest: string[]
294300

demos/unpkg/app/pages/browse.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import { lookup } from 'mrmime'
55
import { html, render, formatBytes, icons } from '../lib/render.ts'
66
import {
77
parsePackagePath,
8+
fetchPackageMetadata,
89
fetchPackageContents,
910
getFilesAtPath,
11+
isFullyResolvedVersion,
12+
resolveVersion,
1013
PackageNotFoundError,
1114
VersionNotFoundError,
1215
InvalidPathError,
@@ -27,6 +30,17 @@ export async function browseHandler(
2730

2831
try {
2932
let { name, version, filePath } = parsePackagePath(path)
33+
34+
// Fetch metadata first to check if version needs resolution
35+
let metadata = await fetchPackageMetadata(name)
36+
37+
// If the version is not fully resolved, redirect to the resolved version URL
38+
if (!isFullyResolvedVersion(metadata, version)) {
39+
let resolvedVersion = resolveVersion(metadata, version)
40+
let redirectUrl = `/${name}@${resolvedVersion}${filePath ? '/' + filePath : ''}`
41+
return redirect(redirectUrl)
42+
}
43+
3044
let contents = await fetchPackageContents(name, version)
3145
let resolvedVersion = contents.metadata.version
3246

demos/unpkg/app/router.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,87 @@ describe('router', () => {
108108
assert.equal(response.status, 404)
109109
})
110110
})
111+
112+
describe('version redirects', () => {
113+
it('redirects package without version to latest', async () => {
114+
let response = await router.fetch(new Request('http://localhost/is-number'))
115+
116+
assert.equal(response.status, 302)
117+
assert.equal(response.headers.get('Location'), '/[email protected]')
118+
})
119+
120+
it('redirects dist-tag "latest" to resolved version', async () => {
121+
let response = await router.fetch(new Request('http://localhost/is-number@latest'))
122+
123+
assert.equal(response.status, 302)
124+
assert.equal(response.headers.get('Location'), '/[email protected]')
125+
})
126+
127+
it('redirects partial major version to highest match', async () => {
128+
let response = await router.fetch(new Request('http://localhost/is-number@1'))
129+
130+
assert.equal(response.status, 302)
131+
assert.equal(response.headers.get('Location'), '/[email protected]')
132+
})
133+
134+
it('redirects partial major.minor version to highest match', async () => {
135+
let response = await router.fetch(new Request('http://localhost/[email protected]'))
136+
137+
assert.equal(response.status, 302)
138+
assert.equal(response.headers.get('Location'), '/[email protected]')
139+
})
140+
141+
it('redirects caret semver range to highest compatible version', async () => {
142+
let response = await router.fetch(new Request('http://localhost/is-number@^1.0.0'))
143+
144+
assert.equal(response.status, 302)
145+
assert.equal(response.headers.get('Location'), '/[email protected]')
146+
})
147+
148+
it('redirects tilde semver range to highest patch version', async () => {
149+
let response = await router.fetch(new Request('http://localhost/is-number@~1.1.0'))
150+
151+
assert.equal(response.status, 302)
152+
assert.equal(response.headers.get('Location'), '/[email protected]')
153+
})
154+
155+
it('redirects URL-encoded caret range', async () => {
156+
// %5E is URL-encoded ^
157+
let response = await router.fetch(new Request('http://localhost/is-number@%5E2.0.0'))
158+
159+
assert.equal(response.status, 302)
160+
assert.equal(response.headers.get('Location'), '/[email protected]')
161+
})
162+
163+
it('redirects URL-encoded tilde range', async () => {
164+
// %7E is URL-encoded ~
165+
let response = await router.fetch(new Request('http://localhost/is-number@%7E2.0.0'))
166+
167+
assert.equal(response.status, 302)
168+
assert.equal(response.headers.get('Location'), '/[email protected]')
169+
})
170+
171+
it('redirects complex semver range', async () => {
172+
let response = await router.fetch(new Request('http://localhost/is-number@>=1.0.0 <2.0.0'))
173+
174+
assert.equal(response.status, 302)
175+
assert.equal(response.headers.get('Location'), '/[email protected]')
176+
})
177+
178+
it('preserves file path in redirect', async () => {
179+
let response = await router.fetch(new Request('http://localhost/is-number@^7/package.json'))
180+
181+
assert.equal(response.status, 302)
182+
assert.equal(response.headers.get('Location'), '/[email protected]/package.json')
183+
})
184+
185+
it('does not redirect fully resolved version', async () => {
186+
let response = await router.fetch(new Request('http://localhost/[email protected]'))
187+
188+
// Should return 200 (directory listing), not a redirect
189+
assert.equal(response.status, 200)
190+
let text = await response.text()
191+
assert.ok(text.includes('is-number'))
192+
})
193+
})
111194
})

demos/unpkg/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
"@remix-run/node-fetch-server": "workspace:*",
1111
"@remix-run/response": "workspace:*",
1212
"@remix-run/tar-parser": "workspace:*",
13-
"mrmime": "^2.0.0"
13+
"mrmime": "^2.0.0",
14+
"semver": "^7.6.3"
1415
},
1516
"devDependencies": {
1617
"@types/node": "^24.6.0",
18+
"@types/semver": "^7.5.8",
1719
"tsx": "^4.20.6"
1820
},
1921
"scripts": {

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)