88 * Client-side usage (in Svelte components):
99 * import { gqlClient } from '$lib/graphql-client';
1010 * const data = await gqlClient<{ removeItems: number }>(MUTATION, vars);
11+ *
12+ * import { gqlSubscribeClient } from '$lib/graphql-client';
13+ * const unsubscribe = gqlSubscribeClient<...>(SUBSCRIPTION, vars, { onData, onError });
14+ *
15+ * Subscriptions go over a single shared WebSocket via the `graphql-ws`
16+ * transport, so any number of concurrent subscriptions multiplex onto one
17+ * TCP connection regardless of HTTP version. This avoids exhausting the
18+ * per-origin HTTP/1.1 connection cap on bare-HTTP deployments.
1119 */
1220
21+ import { createClient , type Client as GraphQLWSClient } from "graphql-ws" ;
22+
1323interface GraphQLResponse < T > {
1424 data ?: T ;
1525 errors ?: Array < { message : string ; locations ?: unknown ; path ?: unknown } > ;
@@ -22,8 +32,6 @@ interface GraphQLSubscribeHandlers<T> {
2232
2333const GRAPHQL_PROXY_URL = "/graphql" ;
2434const JSON_CONTENT_TYPE = "application/json" ;
25- const SUBSCRIPTION_ACCEPT_HEADER =
26- 'multipart/mixed; boundary="graphql"; subscriptionSpec="1.0", application/graphql-response+json' ;
2735
2836function getGraphQLData < T > ( result : GraphQLResponse < T > ) : T {
2937 if ( result . errors && result . errors . length > 0 ) {
@@ -37,98 +45,35 @@ function getGraphQLData<T>(result: GraphQLResponse<T>): T {
3745 return result . data ;
3846}
3947
40- function getMultipartBoundary ( contentType : string | null ) : string {
41- const match = contentType ?. match ( / b o u n d a r y = " ? ( [ ^ " ; ] + ) " ? / i) ;
42- return match ?. [ 1 ] ?? "graphql" ;
43- }
44-
45- function extractMultipartPayloads < T > (
46- buffer : string ,
47- boundary : string
48- ) : { payloads : GraphQLResponse < T > [ ] ; remainder : string } {
49- const payloads : GraphQLResponse < T > [ ] = [ ] ;
50- let cursor = 0 ;
51-
52- while ( true ) {
53- const boundaryIndex = buffer . indexOf ( boundary , cursor ) ;
54-
55- if ( boundaryIndex === - 1 ) {
56- return { payloads, remainder : buffer . slice ( cursor ) } ;
57- }
58-
59- const afterBoundary = boundaryIndex + boundary . length ;
60- const nextChar = buffer . slice ( afterBoundary , afterBoundary + 2 ) ;
61-
62- if ( nextChar === "--" ) {
63- return { payloads, remainder : "" } ;
64- }
65-
66- const headerStart = buffer . startsWith ( "\r\n" , afterBoundary )
67- ? afterBoundary + 2
68- : afterBoundary ;
69- const bodyStart = buffer . indexOf ( "\r\n\r\n" , headerStart ) ;
70-
71- if ( bodyStart === - 1 ) {
72- return { payloads, remainder : buffer . slice ( boundaryIndex ) } ;
73- }
74-
75- const payloadStart = bodyStart + 4 ;
76- const payloadEnd = buffer . indexOf ( "\r\n" , payloadStart ) ;
77-
78- if ( payloadEnd === - 1 ) {
79- return { payloads, remainder : buffer . slice ( boundaryIndex ) } ;
80- }
81-
82- const body = buffer . slice ( payloadStart , payloadEnd ) . trim ( ) ;
83-
84- if ( body && body !== "{}" ) {
85- payloads . push ( JSON . parse ( body ) as GraphQLResponse < T > ) ;
86- }
87-
88- cursor = payloadEnd + 2 ;
89- }
90- }
91-
92- async function consumeMultipartStream < T > (
93- response : Response ,
94- onMessage : ( payload : GraphQLResponse < T > ) => void ,
95- signal ?: AbortSignal
96- ) {
97- if ( ! response . body ) {
98- throw new Error ( "GraphQL subscription response had no body" ) ;
99- }
100-
101- const boundary = `--${ getMultipartBoundary ( response . headers . get ( "content-type" ) ) } ` ;
102- const reader = response . body . getReader ( ) ;
103- const decoder = new TextDecoder ( ) ;
104- let buffer = "" ;
105-
106- while ( true ) {
107- if ( signal ?. aborted ) {
108- await reader . cancel ( ) ;
109- return ;
110- }
111-
112- const { done, value } = await reader . read ( ) ;
113-
114- if ( done ) {
115- buffer += decoder . decode ( ) ;
116- const { payloads } = extractMultipartPayloads < T > ( buffer , boundary ) ;
117- for ( const payload of payloads ) {
118- onMessage ( payload ) ;
119- }
120- return ;
121- }
122-
123- buffer += decoder . decode ( value , { stream : true } ) ;
124- const result = extractMultipartPayloads < T > ( buffer , boundary ) ;
125-
126- for ( const payload of result . payloads ) {
127- onMessage ( payload ) ;
128- }
129-
130- buffer = result . remainder ;
48+ /// Lazily-constructed singleton `graphql-ws` client. All client-side
49+ /// subscriptions share this one WebSocket, so concurrent subscription
50+ /// count no longer pressures the per-origin HTTP connection cap.
51+ ///
52+ /// Constructed on first use because module evaluation runs during SSR
53+ /// where `window` is undefined.
54+ let wsClient : GraphQLWSClient | null = null ;
55+
56+ function getWsClient ( ) : GraphQLWSClient {
57+ if ( wsClient ) return wsClient ;
58+ if ( typeof window === "undefined" ) {
59+ throw new Error ( "gqlSubscribeClient called during SSR (WebSocket unavailable)" ) ;
13160 }
61+ const wsProtocol = window . location . protocol === "https:" ? "wss:" : "ws:" ;
62+ wsClient = createClient ( {
63+ url : `${ wsProtocol } //${ window . location . host } ${ GRAPHQL_PROXY_URL } ` ,
64+ // Allow the browser to send the better-auth session cookie on the
65+ // upgrade request. SvelteKit's `/graphql` proxy gates the upgrade
66+ // on a valid session and injects the backend API key server-side.
67+ lazy : true ,
68+ // Reconnect with exponential backoff up to ~20s on transient
69+ // network failures. graphql-ws handles this automatically once
70+ // `shouldRetry` is truthy.
71+ shouldRetry : ( ) => true ,
72+ retryAttempts : Infinity ,
73+ retryWait : ( retries ) =>
74+ new Promise ( ( resolve ) => setTimeout ( resolve , Math . min ( 1000 * 2 ** retries , 20000 ) ) )
75+ } ) ;
76+ return wsClient ;
13277}
13378
13479/**
@@ -188,55 +133,49 @@ export async function gqlClient<T>(
188133}
189134
190135/**
191- * Execute a client-side GraphQL subscription via multipart HTTP streaming.
192- * Auth is handled transparently by the /graphql SvelteKit proxy route.
136+ * Execute a client-side GraphQL subscription over the shared WebSocket
137+ * connection (graphql-ws / graphql-transport-ws protocol). Any number of
138+ * concurrent subscriptions multiplex onto one TCP connection.
139+ *
140+ * Auth is established at WebSocket upgrade time: the browser sends the
141+ * better-auth session cookie, SvelteKit's `/graphql` upgrade handler
142+ * validates it and the proxy injects the backend API key. The backend
143+ * then authorises the connection as the trusted-API-key principal for
144+ * the duration of the WebSocket.
193145 */
194146export function gqlSubscribeClient < T > (
195147 query : string ,
196148 variables : Record < string , unknown > | undefined ,
197149 handlers : GraphQLSubscribeHandlers < T >
198150) : ( ) => void {
199- const controller = new AbortController ( ) ;
200151 let active = true ;
201-
202- void ( async ( ) => {
203- try {
204- const response = await fetch ( GRAPHQL_PROXY_URL , {
205- method : "POST" ,
206- headers : {
207- "Content-Type" : JSON_CONTENT_TYPE ,
208- Accept : SUBSCRIPTION_ACCEPT_HEADER
209- } ,
210- body : JSON . stringify ( { query, variables : variables ?? { } } ) ,
211- signal : controller . signal
212- } ) ;
213-
214- if ( ! response . ok ) {
215- throw new Error (
216- `GraphQL subscription failed: ${ response . status } ${ response . statusText } `
217- ) ;
218- }
219-
220- await consumeMultipartStream < T > (
221- response ,
222- ( payload ) => {
223- if ( ! active ) return ;
224- handlers . onData ( getGraphQLData ( payload ) ) ;
225- } ,
226- controller . signal
227- ) ;
228-
229- if ( active && ! controller . signal . aborted ) {
230- handlers . onError ?.( new Error ( "Stream ended" ) ) ;
152+ const unsubscribe = getWsClient ( ) . subscribe < T > (
153+ { query, variables : variables ?? { } } ,
154+ {
155+ next : ( result ) => {
156+ if ( ! active ) return ;
157+ if ( result . errors && result . errors . length > 0 ) {
158+ handlers . onError ?.(
159+ new Error ( result . errors . map ( ( e ) => e . message ) . join ( "; " ) )
160+ ) ;
161+ return ;
162+ }
163+ if ( result . data !== undefined && result . data !== null ) {
164+ handlers . onData ( result . data as T ) ;
165+ }
166+ } ,
167+ error : ( err ) => {
168+ if ( ! active ) return ;
169+ handlers . onError ?.( err instanceof Error ? err : new Error ( String ( err ) ) ) ;
170+ } ,
171+ complete : ( ) => {
172+ if ( active ) handlers . onError ?.( new Error ( "Stream ended" ) ) ;
231173 }
232- } catch ( error ) {
233- if ( ! active || controller . signal . aborted ) return ;
234- handlers . onError ?.( error instanceof Error ? error : new Error ( "Subscription failed" ) ) ;
235174 }
236- } ) ( ) ;
175+ ) ;
237176
238177 return ( ) => {
239178 active = false ;
240- controller . abort ( ) ;
179+ unsubscribe ( ) ;
241180 } ;
242181}
0 commit comments