@milaboratories/pl-client 2.16.13 → 2.16.15

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.
Files changed (63) hide show
  1. package/dist/__external/.pnpm/{@rollup_plugin-typescript@12.1.4_rollup@4.52.4_tslib@2.8.1_typescript@5.6.3 → @rollup_plugin-typescript@12.3.0_rollup@4.52.4_tslib@2.8.1_typescript@5.6.3}/__external/tslib/tslib.es6.cjs.map +1 -1
  2. package/dist/__external/.pnpm/{@rollup_plugin-typescript@12.1.4_rollup@4.52.4_tslib@2.8.1_typescript@5.6.3 → @rollup_plugin-typescript@12.3.0_rollup@4.52.4_tslib@2.8.1_typescript@5.6.3}/__external/tslib/tslib.es6.js.map +1 -1
  3. package/dist/core/client.cjs +31 -16
  4. package/dist/core/client.cjs.map +1 -1
  5. package/dist/core/client.d.ts +3 -2
  6. package/dist/core/client.d.ts.map +1 -1
  7. package/dist/core/client.js +31 -16
  8. package/dist/core/client.js.map +1 -1
  9. package/dist/core/default_client.cjs +1 -1
  10. package/dist/core/default_client.cjs.map +1 -1
  11. package/dist/core/default_client.js +1 -1
  12. package/dist/core/default_client.js.map +1 -1
  13. package/dist/core/errors.cjs +71 -26
  14. package/dist/core/errors.cjs.map +1 -1
  15. package/dist/core/errors.d.ts +10 -3
  16. package/dist/core/errors.d.ts.map +1 -1
  17. package/dist/core/errors.js +68 -27
  18. package/dist/core/errors.js.map +1 -1
  19. package/dist/core/ll_client.cjs +48 -18
  20. package/dist/core/ll_client.cjs.map +1 -1
  21. package/dist/core/ll_client.d.ts +12 -3
  22. package/dist/core/ll_client.d.ts.map +1 -1
  23. package/dist/core/ll_client.js +49 -19
  24. package/dist/core/ll_client.js.map +1 -1
  25. package/dist/core/transaction.cjs +1 -1
  26. package/dist/core/transaction.js +1 -1
  27. package/dist/core/unauth_client.cjs +6 -2
  28. package/dist/core/unauth_client.cjs.map +1 -1
  29. package/dist/core/unauth_client.d.ts +2 -1
  30. package/dist/core/unauth_client.d.ts.map +1 -1
  31. package/dist/core/unauth_client.js +6 -2
  32. package/dist/core/unauth_client.js.map +1 -1
  33. package/dist/core/websocket_stream.cjs +23 -2
  34. package/dist/core/websocket_stream.cjs.map +1 -1
  35. package/dist/core/websocket_stream.d.ts.map +1 -1
  36. package/dist/core/websocket_stream.js +23 -2
  37. package/dist/core/websocket_stream.js.map +1 -1
  38. package/dist/index.cjs +4 -0
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/proto-rest/index.cjs +1 -1
  42. package/dist/proto-rest/index.cjs.map +1 -1
  43. package/dist/proto-rest/index.js +2 -2
  44. package/dist/proto-rest/index.js.map +1 -1
  45. package/dist/test/test_config.cjs +13 -3
  46. package/dist/test/test_config.cjs.map +1 -1
  47. package/dist/test/test_config.d.ts +4 -0
  48. package/dist/test/test_config.d.ts.map +1 -1
  49. package/dist/test/test_config.js +12 -4
  50. package/dist/test/test_config.js.map +1 -1
  51. package/package.json +10 -8
  52. package/src/core/client.ts +40 -21
  53. package/src/core/default_client.ts +1 -1
  54. package/src/core/errors.ts +61 -34
  55. package/src/core/ll_client.test.ts +18 -3
  56. package/src/core/ll_client.ts +63 -22
  57. package/src/core/unauth_client.test.ts +4 -4
  58. package/src/core/unauth_client.ts +7 -2
  59. package/src/core/websocket_stream.ts +22 -1
  60. package/src/proto-rest/index.ts +2 -2
  61. package/src/test/test_config.ts +13 -4
  62. /package/dist/__external/.pnpm/{@rollup_plugin-typescript@12.1.4_rollup@4.52.4_tslib@2.8.1_typescript@5.6.3 → @rollup_plugin-typescript@12.3.0_rollup@4.52.4_tslib@2.8.1_typescript@5.6.3}/__external/tslib/tslib.es6.cjs +0 -0
  63. /package/dist/__external/.pnpm/{@rollup_plugin-typescript@12.1.4_rollup@4.52.4_tslib@2.8.1_typescript@5.6.3 → @rollup_plugin-typescript@12.3.0_rollup@4.52.4_tslib@2.8.1_typescript@5.6.3}/__external/tslib/tslib.es6.js +0 -0
@@ -1,4 +1,4 @@
1
- import type { AuthOps, PlClientConfig, PlConnectionStatusListener } from './config';
1
+ import type { AuthOps, PlClientConfig, PlConnectionStatusListener, wireProtocol } from './config';
2
2
  import type { PlCallOps } from './ll_client';
3
3
  import { LLPlClient } from './ll_client';
4
4
  import type { AnyResourceRef } from './transaction';
@@ -22,6 +22,7 @@ import type { AllTxStat, TxStat } from './stat';
22
22
  import { addStat, initialTxStat } from './stat';
23
23
  import type { WireConnection } from './wire';
24
24
  import { advisoryLock } from './advisory_locks';
25
+ import { plAddressToConfig } from './config';
25
26
 
26
27
  export type TxOps = PlCallOps & {
27
28
  sync?: boolean;
@@ -54,8 +55,16 @@ export class PlClient {
54
55
  /** Last resort measure to solve complicated race conditions in pl. */
55
56
  private readonly defaultRetryOptions: RetryOptions;
56
57
 
57
- private readonly buildLLPlClient: (shouldUseGzip: boolean) => LLPlClient;
58
- private _ll: LLPlClient;
58
+ private readonly buildLLPlClient: (shouldUseGzip: boolean, wireProtocol?: wireProtocol) => Promise<LLPlClient>;
59
+ private _ll?: LLPlClient;
60
+
61
+ private get ll(): LLPlClient {
62
+ if (this._ll === undefined) {
63
+ throw new Error('LLPlClient not initialized');
64
+ }
65
+ return this._ll;
66
+ }
67
+
59
68
  /** Stores client root (this abstraction is intended for future implementation of the security model) */
60
69
  private _clientRoot: OptionalResourceId = NullResourceId;
61
70
 
@@ -83,12 +92,13 @@ export class PlClient {
83
92
  finalPredicate?: FinalResourceDataPredicate;
84
93
  } = {},
85
94
  ) {
86
- // Will reinitialize client after getting available feature from server.
87
- this.buildLLPlClient = (shouldUseGzip: boolean) => {
88
- return new LLPlClient(configOrAddress, { auth, ...ops, shouldUseGzip });
95
+ const conf = typeof configOrAddress === 'string' ? plAddressToConfig(configOrAddress) : configOrAddress;
96
+
97
+ this.buildLLPlClient = async (shouldUseGzip: boolean, wireProtocol?: wireProtocol): Promise<LLPlClient> => {
98
+ if (wireProtocol) conf.wireProtocol = wireProtocol;
99
+ return await LLPlClient.build(conf, { auth, ...ops, shouldUseGzip });
89
100
  };
90
- this._ll = this.buildLLPlClient(false);
91
- const conf = this._ll.conf;
101
+
92
102
  this.txDelay = conf.txDelay;
93
103
  this.forceSync = conf.forceSync;
94
104
  this.finalPredicate = ops.finalPredicate ?? DefaultFinalResourceDataPredicate;
@@ -145,23 +155,23 @@ export class PlClient {
145
155
  }
146
156
 
147
157
  public async ping(): Promise<MaintenanceAPI_Ping_Response> {
148
- return await this._ll.ping();
158
+ return await this.ll.ping();
149
159
  }
150
160
 
151
161
  public async license(): Promise<MaintenanceAPI_License_Response> {
152
- return await this._ll.license();
162
+ return await this.ll.license();
153
163
  }
154
164
 
155
165
  public get conf(): PlClientConfig {
156
- return this._ll.conf;
166
+ return this.ll.conf;
157
167
  }
158
168
 
159
169
  public get httpDispatcher(): Dispatcher {
160
- return this._ll.httpDispatcher;
170
+ return this.ll.httpDispatcher;
161
171
  }
162
172
 
163
173
  public get connectionOpts(): WireConnection {
164
- return this._ll.wireConnection;
174
+ return this.ll.wireConnection;
165
175
  }
166
176
 
167
177
  private get initialized() {
@@ -183,18 +193,27 @@ export class PlClient {
183
193
  }
184
194
 
185
195
  /** Currently implements custom logic to emulate future behaviour with single root. */
186
- public async init() {
196
+ private async init() {
187
197
  if (this.initialized) throw new Error('Already initialized');
188
198
 
199
+ // Initial client is created without gzip to perform server ping and detect optimal wire protocol.
200
+ // LLPlClient.build() internally calls detectOptimalWireProtocol() which starts with default 'grpc',
201
+ // then retries with 'rest' if ping fails, alternating until a working protocol is found.
202
+ // We save the detected wireProtocol here because if the server supports gzip compression,
203
+ // we'll need to reinitialize the client with gzip enabled - passing the already-detected
204
+ // wireProtocol avoids redundant protocol detection on reinit.
205
+ this._ll = await this.buildLLPlClient(false);
206
+ const wireProtocol = this._ll.wireProtocol;
207
+
189
208
  // calculating reproducible root name from the username
190
209
  const user = this._ll.authUser;
191
210
  const mainRootName
192
- = user === null ? AnonymousClientRoot : createHash('sha256').update(user).digest('hex');
211
+ = user === null ? AnonymousClientRoot : createHash('sha256').update(user).digest('hex');
193
212
 
194
213
  this._serverInfo = await this.ping();
195
214
  if (this._serverInfo.compression === MaintenanceAPI_Ping_Response_Compression.GZIP) {
196
215
  await this._ll.close();
197
- this._ll = this.buildLLPlClient(true);
216
+ this._ll = await this.buildLLPlClient(true, wireProtocol);
198
217
  }
199
218
 
200
219
  this._clientRoot = await this._withTx('initialization', true, NullResourceId, async (tx) => {
@@ -230,7 +249,7 @@ export class PlClient {
230
249
  /** Returns true if field existed */
231
250
  public async deleteAlternativeRoot(alternativeRootName: string): Promise<boolean> {
232
251
  this.checkInitialized();
233
- if (this._ll.conf.alternativeRoot !== undefined)
252
+ if (this.ll.conf.alternativeRoot !== undefined)
234
253
  throw new Error('Initialized with alternative root.');
235
254
  return await this.withWriteTx('delete-alternative-root', async (tx) => {
236
255
  const fId = {
@@ -259,7 +278,7 @@ export class PlClient {
259
278
 
260
279
  try {
261
280
  // opening low-level tx
262
- const llTx = this._ll.createTx(writable, ops);
281
+ const llTx = this.ll.createTx(writable, ops);
263
282
  // wrapping it into high-level tx (this also asynchronously sends initialization message)
264
283
  const tx = new PlTransaction(
265
284
  llTx,
@@ -306,7 +325,7 @@ export class PlClient {
306
325
  if (ok) {
307
326
  // syncing on transaction if requested
308
327
  if (ops?.sync === undefined ? this.forceSync : ops?.sync)
309
- await this._ll.txSync(txId);
328
+ await this.ll.txSync(txId);
310
329
 
311
330
  // introducing artificial delay, if requested
312
331
  if (writable && this.txDelay > 0)
@@ -355,14 +374,14 @@ export class PlClient {
355
374
  public getDriver<Drv extends PlDriver>(definition: PlDriverDefinition<Drv>): Drv {
356
375
  const attached = this.drivers.get(definition.name);
357
376
  if (attached !== undefined) return attached as Drv;
358
- const driver = definition.init(this, this._ll, this.httpDispatcher);
377
+ const driver = definition.init(this, this.ll, this.httpDispatcher);
359
378
  this.drivers.set(definition.name, driver);
360
379
  return driver;
361
380
  }
362
381
 
363
382
  /** Closes underlying transport */
364
383
  public async close() {
365
- await this._ll.close();
384
+ await this.ll.close();
366
385
  }
367
386
 
368
387
  public static async init(
@@ -130,7 +130,7 @@ export async function defaultPlClient(): Promise<PlClient> {
130
130
  }
131
131
 
132
132
  if (authInformation === undefined) {
133
- const client = new UnauthenticatedPlClient(config);
133
+ const client = await UnauthenticatedPlClient.build(config);
134
134
 
135
135
  if (await client.requireAuth()) {
136
136
  if (config.user === undefined || config.password === undefined)
@@ -5,47 +5,65 @@ import { Code } from '../proto-grpc/google/rpc/code';
5
5
  export function isConnectionProblem(err: unknown, nested: boolean = false): boolean {
6
6
  if (err instanceof DisconnectedError) return true;
7
7
  if ((err as any).name == 'RpcError' && (err as any).code == 'UNAVAILABLE') return true;
8
- if ((err as any).code == Code.UNAVAILABLE) return true;
9
- if ((err as any).cause !== undefined && !nested)
10
- // nested limits the depth of search
11
- return isConnectionProblem((err as any).cause, true);
8
+ if ((err as any).name == 'RESTError' && (err as any).status.code == Code.UNAVAILABLE) return true;
9
+ if ((err as any).cause !== undefined && !nested) return isConnectionProblem((err as any).cause, true);
12
10
  return false;
13
11
  }
14
12
 
15
13
  export function isUnauthenticated(err: unknown, nested: boolean = false): boolean {
16
14
  if (err instanceof UnauthenticatedError) return true;
17
15
  if ((err as any).name == 'RpcError' && (err as any).code == 'UNAUTHENTICATED') return true;
18
- if ((err as any).code == Code.UNAUTHENTICATED) return true;
19
- if ((err as any).cause !== undefined && !nested)
20
- // nested limits the depth of search
21
- return isUnauthenticated((err as any).cause, true);
16
+ if ((err as any).name == 'RESTError' && (err as any).status.code == Code.UNAUTHENTICATED) return true;
17
+ if ((err as any).cause !== undefined && !nested) return isUnauthenticated((err as any).cause, true);
22
18
  return false;
23
19
  }
24
20
 
25
- export function isTimeoutOrCancelError(err: unknown, nested: boolean = false): boolean {
26
- if (err instanceof Aborted || (err as any).name == 'AbortError') return true;
21
+ export function isTimeoutError(err: unknown, nested: boolean = false): boolean {
27
22
  if ((err as any).name == 'TimeoutError') return true;
23
+ if ((err as any).name == 'RpcError' && (err as any).code == 'DEADLINE_EXCEEDED') return true;
24
+ if ((err as any).name == 'RESTError' && (err as any).status.code == Code.DEADLINE_EXCEEDED) return true;
25
+ if ((err as any).cause !== undefined && !nested) return isTimeoutError((err as any).cause, true);
26
+ return false;
27
+ }
28
+
29
+ export function isCancelError(err: unknown, nested: boolean = false): boolean {
30
+ if ((err as any).name == 'RpcError' && (err as any).code == 'CANCELLED') return true;
31
+ if ((err as any).name == 'RESTError' && (err as any).status.code == Code.CANCELLED) return true;
32
+ if ((err as any).cause !== undefined && !nested) return isCancelError((err as any).cause, true);
33
+ return false;
34
+ }
35
+
36
+ export function isAbortedError(err: unknown, nested: boolean = false): boolean {
37
+ if (err instanceof Aborted || (err as any).name == 'AbortError') return true;
28
38
  if ((err as any).code == 'ABORT_ERR') return true;
29
- if ((err as any).code == Code.ABORTED) return true;
30
- if (
31
- (err as any).name == 'RpcError'
32
- && ((err as any).code == 'CANCELLED' || (err as any).code == 'DEADLINE_EXCEEDED')
33
- )
34
- return true;
35
- if ((err as any).code == Code.CANCELLED || (err as any).code == Code.DEADLINE_EXCEEDED)
36
- return true;
37
- if ((err as any).cause !== undefined && !nested)
38
- // nested limits the depth of search
39
- return isTimeoutOrCancelError((err as any).cause, true);
39
+ if (err instanceof DOMException && err.code === DOMException.ABORT_ERR) return true; // WebSocket error
40
+ if ((err as any).name == 'RpcError' && (err as any).code == 'ABORTED') return true;
41
+ if ((err as any).name == 'RESTError' && (err as any).status.code == Code.ABORTED) return true;
42
+ if ((err as any).cause !== undefined && !nested) isAbortedError((err as any).cause, true);
40
43
  return false;
41
44
  }
42
45
 
43
- export const PlErrorCodeNotFound = 5;
46
+ export function isTimeoutOrCancelError(err: unknown, nested: boolean = false): boolean {
47
+ if (isAbortedError(err, true)) return true;
48
+ if (isTimeoutError(err, true)) return true;
49
+ if (isCancelError(err, true)) return true;
50
+ if ((err as any).cause !== undefined && !nested) return isTimeoutOrCancelError((err as any).cause, true);
51
+ return false;
52
+ }
53
+
54
+ export function isNotFoundError(err: unknown, nested: boolean = false): boolean {
55
+ if ((err as any).name == 'RpcError' && (err as any).code == 'NOT_FOUND') return true;
56
+ if ((err as any).name == 'RESTError' && (err as any).status.code == Code.NOT_FOUND) return true;
57
+ if ((err as any).cause !== undefined && !nested) return isNotFoundError((err as any).cause, true);
58
+ return err instanceof RecoverablePlError && err.status.code === PlErrorCodeNotFound;
59
+ }
60
+
61
+ export const PlErrorCodeNotFound: number = Code.NOT_FOUND;
44
62
 
45
63
  export class PlError extends Error {
46
64
  name = 'PlError';
47
- constructor(public readonly status: Status) {
48
- super(`code=${status.code} ${status.message}`);
65
+ constructor(public readonly status: Status, opts?: ErrorOptions) {
66
+ super(`code=${status.code} ${status.message}`, opts);
49
67
  }
50
68
  }
51
69
 
@@ -67,12 +85,6 @@ export class UnrecoverablePlError extends PlError {
67
85
  }
68
86
  }
69
87
 
70
- export function isNotFoundError(err: unknown, nested: boolean = false): boolean {
71
- if ((err as any).name == 'RpcError' && (err as any).code == 'NOT_FOUND') return true;
72
- if ((err as any).cause !== undefined && !nested) return isNotFoundError((err as any).cause, true);
73
- return err instanceof RecoverablePlError && err.status.code === PlErrorCodeNotFound;
74
- }
75
-
76
88
  export class UnauthenticatedError extends Error {
77
89
  name = 'UnauthenticatedError';
78
90
  constructor(message: string) {
@@ -87,10 +99,25 @@ export class DisconnectedError extends Error {
87
99
  }
88
100
  }
89
101
 
102
+ export class RESTError extends PlError {
103
+ name = 'RESTError';
104
+ constructor(status: Status, opts?: ErrorOptions) {
105
+ super(status, opts);
106
+ }
107
+ }
108
+
90
109
  export function rethrowMeaningfulError(error: any, wrapIfUnknown: boolean = false): never {
91
- if (isUnauthenticated(error)) throw new UnauthenticatedError(error.message);
92
- if (isConnectionProblem(error)) throw new DisconnectedError(error.message);
110
+ if (isUnauthenticated(error)) {
111
+ if (error instanceof UnauthenticatedError) throw error;
112
+ throw new UnauthenticatedError(error.message);
113
+ }
114
+ if (isConnectionProblem(error)) {
115
+ if (error instanceof DisconnectedError) throw error;
116
+ throw new DisconnectedError(error.message);
117
+ }
93
118
  if (isTimeoutOrCancelError(error)) throw new Aborted(error);
94
- if (wrapIfUnknown) throw new Error(error.message, { cause: error });
95
- else throw error;
119
+ if (wrapIfUnknown) {
120
+ const message = error.message || String(error) || 'Unknown error';
121
+ throw new Error(message, { cause: error });
122
+ } else throw error;
96
123
  }
@@ -1,5 +1,5 @@
1
1
  import { LLPlClient } from './ll_client';
2
- import { getTestConfig, getTestLLClient, getTestClientConf } from '../test/test_config';
2
+ import { getTestConfig, plAddressToTestConfig, getTestLLClient, getTestClientConf } from '../test/test_config';
3
3
  import { TxAPI_Open_Request_WritableTx } from '../proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api';
4
4
  import { request } from 'undici';
5
5
  import * as tp from 'node:timers/promises';
@@ -7,6 +7,15 @@ import { test, expect } from 'vitest';
7
7
 
8
8
  import { UnauthenticatedError } from './errors';
9
9
 
10
+ test('wire protocol detection', async () => {
11
+ const { conf, auth } = await getTestClientConf();
12
+ const expectedWireProtocol = conf.wireProtocol ?? 'grpc';
13
+ conf.wireProtocol = undefined;
14
+
15
+ const client = await LLPlClient.build(conf, { auth });
16
+ expect(client.wireProtocol).toBe(expectedWireProtocol);
17
+ });
18
+
10
19
  test('authenticated instance test', async () => {
11
20
  const client = await getTestLLClient();
12
21
  const tx = client.createTx(true);
@@ -29,7 +38,7 @@ test('unauthenticated status change', async () => {
29
38
  return;
30
39
  }
31
40
 
32
- const client = new LLPlClient(cfg.address);
41
+ const client = await LLPlClient.build(plAddressToTestConfig(cfg.address));
33
42
  expect(client.status).toBe('OK');
34
43
 
35
44
  const tx = client.createTx(true);
@@ -54,10 +63,16 @@ test('unauthenticated status change', async () => {
54
63
  });
55
64
 
56
65
  test('automatic token update', async () => {
66
+ const cfg = getTestConfig();
67
+ if (cfg.test_password === undefined) {
68
+ console.log("skipping test because target server doesn't support authentication");
69
+ return;
70
+ }
71
+
57
72
  const { conf, auth } = await getTestClientConf();
58
73
  conf.authMaxRefreshSeconds = 1;
59
74
  let numberOfAuthUpdates = 0;
60
- const client = new LLPlClient(conf, {
75
+ const client = await LLPlClient.build(conf, {
61
76
  auth: {
62
77
  authInformation: auth.authInformation,
63
78
  onUpdate: (auth) => {
@@ -26,7 +26,7 @@ import type { WireClientProvider, WireClientProviderFactory, WireConnection } fr
26
26
  import { parseHttpAuth } from '@milaboratories/pl-model-common';
27
27
  import type * as grpcTypes from '../proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api';
28
28
  import { type PlApiPaths, type PlRestClientType, createClient, parseResponseError } from '../proto-rest';
29
- import { notEmpty } from '@milaboratories/ts-helpers';
29
+ import { notEmpty, retry, withTimeout, type RetryOptions } from '@milaboratories/ts-helpers';
30
30
  import { Code } from '../proto-grpc/google/rpc/code';
31
31
  import { WebSocketBiDiStream } from './websocket_stream';
32
32
  import { TxAPI_ClientMessage, TxAPI_ServerMessage } from '../proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api';
@@ -54,8 +54,6 @@ class WireClientProviderImpl<Client> implements WireClientProvider<Client> {
54
54
 
55
55
  /** Abstract out low level networking and authorization details */
56
56
  export class LLPlClient implements WireClientProviderFactory {
57
- public readonly conf: PlClientConfig;
58
-
59
57
  /** Initial authorization information */
60
58
  private authInformation?: AuthInformation;
61
59
  /** Will be executed by the client when it is required */
@@ -70,7 +68,7 @@ export class LLPlClient implements WireClientProviderFactory {
70
68
  private _status: PlConnectionStatus = 'OK';
71
69
  private readonly statusListener?: PlConnectionStatusListener;
72
70
 
73
- private _wireProto: wireProtocol | undefined = undefined;
71
+ private _wireProto: wireProtocol = 'grpc';
74
72
  private _wireConn!: WireConnection;
75
73
 
76
74
  private readonly _restInterceptors: Dispatcher.DispatcherComposeInterceptor[];
@@ -82,18 +80,29 @@ export class LLPlClient implements WireClientProviderFactory {
82
80
 
83
81
  public readonly httpDispatcher: Dispatcher;
84
82
 
85
- constructor(
83
+ public static async build(
86
84
  configOrAddress: PlClientConfig | string,
87
- private readonly ops: {
85
+ ops: {
88
86
  auth?: AuthOps;
89
87
  statusListener?: PlConnectionStatusListener;
90
88
  shouldUseGzip?: boolean;
91
89
  } = {},
92
90
  ) {
93
- this.conf = typeof configOrAddress === 'string'
94
- ? plAddressToConfig(configOrAddress)
95
- : configOrAddress;
91
+ const conf = typeof configOrAddress === 'string' ? plAddressToConfig(configOrAddress) : configOrAddress;
92
+
93
+ const pl = new LLPlClient(conf, ops);
94
+ await pl.detectOptimalWireProtocol();
95
+ return pl;
96
+ }
96
97
 
98
+ private constructor(
99
+ public readonly conf: PlClientConfig,
100
+ private readonly ops: {
101
+ auth?: AuthOps;
102
+ statusListener?: PlConnectionStatusListener;
103
+ shouldUseGzip?: boolean;
104
+ } = {},
105
+ ) {
97
106
  const { auth, statusListener } = ops;
98
107
 
99
108
  if (auth !== undefined) {
@@ -120,8 +129,11 @@ export class LLPlClient implements WireClientProviderFactory {
120
129
  this._grpcInterceptors.push(this.createGrpcErrorInterceptor());
121
130
 
122
131
  this.httpDispatcher = defaultHttpDispatcher(this.conf.httpProxy);
132
+ if (this.conf.wireProtocol) {
133
+ this._wireProto = this.conf.wireProtocol;
134
+ }
123
135
 
124
- this.initWireConnection();
136
+ this.initWireConnection(this._wireProto);
125
137
 
126
138
  if (statusListener !== undefined) {
127
139
  this.statusListener = statusListener;
@@ -142,13 +154,8 @@ export class LLPlClient implements WireClientProviderFactory {
142
154
  });
143
155
  }
144
156
 
145
- private initWireConnection() {
146
- if (this._wireProto === undefined) {
147
- // TODO: implement automatic server mode detection
148
- this._wireProto = this.conf.wireProtocol ?? 'grpc';
149
- }
150
-
151
- switch (this._wireProto) {
157
+ private initWireConnection(protocol: wireProtocol) {
158
+ switch (protocol) {
152
159
  case 'rest':
153
160
  this.initRestConnection();
154
161
  return;
@@ -158,7 +165,7 @@ export class LLPlClient implements WireClientProviderFactory {
158
165
  default:
159
166
  ((v: never) => {
160
167
  throw new Error(`Unsupported wire protocol '${v as string}'. Use one of: ${SUPPORTED_WIRE_PROTOCOLS.join(', ')}`);
161
- })(this._wireProto);
168
+ })(protocol);
162
169
  }
163
170
  }
164
171
 
@@ -221,6 +228,7 @@ export class LLPlClient implements WireClientProviderFactory {
221
228
  private _replaceWireConnection(newConn: WireConnection): void {
222
229
  const oldConn = this._wireConn;
223
230
  this._wireConn = newConn;
231
+ this._wireProto = newConn.type;
224
232
 
225
233
  // Reset all providers to let them reinitialize their clients
226
234
  for (let i = 0; i < this.providers.length; i++) {
@@ -269,6 +277,10 @@ export class LLPlClient implements WireClientProviderFactory {
269
277
  return this._wireConn;
270
278
  }
271
279
 
280
+ public get wireProtocol(): wireProtocol | undefined {
281
+ return this._wireProto;
282
+ }
283
+
272
284
  /** Returns true if client is authenticated. Even with anonymous auth information
273
285
  * connection is considered authenticated. Unauthenticated clients are used for
274
286
  * login and similar tasks, see {@link UnauthenticatedPlClient}. */
@@ -431,7 +443,7 @@ export class LLPlClient implements WireClientProviderFactory {
431
443
  body: { expiration: `${ttlSeconds}s` },
432
444
  headers,
433
445
  });
434
- return notEmpty((await resp).data).token;
446
+ return notEmpty((await resp).data, 'REST: empty response for JWT token request').token;
435
447
  }
436
448
  }
437
449
 
@@ -440,8 +452,37 @@ export class LLPlClient implements WireClientProviderFactory {
440
452
  if (cl instanceof GrpcPlApiClient) {
441
453
  return (await cl.ping({})).response;
442
454
  } else {
443
- return notEmpty((await cl.GET('/v1/ping')).data);
455
+ return notEmpty((await cl.GET('/v1/ping')).data, 'REST: empty response for ping request');
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Detects the best available wire protocol.
461
+ * If wireProtocol is explicitly configured, does nothing.
462
+ * Otherwise probes the current protocol via ping; if it fails, switches to the alternative.
463
+ */
464
+ private async detectOptimalWireProtocol() {
465
+ if (this.conf.wireProtocol) {
466
+ return;
444
467
  }
468
+
469
+ const retryOptions: RetryOptions = {
470
+ type: 'exponentialBackoff',
471
+ maxAttempts: 80,
472
+ initialDelay: 30,
473
+ backoffMultiplier: 1.3,
474
+ jitter: 0.2,
475
+ maxDelay: 500,
476
+ };
477
+
478
+ await retry(
479
+ () => withTimeout(this.ping(), 500),
480
+ retryOptions,
481
+ () => {
482
+ const protocol = this._wireProto === 'grpc' ? 'rest' : 'grpc';
483
+ this.initWireConnection(protocol);
484
+ return true;
485
+ });
445
486
  }
446
487
 
447
488
  public async license(): Promise<grpcTypes.MaintenanceAPI_License_Response> {
@@ -449,7 +490,7 @@ export class LLPlClient implements WireClientProviderFactory {
449
490
  if (cl instanceof GrpcPlApiClient) {
450
491
  return (await cl.license({})).response;
451
492
  } else {
452
- const resp = notEmpty((await cl.GET('/v1/license')).data);
493
+ const resp = notEmpty((await cl.GET('/v1/license')).data, 'REST: empty response for license request');
453
494
  return {
454
495
  status: resp.status,
455
496
  isOk: resp.isOk,
@@ -463,7 +504,7 @@ export class LLPlClient implements WireClientProviderFactory {
463
504
  if (cl instanceof GrpcPlApiClient) {
464
505
  return (await cl.authMethods({})).response;
465
506
  } else {
466
- return notEmpty((await cl.GET('/v1/auth/methods')).data);
507
+ return notEmpty((await cl.GET('/v1/auth/methods')).data, 'REST: empty response for auth methods request');
467
508
  }
468
509
  }
469
510
 
@@ -1,16 +1,16 @@
1
1
  import { UnauthenticatedPlClient } from './unauth_client';
2
- import { getTestConfig } from '../test/test_config';
2
+ import { getTestConfig, plAddressToTestConfig } from '../test/test_config';
3
3
  import { UnauthenticatedError } from './errors';
4
4
  import { test, expect } from 'vitest';
5
5
 
6
6
  test('ping test', async () => {
7
- const client = new UnauthenticatedPlClient(getTestConfig().address);
7
+ const client = await UnauthenticatedPlClient.build(plAddressToTestConfig(getTestConfig().address));
8
8
  const response = await client.ping();
9
9
  expect(response).toHaveProperty('coreVersion');
10
10
  });
11
11
 
12
12
  test('get auth methods', async () => {
13
- const client = new UnauthenticatedPlClient(getTestConfig().address);
13
+ const client = await UnauthenticatedPlClient.build(plAddressToTestConfig(getTestConfig().address));
14
14
  const response = await client.authMethods();
15
15
  expect(response).toHaveProperty('methods');
16
16
  });
@@ -21,7 +21,7 @@ test('wrong login', async () => {
21
21
  console.log('skipped');
22
22
  return;
23
23
  }
24
- const client = new UnauthenticatedPlClient(testConfig.address);
24
+ const client = await UnauthenticatedPlClient.build(plAddressToTestConfig(testConfig.address));
25
25
  await expect(client.login(testConfig.test_user, testConfig.test_password + 'A')).rejects.toThrow(
26
26
  UnauthenticatedError
27
27
  );
@@ -11,8 +11,13 @@ import { UnauthenticatedError } from './errors';
11
11
  export class UnauthenticatedPlClient {
12
12
  public readonly ll: LLPlClient;
13
13
 
14
- constructor(configOrAddress: PlClientConfig | string) {
15
- this.ll = new LLPlClient(configOrAddress);
14
+ private constructor(ll: LLPlClient) {
15
+ this.ll = ll;
16
+ }
17
+
18
+ public static async build(configOrAddress: PlClientConfig | string): Promise<UnauthenticatedPlClient> {
19
+ const ll = await LLPlClient.build(configOrAddress);
20
+ return new UnauthenticatedPlClient(ll);
16
21
  }
17
22
 
18
23
  public async ping(): Promise<MaintenanceAPI_Ping_Response> {
@@ -3,6 +3,7 @@ import type { BiDiStream } from './abstract_stream';
3
3
  import Denque from 'denque';
4
4
  import type { RetryConfig } from '../helpers/retry_strategy';
5
5
  import { RetryStrategy } from '../helpers/retry_strategy';
6
+ import { DisconnectedError } from './errors';
6
7
 
7
8
  interface QueuedMessage<InType extends object> {
8
9
  message: InType;
@@ -183,6 +184,18 @@ export class WebSocketBiDiStream<ClientMsg extends object, ServerMsg extends obj
183
184
  private onClose(): void {
184
185
  this.progressConnectionState(ConnectionState.CLOSED);
185
186
 
187
+ // If abort signal was triggered, use that as the error source
188
+ if (this.options.abortSignal?.aborted && !this.lastError) {
189
+ const reason = this.options.abortSignal.reason;
190
+ if (reason instanceof Error) {
191
+ this.lastError = reason;
192
+ } else if (reason !== undefined) {
193
+ this.lastError = new Error(String(reason), { cause: reason });
194
+ } else {
195
+ this.lastError = this.createStreamClosedError();
196
+ }
197
+ }
198
+
186
199
  if (!this.lastError) {
187
200
  this.rejectAllSendOperations(this.createStreamClosedError());
188
201
  this.resolveAllPendingResponses(); // unblock active async iterator
@@ -380,7 +393,15 @@ export class WebSocketBiDiStream<ClientMsg extends object, ServerMsg extends obj
380
393
 
381
394
  private toError(error: unknown): Error {
382
395
  if (error instanceof Error) return error;
383
- if (error instanceof ErrorEvent) return error.error;
396
+ if (error instanceof ErrorEvent) {
397
+ const err = error.error;
398
+ // undici WebSocket throws TypeError with empty message on socket close
399
+ // (e.g., when connection is lost or server disconnects)
400
+ if (err instanceof TypeError && !err.message) {
401
+ return new DisconnectedError('WebSocket connection closed unexpectedly');
402
+ }
403
+ return err instanceof Error ? err : new Error('WebSocket error', { cause: error });
404
+ }
384
405
  return new Error(String(error));
385
406
  }
386
407
 
@@ -7,7 +7,7 @@
7
7
  import type { paths as PlApiPaths } from './plapi';
8
8
  import { default as createOpenApiClient, type Middleware, type Client } from 'openapi-fetch';
9
9
  import { Dispatcher, fetch as undiciFetch } from 'undici';
10
- import { rethrowMeaningfulError } from '../core/errors';
10
+ import { RESTError, rethrowMeaningfulError } from '../core/errors';
11
11
  import { Code } from '../proto-grpc/google/rpc/code';
12
12
 
13
13
  export { PlApiPaths };
@@ -92,7 +92,7 @@ function errorHandlerMiddleware(): Middleware {
92
92
  throw new Error(respErr.error);
93
93
  }
94
94
 
95
- rethrowMeaningfulError(respErr.error);
95
+ rethrowMeaningfulError(new RESTError(respErr.error));
96
96
  },
97
97
  };
98
98
  }