@zgeoff/imp-client 0.7.0 → 0.9.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.7.0";
6
+ const CLIENT_VERSION = "0.9.0";
7
7
  async function checkServer(rpc) {
8
8
  const info = await rpc.system.info();
9
9
  return {
@@ -29,6 +29,29 @@ function parseVersion(version) {
29
29
  //#region ../api/src/name-schema.ts
30
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
31
  //#endregion
32
+ //#region ../api/src/image-schema.ts
33
+ const ImageSchema = z.object({
34
+ id: z.string(),
35
+ name: NameSchema,
36
+ ref: z.string(),
37
+ digest: z.string(),
38
+ createdAt: z.date(),
39
+ sizeBytes: z.int().nonnegative()
40
+ });
41
+ //#endregion
42
+ //#region ../api/src/image-build-protocol.ts
43
+ const IMAGE_BUILD_PATH = "/images/build";
44
+ const DockerfilePathSchema = z.string().min(1).refine((path) => !path.startsWith("/") && !path.split(/[/\\]/u).includes(".."), { message: "the Dockerfile path must be relative and stay inside the build context" });
45
+ z.object({
46
+ name: NameSchema,
47
+ dockerfile: DockerfilePathSchema.optional()
48
+ });
49
+ const ImageBuildResultSchema = ImageSchema.extend({ createdAt: z.coerce.date() });
50
+ const ImageBuildErrorSchema = z.object({
51
+ code: z.string(),
52
+ message: z.string()
53
+ });
54
+ //#endregion
32
55
  //#region ../api/src/session-schema.ts
33
56
  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
57
  const SessionExitSchema = z.object({
@@ -66,10 +89,14 @@ const ExecStartMessageSchema = z.object({
66
89
  cols: DimensionSchema.optional(),
67
90
  rows: DimensionSchema.optional(),
68
91
  session: SessionNameSchema.optional(),
69
- tool: z.enum(["tar"]).optional()
92
+ tool: z.enum(["tar"]).optional(),
93
+ killGraceMs: z.int().min(1).max(6e4).optional()
70
94
  }).refine((start) => start.session === void 0 || start.tty, {
71
95
  message: "a session needs a tty",
72
96
  path: ["tty"]
97
+ }).refine((start) => start.killGraceMs === void 0 || !start.tty, {
98
+ message: "a tty exec takes no kill grace",
99
+ path: ["killGraceMs"]
73
100
  }).refine((start) => start.tool === void 0 || !start.tty && start.session === void 0 && start.env === void 0 && start.cwd === void 0, {
74
101
  message: "a tool takes no tty, session, env or cwd",
75
102
  path: ["tool"]
@@ -104,7 +131,8 @@ const ExecServerMessageSchema = z.discriminatedUnion("type", [
104
131
  type: z.literal("started"),
105
132
  pid: z.int().positive(),
106
133
  session: SessionNameSchema.optional(),
107
- created: z.boolean().optional()
134
+ created: z.boolean().optional(),
135
+ groupKill: z.boolean().optional()
108
136
  }),
109
137
  z.object({
110
138
  type: z.literal("exit"),
@@ -148,6 +176,63 @@ function isExecChannel(value) {
148
176
  return value === EXEC_CHANNELS.stdin || value === EXEC_CHANNELS.stdout || value === EXEC_CHANNELS.stderr;
149
177
  }
150
178
  //#endregion
179
+ //#region ../api/src/tunnel-protocol.ts
180
+ const TUNNEL_PATH = "/tunnel";
181
+ const TUNNEL_CLOSE_NORMAL = 1e3;
182
+ const TUNNEL_CLOSE_PROTOCOL = 1002;
183
+ const TUNNEL_WINDOW_BYTES = 1048576;
184
+ const TUNNEL_MAX_FRAME_BYTES = 65536;
185
+ const AckSchema = z.object({
186
+ type: z.literal("ack"),
187
+ bytes: z.int().positive()
188
+ });
189
+ const TunnelListenSchema = z.object({
190
+ type: z.literal("listen"),
191
+ name: NameSchema,
192
+ network: z.enum(["unix", "tcp"]),
193
+ path: z.string().startsWith("/").nullable().optional(),
194
+ port: z.int().min(0).max(65535).optional()
195
+ }).refine((listen) => listen.network === "tcp" ? listen.port !== void 0 && listen.path === void 0 : listen.port === void 0, {
196
+ message: "tcp takes a port, unix a path or null",
197
+ path: ["network"]
198
+ });
199
+ z.discriminatedUnion("type", [
200
+ z.object({
201
+ type: z.literal("open"),
202
+ name: NameSchema,
203
+ port: z.int().min(1).max(65535)
204
+ }),
205
+ z.object({ type: z.literal("eof") }),
206
+ AckSchema,
207
+ TunnelListenSchema,
208
+ z.object({
209
+ type: z.literal("accept"),
210
+ name: NameSchema,
211
+ listener: z.string().min(1),
212
+ connection: z.int().positive()
213
+ })
214
+ ]);
215
+ const TunnelServerMessageSchema = z.discriminatedUnion("type", [
216
+ z.object({ type: z.literal("opened") }),
217
+ z.object({ type: z.literal("eof") }),
218
+ AckSchema,
219
+ z.object({
220
+ type: z.literal("listening"),
221
+ listener: z.string(),
222
+ path: z.string().nullable(),
223
+ port: z.int().nullable()
224
+ }),
225
+ z.object({
226
+ type: z.literal("connection"),
227
+ id: z.int().positive()
228
+ }),
229
+ z.object({
230
+ type: z.literal("error"),
231
+ message: z.string(),
232
+ code: z.string().optional()
233
+ })
234
+ ]);
235
+ //#endregion
151
236
  //#region ../api/src/login-shell.ts
152
237
  const CONSOLE_SHELL = [
153
238
  "uid=$(id -u 2>/dev/null) || uid=0",
@@ -160,6 +245,44 @@ const CONSOLE_SHELL = [
160
245
  "exec \"$shell\" -l"
161
246
  ].join("\n");
162
247
  //#endregion
248
+ //#region src/resolve-impd-url.ts
249
+ function resolveImpdUrl(base, path) {
250
+ const url = new URL(base);
251
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`;
252
+ return url;
253
+ }
254
+ //#endregion
255
+ //#region src/build-image.ts
256
+ async function buildImage(deps, name, context, options = {}) {
257
+ const url = resolveImpdUrl(deps.baseUrl, IMAGE_BUILD_PATH);
258
+ url.searchParams.set("name", name);
259
+ if (options.dockerfile !== void 0) url.searchParams.set("dockerfile", options.dockerfile);
260
+ const headers = { "content-type": "application/x-tar" };
261
+ if (deps.token !== null) headers["authorization"] = `Bearer ${deps.token}`;
262
+ if (options.size !== void 0) headers["content-length"] = String(options.size);
263
+ const init = {
264
+ method: "POST",
265
+ headers,
266
+ body: context,
267
+ duplex: "half",
268
+ ...options.signal !== void 0 && { signal: options.signal }
269
+ };
270
+ const request = new Request(url.href, init);
271
+ const response = await (deps.fetch ?? fetch)(request);
272
+ const body = await response.json().catch(() => null);
273
+ if (response.ok) return ImageBuildResultSchema.parse(body);
274
+ const failure = ImageBuildErrorSchema.safeParse(body);
275
+ if (failure.success) throw new ORPCError$1(failure.data.code, {
276
+ status: response.status,
277
+ message: failure.data.message
278
+ });
279
+ const code = response.status === 401 ? "UNAUTHORIZED" : "INTERNAL_SERVER_ERROR";
280
+ throw new ORPCError$1(code, {
281
+ status: response.status,
282
+ message: `impd answered ${String(response.status)} to the image build`
283
+ });
284
+ }
285
+ //#endregion
163
286
  //#region src/exec/exec-error.ts
164
287
  var ExecError = class extends Error {
165
288
  code;
@@ -182,13 +305,6 @@ function toExecError(outcome) {
182
305
  return new ExecError("LOCAL_ERROR", "an output handler threw", { cause: outcome.error });
183
306
  }
184
307
  //#endregion
185
- //#region src/resolve-impd-url.ts
186
- function resolveImpdUrl(base, path) {
187
- const url = new URL(base);
188
- url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`;
189
- return url;
190
- }
191
- //#endregion
192
308
  //#region src/exec/check-impd-access.ts
193
309
  const CHECK_TIMEOUT_MS = 5e3;
194
310
  async function checkImpdAccess(baseUrl, token, customFetch = fetch) {
@@ -272,7 +388,8 @@ function openExecSession(options) {
272
388
  options.onStarted({
273
389
  pid: message.pid,
274
390
  session: message.session ?? null,
275
- created: message.created ?? false
391
+ created: message.created ?? false,
392
+ groupKill: message.groupKill ?? false
276
393
  });
277
394
  } else if (message.type === "exit") resolveOutcome({
278
395
  kind: "exit",
@@ -407,7 +524,8 @@ function openExec(deps, name, argv, options = {}) {
407
524
  ...options.cwd !== void 0 && { cwd: options.cwd },
408
525
  ...options.cols !== void 0 && { cols: options.cols },
409
526
  ...options.rows !== void 0 && { rows: options.rows },
410
- ...options.session !== void 0 && { session: options.session }
527
+ ...options.session !== void 0 && { session: options.session },
528
+ ...options.killGraceMs !== void 0 && { killGraceMs: options.killGraceMs }
411
529
  });
412
530
  }
413
531
  function openAttach(deps, name, session, options = {}) {
@@ -459,7 +577,7 @@ async function openHandle(deps, options, tty, start) {
459
577
  onOutput: (channel, data) => {
460
578
  (channel === "stderr" ? stderr : stdout).push(data);
461
579
  },
462
- connect: (url) => new WebSocket(url),
580
+ connect: deps.connect ?? ((url) => new WebSocket(url)),
463
581
  ...deps.fetch !== void 0 && { fetch: deps.fetch }
464
582
  });
465
583
  const resolveHandle = (result) => {
@@ -701,7 +819,8 @@ function createImpClient(options) {
701
819
  rpc,
702
820
  baseUrl: options.url,
703
821
  token: token ?? null,
704
- ...customFetch !== void 0 && { fetch: customFetch }
822
+ ...customFetch !== void 0 && { fetch: customFetch },
823
+ ...options.connect !== void 0 && { connect: options.connect }
705
824
  };
706
825
  return {
707
826
  ...namespaces,
@@ -710,8 +829,215 @@ function createImpClient(options) {
710
829
  openConsole: (name, consoleOptions) => openConsole(execDeps, name, consoleOptions),
711
830
  openAttach: (name, session, attachOptions) => openAttach(execDeps, name, session, attachOptions),
712
831
  requireAwake: (name, awakeOptions) => requireAwake(rpc, name, awakeOptions),
713
- checkServer: () => checkServer(rpc)
832
+ checkServer: () => checkServer(rpc),
833
+ buildImage: (name, context, buildOptions) => buildImage(execDeps, name, context, buildOptions)
834
+ };
835
+ }
836
+ //#endregion
837
+ //#region src/reverse/open-reverse-forward.ts
838
+ function parseMessage(text) {
839
+ try {
840
+ const parsed = TunnelServerMessageSchema.safeParse(JSON.parse(text));
841
+ return parsed.success ? parsed.data : null;
842
+ } catch {
843
+ return null;
844
+ }
845
+ }
846
+ function buildListenMessage(name, guest) {
847
+ return guest.network === "tcp" ? {
848
+ type: "listen",
849
+ name,
850
+ network: "tcp",
851
+ port: guest.port
852
+ } : {
853
+ type: "listen",
854
+ name,
855
+ network: "unix",
856
+ path: guest.path
857
+ };
858
+ }
859
+ function openRelay(target, accept, handlers) {
860
+ const ws = target.connect(target.url, target.headers);
861
+ const state = {
862
+ opened: false,
863
+ unacked: 0,
864
+ done: false,
865
+ peerEof: false,
866
+ guestEof: false
867
+ };
868
+ const waiters = [];
869
+ const pending = [];
870
+ ws.binaryType = "arraybuffer";
871
+ const wakeWaiters = () => {
872
+ for (const wake of waiters.splice(0)) wake();
873
+ };
874
+ const sendText = (message) => {
875
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
876
+ };
877
+ const sendFrames = (data) => {
878
+ for (let offset = 0; offset < data.byteLength; offset += TUNNEL_MAX_FRAME_BYTES) ws.send(Uint8Array.from(data.subarray(offset, offset + TUNNEL_MAX_FRAME_BYTES)));
879
+ };
880
+ const stopRelay = (lost) => {
881
+ if (state.done) return;
882
+ state.done = true;
883
+ wakeWaiters();
884
+ handlers.onClose(lost);
885
+ };
886
+ const sendAck = async (data) => {
887
+ await handlers.onData(data);
888
+ sendText({
889
+ type: "ack",
890
+ bytes: data.byteLength
891
+ });
892
+ };
893
+ const handleControl = (message) => {
894
+ if (message.type === "opened") {
895
+ state.opened = true;
896
+ for (const data of pending.splice(0)) sendFrames(data);
897
+ if (state.peerEof) sendText({ type: "eof" });
898
+ } else if (message.type === "eof") {
899
+ state.guestEof = true;
900
+ handlers.onEof();
901
+ } else if (message.type === "ack") {
902
+ state.unacked -= message.bytes;
903
+ if (state.unacked <= 1048576) wakeWaiters();
904
+ } else {
905
+ ws.close(TUNNEL_CLOSE_NORMAL, "relay failed");
906
+ stopRelay(true);
907
+ }
908
+ };
909
+ ws.addEventListener("open", () => {
910
+ sendText(accept);
911
+ });
912
+ ws.addEventListener("message", (event) => {
913
+ if (event.data instanceof ArrayBuffer) {
914
+ sendAck(new Uint8Array(event.data));
915
+ return;
916
+ }
917
+ const message = parseMessage(String(event.data));
918
+ if (message === null) {
919
+ ws.close(TUNNEL_CLOSE_PROTOCOL, "bad message");
920
+ stopRelay(true);
921
+ } else handleControl(message);
922
+ });
923
+ ws.addEventListener("close", (event) => {
924
+ stopRelay(event.code !== TUNNEL_CLOSE_NORMAL);
925
+ });
926
+ ws.addEventListener("error", () => {});
927
+ return {
928
+ send: (data) => {
929
+ if (state.done) return true;
930
+ if (state.opened) sendFrames(data);
931
+ else pending.push(Uint8Array.from(data));
932
+ state.unacked += data.byteLength;
933
+ return state.unacked <= TUNNEL_WINDOW_BYTES;
934
+ },
935
+ waitForRoom: async () => {
936
+ while (!state.done && state.unacked > 1048576) await new Promise((resolve) => {
937
+ waiters.push(resolve);
938
+ });
939
+ },
940
+ sendEof: () => {
941
+ state.peerEof = true;
942
+ if (state.opened) sendText({ type: "eof" });
943
+ },
944
+ close: () => {
945
+ if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) ws.close(TUNNEL_CLOSE_NORMAL, "local side closed");
946
+ stopRelay(false);
947
+ }
948
+ };
949
+ }
950
+ function openReverseForward(options) {
951
+ const url = resolveImpdUrl(options.baseUrl, TUNNEL_PATH);
952
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
953
+ const headers = options.token === null ? {} : { authorization: `Bearer ${options.token}` };
954
+ const target = {
955
+ url: url.href,
956
+ headers,
957
+ connect: options.connect
958
+ };
959
+ const ws = options.connect(url.href, headers);
960
+ const listening = Promise.withResolvers();
961
+ const ended = Promise.withResolvers();
962
+ const relays = /* @__PURE__ */ new Set();
963
+ const state = {
964
+ listener: "",
965
+ done: false
966
+ };
967
+ const waitForListening = async () => {
968
+ try {
969
+ await listening.promise;
970
+ } catch {}
971
+ };
972
+ waitForListening();
973
+ const stopForward = (end) => {
974
+ if (state.done) return;
975
+ state.done = true;
976
+ for (const relay of relays) relay.close();
977
+ const why = end.kind === "failed" ? end.message : `the forward ${end.kind}`;
978
+ listening.reject(new Error(why));
979
+ ended.resolve(end);
980
+ };
981
+ const openClientRelay = (id) => {
982
+ const message = {
983
+ type: "accept",
984
+ name: options.name,
985
+ listener: state.listener,
986
+ connection: id
987
+ };
988
+ options.onConnection((handlers) => {
989
+ const relay = openRelay(target, message, {
990
+ ...handlers,
991
+ onClose: (lost) => {
992
+ relays.delete(relay);
993
+ handlers.onClose(lost);
994
+ }
995
+ });
996
+ relays.add(relay);
997
+ return relay;
998
+ });
999
+ };
1000
+ const handleControl = (message) => {
1001
+ if (message.type === "listening") {
1002
+ state.listener = message.listener;
1003
+ listening.resolve({
1004
+ path: message.path,
1005
+ port: message.port
1006
+ });
1007
+ } else if (message.type === "connection") openClientRelay(message.id);
1008
+ else if (message.type === "error") stopForward({
1009
+ kind: "failed",
1010
+ code: message.code ?? null,
1011
+ message: message.message
1012
+ });
1013
+ else ws.close(TUNNEL_CLOSE_PROTOCOL, "bad message");
1014
+ };
1015
+ ws.binaryType = "arraybuffer";
1016
+ ws.addEventListener("open", () => {
1017
+ ws.send(JSON.stringify(buildListenMessage(options.name, options.guest)));
1018
+ });
1019
+ ws.addEventListener("message", (event) => {
1020
+ const message = event.data instanceof ArrayBuffer ? null : parseMessage(String(event.data));
1021
+ if (message === null) ws.close(TUNNEL_CLOSE_PROTOCOL, "bad message");
1022
+ else handleControl(message);
1023
+ });
1024
+ ws.addEventListener("close", (event) => {
1025
+ if (event.code === 4e3) stopForward({ kind: "lost" });
1026
+ else stopForward({
1027
+ kind: "closed",
1028
+ code: event.code,
1029
+ reason: event.reason
1030
+ });
1031
+ });
1032
+ ws.addEventListener("error", () => {});
1033
+ return {
1034
+ listening: listening.promise,
1035
+ ended: ended.promise,
1036
+ stop: () => {
1037
+ if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) ws.close(TUNNEL_CLOSE_NORMAL, "stopped");
1038
+ stopForward({ kind: "stopped" });
1039
+ }
714
1040
  };
715
1041
  }
716
1042
  //#endregion
717
- export { CLIENT_VERSION, CONSOLE_SHELL, ExecError, ORPCError, createImpClient, isDefinedError, openExecSession, safe };
1043
+ 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.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Typed client for impd, the imp host daemon: imps, checkpoints, images and exec",
5
5
  "license": "MIT",
6
6
  "repository": {