[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 552 lines 14 kB view raw
1/** 2 * Shared route mock handlers for external API requests. 3 * 4 * This module contains the URL matching and response generation logic used by both: 5 * - Playwright E2E tests (test/e2e/test-utils.ts) 6 * - Lighthouse CI puppeteer setup (lighthouse-setup.cjs) 7 * 8 * It is intentionally written as CJS so it can be required from the CJS lighthouse 9 * setup script and imported from ESM test utilities. 10 */ 11 12'use strict' 13 14const { existsSync, readFileSync } = require('node:fs') 15const { join } = require('node:path') 16 17const FIXTURES_DIR = join(__dirname) 18 19/** 20 * @param {string} relativePath 21 * @returns {unknown | null} 22 */ 23function readFixture(relativePath) { 24 const fullPath = join(FIXTURES_DIR, relativePath) 25 if (!existsSync(fullPath)) { 26 return null 27 } 28 try { 29 return JSON.parse(readFileSync(fullPath, 'utf-8')) 30 } catch { 31 return null 32 } 33} 34 35/** 36 * Parse a scoped package name into its components. 37 * Handles formats like: @scope/name, @scope/name@version, name, name@version 38 * 39 * @param {string} input 40 * @returns {{ name: string; version?: string }} 41 */ 42function parseScopedPackage(input) { 43 if (input.startsWith('@')) { 44 const slashIndex = input.indexOf('/') 45 if (slashIndex === -1) { 46 return { name: input } 47 } 48 const afterSlash = input.slice(slashIndex + 1) 49 const atIndex = afterSlash.indexOf('@') 50 if (atIndex === -1) { 51 return { name: input } 52 } 53 return { 54 name: input.slice(0, slashIndex + 1 + atIndex), 55 version: afterSlash.slice(atIndex + 1), 56 } 57 } 58 59 const atIndex = input.indexOf('@') 60 if (atIndex === -1) { 61 return { name: input } 62 } 63 return { 64 name: input.slice(0, atIndex), 65 version: input.slice(atIndex + 1), 66 } 67} 68 69/** 70 * @param {string} packageName 71 * @returns {string} 72 */ 73function packageToFixturePath(packageName) { 74 if (packageName.startsWith('@')) { 75 const [scope, name] = packageName.slice(1).split('/') 76 if (!name) { 77 return `npm-registry/packuments/${packageName}.json` 78 } 79 return `npm-registry/packuments/@${scope}/${name}.json` 80 } 81 return `npm-registry/packuments/${packageName}.json` 82} 83 84/** 85 * @typedef {Object} MockResponse 86 * @property {number} status 87 * @property {string} contentType 88 * @property {string} body 89 */ 90 91/** 92 * Determine the mock response for an npm registry request. 93 * 94 * @param {string} urlString 95 * @returns {MockResponse | null} 96 */ 97function matchNpmRegistry(urlString) { 98 const url = new URL(urlString) 99 const pathname = decodeURIComponent(url.pathname) 100 101 // Search endpoint 102 if (pathname === '/-/v1/search') { 103 const query = url.searchParams.get('text') 104 if (query) { 105 const maintainerMatch = query.match(/^maintainer:(.+)$/) 106 if (maintainerMatch && maintainerMatch[1]) { 107 const fixture = readFixture(`users/${maintainerMatch[1]}.json`) 108 return json(fixture || { objects: [], total: 0, time: new Date().toISOString() }) 109 } 110 111 const searchName = query.replace(/:/g, '-') 112 const fixture = readFixture(`npm-registry/search/${searchName}.json`) 113 return json(fixture || { objects: [], total: 0, time: new Date().toISOString() }) 114 } 115 } 116 117 // Org packages 118 const orgMatch = pathname.match(/^\/-\/org\/([^/]+)\/package$/) 119 if (orgMatch && orgMatch[1]) { 120 const fixture = readFixture(`npm-registry/orgs/${orgMatch[1]}.json`) 121 if (fixture) { 122 return json(fixture) 123 } 124 return json({ error: 'Not found' }, 404) 125 } 126 127 // Packument 128 if (!pathname.startsWith('/-/')) { 129 let packageName = pathname.slice(1) 130 131 if (packageName.startsWith('@')) { 132 const parts = packageName.split('/') 133 if (parts.length > 2) { 134 packageName = `${parts[0]}/${parts[1]}` 135 } 136 } else { 137 const slashIndex = packageName.indexOf('/') 138 if (slashIndex !== -1) { 139 packageName = packageName.slice(0, slashIndex) 140 } 141 } 142 143 const fixture = readFixture(packageToFixturePath(packageName)) 144 if (fixture) { 145 return json(fixture) 146 } 147 return json({ error: 'Not found' }, 404) 148 } 149 150 return null 151} 152 153/** 154 * Determine the mock response for an npm API (downloads) request. 155 * 156 * @param {string} urlString 157 * @returns {MockResponse | null} 158 */ 159function matchNpmApi(urlString) { 160 const url = new URL(urlString) 161 const pathname = decodeURIComponent(url.pathname) 162 163 // Downloads point 164 const pointMatch = pathname.match(/^\/downloads\/point\/[^/]+\/(.+)$/) 165 if (pointMatch && pointMatch[1]) { 166 const packageName = pointMatch[1] 167 const fixture = readFixture(`npm-api/downloads/${packageName}.json`) 168 return json( 169 fixture || { 170 downloads: 0, 171 start: '2025-01-01', 172 end: '2025-01-31', 173 package: packageName, 174 }, 175 ) 176 } 177 178 // Downloads range 179 const rangeMatch = pathname.match(/^\/downloads\/range\/[^/]+\/(.+)$/) 180 if (rangeMatch && rangeMatch[1]) { 181 const packageName = rangeMatch[1] 182 return json({ downloads: [], start: '2025-01-01', end: '2025-01-31', package: packageName }) 183 } 184 185 return null 186} 187 188/** 189 * @param {string} urlString 190 * @returns {MockResponse | null} 191 */ 192function matchOsvApi(urlString) { 193 const url = new URL(urlString) 194 195 if (url.pathname === '/v1/querybatch') { 196 return json({ results: [] }) 197 } 198 199 if (url.pathname.startsWith('/v1/query')) { 200 return json({ vulns: [] }) 201 } 202 203 return null 204} 205 206/** 207 * Parse a package query string into name and specifier. 208 * Handles scoped packages: "@scope/name@specifier" and "name@specifier". 209 * 210 * @param {string} query 211 * @param {string} defaultSpecifier 212 * @returns {{ name: string; specifier: string }} 213 */ 214function parsePackageQuery(query, defaultSpecifier) { 215 let name = query 216 let specifier = defaultSpecifier 217 if (name.startsWith('@')) { 218 const atIndex = name.indexOf('@', 1) 219 if (atIndex !== -1) { 220 specifier = name.slice(atIndex + 1) 221 name = name.slice(0, atIndex) 222 } 223 } else { 224 const atIndex = name.indexOf('@') 225 if (atIndex !== -1) { 226 specifier = name.slice(atIndex + 1) 227 name = name.slice(0, atIndex) 228 } 229 } 230 return { name, specifier } 231} 232 233/** 234 * Build a latest-version response for a single package (GET /:pkg endpoint). 235 * 236 * @param {string} query 237 * @returns {object} 238 */ 239function resolveSingleLatest(query) { 240 const { name, specifier } = parsePackageQuery(query, 'latest') 241 const packument = readFixture(packageToFixturePath(name)) 242 243 if (!packument) { 244 return { 245 name, 246 specifier, 247 version: '0.0.0', 248 publishedAt: new Date().toISOString(), 249 lastSynced: Date.now(), 250 } 251 } 252 253 const distTags = packument['dist-tags'] 254 const versions = packument.versions 255 256 let version 257 if (specifier === 'latest' || !specifier) { 258 version = distTags && distTags.latest 259 } else if (distTags && distTags[specifier]) { 260 version = distTags[specifier] 261 } else if (versions && versions[specifier]) { 262 version = specifier 263 } else { 264 version = distTags && distTags.latest 265 } 266 267 if (!version) { 268 return { 269 name, 270 specifier, 271 version: '0.0.0', 272 publishedAt: new Date().toISOString(), 273 lastSynced: Date.now(), 274 } 275 } 276 277 return { 278 name, 279 specifier, 280 version, 281 publishedAt: (packument.time && packument.time[version]) || new Date().toISOString(), 282 lastSynced: Date.now(), 283 } 284} 285 286/** 287 * Build a versions response for a single package (GET /versions/:pkg endpoint). 288 * 289 * @param {string} query 290 * @returns {object} 291 */ 292function resolveSingleVersions(query) { 293 const { name, specifier } = parsePackageQuery(query, '*') 294 const packument = readFixture(packageToFixturePath(name)) 295 296 if (!packument) { 297 return { name, error: `"https://registry.npmjs.org/${name}": 404 Not Found` } 298 } 299 300 return { 301 name, 302 specifier, 303 distTags: packument['dist-tags'] || {}, 304 versions: Object.keys(packument.versions || {}), 305 time: packument.time || {}, 306 lastSynced: Date.now(), 307 } 308} 309 310/** 311 * @param {string} urlString 312 * @returns {MockResponse | null} 313 */ 314function matchFastNpmMeta(urlString) { 315 const url = new URL(urlString) 316 let pathPart = decodeURIComponent(url.pathname.slice(1)) 317 318 if (!pathPart) return null 319 320 // /versions/ endpoint returns version lists (used by getVersionsBatch) 321 const isVersions = pathPart.startsWith('versions/') 322 if (isVersions) pathPart = pathPart.slice('versions/'.length) 323 324 const resolveFn = isVersions ? resolveSingleVersions : resolveSingleLatest 325 326 // Batch requests: package1+package2+... 327 if (pathPart.includes('+')) { 328 const results = pathPart.split('+').map(resolveFn) 329 return json(results) 330 } 331 332 return json(resolveFn(pathPart)) 333} 334 335/** 336 * @param {string} urlString 337 * @returns {MockResponse | null} 338 */ 339function matchJsrRegistry(urlString) { 340 const url = new URL(urlString) 341 342 if (url.pathname.endsWith('/meta.json')) { 343 return json(null) 344 } 345 346 return null 347} 348 349/** 350 * @param {string} urlString 351 * @returns {MockResponse | null} 352 */ 353function matchBundlephobiaApi(urlString) { 354 const url = new URL(urlString) 355 356 if (url.pathname === '/api/size') { 357 const packageSpec = url.searchParams.get('package') 358 if (packageSpec) { 359 return json({ 360 name: packageSpec.split('@')[0], 361 size: 12345, 362 gzip: 4567, 363 dependencyCount: 3, 364 }) 365 } 366 } 367 368 return null 369} 370 371/** 372 * @param {string} urlString 373 * @returns {MockResponse | null} 374 */ 375function matchNpmsApi(urlString) { 376 const url = new URL(urlString) 377 const pathname = decodeURIComponent(url.pathname) 378 379 const packageMatch = pathname.match(/^\/v2\/package\/(.+)$/) 380 if (packageMatch && packageMatch[1]) { 381 const packageName = packageMatch[1] 382 return json({ 383 analyzedAt: new Date().toISOString(), 384 collected: { 385 metadata: { name: packageName }, 386 }, 387 score: { 388 final: 0.75, 389 detail: { 390 quality: 0.8, 391 popularity: 0.7, 392 maintenance: 0.75, 393 }, 394 }, 395 }) 396 } 397 398 return null 399} 400 401/** 402 * @param {string} _urlString 403 * @returns {MockResponse | null} 404 */ 405function matchJsdelivrCdn(_urlString) { 406 return { status: 404, contentType: 'text/plain', body: 'Not found' } 407} 408 409/** 410 * @param {string} urlString 411 * @returns {MockResponse | null} 412 */ 413function matchJsdelivrDataApi(urlString) { 414 const url = new URL(urlString) 415 const pathname = decodeURIComponent(url.pathname) 416 417 const packageMatch = pathname.match(/^\/v1\/packages\/npm\/(.+)$/) 418 if (packageMatch && packageMatch[1]) { 419 const parsed = parseScopedPackage(packageMatch[1]) 420 return json({ 421 type: 'npm', 422 name: parsed.name, 423 version: parsed.version || 'latest', 424 files: [ 425 { name: 'package.json', hash: 'abc123', size: 1000 }, 426 { name: 'index.js', hash: 'def456', size: 500 }, 427 { name: 'README.md', hash: 'ghi789', size: 2000 }, 428 ], 429 }) 430 } 431 432 return null 433} 434 435/** 436 * @param {string} _urlString 437 * @returns {MockResponse} 438 */ 439function matchGravatarApi(_urlString) { 440 return { status: 404, contentType: 'text/plain', body: 'Not found' } 441} 442 443/** 444 * @param {string} urlString 445 * @returns {MockResponse | null} 446 */ 447function matchGitHubApi(urlString) { 448 const url = new URL(urlString) 449 const pathname = url.pathname 450 451 const contributorsMatch = pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/contributors$/) 452 if (contributorsMatch) { 453 const fixture = readFixture('github/contributors.json') 454 return json(fixture || []) 455 } 456 457 return null 458} 459 460/** 461 * Route definitions mapping URL patterns to their matchers. 462 * Each entry has a pattern (for Playwright's page.route) and a match function 463 * that returns a MockResponse or null. 464 * 465 * @type {Array<{ name: string; pattern: string; match: (url: string) => MockResponse | null }>} 466 */ 467const routes = [ 468 { name: 'npm registry', pattern: 'https://registry.npmjs.org/**', match: matchNpmRegistry }, 469 { name: 'npm API', pattern: 'https://api.npmjs.org/**', match: matchNpmApi }, 470 { name: 'OSV API', pattern: 'https://api.osv.dev/**', match: matchOsvApi }, 471 { name: 'fast-npm-meta', pattern: 'https://npm.antfu.dev/**', match: matchFastNpmMeta }, 472 { name: 'JSR registry', pattern: 'https://jsr.io/**', match: matchJsrRegistry }, 473 { name: 'Bundlephobia API', pattern: 'https://bundlephobia.com/**', match: matchBundlephobiaApi }, 474 { name: 'npms.io API', pattern: 'https://api.npms.io/**', match: matchNpmsApi }, 475 { name: 'jsdelivr CDN', pattern: 'https://cdn.jsdelivr.net/**', match: matchJsdelivrCdn }, 476 { 477 name: 'jsdelivr Data API', 478 pattern: 'https://data.jsdelivr.com/**', 479 match: matchJsdelivrDataApi, 480 }, 481 { name: 'Gravatar API', pattern: 'https://www.gravatar.com/**', match: matchGravatarApi }, 482 { name: 'GitHub API', pattern: 'https://api.github.com/**', match: matchGitHubApi }, 483] 484 485/** 486 * Try to match a URL against all known API routes and return a mock response. 487 * 488 * @param {string} url - The full request URL 489 * @returns {{ name: string; response: MockResponse } | null} 490 */ 491function matchRoute(url) { 492 for (const route of routes) { 493 if (urlMatchesPattern(url, route.pattern)) { 494 const response = route.match(url) 495 if (response) { 496 return { name: route.name, response } 497 } 498 // URL matches the domain pattern but handler returned null => unmocked 499 return null 500 } 501 } 502 return null 503} 504 505/** 506 * Check if a URL matches a simple glob pattern like "https://example.com/**". 507 * 508 * @param {string} url 509 * @param {string} pattern 510 * @returns {boolean} 511 */ 512function urlMatchesPattern(url, pattern) { 513 // Convert "https://example.com/**" to a prefix check 514 if (pattern.endsWith('/**')) { 515 const prefix = pattern.slice(0, -2) 516 return url.startsWith(prefix) 517 } 518 return url === pattern 519} 520 521/** 522 * Check if a URL belongs to any of the known external API domains. 523 * 524 * @param {string} url 525 * @returns {string | null} The API name if matched, null otherwise 526 */ 527function getExternalApiName(url) { 528 for (const route of routes) { 529 if (urlMatchesPattern(url, route.pattern)) { 530 return route.name 531 } 532 } 533 return null 534} 535 536// Helper to build a JSON MockResponse 537function json(data, status = 200) { 538 return { 539 status, 540 contentType: 'application/json', 541 body: JSON.stringify(data), 542 } 543} 544 545module.exports = { 546 routes, 547 matchRoute, 548 getExternalApiName, 549 readFixture, 550 parseScopedPackage, 551 packageToFixturePath, 552}