@truenas/api-client 1.0.0

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.
package/dist/index.js ADDED
@@ -0,0 +1,1538 @@
1
+ import { switchMap, map, toArray, concat, from, defer, throwError, firstValueFrom, BehaviorSubject, Subject, distinctUntilChanged, of, filter, take, catchError as catchError$1, retry, startWith, shareReplay as shareReplay$1, takeUntil, combineLatest, interval, Observable, timeout, race, tap, share, takeWhile, merge, finalize } from 'rxjs';
2
+ import { webSocket } from 'rxjs/webSocket';
3
+ import { map as map$1, catchError, shareReplay } from 'rxjs/operators';
4
+
5
+ // src/factory.ts
6
+
7
+ // src/enums/truenas-endpoint.enum.ts
8
+ var TrueNasEndpoint = /* @__PURE__ */ ((TrueNasEndpoint2) => {
9
+ TrueNasEndpoint2["UpdateStatus"] = "update.status";
10
+ TrueNasEndpoint2["AppQuery"] = "app.query";
11
+ TrueNasEndpoint2["AppStart"] = "app.start";
12
+ TrueNasEndpoint2["AppStop"] = "app.stop";
13
+ TrueNasEndpoint2["AppRedeploy"] = "app.redeploy";
14
+ TrueNasEndpoint2["AppUpgrade"] = "app.upgrade";
15
+ TrueNasEndpoint2["CoreSubscribe"] = "core.subscribe";
16
+ TrueNasEndpoint2["ReplicationQuery"] = "replication.query";
17
+ TrueNasEndpoint2["ReplicationRun"] = "replication.run";
18
+ TrueNasEndpoint2["InterfaceQuery"] = "interface.query";
19
+ TrueNasEndpoint2["CloudBackupQuery"] = "cloud_backup.query";
20
+ TrueNasEndpoint2["CloudSyncQuery"] = "cloudsync.query";
21
+ TrueNasEndpoint2["VirtualInstanceQuery"] = "virt.instance.query";
22
+ TrueNasEndpoint2["VirtualInstanceStop"] = "virt.instance.stop";
23
+ TrueNasEndpoint2["VirtualInstanceStart"] = "virt.instance.start";
24
+ TrueNasEndpoint2["VirtualInstanceRestart"] = "virt.instance.restart";
25
+ TrueNasEndpoint2["ContainerQuery"] = "container.query";
26
+ TrueNasEndpoint2["ContainerStart"] = "container.start";
27
+ TrueNasEndpoint2["ContainerStop"] = "container.stop";
28
+ TrueNasEndpoint2["VmQuery"] = "vm.query";
29
+ TrueNasEndpoint2["VmStart"] = "vm.start";
30
+ TrueNasEndpoint2["VmStop"] = "vm.stop";
31
+ TrueNasEndpoint2["VmRestart"] = "vm.restart";
32
+ TrueNasEndpoint2["CoreDownload"] = "core.download";
33
+ TrueNasEndpoint2["CoreGetJobs"] = "core.get_jobs";
34
+ TrueNasEndpoint2["SystemInfo"] = "system.info";
35
+ TrueNasEndpoint2["SystemProductType"] = "system.product_type";
36
+ TrueNasEndpoint2["ExtendedSystemInfo"] = "webui.main.dashboard.sys_info";
37
+ TrueNasEndpoint2["DiskQuery"] = "disk.query";
38
+ TrueNasEndpoint2["DeviceGetInfo"] = "device.get_info";
39
+ TrueNasEndpoint2["CorePing"] = "core.ping";
40
+ TrueNasEndpoint2["GenerateToken"] = "auth.generate_token";
41
+ TrueNasEndpoint2["AlertList"] = "alert.list";
42
+ TrueNasEndpoint2["AuthLogin"] = "auth.login_ex";
43
+ TrueNasEndpoint2["AuthLogout"] = "auth.logout";
44
+ TrueNasEndpoint2["ApiKeyQuery"] = "api_key.query";
45
+ TrueNasEndpoint2["ApiKeyCreate"] = "api_key.create";
46
+ TrueNasEndpoint2["ApiKeyDelete"] = "api_key.delete";
47
+ TrueNasEndpoint2["FailOverStatus"] = "failover.status";
48
+ TrueNasEndpoint2["FailOverNode"] = "failover.node";
49
+ TrueNasEndpoint2["FailOverDisabledReasons"] = "failover.disabled.reasons";
50
+ TrueNasEndpoint2["PoolQuery"] = "pool.query";
51
+ TrueNasEndpoint2["DatasetQuery"] = "pool.dataset.query";
52
+ TrueNasEndpoint2["DatasetCreate"] = "pool.dataset.create";
53
+ TrueNasEndpoint2["DatasetDelete"] = "pool.dataset.delete";
54
+ TrueNasEndpoint2["SnapshotTaskCreate"] = "pool.snapshottask.create";
55
+ TrueNasEndpoint2["SnapshotTaskQuery"] = "pool.snapshottask.query";
56
+ TrueNasEndpoint2["SnapshotTaskDelete"] = "pool.snapshottask.delete";
57
+ TrueNasEndpoint2["SnapshotCreate"] = "pool.snapshot.create";
58
+ TrueNasEndpoint2["SnapshotDelete"] = "pool.snapshot.delete";
59
+ TrueNasEndpoint2["ReplicationCountEligibleSnapshots"] = "replication.count_eligible_manual_snapshots";
60
+ TrueNasEndpoint2["BootGetState"] = "boot.get_state";
61
+ TrueNasEndpoint2["TnConnectUpdate"] = "tn_connect.update";
62
+ TrueNasEndpoint2["HardwareVirtualizationVariant"] = "hardware.virtualization.variant";
63
+ TrueNasEndpoint2["KeychainCredentialGenerateSshKeyPair"] = "keychaincredential.generate_ssh_key_pair";
64
+ TrueNasEndpoint2["KeychainCredentialCreate"] = "keychaincredential.create";
65
+ TrueNasEndpoint2["KeychainCredentialRemoteSshHostKeyScan"] = "keychaincredential.remote_ssh_host_key_scan";
66
+ TrueNasEndpoint2["KeychainCredentialDelete"] = "keychaincredential.delete";
67
+ TrueNasEndpoint2["ReplicationCreate"] = "replication.create";
68
+ TrueNasEndpoint2["UserQuery"] = "user.query";
69
+ TrueNasEndpoint2["UserUpdate"] = "user.update";
70
+ TrueNasEndpoint2["NetworkConfiguration"] = "network.configuration.config";
71
+ TrueNasEndpoint2["ServiceQuery"] = "service.query";
72
+ TrueNasEndpoint2["ServiceUpdate"] = "service.update";
73
+ TrueNasEndpoint2["ServiceControl"] = "service.control";
74
+ return TrueNasEndpoint2;
75
+ })(TrueNasEndpoint || {});
76
+
77
+ // src/types/api-error.type.ts
78
+ function isJsonRpcError(error) {
79
+ return typeof error === "object" && error !== null && "message" in error && typeof error.message === "string";
80
+ }
81
+ function isTrueNasError(error) {
82
+ return typeof error === "object" && error !== null && "reason" in error && typeof error.reason === "string";
83
+ }
84
+ function getApiErrorMessage(error, fallback = "API call failed") {
85
+ if (isJsonRpcError(error) && error.data && isTrueNasError(error.data)) {
86
+ return error.data.reason;
87
+ }
88
+ if (isTrueNasError(error)) {
89
+ return error.reason;
90
+ }
91
+ if (isJsonRpcError(error)) {
92
+ return error.message;
93
+ }
94
+ return fallback;
95
+ }
96
+
97
+ // src/types/job.type.ts
98
+ var JobState = /* @__PURE__ */ ((JobState2) => {
99
+ JobState2["Pending"] = "PENDING";
100
+ JobState2["Running"] = "RUNNING";
101
+ JobState2["Hold"] = "HOLD";
102
+ JobState2["Error"] = "ERROR";
103
+ JobState2["Failed"] = "FAILED";
104
+ JobState2["Aborted"] = "ABORTED";
105
+ JobState2["Success"] = "SUCCESS";
106
+ JobState2["Finished"] = "FINISHED";
107
+ JobState2["Locked"] = "LOCKED";
108
+ JobState2["Waiting"] = "WAITING";
109
+ return JobState2;
110
+ })(JobState || {});
111
+ var withId = (id) => filter((msg) => msg.id === id);
112
+ function randomUUID() {
113
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
114
+ return crypto.randomUUID();
115
+ }
116
+ if (typeof crypto === "undefined" || typeof crypto.getRandomValues !== "function") {
117
+ throw new Error("Web Crypto API unavailable: cannot generate a UUID");
118
+ }
119
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
120
+ const r = crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
121
+ const v = c === "x" ? r : r & 3 | 8;
122
+ return v.toString(16);
123
+ });
124
+ }
125
+
126
+ // src/utils/jsonrpc.utils.ts
127
+ function createJsonRpcMessage(method, params) {
128
+ return {
129
+ jsonrpc: "2.0",
130
+ id: randomUUID(),
131
+ method,
132
+ params: params ?? []
133
+ };
134
+ }
135
+
136
+ // src/api/truenas-api.ts
137
+ var TrueNasApi = class {
138
+ constructor(authenticated, connection) {
139
+ this.authenticated = authenticated;
140
+ this.connection = connection;
141
+ /**
142
+ * Stream of job events from websocket.
143
+ * JSON-RPC 2.0 events have structure: { method: 'collection_update', params: { collection, fields, ... } }
144
+ */
145
+ this.jobEvents = this.connection.messages().pipe(
146
+ filter(
147
+ (res) => res.method === "collection_update" && res.params?.collection === "core.get_jobs"
148
+ ),
149
+ map((event) => event.params.fields),
150
+ filter((job) => !!job?.id),
151
+ share()
152
+ );
153
+ this.initializeJobEventsSubscription();
154
+ }
155
+ call(method, params) {
156
+ const message = createJsonRpcMessage(method, params);
157
+ this.connection.ws.next(message);
158
+ const messageId = message.id ?? "";
159
+ return this.connection.messages().pipe(
160
+ withId(messageId),
161
+ map((msg) => {
162
+ if (msg.error) {
163
+ const errorMessage = getApiErrorMessage(msg.error, "API call failed");
164
+ throw new Error(errorMessage);
165
+ }
166
+ return msg.result;
167
+ }),
168
+ take(1)
169
+ );
170
+ }
171
+ /**
172
+ * Makes an API call and returns the job ID from the websocket event.
173
+ * Used for v26 where API calls return null but job events contain the job ID.
174
+ *
175
+ * The job ID is extracted from the first job event where message_ids contains
176
+ * the original request ID.
177
+ *
178
+ * @param method The API method to call
179
+ * @param params The parameters for the API call
180
+ * @returns Observable that emits the job ID when received from websocket events
181
+ */
182
+ callAndGetJobId(method, params) {
183
+ const message = createJsonRpcMessage(method, params);
184
+ this.connection.ws.next(message);
185
+ const requestId = message.id ?? "";
186
+ return this.jobEvents.pipe(
187
+ filter((job) => job.message_ids?.includes(requestId) ?? false),
188
+ map((job) => job.id),
189
+ take(1)
190
+ );
191
+ }
192
+ events(eventName) {
193
+ this.authenticated.pipe(filter(Boolean), take(1)).subscribe(() => {
194
+ const message = createJsonRpcMessage("core.subscribe", [eventName]);
195
+ this.connection.ws.next(message);
196
+ });
197
+ return this.connection.messages().pipe(
198
+ filter((res) => {
199
+ const params = res.params;
200
+ return res.method === "collection_update" && params?.collection === eventName && ["added", "changed", "removed"].includes(params?.msg || "") && params.fields !== void 0;
201
+ })
202
+ );
203
+ }
204
+ /**
205
+ * Convenience wrapper for auth.generate_token API call.
206
+ */
207
+ generateToken(ttl = 600, matchOrigin = false, singleUse = true) {
208
+ return this.call("auth.generate_token" /* GenerateToken */, [
209
+ ttl,
210
+ {},
211
+ matchOrigin,
212
+ singleUse
213
+ ]);
214
+ }
215
+ trackJob(jobId) {
216
+ const completedStates = [
217
+ "SUCCESS" /* Success */,
218
+ "FAILED" /* Failed */,
219
+ "ABORTED" /* Aborted */,
220
+ "ERROR" /* Error */,
221
+ "FINISHED" /* Finished */
222
+ ];
223
+ const currentJobState$ = this.call("core.get_jobs", [
224
+ [["id", "=", jobId]]
225
+ ]).pipe(
226
+ map((jobs) => jobs[0]),
227
+ filter((job) => job !== void 0)
228
+ );
229
+ const jobUpdates$ = this.jobEvents.pipe(
230
+ filter((job) => job.id === jobId),
231
+ takeWhile((job) => !completedStates.includes(job.state), true)
232
+ // Include the final state
233
+ );
234
+ return currentJobState$.pipe(
235
+ switchMap((currentJob) => {
236
+ if (completedStates.includes(currentJob.state)) {
237
+ return of(currentJob);
238
+ }
239
+ return merge(of(currentJob), jobUpdates$);
240
+ })
241
+ );
242
+ }
243
+ initializeJobEventsSubscription() {
244
+ this.authenticated.pipe(filter(Boolean), take(1)).subscribe(() => {
245
+ const message = createJsonRpcMessage("core.subscribe", ["core.get_jobs"]);
246
+ this.connection.ws.next(message);
247
+ });
248
+ }
249
+ };
250
+
251
+ // src/enums/truenas-auth-mechanism.enum.ts
252
+ var TrueNasAuthMechanism = /* @__PURE__ */ ((TrueNasAuthMechanism2) => {
253
+ TrueNasAuthMechanism2["ApiKey"] = "API_KEY_PLAIN";
254
+ TrueNasAuthMechanism2["Password"] = "PASSWORD_PLAIN";
255
+ TrueNasAuthMechanism2["Otp"] = "OTP_TOKEN";
256
+ TrueNasAuthMechanism2["Token"] = "TOKEN_PLAIN";
257
+ return TrueNasAuthMechanism2;
258
+ })(TrueNasAuthMechanism || {});
259
+
260
+ // src/errors/auth.errors.ts
261
+ var AuthErrorCode = /* @__PURE__ */ ((AuthErrorCode2) => {
262
+ AuthErrorCode2["PasswordAuthFailed"] = "PASSWORD_AUTH_FAILED";
263
+ AuthErrorCode2["OtpAuthFailed"] = "OTP_AUTH_FAILED";
264
+ AuthErrorCode2["ApiKeyAuthFailed"] = "API_KEY_AUTH_FAILED";
265
+ AuthErrorCode2["FullAdminRequired"] = "FULL_ADMIN_REQUIRED";
266
+ return AuthErrorCode2;
267
+ })(AuthErrorCode || {});
268
+ var AuthError = class _AuthError extends Error {
269
+ constructor(code, message) {
270
+ super(message);
271
+ this.code = code;
272
+ this.name = "AuthError";
273
+ Object.setPrototypeOf(this, _AuthError.prototype);
274
+ }
275
+ };
276
+
277
+ // src/auth/truenas-authenticator.ts
278
+ var throwOnAuthenticationFailure = (code, message) => switchMap((response) => {
279
+ if (response.response_type === "AUTH_ERR" /* AuthErr */) {
280
+ return throwError(() => new AuthError(code, message));
281
+ }
282
+ return of(response);
283
+ });
284
+ var TrueNasAuthenticator = class _TrueNasAuthenticator {
285
+ constructor(connection) {
286
+ this.connection = connection;
287
+ // in seconds (5 minutes)
288
+ /**
289
+ * whether or not the system is currently authenticated and accessible.
290
+ * this is a `BehaviorSubject` instead of a signal, since we use its `getValue` method
291
+ * throughout a lot of the codebase.
292
+ */
293
+ this.authenticated$ = new BehaviorSubject(false);
294
+ /**
295
+ * `true` when the system is currently being authenticated to, and `false` otherwise.
296
+ * does not indicate whatsoever if authentication is successful or not, just whether the process
297
+ * is ongoing.
298
+ */
299
+ this.authenticating$ = new BehaviorSubject(false);
300
+ this.credentials = { username: "", password: "", key: "" };
301
+ this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
302
+ this.connection.opened.pipe(
303
+ filter(
304
+ (isOpen) => !!(isOpen && this.credentials?.username && (this.credentials.password || this.credentials.key))
305
+ ),
306
+ switchMap(() => {
307
+ if (this.credentials.password) {
308
+ return this.loginWithUserPass(
309
+ this.credentials.username,
310
+ this.credentials.password
311
+ );
312
+ }
313
+ return this.loginWithApiKey({
314
+ username: this.credentials.username,
315
+ key: this.credentials.key
316
+ });
317
+ })
318
+ ).subscribe();
319
+ connection.closed.subscribe(() => {
320
+ this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
321
+ this.authenticated$.next(false);
322
+ });
323
+ }
324
+ static {
325
+ this.DefaultSessionLifetime = 300;
326
+ }
327
+ loginWithUserPass(username, password) {
328
+ const message = createJsonRpcMessage("auth.login_ex", [
329
+ {
330
+ mechanism: "PASSWORD_PLAIN" /* Password */,
331
+ username,
332
+ password
333
+ }
334
+ ]);
335
+ this.authenticating$.next(true);
336
+ this.connection.send(message);
337
+ const messageId = message.id ?? "";
338
+ return this.connection.messages().pipe(
339
+ withId(messageId),
340
+ map((msg) => {
341
+ if (msg.error) {
342
+ const errorMessage = getApiErrorMessage(
343
+ msg.error,
344
+ "Authentication failed"
345
+ );
346
+ throw new Error(errorMessage);
347
+ }
348
+ return msg.result;
349
+ }),
350
+ throwOnAuthenticationFailure(
351
+ "PASSWORD_AUTH_FAILED" /* PasswordAuthFailed */,
352
+ "TrueNAS authentication failed. Please verify your TrueNAS user credentials and try again."
353
+ ),
354
+ tap((res) => {
355
+ if (res.response_type === "SUCCESS" /* Success */) {
356
+ if (res.user_info?.privilege.roles.$set.includes("FULL_ADMIN" /* FullAdmin */)) {
357
+ this.credentials.username = username;
358
+ this.credentials.password = password;
359
+ this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
360
+ this.authenticated$.next(true);
361
+ } else {
362
+ this.logout();
363
+ this.authenticated$.next(false);
364
+ throw new AuthError(
365
+ "FULL_ADMIN_REQUIRED" /* FullAdminRequired */,
366
+ "User account must have full admin privileges"
367
+ );
368
+ }
369
+ }
370
+ }),
371
+ finalize(() => {
372
+ this.authenticating$.next(false);
373
+ }),
374
+ take(1)
375
+ );
376
+ }
377
+ loginWithOtp(code) {
378
+ const message = createJsonRpcMessage("auth.login_ex", [
379
+ {
380
+ mechanism: "OTP_TOKEN" /* Otp */,
381
+ otp_token: code
382
+ }
383
+ ]);
384
+ this.authenticating$.next(true);
385
+ this.connection.send(message);
386
+ const messageId = message.id ?? "";
387
+ return this.connection.messages().pipe(
388
+ withId(messageId),
389
+ map((msg) => {
390
+ if (msg.error) {
391
+ const errorMessage = getApiErrorMessage(
392
+ msg.error,
393
+ "Authentication failed"
394
+ );
395
+ throw new Error(errorMessage);
396
+ }
397
+ return msg.result;
398
+ }),
399
+ tap((res) => {
400
+ if (res?.response_type === "SUCCESS" /* Success */) {
401
+ this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
402
+ this.authenticated$.next(true);
403
+ }
404
+ }),
405
+ throwOnAuthenticationFailure(
406
+ "OTP_AUTH_FAILED" /* OtpAuthFailed */,
407
+ "TrueNAS authentication failed. Please verify your one-time passcode and try again."
408
+ ),
409
+ finalize(() => {
410
+ this.authenticating$.next(false);
411
+ }),
412
+ take(1)
413
+ );
414
+ }
415
+ loginWithApiKey(credentials) {
416
+ const { username, key } = credentials;
417
+ const message = createJsonRpcMessage("auth.login_ex", [
418
+ {
419
+ mechanism: "API_KEY_PLAIN" /* ApiKey */,
420
+ username,
421
+ api_key: key
422
+ }
423
+ ]);
424
+ this.authenticating$.next(true);
425
+ this.connection.send(message);
426
+ const messageId = message.id ?? "";
427
+ return this.connection.messages().pipe(
428
+ withId(messageId),
429
+ map((msg) => {
430
+ if (msg.error) {
431
+ const errorMessage = getApiErrorMessage(
432
+ msg.error,
433
+ "Authentication failed"
434
+ );
435
+ throw new Error(errorMessage);
436
+ }
437
+ return msg.result;
438
+ }),
439
+ throwOnAuthenticationFailure(
440
+ "API_KEY_AUTH_FAILED" /* ApiKeyAuthFailed */,
441
+ "TrueNAS authentication failed. Has the TrueNAS Connect API key been removed from your TrueNAS server?"
442
+ ),
443
+ tap((res) => {
444
+ this.credentials.username = username;
445
+ this.credentials.key = key;
446
+ this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
447
+ this.authenticated$.next(true);
448
+ }),
449
+ finalize(() => {
450
+ this.authenticating$.next(false);
451
+ }),
452
+ take(1)
453
+ );
454
+ }
455
+ newApiKey(username) {
456
+ const message = createJsonRpcMessage("api_key.create", [
457
+ { name: `tnc-${randomUUID()}`, username }
458
+ ]);
459
+ this.connection.send(message);
460
+ const messageId = message.id ?? "";
461
+ return this.connection.messages().pipe(
462
+ withId(messageId),
463
+ map((msg) => {
464
+ if (msg.error) {
465
+ const errorMessage = getApiErrorMessage(
466
+ msg.error,
467
+ "Failed to create API key"
468
+ );
469
+ throw new Error(errorMessage);
470
+ }
471
+ return msg.result;
472
+ }),
473
+ take(1)
474
+ );
475
+ }
476
+ logout() {
477
+ const message = createJsonRpcMessage("auth.logout");
478
+ this.connection.send(message);
479
+ const messageId = message.id ?? "";
480
+ return this.connection.messages().pipe(
481
+ withId(messageId),
482
+ map((msg) => {
483
+ if (msg.error) {
484
+ return false;
485
+ }
486
+ return msg.result;
487
+ }),
488
+ tap((success) => {
489
+ this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
490
+ this.authenticated$.next(!success);
491
+ }),
492
+ take(1)
493
+ );
494
+ }
495
+ };
496
+
497
+ // src/logger.ts
498
+ var consoleLogger = {
499
+ trace: (message, ...args) => console.trace(message, ...args),
500
+ debug: (message, ...args) => console.debug(message, ...args),
501
+ info: (message, ...args) => console.info(message, ...args),
502
+ warn: (message, ...args) => console.warn(message, ...args),
503
+ error: (message, ...args) => console.error(message, ...args)
504
+ };
505
+ var noop = () => void 0;
506
+ var noopLogger = {
507
+ trace: noop,
508
+ debug: noop,
509
+ info: noop,
510
+ warn: noop,
511
+ error: noop
512
+ };
513
+
514
+ // src/utils/truenas-connection.utils.ts
515
+ function isHttpStatusError(reason) {
516
+ return ["404", "502", "503"].some((code) => reason.includes(code));
517
+ }
518
+ function getHttpError(reason) {
519
+ if (reason.includes("404")) {
520
+ return "API endpoint not found - System may not support versioned API";
521
+ }
522
+ if (reason.includes("502")) {
523
+ return "Bad Gateway - System may be starting up";
524
+ }
525
+ if (reason.includes("503")) {
526
+ return "Service Unavailable - System may be overloaded";
527
+ }
528
+ return "";
529
+ }
530
+ function getWebSocketError(code) {
531
+ switch (code) {
532
+ case 1e3:
533
+ return "Connection closed normally";
534
+ case 1001:
535
+ return "Server is shutting down or restarting";
536
+ case 1002:
537
+ return "Protocol error - Invalid WebSocket communication";
538
+ case 1003:
539
+ return "Unsupported data format received";
540
+ case 1006:
541
+ return "Connection lost unexpectedly - Check network connectivity";
542
+ case 1007:
543
+ return "Invalid data received from server";
544
+ case 1008:
545
+ return "Connection terminated due to policy violation";
546
+ case 1009:
547
+ return "Message size exceeded server limits";
548
+ case 1011:
549
+ return "Server encountered an internal error";
550
+ case 1012:
551
+ return "Service is restarting";
552
+ case 1013:
553
+ return "Server temporarily overloaded - Try again later";
554
+ case 1015:
555
+ return "TLS/Certificate error - Certificate may be expired";
556
+ default:
557
+ return "Network connection failed - Check IP address and connectivity";
558
+ }
559
+ }
560
+ var TrueNasSocket = class {
561
+ constructor(config) {
562
+ this.config = config;
563
+ this.socket = webSocket(this.config);
564
+ }
565
+ messages() {
566
+ return this.socket;
567
+ }
568
+ next(msg) {
569
+ this.socket.next(msg);
570
+ }
571
+ complete() {
572
+ this.socket.complete();
573
+ }
574
+ };
575
+
576
+ // src/connection/truenas-connection.ts
577
+ var tenSeconds = 10 * 1e3;
578
+ var twentySeconds = 20 * 1e3;
579
+ var TrueNasConnection = class {
580
+ constructor(initialEnabled, hostnames, systemUuid, websocketPath, systemName, retryDelay = tenSeconds, maxRetry = 3, logger = noopLogger) {
581
+ this.hostnames = hostnames;
582
+ this.systemUuid = systemUuid;
583
+ this.websocketPath = websocketPath;
584
+ this.systemName = systemName;
585
+ this.retryDelay = retryDelay;
586
+ this.maxRetry = maxRetry;
587
+ this.logger = logger;
588
+ // compatibility properties
589
+ this.opened = new BehaviorSubject(false);
590
+ this.closed = new Subject();
591
+ this.hostname = new BehaviorSubject("");
592
+ this.connectionAttempts = new BehaviorSubject(0);
593
+ this.lastErrorMessage = new BehaviorSubject(null);
594
+ /**
595
+ * emits when the connection is manually closed.
596
+ */
597
+ this.closeConnection = new Subject();
598
+ /**
599
+ * the connection only establishes a socket while this gate is `true`. consumers
600
+ * flip it via `setEnabled()` (the app maps `SystemState.Active -> true`).
601
+ */
602
+ this.enabled$ = new BehaviorSubject(false);
603
+ /**
604
+ * observable which emits the current gate value and only emits again when it changes.
605
+ */
606
+ this.enabledChange$ = this.enabled$.pipe(distinctUntilChanged());
607
+ /**
608
+ * observable that handles the entire connection lifecycle.
609
+ */
610
+ this.connection$ = this.enabledChange$.pipe(
611
+ switchMap((enabled) => {
612
+ if (enabled) {
613
+ return this.connect().pipe(
614
+ map((conn) => makeActiveConnection(conn.ws, conn.hostname))
615
+ );
616
+ }
617
+ return of(closedConnection);
618
+ }),
619
+ switchMap((connection) => {
620
+ if (connection.state !== "active") {
621
+ return of(connection);
622
+ }
623
+ const initMessage = createJsonRpcMessage("core.set_options", [
624
+ { legacy_jobs: false }
625
+ ]);
626
+ connection.ws.next(initMessage);
627
+ return connection.ws.messages().pipe(
628
+ filter((msg) => msg.id === initMessage.id),
629
+ take(1),
630
+ map(() => connection)
631
+ );
632
+ }),
633
+ // in the event of an error, do two things:
634
+ // 1. emit an errored connection downstream to inform consumers that
635
+ // the connection is dead.
636
+ // 2. re-throw an error so the downstream `retry` will re-subscribe.
637
+ catchError$1((err) => {
638
+ this.logger.error("All connections failed - retrying", { message: err?.message, hostname: err?.hostname });
639
+ return concat(
640
+ of(makeConnectionError(err.message, err.hostname)),
641
+ // since this error will immediately get caught by `retry` there's no reason to build a real error.
642
+ throwError(() => null)
643
+ );
644
+ }),
645
+ retry(),
646
+ // start with a closed connection.
647
+ startWith(closedConnection),
648
+ // prevent multiple subscriptions from re-evaluating the entire pipeline.
649
+ shareReplay$1({ bufferSize: 1, refCount: false }),
650
+ takeUntil(this.closeConnection)
651
+ );
652
+ /**
653
+ * observable which is either an instance of the current `TrueNasSocket` used for the connection
654
+ * or `null` if the connection is closed or errored for any reason.
655
+ */
656
+ this.ws$ = this.connection$.pipe(
657
+ map((conn) => conn.state === "active" ? conn.ws : null)
658
+ );
659
+ /**
660
+ * current hostname we used to build the URL for this connection.
661
+ */
662
+ this.hostname$ = this.connection$.pipe(
663
+ map((conn) => conn.state === "active" ? conn.hostname : null)
664
+ );
665
+ /**
666
+ * emits error messages from the connection pipeline. this is used to derive the
667
+ * corresponding compatibility property.
668
+ */
669
+ this.lastErrorMessage$ = this.connection$.pipe(
670
+ map((conn) => conn.state === "error" ? conn.message : null)
671
+ );
672
+ /**
673
+ * emits when the connection state *changes*, so it'll never emit `true` twice
674
+ * in a row nor `false` twice in a row.
675
+ */
676
+ this.opened$ = this.connection$.pipe(
677
+ map((conn) => conn.state),
678
+ distinctUntilChanged(),
679
+ map((state) => state === "active")
680
+ );
681
+ /**
682
+ * emits when the connection state goes from open to closed.
683
+ */
684
+ this.closed$ = this.connection$.pipe(
685
+ map((conn) => conn.state),
686
+ distinctUntilChanged(),
687
+ filter((state) => state === "closed" || state === "error"),
688
+ map(() => {
689
+ })
690
+ );
691
+ /**
692
+ * whether to display a connection error to the user — the **live** signal.
693
+ *
694
+ * `true` only while `connection$` is currently a `ConnectionError`, meaning the entire race +
695
+ * retry cycle has been exhausted for all hostnames right now; it flips back to `false` once a
696
+ * connection is re-established. individual socket losses during a race are expected and not surfaced.
697
+ *
698
+ * NOTE: this is NOT the same as the `hasExhaustedRetries()` method, which is a **cumulative**
699
+ * snapshot (see there). The two can disagree — prefer this observable for "is the connection
700
+ * errored right now?".
701
+ */
702
+ this.hasConnectionError$ = this.connection$.pipe(
703
+ map((conn) => conn.state === "error"),
704
+ takeUntil(this.closeConnection)
705
+ );
706
+ /**
707
+ * observable which always emits messages from the current socket.
708
+ */
709
+ this.messages$ = this.ws$.pipe(
710
+ filter((ws) => ws !== null),
711
+ switchMap((ws) => ws.messages())
712
+ );
713
+ combineLatest([
714
+ interval(twentySeconds),
715
+ this.ws$
716
+ ]).pipe(
717
+ takeUntil(this.closeConnection)
718
+ ).subscribe(([, ws]) => {
719
+ if (ws) {
720
+ const pingMessage = createJsonRpcMessage("core.ping");
721
+ ws.next(pingMessage);
722
+ }
723
+ });
724
+ this.opened$.subscribe((val) => this.opened.next(val));
725
+ this.closed$.subscribe(() => this.closed.next());
726
+ this.hostname$.pipe(filter(Boolean)).subscribe((name) => this.hostname.next(name));
727
+ this.ws$.pipe(filter(Boolean)).subscribe((ws) => this.ws = ws);
728
+ this.lastErrorMessage$.subscribe((msg) => this.lastErrorMessage.next(msg));
729
+ this.enabled$.next(initialEnabled);
730
+ }
731
+ /**
732
+ * whether the connection has exhausted its retries — the **cumulative** snapshot, read
733
+ * synchronously. (Formerly `hasConnectionError()`; renamed to disambiguate it from the
734
+ * live `hasConnectionError$` observable, with which it can disagree.)
735
+ *
736
+ * `true` when the lifetime `connectionAttempts` count exceeds `hostnames.length * maxRetry`,
737
+ * OR when an error message is currently set. Ported from the source (tncui) behavior.
738
+ *
739
+ * CAVEAT (pre-existing tncui behavior, preserved here): `connectionAttempts` only ever grows —
740
+ * it is never reset on a successful (re)connection — so over a long-lived connection with
741
+ * reconnect churn this can latch to `true` even while the connection is currently healthy. For
742
+ * "is it errored right now?" use the live `hasConnectionError$` observable instead.
743
+ */
744
+ hasExhaustedRetries() {
745
+ const attemptsExhausted = this.connectionAttempts.value > this.hostnames.length * this.maxRetry;
746
+ const hasErrorMessage = this.lastErrorMessage.value !== null;
747
+ return attemptsExhausted || hasErrorMessage;
748
+ }
749
+ /**
750
+ * enables or disables the connection gate. the app calls this when its `SystemState`
751
+ * changes (mapping `SystemState.Active -> true`, everything else -> `false`).
752
+ */
753
+ setEnabled(enabled) {
754
+ this.enabled$.next(enabled);
755
+ }
756
+ /**
757
+ * compatibility method which just returns the public `messages$` observable.
758
+ * this is used in a few places in the codebase that relied upon the old implementation.
759
+ */
760
+ messages() {
761
+ return this.messages$;
762
+ }
763
+ /**
764
+ * sends a message over the current websocket OR queues it to send once
765
+ * the next websocket is opened. messages sent via `send` will not be lost until after
766
+ * the next `closed$` emission.
767
+ */
768
+ send(message) {
769
+ return this.ws$.pipe(
770
+ filter((ws) => ws !== null),
771
+ take(1)
772
+ ).subscribe((ws) => ws.next(message));
773
+ }
774
+ /**
775
+ * manually closes this connection and prevents it from retrying/opening any more.
776
+ */
777
+ close() {
778
+ this.closeConnection.next();
779
+ this.closeConnection.complete();
780
+ }
781
+ /**
782
+ * helper method which creates an `Observable` which emits an `ActiveConnection`
783
+ * after it establishes a connection to the given hostname. the observable will emit an
784
+ * error if the connection is never established and will not complete until unsubscribed from or closed.
785
+ */
786
+ createSocket(hostname) {
787
+ const url = `wss://${hostname}${this.websocketPath}`;
788
+ return new Observable((subscriber) => {
789
+ const ws = new TrueNasSocket({
790
+ url,
791
+ openObserver: {
792
+ next: () => {
793
+ subscriber.next({ ws, hostname });
794
+ }
795
+ },
796
+ closeObserver: {
797
+ next: (event) => {
798
+ const reason = event.reason || "";
799
+ let errorMessage;
800
+ if (isHttpStatusError(reason)) {
801
+ errorMessage = getHttpError(reason);
802
+ } else {
803
+ errorMessage = getWebSocketError(event.code);
804
+ }
805
+ this.connectionAttempts.next(this.connectionAttempts.value + 1);
806
+ subscriber.error(makeConnectionError(errorMessage, hostname));
807
+ }
808
+ }
809
+ });
810
+ const startupSub = ws.socket.subscribe({
811
+ error: () => {
812
+ }
813
+ });
814
+ return () => {
815
+ startupSub.unsubscribe();
816
+ ws.complete();
817
+ };
818
+ }).pipe(
819
+ // retry logic:
820
+ // * if a connection is not established in 10 seconds, consider that an error
821
+ timeout({ first: tenSeconds }),
822
+ // * if an error happens, wait `retryDelay` before trying again.
823
+ // after `maxRetry` retries, give up.
824
+ retry({ count: this.maxRetry, delay: this.retryDelay })
825
+ );
826
+ }
827
+ /**
828
+ * helper function which actually performs the parallel connection `race`.
829
+ */
830
+ connect() {
831
+ return race(
832
+ this.hostnames.map(this.createSocket.bind(this))
833
+ ).pipe(
834
+ tap((conn) => this.logger.debug(`TrueNas socket opened to ${conn.hostname}.`)),
835
+ takeUntil(this.closeConnection)
836
+ );
837
+ }
838
+ };
839
+ var makeActiveConnection = (ws, hostname) => ({
840
+ ws,
841
+ hostname,
842
+ state: "active"
843
+ });
844
+ var makeConnectionError = (message, hostname) => ({
845
+ name: "ConnectionError",
846
+ message,
847
+ hostname,
848
+ state: "error"
849
+ });
850
+ var closedConnection = { state: "closed" };
851
+
852
+ // src/config/api-version.config.ts
853
+ var apiVersionConfig = {
854
+ /**
855
+ * Minimum supported API version.
856
+ * Systems with older API versions will be rejected.
857
+ */
858
+ MIN_SUPPORTED_VERSION: "v25.10.0",
859
+ /**
860
+ * Maximum supported API version.
861
+ * Systems with newer API versions will be rejected.
862
+ */
863
+ MAX_SUPPORTED_VERSION: "v26.0.0",
864
+ /**
865
+ * Fallback version to use when version discovery fails due to CORS/network errors.
866
+ * When /api/versions returns HTTP status 0 (CORS block, network down, etc.),
867
+ * the system will attempt to connect using this version as a best-effort fallback.
868
+ *
869
+ * WARNING: Status 0 errors have multiple causes:
870
+ * - CORS policy blocking the request
871
+ * - Network disconnected
872
+ * - DNS lookup failure
873
+ * - Server unreachable
874
+ *
875
+ * Using this fallback means systems with genuine network issues will still
876
+ * attempt connection (and fail during WebSocket handshake instead of immediately).
877
+ */
878
+ FALLBACK_VERSION: "v25.10.0"
879
+ };
880
+
881
+ // src/types/api-version.type.ts
882
+ var VersionCompatibility = /* @__PURE__ */ ((VersionCompatibility2) => {
883
+ VersionCompatibility2["Compatible"] = "compatible";
884
+ VersionCompatibility2["TooOld"] = "too-old";
885
+ VersionCompatibility2["TooNew"] = "too-new";
886
+ VersionCompatibility2["Invalid"] = "invalid";
887
+ return VersionCompatibility2;
888
+ })(VersionCompatibility || {});
889
+
890
+ // src/utils/api-version.utils.ts
891
+ var legacyCutoffYear = 25;
892
+ function parseApiVersion(versionString) {
893
+ const regex = /^v(\d{2})\.(\d+)\.(\d+)$/;
894
+ const match = versionString.match(regex);
895
+ if (!match) {
896
+ return null;
897
+ }
898
+ const [, yearStr, minorStr, patchStr] = match;
899
+ const year = parseInt(yearStr, 10);
900
+ const minor = parseInt(minorStr, 10);
901
+ const patch = parseInt(patchStr, 10);
902
+ if (year <= legacyCutoffYear) {
903
+ if (minor < 1 || minor > 12) {
904
+ return null;
905
+ }
906
+ } else if (year >= 26) {
907
+ if (minor < 0 || minor > 99) {
908
+ return null;
909
+ }
910
+ }
911
+ return {
912
+ version: versionString,
913
+ year,
914
+ minor,
915
+ patch,
916
+ websocketPath: `/api/${versionString}`
917
+ };
918
+ }
919
+ function compareVersions(a, b) {
920
+ if (a.year !== b.year) {
921
+ return a.year - b.year;
922
+ }
923
+ if (a.minor !== b.minor) {
924
+ return a.minor - b.minor;
925
+ }
926
+ return a.patch - b.patch;
927
+ }
928
+ function checkVersionCompatibility(version) {
929
+ const minVersion = parseApiVersion(apiVersionConfig.MIN_SUPPORTED_VERSION);
930
+ const maxVersion = parseApiVersion(apiVersionConfig.MAX_SUPPORTED_VERSION);
931
+ if (!minVersion || !maxVersion) {
932
+ return "invalid" /* Invalid */;
933
+ }
934
+ if (compareVersions(version, minVersion) < 0) {
935
+ return "too-old" /* TooOld */;
936
+ }
937
+ if (compareVersions(version, maxVersion) > 0) {
938
+ return "too-new" /* TooNew */;
939
+ }
940
+ return "compatible" /* Compatible */;
941
+ }
942
+ function isVersionSupported(version) {
943
+ return checkVersionCompatibility(version) === "compatible" /* Compatible */;
944
+ }
945
+ function filterCompatibleVersions(versions) {
946
+ return versions.filter(isVersionSupported);
947
+ }
948
+ function getWebSocketPath(version) {
949
+ return version.websocketPath;
950
+ }
951
+
952
+ // src/client/truenas-api-client.ts
953
+ var TrueNasApiClient = class {
954
+ constructor(uuid, hostnames, version, enabled, systemName, logger = noopLogger) {
955
+ this.uuid = uuid;
956
+ this.hostnames = hostnames;
957
+ this.version = version;
958
+ this.enabled = enabled;
959
+ this.systemName = systemName;
960
+ this.logger = logger;
961
+ this.connection = this.createConnection();
962
+ this.authenticator = this.createAuthenticator();
963
+ this.api = this.createApi();
964
+ this.ops = this.createOperations();
965
+ }
966
+ /**
967
+ * Get current connection status.
968
+ * @returns true if WebSocket is connected
969
+ */
970
+ get connected() {
971
+ return this.connection.opened.getValue();
972
+ }
973
+ /**
974
+ * Get current authentication status.
975
+ * @returns true if authenticated with the system
976
+ */
977
+ get authenticated() {
978
+ return this.authenticator.authenticated$.getValue();
979
+ }
980
+ /**
981
+ * Close the WebSocket connection.
982
+ * Connection will automatically retry if retry is enabled.
983
+ */
984
+ close() {
985
+ this.connection.close();
986
+ }
987
+ /**
988
+ * Factory method to create the WebSocket connection.
989
+ * Override in subclasses to provide version-specific connection handling.
990
+ */
991
+ createConnection() {
992
+ const websocketPath = getWebSocketPath(this.version);
993
+ return new TrueNasConnection(
994
+ this.enabled,
995
+ this.hostnames,
996
+ this.uuid,
997
+ websocketPath,
998
+ this.systemName,
999
+ void 0,
1000
+ // retryDelay (use default)
1001
+ void 0,
1002
+ // maxRetry (use default)
1003
+ this.logger
1004
+ );
1005
+ }
1006
+ /**
1007
+ * Factory method to create the API handler.
1008
+ * Override in subclasses to provide version-specific API implementations.
1009
+ */
1010
+ createApi() {
1011
+ return new TrueNasApi(this.authenticator.authenticated$, this.connection);
1012
+ }
1013
+ /**
1014
+ * Factory method to create the authenticator.
1015
+ * Override in subclasses to provide version-specific authentication.
1016
+ */
1017
+ createAuthenticator() {
1018
+ return new TrueNasAuthenticator(this.connection);
1019
+ }
1020
+ };
1021
+
1022
+ // src/client/truenas-api-client-v25-10.ts
1023
+ var TrueNasApiClientV2510 = class extends TrueNasApiClient {
1024
+ /**
1025
+ * Create v25.10-specific operation mappings
1026
+ *
1027
+ * virt.instance.* APIs return boolean (true) but emit job events via websocket.
1028
+ * We use callAndGetJobId to capture the job ID, then track the job.
1029
+ * All operations emit Job updates until the operation completes.
1030
+ */
1031
+ createOperations() {
1032
+ return {
1033
+ containerQuery: () => this.api.call("virt.instance.query" /* VirtualInstanceQuery */, [
1034
+ [["type", "=", "CONTAINER" /* Container */]]
1035
+ ]).pipe(map((instances) => instances.map(this.toContainer))),
1036
+ containerStart: (id) => this.api.callAndGetJobId("virt.instance.start" /* VirtualInstanceStart */, [id]).pipe(switchMap((jobId) => this.api.trackJob(jobId))),
1037
+ containerStop: (id, options) => this.api.callAndGetJobId("virt.instance.stop" /* VirtualInstanceStop */, [id, options]).pipe(switchMap((jobId) => this.api.trackJob(jobId))),
1038
+ containerRestart: (id, options) => this.api.callAndGetJobId("virt.instance.restart" /* VirtualInstanceRestart */, [
1039
+ id,
1040
+ options
1041
+ ]).pipe(switchMap((jobId) => this.api.trackJob(jobId)))
1042
+ };
1043
+ }
1044
+ /**
1045
+ * Transform VirtualInstanceQuery to unified Container type
1046
+ */
1047
+ toContainer(instance) {
1048
+ return {
1049
+ id: instance.id,
1050
+ name: instance.name,
1051
+ status: instance.status,
1052
+ autostart: instance.autostart,
1053
+ cpu: instance.cpu,
1054
+ memory: instance.memory,
1055
+ image: instance.image
1056
+ };
1057
+ }
1058
+ };
1059
+ var TrueNasApiClientV26 = class _TrueNasApiClientV26 extends TrueNasApiClient {
1060
+ /**
1061
+ * Map v26 status state string to AppState enum
1062
+ */
1063
+ static mapStatus(state) {
1064
+ switch (state.toUpperCase()) {
1065
+ case "RUNNING":
1066
+ return "RUNNING" /* Running */;
1067
+ case "STOPPED":
1068
+ return "STOPPED" /* Stopped */;
1069
+ case "STOPPING":
1070
+ return "STOPPING" /* Stopping */;
1071
+ default:
1072
+ return "STOPPED" /* Stopped */;
1073
+ }
1074
+ }
1075
+ /**
1076
+ * Create v26-specific operation mappings
1077
+ *
1078
+ * Operations return Observable<Job | null>:
1079
+ * - Async operations emit Job updates until complete
1080
+ * - Sync operations emit null once
1081
+ */
1082
+ createOperations() {
1083
+ return {
1084
+ containerQuery: () => this.api.call("container.query" /* ContainerQuery */, [[]]).pipe(map((containers) => containers.map(this.toContainer))),
1085
+ // container.start is synchronous in v26.0.0 - emit null
1086
+ containerStart: (id) => this.api.call("container.start" /* ContainerStart */, [parseInt(id, 10)]).pipe(map(() => null)),
1087
+ // container.stop emits job updates
1088
+ containerStop: (id, options) => this.api.callAndGetJobId("container.stop" /* ContainerStop */, [
1089
+ parseInt(id, 10),
1090
+ {
1091
+ force: options.force,
1092
+ force_after_timeout: options.force
1093
+ }
1094
+ ]).pipe(switchMap((jobId) => this.api.trackJob(jobId))),
1095
+ // v26.0.0 doesn't have container.restart - chain stop + start
1096
+ // Emits Job updates during stop, then null when start completes
1097
+ containerRestart: (id, options) => {
1098
+ const numericId = parseInt(id, 10);
1099
+ return this.api.callAndGetJobId("container.stop" /* ContainerStop */, [
1100
+ numericId,
1101
+ {
1102
+ force: options.force,
1103
+ force_after_timeout: options.force
1104
+ }
1105
+ ]).pipe(
1106
+ // Track stop job until it completes
1107
+ switchMap((stopJobId) => this.api.trackJob(stopJobId)),
1108
+ // Collect all job updates to ensure stop fully completes
1109
+ toArray(),
1110
+ // Re-emit job updates, then call start after stop is done
1111
+ switchMap(
1112
+ (jobUpdates) => concat(
1113
+ from(jobUpdates),
1114
+ this.api.call("container.start" /* ContainerStart */, [numericId]).pipe(map(() => null))
1115
+ )
1116
+ )
1117
+ );
1118
+ }
1119
+ };
1120
+ }
1121
+ /**
1122
+ * Transform v26 ContainerQueryV26 to unified Container type
1123
+ */
1124
+ toContainer(container) {
1125
+ return {
1126
+ id: container.id.toString(),
1127
+ name: container.name,
1128
+ status: _TrueNasApiClientV26.mapStatus(container.status.state),
1129
+ autostart: container.autostart,
1130
+ description: container.description
1131
+ // cpu and memory are not available in v26 container.query
1132
+ // image is not available in v26 container.query
1133
+ };
1134
+ }
1135
+ };
1136
+
1137
+ // src/errors/version-discovery.errors.ts
1138
+ var VersionDiscoveryError = class extends Error {
1139
+ constructor(message, hostname) {
1140
+ super(message);
1141
+ this.hostname = hostname;
1142
+ this.name = this.constructor.name;
1143
+ Object.setPrototypeOf(this, new.target.prototype);
1144
+ }
1145
+ };
1146
+ var VersionTooOldError = class extends VersionDiscoveryError {
1147
+ constructor(hostname, availableVersions, minRequired = apiVersionConfig.MIN_SUPPORTED_VERSION) {
1148
+ super(
1149
+ `System API version is too old. Minimum supported: ${minRequired}. Available versions: ${availableVersions.join(
1150
+ ", "
1151
+ )}`,
1152
+ hostname
1153
+ );
1154
+ this.availableVersions = availableVersions;
1155
+ this.minRequired = minRequired;
1156
+ }
1157
+ };
1158
+ var VersionTooNewError = class extends VersionDiscoveryError {
1159
+ constructor(hostname, availableVersions, maxSupported = apiVersionConfig.MAX_SUPPORTED_VERSION) {
1160
+ super(
1161
+ `System API version is too new. Maximum supported: ${maxSupported}. Available versions: ${availableVersions.join(
1162
+ ", "
1163
+ )}`,
1164
+ hostname
1165
+ );
1166
+ this.availableVersions = availableVersions;
1167
+ this.maxSupported = maxSupported;
1168
+ }
1169
+ };
1170
+ var VersionEndpointNotFoundError = class extends VersionDiscoveryError {
1171
+ constructor(hostname, statusCode = 404) {
1172
+ super(
1173
+ `Version discovery endpoint not found: ${hostname} (HTTP ${String(
1174
+ statusCode
1175
+ )})`,
1176
+ hostname
1177
+ );
1178
+ this.statusCode = statusCode;
1179
+ }
1180
+ };
1181
+ var VersionDiscoveryTimeoutError = class extends VersionDiscoveryError {
1182
+ constructor(hostname, timeoutMs = 5e3) {
1183
+ super(
1184
+ `Cannot connect to system (timeout after ${String(
1185
+ timeoutMs
1186
+ )}ms): ${hostname}`,
1187
+ hostname
1188
+ );
1189
+ this.timeoutMs = timeoutMs;
1190
+ }
1191
+ };
1192
+ var VersionDiscoveryNetworkError = class extends VersionDiscoveryError {
1193
+ constructor(hostname, originalError) {
1194
+ super(
1195
+ `Network error connecting to ${hostname}: ${originalError.message}`,
1196
+ hostname
1197
+ );
1198
+ this.originalError = originalError;
1199
+ }
1200
+ };
1201
+ var NoCompatibleVersionsError = class extends VersionDiscoveryError {
1202
+ constructor(hostname, availableVersions, supportedRange = {
1203
+ min: apiVersionConfig.MIN_SUPPORTED_VERSION,
1204
+ max: apiVersionConfig.MAX_SUPPORTED_VERSION
1205
+ }) {
1206
+ super(
1207
+ `No compatible API versions available. Supported: ${supportedRange.min} to ${supportedRange.max}. Available: ${availableVersions.join(", ")}`,
1208
+ hostname
1209
+ );
1210
+ this.availableVersions = availableVersions;
1211
+ this.supportedRange = supportedRange;
1212
+ }
1213
+ };
1214
+ var InvalidVersionResponseError = class extends VersionDiscoveryError {
1215
+ constructor(hostname, reason) {
1216
+ super(`Invalid API response format from ${hostname}: ${reason}`, hostname);
1217
+ this.reason = reason;
1218
+ }
1219
+ };
1220
+ var discoveryTimeoutMs = 5e3;
1221
+ function hasErrorName(error, expected) {
1222
+ return typeof error === "object" && error !== null && "name" in error && error.name === expected;
1223
+ }
1224
+ var VersionDiscovery = class {
1225
+ constructor(logger = noopLogger) {
1226
+ this.logger = logger;
1227
+ this.versionCache = /* @__PURE__ */ new Map();
1228
+ }
1229
+ /**
1230
+ * Discovers the API version for a given hostname.
1231
+ *
1232
+ * Makes a GET request to `https://{hostname}/api/versions` and returns the latest
1233
+ * compatible version. Results are cached per hostname; the cache entry is removed
1234
+ * on failure so the next call retries.
1235
+ *
1236
+ * @param hostname - The TrueNAS system hostname (e.g., "truenas.local")
1237
+ * @returns Observable that emits the selected ApiVersion
1238
+ * @throws VersionDiscoveryError subclasses for specific failure scenarios
1239
+ */
1240
+ discoverVersion(hostname) {
1241
+ const cached = this.versionCache.get(hostname);
1242
+ if (cached) {
1243
+ this.logger.info("Version discovery cache hit", { hostname });
1244
+ return cached;
1245
+ }
1246
+ const url = `https://${hostname}/api/versions`;
1247
+ this.logger.info("Starting version discovery", { hostname, url });
1248
+ const discovery$ = defer(() => from(this.fetchVersions(hostname))).pipe(
1249
+ map$1((versionStrings) => this.selectVersion(hostname, versionStrings)),
1250
+ catchError((error) => {
1251
+ this.versionCache.delete(hostname);
1252
+ this.logger.error("Version discovery failed", { hostname, error });
1253
+ return throwError(() => this.classify(error, hostname));
1254
+ }),
1255
+ shareReplay(1)
1256
+ );
1257
+ this.versionCache.set(hostname, discovery$);
1258
+ return discovery$;
1259
+ }
1260
+ /**
1261
+ * Clears the version cache for a specific hostname or all hostnames.
1262
+ *
1263
+ * @param hostname - Optional hostname to clear. If omitted, clears all cached versions.
1264
+ */
1265
+ clearCache(hostname) {
1266
+ if (hostname) {
1267
+ this.versionCache.delete(hostname);
1268
+ } else {
1269
+ this.versionCache.clear();
1270
+ }
1271
+ }
1272
+ /**
1273
+ * Performs the `fetch` with a 5-second `AbortController` timeout and inspects the
1274
+ * *resolved* Response (fetch does not reject on non-2xx). Throws
1275
+ * `VersionEndpointNotFoundError` on 404 and `InvalidVersionResponseError` on any
1276
+ * other non-2xx or a body that is not an array of strings; lets `TypeError`
1277
+ * (network) and `AbortError` (timeout) bubble to `classify`.
1278
+ *
1279
+ * The element-type check matters: a reachable server returning an array of
1280
+ * non-strings (e.g. `[1, 2, 3]`) would otherwise reach `parseApiVersion`, whose
1281
+ * `.match()` throws a `TypeError` on a non-string — which `classify` would then
1282
+ * misfile as a network error. Validating here keeps it an `InvalidVersionResponseError`.
1283
+ */
1284
+ async fetchVersions(hostname) {
1285
+ const url = `https://${hostname}/api/versions`;
1286
+ const controller = new AbortController();
1287
+ const timer = setTimeout(() => controller.abort(), discoveryTimeoutMs);
1288
+ try {
1289
+ const response = await fetch(url, { signal: controller.signal });
1290
+ if (response.status === 404) {
1291
+ throw new VersionEndpointNotFoundError(hostname);
1292
+ }
1293
+ if (!response.ok) {
1294
+ throw new InvalidVersionResponseError(
1295
+ hostname,
1296
+ `HTTP ${String(response.status)}`
1297
+ );
1298
+ }
1299
+ const body = await response.json();
1300
+ if (!Array.isArray(body) || !body.every((v) => typeof v === "string")) {
1301
+ throw new InvalidVersionResponseError(
1302
+ hostname,
1303
+ "Response was not an array of version strings"
1304
+ );
1305
+ }
1306
+ return body;
1307
+ } finally {
1308
+ clearTimeout(timer);
1309
+ }
1310
+ }
1311
+ /**
1312
+ * Parses, filters, and selects the latest compatible version from the raw response
1313
+ * array. Throws the appropriate typed error when no valid/compatible version exists.
1314
+ */
1315
+ selectVersion(hostname, versionStrings) {
1316
+ this.logger.info("Version discovery response received", {
1317
+ hostname,
1318
+ versionCount: versionStrings.length,
1319
+ versions: versionStrings
1320
+ });
1321
+ const parsedVersions = versionStrings.map(parseApiVersion).filter((v) => v !== null);
1322
+ if (parsedVersions.length === 0) {
1323
+ this.logger.error("No valid versions in response", {
1324
+ hostname,
1325
+ versionStrings
1326
+ });
1327
+ throw new InvalidVersionResponseError(
1328
+ hostname,
1329
+ "No valid API versions found in response"
1330
+ );
1331
+ }
1332
+ const compatibleVersions = filterCompatibleVersions(parsedVersions);
1333
+ this.logger.info("Version compatibility check", {
1334
+ hostname,
1335
+ total: parsedVersions.length,
1336
+ compatible: compatibleVersions.length,
1337
+ supportedRange: {
1338
+ min: apiVersionConfig.MIN_SUPPORTED_VERSION,
1339
+ max: apiVersionConfig.MAX_SUPPORTED_VERSION
1340
+ }
1341
+ });
1342
+ if (compatibleVersions.length === 0) {
1343
+ const allTooOld = parsedVersions.every(
1344
+ (v) => checkVersionCompatibility(v) === "too-old" /* TooOld */
1345
+ );
1346
+ const allTooNew = parsedVersions.every(
1347
+ (v) => checkVersionCompatibility(v) === "too-new" /* TooNew */
1348
+ );
1349
+ const availableVersions = parsedVersions.map((v) => v.version);
1350
+ if (allTooOld) {
1351
+ this.logger.warn("All available versions too old", {
1352
+ hostname,
1353
+ availableVersions
1354
+ });
1355
+ throw new VersionTooOldError(hostname, availableVersions);
1356
+ } else if (allTooNew) {
1357
+ this.logger.warn("All available versions too new", {
1358
+ hostname,
1359
+ availableVersions
1360
+ });
1361
+ throw new VersionTooNewError(hostname, availableVersions);
1362
+ } else {
1363
+ this.logger.warn("No compatible versions found", {
1364
+ hostname,
1365
+ availableVersions
1366
+ });
1367
+ throw new NoCompatibleVersionsError(hostname, availableVersions);
1368
+ }
1369
+ }
1370
+ const sortedVersions = compatibleVersions.sort(
1371
+ (a, b) => compareVersions(b, a)
1372
+ );
1373
+ const selectedVersion = sortedVersions[0];
1374
+ this.logger.info("Version selected", {
1375
+ hostname,
1376
+ selected: selectedVersion.version,
1377
+ websocketPath: selectedVersion.websocketPath
1378
+ });
1379
+ return selectedVersion;
1380
+ }
1381
+ /**
1382
+ * Maps a raw failure to a typed {@link VersionDiscoveryError}. Errors thrown by
1383
+ * `fetchVersions`/`selectVersion` are already typed and pass through unchanged; a
1384
+ * `fetch` `TypeError` becomes {@link VersionDiscoveryNetworkError} (the CORS/network
1385
+ * sentinel), an `AbortError` becomes {@link VersionDiscoveryTimeoutError}, and any
1386
+ * other failure (e.g. an unparseable body's `SyntaxError`) becomes
1387
+ * {@link InvalidVersionResponseError}.
1388
+ */
1389
+ classify(error, hostname) {
1390
+ if (error instanceof VersionDiscoveryError) {
1391
+ return error;
1392
+ }
1393
+ if (hasErrorName(error, "AbortError")) {
1394
+ this.logger.warn("Version discovery timeout", { hostname });
1395
+ return new VersionDiscoveryTimeoutError(hostname);
1396
+ }
1397
+ if (error instanceof TypeError) {
1398
+ this.logger.warn("Version discovery network error", {
1399
+ hostname,
1400
+ message: error.message
1401
+ });
1402
+ return new VersionDiscoveryNetworkError(hostname, error);
1403
+ }
1404
+ const reason = error instanceof Error ? error.message : "Unknown error";
1405
+ this.logger.warn("Version discovery failed with unknown error", {
1406
+ hostname,
1407
+ reason
1408
+ });
1409
+ return new InvalidVersionResponseError(
1410
+ hostname,
1411
+ `Versioned API not available. This system may be running an older TrueNAS version (requires v25.10.0+). ${reason}`
1412
+ );
1413
+ }
1414
+ };
1415
+
1416
+ // src/factory.ts
1417
+ async function createTrueNasClient(opts) {
1418
+ const { uuid, hostnames, systemName } = opts;
1419
+ const logger = opts.logger ?? noopLogger;
1420
+ if (!hostnames || hostnames.length === 0) {
1421
+ throw new Error(
1422
+ `Cannot create client for system ${uuid}: hostnames array is empty`
1423
+ );
1424
+ }
1425
+ const primaryHostname = hostnames[0];
1426
+ const versionDiscovery = new VersionDiscovery(logger);
1427
+ logger.info("Creating versioned API client", {
1428
+ uuid: uuid.slice(0, 8),
1429
+ hostname: primaryHostname,
1430
+ systemName
1431
+ });
1432
+ let version;
1433
+ try {
1434
+ version = await firstValueFrom(
1435
+ versionDiscovery.discoverVersion(primaryHostname)
1436
+ );
1437
+ logger.info("API version discovered, instantiating client", {
1438
+ uuid: uuid.slice(0, 8),
1439
+ version: version.version,
1440
+ websocketPath: version.websocketPath
1441
+ });
1442
+ } catch (error) {
1443
+ const errorMessage = errorMessageOrDefault(error, "Unknown error");
1444
+ if (!(error instanceof VersionDiscoveryNetworkError)) {
1445
+ logger.error("Version discovery failed", {
1446
+ uuid: uuid.slice(0, 8),
1447
+ hostname: primaryHostname,
1448
+ error: errorMessage,
1449
+ errorType: error instanceof Error ? error.constructor.name : typeof error
1450
+ });
1451
+ throw error;
1452
+ }
1453
+ const fallbackVersionString = apiVersionConfig.FALLBACK_VERSION;
1454
+ const fallbackVersion = parseApiVersion(fallbackVersionString);
1455
+ if (!fallbackVersion) {
1456
+ logger.error("Invalid fallback version configuration", {
1457
+ uuid: uuid.slice(0, 8),
1458
+ hostname: primaryHostname,
1459
+ fallbackVersion: fallbackVersionString
1460
+ });
1461
+ throw error;
1462
+ }
1463
+ logger.warn(
1464
+ "Version discovery failed with a network error (possible CORS or network issue), falling back to assumed version",
1465
+ {
1466
+ uuid: uuid.slice(0, 8),
1467
+ hostname: primaryHostname,
1468
+ fallbackVersion: fallbackVersionString,
1469
+ originalError: errorMessage,
1470
+ warning: "A network error has multiple causes (CORS, network down, DNS failure). The connection may still fail during the WebSocket handshake."
1471
+ }
1472
+ );
1473
+ version = fallbackVersion;
1474
+ }
1475
+ return instantiateClientForVersion(version, opts, logger);
1476
+ }
1477
+ function instantiateClientForVersion(version, opts, logger) {
1478
+ const { uuid, hostnames, enabled, systemName } = opts;
1479
+ let versionKey;
1480
+ if (version.year <= legacyCutoffYear) {
1481
+ const monthPadded = version.minor.toString().padStart(2, "0");
1482
+ versionKey = `${version.year.toString()}.${monthPadded}`;
1483
+ } else {
1484
+ versionKey = version.year.toString();
1485
+ }
1486
+ switch (versionKey) {
1487
+ case "25.10":
1488
+ logger.info("Instantiating TrueNasApiClientV2510", {
1489
+ uuid: uuid.slice(0, 8),
1490
+ version: version.version,
1491
+ versionKey
1492
+ });
1493
+ return new TrueNasApiClientV2510(
1494
+ uuid,
1495
+ hostnames,
1496
+ version,
1497
+ enabled,
1498
+ systemName,
1499
+ logger
1500
+ );
1501
+ case "26":
1502
+ logger.info("Instantiating TrueNasApiClientV26", {
1503
+ uuid: uuid.slice(0, 8),
1504
+ version: version.version,
1505
+ versionKey
1506
+ });
1507
+ return new TrueNasApiClientV26(
1508
+ uuid,
1509
+ hostnames,
1510
+ version,
1511
+ enabled,
1512
+ systemName,
1513
+ logger
1514
+ );
1515
+ default:
1516
+ logger.error("No client implementation for version", {
1517
+ uuid: uuid.slice(0, 8),
1518
+ version: version.version,
1519
+ versionKey
1520
+ });
1521
+ throw new Error(
1522
+ `No client implementation for API version ${version.version}. Supported versions: v25.10.x, v26.x.y. Version key: ${versionKey}`
1523
+ );
1524
+ }
1525
+ }
1526
+ function errorMessageOrDefault(error, fallback) {
1527
+ if (error instanceof Error) {
1528
+ return error.message;
1529
+ }
1530
+ if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
1531
+ return error.message;
1532
+ }
1533
+ return fallback;
1534
+ }
1535
+
1536
+ export { AuthError, AuthErrorCode, InvalidVersionResponseError, JobState, NoCompatibleVersionsError, TrueNasApiClient, TrueNasApiClientV2510, TrueNasApiClientV26, TrueNasAuthMechanism, TrueNasEndpoint, VersionCompatibility, VersionDiscovery, VersionDiscoveryError, VersionDiscoveryNetworkError, VersionDiscoveryTimeoutError, VersionEndpointNotFoundError, VersionTooNewError, VersionTooOldError, consoleLogger, createTrueNasClient, getApiErrorMessage, noopLogger };
1537
+ //# sourceMappingURL=index.js.map
1538
+ //# sourceMappingURL=index.js.map