feat(chart): candle build indicator as direction line #1

Open
u1 wants to merge 31 commits from feat/candle-build-indicator into main
Showing only changes of commit ae41f1a9de - Show all commits

View 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;
},
};
}