1- // worker.js (simplified, no explicit caching)
2-
1+ // worker.js
32const DEFAULT_ALLOWED_ORIGINS = [
43 "https://tools.mathspp.com" ,
54 "http://localhost:5173" ,
65 "http://localhost:3000" ,
76] ;
87
8+ const S_MAX_AGE = 3600 ; // 1h fresh cache
9+ const STALE_WINDOW = 24 * 3600 ; // 24h serve-stale if upstream errors
10+
911function buildAllowedOrigins ( env ) {
1012 const allowList = ( env ?. ALLOWED_ORIGINS || DEFAULT_ALLOWED_ORIGINS . join ( "," ) )
11- . split ( "," )
12- . map ( ( s ) => s . trim ( ) )
13- . filter ( Boolean ) ;
13+ . split ( "," ) . map ( s => s . trim ( ) ) . filter ( Boolean ) ;
1414 return new Set ( allowList ) ;
1515}
16-
1716function corsHeaders ( origin , allowed ) {
1817 const allow = allowed . has ( origin ) ? origin : "" ;
1918 return {
2019 "Access-Control-Allow-Origin" : allow ,
2120 "Access-Control-Allow-Methods" : "GET, OPTIONS" ,
2221 "Access-Control-Allow-Headers" : "Content-Type" ,
23- Vary : "Origin" ,
22+ " Vary" : "Origin" ,
2423 } ;
2524}
26-
2725function respondJSON ( origin , allowed , data , status = 200 , extra = { } ) {
2826 return new Response ( JSON . stringify ( data ) , {
2927 status,
@@ -34,12 +32,8 @@ function respondJSON(origin, allowed, data, status = 200, extra = {}) {
3432 } ,
3533 } ) ;
3634}
35+ function sleep ( ms ) { return new Promise ( r => setTimeout ( r , ms ) ) ; }
3736
38- function sleep ( ms ) {
39- return new Promise ( ( r ) => setTimeout ( r , ms ) ) ;
40- }
41-
42- // Simple backoff for 429/503; not related to caching.
4337async function fetchWithBackoff ( url , init , attempts = 3 ) {
4438 for ( let i = 0 ; i < attempts ; i ++ ) {
4539 const res = await fetch ( url , init ) ;
@@ -64,7 +58,7 @@ async function fetchWithBackoff(url, init, attempts = 3) {
6458}
6559
6660export default {
67- async fetch ( request , env ) {
61+ async fetch ( request , env , ctx ) {
6862 const origin = request . headers . get ( "Origin" ) || "" ;
6963 const allowed = buildAllowedOrigins ( env ) ;
7064 const url = new URL ( request . url ) ;
@@ -76,9 +70,7 @@ export default {
7670 return respondJSON ( origin , allowed , { error : "Method Not Allowed" } , 405 ) ;
7771 }
7872
79- const goodPath =
80- url . pathname === "/api/gumroad-products" ||
81- url . pathname === "/api/gumroad-products/" ;
73+ const goodPath = url . pathname === "/api/gumroad-products" || url . pathname === "/api/gumroad-products/" ;
8274 if ( ! goodPath ) {
8375 return respondJSON ( origin , allowed , { error : "Not Found" } , 404 ) ;
8476 }
@@ -88,54 +80,119 @@ export default {
8880 return respondJSON ( origin , allowed , { error : "Invalid or missing Gumroad username." } , 400 ) ;
8981 }
9082
91- // Try subdomain profile, then path-based profile as fallback
83+ const profileUrl = `https://${ u } .gumroad.com/` ;
84+ const cache = caches . default ;
85+ const dataKey = new Request ( `https://gumroad-products.internal/cache?u=${ encodeURIComponent ( u ) } ` ) ;
86+ const metaKey = new Request ( `https://gumroad-products.internal/meta?u=${ encodeURIComponent ( u ) } ` ) ;
87+
88+ let cachedBody = null , cachedTs = 0 ;
89+ const c = await cache . match ( dataKey ) ;
90+ if ( c ) cachedBody = await c . text ( ) ;
91+ const cm = await cache . match ( metaKey ) ;
92+ if ( cm ) try { cachedTs = ( await cm . json ( ) ) . ts || 0 ; } catch { }
93+
94+ // Serve fresh cache (<= 1h)
95+ const age = Math . floor ( Date . now ( ) / 1000 ) - cachedTs ;
96+ if ( cachedBody && age <= S_MAX_AGE ) {
97+ return new Response ( cachedBody , {
98+ headers : {
99+ "Content-Type" : "application/json; charset=utf-8" ,
100+ "Cache-Control" : `public, max-age=${ S_MAX_AGE } ` ,
101+ "X-Cache" : "HIT" ,
102+ "X-Upstream-Status" : "none" ,
103+ ...corsHeaders ( origin , allowed ) ,
104+ } ,
105+ } ) ;
106+ }
107+
108+ // Try subdomain first, then path-based profile
92109 const profileUrlA = `https://${ u } .gumroad.com/` ;
93110 const profileUrlB = `https://gumroad.com/${ encodeURIComponent ( u ) } ` ;
94111
95112 async function getProfileHTML ( urlStr ) {
96- return fetchWithBackoff ( urlStr , {
113+ const res = await fetchWithBackoff ( urlStr , {
97114 redirect : "follow" ,
98115 headers : {
99- Accept :
100- "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" ,
116+ "Accept" : "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" ,
101117 "Accept-Language" : "en-US,en;q=0.9" ,
102- "User-Agent" :
103- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" ,
104- Referer : "https://gumroad.com/" ,
118+ "User-Agent" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" ,
119+ "Referer" : "https://gumroad.com/" ,
120+ "Cache-Control" : "no-cache" ,
121+ "Pragma" : "no-cache" ,
105122 } ,
106123 } ) ;
124+ return res ;
107125 }
108126
127+ // 1) Try subdomain and then path profile if it fails.
109128 let upstream = await getProfileHTML ( profileUrlA ) ;
110129 if ( ! upstream . ok && ( upstream . status === 429 || upstream . status === 403 ) ) {
111130 upstream = await getProfileHTML ( profileUrlB ) ;
112131 }
132+
133+ // If still not ok, serve stale or bubble error
113134 if ( ! upstream . ok ) {
114- return respondJSON (
115- origin ,
116- allowed ,
117- { error : "Upstream error" , status : upstream . status } ,
118- upstream . status
119- ) ;
135+ // Serve stale (<= 25h old total) instead of failing
136+ if ( cachedBody && age <= ( S_MAX_AGE + STALE_WINDOW ) ) {
137+ return new Response ( cachedBody , {
138+ headers : {
139+ "Content-Type" : "application/json; charset=utf-8" ,
140+ "Cache-Control" : `public, max-age=0, stale-while-revalidate=${ STALE_WINDOW } ` ,
141+ "X-Cache" : "STALE" ,
142+ "X-Upstream-Status" : String ( upstream . status ) ,
143+ ...corsHeaders ( origin , allowed ) ,
144+ } ,
145+ } ) ;
146+ }
147+ // No cache to fall back to
148+ return respondJSON ( origin , allowed , { error : "Upstream error" , status : upstream . status } , upstream . status , {
149+ "X-Cache" : "MISS" ,
150+ "X-Upstream-Status" : String ( upstream . status ) ,
151+ } ) ;
120152 }
121153
122154 const html = await upstream . text ( ) ;
155+
123156 const products = extractProducts ( html ) ;
124157
125158 const payload = {
126159 username : u ,
127- profile_url : `https:// ${ u } .gumroad.com/` ,
160+ profile_url : profileUrl ,
128161 count : products . length ,
129162 products,
130163 fetched_at : new Date ( ) . toISOString ( ) ,
131164 } ;
132-
133- return respondJSON ( origin , allowed , payload , 200 ) ;
165+ const body = JSON . stringify ( payload ) ;
166+
167+ // Update cache
168+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
169+ const dataResp = new Response ( body , {
170+ headers : {
171+ "Content-Type" : "application/json; charset=utf-8" ,
172+ "Cache-Control" : `public, max-age=${ S_MAX_AGE } ` ,
173+ } ,
174+ } ) ;
175+ const metaResp = new Response ( JSON . stringify ( { ts : now } ) , {
176+ headers : { "Content-Type" : "application/json" } ,
177+ } ) ;
178+ ctx . waitUntil ( cache . put ( dataKey , dataResp . clone ( ) ) ) ;
179+ ctx . waitUntil ( cache . put ( metaKey , metaResp . clone ( ) ) ) ;
180+
181+ return new Response ( body , {
182+ headers : {
183+ "Content-Type" : "application/json; charset=utf-8" ,
184+ "Cache-Control" : `public, max-age=${ S_MAX_AGE } ` ,
185+ "X-Cache" : cachedBody ? "MISS-REVAL" : "MISS" ,
186+ "X-Upstream-Status" : "200" ,
187+ ...corsHeaders ( origin , allowed ) ,
188+ } ,
189+ } ) ;
134190 } ,
135191} ;
136192
137193function extractProducts ( html ) {
138194 // Lightweight HTML parsing without DOM: heuristic regex over links.
195+ // For more robustness, you could use an HTML parser lib with Workers Bundler.
139196 const linkRe = / < a \b [ ^ > ] * h r e f = [ " ' ] ( [ ^ " ' ] * \/ l \/ [ ^ " ' ] * ) [ " ' ] [ ^ > ] * > ( [ \s \S ] * ?) < \/ a > / gi;
140197 const tagRe = / < \/ ? [ ^ > ] + > / g;
141198 const nbspRe = / & n b s p ; / g;
@@ -145,16 +202,14 @@ function extractProducts(html) {
145202 let m ;
146203 while ( ( m = linkRe . exec ( html ) ) !== null ) {
147204 const href = m [ 1 ] ;
148- let title = m [ 2 ] . replace ( tagRe , "" ) . replace ( nbspRe , " " ) . trim ( ) ;
205+ let title = m [ 2 ] . replace ( tagRe , '' ) . replace ( nbspRe , ' ' ) . trim ( ) ;
149206 if ( ! title ) continue ;
150207
151208 // Normalize absolute vs relative
152- const url = href . startsWith ( "http" )
153- ? href
154- : new URL ( href , "https://example.com" ) . href ;
155- const slug = url . split ( "/" ) . filter ( Boolean ) . pop ( ) ;
209+ const url = href . startsWith ( 'http' ) ? href : new URL ( href , 'https://example.com' ) . href ;
210+ const slug = url . split ( '/' ) . filter ( Boolean ) . pop ( ) ;
156211
157- const key = url + "|" + title ;
212+ const key = url + '|' + title ;
158213 if ( seen . has ( key ) ) continue ;
159214 seen . add ( key ) ;
160215
0 commit comments