Skip to content

Commit a7899f3

Browse files
authored
Merge pull request #55 from homanp/cursor/fix-prod-widget-proxy-cache
fix production widget proxy stability
2 parents 19b8969 + 372df9a commit a7899f3

File tree

2 files changed

+125
-21
lines changed

2 files changed

+125
-21
lines changed

src/app/api/chat/route.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,17 @@ Utility: \`import { cn } from "@/lib/utils";\`
106106
107107
## Data Fetching
108108
109-
For external APIs, use the CORS proxy provided by the host app:
109+
Only fetch live external data when the user clearly asks for current, remote, or API-backed data.
110+
111+
Never invent API endpoints from memory, and never copy a sibling widget's API URL without re-verifying that it is still valid.
112+
113+
If the \`web_search\` tool is available, use it to verify the current official API docs before writing network code.
114+
115+
If you cannot verify a stable public endpoint, or the API requires auth, rate-limits aggressively, or looks unofficial, ask the user for the API URL/key or build the widget with mock/sample data instead of guessing.
116+
117+
Shared production hosts are often rate-limited by public/demo endpoints, so avoid undocumented or flaky sources unless the user explicitly asked for that exact source and you verified it.
118+
119+
When you do need a verified external API, use the CORS proxy provided by the host app:
110120
\`\`\`tsx
111121
const res = await fetch("/api/proxy?url=" + encodeURIComponent("https://api.example.com/data"));
112122
const data = await res.json();
@@ -133,7 +143,7 @@ Use \`useEffect\` with \`setInterval\` for polling. Always handle loading and er
133143
134144
## Dashboard Awareness
135145
136-
You are building one widget within a larger dashboard. Use \`listDashboardWidgets\` to see what other widgets exist — their titles, descriptions, and whether they have code. Use \`readWidgetCode\` to inspect a sibling widget's source code when you need to match API patterns, data formats, or styling conventions.
146+
You are building one widget within a larger dashboard. Use \`listDashboardWidgets\` to see what other widgets exist — their titles, descriptions, and whether they have code. Use \`readWidgetCode\` to inspect a sibling widget's source code when you need to match layout, styling, or data shapes, but treat any sibling network code as potentially stale until you verify it.
137147
138148
Design your widget to complement the others. Don't duplicate what they already show.
139149
@@ -247,7 +257,7 @@ export async function POST(request: Request) {
247257

248258
const readWidgetCodeTool = tool({
249259
description:
250-
"Read the source code of another widget on the dashboard. Use this to match API patterns, data formats, or styling conventions used by sibling widgets.",
260+
"Read the source code of another widget on the dashboard. Use this to match styling or data shapes, but do not blindly reuse sibling API endpoints without re-verifying them.",
251261
inputSchema: z.object({
252262
targetWidgetId: z.string().describe("The ID of the sibling widget to read"),
253263
path: z

src/app/api/proxy/route.ts

Lines changed: 112 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,101 @@
11
import { scanUrl } from "@/lib/brin";
22

3+
interface ProxyResponsePayload {
4+
body: Uint8Array;
5+
contentType: string;
6+
status: number;
7+
}
8+
9+
interface CachedProxyResponse extends ProxyResponsePayload {
10+
freshUntil: number;
11+
staleUntil: number;
12+
}
13+
14+
const CACHE_TTL_MS = 30_000;
15+
const STALE_TTL_MS = 5 * 60_000;
16+
const MAX_CACHE_BYTES = 1_000_000;
17+
18+
const proxyCache = new Map<string, CachedProxyResponse>();
19+
const inflightProxyRequests = new Map<string, Promise<ProxyResponsePayload>>();
20+
21+
function buildProxyResponse(
22+
payload: ProxyResponsePayload,
23+
cacheStatus: "HIT" | "MISS" | "STALE",
24+
upstreamStatus = payload.status,
25+
) {
26+
return new Response(payload.body.slice(), {
27+
status: payload.status,
28+
headers: {
29+
"Content-Type": payload.contentType,
30+
"Access-Control-Allow-Origin": "*",
31+
"Cache-Control": "no-store",
32+
"X-Proxy-Cache": cacheStatus,
33+
"X-Proxy-Upstream-Status": String(upstreamStatus),
34+
},
35+
});
36+
}
37+
38+
function getFreshCacheEntry(target: string): CachedProxyResponse | null {
39+
const cached = proxyCache.get(target);
40+
if (!cached) return null;
41+
if (cached.staleUntil <= Date.now()) {
42+
proxyCache.delete(target);
43+
return null;
44+
}
45+
return cached.freshUntil > Date.now() ? cached : null;
46+
}
47+
48+
function getStaleCacheEntry(target: string): CachedProxyResponse | null {
49+
const cached = proxyCache.get(target);
50+
if (!cached) return null;
51+
if (cached.staleUntil <= Date.now()) {
52+
proxyCache.delete(target);
53+
return null;
54+
}
55+
return cached;
56+
}
57+
58+
function maybeCacheResponse(target: string, payload: ProxyResponsePayload) {
59+
if (payload.status < 200 || payload.status >= 300) return;
60+
if (payload.body.byteLength > MAX_CACHE_BYTES) return;
61+
62+
proxyCache.set(target, {
63+
...payload,
64+
freshUntil: Date.now() + CACHE_TTL_MS,
65+
staleUntil: Date.now() + STALE_TTL_MS,
66+
});
67+
}
68+
69+
async function fetchUpstream(target: string): Promise<ProxyResponsePayload> {
70+
const existing = inflightProxyRequests.get(target);
71+
if (existing) {
72+
return existing;
73+
}
74+
75+
const request = (async () => {
76+
const upstream = await fetch(target, {
77+
headers: {
78+
"User-Agent": "infinite-monitor/1.0",
79+
Accept: "application/json, text/plain, */*",
80+
},
81+
signal: AbortSignal.timeout(15_000),
82+
});
83+
84+
const contentType =
85+
upstream.headers.get("content-type") ?? "application/json";
86+
const body = new Uint8Array(await upstream.arrayBuffer());
87+
const payload = { body, contentType, status: upstream.status };
88+
89+
maybeCacheResponse(target, payload);
90+
return payload;
91+
})().finally(() => {
92+
inflightProxyRequests.delete(target);
93+
});
94+
95+
inflightProxyRequests.set(target, request);
96+
return request;
97+
}
98+
399
export async function GET(request: Request) {
4100
const { searchParams } = new URL(request.url);
5101
const target = searchParams.get("url");
@@ -19,6 +115,11 @@ export async function GET(request: Request) {
19115
return Response.json({ error: "only http/https allowed" }, { status: 400 });
20116
}
21117

118+
const freshCacheHit = getFreshCacheEntry(target);
119+
if (freshCacheHit) {
120+
return buildProxyResponse(freshCacheHit, "HIT");
121+
}
122+
22123
try {
23124
const scan = await scanUrl(target);
24125
if (!scan.safe) {
@@ -36,27 +137,20 @@ export async function GET(request: Request) {
36137
// Allow request through if brin is unreachable
37138
}
38139

39-
try {
40-
const upstream = await fetch(target, {
41-
headers: {
42-
"User-Agent": "infinite-monitor/1.0",
43-
Accept: "application/json, text/plain, */*",
44-
},
45-
signal: AbortSignal.timeout(15000),
46-
});
140+
const staleCacheHit = getStaleCacheEntry(target);
47141

48-
const contentType =
49-
upstream.headers.get("content-type") ?? "application/json";
142+
try {
143+
const upstream = await fetchUpstream(target);
144+
if ((upstream.status < 200 || upstream.status >= 300) && staleCacheHit) {
145+
return buildProxyResponse(staleCacheHit, "STALE", upstream.status);
146+
}
50147

51-
return new Response(upstream.body, {
52-
status: upstream.status,
53-
headers: {
54-
"Content-Type": contentType,
55-
"Access-Control-Allow-Origin": "*",
56-
"Cache-Control": "no-store",
57-
},
58-
});
148+
return buildProxyResponse(upstream, "MISS");
59149
} catch (err) {
150+
if (staleCacheHit) {
151+
return buildProxyResponse(staleCacheHit, "STALE");
152+
}
153+
60154
return Response.json(
61155
{ error: "upstream fetch failed", detail: String(err) },
62156
{ status: 502 }

0 commit comments

Comments
 (0)