@impz/client 0.0.0-stage → 2.0.6

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Geoff Whatley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,235 @@
1
- # Temporary Holding Version
1
+ # @impz/client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A typed client for [impd](https://github.com/zgeoff/impz), the imp host daemon. It runs in Bun, in
4
+ binaries that `bun build --compile` makes, in Node 22.6 or later, and in browsers with
5
+ `Promise.withResolvers`: Chrome 119, Firefox 121 and Safari 17.4 or later. CI checks each release
6
+ under Node, Bun and a compiled Bun binary.
7
+
8
+ ```sh
9
+ npm install @impz/client
10
+ ```
11
+
12
+ `@opentelemetry/api` is an optional peer: oRPC's type declarations name it, so a project that
13
+ type-checks its dependencies (no `skipLibCheck`) installs it too.
14
+
15
+ ## Connect
16
+
17
+ ```ts
18
+ import { createImpClient } from '@impz/client';
19
+
20
+ const imp = createImpClient({
21
+ url: 'http://localhost:7070',
22
+ token: process.env.IMP_TOKEN,
23
+ });
24
+ ```
25
+
26
+ - `url` is impd's API. A path prefix is kept, so impd can sit behind a proxy at `/impd/`.
27
+ - `token` is the bearer token from `<IMP_DATA_DIR>/token`. It controls every imp on the host, so
28
+ keep it on a server. A browser app sends its requests through a proxy that adds the token, and
29
+ leaves `token` out.
30
+ - `fetch` swaps the transport, for example to call an in-process app in tests.
31
+
32
+ ## Imps, checkpoints and forks
33
+
34
+ Every procedure of impd's API is on the client, with its input and output types.
35
+
36
+ ```ts
37
+ const dev = await imp.imps.create({ name: 'dev', memoryMib: 2048 });
38
+
39
+ const checkpoint = await imp.checkpoints.create({ name: 'dev', label: 'clean' });
40
+
41
+ // a new imp from the checkpoint's disk
42
+ await imp.imps.fork({ source: 'dev', name: 'dev-2', checkpoint: checkpoint.id });
43
+
44
+ await imp.imps.sleep({ name: 'dev' });
45
+ ```
46
+
47
+ `imp.requireAwake(name)` makes one wake call. It wakes a sleeping imp, boots a stopped one and
48
+ returns a running one as it is. impd refuses an imp in the `error` state with `INVALID_STATE`,
49
+ because a wake would restart it; pass `{ restartError: true }` to restart it anyway. While impd
50
+ stops, calls fail with `SERVICE_UNAVAILABLE`, and while it restarts, `fetch` cannot connect; pass
51
+ `{ retryUnavailable: { attempts, delayMs } }` to wait for it to come back. `RAM_BUDGET_EXCEEDED` is
52
+ never retried.
53
+
54
+ ## Run commands
55
+
56
+ `imp.run` runs a command to its exit and collects its output. Without `stdin`, stdin closes at once.
57
+
58
+ ```ts
59
+ const result = await imp.run('dev', ['sh', '-c', 'uname -a; cat'], { stdin: 'hi\n' });
60
+
61
+ console.log(result.code, new TextDecoder().decode(result.stdout));
62
+ ```
63
+
64
+ `imp.openExec` streams instead. `stdout` and `stderr` are `ReadableStream`s that end with the
65
+ session. `write` takes a string or bytes and waits while the socket's buffer is full, so a writer in
66
+ a loop keeps to the network's pace. It sends a large write in frames of at most 1 MiB: impd closes a
67
+ socket that sends one frame over 16 MiB.
68
+
69
+ Read or cancel both streams. Output nobody reads waits in memory, and past 8 MiB on one stream
70
+ (`maxUnreadBytes`) the session ends with `OUTPUT_OVERFLOW`. Cancelling both streams ends the session
71
+ and stops the command; so do `close()` and the `signal` option's abort.
72
+
73
+ ```ts
74
+ const tail = await imp.openExec('dev', ['tail', '-f', '/var/log/app.log']);
75
+
76
+ void tail.stderr.cancel();
77
+
78
+ try {
79
+ for await (const chunk of tail.stdout) {
80
+ if (process.stdout.write(chunk) === false) {
81
+ break;
82
+ }
83
+ }
84
+ } finally {
85
+ tail.close();
86
+ }
87
+ ```
88
+
89
+ `imp.openConsole` opens a login shell with a tty, the shell of the image's user, as `impz console`
90
+ does. With [xterm.js](https://xtermjs.org):
91
+
92
+ ```ts
93
+ const shell = await imp.openConsole('dev', { cols: term.cols, rows: term.rows });
94
+
95
+ term.onData((data) => {
96
+ shell.write(data).catch(() => {});
97
+ });
98
+ term.onResize(({ cols, rows }) => shell.resize(cols, rows));
99
+
100
+ const reader = shell.stdout.getReader();
101
+
102
+ for (let read = await reader.read(); !read.done; read = await reader.read()) {
103
+ term.write(read.value);
104
+ }
105
+ ```
106
+
107
+ With `session: 'main'`, `openConsole` and `openExec` start that session, or attach to it if it runs,
108
+ and the shell outlives the handle: `close()` detaches.
109
+ `imp.openAttach('dev', 'main', { cols, rows })` attaches to a running session; its `stdout` starts
110
+ with a replay of the recent output. `sessions.list` names them without waking the imp. `started`
111
+ resolves with `{ pid, session, created, groupKill, output }`. When impd ends the socket while the
112
+ session runs on, `exit` rejects with `DETACHED`, and its `data.reason` says why: `taken_over`
113
+ (another client attached; one is attached at a time), `slow` (the client fell too far behind) or
114
+ `lost` (impd lost the guest, as when the imp slept; attach again).
115
+
116
+ ### Resuming a session
117
+
118
+ `started.output` places the data in the session's output. With `continuity: 'offsets'` it has the
119
+ generation (`executionGeneration`, one run of the process), the `bootId`, `bufferStart` and `end`,
120
+ and `offset`, the offset of the first data byte after `prelude` mode bytes; `{ continuity: 'none' }`
121
+ means an imp whose agent predates offsets, or an older impd. `exit` resolves with `offset`, and
122
+ `DETACHED` has `data.offset`: the offset after the last byte received. Keep the generation and that
123
+ offset, and resume from them:
124
+
125
+ ```ts
126
+ const tail = await imp.openAttach('dev', 'main', {
127
+ resumeFrom: { executionGeneration, offset },
128
+ wake: false,
129
+ });
130
+
131
+ const { output } = await tail.started;
132
+ ```
133
+
134
+ `output.resume` says how the resume was met: `exact`; `gap`, whose bytes `[from, to)` are gone (a
135
+ terminal should attach again without `resumeFrom`, since the data can start inside an escape
136
+ sequence); or `generation_changed`, a new process, whose data starts at `firstOffset`. A resume can
137
+ repeat bytes you have: drop those below your high-water mark. `output.coldBoots` lists the imp's
138
+ last cold boots, newest first, with a `cause` (`start`, `wake_fallback`, `watchdog`, `restore`,
139
+ `recovery`, `unknown`); the first one after your `bootId` ended your generation. `wake: false`
140
+ rejects with `InvalidStateError` instead of booting or waking the imp.
141
+
142
+ A disconnected client cannot recover bytes below `bufferStart`, any byte of a generation that a cold
143
+ boot ended, or the output of an exec without `session`. Keep what you received if you need them.
144
+ `system.info()` has `features.sessionOffsets` on an impd that carries offsets.
145
+
146
+ With a tty, `sendSignal('SIGINT')` and `sendSignal('SIGQUIT')` send ^C and ^\ as keys, so they reach
147
+ the foreground job; other signals, and every signal without a tty, go to the process. A write after
148
+ the session ended rejects with the error that ended it, such as `CONNECTION_CLOSED` or `RESTARTING`,
149
+ or with `CLOSED` after an exit or a `close`. `closeStdin` after the session ended does nothing.
150
+
151
+ The socket authenticates with a single-use exec ticket from `exec.ticket`, so the token never goes
152
+ in a URL; a browser behind a proxy that adds the token works the same way.
153
+
154
+ `exit` resolves with `{ code, signal }`, where `code` is null when a signal ended the command. When
155
+ the command did not run to its exit, `exit` rejects with an `ExecError` whose `code` is impd's (as
156
+ in the table below, plus `EXEC_FAILED` when the command cannot start and `INNER_DOWN` when the imp's
157
+ container is down) or one of `UNAUTHORIZED` (also for an exec ticket that expired or was used),
158
+ `UNREACHABLE`, `RESTARTING`, `CONNECTION_CLOSED`, `DETACHED`, `BAD_MESSAGE`, `CLOSED`,
159
+ `OUTPUT_OVERFLOW` and `LOCAL_ERROR`. Its `data` is impd's error data. Three codes reject as their
160
+ own subclasses, with typed `data`: `NoSessionError` (`NO_SESSION`:
161
+ `{ bootId, coldBoots, previous? }`, or no data from an agent without offsets), `InvalidStateError`
162
+ (`INVALID_STATE`: `{ state, allowed, coldBoots? }`) and `InvalidResumeError` (`INVALID_RESUME`:
163
+ `{ end, bufferStart }`, a resume past the end). An abort before the command starts, during the
164
+ ticket call or the connect, rejects with the abort's reason instead, an `AbortError` by default;
165
+ after the start it ends the session as `CLOSED`.
166
+
167
+ ## Errors
168
+
169
+ A failed call throws an `ORPCError`, or an `UnknownProcedureError` for a procedure that an older
170
+ impd lacks. impd's errors carry a `code` and typed `data`:
171
+
172
+ | Code | When | `data` |
173
+ | --------------------- | ---------------------------------------------------- | -------------------------------------- |
174
+ | `NOT_FOUND` | No imp, image, checkpoint or session has that name. | `{ kind, name }` |
175
+ | `CONFLICT` | The name is taken. | `{ kind, name }` |
176
+ | `INVALID_STATE` | The imp's state does not allow the call. | `{ state, allowed, coldBoots? }` |
177
+ | `RAM_BUDGET_EXCEEDED` | The host has no room, even after sleeping idle imps. | `{ budgetMib, usedMib, requestedMib }` |
178
+ | `SERVICE_UNAVAILABLE` | impd is stopping. | |
179
+ | `FORBIDDEN` | An exec ticket was used for another imp. | |
180
+ | `AGENT_OUTDATED` | The imp's agent has no sessions; stop and start it. | |
181
+
182
+ ```ts
183
+ import { isDefinedError, safe } from '@impz/client';
184
+
185
+ const [error, created] = await safe(imp.imps.create({ name: 'dev' }));
186
+
187
+ if (isDefinedError(error) && error.code === 'RAM_BUDGET_EXCEEDED') {
188
+ console.log(`needs ${error.data.requestedMib} MiB`);
189
+ }
190
+ ```
191
+
192
+ A 401 means the token is wrong.
193
+
194
+ ## Features
195
+
196
+ An older impd drops an input field it does not know, and the call succeeds without it. Check
197
+ `system.info().features` before you send a field that a later impd added:
198
+
199
+ | Field | Feature | Since |
200
+ | ------------------------------------------------- | ----------------- | ------ |
201
+ | `grantable` on `tokens.create` | `grantableTokens` | 0.27.0 |
202
+ | `rebind` on `secrets.add` (`replace`) | `secretRebind` | 0.27.0 |
203
+ | `require` on `openExec` | `execRequire` | 0.30.0 |
204
+ | `secretFiles`, `removeSecretFiles` on `system.gc` | `secretFilesGc` | 0.31.0 |
205
+
206
+ ```ts
207
+ const info = await imp.system.info();
208
+
209
+ if (info.features?.grantableTokens !== true) {
210
+ throw new Error('this impd makes tokens without a grantable list');
211
+ }
212
+ ```
213
+
214
+ Without the `secretRebind` feature, a `replace` that changes the hosts keeps every grant. With it,
215
+ it fails with `CONFLICT` and `data.reason: 'binding_changed'` unless `rebind` is set.
216
+
217
+ `openExec` and `openExecSession` check `execRequire` themselves when `require` is set. With
218
+ `require: ['broker']`, the command starts only once impd set the credential broker's variables and
219
+ CA bundle for this boot; otherwise the exec fails with `PRECONDITION_FAILED` and
220
+ `data: { reason: 'broker_not_ready', detail }`, and nothing runs. Without the feature it fails the
221
+ same way with `reason: 'impd_outdated'` before it sends the start. See
222
+ [requiring the broker](https://github.com/zgeoff/impz/blob/main/docs/guides/connectors.md#requiring-the-broker).
223
+
224
+ ## Versions
225
+
226
+ The client and impd are released together with the same version. `imp.checkServer()` tells whether
227
+ impd speaks this client's API: the major version must match, and before 1.0 the minor version too.
228
+
229
+ ```ts
230
+ const check = await imp.checkServer();
231
+
232
+ if (!check.compatible) {
233
+ throw new Error(`impd ${check.serverVersion} does not match client ${check.clientVersion}`);
234
+ }
235
+ ```