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,201 @@ 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+ const errMsgLower = errMsg . toLowerCase ( ) ;
113+ // Check for auth-related errors (case-insensitive)
114+ const isAuthError =
115+ errMsgLower . includes ( "unauthorized" ) ||
116+ errMsgLower . includes ( "401" ) ||
117+ errMsgLower . includes ( "auth token" ) ||
118+ errMsgLower . includes ( "authentication" ) ;
119+ if ( isAuthError ) {
120+ clearStoredAuthToken ( ) ;
121+ setState ( { status : "auth_required" , error : token ? "Invalid token" : undefined } ) ;
122+ } else {
123+ setState ( { status : "error" , error : errMsg } ) ;
124+ }
125+ } ) ;
63126 } ) ;
64127
65- newClient = createClient ( link ) ;
66- cleanup = ( ) => {
67- ws . close ( ) ;
68- } ;
69- }
128+ ws . addEventListener ( "error" , ( ) => {
129+ // WebSocket connection failed - might be auth issue or network
130+ cleanup ( ) ;
131+ // If we had a token and failed, likely auth issue
132+ if ( token ) {
133+ clearStoredAuthToken ( ) ;
134+ setState ( { status : "auth_required" , error : "Connection failed - invalid token?" } ) ;
135+ } else {
136+ // Try without token first, server might not require auth
137+ // If server requires auth, the ping will fail with UNAUTHORIZED
138+ setState ( { status : "auth_required" } ) ;
139+ }
140+ } ) ;
70141
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 ) ;
142+ ws . addEventListener ( "close" , ( event ) => {
143+ // 1008 = Policy Violation (often used for auth failures)
144+ // 4401 = Custom unauthorized code
145+ if ( event . code === 1008 || event . code === 4401 ) {
146+ cleanup ( ) ;
147+ clearStoredAuthToken ( ) ;
148+ setState ( { status : "auth_required" , error : "Authentication required" } ) ;
149+ }
150+ } ) ;
151+ } ,
152+ [ props . client ]
153+ ) ;
74154
75- window . __ORPC_CLIENT__ = newClient ;
155+ // Initial connection attempt
156+ useEffect ( ( ) => {
157+ connect ( authToken ) ;
76158
77159 return ( ) => {
78- cleanup ( ) ;
160+ if ( state . status === "connected" ) {
161+ state . cleanup ( ) ;
162+ }
79163 } ;
80- } , [ props . client ] ) ;
164+ // Only run on mount and when authToken changes via handleAuthSubmit
165+ // eslint-disable-next-line react-hooks/exhaustive-deps
166+ } , [ ] ) ;
167+
168+ const handleAuthSubmit = useCallback (
169+ ( token : string ) => {
170+ setAuthToken ( token ) ;
171+ connect ( token ) ;
172+ } ,
173+ [ connect ]
174+ ) ;
175+
176+ // Show auth modal if auth is required
177+ if ( state . status === "auth_required" ) {
178+ return < AuthTokenModal isOpen = { true } onSubmit = { handleAuthSubmit } error = { state . error ?? null } /> ;
179+ }
180+
181+ // Show error state
182+ if ( state . status === "error" ) {
183+ return (
184+ < div
185+ style = { {
186+ display : "flex" ,
187+ alignItems : "center" ,
188+ justifyContent : "center" ,
189+ height : "100vh" ,
190+ color : "var(--color-error, #ff6b6b)" ,
191+ flexDirection : "column" ,
192+ gap : 16 ,
193+ } }
194+ >
195+ < div > Failed to connect to server</ div >
196+ < div style = { { fontSize : 13 , color : "var(--color-text-secondary)" } } > { state . error } </ div >
197+ < button
198+ onClick = { ( ) => connect ( authToken ) }
199+ style = { {
200+ padding : "8px 16px" ,
201+ borderRadius : 4 ,
202+ border : "1px solid var(--color-border)" ,
203+ background : "var(--color-button-background)" ,
204+ color : "var(--color-text)" ,
205+ cursor : "pointer" ,
206+ } }
207+ >
208+ Retry
209+ </ button >
210+ </ div >
211+ ) ;
212+ }
81213
82- if ( ! client ) {
214+ // Show loading while connecting
215+ if ( state . status === "connecting" ) {
83216 return null ; // Or a loading spinner
84217 }
85218
86- return < ORPCContext . Provider value = { client } > { props . children } </ ORPCContext . Provider > ;
219+ return < ORPCContext . Provider value = { state . client } > { props . children } </ ORPCContext . Provider > ;
87220} ;
88221
89222export const useORPC = ( ) : RouterClient < AppRouter > => {
0 commit comments