Skip to content

Commit 69bfc25

Browse files
feat: graphql over ws
1 parent 72cda67 commit 69bfc25

4 files changed

Lines changed: 130 additions & 150 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"d3-shape": "^3.2.0",
8787
"drizzle-orm": "^0.45.2",
8888
"embla-carousel-autoplay": "^8.6.0",
89+
"graphql-ws": "^6.0.8",
8990
"http-proxy": "^1.18.1",
9091
"mode-watcher": "^1.1.0",
9192
"nprogress": "^0.2.0",

pnpm-lock.yaml

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/graphql-client.ts

Lines changed: 70 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,18 @@
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+
1323
interface GraphQLResponse<T> {
1424
data?: T;
1525
errors?: Array<{ message: string; locations?: unknown; path?: unknown }>;
@@ -22,8 +32,6 @@ interface GraphQLSubscribeHandlers<T> {
2232

2333
const GRAPHQL_PROXY_URL = "/graphql";
2434
const JSON_CONTENT_TYPE = "application/json";
25-
const SUBSCRIPTION_ACCEPT_HEADER =
26-
'multipart/mixed; boundary="graphql"; subscriptionSpec="1.0", application/graphql-response+json';
2735

2836
function 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(/boundary="?([^";]+)"?/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
*/
194146
export 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
}
Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
import { gqlSubscribeClient } from "$lib/graphql-client";
2+
import {
3+
MOVIE_REQUESTED_SUBSCRIPTION,
4+
SHOW_INDEXED_SUBSCRIPTION,
5+
SHOW_REQUESTED_SUBSCRIPTION,
6+
SHOW_REQUEST_UPDATED_SUBSCRIPTION
7+
} from "$lib/services/riven-media";
28

3-
/// Unified backend subscription that fans every relevant media-state event
4-
/// (movie/show requested, show indexed, item scraped/downloaded/failed,
5-
/// items deleted) onto a single multipart stream. One connection replaces
6-
/// eight individual subscriptions, which keeps the home/library/dashboard
7-
/// pages under the per-origin connection cap on HTTP/1.1 deployments and
8-
/// cuts subscription-side server work by ~8x on every transport.
9-
const MEDIA_EVENTS_SUBSCRIPTION = `subscription RivenMediaEvents {
10-
mediaEvents { kind itemId }
11-
}`;
12-
13-
type MediaEventPayload = {
14-
mediaEvents: { kind: string; itemId: number | null };
15-
};
9+
const MEDIA_EVENT_SUBSCRIPTIONS = [
10+
MOVIE_REQUESTED_SUBSCRIPTION,
11+
SHOW_REQUESTED_SUBSCRIPTION,
12+
SHOW_REQUEST_UPDATED_SUBSCRIPTION,
13+
SHOW_INDEXED_SUBSCRIPTION,
14+
`subscription RivenItemScraped {
15+
itemScraped
16+
}`,
17+
`subscription RivenItemDownloaded {
18+
itemDownloaded
19+
}`,
20+
`subscription RivenItemFailed {
21+
itemFailed
22+
}`,
23+
`subscription RivenItemsDeleted {
24+
itemsDeleted
25+
}`
26+
];
1627

1728
export function subscribeToRivenMediaEvents(
1829
refresh: () => void | Promise<void>,
@@ -29,21 +40,21 @@ export function subscribeToRivenMediaEvents(
2940
}, debounceMs);
3041
}
3142

32-
const unsubscribe = gqlSubscribeClient<MediaEventPayload>(
33-
MEDIA_EVENTS_SUBSCRIPTION,
34-
undefined,
35-
{
43+
const unsubscribers = MEDIA_EVENT_SUBSCRIPTIONS.map((subscription) =>
44+
gqlSubscribeClient<Record<string, unknown>>(subscription, undefined, {
3645
onData: refreshSoon,
3746
onError: () => {
3847
// Callers keep their last successful data snapshot. The shared GraphQL
3948
// subscription client owns transport-level retry behaviour where needed.
4049
}
41-
}
50+
})
4251
);
4352

4453
return () => {
4554
active = false;
4655
clearTimeout(refreshTimer);
47-
unsubscribe();
56+
for (const unsubscribe of unsubscribers) {
57+
unsubscribe();
58+
}
4859
};
4960
}

0 commit comments

Comments
 (0)