11import { useCallback , useEffect , useRef , useState } from "react" ;
2- import type { SSEEvent , SSEWebhookEvent , WebhookEventData } from "../lib/relay" ;
2+ import type { WebhookEventData } from "../lib/relay" ;
33import {
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+
3348export 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
0 commit comments