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