svamp-cli 0.1.85 → 0.1.87

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.
@@ -1,383 +0,0 @@
1
- import * as os from 'os';
2
- import { requireSandboxApiEnv } from './api-BRbsyqJ4.mjs';
3
- import { WebSocket } from 'ws';
4
-
5
- const PING_INTERVAL_MS = 3e4;
6
- const PONG_TIMEOUT_MS = 1e4;
7
- const DEFAULT_MAX_RECONNECT_ATTEMPTS = 20;
8
- class TunnelClient {
9
- ws = null;
10
- options;
11
- env;
12
- sandboxId;
13
- reconnectAttempts = 0;
14
- maxReconnects;
15
- destroyed = false;
16
- pingTimer = null;
17
- pongTimeoutTimer = null;
18
- lastPongAt = 0;
19
- requestCount = 0;
20
- localWebSockets = /* @__PURE__ */ new Map();
21
- // request_id → local WS connection
22
- pendingBinaryBody = null;
23
- // awaiting binary body frame
24
- constructor(options) {
25
- this.options = {
26
- localHost: "localhost",
27
- requestTimeout: 12e4,
28
- ...options
29
- };
30
- this.maxReconnects = options.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
31
- this.env = options.env || requireSandboxApiEnv();
32
- this.sandboxId = options.sandboxId || this.env.sandboxId || `local-${os.hostname()}-${process.pid}`;
33
- }
34
- /** Build the WebSocket URL for the tunnel endpoint. */
35
- buildWsUrl() {
36
- const baseUrl = this.env.apiUrl.replace(/\/+$/, "");
37
- const wsBase = baseUrl.replace(/^http/, "ws");
38
- const ns = this.env.namespace || "_tunnel";
39
- const params = new URLSearchParams({
40
- token: this.env.apiKey,
41
- sandbox_id: this.sandboxId
42
- });
43
- return `${wsBase}/services/${ns}/${this.options.name}/tunnel?${params}`;
44
- }
45
- /** Connect to the tunnel endpoint. */
46
- async connect() {
47
- if (this.destroyed) return;
48
- const url = this.buildWsUrl();
49
- return new Promise((resolve, reject) => {
50
- try {
51
- this.ws = new WebSocket(url, {
52
- maxPayload: 100 * 1024 * 1024
53
- // 100MB for large binary bodies
54
- });
55
- } catch (err) {
56
- reject(new Error(`Failed to create WebSocket: ${err.message}`));
57
- return;
58
- }
59
- this.ws.on("open", () => {
60
- this.reconnectAttempts = 0;
61
- this.lastPongAt = Date.now();
62
- this.startPingInterval();
63
- this.options.onConnect?.();
64
- resolve();
65
- });
66
- this.ws.on("message", (data, isBinary) => {
67
- if (isBinary) {
68
- this.handleBinaryBody(data);
69
- } else {
70
- const raw = typeof data === "string" ? data : data.toString("utf8");
71
- this.handleMessage(raw);
72
- }
73
- });
74
- this.ws.on("close", () => {
75
- this.stopPingInterval();
76
- this.cleanupLocalWebSockets();
77
- this.pendingBinaryBody = null;
78
- this.options.onDisconnect?.();
79
- if (!this.destroyed) {
80
- this.scheduleReconnect();
81
- }
82
- });
83
- this.ws.on("error", (err) => {
84
- this.options.onError?.(err);
85
- if (this.reconnectAttempts === 0) {
86
- reject(err);
87
- }
88
- });
89
- });
90
- }
91
- /** Disconnect and stop reconnecting. */
92
- destroy() {
93
- this.destroyed = true;
94
- this.stopPingInterval();
95
- this.cleanupLocalWebSockets();
96
- if (this.ws) {
97
- this.ws.close();
98
- this.ws = null;
99
- }
100
- }
101
- /** Number of HTTP requests proxied. */
102
- get totalRequests() {
103
- return this.requestCount;
104
- }
105
- /** Number of active WebSocket connections being proxied. */
106
- get activeWebSockets() {
107
- return this.localWebSockets.size;
108
- }
109
- /** Whether the tunnel WebSocket is currently open. */
110
- get isConnected() {
111
- return this.ws?.readyState === WebSocket.OPEN;
112
- }
113
- /** Snapshot of tunnel health for external monitoring. */
114
- get status() {
115
- return {
116
- connected: this.ws?.readyState === WebSocket.OPEN,
117
- reconnectAttempts: this.reconnectAttempts,
118
- lastPongAt: this.lastPongAt,
119
- totalRequests: this.requestCount,
120
- activeWebSockets: this.localWebSockets.size
121
- };
122
- }
123
- // ── Message handling ────────────────────────────────────────────────
124
- handleMessage(raw) {
125
- let msg;
126
- try {
127
- msg = JSON.parse(raw);
128
- } catch {
129
- return;
130
- }
131
- if (!msg || typeof msg !== "object") return;
132
- switch (msg.type) {
133
- case "ping":
134
- this.send({ type: "pong" });
135
- break;
136
- case "pong":
137
- this.lastPongAt = Date.now();
138
- this.clearPongTimeout();
139
- break;
140
- case "request":
141
- this.options.onRequest?.(msg);
142
- if (msg.has_body) {
143
- this.pendingBinaryBody = msg;
144
- } else {
145
- this.proxyRequest(msg, Buffer.alloc(0)).catch((err) => {
146
- this.options.onError?.(err);
147
- });
148
- }
149
- break;
150
- case "ws_open":
151
- this.handleWsOpen(msg).catch((err) => {
152
- this.options.onError?.(err);
153
- });
154
- break;
155
- case "ws_data":
156
- this.handleWsData(msg);
157
- break;
158
- case "ws_close":
159
- this.handleWsClose(msg);
160
- break;
161
- }
162
- }
163
- handleBinaryBody(data) {
164
- const req = this.pendingBinaryBody;
165
- this.pendingBinaryBody = null;
166
- if (!req) return;
167
- this.proxyRequest(req, data).catch((err) => {
168
- this.options.onError?.(err);
169
- });
170
- }
171
- async proxyRequest(req, bodyBuffer) {
172
- this.requestCount++;
173
- const port = req.port || this.options.ports[0];
174
- const url = `http://${this.options.localHost}:${port}${req.path}`;
175
- const controller = new AbortController();
176
- const timeout = setTimeout(() => controller.abort(), this.options.requestTimeout);
177
- const forwardHeaders = { ...req.headers };
178
- delete forwardHeaders["accept-encoding"];
179
- delete forwardHeaders["Accept-Encoding"];
180
- const init = {
181
- method: req.method,
182
- headers: forwardHeaders,
183
- signal: controller.signal
184
- };
185
- if (bodyBuffer.byteLength > 0 && !["GET", "HEAD"].includes(req.method.toUpperCase())) {
186
- init.body = bodyBuffer;
187
- }
188
- try {
189
- const res = await fetch(url, init);
190
- clearTimeout(timeout);
191
- const headers = {};
192
- res.headers.forEach((value, key) => {
193
- headers[key] = value;
194
- });
195
- this.send({
196
- type: "response",
197
- id: req.id,
198
- status: res.status,
199
- headers,
200
- streaming: true
201
- });
202
- if (res.body) {
203
- const reader = res.body.getReader();
204
- try {
205
- while (true) {
206
- const { done, value } = await reader.read();
207
- if (done) break;
208
- this.send({ type: "response_chunk", id: req.id });
209
- this.sendBinary(Buffer.from(value));
210
- }
211
- } finally {
212
- reader.releaseLock();
213
- }
214
- }
215
- this.send({ type: "response_end", id: req.id });
216
- } catch (err) {
217
- clearTimeout(timeout);
218
- const status = err.name === "AbortError" ? 504 : 502;
219
- const message = err.name === "AbortError" ? "Tunnel: request timeout" : `Tunnel: local service error: ${err.message}`;
220
- this.send({
221
- type: "response",
222
- id: req.id,
223
- status,
224
- headers: { "content-type": "text/plain" },
225
- streaming: true
226
- });
227
- this.send({ type: "response_chunk", id: req.id });
228
- this.sendBinary(Buffer.from(message));
229
- this.send({ type: "response_end", id: req.id });
230
- }
231
- }
232
- // ── WebSocket proxying ───────────────────────────────────────────────
233
- async handleWsOpen(msg) {
234
- const port = msg.port || this.options.ports[0];
235
- const localUrl = `ws://${this.options.localHost}:${port}${msg.path}`;
236
- try {
237
- const localWs = new WebSocket(localUrl, {
238
- headers: msg.headers
239
- });
240
- this.localWebSockets.set(msg.id, localWs);
241
- localWs.on("message", (data, isBinary) => {
242
- const encoded = typeof data === "string" ? Buffer.from(data).toString("base64") : (data instanceof Buffer ? data : Buffer.from(data)).toString("base64");
243
- this.send({
244
- type: "ws_data",
245
- id: msg.id,
246
- data: encoded,
247
- is_text: !isBinary
248
- });
249
- });
250
- localWs.on("close", (code) => {
251
- this.localWebSockets.delete(msg.id);
252
- this.send({ type: "ws_close", id: msg.id, code: code || 1e3 });
253
- });
254
- localWs.on("error", () => {
255
- this.localWebSockets.delete(msg.id);
256
- this.send({ type: "ws_close", id: msg.id, code: 1011 });
257
- });
258
- } catch {
259
- this.send({ type: "ws_close", id: msg.id, code: 1011 });
260
- }
261
- }
262
- handleWsData(msg) {
263
- const localWs = this.localWebSockets.get(msg.id);
264
- if (!localWs || localWs.readyState !== WebSocket.OPEN) return;
265
- const buf = Buffer.from(msg.data, "base64");
266
- try {
267
- if (msg.is_text) {
268
- localWs.send(buf.toString("utf8"));
269
- } else {
270
- localWs.send(buf);
271
- }
272
- } catch (err) {
273
- this.options.onError?.(err);
274
- }
275
- }
276
- handleWsClose(msg) {
277
- const localWs = this.localWebSockets.get(msg.id);
278
- if (localWs) {
279
- this.localWebSockets.delete(msg.id);
280
- localWs.close(msg.code || 1e3);
281
- }
282
- }
283
- cleanupLocalWebSockets() {
284
- for (const [id, ws] of this.localWebSockets) {
285
- ws.close(1001);
286
- }
287
- this.localWebSockets.clear();
288
- }
289
- // ── WebSocket helpers ───────────────────────────────────────────────
290
- send(msg) {
291
- if (this.ws?.readyState === WebSocket.OPEN) {
292
- this.ws.send(JSON.stringify(msg));
293
- }
294
- }
295
- sendBinary(data) {
296
- if (this.ws?.readyState === WebSocket.OPEN) {
297
- this.ws.send(data);
298
- }
299
- }
300
- startPingInterval() {
301
- this.stopPingInterval();
302
- this.pingTimer = setInterval(() => {
303
- this.send({ type: "ping" });
304
- this.schedulePongTimeout();
305
- }, PING_INTERVAL_MS);
306
- }
307
- stopPingInterval() {
308
- if (this.pingTimer) {
309
- clearInterval(this.pingTimer);
310
- this.pingTimer = null;
311
- }
312
- this.clearPongTimeout();
313
- }
314
- schedulePongTimeout() {
315
- this.clearPongTimeout();
316
- this.pongTimeoutTimer = setTimeout(() => {
317
- this.options.onError?.(new Error("Tunnel: pong timeout \u2014 connection stale"));
318
- if (this.ws) {
319
- this.ws.terminate();
320
- }
321
- }, PONG_TIMEOUT_MS);
322
- }
323
- clearPongTimeout() {
324
- if (this.pongTimeoutTimer) {
325
- clearTimeout(this.pongTimeoutTimer);
326
- this.pongTimeoutTimer = null;
327
- }
328
- }
329
- scheduleReconnect() {
330
- if (this.maxReconnects > 0 && this.reconnectAttempts >= this.maxReconnects) {
331
- this.options.onError?.(new Error(
332
- `Tunnel disconnected: max reconnect attempts (${this.maxReconnects}) reached`
333
- ));
334
- return;
335
- }
336
- this.reconnectAttempts++;
337
- const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts - 1), 3e4);
338
- setTimeout(() => {
339
- if (!this.destroyed) {
340
- this.connect().catch((err) => {
341
- this.options.onError?.(err);
342
- });
343
- }
344
- }, delay);
345
- }
346
- }
347
- async function runTunnel(name, ports) {
348
- const portList = ports.join(", ");
349
- const client = new TunnelClient({
350
- name,
351
- ports,
352
- onConnect: () => {
353
- console.log(`Tunnel connected: ${name} \u2192 localhost:[${portList}]`);
354
- },
355
- onDisconnect: () => {
356
- console.log("Tunnel disconnected, reconnecting...");
357
- },
358
- onRequest: (req) => {
359
- console.log(` ${req.method} :${req.port || ports[0]}${req.path}`);
360
- },
361
- onError: (err) => {
362
- console.error(`Tunnel error: ${err.message}`);
363
- }
364
- });
365
- const cleanup = () => {
366
- console.log("\nTunnel shutting down...");
367
- client.destroy();
368
- process.exit(0);
369
- };
370
- process.on("SIGINT", cleanup);
371
- process.on("SIGTERM", cleanup);
372
- try {
373
- await client.connect();
374
- console.log(`Tunnel is active. Press Ctrl+C to stop.`);
375
- await new Promise(() => {
376
- });
377
- } catch (err) {
378
- console.error(`Failed to establish tunnel: ${err.message}`);
379
- process.exit(1);
380
- }
381
- }
382
-
383
- export { TunnelClient, runTunnel };