svamp-cli 0.1.52 → 0.1.53

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.
@@ -0,0 +1,299 @@
1
+ import * as os from 'os';
2
+ import { r as requireSandboxApiEnv } from './commands-ZuFXrcot.mjs';
3
+ import { WebSocket } from 'ws';
4
+
5
+ class TunnelClient {
6
+ ws = null;
7
+ options;
8
+ env;
9
+ sandboxId;
10
+ reconnectAttempts = 0;
11
+ maxReconnectAttempts = 20;
12
+ destroyed = false;
13
+ pingInterval = null;
14
+ requestCount = 0;
15
+ localWebSockets = /* @__PURE__ */ new Map();
16
+ // request_id → local WS connection
17
+ constructor(options) {
18
+ this.options = {
19
+ localHost: "localhost",
20
+ requestTimeout: 3e4,
21
+ ...options
22
+ };
23
+ this.env = options.env || requireSandboxApiEnv();
24
+ this.sandboxId = options.sandboxId || this.env.sandboxId || `local-${os.hostname()}-${process.pid}`;
25
+ }
26
+ /** Build the WebSocket URL for the tunnel endpoint. */
27
+ buildWsUrl() {
28
+ const baseUrl = this.env.apiUrl.replace(/\/+$/, "");
29
+ const wsBase = baseUrl.replace(/^http/, "ws");
30
+ const ns = this.env.namespace || "_tunnel";
31
+ const params = new URLSearchParams({
32
+ token: this.env.apiKey,
33
+ sandbox_id: this.sandboxId
34
+ });
35
+ return `${wsBase}/services/${ns}/${this.options.name}/tunnel?${params}`;
36
+ }
37
+ /** Connect to the tunnel endpoint. */
38
+ async connect() {
39
+ if (this.destroyed) return;
40
+ const url = this.buildWsUrl();
41
+ return new Promise((resolve, reject) => {
42
+ try {
43
+ this.ws = new WebSocket(url);
44
+ } catch (err) {
45
+ reject(new Error(`Failed to create WebSocket: ${err.message}`));
46
+ return;
47
+ }
48
+ this.ws.on("open", () => {
49
+ this.reconnectAttempts = 0;
50
+ this.startPingInterval();
51
+ this.options.onConnect?.();
52
+ resolve();
53
+ });
54
+ this.ws.on("message", (data) => {
55
+ const raw = typeof data === "string" ? data : data.toString("utf8");
56
+ this.handleMessage(raw);
57
+ });
58
+ this.ws.on("close", () => {
59
+ this.stopPingInterval();
60
+ this.cleanupLocalWebSockets();
61
+ this.options.onDisconnect?.();
62
+ if (!this.destroyed) {
63
+ this.scheduleReconnect();
64
+ }
65
+ });
66
+ this.ws.on("error", (err) => {
67
+ this.options.onError?.(err);
68
+ if (this.reconnectAttempts === 0) {
69
+ reject(err);
70
+ }
71
+ });
72
+ });
73
+ }
74
+ /** Disconnect and stop reconnecting. */
75
+ destroy() {
76
+ this.destroyed = true;
77
+ this.stopPingInterval();
78
+ this.cleanupLocalWebSockets();
79
+ if (this.ws) {
80
+ this.ws.close();
81
+ this.ws = null;
82
+ }
83
+ }
84
+ /** Number of HTTP requests proxied. */
85
+ get totalRequests() {
86
+ return this.requestCount;
87
+ }
88
+ /** Number of active WebSocket connections being proxied. */
89
+ get activeWebSockets() {
90
+ return this.localWebSockets.size;
91
+ }
92
+ // ── Message handling ────────────────────────────────────────────────
93
+ handleMessage(raw) {
94
+ let msg;
95
+ try {
96
+ msg = JSON.parse(raw);
97
+ } catch {
98
+ return;
99
+ }
100
+ switch (msg.type) {
101
+ case "ping":
102
+ this.send({ type: "pong" });
103
+ break;
104
+ case "request":
105
+ this.options.onRequest?.(msg);
106
+ this.proxyRequest(msg).catch((err) => {
107
+ this.options.onError?.(err);
108
+ });
109
+ break;
110
+ case "ws_open":
111
+ this.handleWsOpen(msg).catch((err) => {
112
+ this.options.onError?.(err);
113
+ });
114
+ break;
115
+ case "ws_data":
116
+ this.handleWsData(msg);
117
+ break;
118
+ case "ws_close":
119
+ this.handleWsClose(msg);
120
+ break;
121
+ }
122
+ }
123
+ async proxyRequest(req) {
124
+ this.requestCount++;
125
+ const port = req.port || this.options.ports[0];
126
+ const url = `http://${this.options.localHost}:${port}${req.path}`;
127
+ const controller = new AbortController();
128
+ const timeout = setTimeout(() => controller.abort(), this.options.requestTimeout);
129
+ const init = {
130
+ method: req.method,
131
+ headers: req.headers,
132
+ signal: controller.signal
133
+ };
134
+ if (req.body && !["GET", "HEAD"].includes(req.method.toUpperCase())) {
135
+ init.body = Buffer.from(req.body, "base64");
136
+ }
137
+ try {
138
+ const res = await fetch(url, init);
139
+ clearTimeout(timeout);
140
+ const bodyBuffer = await res.arrayBuffer();
141
+ const bodyBase64 = bodyBuffer.byteLength > 0 ? Buffer.from(bodyBuffer).toString("base64") : void 0;
142
+ const headers = {};
143
+ res.headers.forEach((value, key) => {
144
+ headers[key] = value;
145
+ });
146
+ this.send({
147
+ type: "response",
148
+ id: req.id,
149
+ status: res.status,
150
+ headers,
151
+ body: bodyBase64
152
+ });
153
+ } catch (err) {
154
+ clearTimeout(timeout);
155
+ if (err.name === "AbortError") {
156
+ this.send({
157
+ type: "response",
158
+ id: req.id,
159
+ status: 504,
160
+ headers: { "content-type": "text/plain" },
161
+ body: Buffer.from("Tunnel: request timeout").toString("base64")
162
+ });
163
+ } else {
164
+ this.send({
165
+ type: "response",
166
+ id: req.id,
167
+ status: 502,
168
+ headers: { "content-type": "text/plain" },
169
+ body: Buffer.from(`Tunnel: local service error: ${err.message}`).toString("base64")
170
+ });
171
+ }
172
+ }
173
+ }
174
+ // ── WebSocket proxying ───────────────────────────────────────────────
175
+ async handleWsOpen(msg) {
176
+ const port = msg.port || this.options.ports[0];
177
+ const localUrl = `ws://${this.options.localHost}:${port}${msg.path}`;
178
+ try {
179
+ const localWs = new WebSocket(localUrl, {
180
+ headers: msg.headers
181
+ });
182
+ this.localWebSockets.set(msg.id, localWs);
183
+ localWs.on("message", (data, isBinary) => {
184
+ const encoded = typeof data === "string" ? Buffer.from(data).toString("base64") : (data instanceof Buffer ? data : Buffer.from(data)).toString("base64");
185
+ this.send({
186
+ type: "ws_data",
187
+ id: msg.id,
188
+ data: encoded,
189
+ is_text: !isBinary
190
+ });
191
+ });
192
+ localWs.on("close", (code) => {
193
+ this.localWebSockets.delete(msg.id);
194
+ this.send({ type: "ws_close", id: msg.id, code: code || 1e3 });
195
+ });
196
+ localWs.on("error", () => {
197
+ this.localWebSockets.delete(msg.id);
198
+ this.send({ type: "ws_close", id: msg.id, code: 1011 });
199
+ });
200
+ } catch {
201
+ this.send({ type: "ws_close", id: msg.id, code: 1011 });
202
+ }
203
+ }
204
+ handleWsData(msg) {
205
+ const localWs = this.localWebSockets.get(msg.id);
206
+ if (!localWs || localWs.readyState !== WebSocket.OPEN) return;
207
+ const buf = Buffer.from(msg.data, "base64");
208
+ if (msg.is_text) {
209
+ localWs.send(buf.toString("utf8"));
210
+ } else {
211
+ localWs.send(buf);
212
+ }
213
+ }
214
+ handleWsClose(msg) {
215
+ const localWs = this.localWebSockets.get(msg.id);
216
+ if (localWs) {
217
+ this.localWebSockets.delete(msg.id);
218
+ localWs.close(msg.code || 1e3);
219
+ }
220
+ }
221
+ cleanupLocalWebSockets() {
222
+ for (const [id, ws] of this.localWebSockets) {
223
+ ws.close(1001);
224
+ }
225
+ this.localWebSockets.clear();
226
+ }
227
+ // ── WebSocket helpers ───────────────────────────────────────────────
228
+ send(msg) {
229
+ if (this.ws?.readyState === WebSocket.OPEN) {
230
+ this.ws.send(JSON.stringify(msg));
231
+ }
232
+ }
233
+ startPingInterval() {
234
+ this.stopPingInterval();
235
+ this.pingInterval = setInterval(() => {
236
+ this.send({ type: "ping" });
237
+ }, 3e4);
238
+ }
239
+ stopPingInterval() {
240
+ if (this.pingInterval) {
241
+ clearInterval(this.pingInterval);
242
+ this.pingInterval = null;
243
+ }
244
+ }
245
+ scheduleReconnect() {
246
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
247
+ this.options.onError?.(new Error(
248
+ `Tunnel disconnected: max reconnect attempts (${this.maxReconnectAttempts}) reached`
249
+ ));
250
+ return;
251
+ }
252
+ this.reconnectAttempts++;
253
+ const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts - 1), 3e4);
254
+ setTimeout(() => {
255
+ if (!this.destroyed) {
256
+ this.connect().catch((err) => {
257
+ this.options.onError?.(err);
258
+ });
259
+ }
260
+ }, delay);
261
+ }
262
+ }
263
+ async function runTunnel(name, ports) {
264
+ const portList = ports.join(", ");
265
+ const client = new TunnelClient({
266
+ name,
267
+ ports,
268
+ onConnect: () => {
269
+ console.log(`Tunnel connected: ${name} \u2192 localhost:[${portList}]`);
270
+ },
271
+ onDisconnect: () => {
272
+ console.log("Tunnel disconnected, reconnecting...");
273
+ },
274
+ onRequest: (req) => {
275
+ console.log(` ${req.method} :${req.port || ports[0]}${req.path}`);
276
+ },
277
+ onError: (err) => {
278
+ console.error(`Tunnel error: ${err.message}`);
279
+ }
280
+ });
281
+ const cleanup = () => {
282
+ console.log("\nTunnel shutting down...");
283
+ client.destroy();
284
+ process.exit(0);
285
+ };
286
+ process.on("SIGINT", cleanup);
287
+ process.on("SIGTERM", cleanup);
288
+ try {
289
+ await client.connect();
290
+ console.log(`Tunnel is active. Press Ctrl+C to stop.`);
291
+ await new Promise(() => {
292
+ });
293
+ } catch (err) {
294
+ console.error(`Failed to establish tunnel: ${err.message}`);
295
+ process.exit(1);
296
+ }
297
+ }
298
+
299
+ export { TunnelClient, runTunnel };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svamp-cli",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
4
4
  "description": "Svamp CLI — AI workspace daemon on Hypha Cloud",
5
5
  "author": "Amun AI AB",
6
6
  "license": "SEE LICENSE IN LICENSE",