@zgeoff/imp-client 0.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/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/index.d.ts +2265 -0
- package/dist/index.js +678 -0
- package/package.json +38 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { ORPCError, ORPCError as ORPCError$1, createORPCClient, isDefinedError, safe } from "@orpc/client";
|
|
2
|
+
import { RPCLink } from "@orpc/client/fetch";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
//#endregion
|
|
5
|
+
//#region src/check-server.ts
|
|
6
|
+
const CLIENT_VERSION = "0.0.0";
|
|
7
|
+
async function checkServer(rpc) {
|
|
8
|
+
const info = await rpc.system.info();
|
|
9
|
+
return {
|
|
10
|
+
clientVersion: CLIENT_VERSION,
|
|
11
|
+
serverVersion: info.version,
|
|
12
|
+
compatible: isCompatibleVersion(CLIENT_VERSION, info.version)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function isCompatibleVersion(client, server) {
|
|
16
|
+
const ours = parseVersion(client);
|
|
17
|
+
const theirs = parseVersion(server);
|
|
18
|
+
if (ours === null || theirs === null || ours.major !== theirs.major) return false;
|
|
19
|
+
return ours.major > 0 || ours.minor === theirs.minor;
|
|
20
|
+
}
|
|
21
|
+
function parseVersion(version) {
|
|
22
|
+
const groups = /^(?<major>\d+)\.(?<minor>\d+)\.\d+/.exec(version)?.groups;
|
|
23
|
+
return groups === void 0 ? null : {
|
|
24
|
+
major: Number(groups["major"]),
|
|
25
|
+
minor: Number(groups["minor"])
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region ../api/src/name-schema.ts
|
|
30
|
+
const NameSchema = z.string().regex(/^[a-z][a-z0-9-]{0,30}$/, "must be a lowercase letter followed by up to 30 lowercase letters, digits or hyphens");
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region ../api/src/session-schema.ts
|
|
33
|
+
const SessionNameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,31}$/, "must be a lowercase letter or digit followed by up to 31 lowercase letters, digits or hyphens");
|
|
34
|
+
const SessionExitSchema = z.object({
|
|
35
|
+
code: z.int().nullable(),
|
|
36
|
+
signal: z.string().nullable()
|
|
37
|
+
}).readonly();
|
|
38
|
+
z.object({
|
|
39
|
+
name: SessionNameSchema,
|
|
40
|
+
pid: z.int().positive(),
|
|
41
|
+
argv: z.array(z.string()).readonly(),
|
|
42
|
+
state: z.enum(["running", "exited"]),
|
|
43
|
+
attached: z.boolean(),
|
|
44
|
+
cols: z.int().positive(),
|
|
45
|
+
rows: z.int().positive(),
|
|
46
|
+
startedAt: z.date(),
|
|
47
|
+
exit: SessionExitSchema.optional()
|
|
48
|
+
});
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region ../api/src/exec-protocol.ts
|
|
51
|
+
const EXEC_PATH = "/exec";
|
|
52
|
+
const EXEC_TICKET_PARAM = "ticket";
|
|
53
|
+
const EXEC_CHANNELS = {
|
|
54
|
+
stdin: 0,
|
|
55
|
+
stdout: 1,
|
|
56
|
+
stderr: 2
|
|
57
|
+
};
|
|
58
|
+
const DimensionSchema = z.int().min(1).max(65535);
|
|
59
|
+
const ExecStartMessageSchema = z.object({
|
|
60
|
+
type: z.literal("start"),
|
|
61
|
+
name: NameSchema,
|
|
62
|
+
argv: z.array(z.string()).min(1),
|
|
63
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
64
|
+
cwd: z.string().optional(),
|
|
65
|
+
tty: z.boolean(),
|
|
66
|
+
cols: DimensionSchema.optional(),
|
|
67
|
+
rows: DimensionSchema.optional(),
|
|
68
|
+
session: SessionNameSchema.optional()
|
|
69
|
+
}).refine((start) => start.session === void 0 || start.tty, {
|
|
70
|
+
message: "a session needs a tty",
|
|
71
|
+
path: ["tty"]
|
|
72
|
+
});
|
|
73
|
+
const ExecAttachMessageSchema = z.object({
|
|
74
|
+
type: z.literal("attach"),
|
|
75
|
+
name: NameSchema,
|
|
76
|
+
session: SessionNameSchema,
|
|
77
|
+
cols: DimensionSchema.optional(),
|
|
78
|
+
rows: DimensionSchema.optional()
|
|
79
|
+
});
|
|
80
|
+
z.discriminatedUnion("type", [
|
|
81
|
+
ExecStartMessageSchema,
|
|
82
|
+
ExecAttachMessageSchema,
|
|
83
|
+
z.object({ type: z.literal("stdin_eof") }),
|
|
84
|
+
z.object({
|
|
85
|
+
type: z.literal("resize"),
|
|
86
|
+
cols: DimensionSchema,
|
|
87
|
+
rows: DimensionSchema
|
|
88
|
+
}),
|
|
89
|
+
z.object({
|
|
90
|
+
type: z.literal("signal"),
|
|
91
|
+
signal: z.string().regex(/^SIG[A-Z0-9]+$/)
|
|
92
|
+
})
|
|
93
|
+
]);
|
|
94
|
+
const ExecServerMessageSchema = z.discriminatedUnion("type", [
|
|
95
|
+
z.object({
|
|
96
|
+
type: z.literal("started"),
|
|
97
|
+
pid: z.int().positive(),
|
|
98
|
+
session: SessionNameSchema.optional(),
|
|
99
|
+
created: z.boolean().optional()
|
|
100
|
+
}),
|
|
101
|
+
z.object({
|
|
102
|
+
type: z.literal("exit"),
|
|
103
|
+
code: z.int().nullable(),
|
|
104
|
+
signal: z.string().nullable()
|
|
105
|
+
}),
|
|
106
|
+
z.object({
|
|
107
|
+
type: z.literal("detached"),
|
|
108
|
+
reason: z.enum([
|
|
109
|
+
"taken_over",
|
|
110
|
+
"slow",
|
|
111
|
+
"lost"
|
|
112
|
+
])
|
|
113
|
+
}),
|
|
114
|
+
z.object({
|
|
115
|
+
type: z.literal("error"),
|
|
116
|
+
message: z.string(),
|
|
117
|
+
code: z.string().optional(),
|
|
118
|
+
data: z.unknown().optional()
|
|
119
|
+
})
|
|
120
|
+
]);
|
|
121
|
+
function encodeExecFrame(channel, data) {
|
|
122
|
+
const frame = new Uint8Array(data.byteLength + 1);
|
|
123
|
+
frame[0] = channel;
|
|
124
|
+
frame.set(data, 1);
|
|
125
|
+
return frame;
|
|
126
|
+
}
|
|
127
|
+
function decodeExecFrame(frame) {
|
|
128
|
+
const [channel] = frame;
|
|
129
|
+
if (!isExecChannel(channel)) throw new Error(`exec frame has an unknown channel byte: ${String(channel)}`);
|
|
130
|
+
return {
|
|
131
|
+
channel,
|
|
132
|
+
data: frame.subarray(1)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function isExecChannel(value) {
|
|
136
|
+
return value === EXEC_CHANNELS.stdin || value === EXEC_CHANNELS.stdout || value === EXEC_CHANNELS.stderr;
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/exec/exec-error.ts
|
|
140
|
+
var ExecError = class extends Error {
|
|
141
|
+
code;
|
|
142
|
+
data;
|
|
143
|
+
constructor(code, message, options = {}) {
|
|
144
|
+
const errorOptions = options.cause === void 0 ? {} : { cause: options.cause };
|
|
145
|
+
super(message, errorOptions);
|
|
146
|
+
this.name = "ExecError";
|
|
147
|
+
this.code = code;
|
|
148
|
+
this.data = options.data;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
function toExecError(outcome) {
|
|
152
|
+
if (outcome.kind === "failed") return new ExecError(outcome.code ?? "EXEC_ERROR", outcome.message, { data: outcome.data });
|
|
153
|
+
if (outcome.kind === "unauthorized") return new ExecError("UNAUTHORIZED", outcome.ticketRefused === true ? "impd refused the exec ticket: it expired (30 s) or was used already" : "impd rejected the token");
|
|
154
|
+
if (outcome.kind === "unreachable") return new ExecError("UNREACHABLE", `cannot reach impd (${outcome.detail})`);
|
|
155
|
+
if (outcome.kind === "detached") return new ExecError("DETACHED", `detached from the session (${outcome.reason})`, { data: { reason: outcome.reason } });
|
|
156
|
+
if (outcome.kind === "closed") return outcome.closeCode === 1012 ? new ExecError("RESTARTING", "impd is restarting") : new ExecError("CONNECTION_CLOSED", `exec connection closed (${outcome.reason})`);
|
|
157
|
+
if (outcome.kind === "bad_message") return new ExecError("BAD_MESSAGE", `bad message from impd: ${outcome.detail}`);
|
|
158
|
+
return new ExecError("LOCAL_ERROR", "an output handler threw", { cause: outcome.error });
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/resolve-impd-url.ts
|
|
162
|
+
function resolveImpdUrl(base, path) {
|
|
163
|
+
const url = new URL(base);
|
|
164
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`;
|
|
165
|
+
return url;
|
|
166
|
+
}
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/exec/check-impd-access.ts
|
|
169
|
+
const CHECK_TIMEOUT_MS = 5e3;
|
|
170
|
+
async function checkImpdAccess(baseUrl, token, customFetch = fetch) {
|
|
171
|
+
try {
|
|
172
|
+
const response = await customFetch(new Request(resolveImpdUrl(baseUrl, "/rpc/system/info").href, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: token === null ? {} : { authorization: `Bearer ${token}` },
|
|
175
|
+
signal: AbortSignal.timeout(CHECK_TIMEOUT_MS)
|
|
176
|
+
}));
|
|
177
|
+
await response.body?.cancel();
|
|
178
|
+
return response.status === 401 ? "unauthorized" : "reachable";
|
|
179
|
+
} catch {
|
|
180
|
+
return "unreachable";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/exec/open-exec-session.ts
|
|
185
|
+
const OPEN_TIMEOUT_MS = 1e4;
|
|
186
|
+
const HIGH_WATER_BYTES = 1048576;
|
|
187
|
+
const DRAIN_POLL_MS = 50;
|
|
188
|
+
var BadMessageError = class extends Error {
|
|
189
|
+
name = "BadMessageError";
|
|
190
|
+
};
|
|
191
|
+
function openExecSession(options) {
|
|
192
|
+
const url = resolveImpdUrl(options.baseUrl, EXEC_PATH);
|
|
193
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
194
|
+
if (options.ticket !== void 0) url.searchParams.set(EXEC_TICKET_PARAM, options.ticket);
|
|
195
|
+
const headers = options.token === null ? {} : { authorization: `Bearer ${options.token}` };
|
|
196
|
+
const ws = options.connect(url.href, headers);
|
|
197
|
+
const outcome = Promise.withResolvers();
|
|
198
|
+
const state = {
|
|
199
|
+
opened: false,
|
|
200
|
+
started: false,
|
|
201
|
+
finished: false
|
|
202
|
+
};
|
|
203
|
+
ws.binaryType = "arraybuffer";
|
|
204
|
+
const stopSocket = () => {
|
|
205
|
+
state.finished = true;
|
|
206
|
+
clearTimeout(openTimer);
|
|
207
|
+
ws.close();
|
|
208
|
+
};
|
|
209
|
+
const resolveOutcome = (result) => {
|
|
210
|
+
if (state.finished) return;
|
|
211
|
+
stopSocket();
|
|
212
|
+
outcome.resolve(result);
|
|
213
|
+
};
|
|
214
|
+
const openTimer = setTimeout(() => {
|
|
215
|
+
resolveOutcome({
|
|
216
|
+
kind: "unreachable",
|
|
217
|
+
detail: `no answer in ${String(OPEN_TIMEOUT_MS / 1e3)} s`
|
|
218
|
+
});
|
|
219
|
+
}, OPEN_TIMEOUT_MS);
|
|
220
|
+
const sendControl = (text) => {
|
|
221
|
+
if (!state.finished && ws.readyState === WebSocket.OPEN) ws.send(text);
|
|
222
|
+
};
|
|
223
|
+
const handleFrame = (data) => {
|
|
224
|
+
let frame;
|
|
225
|
+
try {
|
|
226
|
+
frame = decodeExecFrame(data);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
throw new BadMessageError(error instanceof Error ? error.message : String(error));
|
|
229
|
+
}
|
|
230
|
+
if (frame.channel === EXEC_CHANNELS.stdin) throw new BadMessageError("impd sent data on the stdin channel");
|
|
231
|
+
const channel = frame.channel === EXEC_CHANNELS.stderr ? "stderr" : "stdout";
|
|
232
|
+
options.onOutput(channel, frame.data);
|
|
233
|
+
};
|
|
234
|
+
const handleControl = (text) => {
|
|
235
|
+
let json;
|
|
236
|
+
try {
|
|
237
|
+
json = JSON.parse(text);
|
|
238
|
+
} catch {
|
|
239
|
+
throw new BadMessageError(`not JSON: ${text.slice(0, 80)}`);
|
|
240
|
+
}
|
|
241
|
+
const parsed = ExecServerMessageSchema.safeParse(json);
|
|
242
|
+
if (!parsed.success) throw new BadMessageError(`unknown message: ${text.slice(0, 80)}`);
|
|
243
|
+
const message = parsed.data;
|
|
244
|
+
if (message.type === "started") {
|
|
245
|
+
if (state.started) return;
|
|
246
|
+
state.started = true;
|
|
247
|
+
options.onStarted({
|
|
248
|
+
pid: message.pid,
|
|
249
|
+
session: message.session ?? null,
|
|
250
|
+
created: message.created ?? false
|
|
251
|
+
});
|
|
252
|
+
} else if (message.type === "exit") resolveOutcome({
|
|
253
|
+
kind: "exit",
|
|
254
|
+
code: message.code,
|
|
255
|
+
signal: message.signal
|
|
256
|
+
});
|
|
257
|
+
else if (message.type === "detached") resolveOutcome({
|
|
258
|
+
kind: "detached",
|
|
259
|
+
reason: message.reason
|
|
260
|
+
});
|
|
261
|
+
else resolveOutcome({
|
|
262
|
+
kind: "failed",
|
|
263
|
+
code: message.code ?? null,
|
|
264
|
+
message: message.message,
|
|
265
|
+
...message.data !== void 0 && { data: message.data }
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
ws.addEventListener("open", () => {
|
|
269
|
+
state.opened = true;
|
|
270
|
+
clearTimeout(openTimer);
|
|
271
|
+
sendControl(JSON.stringify(buildOpenMessage(options.start)));
|
|
272
|
+
});
|
|
273
|
+
ws.addEventListener("message", (event) => {
|
|
274
|
+
if (state.finished) return;
|
|
275
|
+
try {
|
|
276
|
+
if (event.data instanceof ArrayBuffer) handleFrame(new Uint8Array(event.data));
|
|
277
|
+
else handleControl(String(event.data));
|
|
278
|
+
} catch (error) {
|
|
279
|
+
const failure = error instanceof BadMessageError ? {
|
|
280
|
+
kind: "bad_message",
|
|
281
|
+
detail: error.message
|
|
282
|
+
} : {
|
|
283
|
+
kind: "local_error",
|
|
284
|
+
error
|
|
285
|
+
};
|
|
286
|
+
resolveOutcome(failure);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
const resolveRefusal = async (reason) => {
|
|
290
|
+
const access = await checkImpdAccess(options.baseUrl, options.token, options.fetch);
|
|
291
|
+
if (access === "unauthorized") resolveOutcome({ kind: "unauthorized" });
|
|
292
|
+
else if (access === "reachable" && options.ticket !== void 0) resolveOutcome({
|
|
293
|
+
kind: "unauthorized",
|
|
294
|
+
ticketRefused: true
|
|
295
|
+
});
|
|
296
|
+
else if (access === "unreachable") resolveOutcome({
|
|
297
|
+
kind: "unreachable",
|
|
298
|
+
detail: reason
|
|
299
|
+
});
|
|
300
|
+
else resolveOutcome({
|
|
301
|
+
kind: "closed",
|
|
302
|
+
reason
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
ws.addEventListener("close", (event) => {
|
|
306
|
+
if (state.finished) return;
|
|
307
|
+
const reason = event.reason === "" ? `code ${String(event.code)}` : event.reason;
|
|
308
|
+
if (state.opened) {
|
|
309
|
+
resolveOutcome({
|
|
310
|
+
kind: "closed",
|
|
311
|
+
reason,
|
|
312
|
+
closeCode: event.code
|
|
313
|
+
});
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
resolveRefusal(reason);
|
|
317
|
+
});
|
|
318
|
+
const waitForDrain = async () => {
|
|
319
|
+
while (!state.finished && ws.bufferedAmount > HIGH_WATER_BYTES) await new Promise((resolve) => {
|
|
320
|
+
setTimeout(resolve, DRAIN_POLL_MS);
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
return {
|
|
324
|
+
outcome: outcome.promise,
|
|
325
|
+
isStarted: () => state.started,
|
|
326
|
+
sendStdin: (data) => {
|
|
327
|
+
if (!state.finished && ws.readyState === WebSocket.OPEN) ws.send(encodeExecFrame(EXEC_CHANNELS.stdin, data));
|
|
328
|
+
return ws.bufferedAmount <= HIGH_WATER_BYTES;
|
|
329
|
+
},
|
|
330
|
+
waitForDrain,
|
|
331
|
+
closeStdin: () => {
|
|
332
|
+
sendControl(JSON.stringify({ type: "stdin_eof" }));
|
|
333
|
+
},
|
|
334
|
+
resize: (cols, rows) => {
|
|
335
|
+
sendControl(JSON.stringify({
|
|
336
|
+
type: "resize",
|
|
337
|
+
cols,
|
|
338
|
+
rows
|
|
339
|
+
}));
|
|
340
|
+
},
|
|
341
|
+
sendSignal: (signal) => {
|
|
342
|
+
sendControl(JSON.stringify({
|
|
343
|
+
type: "signal",
|
|
344
|
+
signal
|
|
345
|
+
}));
|
|
346
|
+
},
|
|
347
|
+
stop: () => {
|
|
348
|
+
stopSocket();
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function buildOpenMessage(start) {
|
|
353
|
+
if ("argv" in start) return {
|
|
354
|
+
type: "start",
|
|
355
|
+
...start,
|
|
356
|
+
argv: [...start.argv]
|
|
357
|
+
};
|
|
358
|
+
return {
|
|
359
|
+
type: "attach",
|
|
360
|
+
...start
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/exec/open-exec.ts
|
|
365
|
+
const DEFAULT_MAX_UNREAD_BYTES = 8388608;
|
|
366
|
+
const TTY_SIGNAL_KEYS = {
|
|
367
|
+
SIGINT: "",
|
|
368
|
+
SIGQUIT: ""
|
|
369
|
+
};
|
|
370
|
+
const CONSOLE_SHELL = [
|
|
371
|
+
"shell=",
|
|
372
|
+
"while IFS=: read -r user _ _ _ _ _ login; do",
|
|
373
|
+
" if [ \"$user\" = root ]; then shell=$login; break; fi",
|
|
374
|
+
"done < /etc/passwd",
|
|
375
|
+
"[ -x \"$shell\" ] || shell=/bin/bash",
|
|
376
|
+
"[ -x \"$shell\" ] || shell=/bin/sh",
|
|
377
|
+
"exec \"$shell\" -l"
|
|
378
|
+
].join("\n");
|
|
379
|
+
function openExec(deps, name, argv, options = {}) {
|
|
380
|
+
const tty = options.tty ?? false;
|
|
381
|
+
return openHandle(deps, options, tty, {
|
|
382
|
+
name,
|
|
383
|
+
argv,
|
|
384
|
+
tty,
|
|
385
|
+
...options.env !== void 0 && { env: options.env },
|
|
386
|
+
...options.cwd !== void 0 && { cwd: options.cwd },
|
|
387
|
+
...options.cols !== void 0 && { cols: options.cols },
|
|
388
|
+
...options.rows !== void 0 && { rows: options.rows },
|
|
389
|
+
...options.session !== void 0 && { session: options.session }
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
function openAttach(deps, name, session, options = {}) {
|
|
393
|
+
return openHandle(deps, options, true, {
|
|
394
|
+
name,
|
|
395
|
+
session,
|
|
396
|
+
...options.cols !== void 0 && { cols: options.cols },
|
|
397
|
+
...options.rows !== void 0 && { rows: options.rows }
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
async function openHandle(deps, options, tty, start) {
|
|
401
|
+
const name = start.name;
|
|
402
|
+
const abort = options.signal;
|
|
403
|
+
const callOptions = abort === void 0 ? {} : { signal: abort };
|
|
404
|
+
abort?.throwIfAborted();
|
|
405
|
+
const issued = await deps.rpc.exec.ticket({ name }, callOptions);
|
|
406
|
+
abort?.throwIfAborted();
|
|
407
|
+
const maxUnreadBytes = options.maxUnreadBytes ?? DEFAULT_MAX_UNREAD_BYTES;
|
|
408
|
+
const started = Promise.withResolvers();
|
|
409
|
+
const ended = Promise.withResolvers();
|
|
410
|
+
const state = {
|
|
411
|
+
ended: false,
|
|
412
|
+
cancelled: 0
|
|
413
|
+
};
|
|
414
|
+
const stopWith = (code, message) => {
|
|
415
|
+
session.stop();
|
|
416
|
+
resolveHandle({ error: new ExecError(code, message) });
|
|
417
|
+
};
|
|
418
|
+
const outputHooks = {
|
|
419
|
+
maxUnreadBytes,
|
|
420
|
+
onOverflow: (channel) => {
|
|
421
|
+
stopWith("OUTPUT_OVERFLOW", `${channel} holds more than ${String(maxUnreadBytes)} unread bytes; read or cancel it`);
|
|
422
|
+
},
|
|
423
|
+
onCancel: () => {
|
|
424
|
+
state.cancelled += 1;
|
|
425
|
+
if (state.cancelled === 2) stopWith("CLOSED", "both output streams were cancelled");
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
const stdout = buildOutputStream("stdout", outputHooks);
|
|
429
|
+
const stderr = buildOutputStream("stderr", outputHooks);
|
|
430
|
+
const session = openExecSession({
|
|
431
|
+
baseUrl: deps.baseUrl,
|
|
432
|
+
token: deps.token,
|
|
433
|
+
ticket: issued.ticket,
|
|
434
|
+
start,
|
|
435
|
+
onStarted: (info) => {
|
|
436
|
+
started.resolve({ value: info });
|
|
437
|
+
},
|
|
438
|
+
onOutput: (channel, data) => {
|
|
439
|
+
(channel === "stderr" ? stderr : stdout).push(data);
|
|
440
|
+
},
|
|
441
|
+
connect: (url) => new WebSocket(url),
|
|
442
|
+
...deps.fetch !== void 0 && { fetch: deps.fetch }
|
|
443
|
+
});
|
|
444
|
+
const resolveHandle = (result) => {
|
|
445
|
+
if (state.ended) return;
|
|
446
|
+
state.ended = true;
|
|
447
|
+
stdout.end();
|
|
448
|
+
stderr.end();
|
|
449
|
+
abort?.removeEventListener("abort", stopSession);
|
|
450
|
+
if ("error" in result) started.resolve({ error: result.error });
|
|
451
|
+
ended.resolve(result);
|
|
452
|
+
};
|
|
453
|
+
const stopSession = () => {
|
|
454
|
+
stopWith("CLOSED", "the exec session was closed");
|
|
455
|
+
};
|
|
456
|
+
const waitForOutcome = async () => {
|
|
457
|
+
const outcome = await session.outcome;
|
|
458
|
+
const result = outcome.kind === "exit" ? { value: {
|
|
459
|
+
code: outcome.code,
|
|
460
|
+
signal: outcome.signal
|
|
461
|
+
} } : { error: toExecError(outcome) };
|
|
462
|
+
resolveHandle(result);
|
|
463
|
+
};
|
|
464
|
+
if (abort?.aborted === true) stopSession();
|
|
465
|
+
else abort?.addEventListener("abort", stopSession, { once: true });
|
|
466
|
+
waitForOutcome();
|
|
467
|
+
return buildHandle(session, {
|
|
468
|
+
started: started.promise,
|
|
469
|
+
ended: ended.promise,
|
|
470
|
+
isEnded: () => state.ended,
|
|
471
|
+
tty,
|
|
472
|
+
stopSession,
|
|
473
|
+
streams: [stdout.stream, stderr.stream]
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
function openConsole(deps, name, options = {}) {
|
|
477
|
+
return openExec(deps, name, [
|
|
478
|
+
"/bin/sh",
|
|
479
|
+
"-c",
|
|
480
|
+
CONSOLE_SHELL
|
|
481
|
+
], {
|
|
482
|
+
...options,
|
|
483
|
+
tty: true,
|
|
484
|
+
env: options.env ?? { TERM: "xterm-256color" }
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
async function runCommand(deps, name, argv, options = {}) {
|
|
488
|
+
const handle = await openExec(deps, name, argv, options);
|
|
489
|
+
const output = Promise.all([readAll(handle.stdout), readAll(handle.stderr)]);
|
|
490
|
+
if (options.stdin !== void 0) await handle.write(options.stdin);
|
|
491
|
+
await handle.closeStdin();
|
|
492
|
+
const [exit, [stdout, stderr]] = await Promise.all([handle.exit, output]);
|
|
493
|
+
return {
|
|
494
|
+
...exit,
|
|
495
|
+
stdout,
|
|
496
|
+
stderr
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function buildHandle(session, parts) {
|
|
500
|
+
const encoder = new TextEncoder();
|
|
501
|
+
const waitForStart = async () => {
|
|
502
|
+
const result = await parts.started;
|
|
503
|
+
if ("error" in result) throw result.error;
|
|
504
|
+
return result.value;
|
|
505
|
+
};
|
|
506
|
+
const waitForExit = async () => {
|
|
507
|
+
const result = await parts.ended;
|
|
508
|
+
if ("error" in result) throw result.error;
|
|
509
|
+
return result.value;
|
|
510
|
+
};
|
|
511
|
+
const startedPromise = waitForStart();
|
|
512
|
+
const exitPromise = waitForExit();
|
|
513
|
+
waitIgnoringRejection(startedPromise);
|
|
514
|
+
waitIgnoringRejection(exitPromise);
|
|
515
|
+
const requireOpen = () => {
|
|
516
|
+
if (parts.isEnded()) throw new ExecError("CLOSED", "the exec session has ended");
|
|
517
|
+
};
|
|
518
|
+
const write = async (data) => {
|
|
519
|
+
requireOpen();
|
|
520
|
+
await startedPromise;
|
|
521
|
+
requireOpen();
|
|
522
|
+
const bytes = typeof data === "string" ? encoder.encode(data) : data;
|
|
523
|
+
if (!session.sendStdin(bytes)) await session.waitForDrain();
|
|
524
|
+
};
|
|
525
|
+
const sendKey = async (key) => {
|
|
526
|
+
try {
|
|
527
|
+
await write(key);
|
|
528
|
+
} catch {}
|
|
529
|
+
};
|
|
530
|
+
return {
|
|
531
|
+
started: startedPromise,
|
|
532
|
+
stdout: parts.streams[0],
|
|
533
|
+
stderr: parts.streams[1],
|
|
534
|
+
exit: exitPromise,
|
|
535
|
+
write,
|
|
536
|
+
closeStdin: async () => {
|
|
537
|
+
await startedPromise;
|
|
538
|
+
requireOpen();
|
|
539
|
+
session.closeStdin();
|
|
540
|
+
},
|
|
541
|
+
resize: (cols, rows) => {
|
|
542
|
+
session.resize(cols, rows);
|
|
543
|
+
},
|
|
544
|
+
sendSignal: (signal) => {
|
|
545
|
+
const key = parts.tty ? TTY_SIGNAL_KEYS[signal] : void 0;
|
|
546
|
+
if (key === void 0) session.sendSignal(signal);
|
|
547
|
+
else sendKey(key);
|
|
548
|
+
},
|
|
549
|
+
close: parts.stopSession
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
async function waitIgnoringRejection(promise) {
|
|
553
|
+
try {
|
|
554
|
+
await promise;
|
|
555
|
+
} catch {}
|
|
556
|
+
}
|
|
557
|
+
function buildOutputStream(channel, hooks) {
|
|
558
|
+
const state = {
|
|
559
|
+
controller: null,
|
|
560
|
+
open: true
|
|
561
|
+
};
|
|
562
|
+
return {
|
|
563
|
+
stream: new ReadableStream({
|
|
564
|
+
start: (controller) => {
|
|
565
|
+
state.controller = controller;
|
|
566
|
+
},
|
|
567
|
+
cancel: () => {
|
|
568
|
+
state.open = false;
|
|
569
|
+
hooks.onCancel();
|
|
570
|
+
}
|
|
571
|
+
}, {
|
|
572
|
+
highWaterMark: hooks.maxUnreadBytes,
|
|
573
|
+
size: (chunk) => chunk?.byteLength ?? 0
|
|
574
|
+
}),
|
|
575
|
+
push: (data) => {
|
|
576
|
+
if (!state.open || state.controller === null) return;
|
|
577
|
+
state.controller.enqueue(data);
|
|
578
|
+
if ((state.controller.desiredSize ?? 0) < 0) hooks.onOverflow(channel);
|
|
579
|
+
},
|
|
580
|
+
end: () => {
|
|
581
|
+
if (state.open) {
|
|
582
|
+
state.open = false;
|
|
583
|
+
state.controller?.close();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
async function readAll(stream) {
|
|
589
|
+
const reader = stream.getReader();
|
|
590
|
+
const chunks = [];
|
|
591
|
+
for (;;) {
|
|
592
|
+
const result = await reader.read();
|
|
593
|
+
if (result.done) break;
|
|
594
|
+
chunks.push(result.value);
|
|
595
|
+
}
|
|
596
|
+
const all = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0));
|
|
597
|
+
let offset = 0;
|
|
598
|
+
for (const chunk of chunks) {
|
|
599
|
+
all.set(chunk, offset);
|
|
600
|
+
offset += chunk.byteLength;
|
|
601
|
+
}
|
|
602
|
+
return all;
|
|
603
|
+
}
|
|
604
|
+
//#endregion
|
|
605
|
+
//#region src/require-awake.ts
|
|
606
|
+
async function requireAwake(rpc, name, options = {}) {
|
|
607
|
+
const signal = options.signal;
|
|
608
|
+
const callOptions = signal === void 0 ? {} : { signal };
|
|
609
|
+
const retry = options.retryUnavailable ?? {
|
|
610
|
+
attempts: 0,
|
|
611
|
+
delayMs: 0
|
|
612
|
+
};
|
|
613
|
+
const input = {
|
|
614
|
+
name,
|
|
615
|
+
restartError: options.restartError === true
|
|
616
|
+
};
|
|
617
|
+
for (let attempt = 0;; attempt++) try {
|
|
618
|
+
return await rpc.imps.wake(input, callOptions);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
if (!isUnavailable(error) || attempt >= retry.attempts) throw error;
|
|
621
|
+
await waitFor(retry.delayMs, signal);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function isUnavailable(error) {
|
|
625
|
+
if (error instanceof ORPCError$1) return error.code === "SERVICE_UNAVAILABLE";
|
|
626
|
+
return error instanceof TypeError;
|
|
627
|
+
}
|
|
628
|
+
function waitFor(ms, signal) {
|
|
629
|
+
signal?.throwIfAborted();
|
|
630
|
+
const done = Promise.withResolvers();
|
|
631
|
+
const onAbort = () => {
|
|
632
|
+
clearTimeout(timer);
|
|
633
|
+
done.reject(signal?.reason);
|
|
634
|
+
};
|
|
635
|
+
const timer = setTimeout(() => {
|
|
636
|
+
signal?.removeEventListener("abort", onAbort);
|
|
637
|
+
done.resolve();
|
|
638
|
+
}, ms);
|
|
639
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
640
|
+
return done.promise;
|
|
641
|
+
}
|
|
642
|
+
//#endregion
|
|
643
|
+
//#region src/create-imp-client.ts
|
|
644
|
+
function createImpClient(options) {
|
|
645
|
+
const token = options.token;
|
|
646
|
+
const customFetch = options.fetch;
|
|
647
|
+
const link = new RPCLink({
|
|
648
|
+
url: resolveImpdUrl(options.url, "/rpc").href,
|
|
649
|
+
headers: token === void 0 ? {} : { authorization: `Bearer ${token}` },
|
|
650
|
+
...customFetch !== void 0 && { fetch: (request) => customFetch(request) }
|
|
651
|
+
});
|
|
652
|
+
const rpc = createORPCClient(link);
|
|
653
|
+
const namespaces = {
|
|
654
|
+
imps: rpc.imps,
|
|
655
|
+
checkpoints: rpc.checkpoints,
|
|
656
|
+
images: rpc.images,
|
|
657
|
+
exec: rpc.exec,
|
|
658
|
+
sessions: rpc.sessions,
|
|
659
|
+
system: rpc.system
|
|
660
|
+
};
|
|
661
|
+
const execDeps = {
|
|
662
|
+
rpc,
|
|
663
|
+
baseUrl: options.url,
|
|
664
|
+
token: token ?? null,
|
|
665
|
+
...customFetch !== void 0 && { fetch: customFetch }
|
|
666
|
+
};
|
|
667
|
+
return {
|
|
668
|
+
...namespaces,
|
|
669
|
+
openExec: (name, argv, execOptions) => openExec(execDeps, name, argv, execOptions),
|
|
670
|
+
run: (name, argv, runOptions) => runCommand(execDeps, name, argv, runOptions),
|
|
671
|
+
openConsole: (name, consoleOptions) => openConsole(execDeps, name, consoleOptions),
|
|
672
|
+
openAttach: (name, session, attachOptions) => openAttach(execDeps, name, session, attachOptions),
|
|
673
|
+
requireAwake: (name, awakeOptions) => requireAwake(rpc, name, awakeOptions),
|
|
674
|
+
checkServer: () => checkServer(rpc)
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
//#endregion
|
|
678
|
+
export { CLIENT_VERSION, CONSOLE_SHELL, ExecError, ORPCError, createImpClient, isDefinedError, openExecSession, safe };
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zgeoff/imp-client",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Typed client for impd, the imp host daemon: imps, checkpoints, images and exec",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/zgeoff/imp.git",
|
|
9
|
+
"directory": "packages/client"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@orpc/client": "^1.14.15",
|
|
20
|
+
"@orpc/contract": "^1.14.15",
|
|
21
|
+
"zod": "^4.4.3"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@opentelemetry/api": ">=1.9.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependenciesMeta": {
|
|
27
|
+
"@opentelemetry/api": {
|
|
28
|
+
"optional": true
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|