@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.
- package/dist/{chunk-XN4JKIJY.js → chunk-2ALT6CTY.js} +2 -2
- package/dist/{chunk-4ZRHVOEB.cjs → chunk-2YJ2CVRC.cjs} +31 -31
- package/dist/{chunk-4ZRHVOEB.cjs.map → chunk-2YJ2CVRC.cjs.map} +1 -1
- package/dist/{chunk-NAV4FRNL.cjs → chunk-67MWXMNT.cjs} +26 -26
- package/dist/{chunk-NAV4FRNL.cjs.map → chunk-67MWXMNT.cjs.map} +1 -1
- package/dist/{chunk-4QKXNWH5.js → chunk-BSE2UT6L.js} +2 -2
- package/dist/{chunk-2XGHEJZK.js → chunk-BUVD2OAI.js} +4 -4
- package/dist/{chunk-6W3ULZ4X.js → chunk-D62VYSTL.js} +6 -6
- package/dist/{chunk-J4BE4FIR.cjs → chunk-EBSZWSC5.cjs} +4 -4
- package/dist/{chunk-J4BE4FIR.cjs.map → chunk-EBSZWSC5.cjs.map} +1 -1
- package/dist/{chunk-U555TMPC.js → chunk-H4W4LENJ.js} +5 -5
- package/dist/{chunk-DFQLUK75.cjs → chunk-JELZAWSM.cjs} +3 -3
- package/dist/{chunk-DFQLUK75.cjs.map → chunk-JELZAWSM.cjs.map} +1 -1
- package/dist/{chunk-6YXN5RHW.js → chunk-KZ6PWFHK.js} +2 -2
- package/dist/{chunk-JDW2IL7A.cjs → chunk-LBZEPB4Y.cjs} +11 -11
- package/dist/{chunk-JDW2IL7A.cjs.map → chunk-LBZEPB4Y.cjs.map} +1 -1
- package/dist/{chunk-UPHN2VRZ.cjs → chunk-LYRGM25P.cjs} +750 -946
- package/dist/chunk-LYRGM25P.cjs.map +1 -0
- package/dist/{chunk-GNOK5J34.cjs → chunk-M5ODB6RQ.cjs} +37 -37
- package/dist/{chunk-GNOK5J34.cjs.map → chunk-M5ODB6RQ.cjs.map} +1 -1
- package/dist/{chunk-ENPGPZIB.cjs → chunk-MLILEBJU.cjs} +61 -61
- package/dist/{chunk-ENPGPZIB.cjs.map → chunk-MLILEBJU.cjs.map} +1 -1
- package/dist/{chunk-46FKXLNE.js → chunk-MTCU5MQN.js} +2 -2
- package/dist/{chunk-2MYDGLJS.cjs → chunk-NCUKKJRG.cjs} +87 -87
- package/dist/{chunk-2MYDGLJS.cjs.map → chunk-NCUKKJRG.cjs.map} +1 -1
- package/dist/{chunk-LIUARTOZ.js → chunk-O6VJZX2N.js} +56 -252
- package/dist/{chunk-LIUARTOZ.js.map → chunk-O6VJZX2N.js.map} +1 -1
- package/dist/{chunk-LE3BJ5H2.js → chunk-ONIYUMTO.js} +2 -2
- package/dist/{chunk-2TUOBFPK.js → chunk-POFCTXNH.js} +2 -2
- package/dist/{chunk-OLOKQQAP.cjs → chunk-RHKH4YUY.cjs} +5 -5
- package/dist/{chunk-OLOKQQAP.cjs.map → chunk-RHKH4YUY.cjs.map} +1 -1
- package/dist/{chunk-Z3NOI7X7.cjs → chunk-UMDGRL6A.cjs} +26 -26
- package/dist/{chunk-Z3NOI7X7.cjs.map → chunk-UMDGRL6A.cjs.map} +1 -1
- package/dist/{chunk-RKITTO5V.cjs → chunk-UWB33C6R.cjs} +14 -14
- package/dist/{chunk-RKITTO5V.cjs.map → chunk-UWB33C6R.cjs.map} +1 -1
- package/dist/{chunk-MVGL5NNJ.js → chunk-VYOGAQLM.js} +2 -2
- package/dist/{chunk-5U67WTAS.js → chunk-W4MXOBEW.js} +2 -2
- package/dist/{chunk-YLB6XXO6.js → chunk-WBYRBTAS.js} +2 -2
- package/dist/{chunk-KMB66NYX.cjs → chunk-YW2ZU7LC.cjs} +7 -7
- package/dist/{chunk-KMB66NYX.cjs.map → chunk-YW2ZU7LC.cjs.map} +1 -1
- package/dist/{chunk-QZ2NAPLF.cjs → chunk-ZKQXE2P4.cjs} +9 -9
- package/dist/{chunk-QZ2NAPLF.cjs.map → chunk-ZKQXE2P4.cjs.map} +1 -1
- package/dist/{chunk-6SMLYZLH.js → chunk-ZLLXYE2G.js} +4 -4
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +132 -132
- package/dist/components/index.js +11 -11
- package/dist/components/meeting-scheduler/index.cjs +34 -34
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -1
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/utils/social-embed-cache.d.ts +16 -16
- package/dist/utils/social-embed-cache.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +1 -5
- package/src/components/ui/markdown/rich/__tests__/shortcodes.test.ts +58 -0
- package/src/components/ui/markdown/rich/embed-overrides.tsx +13 -7
- package/src/components/ui/markdown/rich/shortcodes.ts +15 -0
- package/src/utils/social-embed-cache.ts +63 -336
- package/dist/chunk-UPHN2VRZ.cjs.map +0 -1
- /package/dist/{chunk-XN4JKIJY.js.map → chunk-2ALT6CTY.js.map} +0 -0
- /package/dist/{chunk-4QKXNWH5.js.map → chunk-BSE2UT6L.js.map} +0 -0
- /package/dist/{chunk-2XGHEJZK.js.map → chunk-BUVD2OAI.js.map} +0 -0
- /package/dist/{chunk-6W3ULZ4X.js.map → chunk-D62VYSTL.js.map} +0 -0
- /package/dist/{chunk-U555TMPC.js.map → chunk-H4W4LENJ.js.map} +0 -0
- /package/dist/{chunk-6YXN5RHW.js.map → chunk-KZ6PWFHK.js.map} +0 -0
- /package/dist/{chunk-46FKXLNE.js.map → chunk-MTCU5MQN.js.map} +0 -0
- /package/dist/{chunk-LE3BJ5H2.js.map → chunk-ONIYUMTO.js.map} +0 -0
- /package/dist/{chunk-2TUOBFPK.js.map → chunk-POFCTXNH.js.map} +0 -0
- /package/dist/{chunk-MVGL5NNJ.js.map → chunk-VYOGAQLM.js.map} +0 -0
- /package/dist/{chunk-5U67WTAS.js.map → chunk-W4MXOBEW.js.map} +0 -0
- /package/dist/{chunk-YLB6XXO6.js.map → chunk-WBYRBTAS.js.map} +0 -0
- /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
|
-
//
|
|
157
|
-
// `<blockquote class="reddit-embed-bq">…<a href="post-url">`
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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
|
|
2
|
-
// Shared between Reddit and Twitter
|
|
1
|
+
// Unified Social Media Embed fetch layer.
|
|
2
|
+
// Shared between Reddit and Twitter embed clients for consistent behavior.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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(`❌ [${
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
private
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|