|
| 1 | +import semver from 'semver' |
| 2 | +import { respondWithError } from '../respondWithError.ts' |
| 3 | +import { rewriteContent } from '../rewriteContent.ts' |
| 4 | +import { Handler, HandlerContext } from '../types.ts' |
| 5 | + |
| 6 | +function parseUrl(url: URL) { |
| 7 | + const pieces = url.pathname.split('/').slice(2) |
| 8 | + const isScoped = pieces[0].startsWith('@') |
| 9 | + |
| 10 | + let name: string |
| 11 | + let version: string | null = null |
| 12 | + let submodulePath: string |
| 13 | + |
| 14 | + if (isScoped) { |
| 15 | + const includesVersion = pieces[1].includes('@') |
| 16 | + |
| 17 | + if (includesVersion) { |
| 18 | + version = pieces[1].split('@')[1] |
| 19 | + pieces[1] = pieces[1].split('@')[0] |
| 20 | + name = pieces.slice(0, 2).join('/') |
| 21 | + } else { |
| 22 | + name = pieces.slice(0, 2).join('/') |
| 23 | + } |
| 24 | + |
| 25 | + submodulePath = '/' + pieces.slice(2).join('/') |
| 26 | + } else { |
| 27 | + const includesVersion = pieces[0].includes('@') |
| 28 | + |
| 29 | + if (includesVersion) { |
| 30 | + name = pieces[0].split('@')[0] |
| 31 | + version = pieces[0].split('@')[1] |
| 32 | + } else { |
| 33 | + name = pieces[0] |
| 34 | + } |
| 35 | + |
| 36 | + submodulePath = '/' + pieces.slice(1).join('/') |
| 37 | + } |
| 38 | + |
| 39 | + return { |
| 40 | + name, |
| 41 | + version, |
| 42 | + submodulePath, |
| 43 | + } |
| 44 | +} |
| 45 | + |
| 46 | +async function fetchVersions( |
| 47 | + ctx: HandlerContext, |
| 48 | + data: ReturnType<typeof parseUrl>, |
| 49 | +): Promise<string[]> { |
| 50 | + const cachedVersions = await ctx.versionCache.get(`npm:${data.name}`) |
| 51 | + |
| 52 | + if (cachedVersions) { |
| 53 | + return cachedVersions |
| 54 | + } |
| 55 | + |
| 56 | + const res = await fetch(`https://registry.npmjs.org/${data.name}`) |
| 57 | + |
| 58 | + if (!res.ok) { |
| 59 | + return [] |
| 60 | + } |
| 61 | + |
| 62 | + let { versions } = await res.json() as { versions: string[] } |
| 63 | + |
| 64 | + versions = Object.keys(versions) |
| 65 | + |
| 66 | + await ctx.versionCache.set(`npm:${data.name}`, versions) |
| 67 | + |
| 68 | + return versions.filter((version) => semver.valid(version)) |
| 69 | +} |
| 70 | + |
| 71 | +export const handle: Handler = async (ctx) => { |
| 72 | + const data = parseUrl(ctx.url) |
| 73 | + |
| 74 | + // if there's no version tag |
| 75 | + if (data.version === null) { |
| 76 | + const versions = await fetchVersions(ctx, data) |
| 77 | + |
| 78 | + if (versions.length === 0) { |
| 79 | + return respondWithError('BAD_MODULE') |
| 80 | + } |
| 81 | + |
| 82 | + data.version = semver.rsort(versions)[0] |
| 83 | + // if there's an invalid version tag or range |
| 84 | + } else if (semver.valid(data.version) === null) { |
| 85 | + if (semver.validRange(data.version) === null) { |
| 86 | + return respondWithError('BAD_VERSION') |
| 87 | + } |
| 88 | + |
| 89 | + const versions = await fetchVersions(ctx, data) |
| 90 | + |
| 91 | + if (versions.length === 0) { |
| 92 | + return respondWithError('BAD_MODULE') |
| 93 | + } |
| 94 | + |
| 95 | + const suggestedVersion = semver.maxSatisfying(versions, data.version) |
| 96 | + |
| 97 | + if (suggestedVersion === null) { |
| 98 | + return respondWithError('BAD_VERSION') |
| 99 | + } |
| 100 | + |
| 101 | + data.version = suggestedVersion |
| 102 | + } |
| 103 | + |
| 104 | + if (ctx.url.searchParams.has('tgz') || ctx.url.searchParams.has('tar.gz')) { |
| 105 | + return Response.redirect( |
| 106 | + `https://registry.npmjs.org/${data.name}/-/${data.name}-${data.version}.tgz`, |
| 107 | + 307, |
| 108 | + ) |
| 109 | + } |
| 110 | + |
| 111 | + const res = await fetch( |
| 112 | + `https://cdn.jsdelivr.net/npm/${data.name}@${data.version}${ |
| 113 | + data.submodulePath === '/' ? '' : data.submodulePath |
| 114 | + }/+esm`, |
| 115 | + ) |
| 116 | + |
| 117 | + if (!res.ok) { |
| 118 | + return respondWithError('UNINDEXED_MODULE') |
| 119 | + } |
| 120 | + |
| 121 | + let content = await res.text() |
| 122 | + |
| 123 | + content = rewriteContent(content, ({ url }) => { |
| 124 | + // e.g. /npm/[email protected]/+esm |
| 125 | + if (/^\/npm(\/[^\/]+)+\/\+esm$/.test(url)) { |
| 126 | + url = url.replace( |
| 127 | + '/npm', |
| 128 | + ctx.url.protocol + '//' + ctx.url.hostname + '/npm', |
| 129 | + ) |
| 130 | + url = url.slice(0, -5) // remove /+esm |
| 131 | + } |
| 132 | + |
| 133 | + return url |
| 134 | + }) |
| 135 | + |
| 136 | + return new Response(content, { |
| 137 | + headers: { |
| 138 | + 'cache-control': `public, max-age=${ctx.cacheDurationInHours * 3600}`, |
| 139 | + 'content-type': 'text/javascript; charset=utf-8', |
| 140 | + 'x-typescript-types': `https://esm.sh/${data.name}@${data.version}${ |
| 141 | + data.submodulePath === '/' ? '' : data.submodulePath |
| 142 | + }?target=es2022`, |
| 143 | + }, |
| 144 | + }) |
| 145 | +} |
0 commit comments