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