@zgeoff/imp-client 0.6.0 → 0.8.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/README.md +5 -3
- package/dist/index.d.ts +571 -2
- package/dist/index.js +301 -12
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { RPCLink } from "@orpc/client/fetch";
|
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
//#endregion
|
|
5
5
|
//#region src/check-server.ts
|
|
6
|
-
const CLIENT_VERSION = "0.
|
|
6
|
+
const CLIENT_VERSION = "0.8.0";
|
|
7
7
|
async function checkServer(rpc) {
|
|
8
8
|
const info = await rpc.system.info();
|
|
9
9
|
return {
|
|
@@ -66,10 +66,14 @@ const ExecStartMessageSchema = z.object({
|
|
|
66
66
|
cols: DimensionSchema.optional(),
|
|
67
67
|
rows: DimensionSchema.optional(),
|
|
68
68
|
session: SessionNameSchema.optional(),
|
|
69
|
-
tool: z.enum(["tar"]).optional()
|
|
69
|
+
tool: z.enum(["tar"]).optional(),
|
|
70
|
+
killGraceMs: z.int().min(1).max(6e4).optional()
|
|
70
71
|
}).refine((start) => start.session === void 0 || start.tty, {
|
|
71
72
|
message: "a session needs a tty",
|
|
72
73
|
path: ["tty"]
|
|
74
|
+
}).refine((start) => start.killGraceMs === void 0 || !start.tty, {
|
|
75
|
+
message: "a tty exec takes no kill grace",
|
|
76
|
+
path: ["killGraceMs"]
|
|
73
77
|
}).refine((start) => start.tool === void 0 || !start.tty && start.session === void 0 && start.env === void 0 && start.cwd === void 0, {
|
|
74
78
|
message: "a tool takes no tty, session, env or cwd",
|
|
75
79
|
path: ["tool"]
|
|
@@ -104,7 +108,8 @@ const ExecServerMessageSchema = z.discriminatedUnion("type", [
|
|
|
104
108
|
type: z.literal("started"),
|
|
105
109
|
pid: z.int().positive(),
|
|
106
110
|
session: SessionNameSchema.optional(),
|
|
107
|
-
created: z.boolean().optional()
|
|
111
|
+
created: z.boolean().optional(),
|
|
112
|
+
groupKill: z.boolean().optional()
|
|
108
113
|
}),
|
|
109
114
|
z.object({
|
|
110
115
|
type: z.literal("exit"),
|
|
@@ -148,6 +153,63 @@ function isExecChannel(value) {
|
|
|
148
153
|
return value === EXEC_CHANNELS.stdin || value === EXEC_CHANNELS.stdout || value === EXEC_CHANNELS.stderr;
|
|
149
154
|
}
|
|
150
155
|
//#endregion
|
|
156
|
+
//#region ../api/src/tunnel-protocol.ts
|
|
157
|
+
const TUNNEL_PATH = "/tunnel";
|
|
158
|
+
const TUNNEL_CLOSE_NORMAL = 1e3;
|
|
159
|
+
const TUNNEL_CLOSE_PROTOCOL = 1002;
|
|
160
|
+
const TUNNEL_WINDOW_BYTES = 1048576;
|
|
161
|
+
const TUNNEL_MAX_FRAME_BYTES = 65536;
|
|
162
|
+
const AckSchema = z.object({
|
|
163
|
+
type: z.literal("ack"),
|
|
164
|
+
bytes: z.int().positive()
|
|
165
|
+
});
|
|
166
|
+
const TunnelListenSchema = z.object({
|
|
167
|
+
type: z.literal("listen"),
|
|
168
|
+
name: NameSchema,
|
|
169
|
+
network: z.enum(["unix", "tcp"]),
|
|
170
|
+
path: z.string().startsWith("/").nullable().optional(),
|
|
171
|
+
port: z.int().min(0).max(65535).optional()
|
|
172
|
+
}).refine((listen) => listen.network === "tcp" ? listen.port !== void 0 && listen.path === void 0 : listen.port === void 0, {
|
|
173
|
+
message: "tcp takes a port, unix a path or null",
|
|
174
|
+
path: ["network"]
|
|
175
|
+
});
|
|
176
|
+
z.discriminatedUnion("type", [
|
|
177
|
+
z.object({
|
|
178
|
+
type: z.literal("open"),
|
|
179
|
+
name: NameSchema,
|
|
180
|
+
port: z.int().min(1).max(65535)
|
|
181
|
+
}),
|
|
182
|
+
z.object({ type: z.literal("eof") }),
|
|
183
|
+
AckSchema,
|
|
184
|
+
TunnelListenSchema,
|
|
185
|
+
z.object({
|
|
186
|
+
type: z.literal("accept"),
|
|
187
|
+
name: NameSchema,
|
|
188
|
+
listener: z.string().min(1),
|
|
189
|
+
connection: z.int().positive()
|
|
190
|
+
})
|
|
191
|
+
]);
|
|
192
|
+
const TunnelServerMessageSchema = z.discriminatedUnion("type", [
|
|
193
|
+
z.object({ type: z.literal("opened") }),
|
|
194
|
+
z.object({ type: z.literal("eof") }),
|
|
195
|
+
AckSchema,
|
|
196
|
+
z.object({
|
|
197
|
+
type: z.literal("listening"),
|
|
198
|
+
listener: z.string(),
|
|
199
|
+
path: z.string().nullable(),
|
|
200
|
+
port: z.int().nullable()
|
|
201
|
+
}),
|
|
202
|
+
z.object({
|
|
203
|
+
type: z.literal("connection"),
|
|
204
|
+
id: z.int().positive()
|
|
205
|
+
}),
|
|
206
|
+
z.object({
|
|
207
|
+
type: z.literal("error"),
|
|
208
|
+
message: z.string(),
|
|
209
|
+
code: z.string().optional()
|
|
210
|
+
})
|
|
211
|
+
]);
|
|
212
|
+
//#endregion
|
|
151
213
|
//#region ../api/src/login-shell.ts
|
|
152
214
|
const CONSOLE_SHELL = [
|
|
153
215
|
"uid=$(id -u 2>/dev/null) || uid=0",
|
|
@@ -222,7 +284,8 @@ function openExecSession(options) {
|
|
|
222
284
|
const state = {
|
|
223
285
|
opened: false,
|
|
224
286
|
started: false,
|
|
225
|
-
finished: false
|
|
287
|
+
finished: false,
|
|
288
|
+
refused: false
|
|
226
289
|
};
|
|
227
290
|
ws.binaryType = "arraybuffer";
|
|
228
291
|
const stopSocket = () => {
|
|
@@ -271,7 +334,8 @@ function openExecSession(options) {
|
|
|
271
334
|
options.onStarted({
|
|
272
335
|
pid: message.pid,
|
|
273
336
|
session: message.session ?? null,
|
|
274
|
-
created: message.created ?? false
|
|
337
|
+
created: message.created ?? false,
|
|
338
|
+
groupKill: message.groupKill ?? false
|
|
275
339
|
});
|
|
276
340
|
} else if (message.type === "exit") resolveOutcome({
|
|
277
341
|
kind: "exit",
|
|
@@ -311,6 +375,8 @@ function openExecSession(options) {
|
|
|
311
375
|
}
|
|
312
376
|
});
|
|
313
377
|
const resolveRefusal = async (reason) => {
|
|
378
|
+
if (state.refused) return;
|
|
379
|
+
state.refused = true;
|
|
314
380
|
const access = await checkImpdAccess(options.baseUrl, options.token, options.fetch);
|
|
315
381
|
if (access === "unauthorized") resolveOutcome({ kind: "unauthorized" });
|
|
316
382
|
else if (access === "reachable" && options.ticket !== void 0) resolveOutcome({
|
|
@@ -326,6 +392,9 @@ function openExecSession(options) {
|
|
|
326
392
|
reason
|
|
327
393
|
});
|
|
328
394
|
};
|
|
395
|
+
ws.addEventListener("error", () => {
|
|
396
|
+
if (!state.opened && !state.finished) resolveRefusal("the connection failed");
|
|
397
|
+
});
|
|
329
398
|
ws.addEventListener("close", (event) => {
|
|
330
399
|
if (state.finished) return;
|
|
331
400
|
const reason = event.reason === "" ? `code ${String(event.code)}` : event.reason;
|
|
@@ -401,7 +470,8 @@ function openExec(deps, name, argv, options = {}) {
|
|
|
401
470
|
...options.cwd !== void 0 && { cwd: options.cwd },
|
|
402
471
|
...options.cols !== void 0 && { cols: options.cols },
|
|
403
472
|
...options.rows !== void 0 && { rows: options.rows },
|
|
404
|
-
...options.session !== void 0 && { session: options.session }
|
|
473
|
+
...options.session !== void 0 && { session: options.session },
|
|
474
|
+
...options.killGraceMs !== void 0 && { killGraceMs: options.killGraceMs }
|
|
405
475
|
});
|
|
406
476
|
}
|
|
407
477
|
function openAttach(deps, name, session, options = {}) {
|
|
@@ -453,7 +523,7 @@ async function openHandle(deps, options, tty, start) {
|
|
|
453
523
|
onOutput: (channel, data) => {
|
|
454
524
|
(channel === "stderr" ? stderr : stdout).push(data);
|
|
455
525
|
},
|
|
456
|
-
connect: (url) => new WebSocket(url),
|
|
526
|
+
connect: deps.connect ?? ((url) => new WebSocket(url)),
|
|
457
527
|
...deps.fetch !== void 0 && { fetch: deps.fetch }
|
|
458
528
|
});
|
|
459
529
|
const resolveHandle = (result) => {
|
|
@@ -461,13 +531,21 @@ async function openHandle(deps, options, tty, start) {
|
|
|
461
531
|
state.ended = true;
|
|
462
532
|
stdout.end();
|
|
463
533
|
stderr.end();
|
|
464
|
-
abort?.removeEventListener("abort",
|
|
534
|
+
abort?.removeEventListener("abort", stopForAbort);
|
|
465
535
|
if ("error" in result) started.resolve({ error: result.error });
|
|
466
536
|
ended.resolve(result);
|
|
467
537
|
};
|
|
468
538
|
const stopSession = () => {
|
|
469
539
|
stopWith("CLOSED", "the exec session was closed");
|
|
470
540
|
};
|
|
541
|
+
const stopForAbort = () => {
|
|
542
|
+
if (session.isStarted()) {
|
|
543
|
+
stopSession();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
session.stop();
|
|
547
|
+
resolveHandle({ error: readAbortReason(abort) });
|
|
548
|
+
};
|
|
471
549
|
const waitForOutcome = async () => {
|
|
472
550
|
const outcome = await session.outcome;
|
|
473
551
|
const result = outcome.kind === "exit" ? { value: {
|
|
@@ -476,8 +554,8 @@ async function openHandle(deps, options, tty, start) {
|
|
|
476
554
|
} } : { error: toExecError(outcome) };
|
|
477
555
|
resolveHandle(result);
|
|
478
556
|
};
|
|
479
|
-
if (abort?.aborted === true)
|
|
480
|
-
else abort?.addEventListener("abort",
|
|
557
|
+
if (abort?.aborted === true) stopForAbort();
|
|
558
|
+
else abort?.addEventListener("abort", stopForAbort, { once: true });
|
|
481
559
|
waitForOutcome();
|
|
482
560
|
return buildHandle(session, {
|
|
483
561
|
started: started.promise,
|
|
@@ -488,6 +566,10 @@ async function openHandle(deps, options, tty, start) {
|
|
|
488
566
|
streams: [stdout.stream, stderr.stream]
|
|
489
567
|
});
|
|
490
568
|
}
|
|
569
|
+
function readAbortReason(abort) {
|
|
570
|
+
const reason = abort?.reason;
|
|
571
|
+
return reason instanceof Error ? reason : new DOMException("the exec was aborted", "AbortError");
|
|
572
|
+
}
|
|
491
573
|
function openConsole(deps, name, options = {}) {
|
|
492
574
|
return openExec(deps, name, [
|
|
493
575
|
"/bin/sh",
|
|
@@ -683,7 +765,8 @@ function createImpClient(options) {
|
|
|
683
765
|
rpc,
|
|
684
766
|
baseUrl: options.url,
|
|
685
767
|
token: token ?? null,
|
|
686
|
-
...customFetch !== void 0 && { fetch: customFetch }
|
|
768
|
+
...customFetch !== void 0 && { fetch: customFetch },
|
|
769
|
+
...options.connect !== void 0 && { connect: options.connect }
|
|
687
770
|
};
|
|
688
771
|
return {
|
|
689
772
|
...namespaces,
|
|
@@ -696,4 +779,210 @@ function createImpClient(options) {
|
|
|
696
779
|
};
|
|
697
780
|
}
|
|
698
781
|
//#endregion
|
|
699
|
-
|
|
782
|
+
//#region src/reverse/open-reverse-forward.ts
|
|
783
|
+
function parseMessage(text) {
|
|
784
|
+
try {
|
|
785
|
+
const parsed = TunnelServerMessageSchema.safeParse(JSON.parse(text));
|
|
786
|
+
return parsed.success ? parsed.data : null;
|
|
787
|
+
} catch {
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
function buildListenMessage(name, guest) {
|
|
792
|
+
return guest.network === "tcp" ? {
|
|
793
|
+
type: "listen",
|
|
794
|
+
name,
|
|
795
|
+
network: "tcp",
|
|
796
|
+
port: guest.port
|
|
797
|
+
} : {
|
|
798
|
+
type: "listen",
|
|
799
|
+
name,
|
|
800
|
+
network: "unix",
|
|
801
|
+
path: guest.path
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
function openRelay(target, accept, handlers) {
|
|
805
|
+
const ws = target.connect(target.url, target.headers);
|
|
806
|
+
const state = {
|
|
807
|
+
opened: false,
|
|
808
|
+
unacked: 0,
|
|
809
|
+
done: false,
|
|
810
|
+
peerEof: false,
|
|
811
|
+
guestEof: false
|
|
812
|
+
};
|
|
813
|
+
const waiters = [];
|
|
814
|
+
const pending = [];
|
|
815
|
+
ws.binaryType = "arraybuffer";
|
|
816
|
+
const wakeWaiters = () => {
|
|
817
|
+
for (const wake of waiters.splice(0)) wake();
|
|
818
|
+
};
|
|
819
|
+
const sendText = (message) => {
|
|
820
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
|
|
821
|
+
};
|
|
822
|
+
const sendFrames = (data) => {
|
|
823
|
+
for (let offset = 0; offset < data.byteLength; offset += TUNNEL_MAX_FRAME_BYTES) ws.send(Uint8Array.from(data.subarray(offset, offset + TUNNEL_MAX_FRAME_BYTES)));
|
|
824
|
+
};
|
|
825
|
+
const stopRelay = (lost) => {
|
|
826
|
+
if (state.done) return;
|
|
827
|
+
state.done = true;
|
|
828
|
+
wakeWaiters();
|
|
829
|
+
handlers.onClose(lost);
|
|
830
|
+
};
|
|
831
|
+
const sendAck = async (data) => {
|
|
832
|
+
await handlers.onData(data);
|
|
833
|
+
sendText({
|
|
834
|
+
type: "ack",
|
|
835
|
+
bytes: data.byteLength
|
|
836
|
+
});
|
|
837
|
+
};
|
|
838
|
+
const handleControl = (message) => {
|
|
839
|
+
if (message.type === "opened") {
|
|
840
|
+
state.opened = true;
|
|
841
|
+
for (const data of pending.splice(0)) sendFrames(data);
|
|
842
|
+
if (state.peerEof) sendText({ type: "eof" });
|
|
843
|
+
} else if (message.type === "eof") {
|
|
844
|
+
state.guestEof = true;
|
|
845
|
+
handlers.onEof();
|
|
846
|
+
} else if (message.type === "ack") {
|
|
847
|
+
state.unacked -= message.bytes;
|
|
848
|
+
if (state.unacked <= 1048576) wakeWaiters();
|
|
849
|
+
} else {
|
|
850
|
+
ws.close(TUNNEL_CLOSE_NORMAL, "relay failed");
|
|
851
|
+
stopRelay(true);
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
ws.addEventListener("open", () => {
|
|
855
|
+
sendText(accept);
|
|
856
|
+
});
|
|
857
|
+
ws.addEventListener("message", (event) => {
|
|
858
|
+
if (event.data instanceof ArrayBuffer) {
|
|
859
|
+
sendAck(new Uint8Array(event.data));
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const message = parseMessage(String(event.data));
|
|
863
|
+
if (message === null) {
|
|
864
|
+
ws.close(TUNNEL_CLOSE_PROTOCOL, "bad message");
|
|
865
|
+
stopRelay(true);
|
|
866
|
+
} else handleControl(message);
|
|
867
|
+
});
|
|
868
|
+
ws.addEventListener("close", (event) => {
|
|
869
|
+
stopRelay(event.code !== TUNNEL_CLOSE_NORMAL);
|
|
870
|
+
});
|
|
871
|
+
ws.addEventListener("error", () => {});
|
|
872
|
+
return {
|
|
873
|
+
send: (data) => {
|
|
874
|
+
if (state.done) return true;
|
|
875
|
+
if (state.opened) sendFrames(data);
|
|
876
|
+
else pending.push(Uint8Array.from(data));
|
|
877
|
+
state.unacked += data.byteLength;
|
|
878
|
+
return state.unacked <= TUNNEL_WINDOW_BYTES;
|
|
879
|
+
},
|
|
880
|
+
waitForRoom: async () => {
|
|
881
|
+
while (!state.done && state.unacked > 1048576) await new Promise((resolve) => {
|
|
882
|
+
waiters.push(resolve);
|
|
883
|
+
});
|
|
884
|
+
},
|
|
885
|
+
sendEof: () => {
|
|
886
|
+
state.peerEof = true;
|
|
887
|
+
if (state.opened) sendText({ type: "eof" });
|
|
888
|
+
},
|
|
889
|
+
close: () => {
|
|
890
|
+
if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) ws.close(TUNNEL_CLOSE_NORMAL, "local side closed");
|
|
891
|
+
stopRelay(false);
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
function openReverseForward(options) {
|
|
896
|
+
const url = resolveImpdUrl(options.baseUrl, TUNNEL_PATH);
|
|
897
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
898
|
+
const headers = options.token === null ? {} : { authorization: `Bearer ${options.token}` };
|
|
899
|
+
const target = {
|
|
900
|
+
url: url.href,
|
|
901
|
+
headers,
|
|
902
|
+
connect: options.connect
|
|
903
|
+
};
|
|
904
|
+
const ws = options.connect(url.href, headers);
|
|
905
|
+
const listening = Promise.withResolvers();
|
|
906
|
+
const ended = Promise.withResolvers();
|
|
907
|
+
const relays = /* @__PURE__ */ new Set();
|
|
908
|
+
const state = {
|
|
909
|
+
listener: "",
|
|
910
|
+
done: false
|
|
911
|
+
};
|
|
912
|
+
const waitForListening = async () => {
|
|
913
|
+
try {
|
|
914
|
+
await listening.promise;
|
|
915
|
+
} catch {}
|
|
916
|
+
};
|
|
917
|
+
waitForListening();
|
|
918
|
+
const stopForward = (end) => {
|
|
919
|
+
if (state.done) return;
|
|
920
|
+
state.done = true;
|
|
921
|
+
for (const relay of relays) relay.close();
|
|
922
|
+
const why = end.kind === "failed" ? end.message : `the forward ${end.kind}`;
|
|
923
|
+
listening.reject(new Error(why));
|
|
924
|
+
ended.resolve(end);
|
|
925
|
+
};
|
|
926
|
+
const openClientRelay = (id) => {
|
|
927
|
+
const message = {
|
|
928
|
+
type: "accept",
|
|
929
|
+
name: options.name,
|
|
930
|
+
listener: state.listener,
|
|
931
|
+
connection: id
|
|
932
|
+
};
|
|
933
|
+
options.onConnection((handlers) => {
|
|
934
|
+
const relay = openRelay(target, message, {
|
|
935
|
+
...handlers,
|
|
936
|
+
onClose: (lost) => {
|
|
937
|
+
relays.delete(relay);
|
|
938
|
+
handlers.onClose(lost);
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
relays.add(relay);
|
|
942
|
+
return relay;
|
|
943
|
+
});
|
|
944
|
+
};
|
|
945
|
+
const handleControl = (message) => {
|
|
946
|
+
if (message.type === "listening") {
|
|
947
|
+
state.listener = message.listener;
|
|
948
|
+
listening.resolve({
|
|
949
|
+
path: message.path,
|
|
950
|
+
port: message.port
|
|
951
|
+
});
|
|
952
|
+
} else if (message.type === "connection") openClientRelay(message.id);
|
|
953
|
+
else if (message.type === "error") stopForward({
|
|
954
|
+
kind: "failed",
|
|
955
|
+
code: message.code ?? null,
|
|
956
|
+
message: message.message
|
|
957
|
+
});
|
|
958
|
+
else ws.close(TUNNEL_CLOSE_PROTOCOL, "bad message");
|
|
959
|
+
};
|
|
960
|
+
ws.binaryType = "arraybuffer";
|
|
961
|
+
ws.addEventListener("open", () => {
|
|
962
|
+
ws.send(JSON.stringify(buildListenMessage(options.name, options.guest)));
|
|
963
|
+
});
|
|
964
|
+
ws.addEventListener("message", (event) => {
|
|
965
|
+
const message = event.data instanceof ArrayBuffer ? null : parseMessage(String(event.data));
|
|
966
|
+
if (message === null) ws.close(TUNNEL_CLOSE_PROTOCOL, "bad message");
|
|
967
|
+
else handleControl(message);
|
|
968
|
+
});
|
|
969
|
+
ws.addEventListener("close", (event) => {
|
|
970
|
+
if (event.code === 4e3) stopForward({ kind: "lost" });
|
|
971
|
+
else stopForward({
|
|
972
|
+
kind: "closed",
|
|
973
|
+
code: event.code,
|
|
974
|
+
reason: event.reason
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
ws.addEventListener("error", () => {});
|
|
978
|
+
return {
|
|
979
|
+
listening: listening.promise,
|
|
980
|
+
ended: ended.promise,
|
|
981
|
+
stop: () => {
|
|
982
|
+
if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) ws.close(TUNNEL_CLOSE_NORMAL, "stopped");
|
|
983
|
+
stopForward({ kind: "stopped" });
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
//#endregion
|
|
988
|
+
export { CLIENT_VERSION, CONSOLE_SHELL, ExecError, ORPCError, createImpClient, isDefinedError, openExecSession, openReverseForward, safe };
|