forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}