@geode/opengeodeweb-front 10.39.0-rc.1 → 10.40.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
+ import { createServerWsRpcClient } from "./ws_client.js";
1
2
  import path from "node:path";
2
3
  import { consola } from "consola";
3
4
  import fs from "node:fs";
4
5
  import { setTimeout } from "node:timers/promises";
5
- import { WebSocket } from "ws";
6
6
  import pTimeout from "p-timeout";
7
7
  import { rimraf } from "rimraf";
8
8
  //#region server/utils/cleanup.ts
@@ -51,40 +51,19 @@ async function killWebsocketMicroservice(microservice) {
51
51
  const successMessage = `Disconnected from ${microservice.name} WebSocket server`;
52
52
  async function do_kill() {
53
53
  if (microservice.url === void 0) return;
54
- const socketUrl = microservice.url;
55
- await new Promise((resolve) => {
56
- const socket = new WebSocket(socketUrl);
57
- socket.on("open", () => {
58
- consola.info("Connected to WebSocket server");
59
- socket.send(JSON.stringify({
60
- id: "system:hello",
61
- method: "wslink.hello",
62
- args: [{ secret: "wslink-secret" }]
63
- }));
64
- });
65
- socket.on("message", (data) => {
66
- const message = data.toString();
67
- consola.debug("Received from server:", message);
68
- if (message.includes("hello")) {
69
- socket.send(JSON.stringify({
70
- id: "application.exit",
71
- method: "application.exit"
72
- }));
73
- consola.info(successMessage);
74
- socket.close();
75
- resolve();
76
- }
77
- });
78
- socket.on("close", () => {
79
- consola.info(successMessage);
80
- resolve();
81
- });
82
- socket.on("error", (error) => {
83
- consola.error("WebSocket error:", error);
84
- socket.close();
85
- resolve();
86
- });
54
+ const client = createServerWsRpcClient(microservice.url);
55
+ const closed = new Promise((resolve) => {
56
+ client.onConnectionClose(resolve);
87
57
  });
58
+ try {
59
+ await client.notify("application.exit");
60
+ await closed;
61
+ consola.info(successMessage);
62
+ } catch (error) {
63
+ consola.error("WebSocket error:", error);
64
+ } finally {
65
+ client.close();
66
+ }
88
67
  }
89
68
  await pTimeout(do_kill(), {
90
69
  milliseconds: 5e3,
@@ -4,8 +4,8 @@ import path from "node:path";
4
4
  import { consola } from "consola";
5
5
  import fs from "node:fs";
6
6
  import { setTimeout } from "node:timers/promises";
7
- import os from "node:os";
8
7
  import { v4 } from "uuid";
8
+ import os from "node:os";
9
9
  //#region server/utils/path.ts
10
10
  function executableName(execName) {
11
11
  if (process.platform === "win32") return `${execName}.exe`;
@@ -1,39 +1,47 @@
1
+ import { Decoder, encode } from "@msgpack/msgpack";
2
+ import { UnChunker, generateChunks } from "wslink/src/WebsocketConnection/chunking.js";
1
3
  import { WebSocket } from "ws";
2
4
  import { v4 } from "uuid";
3
5
  //#region server/utils/ws_client.ts
4
6
  const HELLO_ID = "system:hello";
5
7
  const HELLO_SECRET = "wslink-secret";
8
+ const DEFAULT_MAX_MSG_SIZE = 524288;
6
9
  function isWsRpcMessage(value) {
7
10
  return typeof value === "object" && value !== null;
8
11
  }
9
- function rawDataToString(raw) {
10
- if (Buffer.isBuffer(raw)) return raw.toString();
11
- if (Array.isArray(raw)) return Buffer.concat(raw).toString();
12
- return Buffer.from(raw).toString();
12
+ function rawDataToBlob(raw) {
13
+ if (raw instanceof ArrayBuffer) return new Blob([raw]);
14
+ return new Blob([Uint8Array.from(Array.isArray(raw) ? Buffer.concat(raw) : raw)]);
13
15
  }
14
16
  function createServerWsRpcClient(baseUrl) {
15
17
  const socket = new WebSocket(baseUrl);
16
18
  const pending = /* @__PURE__ */ new Map();
19
+ const unChunker = new UnChunker();
20
+ let maxMsgSize = DEFAULT_MAX_MSG_SIZE;
17
21
  let onCloseCallback = void 0;
18
22
  let onErrorCallback = void 0;
23
+ function send(message) {
24
+ for (const chunk of generateChunks(encode(message), maxMsgSize)) socket.send(chunk);
25
+ }
19
26
  const ready = new Promise((resolve, reject) => {
20
27
  socket.on("open", () => {
21
- socket.send(JSON.stringify({
28
+ send({
22
29
  id: HELLO_ID,
23
30
  method: "wslink.hello",
24
31
  args: [{ secret: HELLO_SECRET }]
25
- }));
32
+ });
26
33
  });
27
- socket.on("message", (raw) => {
34
+ async function handleMessage(raw) {
28
35
  let message = void 0;
29
36
  try {
30
- const parsed = JSON.parse(rawDataToString(raw));
31
- if (!isWsRpcMessage(parsed)) return;
32
- message = parsed;
37
+ const decoded = await unChunker.processChunk(rawDataToBlob(raw), () => new Decoder());
38
+ if (!isWsRpcMessage(decoded)) return;
39
+ message = decoded;
33
40
  } catch {
34
41
  return;
35
42
  }
36
43
  if (message.id === HELLO_ID) {
44
+ maxMsgSize = message.result?.maxMsgSize ?? maxMsgSize;
37
45
  resolve();
38
46
  return;
39
47
  }
@@ -43,12 +51,16 @@ function createServerWsRpcClient(baseUrl) {
43
51
  pending.delete(message.id);
44
52
  if (message.error) entry.reject(new Error(message.error.message ?? "wslink RPC error"));
45
53
  else entry.resolve(message.result);
54
+ }
55
+ socket.on("message", (raw) => {
56
+ handleMessage(raw);
46
57
  });
47
58
  socket.on("error", (error) => {
48
59
  onErrorCallback?.(error);
49
60
  reject(error);
50
61
  });
51
62
  socket.on("close", () => {
63
+ reject(/* @__PURE__ */ new Error("WebSocket closed"));
52
64
  onCloseCallback?.();
53
65
  for (const { reject: rejectPending } of pending.values()) rejectPending(/* @__PURE__ */ new Error("WebSocket closed"));
54
66
  pending.clear();
@@ -62,13 +74,19 @@ function createServerWsRpcClient(baseUrl) {
62
74
  resolve,
63
75
  reject
64
76
  });
65
- socket.send(JSON.stringify({
66
- wslink: "1.0",
77
+ send({
67
78
  id,
68
79
  method: rpc,
69
80
  args: [params],
70
81
  kwargs: { stream: true }
71
- }));
82
+ });
83
+ });
84
+ }
85
+ async function notify(method) {
86
+ await ready;
87
+ send({
88
+ id: method,
89
+ method
72
90
  });
73
91
  }
74
92
  function close() {
@@ -93,6 +111,7 @@ function createServerWsRpcClient(baseUrl) {
93
111
  close,
94
112
  getConnection,
95
113
  isOpen,
114
+ notify,
96
115
  onConnectionClose,
97
116
  onConnectionError,
98
117
  ready
@@ -0,0 +1 @@
1
+ export {};
@@ -1,9 +1,21 @@
1
1
  import pTimeout from "p-timeout";
2
- import "lodash";
2
+ import _ from "lodash";
3
3
  //#region shared/utils/call_raw.ts
4
+ function isPlainRecord(value) {
5
+ return _.isPlainObject(value);
6
+ }
7
+ function omitUndefinedFields(record) {
8
+ function omitUndefined(value) {
9
+ if (Array.isArray(value)) return value.map((item) => omitUndefined(item));
10
+ if (isPlainRecord(value)) return omitUndefinedFields(value);
11
+ return value;
12
+ }
13
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0).map(([key, value]) => [key, omitUndefined(value)]));
14
+ }
4
15
  async function callClient({ rpc, params = {}, client }) {
5
- if (globalThis.window !== void 0) return await client.getConnection().getSession().call(rpc, [params]);
6
- return await client.call(rpc, params);
16
+ const definedParams = omitUndefinedFields(params);
17
+ if (globalThis.window !== void 0) return await client.getConnection().getSession().call(rpc, [definedParams]);
18
+ return await client.call(rpc, definedParams);
7
19
  }
8
20
  async function callRaw({ rpc, params = {}, client, timeout }, { request_error_function, response_function, response_error_function } = {}) {
9
21
  async function performCall() {
@@ -44,7 +44,7 @@ async function takeScreenshot(): Promise<void> {
44
44
  params,
45
45
  },
46
46
  {
47
- // `blob` is a wslink attachment: the viewer sends its key, the client receives the binary in its place
47
+ // `blob` is the image file content, received as binary (Uint8Array) through wslink
48
48
  response_function: async ({ blob }) => {
49
49
  if (screenshot_type.value === "file") {
50
50
  fileDownload(blob, `${current_filename}.${output_extension.value}`);
@@ -47,6 +47,11 @@ function asWidgetFactory(widget: PlaneWidget): vtkAbstractWidgetFactory<vtkAbstr
47
47
  return widget as unknown as vtkAbstractWidgetFactory<vtkAbstractWidget>;
48
48
  }
49
49
 
50
+ function asWidgetHandle(widget: unknown): WidgetHandle {
51
+ // oxlint-disable-next-line no-unsafe-type-assertion -- vtk.js WidgetManager.addWidget is untyped for our custom WidgetHandle shape; narrowed here.
52
+ return widget as WidgetHandle;
53
+ }
54
+
50
55
  interface WidgetEntryManagerParams {
51
56
  planes: Ref<ClippingPlane[]>;
52
57
  debouncedApply: (...args: readonly unknown[]) => void;
@@ -136,10 +141,7 @@ function useWidgetEntryManager({ planes, debouncedApply }: WidgetEntryManagerPar
136
141
  if (!widgetEntries[idx]) {
137
142
  // oxlint-disable-next-line no-unsafe-call no-unsafe-type-assertion -- vtk.js has no types for ImplicitPlaneWidget's factory; narrowed here.
138
143
  const planeWidget = vtkImplicitPlaneWidget() as PlaneWidget;
139
- const widgetHandle = widgetManager.addWidget(
140
- asWidgetFactory(planeWidget),
141
- // oxlint-disable-next-line no-unsafe-type-assertion -- vtk.js WidgetManager.addWidget is untyped for our custom WidgetHandle shape; narrowed here.
142
- ) as unknown as WidgetHandle;
144
+ const widgetHandle = asWidgetHandle(widgetManager.addWidget(asWidgetFactory(planeWidget)));
143
145
  widgetEntries.push(createWidgetEntry(planeWidget, widgetHandle, idx));
144
146
  }
145
147
  const entry = widgetEntries[idx];
@@ -1,10 +1,10 @@
1
- import { type Table, liveQuery } from "dexie";
2
- import type { Observable } from "rxjs";
1
+ import type { Table } from "dexie";
3
2
  import { useObservable } from "@vueuse/rxjs";
4
3
 
5
4
  // Local imports
6
5
  import type { CameraOptions } from "@ogw_internal/stores/hybrid_viewer/vtk_types.js";
7
6
  import { database } from "@ogw_internal/database/database.js";
7
+ import { liveObservable } from "@ogw_front/utils/live_observable";
8
8
  import { useViewerStore } from "@ogw_front/stores/viewer";
9
9
  import viewer_schemas from "@geode/opengeodeweb-viewer/opengeodeweb_viewer_typed_schemas.js";
10
10
 
@@ -26,16 +26,11 @@ export const useCameraManagerStore = defineStore("camera_manager", () => {
26
26
  >;
27
27
 
28
28
  function refAllCameraPositions(): Ref<CameraPositionRecord[]> {
29
- // Dexie's liveQuery() returns Dexie's own minimal Observable shape, not an
30
- // Actual rxjs Observable instance (useObservable's declared parameter type);
31
- // The two are structurally close enough at runtime (vueuse only calls
32
- // `.subscribe`) but not identical, hence the cast.
33
29
  return useObservable(
34
- liveQuery(async () => {
30
+ liveObservable(async () => {
35
31
  const positions = await camera_positions_db.toArray();
36
32
  return positions;
37
- // oxlint-disable-next-line no-unsafe-type-assertion -- trusted vueuse/Dexie Observable boundary; see comment above.
38
- }) as unknown as Observable<CameraPositionRecord[]>,
33
+ }),
39
34
  { initialValue: [] as CameraPositionRecord[] },
40
35
  );
41
36
  }
@@ -1,13 +1,13 @@
1
1
  // oxlint-disable eslint/max-lines
2
2
  // Third party imports
3
- import { type Table, liveQuery } from "dexie";
4
- import type { Observable } from "rxjs";
3
+ import type { Table } from "dexie";
5
4
  import { useObservable } from "@vueuse/rxjs";
6
5
  import viewer_schemas from "@geode/opengeodeweb-viewer/opengeodeweb_viewer_typed_schemas.js";
7
6
 
8
7
  // Local imports
9
8
  import { type ModelComponentRecord, useDataMesh } from "./data_helpers/mesh.js";
10
9
  import { database } from "@ogw_internal/database/database.js";
10
+ import { liveObservable } from "@ogw_front/utils/live_observable";
11
11
  import { useDataCollections } from "./data_helpers/collections.js";
12
12
  import { useViewerStore } from "@ogw_front/stores/viewer";
13
13
 
@@ -137,16 +137,11 @@ export const useDataStore = defineStore("data", () => {
137
137
  return items;
138
138
  }
139
139
  function refItem(id: string): Ref<DataItem | undefined> {
140
- // Dexie's liveQuery() returns Dexie's own minimal Observable shape, not an
141
- // Actual rxjs Observable instance (useObservable's declared parameter type);
142
- // The two are structurally close enough at runtime (vueuse only calls
143
- // `.subscribe`) but not identical, hence the cast.
144
140
  return useObservable(
145
- liveQuery(async () => {
141
+ liveObservable(async () => {
146
142
  const data_item = await data_db.get(id);
147
143
  return data_item;
148
- // oxlint-disable-next-line no-unsafe-type-assertion -- trusted vueuse/Dexie Observable boundary; see comment above.
149
- }) as unknown as Observable<DataItem | undefined>,
144
+ }),
150
145
  {
151
146
  // oxlint-disable-next-line no-unsafe-type-assertion -- placeholder until the live query resolves; consumers must treat this as possibly incomplete.
152
147
  initialValue: {} as DataItem,
@@ -155,11 +150,10 @@ export const useDataStore = defineStore("data", () => {
155
150
  }
156
151
  function refAllItems(): Ref<DataItem[]> {
157
152
  return useObservable(
158
- liveQuery(async () => {
153
+ liveObservable(async () => {
159
154
  const items = await data_db.toArray();
160
155
  return items;
161
- // oxlint-disable-next-line no-unsafe-type-assertion -- trusted vueuse/Dexie Observable boundary; see comment above.
162
- }) as unknown as Observable<DataItem[]>,
156
+ }),
163
157
  {
164
158
  initialValue: [] as DataItem[],
165
159
  },
@@ -1,9 +1,8 @@
1
1
  import { type FormattedComponent, type ModelComponentRecord, useDataMesh } from "./mesh.js";
2
2
  import { MESH_COMPONENT_TYPES } from "@ogw_front/utils/default_styles";
3
- import type { Observable } from "rxjs";
4
3
  import type { Ref } from "vue";
5
4
  import { getTable } from "@ogw_internal/database/database.js";
6
- import { liveQuery } from "dexie";
5
+ import { liveObservable } from "@ogw_front/utils/live_observable";
7
6
  import { useObservable } from "@vueuse/rxjs";
8
7
 
9
8
  interface ModelComponentRelationRecord {
@@ -115,12 +114,10 @@ export function useDataCollections(): {
115
114
  modelId: string,
116
115
  ): Ref<CollectionComponentGroup[] | undefined> {
117
116
  return useObservable(
118
- // Dexie's liveQuery returns its own Observable-like type, not rxjs's Observable, so bridging needs a cast.
119
- liveQuery(async () => {
117
+ liveObservable(async () => {
120
118
  const groups = await formatedCollectionComponents(modelId);
121
119
  return groups;
122
- // oxlint-disable-next-line no-unsafe-type-assertion
123
- }) as unknown as Observable<CollectionComponentGroup[]>,
120
+ }),
124
121
  {
125
122
  initialValue: undefined,
126
123
  },
@@ -1,6 +1,6 @@
1
- import { type Table, liveQuery } from "dexie";
2
- import type { Observable } from "rxjs";
1
+ import type { Table } from "dexie";
3
2
  import { database } from "@ogw_internal/database/database.js";
3
+ import { liveObservable } from "@ogw_front/utils/live_observable";
4
4
  import { useObservable } from "@vueuse/rxjs";
5
5
 
6
6
  interface ModelComponentRecord {
@@ -87,11 +87,10 @@ export function useDataMesh(): {
87
87
 
88
88
  function refFormatedMeshComponents(modelId: string): Ref<FormattedComponentGroup[] | undefined> {
89
89
  return useObservable(
90
- liveQuery(async () => {
90
+ liveObservable(async () => {
91
91
  const components = await formatedMeshComponents(modelId);
92
92
  return components;
93
- // oxlint-disable-next-line no-unsafe-type-assertion
94
- }) as unknown as Observable<FormattedComponentGroup[]>,
93
+ }),
95
94
  {
96
95
  initialValue: undefined,
97
96
  },
@@ -142,12 +142,13 @@ export const useViewerStore = defineStore(
142
142
  }
143
143
  try {
144
144
  status.value = Status.CONNECTING;
145
- client.value = (await initWebSocketClient(base_url.value, client.value, {
145
+ const wsClient = await initWebSocketClient(base_url.value, client.value, {
146
146
  onConnectionClose: () => {
147
147
  status.value = Status.NOT_CONNECTED;
148
148
  },
149
- // oxlint-disable-next-line no-unsafe-type-assertion -- initWebSocketClient's return is not typed as RpcClient.
150
- })) as unknown as RpcClient;
149
+ });
150
+ // oxlint-disable-next-line no-unsafe-type-assertion -- initWebSocketClient's return is not typed as RpcClient.
151
+ client.value = wsClient as unknown as RpcClient;
151
152
  connectImageStream(client.value.getConnection().getSession());
152
153
  // oxlint-disable-next-line no-unsafe-type-assertion -- endBusy is not part of the RpcClient type.
153
154
  (client.value as unknown as { endBusy: () => void }).endBusy();
@@ -0,0 +1,14 @@
1
+ // Third party imports
2
+ import type { Observable } from "rxjs";
3
+ import { liveQuery } from "dexie";
4
+
5
+ // Dexie's liveQuery() returns Dexie's own minimal Observable shape, not an
6
+ // Actual rxjs Observable instance (useObservable's declared parameter type);
7
+ // The two are structurally close enough at runtime (vueuse only calls
8
+ // `.subscribe`) but not identical, hence the cast.
9
+ function liveObservable<Value>(query: () => Promise<Value>): Observable<Value> {
10
+ // oxlint-disable-next-line no-unsafe-type-assertion -- trusted vueuse/Dexie Observable boundary; see comment above.
11
+ return liveQuery(query) as unknown as Observable<Value>;
12
+ }
13
+
14
+ export { liveObservable };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geode/opengeodeweb-front",
3
- "version": "10.39.0-rc.1",
3
+ "version": "10.40.0-rc.1",
4
4
  "description": "OpenSource Vue/Nuxt/Pinia/Vuetify framework for web applications",
5
5
  "homepage": "https://github.com/Geode-solutions/OpenGeodeWeb-Front",
6
6
  "bugs": {
@@ -69,6 +69,7 @@
69
69
  "@geode/opengeodeweb-viewer": "next",
70
70
  "@kitware/vtk.js": "33.3.0",
71
71
  "@mdi/font": "7.4.47",
72
+ "@msgpack/msgpack": "2.8.0",
72
73
  "@pinia/nuxt": "1.0.1",
73
74
  "@types/busboy": "1.5.4",
74
75
  "@types/node": "22.20.3",
@@ -101,7 +102,7 @@
101
102
  "vuetify": "4.2.0",
102
103
  "vuetify-nuxt-module": "1.0.0-rc.5",
103
104
  "ws": "8.21.2",
104
- "wslink": "1.12.4",
105
+ "wslink": "2.5.0",
105
106
  "zod": "4.4.3"
106
107
  },
107
108
  "devDependencies": {
@@ -4,11 +4,13 @@ import path from "node:path";
4
4
  import { setTimeout } from "node:timers/promises";
5
5
 
6
6
  // Third party imports
7
- import { WebSocket } from "ws";
8
7
  import { consola } from "consola";
9
8
  import pTimeout from "p-timeout";
10
9
  import { rimraf } from "rimraf";
11
10
 
11
+ // Local imports
12
+ import { createServerWsRpcClient } from "./ws_client.js";
13
+
12
14
  interface Microservice {
13
15
  type: "back" | "viewer";
14
16
  name: string;
@@ -88,49 +90,21 @@ async function killWebsocketMicroservice(microservice: Microservice): Promise<vo
88
90
  if (microservice.url === undefined) {
89
91
  return;
90
92
  }
91
- const socketUrl = microservice.url;
93
+ const client = createServerWsRpcClient(microservice.url);
92
94
  // oxlint-disable-next-line promise/avoid-new
93
- await new Promise<void>((resolve) => {
94
- const socket = new WebSocket(socketUrl);
95
- socket.on("open", () => {
96
- consola.info("Connected to WebSocket server");
97
- socket.send(
98
- JSON.stringify({
99
- id: "system:hello",
100
- method: "wslink.hello",
101
- args: [
102
- {
103
- secret: "wslink-secret",
104
- },
105
- ],
106
- }),
107
- );
108
- });
109
- socket.on("message", (data: Buffer | string) => {
110
- const message = data.toString();
111
- consola.debug("Received from server:", message);
112
- if (message.includes("hello")) {
113
- socket.send(
114
- JSON.stringify({
115
- id: "application.exit",
116
- method: "application.exit",
117
- }),
118
- );
119
- consola.info(successMessage);
120
- socket.close();
121
- resolve();
122
- }
123
- });
124
- socket.on("close", () => {
125
- consola.info(successMessage);
126
- resolve();
127
- });
128
- socket.on("error", (error: Error) => {
129
- consola.error("WebSocket error:", error);
130
- socket.close();
131
- resolve();
132
- });
95
+ const closed = new Promise<void>((resolve) => {
96
+ client.onConnectionClose(resolve);
133
97
  });
98
+ try {
99
+ await client.notify("application.exit");
100
+ // Wslink handles messages asynchronously: closing right away would drop the exit request
101
+ await closed;
102
+ consola.info(successMessage);
103
+ } catch (error) {
104
+ consola.error("WebSocket error:", error);
105
+ } finally {
106
+ client.close();
107
+ }
134
108
  }
135
109
  await pTimeout(do_kill(), {
136
110
  milliseconds: 5000,
@@ -1,4 +1,6 @@
1
1
  // Third party imports
2
+ import { Decoder, encode } from "@msgpack/msgpack";
3
+ import { UnChunker, generateChunks } from "wslink/src/WebsocketConnection/chunking.js";
2
4
  import { WebSocket } from "ws";
3
5
  import { v4 as uuidv4 } from "uuid";
4
6
 
@@ -6,6 +8,8 @@ import { v4 as uuidv4 } from "uuid";
6
8
 
7
9
  const HELLO_ID = "system:hello";
8
10
  const HELLO_SECRET = "wslink-secret";
11
+ // Wslink default (512 KiB), until the hello response gives the server's limit
12
+ const DEFAULT_MAX_MSG_SIZE = 524_288;
9
13
 
10
14
  interface PendingCall {
11
15
  resolve: (value: unknown) => void;
@@ -15,7 +19,7 @@ interface PendingCall {
15
19
  interface WsRpcMessage {
16
20
  id?: string;
17
21
  error?: { message?: string };
18
- result?: unknown;
22
+ result?: { maxMsgSize?: number } & Record<string, unknown>;
19
23
  }
20
24
 
21
25
  function isWsRpcMessage(value: unknown): value is WsRpcMessage {
@@ -23,14 +27,11 @@ function isWsRpcMessage(value: unknown): value is WsRpcMessage {
23
27
  }
24
28
 
25
29
  // oxlint-disable-next-line prefer-readonly-parameter-types
26
- function rawDataToString(raw: WebSocket.RawData): string {
27
- if (Buffer.isBuffer(raw)) {
28
- return raw.toString();
30
+ function rawDataToBlob(raw: WebSocket.RawData): Blob {
31
+ if (raw instanceof ArrayBuffer) {
32
+ return new Blob([raw]);
29
33
  }
30
- if (Array.isArray(raw)) {
31
- return Buffer.concat(raw).toString();
32
- }
33
- return Buffer.from(raw).toString();
34
+ return new Blob([Uint8Array.from(Array.isArray(raw) ? Buffer.concat(raw) : raw)]);
34
35
  }
35
36
 
36
37
  interface ServerWsRpcClient {
@@ -42,6 +43,7 @@ interface ServerWsRpcClient {
42
43
  };
43
44
  };
44
45
  isOpen: () => boolean;
46
+ notify: (method: string) => Promise<void>;
45
47
  onConnectionClose: (callback: () => void) => void;
46
48
  onConnectionError: (callback: (error: unknown) => void) => void;
47
49
  ready: Promise<void>;
@@ -51,35 +53,43 @@ interface ServerWsRpcClient {
51
53
  function createServerWsRpcClient(baseUrl: string): ServerWsRpcClient {
52
54
  const socket = new WebSocket(baseUrl);
53
55
  const pending = new Map<string, PendingCall>();
56
+ // Wslink 2 messages are msgpack-encoded and split into chunks
57
+ const unChunker = new UnChunker();
58
+ let maxMsgSize = DEFAULT_MAX_MSG_SIZE;
54
59
  let onCloseCallback: (() => void) | undefined = undefined;
55
60
  let onErrorCallback: ((error: unknown) => void) | undefined = undefined;
56
61
 
62
+ function send(message: object): void {
63
+ for (const chunk of generateChunks(encode(message), maxMsgSize)) {
64
+ socket.send(chunk);
65
+ }
66
+ }
67
+
57
68
  //oxlint-disable-next-line promise/avoid-new max-lines-per-function
58
69
  const ready = new Promise<void>((resolve, reject) => {
59
70
  socket.on("open", () => {
60
- socket.send(
61
- JSON.stringify({
62
- id: HELLO_ID,
63
- method: "wslink.hello",
64
- args: [{ secret: HELLO_SECRET }],
65
- }),
66
- );
71
+ send({
72
+ id: HELLO_ID,
73
+ method: "wslink.hello",
74
+ args: [{ secret: HELLO_SECRET }],
75
+ });
67
76
  });
68
77
 
69
78
  // oxlint-disable-next-line prefer-readonly-parameter-types
70
- socket.on("message", (raw: WebSocket.RawData) => {
79
+ async function handleMessage(raw: WebSocket.RawData): Promise<void> {
71
80
  let message: WsRpcMessage | undefined = undefined;
72
81
  try {
73
- const parsed: unknown = JSON.parse(rawDataToString(raw));
74
- if (!isWsRpcMessage(parsed)) {
82
+ const decoded = await unChunker.processChunk(rawDataToBlob(raw), () => new Decoder());
83
+ if (!isWsRpcMessage(decoded)) {
75
84
  return;
76
85
  }
77
- message = parsed;
86
+ message = decoded;
78
87
  } catch {
79
88
  return;
80
89
  }
81
90
 
82
91
  if (message.id === HELLO_ID) {
92
+ maxMsgSize = message.result?.maxMsgSize ?? maxMsgSize;
83
93
  resolve();
84
94
  return;
85
95
  }
@@ -98,6 +108,11 @@ function createServerWsRpcClient(baseUrl: string): ServerWsRpcClient {
98
108
  } else {
99
109
  entry.resolve(message.result);
100
110
  }
111
+ }
112
+
113
+ // oxlint-disable-next-line prefer-readonly-parameter-types
114
+ socket.on("message", (raw: WebSocket.RawData) => {
115
+ void handleMessage(raw);
101
116
  });
102
117
 
103
118
  socket.on("error", (error: Error) => {
@@ -106,6 +121,7 @@ function createServerWsRpcClient(baseUrl: string): ServerWsRpcClient {
106
121
  });
107
122
 
108
123
  socket.on("close", () => {
124
+ reject(new Error("WebSocket closed"));
109
125
  onCloseCallback?.();
110
126
  for (const { reject: rejectPending } of pending.values()) {
111
127
  rejectPending(new Error("WebSocket closed"));
@@ -120,18 +136,21 @@ function createServerWsRpcClient(baseUrl: string): ServerWsRpcClient {
120
136
  //oxlint-disable-next-line promise/avoid-new
121
137
  return new Promise((resolve, reject) => {
122
138
  pending.set(id, { resolve, reject });
123
- socket.send(
124
- JSON.stringify({
125
- wslink: "1.0",
126
- id,
127
- method: rpc,
128
- args: [params],
129
- kwargs: { stream: true },
130
- }),
131
- );
139
+ send({
140
+ id,
141
+ method: rpc,
142
+ args: [params],
143
+ kwargs: { stream: true },
144
+ });
132
145
  });
133
146
  }
134
147
 
148
+ // Call an RPC without arguments nor waiting for its response (e.g. "application.exit")
149
+ async function notify(method: string): Promise<void> {
150
+ await ready;
151
+ send({ id: method, method });
152
+ }
153
+
135
154
  function close(): void {
136
155
  socket.close();
137
156
  }
@@ -161,7 +180,16 @@ function createServerWsRpcClient(baseUrl: string): ServerWsRpcClient {
161
180
  onErrorCallback = callback;
162
181
  }
163
182
 
164
- return { call, close, getConnection, isOpen, onConnectionClose, onConnectionError, ready };
183
+ return {
184
+ call,
185
+ close,
186
+ getConnection,
187
+ isOpen,
188
+ notify,
189
+ onConnectionClose,
190
+ onConnectionError,
191
+ ready,
192
+ };
165
193
  }
166
194
 
167
195
  export { createServerWsRpcClient };
@@ -0,0 +1,12 @@
1
+ // Wslink ships no types for its chunking module; this describes what ws_client.ts uses.
2
+ declare module "wslink/src/WebsocketConnection/chunking.js" {
3
+ interface Decoder {
4
+ decode: (buffer: Uint8Array) => unknown;
5
+ }
6
+ function generateChunks(message: Uint8Array, maxSize: number): Generator<Uint8Array>;
7
+ class UnChunker {
8
+ public processChunk(chunk: Blob, decoderFactory: () => Decoder): Promise<unknown>;
9
+ public releasePendingMessages(): void;
10
+ }
11
+ export { UnChunker, generateChunks };
12
+ }
@@ -26,12 +26,35 @@ interface CallRawOptions extends CallClientOptions {
26
26
  timeout?: number;
27
27
  }
28
28
 
29
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
30
+ return _.isPlainObject(value);
31
+ }
32
+
33
+ // Wslink encodes messages with msgpack, which sends undefined values as null where JSON omitted them
34
+ function omitUndefinedFields(record: object): Record<string, unknown> {
35
+ function omitUndefined(value: unknown): unknown {
36
+ if (Array.isArray(value)) {
37
+ return value.map((item: unknown) => omitUndefined(item));
38
+ }
39
+ if (isPlainRecord(value)) {
40
+ return omitUndefinedFields(value);
41
+ }
42
+ return value;
43
+ }
44
+ return Object.fromEntries(
45
+ Object.entries(record)
46
+ .filter(([, value]) => value !== undefined)
47
+ .map(([key, value]) => [key, omitUndefined(value)]),
48
+ );
49
+ }
50
+
29
51
  async function callClient({ rpc, params = {}, client }: CallClientOptions): Promise<unknown> {
52
+ const definedParams = omitUndefinedFields(params);
30
53
  if (globalThis.window !== undefined) {
31
- const response = await client.getConnection().getSession().call(rpc, [params]);
54
+ const response = await client.getConnection().getSession().call(rpc, [definedParams]);
32
55
  return response;
33
56
  }
34
- const response = await client.call(rpc, params);
57
+ const response = await client.call(rpc, definedParams);
35
58
  return response;
36
59
  }
37
60