diff --git a/kb-app-demo-desktop/frontend/demo_ws.html b/kb-app-demo-desktop/frontend/demo_ws.html new file mode 100644 index 0000000..ca76120 --- /dev/null +++ b/kb-app-demo-desktop/frontend/demo_ws.html @@ -0,0 +1,113 @@ + + + + + + + + + Khadhroony Bot3 - WebSocket standard + + + + +
+ +
+ +
+
+
+
+
+
+
+

Souscription WebSocket Solana standard

+

Cette démo garde une connexion WebSocket ouverte dans AppState. Le bouton Souscrire réutilise la même connexion tant que l’endpoint reste identique ; la déconnexion est explicite ou réalisée à la fermeture globale de l’application.

+

Les notifications très fréquentes sont limitées côté Rust puis bornées côté interface pour éviter de bloquer la WebView.

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+
+
+
+
+
+

Statut

+ Déconnecté +
+ +
+
+
+
+
+

Endpoints WebSocket

+ +
+ +
+
+
+
+
+

Messages

+ +
+ +
+
+
+
+
+
+
+ + + + + + + diff --git a/kb-app-demo-desktop/frontend/ts/demo_ws.ts b/kb-app-demo-desktop/frontend/ts/demo_ws.ts new file mode 100644 index 0000000..9d658cc --- /dev/null +++ b/kb-app-demo-desktop/frontend/ts/demo_ws.ts @@ -0,0 +1,325 @@ +// file: kb-app-demo-desktop/frontend/ts/demo_ws.ts +// version: 7 + +import * as bootstrap from "bootstrap"; +import "simplebar"; +import ResizeObserver from "resize-observer-polyfill"; +import {invoke} from "@tauri-apps/api/core"; +import {listen} from "@tauri-apps/api/event"; +import {frontendDebug, frontendError, installFrontendConsoleBridge} from "./frontend_log.ts"; +import type {DemoWsExecutionPayload} from "./bindings/kb_app_demo_desktop/demo_ws/DemoWsExecutionPayload.ts"; +import type {DemoWsMessagePayload} from "./bindings/kb_app_demo_desktop/demo_ws/DemoWsMessagePayload.ts"; +import type {DemoWsMethodOption} from "./bindings/kb_app_demo_desktop/demo_ws/DemoWsMethodOption.ts"; +import type {DemoWsOptionsPayload} from "./bindings/kb_app_demo_desktop/demo_ws/DemoWsOptionsPayload.ts"; +import type {DemoWsRequest} from "./bindings/kb_app_demo_desktop/demo_ws/DemoWsRequest.ts"; +import type {DemoWsRoleOption} from "./bindings/kb_app_demo_desktop/demo_ws/DemoWsRoleOption.ts"; +import type {DemoWsStatusPayload} from "./bindings/kb_app_demo_desktop/demo_ws/DemoWsStatusPayload.ts"; +import type {DemoWsUnsubscribeRequest} from "./bindings/kb_app_demo_desktop/demo_ws/DemoWsUnsubscribeRequest.ts"; + +(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap; +(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver; + +let roleOptions: DemoWsRoleOption[] = []; +let methodOptions: DemoWsMethodOption[] = []; + +const maxResultOutputCharacters = 200_000; +const maxIncomingMessageCharacters = 25_000; + +function textInputValue(selector: string): string { + const element = document.querySelector(selector); + return element ? element.value.trim() : ""; +} + +function writeTextarea(selector: string, value: string): void { + const element = document.querySelector(selector); + if (element) { + element.value = value; + } +} + +function truncateIncomingMessage(value: string): string { + if (value.length <= maxIncomingMessageCharacters) { + return value; + } + return `${value.slice(0, maxIncomingMessageCharacters)}\n... message tronqué côté UI ...`; +} + +function capTextareaValue(value: string): string { + if (value.length <= maxResultOutputCharacters) { + return value; + } + return `... historique tronqué côté UI ...\n${value.slice(value.length - maxResultOutputCharacters)}`; +} + +function appendTextarea(selector: string, value: string): void { + const element = document.querySelector(selector); + if (!element) { + return; + } + const previous = element.value.trim(); + const safeValue = truncateIncomingMessage(value); + const nextValue = previous.length > 0 ? `${previous}\n\n${safeValue}` : safeValue; + element.value = capTextareaValue(nextValue); + element.scrollTop = element.scrollHeight; +} + +function jsonText(value: unknown): string { + if (typeof value === "string") { + return value; + } + return JSON.stringify(value, null, 2); +} + +function roleSupportsMethod(role: DemoWsRoleOption, method: DemoWsMethodOption): boolean { + return role.requestKinds.includes("*") || role.requestKinds.includes(method.requestKind); +} + +function selectedRole(): DemoWsRoleOption | null { + const role = textInputValue("#wsRoleSelect"); + return roleOptions.find(option => option.role === role) ?? null; +} + +function populateSelect(selector: string, values: Array<{ value: string; label: string }>): void { + const select = document.querySelector(selector); + if (!select) { + return; + } + select.textContent = ""; + for (const item of values) { + const option = document.createElement("option"); + option.value = item.value; + option.textContent = item.label; + select.appendChild(option); + } +} + +function refreshMethodList(): void { + const role = selectedRole(); + const filtered = role ? methodOptions.filter(method => roleSupportsMethod(role, method)) : methodOptions; + populateSelect("#wsMethodSelect", filtered.map(method => ({ + value: method.method, + label: `${method.method} — ${method.label}`, + }))); + refreshMethodFields(); +} + +function refreshMethodFields(): void { + const methodName = textInputValue("#wsMethodSelect"); + const method = methodOptions.find(option => option.method === methodName) ?? null; + const target = document.querySelector("#wsTargetInput"); + const filter = document.querySelector("#wsFilterInput"); + const config = document.querySelector("#wsConfigInput"); + if (target) { + target.disabled = method ? !method.requiresTarget : false; + target.placeholder = method && method.requiresTarget ? "Target requis" : "Non requis pour cette méthode"; + } + if (filter) { + filter.disabled = method ? !method.requiresFilterJson : false; + } + if (config) { + config.disabled = method ? !method.supportsConfigJson : false; + } +} + +function refreshSubscriptionSelect(status: DemoWsStatusPayload): void { + const select = document.querySelector("#wsSubscriptionSelect"); + const unsubscribeButton = document.querySelector("#unsubscribeWsButton"); + if (!select) { + return; + } + const previous = select.value; + select.textContent = ""; + for (const subscription of status.subscriptions) { + const option = document.createElement("option"); + option.value = String(subscription.subscriptionId); + option.textContent = `${subscription.subscriptionId} · ${subscription.method}`; + select.appendChild(option); + } + if (previous.length > 0) { + select.value = previous; + } + if (unsubscribeButton) { + unsubscribeButton.disabled = !status.connected || status.subscriptions.length === 0; + } +} + +function setConnectedUi(status: DemoWsStatusPayload): void { + const badge = document.querySelector("#wsStatusBadge"); + const connectButton = document.querySelector("#connectWsButton"); + const disconnectButton = document.querySelector("#disconnectWsButton"); + if (badge) { + badge.textContent = status.connected ? `Connecté · ${status.subscriptionCount} subscription(s)` : "Déconnecté"; + badge.className = status.connected ? "badge text-bg-success" : "badge text-bg-secondary"; + } + if (connectButton) { + connectButton.disabled = false; + } + if (disconnectButton) { + disconnectButton.disabled = !status.connected; + } + refreshSubscriptionSelect(status); + writeTextarea("#wsStatusOutput", jsonText(status)); +} + +function formatWsExecutionPayload(payload: DemoWsExecutionPayload): string { + const header = { + endpointName: payload.endpointName, + provider: payload.provider, + endpointUrl: payload.endpointUrl, + role: payload.role, + method: payload.method, + requestKind: payload.requestKind, + responseKind: payload.responseKind, + subscriptionId: payload.subscriptionId, + }; + return `${JSON.stringify(header, null, 2)}\n\n--- subscription response ---\n${payload.responseJson}`; +} + +async function copyTextarea(selector: string): Promise { + const element = document.querySelector(selector); + if (!element) { + return; + } + await navigator.clipboard.writeText(element.value); +} + +async function refreshWsOptions(): Promise { + try { + const options = await invoke("demo_ws_options"); + roleOptions = options.roles; + methodOptions = options.methods; + populateSelect("#wsRoleSelect", roleOptions.map(role => ({ + value: role.role, + label: role.role, + }))); + refreshMethodList(); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + writeTextarea("#wsResultOutput", `Erreur options WebSocket : ${message}`); + frontendError("kb-app-demo-desktop.frontend.demo_ws", `WebSocket options loading failed: ${message}`); + } +} + +async function refreshWsPool(): Promise { + try { + const snapshots = await invoke("demo_ws_list_pool_clients"); + writeTextarea("#wsPoolOutput", jsonText(snapshots)); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + writeTextarea("#wsPoolOutput", `Erreur : ${message}`); + frontendError("kb-app-demo-desktop.frontend.demo_ws", `WebSocket pool refresh failed: ${message}`); + } +} + +async function refreshWsStatus(): Promise { + try { + const status = await invoke("demo_ws_status"); + setConnectedUi(status); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + writeTextarea("#wsStatusOutput", `Erreur statut WebSocket : ${message}`); + frontendError("kb-app-demo-desktop.frontend.demo_ws", `WebSocket status failed: ${message}`); + } +} + +async function connectWsRequest(): Promise { + const request: DemoWsRequest = { + role: textInputValue("#wsRoleSelect"), + method: textInputValue("#wsMethodSelect"), + target: textInputValue("#wsTargetInput") || null, + filterJson: textInputValue("#wsFilterInput") || null, + configJson: textInputValue("#wsConfigInput") || null, + }; + writeTextarea("#wsResultOutput", "Souscription en cours sur la connexion WebSocket..."); + try { + const response = await invoke("demo_ws_connect", { request }); + writeTextarea("#wsResultOutput", formatWsExecutionPayload(response)); + frontendDebug("kb-app-demo-desktop.frontend.demo_ws", `WebSocket subscribed: ${response.method}`); + await refreshWsStatus(); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + writeTextarea("#wsResultOutput", `Erreur : ${message}`); + frontendError("kb-app-demo-desktop.frontend.demo_ws", `WebSocket subscription failed: ${message}`); + await refreshWsStatus(); + } +} + +async function unsubscribeWsRequest(): Promise { + const subscriptionIdText = textInputValue("#wsSubscriptionSelect"); + if (subscriptionIdText.length === 0) { + appendTextarea("#wsResultOutput", "Aucune subscription sélectionnée."); + return; + } + const subscriptionId = Number(subscriptionIdText); + if (!Number.isSafeInteger(subscriptionId) || subscriptionId < 0) { + appendTextarea("#wsResultOutput", "Identifiant de subscription invalide."); + return; + } + const request: DemoWsUnsubscribeRequest = { + subscriptionId, + }; + try { + const status = await invoke("demo_ws_unsubscribe", { request }); + setConnectedUi(status); + appendTextarea("#wsResultOutput", `--- unsubscribe ---\nSubscription ${request.subscriptionId} désinscrite.`); + frontendDebug("kb-app-demo-desktop.frontend.demo_ws", `WebSocket unsubscribed: ${request.subscriptionId}`); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + appendTextarea("#wsResultOutput", `Erreur unsubscribe : ${message}`); + frontendError("kb-app-demo-desktop.frontend.demo_ws", `WebSocket unsubscribe failed: ${message}`); + await refreshWsStatus(); + } +} + +async function disconnectWsRequest(): Promise { + try { + const status = await invoke("demo_ws_disconnect"); + setConnectedUi(status); + appendTextarea("#wsResultOutput", "--- disconnect ---\nDéconnexion demandée."); + frontendDebug("kb-app-demo-desktop.frontend.demo_ws", "WebSocket disconnected"); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + appendTextarea("#wsResultOutput", `Erreur déconnexion : ${message}`); + frontendError("kb-app-demo-desktop.frontend.demo_ws", `WebSocket disconnection failed: ${message}`); + } +} + +async function installWsEventListeners(): Promise { + await listen("demo-ws-status", event => { + setConnectedUi(event.payload); + }); + await listen("demo-ws-message", event => { + appendTextarea("#wsResultOutput", `--- ${event.payload.kind} ---\n${event.payload.payloadJson}`); + }); +} + +document.addEventListener("DOMContentLoaded", () => { + installFrontendConsoleBridge("kb-app-demo-desktop.frontend.demo_ws"); + frontendDebug("kb-app-demo-desktop.frontend.demo_ws", "WebSocket demo window loaded"); + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); + document.querySelector("#wsRoleSelect")?.addEventListener("change", refreshMethodList); + document.querySelector("#wsMethodSelect")?.addEventListener("change", refreshMethodFields); + document.querySelector("#connectWsButton")?.addEventListener("click", () => { + void connectWsRequest(); + }); + document.querySelector("#unsubscribeWsButton")?.addEventListener("click", () => { + void unsubscribeWsRequest(); + }); + document.querySelector("#disconnectWsButton")?.addEventListener("click", () => { + void disconnectWsRequest(); + }); + document.querySelector("#refreshWsPoolButton")?.addEventListener("click", () => { + void refreshWsPool(); + }); + document.querySelector("#copyWsPoolButton")?.addEventListener("click", () => { + void copyTextarea("#wsPoolOutput"); + }); + document.querySelector("#copyWsResultButton")?.addEventListener("click", () => { + void copyTextarea("#wsResultOutput"); + }); + void installWsEventListeners(); + void refreshWsOptions(); + void refreshWsPool(); + void refreshWsStatus(); +});