[READ-ONLY] a fast, modern browser for the npm registry
at main 865 lines 27 kB view raw
1import process from 'node:process' 2import type { CachedFetchResult } from '#shared/utils/fetch-cache-config' 3import { createFetch } from 'ofetch' 4 5/** 6 * Test fixtures plugin for CI environments. 7 * 8 * This plugin intercepts all cachedFetch calls and serves pre-recorded fixture data 9 * instead of hitting the real npm API. 10 * 11 * This ensures: 12 * - Tests are deterministic and don't depend on external API availability 13 * - We don't hammer the npm registry during CI runs 14 * - Tests run faster with no network latency 15 * 16 * Set NUXT_TEST_FIXTURES_VERBOSE=true for detailed logging. 17 */ 18 19const VERBOSE = process.env.NUXT_TEST_FIXTURES_VERBOSE === 'true' 20 21const FIXTURE_PATHS = { 22 packument: 'npm-registry:packuments', 23 search: 'npm-registry:search', 24 org: 'npm-registry:orgs', 25 downloads: 'npm-api:downloads', 26 user: 'users', 27 esmHeaders: 'esm-sh:headers', 28 esmTypes: 'esm-sh:types', 29 githubContributors: 'github:contributors.json', 30 githubContributorsStats: 'github:contributors-stats.json', 31} as const 32 33type FixtureType = keyof typeof FIXTURE_PATHS 34 35interface FixtureMatch { 36 type: FixtureType 37 name: string 38} 39 40interface MockResult { 41 data: unknown 42} 43 44function getFixturePath(type: FixtureType, name: string): string { 45 const dir = FIXTURE_PATHS[type] 46 let filename: string 47 48 switch (type) { 49 case 'packument': 50 case 'downloads': 51 filename = `${name}.json` 52 break 53 case 'search': 54 filename = `${name.replace(/:/g, '-')}.json` 55 break 56 case 'org': 57 case 'user': 58 filename = `${name}.json` 59 break 60 default: 61 filename = `${name}.json` 62 } 63 64 return `${dir}:${filename.replace(/\//g, ':')}` 65} 66 67/** 68 * Parse a scoped package name with optional version. 69 * Handles formats like: @scope/name, @scope/name@version, name, name@version 70 */ 71function parseScopedPackageWithVersion(input: string): { name: string; version?: string } { 72 if (input.startsWith('@')) { 73 // Scoped package: @scope/name or @scope/name@version 74 const slashIndex = input.indexOf('/') 75 if (slashIndex === -1) { 76 // Invalid format like just "@scope" 77 return { name: input } 78 } 79 const afterSlash = input.slice(slashIndex + 1) 80 const atIndex = afterSlash.indexOf('@') 81 if (atIndex === -1) { 82 // @scope/name (no version) 83 return { name: input } 84 } 85 // @scope/name@version 86 return { 87 name: input.slice(0, slashIndex + 1 + atIndex), 88 version: afterSlash.slice(atIndex + 1), 89 } 90 } 91 92 // Unscoped package: name or name@version 93 const atIndex = input.indexOf('@') 94 if (atIndex === -1) { 95 return { name: input } 96 } 97 return { 98 name: input.slice(0, atIndex), 99 version: input.slice(atIndex + 1), 100 } 101} 102 103function getMockForUrl(url: string): MockResult | null { 104 let urlObj: URL 105 try { 106 urlObj = new URL(url) 107 } catch { 108 return null 109 } 110 111 const { host, pathname, searchParams } = urlObj 112 113 // OSV API - return empty vulnerability results 114 if (host === 'api.osv.dev') { 115 if (pathname === '/v1/querybatch') { 116 return { data: { results: [] } } 117 } 118 if (pathname.startsWith('/v1/query')) { 119 return { data: { vulns: [] } } 120 } 121 } 122 123 // JSR registry - return null (npm packages aren't on JSR) 124 if (host === 'jsr.io' && pathname.endsWith('/meta.json')) { 125 return { data: null } 126 } 127 128 // Bundlephobia API - return mock size data 129 if (host === 'bundlephobia.com' && pathname === '/api/size') { 130 const packageSpec = searchParams.get('package') 131 if (packageSpec) { 132 return { 133 data: { 134 name: packageSpec.split('@')[0], 135 size: 12345, 136 gzip: 4567, 137 dependencyCount: 3, 138 }, 139 } 140 } 141 } 142 143 // npms.io API - return mock package score data 144 if (host === 'api.npms.io') { 145 const packageMatch = decodeURIComponent(pathname).match(/^\/v2\/package\/(.+)$/) 146 if (packageMatch?.[1]) { 147 return { 148 data: { 149 analyzedAt: new Date().toISOString(), 150 collected: { 151 metadata: { name: packageMatch[1] }, 152 }, 153 score: { 154 final: 0.75, 155 detail: { 156 quality: 0.8, 157 popularity: 0.7, 158 maintenance: 0.75, 159 }, 160 }, 161 }, 162 } 163 } 164 } 165 166 // jsdelivr CDN - return 404 for README files, etc. 167 if (host === 'cdn.jsdelivr.net') { 168 // Return null data which will cause a 404 - README files are optional 169 return { data: null } 170 } 171 172 // jsdelivr data API - return mock file listing 173 if (host === 'data.jsdelivr.com') { 174 const packageMatch = decodeURIComponent(pathname).match(/^\/v1\/packages\/npm\/(.+)$/) 175 if (packageMatch?.[1]) { 176 const pkgWithVersion = packageMatch[1] 177 const parsed = parseScopedPackageWithVersion(pkgWithVersion) 178 return { 179 data: { 180 type: 'npm', 181 name: parsed.name, 182 version: parsed.version || 'latest', 183 files: [ 184 { name: 'package.json', hash: 'abc123', size: 1000 }, 185 { name: 'index.js', hash: 'def456', size: 500 }, 186 { name: 'README.md', hash: 'ghi789', size: 2000 }, 187 ], 188 }, 189 } 190 } 191 } 192 193 // Gravatar API - return 404 (avatars not needed in tests) 194 if (host === 'www.gravatar.com') { 195 return { data: null } 196 } 197 198 // GitHub API - handled via fixtures, return null to use fixture system 199 // Note: The actual fixture loading is handled in fetchFromFixtures via special case 200 if (host === 'api.github.com') { 201 // Return null here so it goes through fetchFromFixtures which handles the fixture loading 202 return null 203 } 204 205 // esm.sh is handled specially via $fetch.raw override, not here 206 // Return null to indicate no mock available at the cachedFetch level 207 208 return null 209} 210 211/** 212 * Process a single package query for fast-npm-meta. 213 * Returns the metadata for a single package or null/error result. 214 */ 215async function processSingleFastNpmMeta( 216 packageQuery: string, 217 storage: ReturnType<typeof useStorage>, 218 metadata: boolean, 219): Promise<Record<string, unknown>> { 220 let packageName = packageQuery 221 let specifier = 'latest' 222 223 if (packageName.startsWith('@')) { 224 const atIndex = packageName.indexOf('@', 1) 225 if (atIndex !== -1) { 226 specifier = packageName.slice(atIndex + 1) 227 packageName = packageName.slice(0, atIndex) 228 } 229 } else { 230 const atIndex = packageName.indexOf('@') 231 if (atIndex !== -1) { 232 specifier = packageName.slice(atIndex + 1) 233 packageName = packageName.slice(0, atIndex) 234 } 235 } 236 237 // Special case: packages with "does-not-exist" in the name should 404 238 if (packageName.includes('does-not-exist') || packageName.includes('nonexistent')) { 239 return { error: 'not_found' } 240 } 241 242 const fixturePath = getFixturePath('packument', packageName) 243 const packument = await storage.getItem<any>(fixturePath) 244 245 if (!packument) { 246 // For unknown packages without the special markers, try to return stub data 247 // This is handled elsewhere - returning error here for fast-npm-meta 248 return { error: 'not_found' } 249 } 250 251 let version: string | undefined 252 if (specifier === 'latest' || !specifier) { 253 version = packument['dist-tags']?.latest 254 } else if (packument['dist-tags']?.[specifier]) { 255 version = packument['dist-tags'][specifier] 256 } else if (packument.versions?.[specifier]) { 257 version = specifier 258 } else { 259 version = packument['dist-tags']?.latest 260 } 261 262 if (!version) { 263 return { error: 'version_not_found' } 264 } 265 266 const result: Record<string, unknown> = { 267 name: packageName, 268 specifier, 269 version, 270 publishedAt: packument.time?.[version] || new Date().toISOString(), 271 lastSynced: Date.now(), 272 } 273 274 // Include metadata if requested 275 if (metadata) { 276 const versionData = packument.versions?.[version] 277 if (versionData?.deprecated) { 278 result.deprecated = versionData.deprecated 279 } 280 } 281 282 return result 283} 284 285/** 286 * Process a single package for the /versions/ endpoint. 287 * Returns PackageVersionsInfo shape: { name, distTags, versions, specifier, time, lastSynced } 288 */ 289async function processSingleVersionsMeta( 290 packageQuery: string, 291 storage: ReturnType<typeof useStorage>, 292 metadata: boolean, 293): Promise<Record<string, unknown>> { 294 let packageName = packageQuery 295 let specifier = '*' 296 297 if (packageName.startsWith('@')) { 298 const atIndex = packageName.indexOf('@', 1) 299 if (atIndex !== -1) { 300 specifier = packageName.slice(atIndex + 1) 301 packageName = packageName.slice(0, atIndex) 302 } 303 } else { 304 const atIndex = packageName.indexOf('@') 305 if (atIndex !== -1) { 306 specifier = packageName.slice(atIndex + 1) 307 packageName = packageName.slice(0, atIndex) 308 } 309 } 310 311 if (packageName.includes('does-not-exist') || packageName.includes('nonexistent')) { 312 return { name: packageName, error: 'not_found' } 313 } 314 315 const fixturePath = getFixturePath('packument', packageName) 316 const packument = await storage.getItem<any>(fixturePath) 317 318 if (!packument) { 319 return { name: packageName, error: 'not_found' } 320 } 321 322 const result: Record<string, unknown> = { 323 name: packageName, 324 specifier, 325 distTags: packument['dist-tags'] || {}, 326 versions: Object.keys(packument.versions || {}), 327 time: packument.time || {}, 328 lastSynced: Date.now(), 329 } 330 331 if (metadata) { 332 const versionsMeta: Record<string, Record<string, unknown>> = {} 333 for (const [ver, data] of Object.entries(packument.versions || {})) { 334 const meta: Record<string, unknown> = { version: ver } 335 const vData = data as Record<string, unknown> 336 if (vData.deprecated) meta.deprecated = vData.deprecated 337 if (packument.time?.[ver]) meta.time = packument.time[ver] 338 versionsMeta[ver] = meta 339 } 340 result.versionsMeta = versionsMeta 341 } 342 343 return result 344} 345 346async function handleFastNpmMeta( 347 url: string, 348 storage: ReturnType<typeof useStorage>, 349): Promise<MockResult | null> { 350 let urlObj: URL 351 try { 352 urlObj = new URL(url) 353 } catch { 354 return null 355 } 356 357 const { host, pathname, searchParams } = urlObj 358 359 if (host !== 'npm.antfu.dev') return null 360 361 const rawPath = decodeURIComponent(pathname.slice(1)) 362 if (!rawPath) return null 363 364 const metadata = searchParams.get('metadata') === 'true' 365 366 // Determine if this is a /versions/ request 367 const isVersions = rawPath.startsWith('versions/') 368 const pathPart = isVersions ? rawPath.slice('versions/'.length) : rawPath 369 const processFn = isVersions 370 ? (pkg: string) => processSingleVersionsMeta(pkg, storage, metadata) 371 : (pkg: string) => processSingleFastNpmMeta(pkg, storage, metadata) 372 373 // Handle batch requests (package1+package2+...) 374 if (pathPart.includes('+')) { 375 const packages = pathPart.split('+') 376 const results = await Promise.all(packages.map(processFn)) 377 return { data: results } 378 } 379 380 // Handle single package request 381 const result = await processFn(pathPart) 382 if ('error' in result) { 383 return { data: null } 384 } 385 return { data: result } 386} 387 388/** 389 * Handle GitHub API requests using fixtures. 390 */ 391async function handleGitHubApi( 392 url: string, 393 storage: ReturnType<typeof useStorage>, 394): Promise<MockResult | null> { 395 let urlObj: URL 396 try { 397 urlObj = new URL(url) 398 } catch { 399 return null 400 } 401 402 const { host, pathname } = urlObj 403 404 if (host !== 'api.github.com') return null 405 406 // Contributors stats endpoint: /repos/{owner}/{repo}/stats/contributors 407 const contributorsStatsMatch = pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/stats\/contributors$/) 408 if (contributorsStatsMatch) { 409 const contributorsStats = await storage.getItem<unknown[]>( 410 FIXTURE_PATHS.githubContributorsStats, 411 ) 412 if (contributorsStats) { 413 return { data: contributorsStats } 414 } 415 return { data: [] } 416 } 417 418 // Contributors endpoint: /repos/{owner}/{repo}/contributors 419 const contributorsMatch = pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/contributors$/) 420 if (contributorsMatch) { 421 const contributors = await storage.getItem<unknown[]>(FIXTURE_PATHS.githubContributors) 422 if (contributors) { 423 return { data: contributors } 424 } 425 // Return empty array if no fixture exists 426 return { data: [] } 427 } 428 429 // Other GitHub API endpoints can be added here as needed 430 return null 431} 432 433interface FixtureMatchWithVersion extends FixtureMatch { 434 version?: string // 'latest', a semver version, or undefined for full packument 435} 436 437function matchUrlToFixture(url: string): FixtureMatchWithVersion | null { 438 let urlObj: URL 439 try { 440 urlObj = new URL(url) 441 } catch { 442 return null 443 } 444 445 const { host, pathname, searchParams } = urlObj 446 447 // npm registry (registry.npmjs.org) 448 if (host === 'registry.npmjs.org') { 449 // Search endpoint 450 if (pathname === '/-/v1/search') { 451 const query = searchParams.get('text') 452 if (query) { 453 const maintainerMatch = query.match(/^maintainer:(.+)$/) 454 if (maintainerMatch?.[1]) { 455 return { type: 'user', name: maintainerMatch[1] } 456 } 457 return { type: 'search', name: query } 458 } 459 return { type: 'search', name: '' } 460 } 461 462 // Org packages 463 const orgMatch = pathname.match(/^\/-\/org\/([^/]+)\/package$/) 464 if (orgMatch?.[1]) { 465 return { type: 'org', name: orgMatch[1] } 466 } 467 468 // Packument - handle both full packument and version manifest requests 469 let packagePath = decodeURIComponent(pathname.slice(1)) 470 if (packagePath && !packagePath.startsWith('-/')) { 471 let version: string | undefined 472 473 if (packagePath.startsWith('@')) { 474 const parts = packagePath.split('/') 475 if (parts.length > 2) { 476 // @scope/name/version or @scope/name/latest 477 version = parts[2] 478 packagePath = `${parts[0]}/${parts[1]}` 479 } 480 // else just @scope/name - full packument 481 } else { 482 const slashIndex = packagePath.indexOf('/') 483 if (slashIndex !== -1) { 484 // name/version or name/latest 485 version = packagePath.slice(slashIndex + 1) 486 packagePath = packagePath.slice(0, slashIndex) 487 } 488 // else just name - full packument 489 } 490 491 return { type: 'packument', name: packagePath, version } 492 } 493 } 494 495 // npm API (api.npmjs.org) 496 if (host === 'api.npmjs.org') { 497 const downloadsMatch = pathname.match(/^\/downloads\/point\/[^/]+\/(.+)$/) 498 if (downloadsMatch?.[1]) { 499 return { type: 'downloads', name: decodeURIComponent(downloadsMatch[1]) } 500 } 501 } 502 503 return null 504} 505 506/** 507 * Log a message to stderr with clear formatting for unmocked requests. 508 */ 509function logUnmockedRequest(type: string, detail: string, url: string): void { 510 process.stderr.write( 511 `\n` + 512 `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` + 513 `[test-fixtures] ${type}\n` + 514 `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` + 515 `${detail}\n` + 516 `URL: ${url}\n` + 517 `\n` + 518 `To fix: Add a fixture file or update test/e2e/test-utils.ts\n` + 519 `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`, 520 ) 521} 522 523/** 524 * Shared fixture-backed fetch implementation. 525 * This is used by both cachedFetch and the global $fetch override. 526 */ 527async function fetchFromFixtures<T>( 528 url: string, 529 storage: ReturnType<typeof useStorage>, 530): Promise<CachedFetchResult<T>> { 531 // Check for mock responses (OSV, JSR) 532 const mockResult = getMockForUrl(url) 533 if (mockResult) { 534 if (VERBOSE) process.stdout.write(`[test-fixtures] Mock: ${url}\n`) 535 return { data: mockResult.data as T, isStale: false, cachedAt: Date.now() } 536 } 537 538 // Check for fast-npm-meta 539 const fastNpmMetaResult = await handleFastNpmMeta(url, storage) 540 if (fastNpmMetaResult) { 541 if (VERBOSE) process.stdout.write(`[test-fixtures] Fast-npm-meta: ${url}\n`) 542 return { data: fastNpmMetaResult.data as T, isStale: false, cachedAt: Date.now() } 543 } 544 545 // Check for GitHub API 546 const githubResult = await handleGitHubApi(url, storage) 547 if (githubResult) { 548 if (VERBOSE) process.stdout.write(`[test-fixtures] GitHub API: ${url}\n`) 549 return { data: githubResult.data as T, isStale: false, cachedAt: Date.now() } 550 } 551 552 const match = matchUrlToFixture(url) 553 554 if (!match) { 555 logUnmockedRequest('NO FIXTURE PATTERN', 'URL does not match any known fixture pattern', url) 556 throw createError({ 557 statusCode: 404, 558 statusMessage: 'No test fixture available', 559 message: `No fixture pattern matches URL: ${url}`, 560 }) 561 } 562 563 const fixturePath = getFixturePath(match.type, match.name) 564 const rawData = await storage.getItem<any>(fixturePath) 565 566 if (rawData === null) { 567 // For user searches or search queries without fixtures, return empty results 568 if (match.type === 'user' || match.type === 'search') { 569 if (VERBOSE) process.stdout.write(`[test-fixtures] Empty ${match.type}: ${match.name}\n`) 570 return { 571 data: { objects: [], total: 0, time: new Date().toISOString() } as T, 572 isStale: false, 573 cachedAt: Date.now(), 574 } 575 } 576 577 // For org packages without fixtures, return 404 578 if (match.type === 'org') { 579 throw createError({ 580 statusCode: 404, 581 statusMessage: 'Org not found', 582 message: `No fixture for org: ${match.name}`, 583 }) 584 } 585 586 // For packuments without fixtures, return a stub packument 587 // This allows tests to work without needing fixtures for every dependency 588 if (match.type === 'packument') { 589 // Special case: packages with "does-not-exist" in the name should 404 590 // This allows tests to verify 404 behavior for nonexistent packages 591 if (match.name.includes('does-not-exist') || match.name.includes('nonexistent')) { 592 throw createError({ 593 statusCode: 404, 594 statusMessage: 'Package not found', 595 message: `Package ${match.name} does not exist`, 596 }) 597 } 598 599 if (VERBOSE) process.stderr.write(`[test-fixtures] Stub packument: ${match.name}\n`) 600 const stubVersion = '1.0.0' 601 const stubPackument = { 602 'name': match.name, 603 'dist-tags': { latest: stubVersion }, 604 'versions': { 605 [stubVersion]: { 606 name: match.name, 607 version: stubVersion, 608 description: `Stub fixture for ${match.name}`, 609 dependencies: {}, 610 }, 611 }, 612 'time': { 613 created: new Date().toISOString(), 614 modified: new Date().toISOString(), 615 [stubVersion]: new Date().toISOString(), 616 }, 617 'maintainers': [], 618 } 619 620 // If a specific version was requested, return just that version manifest 621 if (match.version) { 622 return { 623 data: stubPackument.versions[stubVersion] as T, 624 isStale: false, 625 cachedAt: Date.now(), 626 } 627 } 628 629 return { 630 data: stubPackument as T, 631 isStale: false, 632 cachedAt: Date.now(), 633 } 634 } 635 636 // For downloads without fixtures, return zero downloads 637 if (match.type === 'downloads') { 638 if (VERBOSE) process.stderr.write(`[test-fixtures] Stub downloads: ${match.name}\n`) 639 return { 640 data: { 641 downloads: 0, 642 start: '2025-01-01', 643 end: '2025-01-31', 644 package: match.name, 645 } as T, 646 isStale: false, 647 cachedAt: Date.now(), 648 } 649 } 650 651 // Log missing fixture for unknown types 652 if (VERBOSE) { 653 process.stderr.write(`[test-fixtures] Missing: ${fixturePath}\n`) 654 } 655 656 throw createError({ 657 statusCode: 404, 658 statusMessage: 'Not found', 659 message: `No fixture for ${match.type}: ${match.name}`, 660 }) 661 } 662 663 // Handle version-specific requests for packuments (e.g., /create-vite/latest) 664 let data: T = rawData 665 if (match.type === 'packument' && match.version) { 666 const packument = rawData as any 667 let resolvedVersion = match.version 668 669 // Resolve 'latest' or dist-tags to actual version 670 if (packument['dist-tags']?.[resolvedVersion]) { 671 resolvedVersion = packument['dist-tags'][resolvedVersion] 672 } 673 674 // Return the version manifest instead of full packument 675 const versionData = packument.versions?.[resolvedVersion] 676 if (versionData) { 677 data = versionData as T 678 if (VERBOSE) 679 process.stdout.write( 680 `[test-fixtures] Served: ${match.type}/${match.name}@${resolvedVersion}\n`, 681 ) 682 } else { 683 if (VERBOSE) 684 process.stderr.write( 685 `[test-fixtures] Version not found: ${match.name}@${resolvedVersion}\n`, 686 ) 687 throw createError({ 688 statusCode: 404, 689 statusMessage: 'Version not found', 690 message: `No version ${resolvedVersion} in fixture for ${match.name}`, 691 }) 692 } 693 } else { 694 if (VERBOSE) process.stdout.write(`[test-fixtures] Served: ${match.type}/${match.name}\n`) 695 } 696 697 return { data, isStale: false, cachedAt: Date.now() } 698} 699 700/** 701 * Handle native fetch for esm.sh URLs. 702 */ 703async function handleEsmShFetch( 704 urlStr: string, 705 init: RequestInit | undefined, 706 storage: ReturnType<typeof useStorage>, 707): Promise<Response> { 708 const method = init?.method?.toUpperCase() || 'GET' 709 const urlObj = new URL(urlStr) 710 const pathname = urlObj.pathname.slice(1) // Remove leading / 711 712 // HEAD request - return headers with x-typescript-types if fixture exists 713 if (method === 'HEAD') { 714 // Extract package@version from pathname 715 let pkgVersion = pathname 716 const slashIndex = pkgVersion.indexOf( 717 '/', 718 pkgVersion.includes('@') ? pkgVersion.lastIndexOf('@') + 1 : 0, 719 ) 720 if (slashIndex !== -1) { 721 pkgVersion = pkgVersion.slice(0, slashIndex) 722 } 723 724 const fixturePath = `${FIXTURE_PATHS.esmHeaders}:${pkgVersion.replace(/\//g, ':')}.json` 725 const headerData = await storage.getItem<{ 'x-typescript-types': string }>(fixturePath) 726 727 if (headerData) { 728 if (VERBOSE) process.stdout.write(`[test-fixtures] fetch HEAD esm.sh: ${pkgVersion}\n`) 729 return new Response(null, { 730 status: 200, 731 headers: { 732 'x-typescript-types': headerData['x-typescript-types'], 733 'content-type': 'application/javascript', 734 }, 735 }) 736 } 737 738 // No fixture - return 200 without x-typescript-types header (types not available) 739 if (VERBOSE) 740 process.stdout.write(`[test-fixtures] fetch HEAD esm.sh (no fixture): ${pkgVersion}\n`) 741 return new Response(null, { 742 status: 200, 743 headers: { 'content-type': 'application/javascript' }, 744 }) 745 } 746 747 // GET request - return .d.ts content if fixture exists 748 if (method === 'GET' && pathname.endsWith('.d.ts')) { 749 const fixturePath = `${FIXTURE_PATHS.esmTypes}:${pathname.replace(/\//g, ':')}` 750 const content = await storage.getItem<string>(fixturePath) 751 752 if (content) { 753 if (VERBOSE) process.stdout.write(`[test-fixtures] fetch GET esm.sh: ${pathname}\n`) 754 return new Response(content, { 755 status: 200, 756 headers: { 'content-type': 'application/typescript' }, 757 }) 758 } 759 760 // Return a minimal stub .d.ts file instead of 404 761 // This allows docs tests to work without real type definition fixtures 762 if (VERBOSE) 763 process.stdout.write(`[test-fixtures] fetch GET esm.sh (stub types): ${pathname}\n`) 764 const stubTypes = `// Stub types for ${pathname} 765export declare function stubFunction(): void; 766export declare const stubConstant: string; 767export type StubType = string | number; 768export interface StubInterface { 769 value: string; 770} 771` 772 return new Response(stubTypes, { 773 status: 200, 774 headers: { 'content-type': 'application/typescript' }, 775 }) 776 } 777 778 // Other esm.sh requests - return empty response 779 return new Response(null, { status: 200 }) 780} 781 782export default defineNitroPlugin(nitroApp => { 783 const storage = useStorage('fixtures') 784 785 if (VERBOSE) { 786 process.stdout.write('[test-fixtures] Test mode active (verbose logging enabled)\n') 787 } 788 789 const originalFetch = globalThis.fetch 790 const original$fetch = globalThis.$fetch 791 792 // Override native fetch for esm.sh requests and to inject test fixture responses 793 globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit): Promise<Response> => { 794 const urlStr = 795 typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url 796 797 if ( 798 urlStr.startsWith('/') || 799 urlStr.startsWith('data:') || 800 urlStr.includes('woff') || 801 urlStr.includes('fonts') 802 ) { 803 return await originalFetch(input, init) 804 } 805 806 if (urlStr.startsWith('https://esm.sh/')) { 807 return await handleEsmShFetch(urlStr, init, storage) 808 } 809 810 try { 811 const res = await fetchFromFixtures(urlStr, storage) 812 if (res.data) { 813 return new Response(JSON.stringify(res.data), { 814 status: 200, 815 headers: { 'content-type': 'application/json' }, 816 }) 817 } 818 return new Response('Not Found', { status: 404 }) 819 } catch (err: any) { 820 // Convert createError exceptions to proper HTTP responses 821 const statusCode = err?.statusCode || err?.status || 404 822 const message = err?.message || 'Not Found' 823 return new Response(JSON.stringify({ error: message }), { 824 status: statusCode, 825 headers: { 'content-type': 'application/json' }, 826 }) 827 } 828 } 829 830 const $fetch = createFetch({ 831 fetch: globalThis.fetch, 832 }) 833 834 // Create the wrapper function for globalThis.$fetch 835 const fetchWrapper = async <T = unknown>( 836 url: string, 837 options?: Parameters<typeof $fetch>[1], 838 ): Promise<T> => { 839 if (typeof url === 'string' && !url.startsWith('/')) { 840 return $fetch<T>(url, options as any) 841 } 842 return original$fetch<T>(url, options as any) as any 843 } 844 845 // Copy .raw and .create from the created $fetch instance to the wrapper 846 Object.assign(fetchWrapper, { 847 raw: $fetch.raw, 848 create: $fetch.create, 849 }) 850 851 // Replace globalThis.$fetch with our wrapper (must be done AFTER setting .raw/.create) 852 // @ts-expect-error - wrapper function types don't fully match Nitro's $fetch types 853 globalThis.$fetch = fetchWrapper 854 855 // Per-request: set up cachedFetch on the event context 856 nitroApp.hooks.hook('request', event => { 857 event.context.cachedFetch = async (url: string, options?: any) => { 858 return { 859 data: await globalThis.$fetch(url, options), 860 isStale: false, 861 cachedAt: null, 862 } 863 } 864 }) 865})