@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/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.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", stopSession);
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) stopSession();
480
- else abort?.addEventListener("abort", stopSession, { once: true });
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
- export { CLIENT_VERSION, CONSOLE_SHELL, ExecError, ORPCError, createImpClient, isDefinedError, openExecSession, safe };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/imp-client",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Typed client for impd, the imp host daemon: imps, checkpoints, images and exec",
5
5
  "license": "MIT",
6
6
  "repository": {