@flamingo-stack/openframe-frontend-core 0.0.533 → 0.0.534

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/{chunk-XN4JKIJY.js → chunk-2ALT6CTY.js} +2 -2
  2. package/dist/{chunk-4ZRHVOEB.cjs → chunk-2YJ2CVRC.cjs} +31 -31
  3. package/dist/{chunk-4ZRHVOEB.cjs.map → chunk-2YJ2CVRC.cjs.map} +1 -1
  4. package/dist/{chunk-NAV4FRNL.cjs → chunk-67MWXMNT.cjs} +26 -26
  5. package/dist/{chunk-NAV4FRNL.cjs.map → chunk-67MWXMNT.cjs.map} +1 -1
  6. package/dist/{chunk-4QKXNWH5.js → chunk-BSE2UT6L.js} +2 -2
  7. package/dist/{chunk-2XGHEJZK.js → chunk-BUVD2OAI.js} +4 -4
  8. package/dist/{chunk-6W3ULZ4X.js → chunk-D62VYSTL.js} +6 -6
  9. package/dist/{chunk-J4BE4FIR.cjs → chunk-EBSZWSC5.cjs} +4 -4
  10. package/dist/{chunk-J4BE4FIR.cjs.map → chunk-EBSZWSC5.cjs.map} +1 -1
  11. package/dist/{chunk-U555TMPC.js → chunk-H4W4LENJ.js} +5 -5
  12. package/dist/{chunk-DFQLUK75.cjs → chunk-JELZAWSM.cjs} +3 -3
  13. package/dist/{chunk-DFQLUK75.cjs.map → chunk-JELZAWSM.cjs.map} +1 -1
  14. package/dist/{chunk-6YXN5RHW.js → chunk-KZ6PWFHK.js} +2 -2
  15. package/dist/{chunk-JDW2IL7A.cjs → chunk-LBZEPB4Y.cjs} +11 -11
  16. package/dist/{chunk-JDW2IL7A.cjs.map → chunk-LBZEPB4Y.cjs.map} +1 -1
  17. package/dist/{chunk-UPHN2VRZ.cjs → chunk-LYRGM25P.cjs} +750 -946
  18. package/dist/chunk-LYRGM25P.cjs.map +1 -0
  19. package/dist/{chunk-GNOK5J34.cjs → chunk-M5ODB6RQ.cjs} +37 -37
  20. package/dist/{chunk-GNOK5J34.cjs.map → chunk-M5ODB6RQ.cjs.map} +1 -1
  21. package/dist/{chunk-ENPGPZIB.cjs → chunk-MLILEBJU.cjs} +61 -61
  22. package/dist/{chunk-ENPGPZIB.cjs.map → chunk-MLILEBJU.cjs.map} +1 -1
  23. package/dist/{chunk-46FKXLNE.js → chunk-MTCU5MQN.js} +2 -2
  24. package/dist/{chunk-2MYDGLJS.cjs → chunk-NCUKKJRG.cjs} +87 -87
  25. package/dist/{chunk-2MYDGLJS.cjs.map → chunk-NCUKKJRG.cjs.map} +1 -1
  26. package/dist/{chunk-LIUARTOZ.js → chunk-O6VJZX2N.js} +56 -252
  27. package/dist/{chunk-LIUARTOZ.js.map → chunk-O6VJZX2N.js.map} +1 -1
  28. package/dist/{chunk-LE3BJ5H2.js → chunk-ONIYUMTO.js} +2 -2
  29. package/dist/{chunk-2TUOBFPK.js → chunk-POFCTXNH.js} +2 -2
  30. package/dist/{chunk-OLOKQQAP.cjs → chunk-RHKH4YUY.cjs} +5 -5
  31. package/dist/{chunk-OLOKQQAP.cjs.map → chunk-RHKH4YUY.cjs.map} +1 -1
  32. package/dist/{chunk-Z3NOI7X7.cjs → chunk-UMDGRL6A.cjs} +26 -26
  33. package/dist/{chunk-Z3NOI7X7.cjs.map → chunk-UMDGRL6A.cjs.map} +1 -1
  34. package/dist/{chunk-RKITTO5V.cjs → chunk-UWB33C6R.cjs} +14 -14
  35. package/dist/{chunk-RKITTO5V.cjs.map → chunk-UWB33C6R.cjs.map} +1 -1
  36. package/dist/{chunk-MVGL5NNJ.js → chunk-VYOGAQLM.js} +2 -2
  37. package/dist/{chunk-5U67WTAS.js → chunk-W4MXOBEW.js} +2 -2
  38. package/dist/{chunk-YLB6XXO6.js → chunk-WBYRBTAS.js} +2 -2
  39. package/dist/{chunk-KMB66NYX.cjs → chunk-YW2ZU7LC.cjs} +7 -7
  40. package/dist/{chunk-KMB66NYX.cjs.map → chunk-YW2ZU7LC.cjs.map} +1 -1
  41. package/dist/{chunk-QZ2NAPLF.cjs → chunk-ZKQXE2P4.cjs} +9 -9
  42. package/dist/{chunk-QZ2NAPLF.cjs.map → chunk-ZKQXE2P4.cjs.map} +1 -1
  43. package/dist/{chunk-6SMLYZLH.js → chunk-ZLLXYE2G.js} +4 -4
  44. package/dist/components/case-studies/index.cjs +8 -8
  45. package/dist/components/case-studies/index.js +2 -2
  46. package/dist/components/chat/index.cjs +2 -2
  47. package/dist/components/chat/index.js +1 -1
  48. package/dist/components/contact/index.cjs +3 -3
  49. package/dist/components/contact/index.js +2 -2
  50. package/dist/components/docs/index.cjs +5 -5
  51. package/dist/components/docs/index.js +4 -4
  52. package/dist/components/embeds/index.cjs +3 -3
  53. package/dist/components/embeds/index.js +2 -2
  54. package/dist/components/faq/index.cjs +3 -3
  55. package/dist/components/faq/index.js +2 -2
  56. package/dist/components/features/index.cjs +2 -2
  57. package/dist/components/features/index.js +1 -1
  58. package/dist/components/help-center-pages/index.cjs +22 -22
  59. package/dist/components/help-center-pages/index.js +13 -13
  60. package/dist/components/index.cjs +132 -132
  61. package/dist/components/index.js +11 -11
  62. package/dist/components/meeting-scheduler/index.cjs +34 -34
  63. package/dist/components/meeting-scheduler/index.js +3 -3
  64. package/dist/components/navigation/index.cjs +2 -2
  65. package/dist/components/navigation/index.js +1 -1
  66. package/dist/components/onboarding-guides/index.cjs +5 -5
  67. package/dist/components/onboarding-guides/index.js +4 -4
  68. package/dist/components/related-content/index.cjs +3 -3
  69. package/dist/components/related-content/index.js +2 -2
  70. package/dist/components/tickets/index.cjs +6 -6
  71. package/dist/components/tickets/index.js +5 -5
  72. package/dist/components/ui/index.cjs +2 -2
  73. package/dist/components/ui/index.js +1 -1
  74. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -1
  75. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -1
  76. package/dist/index.cjs +2 -2
  77. package/dist/index.js +1 -1
  78. package/dist/utils/social-embed-cache.d.ts +16 -16
  79. package/dist/utils/social-embed-cache.d.ts.map +1 -1
  80. package/package.json +1 -1
  81. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +1 -5
  82. package/src/components/ui/markdown/rich/__tests__/shortcodes.test.ts +58 -0
  83. package/src/components/ui/markdown/rich/embed-overrides.tsx +13 -7
  84. package/src/components/ui/markdown/rich/shortcodes.ts +15 -0
  85. package/src/utils/social-embed-cache.ts +63 -336
  86. package/dist/chunk-UPHN2VRZ.cjs.map +0 -1
  87. /package/dist/{chunk-XN4JKIJY.js.map → chunk-2ALT6CTY.js.map} +0 -0
  88. /package/dist/{chunk-4QKXNWH5.js.map → chunk-BSE2UT6L.js.map} +0 -0
  89. /package/dist/{chunk-2XGHEJZK.js.map → chunk-BUVD2OAI.js.map} +0 -0
  90. /package/dist/{chunk-6W3ULZ4X.js.map → chunk-D62VYSTL.js.map} +0 -0
  91. /package/dist/{chunk-U555TMPC.js.map → chunk-H4W4LENJ.js.map} +0 -0
  92. /package/dist/{chunk-6YXN5RHW.js.map → chunk-KZ6PWFHK.js.map} +0 -0
  93. /package/dist/{chunk-46FKXLNE.js.map → chunk-MTCU5MQN.js.map} +0 -0
  94. /package/dist/{chunk-LE3BJ5H2.js.map → chunk-ONIYUMTO.js.map} +0 -0
  95. /package/dist/{chunk-2TUOBFPK.js.map → chunk-POFCTXNH.js.map} +0 -0
  96. /package/dist/{chunk-MVGL5NNJ.js.map → chunk-VYOGAQLM.js.map} +0 -0
  97. /package/dist/{chunk-5U67WTAS.js.map → chunk-W4MXOBEW.js.map} +0 -0
  98. /package/dist/{chunk-YLB6XXO6.js.map → chunk-WBYRBTAS.js.map} +0 -0
  99. /package/dist/{chunk-6SMLYZLH.js.map → chunk-ZLLXYE2G.js.map} +0 -0
@@ -153,13 +153,15 @@ export function buildRichEmbedOverrides({
153
153
  return (standard.div as any)(divProps);
154
154
  },
155
155
 
156
- // Reddit's OWN embed markup lives in 58 published blog posts as
157
- // `<blockquote class="reddit-embed-bq">…<a href="post-url">` paired with
158
- // a `<script src="embed.reddit.com/widgets.js">` loader. The sanitize
159
- // stack strips the script (correctly), so the composition rehydrates the
160
- // blockquote itself: extract the post URL from the first reddit link and
161
- // render the RedditEmbedClient SSOT. Non-reddit blockquotes fall through
162
- // to the engine's base blockquote.
156
+ // Pasted-from-the-platform embed markup lives in published blog posts as
157
+ // `<blockquote class="reddit-embed-bq">…<a href="post-url">` (58 blocks
158
+ // across 9 posts) and Twitter's equivalent is
159
+ // `<blockquote class="twitter-tweet">…<a href="status-url">`. Each pair
160
+ // ships a `widgets.js` loader script, which `processShortcodes` strips
161
+ // from the source text (Step 1.5). The composition rehydrates the
162
+ // blockquote itself: extract the post URL from the first matching link
163
+ // and render the platform's embed-client SSOT. Other blockquotes fall
164
+ // through to the engine's base blockquote.
163
165
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
166
  blockquote: (bqProps: any) => {
165
167
  const { node, className } = bqProps;
@@ -168,6 +170,10 @@ export function buildRichEmbedOverrides({
168
170
  const postUrl = findFirstHref(node, /reddit\.com/);
169
171
  if (postUrl) return <RedditEmbedClient url={postUrl} />;
170
172
  }
173
+ if (classNames.includes('twitter-tweet')) {
174
+ const tweetUrl = findFirstHref(node, /(?:twitter\.com|x\.com)\/[^/]+\/status\//);
175
+ if (tweetUrl) return <TwitterEmbedClient url={tweetUrl} />;
176
+ }
171
177
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
178
  return (standard.blockquote as any)(bqProps);
173
179
  },
@@ -78,6 +78,21 @@ export const processShortcodes = (content: string): string => {
78
78
  return placeholder;
79
79
  });
80
80
 
81
+ // Step 1.5: Strip well-formed `<script …>…</script>` elements from prose.
82
+ // Pasted social embed markup ships a loader script alongside its markup
83
+ // (Reddit: `<blockquote class="reddit-embed-bq">…` + embed.reddit.com
84
+ // widgets.js; Twitter: platform.twitter.com widgets.js). Scripts NEVER
85
+ // execute on this surface — the sanitize stack strips real ones — but the
86
+ // engine's text pre-pass escapes the tag first, so it used to render as
87
+ // VISIBLE source text under every embed. Runs after Step 1 so script tags
88
+ // inside code fences / inline code stay literal sample code; an UNCLOSED
89
+ // opener is left alone (falls through to today's escape-to-text behavior,
90
+ // which is the safe degrade). Quantifiers are hard-bounded (ReDoS).
91
+ processedContent = processedContent.replace(
92
+ /<script\b[^>]{0,2000}>[\s\S]{0,20000}?<\/script\s{0,10}>/gi,
93
+ ''
94
+ );
95
+
81
96
  // Step 2: Temporarily replace markdown links to protect them
82
97
  const markdownLinks: string[] = [];
83
98
  processedContent = processedContent.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match) => {
@@ -1,26 +1,35 @@
1
- // Unified Social Media Embed Caching System
2
- // Shared between Reddit and Twitter embeds for consistent behavior.
1
+ // Unified Social Media Embed fetch layer.
2
+ // Shared between Reddit and Twitter embed clients for consistent behavior.
3
3
  //
4
- // Lifted from hub `lib/utils/social-embed-cache.ts` so the lib's
5
- // RichMarkdownRenderer satellites (reddit/twitter embed clients) can
6
- // share it without an `@/lib/...` hub dependency. Pure-TS; no React.
4
+ // SIMPLIFIED (2026-08, "all 58 Reddit embeds broken" incident): the server
5
+ // proxy is now the single authority — it owns a DURABLE database cache and
6
+ // the authenticated upstream fetch (Reddit's `.json` API 403s all
7
+ // unauthenticated clients, so the old browser direct-fetch could never work
8
+ // again). The old client-side hierarchy (cache-only preflight round-trip,
9
+ // direct browser fetch, client→server POST cache pushes, background
10
+ // refreshes) was flaky machinery layered over an unreliable server cache and
11
+ // is DELETED — do not reintroduce it. Client flow is exactly:
12
+ //
13
+ // memory (per-pageload dedupe) → GET proxy (server cache + upstream)
7
14
  //
8
15
  // Endpoint paths are passed in via `apiEndpoint` on each call so embedders
9
16
  // can route through their own reverse proxy (e.g. `/content/api/blog/reddit-proxy`).
10
17
 
11
- // Global request deduplication to prevent duplicate caching requests
12
- const cachingRequests = new Map<string, Promise<void>>();
13
-
14
- // Global data cache to share fetched data between component instances
18
+ // Per-pageload memory cache shared between component instances.
15
19
  const dataCache = new Map<string, any>();
16
20
 
17
- // Global refresh tracking to prevent duplicate background refreshes
18
- const refreshTimestamps = new Map<string, number>();
21
+ // In-flight GET dedupe: several embeds of the same URL mount concurrently
22
+ // (before any of them has populated the memory cache).
23
+ const inflightRequests = new Map<string, Promise<any | null>>();
19
24
 
20
- interface CacheOptions {
25
+ interface FetchOptions {
21
26
  platform: 'reddit' | 'twitter';
22
27
  url: string;
23
28
  apiEndpoint: string;
29
+ dataValidator: (data: any) => boolean;
30
+ onDataUpdate: (data: any) => void;
31
+ onError: (error: string) => void;
32
+ onLoading: (loading: boolean) => void;
24
33
  }
25
34
 
26
35
  export class SocialEmbedCache {
@@ -33,358 +42,76 @@ export class SocialEmbedCache {
33
42
  return SocialEmbedCache.instance;
34
43
  }
35
44
 
36
- // Check if data exists in memory cache
37
45
  getFromMemory(url: string): any | null {
38
46
  return dataCache.get(url) || null;
39
47
  }
40
48
 
41
- // Store data in memory cache
42
49
  setInMemory(url: string, data: any): void {
43
50
  dataCache.set(url, data);
44
51
  }
45
52
 
46
- // Check server cache (cache-only mode)
47
- async getFromServer(options: CacheOptions): Promise<any | null> {
48
- try {
49
- console.log(`🔍 [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Checking server cache for: ${options.url}`);
50
- const response = await fetch(`${options.apiEndpoint}?url=${encodeURIComponent(options.url)}&cache-only=true`);
51
-
52
- if (response.ok) {
53
- const data = await response.json();
54
- console.log(`✅ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Server cache hit: ${options.url}`);
55
- return data;
56
- } else if (response.status === 404) {
57
- console.log(`💨 [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] No server cache available for: ${options.url}`);
58
- return null;
59
- }
60
- } catch (error) {
61
- console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Server cache check failed for: ${options.url}`, error);
62
- }
63
- return null;
64
- }
65
-
66
- // Helper method to update server cache asynchronously
67
- private async updateServerCache(apiEndpoint: string, url: string, data: any): Promise<void> {
68
- try {
69
- console.log(`💾 [Cache] Updating server cache for: ${url}`);
70
- await fetch(`${apiEndpoint}?url=${encodeURIComponent(url)}`, {
71
- method: 'POST',
72
- headers: { 'Content-Type': 'application/json' },
73
- body: JSON.stringify(data)
74
- });
75
- console.log(`✅ [Cache] Server cache updated for: ${url}`);
76
- } catch (error) {
77
- console.log(`❌ [Cache] Server cache update failed for: ${url}`, error);
78
- }
79
- }
53
+ /**
54
+ * Memory proxy. Kept the historical name so the embed clients'
55
+ * call sites are unchanged.
56
+ */
57
+ async fetchWithHierarchy(options: FetchOptions): Promise<void> {
58
+ const { url, platform, apiEndpoint, dataValidator, onDataUpdate, onError, onLoading } = options;
80
59
 
81
- // Store data in server cache (with deduplication)
82
- async setInServer(options: CacheOptions, data: any): Promise<void> {
83
- const cacheKey = `${options.platform}-${options.url}`;
84
-
85
- // Prevent duplicate caching requests
86
- if (cachingRequests.has(cacheKey)) {
87
- console.log(`⏭️ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Server cache update skipped (already in progress): ${options.url}`);
88
- return;
89
- }
90
-
91
- console.log(`💾 [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Updating server cache for: ${options.url}`);
92
- const cachePromise = fetch(`${options.apiEndpoint}?url=${encodeURIComponent(options.url)}`, {
93
- method: 'POST',
94
- headers: { 'Content-Type': 'application/json' },
95
- body: JSON.stringify(data)
96
- }).then(() => {
97
- console.log(`✅ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Server cache updated successfully: ${options.url}`);
98
- cachingRequests.delete(cacheKey);
99
- }).catch((error) => {
100
- console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Cache] Server cache update failed: ${options.url}`, error);
101
- cachingRequests.delete(cacheKey);
102
- });
103
-
104
- cachingRequests.set(cacheKey, cachePromise);
105
- await cachePromise;
106
- }
107
-
108
- // Direct fetch with platform-specific logic
109
- async fetchDirect(options: CacheOptions & {
110
- directFetcher: () => Promise<any>
111
- }): Promise<any | null> {
112
- try {
113
- console.log(`🔄 [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Direct] Fetching content: ${options.url}`);
114
- const result = await options.directFetcher();
115
- console.log(`✅ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Direct] Fetch successful: ${options.url}`);
116
- return result;
117
- } catch (error) {
118
- console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Direct] Fetch failed: ${options.url}`, error);
119
- return null;
120
- }
121
- }
122
-
123
- // Unified cache hierarchy: Memory → Server → Direct
124
- async fetchWithHierarchy(options: CacheOptions & {
125
- dataValidator: (data: any) => boolean;
126
- onDataUpdate: (data: any) => void;
127
- onError: (error: string) => void;
128
- onLoading: (loading: boolean) => void;
129
- }): Promise<void> {
130
- const { url, platform, onDataUpdate, onError, onLoading, dataValidator } = options;
131
- const platformName = platform.charAt(0).toUpperCase() + platform.slice(1);
132
-
133
- // Schedule background refresh for ALL loads (not just cache hits)
134
- this.scheduleAsyncRefresh(platform, url, dataValidator, options.apiEndpoint);
135
-
136
- // Step 1: Check memory cache first
137
- console.log(`🔍 [${platformName} Cache] Checking memory cache for: ${url}`);
138
60
  const memoryData = this.getFromMemory(url);
139
61
  if (memoryData && dataValidator(memoryData)) {
140
- console.log(`💎 [${platformName} Cache] Memory cache hit for: ${url}`);
141
62
  onDataUpdate(memoryData);
142
63
  onLoading(false);
143
64
  return;
144
65
  }
145
66
 
146
- // Step 2: Check server cache (file system cache)
147
- console.log(`🔍 [${platformName} Cache] No memory cache, checking server cache for: ${url}`);
148
- const serverCacheData = await this.getFromServer(options);
149
- if (serverCacheData && dataValidator(serverCacheData)) {
150
- console.log(`🎯 [${platformName} Cache] Server cache hit (cached data) for: ${url}`);
151
- onDataUpdate(serverCacheData);
152
- this.setInMemory(url, serverCacheData); // Update memory cache
153
- onLoading(false);
154
- return;
155
- }
156
-
157
- // Step 3: Direct fetch from browser (bypasses server proxy)
158
- console.log(`🔍 [${platformName} Cache] No server cache, attempting direct fetch for: ${url}`);
159
-
160
- // For Reddit, try direct browser fetch first (CORS enabled). Use
161
- // `AbortSignal.timeout(...)` to bound the wait — without it a hung
162
- // upstream (Reddit dropping the connection without RST, or a captive-
163
- // portal proxy stalling) would block this code path indefinitely and
164
- // never fall through to the server proxy.
165
- if (platform === 'reddit') {
166
- try {
167
- const directResponse = await fetch(url, {
168
- method: 'GET',
169
- headers: {
170
- 'Accept': 'application/json',
171
- },
172
- signal: AbortSignal.timeout(10_000),
173
- });
174
-
175
- if (directResponse.ok) {
176
- const directData = await directResponse.json();
177
- if (directData && dataValidator(directData)) {
178
- console.log(`✅ [${platformName} Direct] Browser direct fetch successful for: ${url}`);
179
- onDataUpdate(directData);
180
- this.setInMemory(url, directData);
181
- onLoading(false);
182
-
183
- // Async server cache update
184
- setTimeout(() => {
185
- this.updateServerCache(options.apiEndpoint, url, directData);
186
- }, 0);
187
- return;
188
- }
189
- }
190
- console.log(`💨 [${platformName} Direct] Browser direct fetch failed (${directResponse.status}), trying server proxy...`);
191
- } catch (directError) {
192
- console.log(`💨 [${platformName} Direct] Browser direct fetch failed, trying server proxy...`, directError);
193
- }
194
- }
195
-
196
- // Step 4: Server proxy as fallback
197
- console.log(`🔍 [${platformName} Cache] Trying server proxy for: ${url}`);
198
67
  try {
199
- const response = await fetch(`${options.apiEndpoint}?url=${encodeURIComponent(url)}`);
200
-
201
- if (response.ok) {
202
- const serverData = await response.json();
203
- if (serverData && dataValidator(serverData)) {
204
- // Check X-Cache header to determine if this was a true cache hit or fresh fetch
205
- const cacheStatus = response.headers.get('x-cache') || 'UNKNOWN';
206
- if (cacheStatus === 'HIT') {
207
- console.log(`🎯 [${platformName} Proxy] Server cache hit (cached data) for: ${url}`);
208
- } else if (cacheStatus === 'MISS-FETCHED') {
209
- console.log(`🔄 [${platformName} Proxy] Server processed fresh fetch (now cached) for: ${url}`);
210
- } else {
211
- console.log(`✅ [${platformName} Proxy] Server returned data (${cacheStatus}) for: ${url}`);
212
- }
213
- onDataUpdate(serverData);
214
- this.setInMemory(url, serverData);
215
- onLoading(false);
216
- return;
217
- } else {
218
- // 200 with a body that fails our shape check — throw so the
219
- // outer catch surfaces `onError` instead of silently dropping.
220
- console.log(`⚠️ [${platformName} Proxy] Server returned data but validation failed for: ${url}`);
221
- throw new Error('Server proxy returned invalid data shape');
222
- }
223
- } else {
224
- // The Response body is a one-shot stream — read it ONCE and branch
225
- // on shape afterwards. The previous code parsed 404s, fell through
226
- // to the generic-error path, and then called `response.json()` a
227
- // second time, which throws `TypeError: body stream already read`
228
- // and masks the real status with a confusing error.
229
- let errorData: { error?: string; message?: string } | null = null;
230
- try {
231
- errorData = await response.json();
232
- } catch {
233
- // Body wasn't JSON; errorData stays null and we fall through.
234
- }
235
- if (response.status === 404 && errorData?.error && errorData?.message) {
236
- // Structured 404 (e.g. an unavailable Reddit post). Surface
237
- // the proxy's message directly to the consumer.
238
- console.log(`🚫 [${platformName} Proxy] Content unavailable: ${errorData.message}`);
239
- onError(errorData.message);
240
- onLoading(false);
241
- return;
242
- }
243
- if (errorData?.error) {
244
- throw new Error(`Server error: ${errorData.error}`);
245
- }
246
- throw new Error(`Server proxy failed: ${response.status}`);
68
+ const data = await this.fetchFromProxy(url, apiEndpoint, dataValidator);
69
+ if (data) {
70
+ this.setInMemory(url, data);
71
+ onDataUpdate(data);
72
+ onLoading(false);
73
+ return;
247
74
  }
75
+ // Structured 404 from the proxy — the content itself is unavailable.
76
+ onError(`This ${platform === 'reddit' ? 'Reddit post' : 'tweet'} could not be loaded.`);
248
77
  } catch (error) {
249
- console.log(`❌ [${platformName} Cache] All fetch attempts failed for: ${url}`, error);
78
+ console.log(`❌ [${platform} embed] Proxy fetch failed for: ${url}`, error);
250
79
  onError(`Unable to load ${platform} content`);
251
- onLoading(false);
252
80
  }
81
+ onLoading(false);
253
82
  }
254
83
 
255
- // Schedule async background refresh (runs once, 1 second after load)
256
- //
257
- // The hub's old version hard-coded `/api/blog/{reddit,twitter}-proxy`. We
258
- // now require `apiEndpoint` so embedders can route through their own
259
- // reverse proxy (e.g. `/content/api/blog/...`).
260
- private scheduleAsyncRefresh(
261
- platform: string,
84
+ /**
85
+ * Returns the validated payload, `null` for a structured "content
86
+ * unavailable" 404, and throws for everything else. Concurrent calls for
87
+ * the same URL share one request.
88
+ */
89
+ private fetchFromProxy(
262
90
  url: string,
263
- dataValidator: (data: any) => boolean,
264
91
  apiEndpoint: string,
265
- ): void {
266
- setTimeout(() => {
267
- const platformName = platform.charAt(0).toUpperCase() + platform.slice(1);
268
- console.log(`🔄 [${platformName} Async] Background refresh starting for: ${url}`);
269
-
270
- fetch(`${apiEndpoint}?url=${encodeURIComponent(url)}`)
271
- .then(response => {
272
- if (response.ok) {
273
- return response.json();
274
- } else {
275
- console.log(`❌ [${platformName} Async] Background refresh failed (${response.status}) for: ${url}`);
276
- return null;
277
- }
278
- })
279
- .then(data => {
280
- if (data && dataValidator(data)) {
281
- this.setInMemory(url, data);
282
- console.log(`✅ [${platformName} Async] Background refresh completed for: ${url}`);
283
- } else if (data) {
284
- console.log(`❌ [${platformName} Async] Background refresh failed (invalid data) for: ${url}`);
285
- }
286
- })
287
- .catch((error) => console.log(`❌ [${platformName} Async] Background refresh failed for: ${url}`, error));
288
- }, 1000); // Runs exactly once, 1 second after load
289
- }
290
-
291
- // Background refresh to keep cache fresh (with request deduplication)
292
- private async backgroundRefresh(options: CacheOptions & {
293
- directFetcher: () => Promise<any>;
294
- dataValidator: (data: any) => boolean;
295
- onDataUpdate: (data: any) => void;
296
- }, currentData: any): Promise<void> {
297
- const refreshKey = `refresh-${options.platform}-${options.url}`;
298
- const now = Date.now();
299
-
300
- // Check if we've refreshed this URL recently (within 30 seconds)
301
- const lastRefresh = refreshTimestamps.get(refreshKey);
302
- if (lastRefresh && (now - lastRefresh) < 30000) {
303
- console.log(`⏭️ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Background] Refresh skipped (recent refresh): ${options.url}`);
304
- return;
305
- }
306
-
307
- // Prevent multiple background refresh requests for the same URL
308
- if (cachingRequests.has(refreshKey)) {
309
- console.log(`⏭️ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Background] Refresh skipped (already in progress): ${options.url}`);
310
- return;
311
- }
312
-
313
- const refreshPromise = (async () => {
314
- try {
315
- console.log(`🔄 [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Background] Starting background refresh for: ${options.url}`);
316
- refreshTimestamps.set(refreshKey, now);
317
-
318
- const freshData = await this.fetchDirect(options);
319
-
320
- if (freshData && options.dataValidator(freshData)) {
321
- // Update cache silently without triggering UI re-render
322
- this.setInMemory(options.url, freshData);
323
- await this.setInServer(options, freshData);
324
-
325
- console.log(`✅ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Background] Background refresh completed for: ${options.url}`);
326
- } else {
327
- console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Background] Background refresh failed (invalid data) for: ${options.url}`);
328
- }
329
- } catch (error) {
330
- console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Background] Background refresh failed for: ${options.url}`, error);
331
- } finally {
332
- cachingRequests.delete(refreshKey);
92
+ dataValidator: (data: any) => boolean
93
+ ): Promise<any | null> {
94
+ const inflight = inflightRequests.get(url);
95
+ if (inflight) return inflight;
96
+
97
+ const request = (async () => {
98
+ const response = await fetch(`${apiEndpoint}?url=${encodeURIComponent(url)}`, {
99
+ signal: AbortSignal.timeout(15_000),
100
+ });
101
+ if (response.ok) {
102
+ const data = await response.json();
103
+ if (data && dataValidator(data)) return data;
104
+ throw new Error('Server proxy returned invalid data shape');
333
105
  }
106
+ if (response.status === 404) return null;
107
+ throw new Error(`Server proxy failed: ${response.status}`);
334
108
  })();
335
109
 
336
- cachingRequests.set(refreshKey, refreshPromise);
337
- await refreshPromise;
338
- }
339
-
340
- // Silent background refresh for UI-sensitive components
341
- private silentBackgroundRefresh(options: CacheOptions & {
342
- directFetcher: () => Promise<any>;
343
- dataValidator: (data: any) => boolean;
344
- }): void {
345
- const refreshKey = `silent-refresh-${options.platform}-${options.url}`;
346
- const now = Date.now();
347
-
348
- // Check if we've refreshed this URL recently (within 5 minutes for silent refresh)
349
- const lastRefresh = refreshTimestamps.get(refreshKey);
350
- if (lastRefresh && (now - lastRefresh) < 300000) {
351
- console.log(`⏭️ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Silent] Silent refresh skipped (recent refresh): ${options.url}`);
352
- return; // Skip if refreshed within 5 minutes
353
- }
354
-
355
- // Prevent multiple silent refresh requests for the same URL
356
- if (cachingRequests.has(refreshKey)) {
357
- console.log(`⏭️ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Silent] Silent refresh skipped (already in progress): ${options.url}`);
358
- return;
359
- }
360
-
361
- // Run in next tick to avoid blocking UI thread
362
- setTimeout(() => {
363
- const refreshPromise = (async () => {
364
- try {
365
- console.log(`🔄 [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Silent] Starting silent background refresh for: ${options.url}`);
366
- refreshTimestamps.set(refreshKey, now);
367
-
368
- // Fetch fresh data silently
369
- const freshData = await this.fetchDirect(options);
370
-
371
- if (freshData && options.dataValidator(freshData)) {
372
- // Update cache silently - no UI notifications
373
- this.setInMemory(options.url, freshData);
374
- await this.setInServer(options, freshData);
375
- console.log(`✅ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Silent] Silent refresh completed for: ${options.url}`);
376
- } else {
377
- console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Silent] Silent refresh failed (invalid data) for: ${options.url}`);
378
- }
379
- } catch (error) {
380
- console.log(`❌ [${options.platform.charAt(0).toUpperCase() + options.platform.slice(1)} Silent] Silent refresh failed for: ${options.url}`, error);
381
- } finally {
382
- cachingRequests.delete(refreshKey);
383
- }
384
- })();
385
-
386
- cachingRequests.set(refreshKey, refreshPromise);
387
- }, 0); // Defer to next event loop tick
110
+ inflightRequests.set(
111
+ url,
112
+ request.finally(() => inflightRequests.delete(url))
113
+ );
114
+ return inflightRequests.get(url)!;
388
115
  }
389
116
  }
390
117