@@ -316,171 +316,182 @@ function logUnmockedRequest(type: string, detail: string, url: string): void {
316316 )
317317}
318318
319- export default defineNitroPlugin ( nitroApp => {
320- const storage = useStorage ( 'fixtures' )
319+ /**
320+ * Shared fixture-backed fetch implementation.
321+ * This is used by both cachedFetch and the global $fetch override.
322+ */
323+ async function fetchFromFixtures < T > (
324+ url : string ,
325+ storage : ReturnType < typeof useStorage > ,
326+ ) : Promise < CachedFetchResult < T > > {
327+ // Check for mock responses (OSV, JSR)
328+ const mockResult = getMockForUrl ( url )
329+ if ( mockResult ) {
330+ if ( VERBOSE ) process . stdout . write ( `[test-fixtures] Mock: ${ url } \n` )
331+ return { data : mockResult . data as T , isStale : false , cachedAt : Date . now ( ) }
332+ }
321333
322- if ( VERBOSE ) {
323- process . stdout . write ( '[test-fixtures] Test mode active (verbose logging enabled)\n' )
334+ // Check for fast-npm-meta
335+ const fastNpmMetaResult = await handleFastNpmMeta ( url , storage )
336+ if ( fastNpmMetaResult ) {
337+ if ( VERBOSE ) process . stdout . write ( `[test-fixtures] Fast-npm-meta: ${ url } \n` )
338+ return { data : fastNpmMetaResult . data as T , isStale : false , cachedAt : Date . now ( ) }
324339 }
325340
326- nitroApp . hooks . hook ( 'request' , event => {
327- event . context . cachedFetch = async < T = unknown > (
328- url : string ,
329- _options ?: Parameters < typeof $fetch > [ 1 ] ,
330- _ttl ?: number ,
331- ) : Promise < CachedFetchResult < T > > => {
332- // Check for mock responses (OSV, JSR)
333- const mockResult = getMockForUrl ( url )
334- if ( mockResult ) {
335- if ( VERBOSE ) process . stdout . write ( `[test-fixtures] Mock: ${ url } \n` )
336- return { data : mockResult . data as T , isStale : false , cachedAt : Date . now ( ) }
337- }
341+ const match = matchUrlToFixture ( url )
338342
339- // Check for fast-npm-meta
340- const fastNpmMetaResult = await handleFastNpmMeta ( url , storage )
341- if ( fastNpmMetaResult ) {
342- if ( VERBOSE ) process . stdout . write ( `[test-fixtures] Fast-npm-meta: ${ url } \n` )
343- return { data : fastNpmMetaResult . data as T , isStale : false , cachedAt : Date . now ( ) }
344- }
343+ if ( ! match ) {
344+ logUnmockedRequest ( 'NO FIXTURE PATTERN' , 'URL does not match any known fixture pattern' , url )
345+ throw createError ( {
346+ statusCode : 404 ,
347+ statusMessage : 'No test fixture available' ,
348+ message : `No fixture pattern matches URL: ${ url } ` ,
349+ } )
350+ }
345351
346- const match = matchUrlToFixture ( url )
352+ const fixturePath = getFixturePath ( match . type , match . name )
353+ const data = await storage . getItem < T > ( fixturePath )
347354
348- if ( ! match ) {
349- logUnmockedRequest (
350- 'NO FIXTURE PATTERN' ,
351- 'URL does not match any known fixture pattern' ,
352- url ,
353- )
354- throw createError ( {
355- statusCode : 404 ,
356- statusMessage : 'No test fixture available' ,
357- message : `No fixture pattern matches URL: ${ url } ` ,
358- } )
355+ if ( data === null ) {
356+ // For user searches or search queries without fixtures, return empty results
357+ if ( match . type === 'user' || match . type === 'search' ) {
358+ if ( VERBOSE ) process . stdout . write ( `[test-fixtures] Empty ${ match . type } : ${ match . name } \n` )
359+ return {
360+ data : { objects : [ ] , total : 0 , time : new Date ( ) . toISOString ( ) } as T ,
361+ isStale : false ,
362+ cachedAt : Date . now ( ) ,
359363 }
364+ }
360365
361- const fixturePath = getFixturePath ( match . type , match . name )
362- const data = await storage . getItem < T > ( fixturePath )
363-
364- if ( data === null ) {
365- // For user searches or search queries without fixtures, return empty results
366- if ( match . type === 'user' || match . type === 'search' ) {
367- if ( VERBOSE ) process . stdout . write ( `[test-fixtures] Empty ${ match . type } : ${ match . name } \n` )
368- return {
369- data : { objects : [ ] , total : 0 , time : new Date ( ) . toISOString ( ) } as T ,
370- isStale : false ,
371- cachedAt : Date . now ( ) ,
372- }
373- }
366+ // Log missing fixture (but don't spam - these are often expected for dependencies)
367+ if ( VERBOSE ) {
368+ process . stderr . write ( `[test-fixtures] Missing: ${ fixturePath } \n` )
369+ }
374370
375- // Log missing fixture (but don't spam - these are often expected for dependencies)
376- if ( VERBOSE ) {
377- process . stderr . write ( `[test-fixtures] Missing: ${ fixturePath } \n` )
378- }
371+ throw createError ( {
372+ statusCode : 404 ,
373+ statusMessage : 'Package not found' ,
374+ message : `No fixture for ${ match . type } : ${ match . name } ` ,
375+ } )
376+ }
379377
380- throw createError ( {
381- statusCode : 404 ,
382- statusMessage : 'Package not found' ,
383- message : `No fixture for ${ match . type } : ${ match . name } ` ,
384- } )
385- }
378+ if ( VERBOSE ) process . stdout . write ( `[test-fixtures] Served: ${ match . type } /${ match . name } \n` )
386379
387- if ( VERBOSE ) process . stdout . write ( `[test-fixtures] Served: ${ match . type } /${ match . name } \n` )
380+ return { data, isStale : false , cachedAt : Date . now ( ) }
381+ }
388382
389- return { data, isStale : false , cachedAt : Date . now ( ) }
390- }
383+ /**
384+ * Handle native fetch for esm.sh URLs.
385+ */
386+ async function handleEsmShFetch (
387+ urlStr : string ,
388+ init : RequestInit | undefined ,
389+ storage : ReturnType < typeof useStorage > ,
390+ ) : Promise < Response | null > {
391+ if ( ! urlStr . startsWith ( 'https://esm.sh/' ) ) {
392+ return null
393+ }
391394
392- const original$Fetch = globalThis . $fetch
393- const originalFetch = globalThis . fetch
395+ const method = init ?. method ?. toUpperCase ( ) || 'GET'
396+ const urlObj = new URL ( urlStr )
397+ const pathname = urlObj . pathname . slice ( 1 ) // Remove leading /
394398
395- globalThis . fetch = async ( url : URL | RequestInfo , init ?: RequestInit ) : Promise < Response > => {
396- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url . toString ( ) : url . url
399+ // HEAD request - return headers with x-typescript-types if fixture exists
400+ if ( method === 'HEAD' ) {
401+ // Extract package@version from pathname
402+ let pkgVersion = pathname
403+ const slashIndex = pkgVersion . indexOf (
404+ '/' ,
405+ pkgVersion . includes ( '@' ) ? pkgVersion . lastIndexOf ( '@' ) + 1 : 0 ,
406+ )
407+ if ( slashIndex !== - 1 ) {
408+ pkgVersion = pkgVersion . slice ( 0 , slashIndex )
409+ }
397410
398- // Handle esm.sh requests specially (used by docs feature)
399- if ( urlStr . startsWith ( 'https://esm.sh/' ) ) {
400- const method = init ?. method ?. toUpperCase ( ) || 'GET'
401- const urlObj = new URL ( urlStr )
402- const pathname = urlObj . pathname . slice ( 1 ) // Remove leading /
403-
404- // HEAD request - return headers with x-typescript-types if fixture exists
405- if ( method === 'HEAD' ) {
406- // Extract package@version from pathname
407- let pkgVersion = pathname
408- const slashIndex = pkgVersion . indexOf (
409- '/' ,
410- pkgVersion . includes ( '@' ) ? pkgVersion . lastIndexOf ( '@' ) + 1 : 0 ,
411- )
412- if ( slashIndex !== - 1 ) {
413- pkgVersion = pkgVersion . slice ( 0 , slashIndex )
414- }
415-
416- const fixturePath = `${ FIXTURE_PATHS . esmHeaders } :${ pkgVersion . replace ( / \/ / g, ':' ) } .json`
417- const headerData = await storage . getItem < { 'x-typescript-types' : string } > ( fixturePath )
418-
419- if ( headerData ) {
420- if ( VERBOSE ) process . stdout . write ( `[test-fixtures] fetch HEAD esm.sh: ${ pkgVersion } \n` )
421- return new Response ( null , {
422- status : 200 ,
423- headers : {
424- 'x-typescript-types' : headerData [ 'x-typescript-types' ] ,
425- 'content-type' : 'application/javascript' ,
426- } ,
427- } )
428- }
429-
430- // No fixture - return 200 without x-typescript-types header (types not available)
431- if ( VERBOSE )
432- process . stdout . write ( `[test-fixtures] fetch HEAD esm.sh (no fixture): ${ pkgVersion } \n` )
433- return new Response ( null , {
434- status : 200 ,
435- headers : { 'content-type' : 'application/javascript' } ,
436- } )
437- }
411+ const fixturePath = `${ FIXTURE_PATHS . esmHeaders } :${ pkgVersion . replace ( / \/ / g, ':' ) } .json`
412+ const headerData = await storage . getItem < { 'x-typescript-types' : string } > ( fixturePath )
438413
439- // GET request - return .d.ts content if fixture exists
440- if ( method === 'GET' && pathname . endsWith ( '.d.ts' ) ) {
441- const fixturePath = `${ FIXTURE_PATHS . esmTypes } :${ pathname . replace ( / \/ / g, ':' ) } `
442- const content = await storage . getItem < string > ( fixturePath )
443-
444- if ( content ) {
445- if ( VERBOSE ) process . stdout . write ( `[test-fixtures] fetch GET esm.sh: ${ pathname } \n` )
446- return new Response ( content , {
447- status : 200 ,
448- headers : { 'content-type' : 'application/typescript' } ,
449- } )
450- }
451-
452- // No fixture - return 404 for missing .d.ts files
453- if ( VERBOSE )
454- process . stdout . write ( `[test-fixtures] fetch GET esm.sh (no fixture): ${ pathname } \n` )
455- return new Response ( 'Not Found' , {
456- status : 404 ,
457- headers : { 'content-type' : 'text/plain' } ,
458- } )
459- }
414+ if ( headerData ) {
415+ if ( VERBOSE ) process . stdout . write ( `[test-fixtures] fetch HEAD esm.sh: ${ pkgVersion } \n` )
416+ return new Response ( null , {
417+ status : 200 ,
418+ headers : {
419+ 'x-typescript-types' : headerData [ 'x-typescript-types' ] ,
420+ 'content-type' : 'application/javascript' ,
421+ } ,
422+ } )
423+ }
460424
461- // Other esm.sh requests - return empty response
462- return new Response ( null , { status : 200 } )
463- }
425+ // No fixture - return 200 without x-typescript-types header (types not available)
426+ if ( VERBOSE )
427+ process . stdout . write ( `[test-fixtures] fetch HEAD esm.sh (no fixture): ${ pkgVersion } \n` )
428+ return new Response ( null , {
429+ status : 200 ,
430+ headers : { 'content-type' : 'application/javascript' } ,
431+ } )
432+ }
464433
465- return originalFetch ( url , init )
434+ // GET request - return .d.ts content if fixture exists
435+ if ( method === 'GET' && pathname . endsWith ( '.d.ts' ) ) {
436+ const fixturePath = `${ FIXTURE_PATHS . esmTypes } :${ pathname . replace ( / \/ / g, ':' ) } `
437+ const content = await storage . getItem < string > ( fixturePath )
438+
439+ if ( content ) {
440+ if ( VERBOSE ) process . stdout . write ( `[test-fixtures] fetch GET esm.sh: ${ pathname } \n` )
441+ return new Response ( content , {
442+ status : 200 ,
443+ headers : { 'content-type' : 'application/typescript' } ,
444+ } )
466445 }
467446
468- // @ts -expect-error invalid global augmentation
469- globalThis . $fetch = async ( url , options ) => {
470- if ( typeof url === 'string' && url . startsWith ( '/' ) ) {
471- return original$Fetch ( url , options )
447+ // No fixture - return 404 for missing .d.ts files
448+ if ( VERBOSE )
449+ process . stdout . write ( `[test-fixtures] fetch GET esm.sh (no fixture): ${ pathname } \n` )
450+ return new Response ( 'Not Found' , {
451+ status : 404 ,
452+ headers : { 'content-type' : 'text/plain' } ,
453+ } )
454+ }
455+
456+ // Other esm.sh requests - return empty response
457+ return new Response ( null , { status : 200 } )
458+ }
459+
460+ // Flag to ensure we only patch globals once
461+ let globalsPatched = false
462+
463+ export default defineNitroPlugin ( nitroApp => {
464+ const storage = useStorage ( 'fixtures' )
465+
466+ if ( VERBOSE ) {
467+ process . stdout . write ( '[test-fixtures] Test mode active (verbose logging enabled)\n' )
468+ }
469+
470+ // Patch global fetch/$fetch once, not per-request
471+ if ( ! globalsPatched ) {
472+ globalsPatched = true
473+
474+ const originalFetch = globalThis . fetch
475+ const original$FetchRaw = globalThis . $fetch . raw
476+
477+ // Override native fetch for esm.sh requests
478+ globalThis . fetch = async ( input : URL | RequestInfo , init ?: RequestInit ) : Promise < Response > => {
479+ const urlStr =
480+ typeof input === 'string' ? input : input instanceof URL ? input . toString ( ) : input . url
481+
482+ const esmResponse = await handleEsmShFetch ( urlStr , init , storage )
483+ if ( esmResponse ) {
484+ return esmResponse
472485 }
473486
474- const { data } = await event . context . cachedFetch ! < any > ( url as string , options )
475- return data
487+ return originalFetch ( input , init )
476488 }
477489
478- // Also override $fetch.raw for esm.sh
479- const originalFetchRaw = globalThis . $fetch . raw
480- // @ts -expect-error invalid global augmentation
481- globalThis . $fetch . raw = async ( url , options ) => {
490+ // Override $fetch.raw for esm.sh requests
491+ // @ts -expect-error - modifying global
492+ globalThis . $fetch . raw = async ( url : string , options ?: any ) => {
482493 if ( typeof url === 'string' && url . startsWith ( '/' ) ) {
483- return originalFetchRaw ( url , options )
494+ return original$FetchRaw ( url , options )
484495 }
485496
486497 // Handle esm.sh requests specially (used by docs feature)
@@ -489,15 +500,33 @@ export default defineNitroPlugin(nitroApp => {
489500 if ( esmResult !== null ) {
490501 return esmResult
491502 }
492- // If no fixture found, throw an error in test mode
493- throw createError ( {
494- statusCode : 404 ,
495- statusMessage : 'No esm.sh fixture available' ,
496- message : `No fixture for esm.sh URL: ${ url } ` ,
497- } )
503+ // No fixture - return response without types (graceful degradation)
504+ if ( VERBOSE )
505+ process . stdout . write ( `[test-fixtures] $fetch.raw esm.sh (no fixture): ${ url } \n` )
506+ return {
507+ status : 200 ,
508+ statusText : 'OK' ,
509+ url,
510+ headers : new Headers ( { 'content-type' : 'application/javascript' } ) ,
511+ _data : null ,
512+ }
498513 }
499514
500- return originalFetchRaw ( url , options )
515+ return original$FetchRaw ( url , options )
516+ }
517+
518+ // Note: We don't override $fetch itself globally because it needs
519+ // access to event.context.cachedFetch which is per-request
520+ }
521+
522+ // Per-request: set up cachedFetch on the event context
523+ nitroApp . hooks . hook ( 'request' , event => {
524+ event . context . cachedFetch = < T = unknown > (
525+ url : string ,
526+ _options ?: Parameters < typeof $fetch > [ 1 ] ,
527+ _ttl ?: number ,
528+ ) : Promise < CachedFetchResult < T > > => {
529+ return fetchFromFixtures < T > ( url , storage )
501530 }
502531 } )
503532} )
0 commit comments