@@ -31,6 +31,11 @@ export interface StaticAssetServiceOptions {
3131 loggerName ?: string
3232 rewriteAssetReferences ?: boolean
3333 assetLinkRels ?: Iterable < string >
34+ staticAssetHostResolver ?: ( requestHost ?: string | null ) => Promise < string | null >
35+ }
36+
37+ export interface StaticAssetRequestOptions {
38+ requestHost ?: string | null
3439}
3540
3641export interface ResolvedStaticAsset {
@@ -42,18 +47,25 @@ export interface ResolvedStaticAsset {
4247export abstract class StaticAssetService {
4348 protected readonly logger : PrettyLogger
4449 private readonly assetLinkRels : ReadonlySet < string >
50+ private readonly staticAssetHostResolver ?: ( requestHost ?: string | null ) => Promise < string | null >
4551
4652 private staticRoot : string | null | undefined
53+ private staticAssetHosts = new Map < string , string | null > ( )
4754 private warnedMissingRoot = false
4855
4956 protected constructor ( private readonly options : StaticAssetServiceOptions ) {
5057 this . logger = createLogger ( options . loggerName ?? this . constructor . name )
5158 this . assetLinkRels = new Set (
5259 options . assetLinkRels ? Array . from ( options . assetLinkRels , ( rel ) => rel . toLowerCase ( ) ) : DEFAULT_ASSET_LINK_RELS ,
5360 )
61+ this . staticAssetHostResolver = options . staticAssetHostResolver
5462 }
5563
56- async handleRequest ( fullPath : string , headOnly : boolean ) : Promise < Response | null > {
64+ async handleRequest (
65+ fullPath : string ,
66+ headOnly : boolean ,
67+ options ?: StaticAssetRequestOptions ,
68+ ) : Promise < Response | null > {
5769 const staticRoot = await this . resolveStaticRoot ( )
5870 if ( ! staticRoot ) {
5971 return null
@@ -65,7 +77,7 @@ export abstract class StaticAssetService {
6577 return null
6678 }
6779
68- return await this . createResponse ( target , headOnly )
80+ return await this . createResponse ( target , headOnly , options )
6981 }
7082
7183 protected get routeSegment ( ) : string {
@@ -78,10 +90,10 @@ export abstract class StaticAssetService {
7890
7991 protected async decorateDocument ( _document : StaticAssetDocument , _file : ResolvedStaticAsset ) : Promise < void > { }
8092
81- protected rewriteStaticAssetReferences ( document : StaticAssetDocument ) : void {
93+ protected rewriteStaticAssetReferences ( document : StaticAssetDocument , staticAssetHost : string | null ) : void {
8294 const prefixAttr = ( element : Element , attr : string ) => {
8395 const current = element . getAttribute ( attr )
84- const next = this . prefixStaticAssetPath ( current )
96+ const next = this . applyStaticAssetPrefixes ( current , staticAssetHost )
8597 if ( next !== null && next !== current ) {
8698 element . setAttribute ( attr , next )
8799 }
@@ -106,7 +118,7 @@ export abstract class StaticAssetService {
106118
107119 document . querySelectorAll ( 'img[srcset], source[srcset]' ) . forEach ( ( element ) => {
108120 const current = element . getAttribute ( 'srcset' )
109- const next = this . prefixSrcset ( current )
121+ const next = this . prefixSrcset ( current , staticAssetHost )
110122 if ( next !== null && next !== current ) {
111123 element . setAttribute ( 'srcset' , next )
112124 }
@@ -148,7 +160,12 @@ export abstract class StaticAssetService {
148160 return value === trimmed ? prefixed : value . replace ( trimmed , prefixed )
149161 }
150162
151- private prefixSrcset ( value : string | null ) : string | null {
163+ private applyStaticAssetPrefixes ( value : string | null , staticAssetHost : string | null ) : string | null {
164+ const prefixed = this . prefixStaticAssetPath ( value )
165+ return this . prefixStaticAssetHost ( prefixed , staticAssetHost )
166+ }
167+
168+ private prefixSrcset ( value : string | null , staticAssetHost : string | null ) : string | null {
152169 if ( ! value ) {
153170 return value
154171 }
@@ -160,13 +177,27 @@ export abstract class StaticAssetService {
160177 }
161178
162179 const [ url , ...rest ] = trimmed . split ( / \s + / )
163- const prefixed = this . prefixStaticAssetPath ( url ) ?? url
180+ const prefixed = this . applyStaticAssetPrefixes ( url , staticAssetHost ) ?? url
164181 return [ prefixed , ...rest ] . join ( ' ' ) . trim ( )
165182 } )
166183
167184 return parts . join ( ', ' )
168185 }
169186
187+ private prefixStaticAssetHost ( value : string | null , staticAssetHost : string | null ) : string | null {
188+ if ( ! value || ! staticAssetHost ) {
189+ return value
190+ }
191+
192+ const trimmed = value . trim ( )
193+ if ( ! trimmed . startsWith ( this . routeSegment ) ) {
194+ return value
195+ }
196+
197+ const rewrote = `${ staticAssetHost } ${ trimmed } `
198+ return value === trimmed ? rewrote : value . replace ( trimmed , rewrote )
199+ }
200+
170201 private async resolveStaticRoot ( ) : Promise < string | null > {
171202 if ( this . staticRoot !== undefined ) {
172203 return this . staticRoot
@@ -307,9 +338,13 @@ export abstract class StaticAssetService {
307338 return relativePath !== '' && ! relativePath . startsWith ( '..' ) && ! isAbsolute ( relativePath )
308339 }
309340
310- private async createResponse ( file : ResolvedStaticAsset , headOnly : boolean ) : Promise < Response > {
341+ private async createResponse (
342+ file : ResolvedStaticAsset ,
343+ headOnly : boolean ,
344+ options ?: StaticAssetRequestOptions ,
345+ ) : Promise < Response > {
311346 if ( this . isHtml ( file . relativePath ) ) {
312- return await this . createHtmlResponse ( file , headOnly )
347+ return await this . createHtmlResponse ( file , headOnly , options )
313348 }
314349
315350 const mimeType = lookupMimeType ( file . absolutePath ) || 'application/octet-stream'
@@ -319,6 +354,7 @@ export abstract class StaticAssetService {
319354 headers . set ( 'last-modified' , file . stats . mtime . toUTCString ( ) )
320355
321356 this . applyCacheHeaders ( headers , file . relativePath )
357+ this . applyCorsHeaders ( headers )
322358
323359 if ( headOnly ) {
324360 return new Response ( null , { headers, status : 200 } )
@@ -329,14 +365,19 @@ export abstract class StaticAssetService {
329365 return new Response ( body , { headers, status : 200 } )
330366 }
331367
332- private async createHtmlResponse ( file : ResolvedStaticAsset , headOnly : boolean ) : Promise < Response > {
368+ private async createHtmlResponse (
369+ file : ResolvedStaticAsset ,
370+ headOnly : boolean ,
371+ options ?: StaticAssetRequestOptions ,
372+ ) : Promise < Response > {
333373 const html = await readFile ( file . absolutePath , 'utf-8' )
334- const transformed = await this . transformIndexHtml ( html , file )
374+ const transformed = await this . transformIndexHtml ( html , file , options )
335375 const headers = new Headers ( )
336376 headers . set ( 'content-type' , 'text/html; charset=utf-8' )
337377 headers . set ( 'content-length' , `${ Buffer . byteLength ( transformed , 'utf-8' ) } ` )
338378 headers . set ( 'last-modified' , file . stats . mtime . toUTCString ( ) )
339379 this . applyCacheHeaders ( headers , file . relativePath )
380+ this . applyCorsHeaders ( headers )
340381
341382 if ( headOnly ) {
342383 return new Response ( null , { headers, status : 200 } )
@@ -345,12 +386,17 @@ export abstract class StaticAssetService {
345386 return new Response ( transformed , { headers, status : 200 } )
346387 }
347388
348- private async transformIndexHtml ( html : string , file : ResolvedStaticAsset ) : Promise < string > {
389+ private async transformIndexHtml (
390+ html : string ,
391+ file : ResolvedStaticAsset ,
392+ options ?: StaticAssetRequestOptions ,
393+ ) : Promise < string > {
349394 try {
350395 const document = DOM_PARSER . parseFromString ( html , 'text/html' ) as unknown as StaticAssetDocument
351396 await this . decorateDocument ( document , file )
352397 if ( this . shouldRewriteAssetReferences ( file ) ) {
353- this . rewriteStaticAssetReferences ( document )
398+ const staticAssetHost = await this . getStaticAssetHost ( options ?. requestHost )
399+ this . rewriteStaticAssetReferences ( document , staticAssetHost )
354400 }
355401 return document . documentElement . outerHTML
356402 } catch ( error ) {
@@ -359,6 +405,35 @@ export abstract class StaticAssetService {
359405 }
360406 }
361407
408+ private async getStaticAssetHost ( requestHost ?: string | null ) : Promise < string | null > {
409+ if ( ! this . staticAssetHostResolver ) {
410+ return null
411+ }
412+
413+ const cacheKey = this . buildStaticAssetHostCacheKey ( requestHost )
414+ if ( this . staticAssetHosts . has ( cacheKey ) ) {
415+ return this . staticAssetHosts . get ( cacheKey ) ?? null
416+ }
417+
418+ try {
419+ const resolved = await this . staticAssetHostResolver ( requestHost )
420+ this . staticAssetHosts . set ( cacheKey , resolved ?? null )
421+ return resolved ?? null
422+ } catch ( error ) {
423+ this . logger . warn ( 'Failed to resolve static asset host' , error )
424+ this . staticAssetHosts . set ( cacheKey , null )
425+ }
426+
427+ return null
428+ }
429+
430+ private buildStaticAssetHostCacheKey ( requestHost ?: string | null ) : string {
431+ if ( ! requestHost ) {
432+ return '__default__'
433+ }
434+ return requestHost . trim ( ) . toLowerCase ( )
435+ }
436+
362437 private shouldTreatAsImmutable ( relativePath : string ) : boolean {
363438 if ( this . isHtml ( relativePath ) ) {
364439 return false
@@ -374,6 +449,12 @@ export abstract class StaticAssetService {
374449 headers . set ( 'surrogate-control' , policy . cdn )
375450 }
376451
452+ private applyCorsHeaders ( headers : Headers ) : void {
453+ headers . set ( 'access-control-allow-origin' , '*' )
454+ headers . set ( 'access-control-allow-methods' , 'GET, HEAD, OPTIONS' )
455+ headers . set ( 'access-control-allow-headers' , 'content-type' )
456+ }
457+
377458 private resolveCachePolicy ( relativePath : string ) : { browser : string ; cdn : string } {
378459 if ( this . isHtml ( relativePath ) ) {
379460 return {
0 commit comments