@slicervm/sdk 0.1.1 → 0.1.3

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
@@ -2,7 +2,10 @@ import http from 'http';
2
2
  import https from 'https';
3
3
  import { URL } from 'url';
4
4
  import os from 'os';
5
- import path from 'path';
5
+ import path2 from 'path';
6
+ import net, { createServer } from 'net';
7
+ import fs from 'fs';
8
+ import { createWebSocketStream, WebSocket } from 'ws';
6
9
 
7
10
  // src/types.ts
8
11
  var ExecStdioText = "text";
@@ -18,11 +21,11 @@ var SlicerAPIError = class extends Error {
18
21
  method;
19
22
  path;
20
23
  body;
21
- constructor(method, path2, status, body) {
22
- super(`slicer ${method} ${path2} failed: ${status} ${body}`);
24
+ constructor(method, path3, status, body) {
25
+ super(`slicer ${method} ${path3} failed: ${status} ${body}`);
23
26
  this.name = "SlicerAPIError";
24
27
  this.method = method;
25
- this.path = path2;
28
+ this.path = path3;
26
29
  this.status = status;
27
30
  this.body = body;
28
31
  }
@@ -35,7 +38,7 @@ function resolveTransport(baseURL) {
35
38
  if (!trimmed) throw new Error("Slicer baseURL is required");
36
39
  let candidate = trimmed;
37
40
  if (candidate.startsWith("unix://")) candidate = candidate.slice("unix://".length);
38
- if (candidate.startsWith("~/")) candidate = path.join(os.homedir(), candidate.slice(2));
41
+ if (candidate.startsWith("~/")) candidate = path2.join(os.homedir(), candidate.slice(2));
39
42
  const socketLike = candidate.startsWith("/") || candidate.startsWith("./") || candidate.startsWith("../") || candidate.endsWith(".sock");
40
43
  if (socketLike) return { kind: "socket", socketPath: candidate };
41
44
  return { kind: "net", url: new URL(trimmed) };
@@ -147,9 +150,9 @@ var TransportClient = class {
147
150
  });
148
151
  }
149
152
  /** Streaming request producing a Node Readable of the response body. */
150
- requestStreamRaw(method, reqPath, body, contentType = "application/octet-stream") {
153
+ requestStreamRaw(method, reqPath, body, contentType = "application/octet-stream", extraHeaders = {}) {
151
154
  return new Promise((resolve, reject) => {
152
- const headers = {};
155
+ const headers = { ...extraHeaders };
153
156
  if (body instanceof Buffer) {
154
157
  headers["Content-Type"] = contentType;
155
158
  headers["Content-Length"] = String(body.length);
@@ -294,6 +297,258 @@ function secretFromWire(w) {
294
297
  ...w.modified_at !== void 0 && { modifiedAt: w.modified_at }
295
298
  };
296
299
  }
300
+ function looksLikeUnixSocketPath(s) {
301
+ if (!s) return false;
302
+ return s.startsWith("/") || s.startsWith("./") || s.startsWith("../") || s.includes("/");
303
+ }
304
+ function parseAddressMapping(spec) {
305
+ const lastColon = spec.lastIndexOf(":");
306
+ if (lastColon !== -1) {
307
+ const localPart = spec.slice(0, lastColon);
308
+ const remotePart = spec.slice(lastColon + 1);
309
+ if (looksLikeUnixSocketPath(localPart) && looksLikeUnixSocketPath(remotePart)) {
310
+ const localAbs = path2.isAbsolute(localPart) ? localPart : path2.resolve(localPart);
311
+ return { rawSpec: spec, listenUnixPath: localAbs, remoteUnixPath: remotePart };
312
+ }
313
+ }
314
+ const tcpToUnix = spec.indexOf(":/");
315
+ if (tcpToUnix !== -1) {
316
+ const listenPart = spec.slice(0, tcpToUnix);
317
+ const socketPath = spec.slice(tcpToUnix + 1);
318
+ const innerColon = listenPart.lastIndexOf(":");
319
+ if (innerColon === -1) {
320
+ return {
321
+ rawSpec: spec,
322
+ listenAddr: "0.0.0.0",
323
+ listenPort: parsePort(listenPart, spec),
324
+ remoteUnixPath: socketPath
325
+ };
326
+ }
327
+ return {
328
+ rawSpec: spec,
329
+ listenAddr: listenPart.slice(0, innerColon),
330
+ listenPort: parsePort(listenPart.slice(innerColon + 1), spec),
331
+ remoteUnixPath: socketPath
332
+ };
333
+ }
334
+ const parts = spec.split(":");
335
+ switch (parts.length) {
336
+ case 2:
337
+ return {
338
+ rawSpec: spec,
339
+ listenAddr: parts[0],
340
+ listenPort: parsePort(parts[1], spec),
341
+ remoteHost: parts[0],
342
+ remotePort: parsePort(parts[1], spec)
343
+ };
344
+ case 3:
345
+ return {
346
+ rawSpec: spec,
347
+ listenAddr: parts[1],
348
+ listenPort: parsePort(parts[0], spec),
349
+ remoteHost: parts[1],
350
+ remotePort: parsePort(parts[2], spec)
351
+ };
352
+ case 4:
353
+ return {
354
+ rawSpec: spec,
355
+ listenAddr: parts[0],
356
+ listenPort: parsePort(parts[1], spec),
357
+ remoteHost: parts[2],
358
+ remotePort: parsePort(parts[3], spec)
359
+ };
360
+ default:
361
+ throw new Error(`invalid forward spec ${JSON.stringify(spec)}: expected 2-4 colon-separated parts`);
362
+ }
363
+ }
364
+ function parsePort(s, spec) {
365
+ const n = Number(s);
366
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
367
+ throw new Error(`invalid port ${JSON.stringify(s)} in forward spec ${JSON.stringify(spec)}`);
368
+ }
369
+ return n;
370
+ }
371
+ function isListenUnix(m) {
372
+ return m.listenUnixPath !== void 0;
373
+ }
374
+ function remoteTargetHeader(m) {
375
+ if (m.remoteUnixPath) return `unix:${m.remoteUnixPath}`;
376
+ return `${m.remoteHost}:${m.remotePort}`;
377
+ }
378
+ function listenAddressDescription(m) {
379
+ if (isListenUnix(m)) return m.listenUnixPath;
380
+ return `${m.listenAddr}:${m.listenPort}`;
381
+ }
382
+ var Forwarder = class _Forwarder {
383
+ constructor(init, mappings) {
384
+ this.init = init;
385
+ this.mappings = mappings;
386
+ }
387
+ init;
388
+ mappings;
389
+ listeners = [];
390
+ servers = [];
391
+ liveSockets = /* @__PURE__ */ new Set();
392
+ closed = false;
393
+ static async start(init) {
394
+ if (init.specs.length === 0) {
395
+ throw new Error("Forwarder requires at least one forward spec");
396
+ }
397
+ const mappings = init.specs.map(parseAddressMapping);
398
+ const fwd = new _Forwarder(init, mappings);
399
+ try {
400
+ await fwd.bindAll();
401
+ } catch (err) {
402
+ await fwd.close();
403
+ throw err;
404
+ }
405
+ return fwd;
406
+ }
407
+ async bindAll() {
408
+ for (const m of this.mappings) {
409
+ const server = createServer((socket) => this.handleAccept(m, socket));
410
+ const { local, port } = await listen(server, m);
411
+ this.servers.push(server);
412
+ this.listeners.push({
413
+ spec: m.rawSpec,
414
+ local,
415
+ remote: remoteTargetHeader(m),
416
+ ...port !== void 0 && { port }
417
+ });
418
+ this.log(`listen ${local} \u2192 ${remoteTargetHeader(m)}`);
419
+ }
420
+ }
421
+ handleAccept(mapping, socket) {
422
+ if (this.closed) {
423
+ socket.destroy();
424
+ return;
425
+ }
426
+ socket.on("error", () => {
427
+ });
428
+ const ws = openWebSocket(this.init, mapping);
429
+ let closed = false;
430
+ const cleanup = () => {
431
+ if (closed) return;
432
+ closed = true;
433
+ try {
434
+ socket.destroy();
435
+ } catch {
436
+ }
437
+ try {
438
+ ws.close();
439
+ } catch {
440
+ }
441
+ this.liveSockets.delete(handle);
442
+ };
443
+ const handle = { close: cleanup };
444
+ this.liveSockets.add(handle);
445
+ ws.binaryType = "nodebuffer";
446
+ ws.on("open", () => {
447
+ const wsStream = createWebSocketStream(ws);
448
+ wsStream.on("error", cleanup);
449
+ socket.pipe(wsStream);
450
+ wsStream.pipe(socket);
451
+ });
452
+ ws.on("close", cleanup);
453
+ ws.on("error", (err) => {
454
+ this.log(`tunnel error: ${err.message}`);
455
+ cleanup();
456
+ });
457
+ socket.on("close", cleanup);
458
+ }
459
+ /** Tear down all listeners and any in-flight tunnel sockets. */
460
+ async close() {
461
+ this.closed = true;
462
+ for (const handle of [...this.liveSockets]) handle.close();
463
+ this.liveSockets.clear();
464
+ await Promise.all(
465
+ this.servers.map(
466
+ (s) => new Promise((resolve) => {
467
+ s.close(() => resolve());
468
+ })
469
+ )
470
+ );
471
+ for (const m of this.mappings) {
472
+ if (isListenUnix(m) && m.listenUnixPath) {
473
+ try {
474
+ fs.rmSync(m.listenUnixPath, { force: true });
475
+ } catch {
476
+ }
477
+ }
478
+ }
479
+ }
480
+ log(msg) {
481
+ this.init.options?.log?.(msg);
482
+ }
483
+ };
484
+ function listen(server, mapping) {
485
+ return new Promise((resolve, reject) => {
486
+ server.once("error", reject);
487
+ if (isListenUnix(mapping)) {
488
+ const p = mapping.listenUnixPath;
489
+ try {
490
+ fs.rmSync(p, { force: true });
491
+ } catch {
492
+ }
493
+ server.listen(p, () => {
494
+ try {
495
+ fs.chmodSync(p, 432);
496
+ } catch {
497
+ }
498
+ resolve({ local: p });
499
+ });
500
+ return;
501
+ }
502
+ server.listen({ host: mapping.listenAddr, port: mapping.listenPort }, () => {
503
+ const addr = server.address();
504
+ if (addr && typeof addr === "object") {
505
+ const local = `${mapping.listenAddr}:${addr.port}`;
506
+ resolve({ local, port: addr.port });
507
+ } else {
508
+ resolve({ local: listenAddressDescription(mapping) });
509
+ }
510
+ });
511
+ });
512
+ }
513
+ function openWebSocket(init, mapping) {
514
+ const url = wsURLForVM(init.transport, init.hostname);
515
+ const headers = {
516
+ "X-Inlets-Client-ID": init.options?.clientId ?? os.hostname(),
517
+ "X-Inlets-Mode": "local",
518
+ "X-Inlets-Upstream": remoteTargetHeader(mapping),
519
+ "User-Agent": init.userAgent
520
+ };
521
+ if (init.token) headers["Authorization"] = `Bearer ${init.token}`;
522
+ const opts = {
523
+ headers,
524
+ handshakeTimeout: init.options?.dialTimeoutMs ?? 1e4
525
+ };
526
+ if (init.transport.kind === "socket") {
527
+ opts.agent = unixAgent(init.transport.socketPath);
528
+ }
529
+ return new WebSocket(url, opts);
530
+ }
531
+ function wsURLForVM(transport, hostname) {
532
+ if (transport.kind === "socket") {
533
+ return `ws://localhost/vm/${encodeURIComponent(hostname)}/forward`;
534
+ }
535
+ const u = transport.url;
536
+ const scheme = u.protocol === "https:" ? "wss" : "ws";
537
+ const port = u.port ? `:${u.port}` : "";
538
+ return `${scheme}://${u.hostname}${port}/vm/${encodeURIComponent(hostname)}/forward`;
539
+ }
540
+ function unixAgent(socketPath) {
541
+ const agent = new http.Agent({ keepAlive: false });
542
+ agent.createConnection = ((_opts, cb) => {
543
+ const conn = net.createConnection({ path: socketPath });
544
+ if (cb) {
545
+ conn.once("connect", () => cb(null, conn));
546
+ conn.once("error", (err) => cb(err));
547
+ }
548
+ return conn;
549
+ });
550
+ return agent;
551
+ }
297
552
 
298
553
  // src/vm.ts
299
554
  var VMFileSystem = class {
@@ -303,16 +558,16 @@ var VMFileSystem = class {
303
558
  }
304
559
  transport;
305
560
  hostname;
306
- async readDir(path2) {
307
- const q = new URLSearchParams({ path: path2 });
561
+ async readDir(path3) {
562
+ const q = new URLSearchParams({ path: path3 });
308
563
  const wire = await this.transport.request(
309
564
  "GET",
310
565
  `/vm/${encodeURIComponent(this.hostname)}/fs/readdir?${q.toString()}`
311
566
  );
312
567
  return (wire ?? []).map(fsEntryFromWire);
313
568
  }
314
- async stat(path2) {
315
- const q = new URLSearchParams({ path: path2 });
569
+ async stat(path3) {
570
+ const q = new URLSearchParams({ path: path3 });
316
571
  try {
317
572
  const wire = await this.transport.request(
318
573
  "GET",
@@ -324,8 +579,8 @@ var VMFileSystem = class {
324
579
  throw err;
325
580
  }
326
581
  }
327
- async exists(path2) {
328
- return await this.stat(path2) !== null;
582
+ async exists(path3) {
583
+ return await this.stat(path3) !== null;
329
584
  }
330
585
  async mkdir(req) {
331
586
  await this.transport.request(
@@ -338,22 +593,22 @@ var VMFileSystem = class {
338
593
  }
339
594
  );
340
595
  }
341
- async remove(path2, recursive = false) {
342
- const q = new URLSearchParams({ path: path2, recursive: String(recursive) });
596
+ async remove(path3, recursive = false) {
597
+ const q = new URLSearchParams({ path: path3, recursive: String(recursive) });
343
598
  await this.transport.request(
344
599
  "DELETE",
345
600
  `/vm/${encodeURIComponent(this.hostname)}/fs/remove?${q.toString()}`
346
601
  );
347
602
  }
348
- async readFile(path2) {
349
- const q = new URLSearchParams({ path: path2, mode: "binary" });
603
+ async readFile(path3) {
604
+ const q = new URLSearchParams({ path: path3, mode: "binary" });
350
605
  return this.transport.requestRaw(
351
606
  "GET",
352
607
  `/vm/${encodeURIComponent(this.hostname)}/cp?${q.toString()}`
353
608
  );
354
609
  }
355
- async writeFile(path2, content, opts = {}) {
356
- const q = new URLSearchParams({ path: path2, mode: "binary" });
610
+ async writeFile(path3, content, opts = {}) {
611
+ const q = new URLSearchParams({ path: path3, mode: "binary" });
357
612
  if (opts.uid !== void 0) q.set("uid", String(opts.uid));
358
613
  if (opts.gid !== void 0) q.set("gid", String(opts.gid));
359
614
  if (opts.permissions) q.set("permissions", opts.permissions);
@@ -365,8 +620,8 @@ var VMFileSystem = class {
365
620
  );
366
621
  }
367
622
  /** Upload a tar archive, expanded into the VM at `path`. */
368
- async tarTo(path2, tar) {
369
- const q = new URLSearchParams({ path: path2, mode: "tar" });
623
+ async tarTo(path3, tar) {
624
+ const q = new URLSearchParams({ path: path3, mode: "tar" });
370
625
  if (tar instanceof Buffer) {
371
626
  await this.transport.requestRaw(
372
627
  "POST",
@@ -384,9 +639,88 @@ var VMFileSystem = class {
384
639
  );
385
640
  for await (const _ of res) void _;
386
641
  }
642
+ /**
643
+ * Open a Server-Sent Events stream of filesystem events from the VM.
644
+ * Yields one `FSWatchEvent` per agent-side event. The stream stays open
645
+ * until the supplied request's `timeout` / `maxEvents` is hit, the daemon
646
+ * tears it down, or the caller breaks out of the loop.
647
+ *
648
+ * Heartbeat SSE comments and named `event:` lines are silently dropped.
649
+ *
650
+ * Example:
651
+ * ```ts
652
+ * for await (const e of vm.fs.watch({ paths: ['/tmp'], recursive: true })) {
653
+ * console.log(e.type, e.path);
654
+ * }
655
+ * ```
656
+ */
657
+ async *watch(req) {
658
+ if (!req.paths || req.paths.length === 0) {
659
+ throw new Error("vm.fs.watch: paths is required");
660
+ }
661
+ const q = new URLSearchParams();
662
+ for (const p of req.paths) if (p) q.append("paths", p);
663
+ for (const p of req.patterns ?? []) if (p) q.append("patterns", p);
664
+ for (const e of req.events ?? []) if (e) q.append("events", e);
665
+ if (req.uid !== void 0 && req.uid !== 0) q.set("uid", String(req.uid));
666
+ if (req.recursive) q.set("recursive", "true");
667
+ if (req.oneShot) q.set("one_shot", "true");
668
+ if (req.debounce) q.set("debounce", req.debounce);
669
+ if (req.timeout) q.set("timeout", req.timeout);
670
+ if (req.maxEvents !== void 0 && req.maxEvents > 0) {
671
+ q.set("max_events", String(req.maxEvents));
672
+ }
673
+ const extraHeaders = { Accept: "text/event-stream" };
674
+ if (req.lastEventId) extraHeaders["Last-Event-ID"] = req.lastEventId;
675
+ const res = await this.transport.requestStreamRaw(
676
+ "GET",
677
+ `/vm/${encodeURIComponent(this.hostname)}/fs/watch?${q.toString()}`,
678
+ void 0,
679
+ void 0,
680
+ extraHeaders
681
+ );
682
+ res.setEncoding("utf8");
683
+ let buffer = "";
684
+ let dataLines = [];
685
+ let pendingId = 0;
686
+ for await (const chunk of res) {
687
+ buffer += chunk;
688
+ let nl;
689
+ while ((nl = buffer.indexOf("\n")) >= 0) {
690
+ const raw = buffer.slice(0, nl);
691
+ buffer = buffer.slice(nl + 1);
692
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
693
+ if (line === "") {
694
+ if (dataLines.length > 0) {
695
+ const payload = dataLines.join("\n");
696
+ dataLines = [];
697
+ try {
698
+ const parsed = JSON.parse(payload);
699
+ const evt = {
700
+ id: typeof parsed.id === "number" && parsed.id !== 0 ? parsed.id : pendingId,
701
+ type: parsed.type ?? "",
702
+ path: parsed.path ?? "",
703
+ timestamp: parsed.timestamp ?? "",
704
+ size: parsed.size ?? 0,
705
+ isDir: parsed.isDir ?? false,
706
+ ...parsed.message !== void 0 && { message: parsed.message }
707
+ };
708
+ yield evt;
709
+ } catch {
710
+ }
711
+ }
712
+ } else if (line.startsWith(":")) ; else if (line.startsWith("data:")) {
713
+ dataLines.push(line.slice(5).replace(/^ /, ""));
714
+ } else if (line.startsWith("id:")) {
715
+ const v = parseInt(line.slice(3).trim(), 10);
716
+ if (!Number.isNaN(v)) pendingId = v;
717
+ }
718
+ }
719
+ }
720
+ }
387
721
  /** Download `path` from the VM as a tar archive. */
388
- async tarFrom(path2) {
389
- const q = new URLSearchParams({ path: path2, mode: "tar" });
722
+ async tarFrom(path3) {
723
+ const q = new URLSearchParams({ path: path3, mode: "tar" });
390
724
  return this.transport.requestRaw(
391
725
  "GET",
392
726
  `/vm/${encodeURIComponent(this.hostname)}/cp?${q.toString()}`
@@ -491,6 +825,30 @@ var VM = class {
491
825
  async restore() {
492
826
  await this.transport.request("POST", `/vm/${encodeURIComponent(this.hostname)}/restore`);
493
827
  }
828
+ // --- port forwarding ---------------------------------------------------
829
+ /**
830
+ * Open one or more port forwards from the host to this VM. Each spec follows
831
+ * the same syntax as `slicer vm forward -L`:
832
+ *
833
+ * `127.0.0.1:9000` — listen and forward on the same TCP port
834
+ * `8081:127.0.0.1:8080` — listen on `0.0.0.0:8081`, forward to `127.0.0.1:8080`
835
+ * `0.0.0.0:8080:127.0.0.1:8080` — fully explicit
836
+ * `9000:/var/run/docker.sock` — TCP listen, Unix socket forward
837
+ * `/tmp/docker.sock:/var/run/docker.sock` — Unix-to-Unix
838
+ *
839
+ * Returns a {@link Forwarder} handle. Call `forwarder.close()` to tear down
840
+ * all listeners and any in-flight tunnel sockets.
841
+ */
842
+ async forward(specs, options) {
843
+ return Forwarder.start({
844
+ hostname: this.hostname,
845
+ transport: this.transport.transport,
846
+ ...this.transport.token !== void 0 && { token: this.transport.token },
847
+ userAgent: this.transport.userAgent,
848
+ specs: typeof specs === "string" ? [specs] : specs,
849
+ ...options !== void 0 && { options }
850
+ });
851
+ }
494
852
  // --- exec -------------------------------------------------------------
495
853
  /**
496
854
  * Streaming exec — yields NDJSON frames (`started`, `stdout`, `stderr`, `exit`).
@@ -499,8 +857,8 @@ var VM = class {
499
857
  * `dataBytes`/`stdoutBytes`/`stderrBytes` Buffers alongside for convenience.
500
858
  */
501
859
  async *exec(req) {
502
- const { path: path2, body } = buildExecPath(this.hostname, req, false);
503
- for await (const frame of this.transport.requestNDJSON("POST", path2, body)) {
860
+ const { path: path3, body } = buildExecPath(this.hostname, req, false);
861
+ for await (const frame of this.transport.requestNDJSON("POST", path3, body)) {
504
862
  if (frame.encoding === "base64") {
505
863
  if (frame.data) frame.dataBytes = Buffer.from(frame.data, "base64");
506
864
  if (frame.stdout) frame.stdoutBytes = Buffer.from(frame.stdout, "base64");
@@ -513,8 +871,8 @@ var VM = class {
513
871
  if (req.stdin !== void 0) {
514
872
  throw new Error("stdin is not supported by execBuffered; use exec() instead");
515
873
  }
516
- const { path: path2, body } = buildExecPath(this.hostname, req, true);
517
- const raw = await this.transport.requestRaw("POST", path2, body);
874
+ const { path: path3, body } = buildExecPath(this.hostname, req, true);
875
+ const raw = await this.transport.requestRaw("POST", path3, body);
518
876
  const text = raw.toString("utf8");
519
877
  const parsed = text ? JSON.parse(text) : {};
520
878
  const common = {
@@ -738,6 +1096,6 @@ var SlicerClient = class _SlicerClient {
738
1096
  }
739
1097
  };
740
1098
 
741
- export { ExecStdioBase64, ExecStdioText, GiB, HostGroupsAPI, MiB, NonRootUser, SecretExistsError, SecretsAPI, SlicerAPIError, SlicerClient, VM, VMFileSystem, VMsAPI, resolveTransport };
1099
+ export { ExecStdioBase64, ExecStdioText, Forwarder, GiB, HostGroupsAPI, MiB, NonRootUser, SecretExistsError, SecretsAPI, SlicerAPIError, SlicerClient, VM, VMFileSystem, VMsAPI, parseAddressMapping, resolveTransport };
742
1100
  //# sourceMappingURL=index.js.map
743
1101
  //# sourceMappingURL=index.js.map