@impz/client 0.0.0-stage → 2.0.7
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 +21 -0
- package/README.md +234 -2
- package/dist/index.d.ts +20839 -0
- package/dist/index.js +1860 -0
- package/package.json +36 -4
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
|
-
#
|
|
1
|
+
# @impz/client
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
```
|