|
| 1 | +/** |
| 2 | + * base on @volar/jsdelivr |
| 3 | + * MIT License https://github.com/volarjs/volar.js/blob/master/packages/jsdelivr/LICENSE |
| 4 | + */ |
| 5 | +import type { FileSystem, FileType } from '@volar/language-service' |
| 6 | +import type { URI } from 'vscode-uri' |
| 7 | + |
| 8 | +const textCache = new Map<string, Promise<string | undefined>>() |
| 9 | +const jsonCache = new Map<string, Promise<any>>() |
| 10 | + |
| 11 | +export function createNpmFileSystem( |
| 12 | + getCdnPath = (uri: URI): string | undefined => { |
| 13 | + if (uri.path === '/node_modules') { |
| 14 | + return '' |
| 15 | + } else if (uri.path.startsWith('/node_modules/')) { |
| 16 | + return uri.path.slice('/node_modules/'.length) |
| 17 | + } |
| 18 | + }, |
| 19 | + getPackageVersion?: (pkgName: string) => string | undefined, |
| 20 | + onFetch?: (path: string, content: string) => void, |
| 21 | +): FileSystem { |
| 22 | + const fetchResults = new Map<string, Promise<string | undefined>>() |
| 23 | + const flatResults = new Map< |
| 24 | + string, |
| 25 | + Promise< |
| 26 | + { |
| 27 | + name: string |
| 28 | + size: number |
| 29 | + time: string |
| 30 | + hash: string |
| 31 | + }[] |
| 32 | + > |
| 33 | + >() |
| 34 | + |
| 35 | + return { |
| 36 | + async stat(uri) { |
| 37 | + const path = getCdnPath(uri) |
| 38 | + if (path === undefined) { |
| 39 | + return |
| 40 | + } |
| 41 | + if (path === '') { |
| 42 | + return { |
| 43 | + type: 2 satisfies FileType.Directory, |
| 44 | + size: -1, |
| 45 | + ctime: -1, |
| 46 | + mtime: -1, |
| 47 | + } |
| 48 | + } |
| 49 | + return await _stat(path) |
| 50 | + }, |
| 51 | + async readFile(uri) { |
| 52 | + const path = getCdnPath(uri) |
| 53 | + if (path === undefined) { |
| 54 | + return |
| 55 | + } |
| 56 | + return await _readFile(path) |
| 57 | + }, |
| 58 | + readDirectory(uri) { |
| 59 | + const path = getCdnPath(uri) |
| 60 | + if (path === undefined) { |
| 61 | + return [] |
| 62 | + } |
| 63 | + return _readDirectory(path) |
| 64 | + }, |
| 65 | + } |
| 66 | + |
| 67 | + async function _stat(path: string) { |
| 68 | + const [modName, pkgName, pkgVersion, pkgFilePath] = resolvePackageName(path) |
| 69 | + if (!pkgName) { |
| 70 | + if (modName.startsWith('@')) { |
| 71 | + return { |
| 72 | + type: 2 satisfies FileType.Directory, |
| 73 | + ctime: -1, |
| 74 | + mtime: -1, |
| 75 | + size: -1, |
| 76 | + } |
| 77 | + } else { |
| 78 | + return |
| 79 | + } |
| 80 | + } |
| 81 | + if (!(await isValidPackageName(pkgName))) { |
| 82 | + return |
| 83 | + } |
| 84 | + |
| 85 | + if (!pkgFilePath) { |
| 86 | + // perf: skip flat request |
| 87 | + return { |
| 88 | + type: 2 satisfies FileType.Directory, |
| 89 | + ctime: -1, |
| 90 | + mtime: -1, |
| 91 | + size: -1, |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + if (!flatResults.has(modName)) { |
| 96 | + flatResults.set(modName, flat(pkgName, pkgVersion)) |
| 97 | + } |
| 98 | + |
| 99 | + const flatResult = await flatResults.get(modName)! |
| 100 | + const filePath = path.slice(modName.length) |
| 101 | + const file = flatResult.find((file) => file.name === filePath) |
| 102 | + if (file) { |
| 103 | + return { |
| 104 | + type: 1 satisfies FileType.File, |
| 105 | + ctime: new Date(file.time).valueOf(), |
| 106 | + mtime: new Date(file.time).valueOf(), |
| 107 | + size: file.size, |
| 108 | + } |
| 109 | + } else if ( |
| 110 | + flatResult.some((file) => file.name.startsWith(filePath + '/')) |
| 111 | + ) { |
| 112 | + return { |
| 113 | + type: 2 satisfies FileType.Directory, |
| 114 | + ctime: -1, |
| 115 | + mtime: -1, |
| 116 | + size: -1, |
| 117 | + } |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + async function _readDirectory(path: string): Promise<[string, FileType][]> { |
| 122 | + const [modName, pkgName, pkgVersion] = resolvePackageName(path) |
| 123 | + if (!pkgName || !(await isValidPackageName(pkgName))) { |
| 124 | + return [] |
| 125 | + } |
| 126 | + |
| 127 | + if (!flatResults.has(modName)) { |
| 128 | + flatResults.set(modName, flat(pkgName, pkgVersion)) |
| 129 | + } |
| 130 | + |
| 131 | + const flatResult = await flatResults.get(modName)! |
| 132 | + const dirPath = path.slice(modName.length) |
| 133 | + const files = flatResult |
| 134 | + .filter((f) => f.name.substring(0, f.name.lastIndexOf('/')) === dirPath) |
| 135 | + .map((f) => f.name.slice(dirPath.length + 1)) |
| 136 | + const dirs = flatResult |
| 137 | + .filter( |
| 138 | + (f) => |
| 139 | + f.name.startsWith(dirPath + '/') && |
| 140 | + f.name.substring(dirPath.length + 1).split('/').length >= 2, |
| 141 | + ) |
| 142 | + .map((f) => f.name.slice(dirPath.length + 1).split('/')[0]) |
| 143 | + |
| 144 | + return [ |
| 145 | + ...files.map<[string, FileType]>((f) => [f, 1 satisfies FileType.File]), |
| 146 | + ...[...new Set(dirs)].map<[string, FileType]>((f) => [ |
| 147 | + f, |
| 148 | + 2 satisfies FileType.Directory, |
| 149 | + ]), |
| 150 | + ] |
| 151 | + } |
| 152 | + |
| 153 | + async function _readFile(path: string): Promise<string | undefined> { |
| 154 | + const [_modName, pkgName, _version, pkgFilePath] = resolvePackageName(path) |
| 155 | + if (!pkgName || !pkgFilePath || !(await isValidPackageName(pkgName))) { |
| 156 | + return |
| 157 | + } |
| 158 | + |
| 159 | + if (!fetchResults.has(path)) { |
| 160 | + fetchResults.set( |
| 161 | + path, |
| 162 | + (async () => { |
| 163 | + if ((await _stat(path))?.type !== (1 satisfies FileType.File)) { |
| 164 | + return |
| 165 | + } |
| 166 | + const text = await fetchText(`https://cdn.jsdelivr.net/npm/${path}`) |
| 167 | + if (text !== undefined) { |
| 168 | + onFetch?.(path, text) |
| 169 | + } |
| 170 | + return text |
| 171 | + })(), |
| 172 | + ) |
| 173 | + } |
| 174 | + |
| 175 | + return await fetchResults.get(path)! |
| 176 | + } |
| 177 | + |
| 178 | + async function flat(pkgName: string, version: string | undefined) { |
| 179 | + version ??= 'latest' |
| 180 | + |
| 181 | + // resolve latest tag |
| 182 | + if (version === 'latest') { |
| 183 | + const data = await fetchJson<{ version: string | null }>( |
| 184 | + `https://data.jsdelivr.com/v1/package/resolve/npm/${pkgName}@${version}`, |
| 185 | + ) |
| 186 | + if (!data?.version) { |
| 187 | + return [] |
| 188 | + } |
| 189 | + version = data.version |
| 190 | + } |
| 191 | + |
| 192 | + const flat = await fetchJson<{ |
| 193 | + files: { |
| 194 | + name: string |
| 195 | + size: number |
| 196 | + time: string |
| 197 | + hash: string |
| 198 | + }[] |
| 199 | + }>(`https://data.jsdelivr.com/v1/package/npm/${pkgName}@${version}/flat`) |
| 200 | + if (!flat) { |
| 201 | + return [] |
| 202 | + } |
| 203 | + |
| 204 | + return flat.files |
| 205 | + } |
| 206 | + |
| 207 | + async function isValidPackageName(pkgName: string) { |
| 208 | + // ignore @aaa/node_modules |
| 209 | + if (pkgName.endsWith('/node_modules')) { |
| 210 | + return false |
| 211 | + } |
| 212 | + // hard code to skip known invalid package |
| 213 | + if ( |
| 214 | + pkgName.endsWith('.d.ts') || |
| 215 | + pkgName.startsWith('@typescript/') || |
| 216 | + pkgName.startsWith('@types/typescript__') |
| 217 | + ) { |
| 218 | + return false |
| 219 | + } |
| 220 | + // don't check @types if original package already having types |
| 221 | + if (pkgName.startsWith('@types/')) { |
| 222 | + let originalPkgName = pkgName.slice('@types/'.length) |
| 223 | + if (originalPkgName.indexOf('__') >= 0) { |
| 224 | + originalPkgName = '@' + originalPkgName.replace('__', '/') |
| 225 | + } |
| 226 | + const packageJson = await _readFile(`${originalPkgName}/package.json`) |
| 227 | + if (!packageJson) { |
| 228 | + return false |
| 229 | + } |
| 230 | + const packageJsonObj = JSON.parse(packageJson) |
| 231 | + if (packageJsonObj.types || packageJsonObj.typings) { |
| 232 | + return false |
| 233 | + } |
| 234 | + const indexDts = await _stat(`${originalPkgName}/index.d.ts`) |
| 235 | + if (indexDts?.type === (1 satisfies FileType.File)) { |
| 236 | + return false |
| 237 | + } |
| 238 | + } |
| 239 | + return true |
| 240 | + } |
| 241 | + |
| 242 | + /** |
| 243 | + * @example |
| 244 | + * "a/b/c" -> ["a", "a", undefined, "b/c"] |
| 245 | + * "@a" -> ["@a", undefined, undefined, ""] |
| 246 | + * "@a/b/c" -> ["@a/b", "@a/b", undefined, "c"] |
| 247 | + * "@a/[email protected]/c" -> ["@a/[email protected]", "@a/b", "1.2.3", "c"] |
| 248 | + */ |
| 249 | + function resolvePackageName( |
| 250 | + input: string, |
| 251 | + ): [ |
| 252 | + modName: string, |
| 253 | + pkgName: string | undefined, |
| 254 | + version: string | undefined, |
| 255 | + path: string, |
| 256 | + ] { |
| 257 | + const parts = input.split('/') |
| 258 | + let modName = parts[0] |
| 259 | + let path: string |
| 260 | + if (modName.startsWith('@')) { |
| 261 | + if (!parts[1]) { |
| 262 | + return [modName, undefined, undefined, ''] |
| 263 | + } |
| 264 | + modName += '/' + parts[1] |
| 265 | + path = parts.slice(2).join('/') |
| 266 | + } else { |
| 267 | + path = parts.slice(1).join('/') |
| 268 | + } |
| 269 | + let pkgName = modName |
| 270 | + let version: string | undefined |
| 271 | + if (modName.lastIndexOf('@') >= 1) { |
| 272 | + pkgName = modName.substring(0, modName.lastIndexOf('@')) |
| 273 | + version = modName.substring(modName.lastIndexOf('@') + 1) |
| 274 | + } |
| 275 | + if (!version && getPackageVersion) { |
| 276 | + getPackageVersion?.(pkgName) |
| 277 | + } |
| 278 | + return [modName, pkgName, version, path] |
| 279 | + } |
| 280 | +} |
| 281 | + |
| 282 | +async function fetchText(url: string) { |
| 283 | + if (!textCache.has(url)) { |
| 284 | + textCache.set( |
| 285 | + url, |
| 286 | + (async () => { |
| 287 | + try { |
| 288 | + const res = await fetch(url) |
| 289 | + if (res.status === 200) { |
| 290 | + return await res.text() |
| 291 | + } |
| 292 | + } catch { |
| 293 | + // ignore |
| 294 | + } |
| 295 | + })(), |
| 296 | + ) |
| 297 | + } |
| 298 | + return await textCache.get(url)! |
| 299 | +} |
| 300 | + |
| 301 | +async function fetchJson<T>(url: string) { |
| 302 | + if (!jsonCache.has(url)) { |
| 303 | + jsonCache.set( |
| 304 | + url, |
| 305 | + (async () => { |
| 306 | + try { |
| 307 | + const res = await fetch(url) |
| 308 | + if (res.status === 200) { |
| 309 | + return await res.json() |
| 310 | + } |
| 311 | + } catch { |
| 312 | + // ignore |
| 313 | + } |
| 314 | + })(), |
| 315 | + ) |
| 316 | + } |
| 317 | + return (await jsonCache.get(url)!) as T |
| 318 | +} |
0 commit comments