@@ -10,11 +10,27 @@ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
1010import { createRequire } from 'node:module'
1111import { Readable } from 'node:stream'
1212import { lookup } from 'mrmime'
13+ import { createDebug } from 'obug'
1314import { dirname , extname , join , normalize , sep } from 'pathe'
1415import { diagnostics } from '../node/diagnostics'
1516
17+ const debugFetch = createDebug ( 'devframe:remote-assets:fetch' )
18+ const debugCache = createDebug ( 'devframe:remote-assets:cache' )
19+
1620const MANIFEST_FILENAME = '.manifest.json'
1721
22+ /**
23+ * Upstream response headers replayed to the browser. Everything outside this
24+ * list is dropped, because it describes the *provider's* transfer rather than
25+ * the file: hop-by-hop and encoding headers no longer match the body `fetch`
26+ * already decoded, and a CDN's policy headers (`set-cookie`, `cache-control`,
27+ * framing/CSP) belong to its origin — replaying them under the dev server's
28+ * origin could just as well break the iframe these assets render in.
29+ */
30+ const PROXIED_HEADERS = [ 'content-language' , 'etag' , 'last-modified' ] as const
31+
32+ const CACHE_CONTROL_HEADER = 'no-store'
33+
1834// ---------------------------------------------------------------------------
1935// Providers
2036// ---------------------------------------------------------------------------
@@ -48,18 +64,22 @@ const providers: Record<'jsdelivr' | 'unpkg', Required<RemoteAssetsProviderCusto
4864 jsdelivr : {
4965 fileUrl : ( pkg , version , filePath ) => `https://cdn.jsdelivr.net/npm/${ pkg } @${ version } /${ filePath } ` ,
5066 listFiles : async ( pkg , version , fetchImpl ) => {
51- const res = await fetchImpl ( `https://data.jsdelivr.com/v1/packages/npm/${ pkg } @${ version } ` )
67+ const url = `https://data.jsdelivr.com/v1/packages/npm/${ pkg } @${ version } `
68+ debugFetch ( 'listing files for %s@%s from %s' , pkg , version , url )
69+ const res = await fetchImpl ( url )
5270 if ( ! res . ok )
53- throw new Error ( `HTTP ${ res . status } from data.jsdelivr.com ` )
71+ throw new Error ( `HTTP ${ res . status } from ${ url } ` )
5472 return flattenTree ( ( await res . json ( ) as { files ?: TreeNode [ ] } ) . files ?? [ ] , 'name' )
5573 } ,
5674 } ,
5775 unpkg : {
5876 fileUrl : ( pkg , version , filePath ) => `https://unpkg.com/${ pkg } @${ version } /${ filePath } ` ,
5977 listFiles : async ( pkg , version , fetchImpl ) => {
60- const res = await fetchImpl ( `https://unpkg.com/${ pkg } @${ version } /?meta` )
78+ const url = `https://unpkg.com/${ pkg } @${ version } /?meta`
79+ debugFetch ( 'listing files for %s@%s from %s' , pkg , version , url )
80+ const res = await fetchImpl ( url )
6181 if ( ! res . ok )
62- throw new Error ( `HTTP ${ res . status } from unpkg.com ` )
82+ throw new Error ( `HTTP ${ res . status } from ${ url } ` )
6383 return flattenTree ( [ await res . json ( ) as TreeNode ] , 'path' )
6484 } ,
6585 } ,
@@ -81,7 +101,7 @@ function resolveProvider(assets: RemoteAssets): { provider: RemoteAssetsProvider
81101 * installed version warns (`DF0062`); a different major throws (`DF0061`).
82102 */
83103function resolveInstalled ( assets : RemoteAssets ) : string | undefined {
84- if ( ! assets . resolveFrom )
104+ if ( assets . resolveFrom == null )
85105 return undefined
86106 let pkgJsonPath : string
87107 let installed : unknown
@@ -116,6 +136,30 @@ function contentTypeFor(filePath: string): string {
116136 return type === 'text/html' ? 'text/html; charset=utf-8' : type
117137}
118138
139+ /**
140+ * Headers for a file streamed through from the provider. `Content-Type` and
141+ * `Cache-Control` are ours, so a file looks identical whether it came from the
142+ * provider or from the cache ({@link createStore}'s `serveCached`).
143+ */
144+ function proxyHeaders ( filePath : string , upstream : Headers ) : Headers {
145+ const headers = new Headers ( {
146+ 'Content-Type' : contentTypeFor ( filePath ) ,
147+ 'Cache-Control' : CACHE_CONTROL_HEADER ,
148+ } )
149+ // `fetch` decodes the body, so an encoded response's `Content-Length`
150+ // counts bytes the browser will never see — it only survives verbatim.
151+ const encoding = upstream . get ( 'content-encoding' )
152+ const length = upstream . get ( 'content-length' )
153+ if ( length && ( ! encoding || encoding === 'identity' ) )
154+ headers . set ( 'Content-Length' , length )
155+ for ( const name of PROXIED_HEADERS ) {
156+ const value = upstream . get ( name )
157+ if ( value != null )
158+ headers . set ( name , value )
159+ }
160+ return headers
161+ }
162+
119163/** Clean a request path into a safe package-relative POSIX path, or `null` if it escapes root. */
120164function cleanRequestPath ( urlPath : string ) : string | null {
121165 let cleaned : string
@@ -155,13 +199,19 @@ function createStore(assets: RemoteAssets, cacheDir: string): RemoteAssetsStore
155199
156200 async function loadManifest ( ) : Promise < Set < string > | null > {
157201 const manifestFile = join ( cacheDir , MANIFEST_FILENAME )
158- try {
159- return new Set ( JSON . parse ( await readFile ( manifestFile , 'utf8' ) ) as string [ ] )
202+ if ( existsSync ( manifestFile ) ) {
203+ try {
204+ return new Set ( JSON . parse ( await readFile ( manifestFile , 'utf8' ) ) as string [ ] )
205+ }
206+ catch { }
160207 }
161- catch { }
162208 if ( assets . offline || ! provider . listFiles )
163209 return null
164210 try {
211+ // A listing failure is not fatal — requests degrade to probing the
212+ // provider per candidate. Only a file that can't be fetched at all
213+ // surfaces to the user (`DF0060`, and the fallback page that carries
214+ // it into the viewer — see `remoteErrorPage` in `serve-static`).
165215 const files = await provider . listFiles ( normalized . package , normalized . version , fetchImpl )
166216 await mkdir ( cacheDir , { recursive : true } )
167217 await writeFile ( manifestFile , JSON . stringify ( files ) , 'utf8' ) . catch ( ( ) => { } )
@@ -188,8 +238,13 @@ function createStore(assets: RemoteAssets, cacheDir: string): RemoteAssetsStore
188238 catch {
189239 return null
190240 }
241+ debugCache ( 'serving %s from cache (%d bytes)' , filePath , size )
191242 return new Response ( Readable . toWeb ( createReadStream ( abs ) ) as ReadableStream < Uint8Array > , {
192- headers : { 'Content-Type' : contentTypeFor ( filePath ) , 'Content-Length' : String ( size ) , 'Cache-Control' : 'no-store' } ,
243+ headers : {
244+ 'Content-Type' : contentTypeFor ( filePath ) ,
245+ 'Content-Length' : String ( size ) ,
246+ 'Cache-Control' : CACHE_CONTROL_HEADER ,
247+ } ,
193248 } )
194249 }
195250
@@ -213,6 +268,7 @@ function createStore(assets: RemoteAssets, cacheDir: string): RemoteAssetsStore
213268 const url = provider . fileUrl ( normalized . package , normalized . version , filePath )
214269 let res : Response
215270 try {
271+ debugFetch ( 'fetching %s from %s' , filePath , url )
216272 res = await fetchImpl ( url )
217273 }
218274 catch ( error ) {
@@ -226,11 +282,15 @@ function createStore(assets: RemoteAssets, cacheDir: string): RemoteAssetsStore
226282 await res . body ?. cancel ( ) . catch ( ( ) => { } )
227283 throw diagnostics . DF0060 ( { url, package : normalized . package , reason : `HTTP ${ res . status } ` } )
228284 }
285+ // The body is consumed twice — once by the client, once by the cache
286+ // writer — so the client gets a fresh `Response` over its own branch of
287+ // the tee; `res` itself is unusable from here on (the tee locked its body).
229288 const [ toClient , toCache ] = res . body . tee ( )
230289 void persist ( filePath , toCache )
231- const length = res . headers . get ( 'content-length' )
232290 return new Response ( toClient , {
233- headers : { 'Content-Type' : contentTypeFor ( filePath ) , 'Cache-Control' : 'no-store' , ...( length ? { 'Content-Length' : length } : { } ) } ,
291+ status : res . status ,
292+ statusText : res . statusText ,
293+ headers : proxyHeaders ( filePath , res . headers ) ,
234294 } )
235295 }
236296
0 commit comments