Skip to content

Commit 7ab3047

Browse files
hallelx2claude
andcommitted
feat: switch from SSE to polling for Workers free tier compatibility
Cloudflare Workers free tier kills idle SSE connections after ~30s. Replace SSE with polling every 2s: - New pollEvents() function detects new events by comparing IDs - useBridge hook forwards unprocessed events to localhost on each poll - Tracks forwarded event IDs to avoid duplicate forwarding - More reliable on free tier, ~2s latency instead of real-time Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 049d0b6 commit 7ab3047

2 files changed

Lines changed: 134 additions & 109 deletions

File tree

apps/web/src/hooks/useBridge.ts

Lines changed: 71 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { useCallback, useEffect, useRef, useState } from "react";
2-
import type { SSEEvent, SSEWebhookEvent, WebhookEventData } from "../lib/relay";
2+
import type { WebhookEventData } from "../lib/relay";
33
import {
4-
connectSSE,
54
createChannel,
65
forwardToLocalhost,
76
getEvents,
7+
pollEvents,
88
sendResponse,
99
} from "../lib/relay";
1010

@@ -30,6 +30,21 @@ interface BridgeState {
3030
error: string | null;
3131
}
3232

33+
function toLiveEvent(e: WebhookEventData): LiveEvent {
34+
return {
35+
id: e.id,
36+
method: e.method,
37+
path: e.path,
38+
requestHeaders: JSON.parse(e.requestHeaders || "{}"),
39+
requestBody: e.requestBody,
40+
responseStatus: e.responseStatus,
41+
responseBody: e.responseBody,
42+
latencyMs: e.latencyMs,
43+
error: e.error,
44+
receivedAt: e.receivedAt,
45+
};
46+
}
47+
3348
export function useBridge() {
3449
const [state, setState] = useState<BridgeState>({
3550
status: "idle",
@@ -40,43 +55,41 @@ export function useBridge() {
4055
error: null,
4156
});
4257

43-
const disconnectRef = useRef<(() => void) | null>(null);
44-
45-
const handleSSEEvent = useCallback(
46-
(sseEvent: SSEEvent) => {
47-
if (sseEvent.type === "connected") {
58+
const stopPollingRef = useRef<(() => void) | null>(null);
59+
const forwardedRef = useRef<Set<string>>(new Set());
60+
61+
/**
62+
* When polling detects new events, check for unforwarded ones
63+
* and forward them to localhost (the bridge!).
64+
*/
65+
const handleNewEvents = useCallback(
66+
(events: WebhookEventData[]) => {
67+
if (events.length === 0) {
68+
// Initial "connected" signal
4869
setState((s) => ({ ...s, status: "connected" }));
70+
return;
4971
}
5072

51-
if (sseEvent.type === "webhook") {
52-
const webhookEvent = sseEvent as SSEWebhookEvent;
53-
// Add event to list immediately
54-
const liveEvent: LiveEvent = {
55-
id: webhookEvent.id,
56-
method: webhookEvent.method,
57-
path: webhookEvent.path,
58-
requestHeaders: webhookEvent.headers,
59-
requestBody: webhookEvent.body,
60-
responseStatus: null,
61-
responseBody: null,
62-
latencyMs: null,
63-
error: null,
64-
receivedAt: webhookEvent.receivedAt,
65-
};
73+
// Update event list
74+
const liveEvents = events.map(toLiveEvent);
75+
setState((s) => ({ ...s, events: liveEvents }));
6676

67-
setState((s) => ({
68-
...s,
69-
events: [liveEvent, ...s.events].slice(0, 100),
70-
}));
77+
// Find events that haven't been forwarded yet (no response)
78+
const unforwarded = events.filter(
79+
(e) => !e.responseStatus && !e.error && !forwardedRef.current.has(e.id),
80+
);
81+
82+
// Forward each to localhost
83+
for (const evt of unforwarded) {
84+
forwardedRef.current.add(evt.id);
7185

72-
// Forward to localhost (client-side bridge!)
73-
forwardToLocalhost(webhookEvent, state.port)
86+
forwardToLocalhost(evt, state.port)
7487
.then((response) => {
75-
// Update event with response
88+
// Update local state with response
7689
setState((s) => ({
7790
...s,
7891
events: s.events.map((e) =>
79-
e.id === webhookEvent.id
92+
e.id === evt.id
8093
? {
8194
...e,
8295
responseStatus: response.status,
@@ -89,43 +102,28 @@ export function useBridge() {
89102

90103
// Send response back to relay (server stores in Neon)
91104
if (state.channelId) {
92-
sendResponse(state.channelId, webhookEvent.id, response);
105+
sendResponse(state.channelId, evt.id, response);
93106
}
94107
})
95108
.catch((err) => {
96109
setState((s) => ({
97110
...s,
98111
events: s.events.map((e) =>
99-
e.id === webhookEvent.id ? { ...e, error: (err as Error).message } : e,
112+
e.id === evt.id ? { ...e, error: (err as Error).message } : e,
100113
),
101114
}));
102115
});
103116
}
104-
105-
if (sseEvent.type === "response") {
106-
// Update from server-side confirmation
107-
setState((s) => ({
108-
...s,
109-
events: s.events.map((e) =>
110-
e.id === sseEvent.eventId
111-
? {
112-
...e,
113-
responseStatus: sseEvent.status,
114-
latencyMs: sseEvent.latencyMs,
115-
}
116-
: e,
117-
),
118-
}));
119-
}
120117
},
121118
[state.port, state.channelId],
122119
);
123120

124-
/** Start a new bridge: create channel (server-side), connect SSE, start forwarding */
121+
/** Start a new bridge: create channel, start polling */
125122
const connect = useCallback(
126123
async (port: number, allowedPaths: string[]) => {
127124
try {
128125
setState((s) => ({ ...s, status: "connecting", port, error: null }));
126+
forwardedRef.current = new Set();
129127

130128
// Server creates channel + persists to Neon
131129
const channel = await createChannel(port, allowedPaths);
@@ -138,31 +136,25 @@ export function useBridge() {
138136

139137
// Load existing events from DB
140138
const existingEvents = await getEvents(channel.channelId);
141-
const liveEvents: LiveEvent[] = existingEvents.map((e: WebhookEventData) => ({
142-
id: e.id,
143-
method: e.method,
144-
path: e.path,
145-
requestHeaders: JSON.parse(e.requestHeaders || "{}"),
146-
requestBody: e.requestBody,
147-
responseStatus: e.responseStatus,
148-
responseBody: e.responseBody,
149-
latencyMs: e.latencyMs,
150-
error: e.error,
151-
receivedAt: e.receivedAt,
152-
}));
153-
154-
setState((s) => ({ ...s, events: liveEvents }));
155-
156-
// Connect SSE (client-side real-time stream)
157-
const disconnect = connectSSE(channel.channelId, handleSSEEvent, () => {
158-
setState((s) => ({
159-
...s,
160-
status: "error",
161-
error: "SSE connection lost",
162-
}));
163-
});
164-
165-
disconnectRef.current = disconnect;
139+
const liveEvents = existingEvents.map(toLiveEvent);
140+
// Mark existing events as already forwarded
141+
for (const e of existingEvents) {
142+
forwardedRef.current.add(e.id);
143+
}
144+
setState((s) => ({ ...s, events: liveEvents, status: "connected" }));
145+
146+
// Start polling for new events (every 2 seconds)
147+
const stopPolling = pollEvents(
148+
channel.channelId,
149+
handleNewEvents,
150+
(err) => {
151+
console.error("Polling error:", err);
152+
// Don't set error state for transient polling failures
153+
},
154+
2000,
155+
);
156+
157+
stopPollingRef.current = stopPolling;
166158
} catch (err) {
167159
setState((s) => ({
168160
...s,
@@ -171,13 +163,14 @@ export function useBridge() {
171163
}));
172164
}
173165
},
174-
[handleSSEEvent],
166+
[handleNewEvents],
175167
);
176168

177169
/** Disconnect the bridge */
178170
const disconnect = useCallback(() => {
179-
disconnectRef.current?.();
180-
disconnectRef.current = null;
171+
stopPollingRef.current?.();
172+
stopPollingRef.current = null;
173+
forwardedRef.current = new Set();
181174
setState({
182175
status: "idle",
183176
channelId: null,
@@ -191,7 +184,7 @@ export function useBridge() {
191184
// Cleanup on unmount
192185
useEffect(() => {
193186
return () => {
194-
disconnectRef.current?.();
187+
stopPollingRef.current?.();
195188
};
196189
}, []);
197190

apps/web/src/lib/relay.ts

Lines changed: 63 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
/**
22
* Client-side API helpers for talking to the relay server.
33
* All DB operations happen server-side on the relay — the client
4-
* only makes fetch() calls and connects via SSE.
4+
* only makes fetch() calls.
5+
*
6+
* Uses polling (not SSE) because Cloudflare Workers free tier
7+
* kills idle connections after ~30s. Polling every 2s is reliable.
58
*/
69

710
const RELAY_URL = import.meta.env.VITE_RELAY_URL || "http://localhost:8787";
@@ -53,7 +56,6 @@ export type SSEEvent =
5356

5457
/** Create a new channel on the relay (server-side persists to Neon) */
5558
export async function createChannel(port: number, allowedPaths: string[]): Promise<ChannelInfo> {
56-
// Generate a secret client-side, hash it, send hash to server
5759
const secret = crypto.randomUUID();
5860
const encoder = new TextEncoder();
5961
const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(secret));
@@ -70,9 +72,7 @@ export async function createChannel(port: number, allowedPaths: string[]): Promi
7072
if (!res.ok) throw new Error(`Failed to create channel: ${res.status}`);
7173
const data = await res.json();
7274

73-
// Store secret locally
7475
localStorage.setItem(`bh_secret_${data.channelId}`, secret);
75-
7676
return data;
7777
}
7878

@@ -90,60 +90,92 @@ export async function deleteChannel(channelId: string): Promise<void> {
9090
localStorage.removeItem(`bh_secret_${channelId}`);
9191
}
9292

93-
/** Fetch historical events for a channel */
93+
/** Fetch events for a channel (used for both initial load and polling) */
9494
export async function getEvents(channelId: string, limit = 50): Promise<WebhookEventData[]> {
9595
const res = await fetch(`${RELAY_URL}/api/channels/${channelId}/events?limit=${limit}`);
9696
if (!res.ok) throw new Error(`Failed to get events: ${res.status}`);
9797
return res.json();
9898
}
9999

100-
/** Connect to SSE stream for real-time events */
101-
export function connectSSE(
100+
/**
101+
* Poll for new events every `intervalMs`.
102+
* Returns a cleanup function to stop polling.
103+
* Calls `onNewEvents` only when new events are detected (by comparing IDs).
104+
*/
105+
export function pollEvents(
102106
channelId: string,
103-
onEvent: (event: SSEEvent) => void,
104-
onError?: (error: Event) => void,
107+
onNewEvents: (events: WebhookEventData[]) => void,
108+
onError?: (error: Error) => void,
109+
intervalMs = 2000,
105110
): () => void {
106-
const source = new EventSource(`${RELAY_URL}/hook/${channelId}/events`);
107-
108-
source.onmessage = (msg) => {
109-
const data = JSON.parse(msg.data) as SSEEvent;
110-
onEvent(data);
111-
};
112-
113-
source.onerror = (err) => {
114-
onError?.(err);
111+
let lastSeenId: string | null = null;
112+
let stopped = false;
113+
114+
async function poll() {
115+
if (stopped) return;
116+
try {
117+
const events = await getEvents(channelId, 50);
118+
// Events come newest-first from the API
119+
if (events.length > 0 && events[0].id !== lastSeenId) {
120+
lastSeenId = events[0].id;
121+
onNewEvents(events);
122+
}
123+
} catch (err) {
124+
onError?.(err as Error);
125+
}
126+
if (!stopped) {
127+
setTimeout(poll, intervalMs);
128+
}
129+
}
130+
131+
// Start polling
132+
poll();
133+
134+
// Also fire a "connected" callback immediately
135+
onNewEvents([]);
136+
137+
return () => {
138+
stopped = true;
115139
};
116-
117-
return () => source.close();
118140
}
119141

120142
/**
121143
* Forward a webhook event to localhost.
122144
* This runs CLIENT-SIDE in the browser — the browser IS the bridge.
123145
*/
124146
export async function forwardToLocalhost(
125-
event: SSEWebhookEvent,
147+
event: SSEWebhookEvent | WebhookEventData,
126148
port: number,
127149
): Promise<{ status: number; headers: Record<string, string>; body: string; latencyMs: number }> {
128150
const start = performance.now();
129151

130-
// Strip the /hook/:channelId prefix to get the actual path
131-
const localPath = event.path.replace(/^\/hook\/[a-z0-9]+/, "") || "/";
152+
// Determine path and headers based on event type
153+
const eventPath =
154+
"headers" in event && typeof event.headers === "object" && !Array.isArray(event.headers)
155+
? event.path.replace(/^\/hook\/[a-z0-9]+/, "") || "/"
156+
: event.path.replace(/^\/hook\/[a-z0-9]+/, "") || "/";
157+
158+
const headers: Record<string, string> =
159+
"requestHeaders" in event ? JSON.parse(event.requestHeaders || "{}") : event.headers;
160+
161+
const body = "requestBody" in event ? event.requestBody : event.body;
162+
163+
const method = event.method;
132164

133-
const response = await fetch(`http://localhost:${port}${localPath}`, {
134-
method: event.method,
135-
headers: event.headers,
136-
body: event.body || undefined,
165+
const response = await fetch(`http://localhost:${port}${eventPath}`, {
166+
method,
167+
headers,
168+
body: body || undefined,
137169
});
138170

139171
const latencyMs = Math.round(performance.now() - start);
140-
const body = await response.text();
141-
const headers: Record<string, string> = {};
172+
const respBody = await response.text();
173+
const respHeaders: Record<string, string> = {};
142174
response.headers.forEach((v, k) => {
143-
headers[k] = v;
175+
respHeaders[k] = v;
144176
});
145177

146-
return { status: response.status, headers, body, latencyMs };
178+
return { status: response.status, headers: respHeaders, body: respBody, latencyMs };
147179
}
148180

149181
/** Send the local response back to the relay (server stores it in Neon) */

0 commit comments

Comments
 (0)