@@ -11,6 +11,10 @@ import { createHash } from "node:crypto";
1111import type { DesignTokens , DownloadedAsset } from "./types.js" ;
1212import type { CatalogedAsset } from "./assetCataloger.js" ;
1313
14+ interface DownloadBudgetOptions {
15+ remainingMs ?: ( ) => number ;
16+ }
17+
1418// SVGs: hash-of-bytes filename so it can't drift from content; label-derived names mis-assigned brands.
1519function svgContentHashSlug ( svgSource : string | Buffer , isLogo : boolean ) : string {
1620 const hash = createHash ( "sha1" ) . update ( svgSource ) . digest ( "hex" ) . slice ( 0 , 8 ) ;
@@ -43,11 +47,13 @@ export function toStandaloneSvg(outerHTML: string): string {
4347 return outerHTML . replace ( original , tag ) ;
4448}
4549
50+ // fallow-ignore-next-line complexity
4651export async function downloadAssets (
4752 tokens : DesignTokens ,
4853 outputDir : string ,
4954 catalogedAssets ?: CatalogedAsset [ ] ,
5055 faviconLinks ?: Array < { rel : string ; href : string } > ,
56+ options : DownloadBudgetOptions = { } ,
5157) : Promise < DownloadedAsset [ ] > {
5258 const assetsDir = join ( outputDir , "assets" ) ;
5359 mkdirSync ( assetsDir , { recursive : true } ) ;
@@ -82,12 +88,14 @@ export async function downloadAssets(
8288
8389 // 2. Favicon
8490 for ( const icon of faviconLinks || [ ] ) {
91+ const remainingMs = options . remainingMs ?.( ) ?? 10_000 ;
92+ if ( remainingMs <= 0 ) break ;
8593 if ( ! icon . href ) continue ;
8694 try {
8795 const ext = extname ( new URL ( icon . href ) . pathname ) || ".ico" ;
8896 const name = `favicon${ ext } ` ;
8997 const localPath = `assets/${ name } ` ;
90- const buffer = await fetchBuffer ( icon . href ) ;
98+ const buffer = await fetchBuffer ( icon . href , Math . min ( 10_000 , remainingMs ) ) ;
9199 if ( buffer ) {
92100 writeFileSync ( join ( outputDir , localPath ) , buffer ) ;
93101 assets . push ( { url : icon . href , localPath, type : "favicon" } ) ;
@@ -149,13 +157,15 @@ export async function downloadAssets(
149157 let imgIdx = 0 ;
150158 const usedNames = new Set < string > ( ) ;
151159 for ( let i = 0 ; i < toDownload . length ; i += BATCH_SIZE ) {
160+ const remainingMs = options . remainingMs ?.( ) ?? 10_000 ;
161+ if ( remainingMs <= 0 ) break ;
152162 const batch = toDownload . slice ( i , i + BATCH_SIZE ) ;
153163 const results = await Promise . allSettled (
154164 batch . map ( async ( { url, isPoster, catalog } ) => {
155165 const parsedUrl = new URL ( url ) ;
156166 const pathExt = extname ( parsedUrl . pathname ) ;
157167 const ext = pathExt && pathExt . length <= 5 ? pathExt : ".jpg" ;
158- const buffer = await fetchBuffer ( url ) ;
168+ const buffer = await fetchBuffer ( url , Math . min ( 10_000 , remainingMs ) ) ;
159169 if ( ! buffer ) return null ;
160170 const isSvg = ext === ".svg" || url . includes ( ".svg" ) ;
161171 const minSize = isSvg ? 200 : 10000 ;
@@ -198,10 +208,12 @@ export async function downloadAssets(
198208
199209 // 4. OG image (if not already downloaded)
200210 if ( tokens . ogImage && ! downloadedUrls . has ( normalizeUrl ( tokens . ogImage ) ) ) {
211+ const remainingMs = options . remainingMs ?.( ) ?? 10_000 ;
201212 try {
202213 const ext = extname ( new URL ( tokens . ogImage ) . pathname ) || ".jpg" ;
203214 const localPath = `assets/og-image${ ext } ` ;
204- const buffer = await fetchBuffer ( tokens . ogImage ) ;
215+ const buffer =
216+ remainingMs > 0 ? await fetchBuffer ( tokens . ogImage , Math . min ( 10_000 , remainingMs ) ) : null ;
205217 if ( buffer && buffer . length > 5000 ) {
206218 writeFileSync ( join ( outputDir , localPath ) , buffer ) ;
207219 assets . push ( { url : tokens . ogImage , localPath, type : "image" } ) ;
@@ -234,7 +246,12 @@ function normalizeUrl(u: string): string {
234246 * Download fonts referenced in CSS and rewrite URLs to local paths.
235247 * Returns the modified CSS string with local font paths.
236248 */
237- export async function downloadAndRewriteFonts ( css : string , outputDir : string ) : Promise < string > {
249+ // fallow-ignore-next-line complexity
250+ export async function downloadAndRewriteFonts (
251+ css : string ,
252+ outputDir : string ,
253+ options : DownloadBudgetOptions = { } ,
254+ ) : Promise < string > {
238255 const assetsDir = join ( outputDir , "assets" , "fonts" ) ;
239256 mkdirSync ( assetsDir , { recursive : true } ) ;
240257
@@ -247,8 +264,10 @@ export async function downloadAndRewriteFonts(css: string, outputDir: string): P
247264
248265 if ( fontUrls . size === 0 ) return css ;
249266
250- // Limit font downloads to avoid bloat. Google Fonts serves 20+ unicode-range
251- // subsets per weight — we only need a few per family for video production.
267+ // Limit font download attempts to bound worst-case egress and latency. Google Fonts serves
268+ // 20+ unicode-range subsets per weight, so successes alone cannot be the bound: six transient
269+ // failures can intentionally suppress later URLs in that family. Latin-priority sorting below
270+ // makes the limited attempts useful while keeping this failure tradeoff explicit.
252271 const MAX_FONTS_PER_FAMILY = 6 ;
253272 const MAX_TOTAL_FONTS = 30 ;
254273 const familyCounts = new Map < string , number > ( ) ;
@@ -275,23 +294,25 @@ export async function downloadAndRewriteFonts(css: string, outputDir: string): P
275294 let count = 0 ;
276295
277296 for ( const fontUrl of sortedUrls ) {
297+ const remainingMs = options . remainingMs ?.( ) ?? 10_000 ;
298+ if ( remainingMs <= 0 ) break ;
278299 if ( count >= MAX_TOTAL_FONTS ) break ;
279300 const family = getFamilyForUrl ( fontUrl ) ;
280301 const familyCount = familyCounts . get ( family ) || 0 ;
281302 if ( familyCount >= MAX_FONTS_PER_FAMILY ) continue ;
303+ familyCounts . set ( family , familyCount + 1 ) ;
304+ count ++ ;
282305
283306 try {
284307 const urlObj = new URL ( fontUrl ) ;
285308 const filename = urlObj . pathname . split ( "/" ) . pop ( ) || `font-${ count } .woff2` ;
286309 const localPath = join ( assetsDir , filename ) ;
287310 const relativePath = `assets/fonts/${ filename } ` ;
288311
289- const buffer = await fetchBuffer ( fontUrl ) ;
312+ const buffer = await fetchBuffer ( fontUrl , Math . min ( 10_000 , remainingMs ) ) ;
290313 if ( buffer ) {
291314 writeFileSync ( localPath , buffer ) ;
292315 rewritten = rewritten . split ( fontUrl ) . join ( relativePath ) ;
293- familyCounts . set ( family , familyCount + 1 ) ;
294- count ++ ;
295316 }
296317 } catch {
297318 /* skip */
@@ -391,10 +412,10 @@ export async function safeFetch(url: string, init?: RequestInit): Promise<Respon
391412 return null ; // too many redirects
392413}
393414
394- async function fetchBuffer ( url : string ) : Promise < Buffer | null > {
415+ async function fetchBuffer ( url : string , timeoutMs = 10_000 ) : Promise < Buffer | null > {
395416 try {
396417 const res = await safeFetch ( url , {
397- signal : AbortSignal . timeout ( 10000 ) ,
418+ signal : AbortSignal . timeout ( timeoutMs ) ,
398419 headers : { "User-Agent" : "HyperFrames/1.0" } ,
399420 } ) ;
400421 if ( ! res || ! res . ok ) return null ;
0 commit comments