feat(chart): candle build indicator as direction line #1
181
apps/visualizer/src/lib/graphqlWs.ts
Normal file
181
apps/visualizer/src/lib/graphqlWs.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
type HeadersMap = Record<string, string>;
|
||||||
|
|
||||||
|
type SubscribeParams<T> = {
|
||||||
|
query: string;
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
|
onData: (data: T) => void;
|
||||||
|
onError?: (err: string) => void;
|
||||||
|
onStatus?: (s: { connected: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function envString(name: string): string | undefined {
|
||||||
|
const v = (import.meta as any).env?.[name];
|
||||||
|
const s = v == null ? '' : String(v).trim();
|
||||||
|
return s ? s : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGraphqlHttpUrl(): string {
|
||||||
|
return envString('VITE_HASURA_URL') || '/graphql';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGraphqlWsUrl(): string {
|
||||||
|
const explicit = envString('VITE_HASURA_WS_URL');
|
||||||
|
if (explicit) {
|
||||||
|
if (explicit.startsWith('ws://') || explicit.startsWith('wss://')) return explicit;
|
||||||
|
if (explicit.startsWith('http://')) return `ws://${explicit.slice('http://'.length)}`;
|
||||||
|
if (explicit.startsWith('https://')) return `wss://${explicit.slice('https://'.length)}`;
|
||||||
|
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
const path = explicit.startsWith('/') ? explicit : `/${explicit}`;
|
||||||
|
return `${proto}//${host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpUrl = resolveGraphqlHttpUrl();
|
||||||
|
if (httpUrl.startsWith('ws://') || httpUrl.startsWith('wss://')) return httpUrl;
|
||||||
|
if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice('http://'.length)}`;
|
||||||
|
if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice('https://'.length)}`;
|
||||||
|
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
const path = httpUrl.startsWith('/') ? httpUrl : `/${httpUrl}`;
|
||||||
|
return `${proto}//${host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthHeaders(): HeadersMap | undefined {
|
||||||
|
const token = envString('VITE_HASURA_AUTH_TOKEN');
|
||||||
|
if (token) return { authorization: `Bearer ${token}` };
|
||||||
|
const secret = envString('VITE_HASURA_ADMIN_SECRET');
|
||||||
|
if (secret) return { 'x-hasura-admin-secret': secret };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WsMessage =
|
||||||
|
| { type: 'connection_ack' | 'ka' | 'complete' }
|
||||||
|
| { type: 'connection_error'; payload?: any }
|
||||||
|
| { type: 'data'; id: string; payload: { data?: any; errors?: Array<{ message: string }> } }
|
||||||
|
| { type: 'error'; id: string; payload?: any };
|
||||||
|
|
||||||
|
export type SubscriptionHandle = {
|
||||||
|
unsubscribe: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onStatus }: SubscribeParams<T>): SubscriptionHandle {
|
||||||
|
const wsUrl = resolveGraphqlWsUrl();
|
||||||
|
const headers = resolveAuthHeaders();
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let closed = false;
|
||||||
|
let started = false;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
|
||||||
|
const subId = '1';
|
||||||
|
|
||||||
|
const emitError = (e: unknown) => {
|
||||||
|
const msg = typeof e === 'string' ? e : String((e as any)?.message || e);
|
||||||
|
onError?.(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setConnected = (connected: boolean) => onStatus?.({ connected });
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (!ws || started) return;
|
||||||
|
started = true;
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id: subId,
|
||||||
|
type: 'start',
|
||||||
|
payload: { query, variables: variables ?? {} },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (closed) return;
|
||||||
|
started = false;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(wsUrl, 'graphql-ws');
|
||||||
|
} catch (e) {
|
||||||
|
emitError(e);
|
||||||
|
reconnectTimer = window.setTimeout(connect, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
const payload = headers ? { headers } : {};
|
||||||
|
ws?.send(JSON.stringify({ type: 'connection_init', payload }));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg: WsMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(String(ev.data));
|
||||||
|
} catch (e) {
|
||||||
|
emitError(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'connection_ack') {
|
||||||
|
start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'connection_error') {
|
||||||
|
emitError(msg.payload || 'connection_error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'ka' || msg.type === 'complete') return;
|
||||||
|
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
emitError(msg.payload || 'subscription_error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'data') {
|
||||||
|
const errors = msg.payload?.errors;
|
||||||
|
if (Array.isArray(errors) && errors.length) {
|
||||||
|
emitError(errors.map((e) => e.message).join(' | '));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.payload?.data != null) onData(msg.payload.data as T);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
if (closed) return;
|
||||||
|
reconnectTimer = window.setTimeout(connect, 1000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
closed = true;
|
||||||
|
setConnected(false);
|
||||||
|
if (reconnectTimer != null) {
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (!ws) return;
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ id: subId, type: 'stop' }));
|
||||||
|
ws.send(JSON.stringify({ type: 'connection_terminate' }));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
ws = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user