@truenas/api-client 6.0.0 → 6.0.2

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/README.md CHANGED
@@ -1,280 +1,207 @@
1
1
  # @truenas/api-client
2
2
 
3
- Framework-agnostic TypeScript client for the TrueNAS JSON-RPC 2.0 WebSocket API.
3
+ The TypeScript client for the TrueNAS API. Every method, parameter and response
4
+ middleware exposes is checked at compile time, and every request travels over
5
+ one self-healing WebSocket.
4
6
 
5
- > **Status:** early extraction in progress. The client is being pulled out of the TrueNAS Connect UI
6
- > into this standalone package.
7
+ ```typescript
8
+ const users = await firstValueFrom(
9
+ client.api.query('user.query', [['builtin', '=', false]], { select: ['id', 'username'] })
10
+ );
11
+ // users: Pick<UserEntry, 'id' | 'username'>[]
12
+ ```
7
13
 
8
- ## Requirements
14
+ ## Why this client
15
+
16
+ - **Typed from the source.** Types are generated from `middlewared --dump-api`
17
+ for every supported release. A misspelled method, a missing parameter or a
18
+ filter on a field that doesn't exist fails the build, not the user.
19
+ - **Version-aware.** The client asks the appliance which API it speaks and
20
+ builds the matching implementation. API versions v25.10.0 through v27.0.0
21
+ are supported side by side.
22
+ - **One call across releases.** `client.ops` keeps working when middleware
23
+ renames or reshapes an endpoint between versions.
24
+ - **Queries that know their shape.** List, single entry or count is chosen by
25
+ the method you call, and `select` narrows the result to the fields you asked
26
+ for.
27
+ - **Jobs and events as streams.** Jobs report progress until they finish.
28
+ Collection subscriptions deliver typed changes and come back after a
29
+ reconnect.
30
+ - **Built to stay connected.** It races every hostname you give it, retries,
31
+ keeps the socket alive, holds requests made while it reconnects, and logs
32
+ password and API-key sessions back in.
33
+ - **Framework-agnostic.** Plain RxJS, no framework. ESM and CommonJS, Node 22+
34
+ or any modern browser, and your own logger.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ npm install @truenas/api-client rxjs
40
+ ```
9
41
 
10
- - **Node ≥ 22** (provides a global `WebSocket`) or a browser. On older Node, supply a `WebSocket`
11
- implementation (e.g. the [`ws`](https://www.npmjs.com/package/ws) package) via the socket config.
12
- - **`rxjs` ^7.8** is a peer dependency — the consuming project provides it.
42
+ `rxjs` ^7.8 is a peer dependency.
13
43
 
14
- ## Usage
44
+ ## Quick start
15
45
 
16
46
  ```typescript
17
47
  import { createTrueNasClient } from '@truenas/api-client';
48
+ import { firstValueFrom } from 'rxjs';
18
49
 
19
50
  const client = await createTrueNasClient({
20
- uuid: 'system-uuid',
21
- hostnames: ['truenas.local'],
51
+ uuid: systemUuid,
52
+ hostnames: ['truenas.local', '192.168.1.50'],
22
53
  enabled: true,
23
54
  });
24
- ```
25
-
26
- `createTrueNasClient` does not take credentials, so log in before calling
27
- anything — middleware refuses an unauthenticated call, and `authenticated$`
28
- only turns true once one of these resolves:
29
55
 
30
- ```typescript
31
56
  await firstValueFrom(
32
- client.authenticator.loginWithApiKey({ username, key })
57
+ client.authenticator.loginWithApiKey({ username: 'admin', key: apiKey })
33
58
  );
34
- // or client.authenticator.loginWithUserPass(username, password)
59
+
60
+ const info = await firstValueFrom(client.api.call('system.info'));
61
+ console.log(`${info.hostname} runs ${info.version}`);
62
+
63
+ client.close();
35
64
  ```
36
65
 
37
- Everything below hangs off `client.api`, and every method name it accepts comes
38
- from types generated from `middlewared --dump-api`. A name the declared version
39
- does not have is a compile error, and params and responses come from the same
40
- source — there is no list of endpoint constants to import.
66
+ Every request is a cold `Observable`: nothing is sent until you subscribe, so
67
+ requests compose with the rest of RxJS. `firstValueFrom` turns one into a
68
+ promise.
69
+
70
+ ## Logging in
71
+
72
+ | Method | Use it for |
73
+ |---|---|
74
+ | `loginWithUserPass(username, password)` | Interactive logins. Resolves with `response_type: 'OTP_REQUIRED'` when two-factor is on. |
75
+ | `loginWithOtp(code)` | The second step of a two-factor login. |
76
+ | `loginWithApiKey({ username, key })` | Services and scripts. |
77
+ | `loginWithToken(token)` | Opening another session from a previous login's `reconnect_token`. |
78
+
79
+ All four live on `client.authenticator`. Password and API-key sessions log back
80
+ in by themselves after a reconnect. `authenticated$` tracks the session,
81
+ `logout()` ends it, and a rejected login errors with an `AuthError` carrying an
82
+ `AuthErrorCode`.
83
+
84
+ ## Calls
41
85
 
42
86
  ```typescript
43
- client.api.call('system.info'); // SystemInfoResult
44
- client.api.call('alert.dismiss', ['uuid-1']); // params required
45
- client.api.call('nope.nope'); // ✗ compile error
87
+ client.api.call('system.info'); // Observable<SystemInfoResult>
88
+ client.api.call('alert.dismiss', ['uuid-1']); // params checked against the method
89
+ client.api.call('nope.nope'); // ✗ compile error
46
90
  ```
47
91
 
48
- **Queries.** Middleware's `.query` methods are polymorphic in their options —
49
- the same endpoint returns a list, one entry, or a count. Which you get is
50
- chosen by the verb, so there is nothing to narrow:
92
+ A failed call errors with middleware's own message.
51
93
 
52
- ```typescript
53
- client.api.query('user.query', [['uid', '>', 1000]]); // UserEntry[]
54
- client.api.queryOne('user.query', [['id', '=', 1]]); // UserEntry
55
- client.api.queryCount('user.query'); // number
94
+ ## Queries
56
95
 
57
- client.api.query('user.query', [], { select: ['id', 'username'] });
58
- // Pick<UserEntry, 'id' | 'username'>[]
96
+ ```typescript
97
+ client.api.query('user.query', [['uid', '>', 1000]]); // UserEntry[]
98
+ client.api.query('user.query', [], { select: ['id', 'username'] }); // Pick<UserEntry, 'id' | 'username'>[]
99
+ client.api.queryOne('user.query', [['username', '=', 'root']]); // UserEntry
100
+ client.api.queryCount('user.query'); // number
101
+ client.api.query('user.query', [['uidd', '>', 1000]]); // ✗ no such field
59
102
  ```
60
103
 
61
- Use `satisfies` rather than an annotation when building options into a
62
- variable — an annotated `QueryListOptions<E>` widens `select`, and the result
63
- degrades to `Partial<E>[]`.
104
+ When you build options in a variable, write `satisfies QueryListOptions<…>`
105
+ rather than a type annotation, so the result keeps its precise type.
64
106
 
65
- **Jobs.** A separate key space from `call`: `app.start` runs as a job and does
66
- not appear in the call directory. `job` starts one and follows it to
67
- completion, typing the result from the job directory:
107
+ ## Jobs
68
108
 
69
109
  ```typescript
70
- client.api.job('pool.dataset.export_key', ['tank/enc'])
71
- .subscribe(job => report(job.progress.percent)); // Job<string | null>
110
+ client.api.job('pool.dataset.export_key', ['tank/encrypted']).subscribe(job => {
111
+ progress.set(job.progress.percent ?? 0);
112
+
113
+ if (job.state === JobState.Success) save(job.result);
114
+ if (job.state === JobState.Failed) report(job.error);
115
+ });
72
116
  ```
73
117
 
74
- **Events.** Emits the change as a union discriminated on `msg`. Narrowing is
75
- load-bearing: a removal carries an `id` and no `fields` in almost every
76
- collection.
118
+ The stream emits each state and completes when the job finishes. A failed job
119
+ completes too, with `state` and `error` set.
120
+
121
+ ## Events
77
122
 
78
123
  ```typescript
79
- client.api.events('app.query').subscribe(event => {
80
- if (event.msg === 'removed') return drop(event.id);
81
- render(event.fields);
124
+ client.api.events('app.query').subscribe(change => {
125
+ if (change.msg === 'removed') return removeRow(change.id);
126
+ upsertRow(change.fields);
82
127
  });
83
128
  ```
84
129
 
85
- ### Reaching an appliance over http
130
+ Changes are typed per collection and discriminated on `msg`. One server
131
+ subscription is shared by every subscriber and re-registered after each
132
+ reconnect.
86
133
 
87
- By default the client discovers over `https://` and connects over `wss://`,
88
- which is what an appliance serves. An appliance reached without TLS needs
89
- `protocol`:
134
+ ## Working across versions
90
135
 
91
- ```typescript
92
- import type { ApplianceProtocol } from '@truenas/api-client';
136
+ Without a hint, the client is typed as the oldest supported version, since that
137
+ is a safe floor against any appliance. Reach newer methods one of two ways:
93
138
 
94
- const protocol: ApplianceProtocol =
95
- location.protocol === 'http:' ? 'http:' : 'https:';
139
+ ```typescript
140
+ // Discover the version, and write against v26:
141
+ const client = await createTrueNasClient<ApiDirectoryV26_0_0>(options);
142
+ client.api.query('container.query');
96
143
 
97
- const client = await createTrueNasClient({
98
- uuid, hostnames: [location.host], enabled: true, protocol,
99
- });
144
+ // Or skip discovery, and let the types follow the version:
145
+ const pinned = await createTrueNasClient({ ...options, version: 'v27.0.0' });
146
+ pinned.api.query('container.query');
100
147
  ```
101
148
 
102
- It selects both halves of the transport — `https:` gives `https` discovery and a
103
- `wss` socket, `http:` gives `http` and `ws` — and defaults to `https:`, so
104
- existing callers are unaffected.
105
-
106
- `protocol` describes the **appliance**, not the page. Reading it from
107
- `location.protocol` is right when the appliance serves the page, which is the
108
- same-origin case this exists for. A page served from somewhere else — a dev
109
- server on `http://localhost:5173` talking to an https appliance — must pass what
110
- the *appliance* uses. Getting it wrong breaks both halves but reports only one:
111
- discovery's `fetch` follows the redirect and looks fine, while the socket opens
112
- `ws://`, meets the same redirect, and fails the handshake without naming the
113
- scheme.
114
-
115
- Omitting it against a plaintext appliance fails the other way, and more quietly.
116
- Discovery tries `https://`, `fetch` rejects, and the factory cannot tell that
117
- apart from the CORS block that v25.10.0 has on `/api/versions` — so it takes the
118
- fallback and hands back a client pinned to `v25.10.0` on `/api/v25.10.0`, with
119
- only a `logger.warn` to say so. Against a v26 or v27 box that is a wrong-version
120
- client that looks configured. If the appliance is plaintext, say so.
121
-
122
- Narrow rather than cast: `location.protocol` is a `string`, and it is genuinely
123
- `file:` for a locally-opened page or `chrome-extension:` in an extension. Both
124
- halves fall back to the encrypted scheme for anything off-contract, so a bad
125
- value cannot downgrade the transport — but the compiler will not stop you
126
- asserting one into this option, and it will not be the value you meant.
127
-
128
- ### Naming a version
129
-
130
- By default the version is discovered at runtime while the types are fixed at
131
- compile time, and `createTrueNasClient` assumes the oldest supported version —
132
- which understates a newer server rather than promising methods it lacks. There
133
- are two ways to reach the rest, and they differ in more than syntax.
134
-
135
- **Assert the surface** when you do not know the version but intend to write
136
- against a particular one:
149
+ A type argument is a claim about the appliance. Discovery still decides which
150
+ client you get. A named `version` skips discovery and also selects the
151
+ WebSocket endpoint, so name the version the appliance actually runs. The types
152
+ follow it when the version is a literal.
153
+
154
+ For code that must run against any supported release, use `client.ops`. It
155
+ offers the same calls on every version and maps them onto whatever that version
156
+ provides:
137
157
 
138
158
  ```typescript
139
- const client = await createTrueNasClient<ApiDirectoryV26_0_0>(opts);
140
- client.api.query('container.query'); // v26-only, reachable
159
+ client.ops.containerQuery(); // Observable<Container[]>
160
+ client.ops.containerStop(id, { force: true }); // Observable<Job | null>
161
+ client.ops.smbStatus({ infoLevel: 'SESSIONS' });
141
162
  ```
142
163
 
143
- Discovery still runs and still decides which client is built. The type argument
144
- is a claim about the server, not a guarantee — the client you get is whichever
145
- version discovery found, so a wrong claim fails at runtime.
164
+ Per-version details, such as permissions that differ between releases, are in
165
+ the `OperationMappings` reference.
146
166
 
147
- **State the version** when you already know it — a UI served by the appliance,
148
- a harness against a pinned image:
167
+ ## Connection
149
168
 
150
169
  ```typescript
151
- const client = await createTrueNasClient({
152
- uuid, hostnames, enabled: true, version: 'v27.0.0',
153
- });
154
- client.api.query('container.query'); // typed v27, derived from the string
170
+ client.connection.opened.subscribe(open => indicator.set(open));
171
+ client.connection.setEnabled(false); // disconnect; true connects again
172
+ client.close(); // disconnect for good
155
173
  ```
156
174
 
157
- This skips discovery entirely: no `GET /api/versions`, no CORS fallback. The
158
- surface is *derived* rather than asserted, so there is no type argument to get
159
- wrong, and a version the package ships no types for does not compile.
160
-
161
- It is the stronger claim of the two, because the version also selects the
162
- websocket path. Naming `v27.0.0` at a v26 appliance connects on `/api/v27.0.0`
163
- with v27 types over a v26 server, and discovery cannot correct it — declining
164
- discovery is the point.
165
-
166
- The derivation needs the version to be literal at the call site. Passing a type
167
- argument as well, forwarding `version` through a wrapper, or annotating the
168
- options object as `CreateClientOptions` all compile, all connect to the version
169
- you named, and all type as the default surface instead. That
170
- errs safely — understated types fail at the method call, not at runtime — but
171
- silently, so keep the literal where the call is.
172
-
173
- Compatibility is still checked, and two kinds of refusal reach a caller. A string that
174
- is not a supported version — reachable only from JavaScript — throws a plain
175
- `Error` naming the ones that are. A supported version this build has no client
176
- for throws `VersionTooNewError`, the same type discovery raises; that happens
177
- when types have been generated for a release before its client was written.
178
- There is no `VersionTooOldError` here, because the oldest version you can name
179
- is the oldest one supported.
180
-
181
- Operations that must work across versions belong on `client.ops`. On the
182
- discovery route that resolves against whatever the appliance turned out to be.
183
- On the named route it cannot: the client class is picked from the version you
184
- stated, so `ops` is that version's mappings whether or not the server agrees.
185
-
186
- `ops` is deliberately flat — `OperationMappings`, not parameterized by the
187
- directory — so every operation is callable whether or not you pinned a version.
188
- That is what lets an operation paper over a difference the type system would
189
- otherwise force the caller to handle.
190
-
191
- ```ts
192
- // The same call on every supported version.
193
- const sessions = await firstValueFrom(
194
- client.ops.smbStatus({ infoLevel: 'SESSIONS' })
195
- );
175
+ An appliance served without TLS needs `protocol: 'http:'`, which switches
176
+ discovery to `http` and the socket to `ws`. It describes the appliance, not the
177
+ page, and defaults to `https:`.
196
178
 
197
- // A client count is this composed, not a second operation.
198
- const count = await firstValueFrom(
199
- client.ops.smbStatus({
200
- infoLevel: 'SESSIONS',
201
- options: { count: true },
202
- statusOptions: { fast: true },
203
- })
204
- );
205
- ```
179
+ Pass `logger: consoleLogger`, or any object implementing `Logger`, to see what
180
+ the client is doing.
206
181
 
207
- `smbStatus` is worth singling out, because it is the first operation whose
208
- v25.10 leg is invisible to `middlewared --dump-api`. `smb.status` is public on
209
- v26+ and generated like any other method; on v25.10 it is the same method taking
210
- the same four positional arguments and returning the same `list | dict | int`,
211
- but middleware declares it `private=True`, so the dump omits it and no generated
212
- v25.10 type mentions it. The v25.10 client therefore *asserts* the method exists
213
- rather than reading it from the directory — a single narrow declaration admitting
214
- one method name, one argument tuple and one return type, checked against
215
- middleware source instead of against the dump.
216
-
217
- Three consequences worth knowing before you reach for it.
218
-
219
- **It needs a full-admin session on v25.10.** Being private there decides
220
- authorization, not just documentation. `smb.status` declares no roles on
221
- v25.10, and middleware role-registers a method only `if roles:` — so it is in no
222
- role's allowlist, and only a **non-STIG full-admin** session, whose allowlist is
223
- the wildcard `{ method: '*', resource: '*' }`, reaches it. A session holding
224
- exactly `SHARING_SMB_READ` is refused with `EACCES` on v25.10 and succeeds on
225
- v26+, where the method carries that role. Under STIG, full admin is expanded to
226
- the union of its roles' allowlists rather than the wildcard, so it is refused on
227
- v25.10 as well. The client does not pre-empt any of this: you get middleware's
228
- own error, on the version where it applies.
229
-
230
- **It logs server-side on every dispatch**, which matters if you poll it. v25.10
231
- is in maintenance and that cost is accepted there, but it is not a pattern to
232
- extend.
233
-
234
- **The result is the union middleware sends** — which arm arrives is decided by
235
- the options, not the info level — so callers narrow it themselves.
236
-
237
- These types are deliberately not the generated ones. `SmbStatusOptions` is not
238
- v26's `SMBStatusOptions`, and `SmbStatusResponse` is not that method's generated
239
- response: the generated types describe one version's dump, while these describe
240
- the contract both versions honour. They are named `Smb…` rather than `SMB…` so
241
- the two cannot be confused at a call site — and `SmbStatusOptions` is the one
242
- pair where the names sit closest, since it is the fourth positional argument on
243
- both versions.
182
+ ## Errors
183
+
184
+ - **Calls** error with middleware's message.
185
+ - **Logins** error with `AuthError`. Check `code` against `AuthErrorCode`.
186
+ - **`createTrueNasClient`** rejects with a `VersionDiscoveryError` subclass, such
187
+ as `VersionTooOldError`, `VersionTooNewError` or
188
+ `VersionDiscoveryNetworkError`.
244
189
 
245
190
  ## Documentation
246
191
 
247
- The API reference is generated from the TSDoc comments in the source with
248
- [TypeDoc](https://typedoc.org/) and published to GitHub Pages with each npm release:
249
- <https://truenas.github.io/api-client-ts/>
250
-
251
- ```bash
252
- yarn docs # generate locally into docs/ (gitignored)
253
- yarn docs:check # validate doc comments without rendering (run in CI)
254
- ```
192
+ The full API reference is at <https://truenas.github.io/api-client-ts/>,
193
+ generated from the source with each release.
255
194
 
256
195
  ## Development
257
196
 
258
197
  ```bash
259
- corepack enable # once, to enable Yarn 4
198
+ corepack enable # once, for Yarn 4
260
199
  yarn install
261
- yarn build # bundle to dist/ (ESM + CJS + .d.ts) via tsup
262
- yarn typecheck # tsc --noEmit
263
- yarn test # vitest
264
- yarn lint # eslint
265
- ```
266
-
267
- ## Layout
268
-
269
- Sources live under `src/`, grouped by role:
270
-
271
- ```
272
- src/
273
- connection/ api/ auth/ client/ # the WebSocket client, split by responsibility
274
- types/ enums/ utils/ config/ errors/
275
- logger.ts factory.ts version-discovery.ts index.ts
200
+ yarn build # ESM + CJS + .d.ts into dist/
201
+ yarn typecheck # sources, specs and scripts
202
+ yarn test
203
+ yarn lint
204
+ yarn generate:api # regenerate src/generated from middleware dumps
276
205
  ```
277
206
 
278
- Internal modules import each other through the `@/*` path alias (`@/* → src/*`). The alias is a
279
- build-time convenience only — it is inlined away during bundling and never reaches consumers; the public
280
- API is solely what `src/index.ts` (the barrel) re-exports.
207
+ Releases are automated from commit titles. See [RELEASING.md](RELEASING.md).