1- import { createContext , useContext , useEffect , useState } from "react" ;
1+ import { createContext , useContext , useEffect , useState , useCallback } from "react" ;
22import { createClient } from "@/common/orpc/client" ;
33import { RPCLink as WebSocketLink } from "@orpc/client/websocket" ;
44import { RPCLink as MessagePortLink } from "@orpc/client/message-port" ;
55import type { AppRouter } from "@/node/orpc/router" ;
66import type { RouterClient } from "@orpc/server" ;
7+ import {
8+ AuthTokenModal ,
9+ getStoredAuthToken ,
10+ clearStoredAuthToken ,
11+ } from "@/browser/components/AuthTokenModal" ;
712
813type ORPCClient = ReturnType < typeof createClient > ;
914
@@ -17,73 +22,196 @@ interface ORPCProviderProps {
1722 client ?: ORPCClient ;
1823}
1924
20- export const ORPCProvider = ( props : ORPCProviderProps ) => {
21- const [ client , setClient ] = useState < ORPCClient | null > ( props . client ?? null ) ;
25+ type ConnectionState =
26+ | { status : "connecting" }
27+ | { status : "connected" ; client : ORPCClient ; cleanup : ( ) => void }
28+ | { status : "auth_required" ; error ?: string }
29+ | { status : "error" ; error : string } ;
2230
23- useEffect ( ( ) => {
24- // If client provided externally, use it directly
25- if ( props . client ) {
26- setClient ( ( ) => props . client ! ) ;
27- window . __ORPC_CLIENT__ = props . client ;
28- return ;
29- }
30-
31- let cleanup : ( ) => void ;
32- let newClient : ORPCClient ;
33-
34- // Detect Electron mode by checking if window.api exists (exposed by preload script)
35- // window.api.platform contains the actual OS platform (darwin/win32/linux), not "electron"
36- if ( window . api ) {
37- // Electron Mode: Use MessageChannel
38- const { port1 : clientPort , port2 : serverPort } = new MessageChannel ( ) ;
39-
40- // Send port to preload/main
41- window . postMessage ( "start-orpc-client" , "*" , [ serverPort ] ) ;
42-
43- const link = new MessagePortLink ( {
44- port : clientPort ,
45- } ) ;
46- clientPort . start ( ) ;
47-
48- newClient = createClient ( link ) ;
49- cleanup = ( ) => {
50- clientPort . close ( ) ;
51- } ;
52- } else {
53- // Browser Mode: Use HTTP/WebSocket
54- // Assume server is at same origin or configured via VITE_BACKEND_URL
55- // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
56- // @ts -ignore - import.meta is available in Vite
57- const API_BASE = import . meta. env . VITE_BACKEND_URL ?? window . location . origin ;
58- const WS_BASE = API_BASE . replace ( "http://" , "ws://" ) . replace ( "https://" , "wss://" ) ;
59-
60- const ws = new WebSocket ( `${ WS_BASE } /orpc/ws` ) ;
61- const link = new WebSocketLink ( {
62- websocket : ws ,
31+ function getApiBase ( ) : string {
32+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
33+ // @ts -ignore - import.meta is available in Vite
34+ return import . meta. env . VITE_BACKEND_URL ?? window . location . origin ;
35+ }
36+
37+ function createElectronClient ( ) : { client : ORPCClient ; cleanup : ( ) => void } {
38+ const { port1 : clientPort , port2 : serverPort } = new MessageChannel ( ) ;
39+ window . postMessage ( "start-orpc-client" , "*" , [ serverPort ] ) ;
40+
41+ const link = new MessagePortLink ( { port : clientPort } ) ;
42+ clientPort . start ( ) ;
43+
44+ return {
45+ client : createClient ( link ) ,
46+ cleanup : ( ) => clientPort . close ( ) ,
47+ } ;
48+ }
49+
50+ function createBrowserClient ( authToken : string | null ) : {
51+ client : ORPCClient ;
52+ cleanup : ( ) => void ;
53+ ws : WebSocket ;
54+ } {
55+ const API_BASE = getApiBase ( ) ;
56+ const WS_BASE = API_BASE . replace ( "http://" , "ws://" ) . replace ( "https://" , "wss://" ) ;
57+
58+ const wsUrl = authToken
59+ ? `${ WS_BASE } /orpc/ws?token=${ encodeURIComponent ( authToken ) } `
60+ : `${ WS_BASE } /orpc/ws` ;
61+
62+ const ws = new WebSocket ( wsUrl ) ;
63+ const link = new WebSocketLink ( { websocket : ws } ) ;
64+
65+ return {
66+ client : createClient ( link ) ,
67+ cleanup : ( ) => ws . close ( ) ,
68+ ws,
69+ } ;
70+ }
71+
72+ export const ORPCProvider = ( props : ORPCProviderProps ) => {
73+ const [ state , setState ] = useState < ConnectionState > ( { status : "connecting" } ) ;
74+ const [ authToken , setAuthToken ] = useState < string | null > ( ( ) => {
75+ // Check URL param first, then localStorage
76+ const urlParams = new URLSearchParams ( window . location . search ) ;
77+ return urlParams . get ( "token" ) ?? getStoredAuthToken ( ) ;
78+ } ) ;
79+
80+ const connect = useCallback (
81+ ( token : string | null ) => {
82+ // If client provided externally, use it directly
83+ if ( props . client ) {
84+ window . __ORPC_CLIENT__ = props . client ;
85+ setState ( { status : "connected" , client : props . client , cleanup : ( ) => undefined } ) ;
86+ return ;
87+ }
88+
89+ // Electron mode - no auth needed
90+ if ( window . api ) {
91+ const { client, cleanup } = createElectronClient ( ) ;
92+ window . __ORPC_CLIENT__ = client ;
93+ setState ( { status : "connected" , client, cleanup } ) ;
94+ return ;
95+ }
96+
97+ // Browser mode - connect with optional auth token
98+ setState ( { status : "connecting" } ) ;
99+ const { client, cleanup, ws } = createBrowserClient ( token ) ;
100+
101+ ws . addEventListener ( "open" , ( ) => {
102+ // Connection successful - test with a ping to verify auth
103+ client . general
104+ . ping ( "auth-check" )
105+ . then ( ( ) => {
106+ window . __ORPC_CLIENT__ = client ;
107+ setState ( { status : "connected" , client, cleanup } ) ;
108+ } )
109+ . catch ( ( err : unknown ) => {
110+ cleanup ( ) ;
111+ const errMsg = err instanceof Error ? err . message : String ( err ) ;
112+ if ( errMsg . includes ( "UNAUTHORIZED" ) || errMsg . includes ( "401" ) ) {
113+ clearStoredAuthToken ( ) ;
114+ setState ( { status : "auth_required" , error : token ? "Invalid token" : undefined } ) ;
115+ } else {
116+ setState ( { status : "error" , error : errMsg } ) ;
117+ }
118+ } ) ;
63119 } ) ;
64120
65- newClient = createClient ( link ) ;
66- cleanup = ( ) => {
67- ws . close ( ) ;
68- } ;
69- }
121+ ws . addEventListener ( "error" , ( ) => {
122+ // WebSocket connection failed - might be auth issue or network
123+ cleanup ( ) ;
124+ // If we had a token and failed, likely auth issue
125+ if ( token ) {
126+ clearStoredAuthToken ( ) ;
127+ setState ( { status : "auth_required" , error : "Connection failed - invalid token?" } ) ;
128+ } else {
129+ // Try without token first, server might not require auth
130+ // If server requires auth, the ping will fail with UNAUTHORIZED
131+ setState ( { status : "auth_required" } ) ;
132+ }
133+ } ) ;
70134
71- // Pass a function to setClient to prevent React from treating the client (which is a callable Proxy)
72- // as a functional state update. Without this, React calls client(prevState), triggering a request to root /.
73- setClient ( ( ) => newClient ) ;
135+ ws . addEventListener ( "close" , ( event ) => {
136+ // 1008 = Policy Violation (often used for auth failures)
137+ // 4401 = Custom unauthorized code
138+ if ( event . code === 1008 || event . code === 4401 ) {
139+ cleanup ( ) ;
140+ clearStoredAuthToken ( ) ;
141+ setState ( { status : "auth_required" , error : "Authentication required" } ) ;
142+ }
143+ } ) ;
144+ } ,
145+ [ props . client ]
146+ ) ;
74147
75- window . __ORPC_CLIENT__ = newClient ;
148+ // Initial connection attempt
149+ useEffect ( ( ) => {
150+ connect ( authToken ) ;
76151
77152 return ( ) => {
78- cleanup ( ) ;
153+ if ( state . status === "connected" ) {
154+ state . cleanup ( ) ;
155+ }
79156 } ;
80- } , [ props . client ] ) ;
157+ // Only run on mount and when authToken changes via handleAuthSubmit
158+ // eslint-disable-next-line react-hooks/exhaustive-deps
159+ } , [ ] ) ;
160+
161+ const handleAuthSubmit = useCallback (
162+ ( token : string ) => {
163+ setAuthToken ( token ) ;
164+ connect ( token ) ;
165+ } ,
166+ [ connect ]
167+ ) ;
168+
169+ // Show auth modal if auth is required
170+ if ( state . status === "auth_required" ) {
171+ return (
172+ < AuthTokenModal isOpen = { true } onSubmit = { handleAuthSubmit } error = { state . error ?? null } />
173+ ) ;
174+ }
175+
176+ // Show error state
177+ if ( state . status === "error" ) {
178+ return (
179+ < div
180+ style = { {
181+ display : "flex" ,
182+ alignItems : "center" ,
183+ justifyContent : "center" ,
184+ height : "100vh" ,
185+ color : "var(--color-error, #ff6b6b)" ,
186+ flexDirection : "column" ,
187+ gap : 16 ,
188+ } }
189+ >
190+ < div > Failed to connect to server</ div >
191+ < div style = { { fontSize : 13 , color : "var(--color-text-secondary)" } } > { state . error } </ div >
192+ < button
193+ onClick = { ( ) => connect ( authToken ) }
194+ style = { {
195+ padding : "8px 16px" ,
196+ borderRadius : 4 ,
197+ border : "1px solid var(--color-border)" ,
198+ background : "var(--color-button-background)" ,
199+ color : "var(--color-text)" ,
200+ cursor : "pointer" ,
201+ } }
202+ >
203+ Retry
204+ </ button >
205+ </ div >
206+ ) ;
207+ }
81208
82- if ( ! client ) {
209+ // Show loading while connecting
210+ if ( state . status === "connecting" ) {
83211 return null ; // Or a loading spinner
84212 }
85213
86- return < ORPCContext . Provider value = { client } > { props . children } </ ORPCContext . Provider > ;
214+ return < ORPCContext . Provider value = { state . client } > { props . children } </ ORPCContext . Provider > ;
87215} ;
88216
89217export const useORPC = ( ) : RouterClient < AppRouter > => {
0 commit comments