@@ -23,6 +23,8 @@ const FIXTURE_PATHS = {
2323 org : 'npm-registry/orgs' ,
2424 downloads : 'npm-api/downloads' ,
2525 user : 'users' ,
26+ esmHeaders : 'esm-sh/headers' ,
27+ esmTypes : 'esm-sh/types' ,
2628} as const
2729
2830type FixtureType = keyof typeof FIXTURE_PATHS
@@ -84,6 +86,9 @@ function getMockForUrl(url: string): MockResult | null {
8486 return { data : null }
8587 }
8688
89+ // esm.sh is handled specially via $fetch.raw override, not here
90+ // Return null to indicate no mock available at the cachedFetch level
91+
8792 return null
8893}
8994
@@ -152,6 +157,114 @@ async function handleFastNpmMeta(
152157 }
153158}
154159
160+ /**
161+ * Handle esm.sh requests for the standard $fetch (returns data directly).
162+ */
163+ async function handleEsmShRequest (
164+ url : string ,
165+ _options : unknown ,
166+ storage : ReturnType < typeof useStorage > ,
167+ ) : Promise < unknown | null > {
168+ const urlObj = new URL ( url )
169+ if ( urlObj . hostname !== 'esm.sh' ) return null
170+
171+ // For GET requests to .d.ts files, return the file content
172+ const typesPath = urlObj . pathname . slice ( 1 ) // Remove leading /
173+ if ( typesPath . endsWith ( '.d.ts' ) ) {
174+ const fixturePath = `${ FIXTURE_PATHS . esmTypes } :${ typesPath . replace ( / \/ / g, ':' ) } `
175+ const content = await storage . getItem < string > ( fixturePath )
176+ if ( content ) {
177+ if ( VERBOSE ) process . stdout . write ( `[test-fixtures] esm.sh types: ${ typesPath } \n` )
178+ return content
179+ }
180+ }
181+
182+ return null
183+ }
184+
185+ /**
186+ * Handle esm.sh requests for $fetch.raw (returns response object with headers).
187+ */
188+ async function handleEsmShRawRequest (
189+ url : string ,
190+ options : unknown ,
191+ storage : ReturnType < typeof useStorage > ,
192+ ) : Promise < unknown | null > {
193+ const urlObj = new URL ( url )
194+ if ( urlObj . hostname !== 'esm.sh' ) return null
195+
196+ const pathname = urlObj . pathname . slice ( 1 ) // Remove leading /
197+ const opts = options as { method ?: string } | undefined
198+
199+ // HEAD request - return headers with x-typescript-types
200+ if ( opts ?. method === 'HEAD' ) {
201+ // Extract package@version from pathname (e.g., "ufo@1.6.3" or "@scope/pkg@1.0.0")
202+ let pkgVersion = pathname
203+ // Remove any trailing path after the version
204+ const slashIndex = pkgVersion . indexOf (
205+ '/' ,
206+ pkgVersion . includes ( '@' ) ? pkgVersion . lastIndexOf ( '@' ) + 1 : 0 ,
207+ )
208+ if ( slashIndex !== - 1 ) {
209+ pkgVersion = pkgVersion . slice ( 0 , slashIndex )
210+ }
211+
212+ const fixturePath = `${ FIXTURE_PATHS . esmHeaders } :${ pkgVersion . replace ( / \/ / g, ':' ) } .json`
213+ const headerData = await storage . getItem < { 'x-typescript-types' : string } > ( fixturePath )
214+
215+ if ( headerData ) {
216+ if ( VERBOSE ) process . stdout . write ( `[test-fixtures] esm.sh HEAD: ${ pkgVersion } \n` )
217+ // Return a mock response object similar to what $fetch.raw returns
218+ return {
219+ status : 200 ,
220+ statusText : 'OK' ,
221+ url,
222+ headers : new Headers ( {
223+ 'x-typescript-types' : headerData [ 'x-typescript-types' ] ,
224+ 'content-type' : 'application/javascript' ,
225+ } ) ,
226+ _data : null ,
227+ }
228+ }
229+
230+ // No fixture - return empty response (no types available)
231+ if ( VERBOSE ) process . stdout . write ( `[test-fixtures] esm.sh HEAD (no fixture): ${ pkgVersion } \n` )
232+ return {
233+ status : 200 ,
234+ statusText : 'OK' ,
235+ url,
236+ headers : new Headers ( {
237+ 'content-type' : 'application/javascript' ,
238+ } ) ,
239+ _data : null ,
240+ }
241+ }
242+
243+ // GET request - return .d.ts content
244+ if ( pathname . endsWith ( '.d.ts' ) || pathname . includes ( '.d.ts' ) ) {
245+ const fixturePath = `${ FIXTURE_PATHS . esmTypes } :${ pathname . replace ( / \/ / g, ':' ) } `
246+ const content = await storage . getItem < string > ( fixturePath )
247+
248+ if ( content ) {
249+ if ( VERBOSE ) process . stdout . write ( `[test-fixtures] esm.sh GET: ${ pathname } \n` )
250+ // Create a blob-like response
251+ return {
252+ status : 200 ,
253+ statusText : 'OK' ,
254+ url,
255+ headers : new Headers ( {
256+ 'content-type' : 'application/typescript' ,
257+ } ) ,
258+ _data : {
259+ text : async ( ) => content ,
260+ } ,
261+ }
262+ }
263+ }
264+
265+ return null
266+ }
267+
155268function matchUrlToFixture ( url : string ) : FixtureMatch | null {
156269 let urlObj : URL
157270 try {
@@ -236,6 +349,80 @@ export default defineNitroPlugin(nitroApp => {
236349 process . stdout . write ( '[test-fixtures] Test mode active (verbose logging enabled)\n' )
237350 }
238351
352+ // Override native fetch globally to intercept esm.sh requests from ofetch
353+ // This is needed because server/utils/docs/client.ts imports $fetch from ofetch
354+ // directly, bypassing our globalThis.$fetch override
355+ const originalNativeFetch = globalThis . fetch
356+ globalThis . fetch = async ( input : RequestInfo | URL , init ?: RequestInit ) => {
357+ const url =
358+ typeof input === 'string' ? input : input instanceof URL ? input . toString ( ) : input . url
359+ if ( url . includes ( 'esm.sh' ) ) {
360+ const method = init ?. method || 'GET'
361+
362+ // HEAD request - return headers with x-typescript-types
363+ if ( method === 'HEAD' ) {
364+ const urlObj = new URL ( url )
365+ const pathname = urlObj . pathname . slice ( 1 )
366+ let pkgVersion = pathname
367+ const slashIndex = pkgVersion . indexOf (
368+ '/' ,
369+ pkgVersion . includes ( '@' ) ? pkgVersion . lastIndexOf ( '@' ) + 1 : 0 ,
370+ )
371+ if ( slashIndex !== - 1 ) {
372+ pkgVersion = pkgVersion . slice ( 0 , slashIndex )
373+ }
374+
375+ const fixturePath = `${ FIXTURE_PATHS . esmHeaders } :${ pkgVersion . replace ( / \/ / g, ':' ) } .json`
376+ const headerData = await storage . getItem < { 'x-typescript-types' : string } > ( fixturePath )
377+
378+ if ( headerData ) {
379+ if ( VERBOSE ) process . stdout . write ( `[test-fixtures] fetch HEAD esm.sh: ${ pkgVersion } \n` )
380+ return new Response ( null , {
381+ status : 200 ,
382+ headers : new Headers ( {
383+ 'x-typescript-types' : headerData [ 'x-typescript-types' ] ,
384+ 'content-type' : 'application/javascript' ,
385+ } ) ,
386+ } )
387+ }
388+
389+ // No fixture - return response without types header
390+ if ( VERBOSE )
391+ process . stdout . write ( `[test-fixtures] fetch HEAD esm.sh (no fixture): ${ pkgVersion } \n` )
392+ return new Response ( null , {
393+ status : 200 ,
394+ headers : new Headers ( { 'content-type' : 'application/javascript' } ) ,
395+ } )
396+ }
397+
398+ // GET request for .d.ts files
399+ if ( method === 'GET' ) {
400+ const urlObj = new URL ( url )
401+ const pathname = urlObj . pathname . slice ( 1 )
402+ const fixturePath = `${ FIXTURE_PATHS . esmTypes } :${ pathname . replace ( / \/ / g, ':' ) } `
403+ const content = await storage . getItem < string > ( fixturePath )
404+
405+ if ( content ) {
406+ if ( VERBOSE ) process . stdout . write ( `[test-fixtures] fetch GET esm.sh: ${ pathname } \n` )
407+ return new Response ( content , {
408+ status : 200 ,
409+ headers : new Headers ( { 'content-type' : 'application/typescript' } ) ,
410+ } )
411+ }
412+
413+ // No fixture - return 404 so the loader skips this file
414+ if ( VERBOSE )
415+ process . stdout . write ( `[test-fixtures] fetch GET esm.sh (no fixture): ${ pathname } \n` )
416+ return new Response ( 'Not Found' , {
417+ status : 404 ,
418+ headers : new Headers ( { 'content-type' : 'text/plain' } ) ,
419+ } )
420+ }
421+ }
422+
423+ return originalNativeFetch ( input , init )
424+ }
425+
239426 nitroApp . hooks . hook ( 'request' , event => {
240427 event . context . cachedFetch = async < T = unknown > (
241428 url : string ,
@@ -309,8 +496,48 @@ export default defineNitroPlugin(nitroApp => {
309496 if ( typeof url === 'string' && url . startsWith ( '/' ) ) {
310497 return originalFetch ( url , options )
311498 }
499+
500+ // Handle esm.sh requests specially (used by docs feature)
501+ if ( typeof url === 'string' && url . includes ( 'esm.sh' ) ) {
502+ const esmResult = await handleEsmShRequest ( url , options , storage )
503+ if ( esmResult !== null ) {
504+ return esmResult
505+ }
506+ // If no fixture found, throw an error in test mode
507+ throw createError ( {
508+ statusCode : 404 ,
509+ statusMessage : 'No esm.sh fixture available' ,
510+ message : `No fixture for esm.sh URL: ${ url } ` ,
511+ } )
512+ }
513+
312514 const { data } = await event . context . cachedFetch ! < any > ( url as string , options )
313515 return data
314516 }
517+
518+ // Also override $fetch.raw for esm.sh
519+ const originalFetchRaw = globalThis . $fetch . raw
520+ // @ts -expect-error invalid global augmentation
521+ globalThis . $fetch . raw = async ( url , options ) => {
522+ if ( typeof url === 'string' && url . startsWith ( '/' ) ) {
523+ return originalFetchRaw ( url , options )
524+ }
525+
526+ // Handle esm.sh requests specially (used by docs feature)
527+ if ( typeof url === 'string' && url . includes ( 'esm.sh' ) ) {
528+ const esmResult = await handleEsmShRawRequest ( url , options , storage )
529+ if ( esmResult !== null ) {
530+ return esmResult
531+ }
532+ // If no fixture found, throw an error in test mode
533+ throw createError ( {
534+ statusCode : 404 ,
535+ statusMessage : 'No esm.sh fixture available' ,
536+ message : `No fixture for esm.sh URL: ${ url } ` ,
537+ } )
538+ }
539+
540+ return originalFetchRaw ( url , options )
541+ }
315542 } )
316543} )
0 commit comments