diff --git a/app/stores/viewer.js b/app/stores/viewer.js index f647533a..b1e893f3 100644 --- a/app/stores/viewer.js +++ b/app/stores/viewer.js @@ -1,10 +1,9 @@ // Third party imports -import vtkWSLinkClient, { newInstance } from "@kitware/vtk.js/IO/Core/WSLinkClient"; import _ from "lodash"; // oxlint-disable-next-line no-unassigned-import import "@kitware/vtk.js/Rendering/OpenGL/Profiles/Geometry"; -import SmartConnect from "wslink/src/SmartConnect"; import { connectImageStream } from "@kitware/vtk.js/Rendering/Misc/RemoteView"; +import { initWebSocketClient } from "@ogw_internal/utils/ws_client"; import schemas from "@geode/opengeodeweb-viewer/opengeodeweb_viewer_schemas.json"; // Local imports @@ -35,7 +34,6 @@ export const useViewerStore = defineStore( const request_counter = ref(0); const status = ref(Status.NOT_CONNECTED); const version = ref("0.0.0"); - const busy = ref(0); const protocol = computed(() => getWebsocketApiProtocol()); @@ -75,35 +73,11 @@ export const useViewerStore = defineStore( try { console.log("VIEWER LOCK GRANTED !", lock); status.value = Status.CONNECTING; - vtkWSLinkClient.setSmartConnectClass(SmartConnect); - - if (_.isEmpty(client.value)) { - client.value = newInstance(); - } - - client.value.onBusyChange((count) => { - busy.value = count; - }); - client.value.onConnectionError((httpReq) => { - const message = httpReq?.response?.error || `Connection error`; - console.error(message); - }); - client.value.onConnectionClose((httpReq) => { - const message = httpReq?.response?.error || `Connection close`; - status.value = Status.NOT_CONNECTED; - console.error(message); - }); - - client.value.beginBusy(); - await client.value.connect({ - application: "Viewer", - sessionURL: base_url.value, - }); + client.value = await initWebSocketClient(base_url.value, client.value); connectImageStream(client.value.getConnection().getSession()); client.value.endBusy(); const schema = schemas.opengeodeweb_viewer.viewer.reset_visualization; - const timeout = undefined; - await request({ schema, timeout }); + await request({ schema }); status.value = Status.CONNECTED; } catch (error) { console.error("ws_connect error", error); diff --git a/internal/stores/data_style/mesh/points/common.js b/internal/stores/data_style/mesh/points/common.js index ec8971c5..a0ec2aca 100644 --- a/internal/stores/data_style/mesh/points/common.js +++ b/internal/stores/data_style/mesh/points/common.js @@ -9,6 +9,10 @@ export function useMeshPointsCommonStyle() { }); } + function mutateMeshPointsVisibility(response) { + return mutateMeshPointsStyle(response.id, { visibility: response.visibility }); + } + function meshPointsStyle(id) { return dataStyleState.getStyle(id).points; } @@ -26,7 +30,8 @@ export function useMeshPointsCommonStyle() { return { meshPointsStyle, meshPointsColoring, - mutateMeshPointsStyle, mutateMeshPointsColoring, + mutateMeshPointsStyle, + mutateMeshPointsVisibility, }; } diff --git a/internal/stores/data_style/mesh/points/visibility.js b/internal/stores/data_style/mesh/points/visibility.js index fa929e86..e01242db 100644 --- a/internal/stores/data_style/mesh/points/visibility.js +++ b/internal/stores/data_style/mesh/points/visibility.js @@ -23,7 +23,9 @@ export function useMeshPointsVisibilityStyle() { params, }, { - response_function: () => meshPointsCommonStyle.mutateMeshPointsStyle(id, { visibility }), + response_function(response) { + return meshPointsCommonStyle.mutateMeshPointsVisibility(response); + }, }, ); } diff --git a/internal/utils/api_fetch.js b/internal/utils/api_fetch.js index a417b1e7..55775850 100644 --- a/internal/utils/api_fetch.js +++ b/internal/utils/api_fetch.js @@ -15,8 +15,8 @@ export function api_fetch( return fetchSchema( { schema, - baseURL: microservice.base_url, params, + baseURL: microservice.base_url, headers, max_retry: schema.max_retry, timeout, diff --git a/internal/utils/viewer_call.js b/internal/utils/viewer_call.js index d03a9848..d333bd51 100644 --- a/internal/utils/viewer_call.js +++ b/internal/utils/viewer_call.js @@ -1,12 +1,6 @@ -// Third party imports -import pTimeout from "p-timeout"; - -// Local imports import { endRequestLog, startRequestLog } from "@ogw_front/utils/log"; +import { callSchema } from "@ogw_shared/utils/call_schema"; import { useFeedbackStore } from "@ogw_front/stores/feedback"; -import { validate_schema } from "@ogw_shared/utils/validate_schema"; - -const ERROR_400 = 400; export function viewer_call( microservice, @@ -14,52 +8,42 @@ export function viewer_call( { request_error_function, response_function, response_error_function } = {}, ) { const feedbackStore = useFeedbackStore(); - - const { valid, error: schema_error } = validate_schema(schema, params); - - if (!valid) { - if (process.env.NODE_ENV !== "production") { - console.log("Bad request", schema_error, schema, params); - } - feedbackStore.add_error(ERROR_400, schema.$id, "Bad request", schema_error); - throw new Error(`${schema.$id}: ${schema_error}`); - } - const { client } = microservice; - async function performCall() { - if (!client.getConnection) { - return; - } - microservice.start_request(); - const requestStart = startRequestLog(microservice, schema); - try { - const value = await client.getConnection().getSession().call(schema.$id, [params]); - endRequestLog(microservice, schema, requestStart); - if (response_function) { - await response_function(value); - } - return value; - } catch (error) { - feedbackStore.add_error(error.code, schema.$id, error.message, error.message); - if (request_error_function) { - request_error_function(error); - } - if (response_error_function) { - response_error_function(error); - } - throw error; - } finally { - microservice.stop_request(); - } - } - - if (timeout > 0) { - return pTimeout(performCall(), { - milliseconds: timeout, - message: `${schema.$id}: Timed out after ${timeout}ms`, - }); - } - - return performCall(); + const requestStartingTime = startRequestLog(microservice, schema); + return callSchema( + { + schema, + params, + client, + timeout, + }, + { + request_error_function(error) { + microservice.stop_request(); + feedbackStore.add_error(error.code, schema.$id, error.message, error.message); + if (request_error_function) { + request_error_function(error); + } + }, + response_function(data) { + endRequestLog(microservice, schema, requestStartingTime); + microservice.stop_request(); + if (response_function) { + response_function(data); + } + }, + response_error_function(response) { + microservice.stop_request(); + feedbackStore.add_error(error.code, schema.$id, error.message, error.message); + if (response_error_function) { + response_error_function(response); + } + }, + validation_error_function({ code, name, error }) { + microservice.stop_request(); + feedbackStore.add_error(code, schema.$id, name, error); + }, + }, + ); } diff --git a/internal/utils/ws_client.js b/internal/utils/ws_client.js new file mode 100644 index 00000000..47b3cbb4 --- /dev/null +++ b/internal/utils/ws_client.js @@ -0,0 +1,29 @@ +// Third party imports +import vtkWSLinkClient, { newInstance } from "@kitware/vtk.js/IO/Core/WSLinkClient"; +import SmartConnect from "wslink/src/SmartConnect"; +import _ from "lodash"; + +async function initWebSocketClient(baseUrl, initialClient = {}) { + vtkWSLinkClient.setSmartConnectClass(SmartConnect); + const client = _.isEmpty(initialClient) ? newInstance() : initialClient; + + client.onConnectionError((httpReq) => { + const message = httpReq?.response?.error || `Connection error`; + console.error(message); + }); + client.onConnectionClose((httpReq) => { + const message = httpReq?.response?.error || `Connection close`; + status.value = Status.NOT_CONNECTED; + console.error(message); + }); + + client.beginBusy(); + await client.connect({ + application: "Viewer", + sessionURL: baseUrl, + }); + + return client; +} + +export { initWebSocketClient }; diff --git a/nuxt.config.js b/nuxt.config.js index c87d890f..5c45a84a 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -62,13 +62,36 @@ export default defineNuxtConfig({ vite: { optimizeDeps: { include: [ + "@kitware/vtk.js", + "@kitware/vtk.js/Common/Core/Math", + "@kitware/vtk.js/IO/Core/WSLinkClient", + "@kitware/vtk.js/IO/XML/XMLPolyDataReader", + "@kitware/vtk.js/Rendering/Core/Actor", + "@kitware/vtk.js/Rendering/Core/AnnotatedCubeActor", + "@kitware/vtk.js/Rendering/Core/ColorTransferFunction", + "@kitware/vtk.js/Rendering/Core/Mapper", + "@kitware/vtk.js/Rendering/Misc/GenericRenderWindow", + "@kitware/vtk.js/Rendering/Misc/RemoteView", + "@kitware/vtk.js/Rendering/OpenGL/Profiles/Geometry", + "@kitware/vtk.js/Widgets/Core/WidgetManager", + "@kitware/vtk.js/Widgets/Widgets3D/ImplicitPlaneWidget", + "@vue/devtools-core", + "@vue/devtools-kit", "ajv", - "fast-deep-equal", + "broadcast-channel", + "dexie", "globalthis", "h3", "js-file-download", "lodash", + "lodash/merge", + "p-timeout", "seedrandom", + "spark-md5", + "uuid", + "wslink", + "wslink/src/SmartConnect", + "xmlbuilder2", ], }, }, diff --git a/server/utils/server_config.js b/server/utils/server_config.js index 62dee986..9d908381 100644 --- a/server/utils/server_config.js +++ b/server/utils/server_config.js @@ -1,3 +1,5 @@ +import { createServerWsRpcClient } from "./ws_client.js"; + const storage = new Map(); function getAppBaseUrl() { @@ -25,13 +27,36 @@ function setIsAppReady(isAppReady) { return storage.set("IS_APP_READY", isAppReady); } +async function getViewerWebSocketClient() { + const viewerClient = storage.get("VIEWER_CLIENT") ?? undefined; + if (viewerClient?.isOpen()) { + return viewerClient; + } + const viewerBaseUrl = await getViewerBaseUrl(); + return setViewerWebSocketClient(viewerBaseUrl); +} + +async function setViewerWebSocketClient(baseUrl) { + const client = createServerWsRpcClient(baseUrl); + client.onConnectionClose(() => { + if (viewerClient === client) { + viewerClient = undefined; + } + }); + await client.ready; + storage.set("VIEWER_CLIENT", client); + return client; +} + export { getAppBaseUrl, getBackBaseUrl, getIsAppReady, getViewerBaseUrl, + getViewerWebSocketClient, setAppBaseUrl, setBackBaseUrl, setIsAppReady, setViewerBaseUrl, + setViewerWebSocketClient, }; diff --git a/server/utils/ws_client.js b/server/utils/ws_client.js new file mode 100644 index 00000000..0519c654 --- /dev/null +++ b/server/utils/ws_client.js @@ -0,0 +1,112 @@ +// Third party imports +import { WebSocket } from "ws"; +import { v4 as uuidv4 } from "uuid"; + +// Local imports + +const HELLO_ID = "system:hello"; +const HELLO_SECRET = "wslink-secret"; + +//oxlint-disable-next-line max-lines-per-function +function createServerWsRpcClient(baseUrl) { + const socket = new WebSocket(baseUrl); + const pending = new Map(); + let onCloseCallback = undefined; + let onErrorCallback = undefined; + + //oxlint-disable-next-line promise/avoid-new + const ready = new Promise((resolve, reject) => { + socket.on("open", () => { + socket.send( + JSON.stringify({ + id: HELLO_ID, + method: "wslink.hello", + args: [{ secret: HELLO_SECRET }], + }), + ); + }); + + socket.on("message", (raw) => { + console.log("RAW WS MESSAGE:", raw.toString()); + let message = undefined; + try { + message = JSON.parse(raw.toString()); + } catch { + return; + } + + if (message.id === HELLO_ID) { + resolve(); + return; + } + + if (typeof message.id === "string" && message.id.startsWith("publish:")) { + return; + } + + const entry = pending.get(message.id); + if (!entry) { + return; + } + pending.delete(message.id); + if (message.error) { + entry.reject(new Error(message.error.message || "wslink RPC error")); + } else { + entry.resolve(message.result); + } + }); + + socket.on("error", (error) => { + onErrorCallback?.(error); + reject(error); + }); + + socket.on("close", () => { + onCloseCallback?.(); + for (const { reject: rejectPending } of pending.values()) { + rejectPending(new Error("WebSocket closed")); + } + pending.clear(); + }); + }); + + async function call(rpc, params = {}) { + await ready; + const id = uuidv4(); + //oxlint-disable-next-line promise/avoid-new + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + socket.send( + JSON.stringify({ + wslink: "1.0", + id, + method: rpc, + args: [params], + kwargs: { stream: true }, + }), + ); + }); + } + + function close() { + socket.close(); + } + + function isOpen() { + return socket.readyState === WebSocket.OPEN; + } + + //oxlint-disable-next-line promise/prefer-await-to-callbacks + function onConnectionClose(callback) { + onCloseCallback = callback; + } + + //oxlint-disable-next-line promise/prefer-await-to-callbacks + function onConnectionError(callback) { + onErrorCallback = callback; + } + + return { call, close, isOpen, onConnectionClose, onConnectionError, ready }; +} + +export { createServerWsRpcClient }; diff --git a/shared/utils/call_raw.js b/shared/utils/call_raw.js new file mode 100644 index 00000000..40632be3 --- /dev/null +++ b/shared/utils/call_raw.js @@ -0,0 +1,46 @@ +// Third party imports +import _ from "lodash"; +import pTimeout from "p-timeout"; + +// Local imports + +function callClient({ rpc, params = {}, client }) { + if (globalThis.window !== undefined) { + return client.getConnection().getSession().call(rpc, [params]); + } + return client.call(rpc, params); +} + +function callRaw( + { rpc, params = {}, client, timeout }, + { request_error_function, response_function, response_error_function } = {}, +) { + async function performCall() { + try { + const response = await callClient({ rpc, params, client }); + if (response_function) { + await response_function(response); + } + return response; + } catch (error) { + if (request_error_function) { + request_error_function(error); + } + if (response_error_function) { + response_error_function(error); + } + throw error; + } + } + + if (timeout > 0) { + return pTimeout(performCall(), { + milliseconds: timeout, + message: `${rpc}: Timed out after ${timeout}ms`, + }); + } + + return performCall(); +} + +export { callRaw }; diff --git a/shared/utils/call_schema.js b/shared/utils/call_schema.js new file mode 100644 index 00000000..e374a2f5 --- /dev/null +++ b/shared/utils/call_schema.js @@ -0,0 +1,45 @@ +// Third party imports + +// Local imports +import { callRaw } from "./call_raw.js"; +import { validateSchema } from "./validate_schema.js"; + +const ERROR_400 = 400; + +function callSchema( + { schema, params = {}, client, timeout }, + { + request_error_function, + response_function, + response_error_function, + validation_error_function, + } = {}, +) { + const { valid, error: schema_error } = validateSchema(schema, params); + + if (!valid) { + if (process.env.NODE_ENV !== "production") { + console.log("Bad request", schema_error, schema, params); + } + if (validation_error_function) { + validation_error_function({ code: ERROR_400, name: "Bad request", error: schema_error }); + } + throw new Error(`${schema.$id}: ${schema_error}`); + } + + return callRaw( + { + rpc: schema.$id, + params, + client, + timeout, + }, + { + request_error_function, + response_function, + response_error_function, + }, + ); +} + +export { callSchema }; diff --git a/shared/utils/fetch_schema.js b/shared/utils/fetch_schema.js index e8534426..72e6e6a0 100644 --- a/shared/utils/fetch_schema.js +++ b/shared/utils/fetch_schema.js @@ -2,7 +2,7 @@ // Local imports import { fetchRaw } from "./fetch_raw.js"; -import { validate_schema } from "./validate_schema.js"; +import { validateSchema } from "./validate_schema.js"; const ERROR_400 = 400; @@ -15,8 +15,7 @@ function fetchSchema( validation_error_function, } = {}, ) { - console.log("fetchSchema", { schema, baseURL, params, headers, timeout }); - const { valid, error: schema_error } = validate_schema(schema, params); + const { valid, error: schema_error } = validateSchema(schema, params); if (!valid) { if (process.env.NODE_ENV !== "production") { @@ -39,7 +38,11 @@ function fetchSchema( timeout, expectEvent, }, - { request_error_function, response_function, response_error_function }, + { + request_error_function, + response_function, + response_error_function, + }, ); } diff --git a/shared/utils/parse_boolean.js b/shared/utils/parse_boolean.js new file mode 100644 index 00000000..a89afc06 --- /dev/null +++ b/shared/utils/parse_boolean.js @@ -0,0 +1,16 @@ +const TRUTHY_VALUES = new Set([true, 1, "1", "true", "yes"]); +const FALSY_VALUES = new Set([false, 0, "0", "false", "no"]); + +function parseBoolean(value) { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : value; + + if (TRUTHY_VALUES.has(normalized)) { + return true; + } + if (FALSY_VALUES.has(normalized)) { + return false; + } + throw new Error(`Cannot parse boolean from: ${value}`); +} + +export { parseBoolean }; diff --git a/shared/utils/validate_schema.js b/shared/utils/validate_schema.js index e1b59aa4..ca5b5a9f 100644 --- a/shared/utils/validate_schema.js +++ b/shared/utils/validate_schema.js @@ -1,6 +1,6 @@ import Ajv from "ajv"; -function validate_schema(schema, body) { +function validateSchema(schema, body) { const ajv = new Ajv(); const list_keywords = ["methods", "route", "max_retry", "rpc"]; for (const keyword of list_keywords) { @@ -10,4 +10,4 @@ function validate_schema(schema, body) { return { valid, error: ajv.errorsText() }; } -export { validate_schema }; +export { validateSchema }; diff --git a/tests/unit/utils/validate_schema.nuxt.test.js b/tests/unit/utils/validate_schema.nuxt.test.js index 8fa29b26..a43d3554 100644 --- a/tests/unit/utils/validate_schema.nuxt.test.js +++ b/tests/unit/utils/validate_schema.nuxt.test.js @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; // Local imports -import { validate_schema } from "@ogw_shared/utils/validate_schema"; +import { validateSchema } from "@ogw_shared/utils/validate_schema"; // CONSTANTS const MIN_0 = 0; @@ -24,14 +24,14 @@ describe("validate schema", () => { test("ajv wrong params", () => { const params = {}; - const { valid, error } = validate_schema(schema, params); + const { valid, error } = validateSchema(schema, params); expect(valid).toBe(false); expect(error).toBe("data must have required property 'var_1'"); }); test("good params", () => { const params = { var_1: "test", var_2: VAL_5 }; - const { valid, error } = validate_schema(schema, params); + const { valid, error } = validateSchema(schema, params); expect(valid).toBe(true); expect(error).toBe("No errors"); });