svamp-cli 0.1.75 → 0.1.78

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,477 @@
1
+ import * as http from 'http';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as net from 'net';
5
+
6
+ const MIME_TYPES = {
7
+ ".html": "text/html; charset=utf-8",
8
+ ".htm": "text/html; charset=utf-8",
9
+ ".css": "text/css; charset=utf-8",
10
+ ".js": "application/javascript; charset=utf-8",
11
+ ".mjs": "application/javascript; charset=utf-8",
12
+ ".json": "application/json; charset=utf-8",
13
+ ".xml": "application/xml; charset=utf-8",
14
+ ".csv": "text/csv; charset=utf-8",
15
+ ".txt": "text/plain; charset=utf-8",
16
+ ".md": "text/markdown; charset=utf-8",
17
+ ".png": "image/png",
18
+ ".jpg": "image/jpeg",
19
+ ".jpeg": "image/jpeg",
20
+ ".gif": "image/gif",
21
+ ".svg": "image/svg+xml",
22
+ ".ico": "image/x-icon",
23
+ ".webp": "image/webp",
24
+ ".avif": "image/avif",
25
+ ".woff": "font/woff",
26
+ ".woff2": "font/woff2",
27
+ ".ttf": "font/ttf",
28
+ ".otf": "font/otf",
29
+ ".eot": "application/vnd.ms-fontobject",
30
+ ".pdf": "application/pdf",
31
+ ".zip": "application/zip",
32
+ ".gz": "application/gzip",
33
+ ".tar": "application/x-tar",
34
+ ".wasm": "application/wasm",
35
+ ".mp4": "video/mp4",
36
+ ".webm": "video/webm",
37
+ ".mp3": "audio/mpeg",
38
+ ".ogg": "audio/ogg",
39
+ ".wav": "audio/wav",
40
+ ".yaml": "text/yaml; charset=utf-8",
41
+ ".yml": "text/yaml; charset=utf-8",
42
+ ".toml": "text/plain; charset=utf-8"
43
+ };
44
+ function getMimeType(filePath) {
45
+ const ext = path.extname(filePath).toLowerCase();
46
+ return MIME_TYPES[ext] || "application/octet-stream";
47
+ }
48
+ function setCorsHeaders(res) {
49
+ res.setHeader("Access-Control-Allow-Origin", "*");
50
+ res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, DELETE, OPTIONS");
51
+ res.setHeader("Access-Control-Allow-Headers", "*");
52
+ res.setHeader("Access-Control-Max-Age", "86400");
53
+ }
54
+ async function findAvailablePort(startPort) {
55
+ return new Promise((resolve, reject) => {
56
+ const server = net.createServer();
57
+ server.listen(startPort, "127.0.0.1", () => {
58
+ const addr = server.address();
59
+ server.close(() => resolve(addr.port));
60
+ });
61
+ server.on("error", () => {
62
+ if (startPort < 65535) {
63
+ findAvailablePort(startPort + 1).then(resolve, reject);
64
+ } else {
65
+ reject(new Error("No available ports"));
66
+ }
67
+ });
68
+ });
69
+ }
70
+ function escapeHtml(s) {
71
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
72
+ }
73
+ function formatSize(bytes) {
74
+ if (bytes < 1024) return `${bytes} B`;
75
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
76
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
77
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
78
+ }
79
+ function getFileIcon(name, isDir) {
80
+ if (isDir) return "&#128193;";
81
+ const ext = path.extname(name).toLowerCase();
82
+ const imageExts = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".ico"];
83
+ const codeExts = [".js", ".mjs", ".ts", ".tsx", ".jsx", ".py", ".go", ".rs", ".c", ".cpp", ".h", ".java", ".rb", ".sh", ".css", ".html", ".htm", ".xml", ".yaml", ".yml", ".toml", ".json"];
84
+ const docExts = [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".md", ".txt", ".csv"];
85
+ const archiveExts = [".zip", ".gz", ".tar", ".rar", ".7z"];
86
+ const videoExts = [".mp4", ".webm", ".avi", ".mov"];
87
+ const audioExts = [".mp3", ".ogg", ".wav", ".flac"];
88
+ if (imageExts.includes(ext)) return "&#128444;";
89
+ if (codeExts.includes(ext)) return "&#128196;";
90
+ if (docExts.includes(ext)) return "&#128203;";
91
+ if (archiveExts.includes(ext)) return "&#128230;";
92
+ if (videoExts.includes(ext)) return "&#127909;";
93
+ if (audioExts.includes(ext)) return "&#127925;";
94
+ return "&#128196;";
95
+ }
96
+ function generateDirectoryListing(dirPath, urlPath) {
97
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
98
+ const items = entries.sort((a, b) => {
99
+ if (a.isDirectory() && !b.isDirectory()) return -1;
100
+ if (!a.isDirectory() && b.isDirectory()) return 1;
101
+ return a.name.localeCompare(b.name);
102
+ }).map((entry) => {
103
+ const isDir = entry.isDirectory();
104
+ const name = isDir ? `${entry.name}/` : entry.name;
105
+ const href = path.posix.join(urlPath, entry.name) + (isDir ? "/" : "");
106
+ let size = "";
107
+ let mtime = "";
108
+ if (!isDir) {
109
+ try {
110
+ const stat = fs.statSync(path.join(dirPath, entry.name));
111
+ size = formatSize(stat.size);
112
+ mtime = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
113
+ } catch {
114
+ }
115
+ }
116
+ const icon = getFileIcon(entry.name, isDir);
117
+ return { name, href, size, mtime, icon, isDir, rawName: entry.name };
118
+ });
119
+ const rows = items.map((item) => {
120
+ const downloadBtn = !item.isDir ? `<a class="act-btn download-btn" href="${escapeHtml(item.href)}" download title="Download">&#11015;</a>` : "";
121
+ const menuBtn = `<button class="act-btn menu-btn" data-path="${escapeHtml(item.href)}" data-name="${escapeHtml(item.rawName)}" data-isdir="${item.isDir}" title="More actions">&#8943;</button>`;
122
+ return `<tr>
123
+ <td class="icon-cell">${item.icon}</td>
124
+ <td class="name-cell"><a href="${escapeHtml(item.href)}">${escapeHtml(item.name)}</a></td>
125
+ <td class="size-cell">${item.size}</td>
126
+ <td class="date-cell">${item.mtime}</td>
127
+ <td class="action-cell">${downloadBtn}${menuBtn}</td>
128
+ </tr>`;
129
+ }).join("\n");
130
+ const parentRow = urlPath !== "/" ? `<tr><td class="icon-cell">&#128193;</td><td class="name-cell"><a href="${escapeHtml(path.posix.dirname(urlPath))}/">..</a></td><td></td><td></td><td></td></tr>
131
+ ` : "";
132
+ return `<!DOCTYPE html>
133
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
134
+ <title>Index of ${escapeHtml(urlPath)}</title>
135
+ <style>
136
+ *{box-sizing:border-box;margin:0;padding:0}
137
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#24292f;background:#fff;padding:24px;max-width:960px;margin:0 auto}
138
+ h1{font-size:1.25rem;font-weight:600;margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid #d0d7de}
139
+ table{width:100%;border-collapse:collapse}
140
+ tr{border-bottom:1px solid #eaeef2}
141
+ tr:hover{background:#f6f8fa}
142
+ td{padding:8px 12px;vertical-align:middle}
143
+ .icon-cell{width:32px;text-align:center;font-size:1.1rem}
144
+ .name-cell a{text-decoration:none;color:#0969da;font-weight:500}
145
+ .name-cell a:hover{text-decoration:underline}
146
+ .size-cell{width:80px;text-align:right;color:#656d76;font-size:0.85rem}
147
+ .date-cell{width:140px;color:#656d76;font-size:0.85rem}
148
+ .action-cell{width:70px;text-align:right;white-space:nowrap}
149
+ .act-btn{background:none;border:none;cursor:pointer;padding:4px 6px;border-radius:4px;color:#656d76;font-size:1rem;text-decoration:none;display:inline-block;line-height:1}
150
+ .act-btn:hover{background:#eaeef2;color:#24292f}
151
+ .download-btn{font-size:0.85rem}
152
+
153
+ /* Drop zone */
154
+ #drop-overlay{display:none;position:fixed;inset:0;background:rgba(9,105,218,0.08);border:3px dashed #0969da;z-index:1000;align-items:center;justify-content:center;pointer-events:none}
155
+ #drop-overlay.active{display:flex}
156
+ #drop-overlay .label{background:#0969da;color:#fff;padding:12px 24px;border-radius:8px;font-size:1.1rem;font-weight:600}
157
+
158
+ /* Upload progress */
159
+ #upload-bar{display:none;position:fixed;bottom:0;left:0;right:0;background:#f6f8fa;border-top:1px solid #d0d7de;padding:12px 24px;z-index:999;font-size:0.9rem}
160
+ #upload-bar.active{display:block}
161
+ #upload-bar .bar{height:4px;background:#d0d7de;border-radius:2px;margin-top:8px;overflow:hidden}
162
+ #upload-bar .bar .fill{height:100%;background:#0969da;transition:width 0.2s}
163
+
164
+ /* Context menu */
165
+ #ctx-menu{display:none;position:fixed;background:#fff;border:1px solid #d0d7de;border-radius:8px;box-shadow:0 8px 24px rgba(31,35,40,0.12);padding:4px 0;z-index:2000;min-width:160px}
166
+ #ctx-menu.active{display:block}
167
+ #ctx-menu button{display:flex;align-items:center;gap:8px;width:100%;background:none;border:none;padding:8px 16px;text-align:left;cursor:pointer;font-size:0.9rem;color:#24292f}
168
+ #ctx-menu button:hover{background:#f6f8fa}
169
+ #ctx-menu button.danger{color:#cf222e}
170
+ #ctx-menu button.danger:hover{background:#fff1f0}
171
+ #ctx-menu .divider{height:1px;background:#d0d7de;margin:4px 0}
172
+
173
+ /* Toast */
174
+ #toast{display:none;position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#24292f;color:#fff;padding:8px 20px;border-radius:6px;font-size:0.85rem;z-index:3000}
175
+ #toast.active{display:block}
176
+ </style>
177
+ </head><body>
178
+ <h1>Index of ${escapeHtml(urlPath)}</h1>
179
+ <table><thead><tr><th></th><th>Name</th><th style="text-align:right">Size</th><th>Modified</th><th></th></tr></thead>
180
+ <tbody>${parentRow}${rows}</tbody></table>
181
+
182
+ <div id="drop-overlay"><div class="label">Drop files to upload</div></div>
183
+ <div id="upload-bar"><span id="upload-status">Uploading...</span><div class="bar"><div class="fill" id="upload-fill" style="width:0%"></div></div></div>
184
+ <div id="ctx-menu">
185
+ <button id="ctx-download">&#11015; Download</button>
186
+ <button id="ctx-copy">&#128203; Copy path</button>
187
+ <div class="divider"></div>
188
+ <button id="ctx-delete" class="danger">&#128465; Delete</button>
189
+ </div>
190
+ <div id="toast"></div>
191
+
192
+ <script>
193
+ (function(){
194
+ const currentPath = ${JSON.stringify(urlPath)};
195
+ const dropOverlay = document.getElementById('drop-overlay');
196
+ const uploadBar = document.getElementById('upload-bar');
197
+ const uploadFill = document.getElementById('upload-fill');
198
+ const uploadStatus = document.getElementById('upload-status');
199
+ const ctxMenu = document.getElementById('ctx-menu');
200
+ const toast = document.getElementById('toast');
201
+ let ctxTarget = null;
202
+ let dragCounter = 0;
203
+
204
+ // Toast
205
+ function showToast(msg, ms) {
206
+ toast.textContent = msg;
207
+ toast.classList.add('active');
208
+ setTimeout(() => toast.classList.remove('active'), ms || 2000);
209
+ }
210
+
211
+ // Context menu
212
+ document.querySelectorAll('.menu-btn').forEach(btn => {
213
+ btn.addEventListener('click', (e) => {
214
+ e.stopPropagation();
215
+ ctxTarget = { path: btn.dataset.path, name: btn.dataset.name, isDir: btn.dataset.isdir === 'true' };
216
+ const rect = btn.getBoundingClientRect();
217
+ ctxMenu.style.top = rect.bottom + 4 + 'px';
218
+ ctxMenu.style.left = Math.min(rect.left, window.innerWidth - 180) + 'px';
219
+ ctxMenu.classList.add('active');
220
+ document.getElementById('ctx-download').style.display = ctxTarget.isDir ? 'none' : '';
221
+ });
222
+ });
223
+
224
+ document.addEventListener('click', () => ctxMenu.classList.remove('active'));
225
+
226
+ document.getElementById('ctx-copy').addEventListener('click', () => {
227
+ if (!ctxTarget) return;
228
+ const fullPath = new URL(ctxTarget.path, location.href).pathname;
229
+ navigator.clipboard.writeText(fullPath).then(() => showToast('Path copied'));
230
+ });
231
+
232
+ document.getElementById('ctx-download').addEventListener('click', () => {
233
+ if (!ctxTarget || ctxTarget.isDir) return;
234
+ const a = document.createElement('a');
235
+ a.href = ctxTarget.path;
236
+ a.download = ctxTarget.name;
237
+ document.body.appendChild(a);
238
+ a.click();
239
+ a.remove();
240
+ });
241
+
242
+ document.getElementById('ctx-delete').addEventListener('click', async () => {
243
+ if (!ctxTarget) return;
244
+ const label = ctxTarget.isDir ? 'directory' : 'file';
245
+ if (!confirm('Delete ' + label + ' "' + ctxTarget.name + '"?')) return;
246
+ try {
247
+ const res = await fetch(ctxTarget.path, { method: 'DELETE' });
248
+ if (res.ok) {
249
+ showToast('Deleted ' + ctxTarget.name);
250
+ setTimeout(() => location.reload(), 500);
251
+ } else {
252
+ const text = await res.text();
253
+ alert('Delete failed: ' + text);
254
+ }
255
+ } catch(err) {
256
+ alert('Delete failed: ' + err.message);
257
+ }
258
+ });
259
+
260
+ // Drag and drop
261
+ document.addEventListener('dragenter', (e) => {
262
+ e.preventDefault();
263
+ dragCounter++;
264
+ dropOverlay.classList.add('active');
265
+ });
266
+ document.addEventListener('dragleave', (e) => {
267
+ e.preventDefault();
268
+ dragCounter--;
269
+ if (dragCounter <= 0) { dragCounter = 0; dropOverlay.classList.remove('active'); }
270
+ });
271
+ document.addEventListener('dragover', (e) => e.preventDefault());
272
+ document.addEventListener('drop', async (e) => {
273
+ e.preventDefault();
274
+ dragCounter = 0;
275
+ dropOverlay.classList.remove('active');
276
+
277
+ const items = e.dataTransfer.items;
278
+ if (!items || items.length === 0) return;
279
+
280
+ // Collect all files (including from folder drops via webkitGetAsEntry)
281
+ const files = [];
282
+ const promises = [];
283
+ for (let i = 0; i < items.length; i++) {
284
+ const entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
285
+ if (entry) {
286
+ promises.push(traverseEntry(entry, '', files));
287
+ } else if (items[i].kind === 'file') {
288
+ const f = items[i].getAsFile();
289
+ if (f) files.push({ file: f, relativePath: f.name });
290
+ }
291
+ }
292
+ await Promise.all(promises);
293
+ if (files.length === 0) return;
294
+
295
+ // Upload sequentially with progress
296
+ uploadBar.classList.add('active');
297
+ for (let i = 0; i < files.length; i++) {
298
+ const { file, relativePath } = files[i];
299
+ uploadStatus.textContent = 'Uploading ' + (i+1) + '/' + files.length + ': ' + relativePath;
300
+ uploadFill.style.width = Math.round((i / files.length) * 100) + '%';
301
+ const uploadPath = currentPath.replace(/\\/$/, '') + '/' + relativePath;
302
+ await uploadFile(uploadPath, file);
303
+ }
304
+ uploadFill.style.width = '100%';
305
+ uploadStatus.textContent = 'Done \u2014 ' + files.length + ' file(s) uploaded';
306
+ setTimeout(() => { uploadBar.classList.remove('active'); location.reload(); }, 1000);
307
+ });
308
+
309
+ function traverseEntry(entry, basePath, results) {
310
+ return new Promise((resolve) => {
311
+ if (entry.isFile) {
312
+ entry.file((f) => {
313
+ results.push({ file: f, relativePath: basePath ? basePath + '/' + f.name : f.name });
314
+ resolve();
315
+ }, () => resolve());
316
+ } else if (entry.isDirectory) {
317
+ const reader = entry.createReader();
318
+ reader.readEntries(async (entries) => {
319
+ const subBase = basePath ? basePath + '/' + entry.name : entry.name;
320
+ await Promise.all(entries.map(e => traverseEntry(e, subBase, results)));
321
+ resolve();
322
+ }, () => resolve());
323
+ } else {
324
+ resolve();
325
+ }
326
+ });
327
+ }
328
+
329
+ async function uploadFile(urlPath, file) {
330
+ const res = await fetch(urlPath, { method: 'POST', body: file,
331
+ headers: { 'Content-Type': 'application/octet-stream' }
332
+ });
333
+ if (!res.ok) throw new Error('Upload failed: ' + (await res.text()));
334
+ }
335
+ })();
336
+ <\/script>
337
+ </body></html>`;
338
+ }
339
+ function collectBody(req) {
340
+ return new Promise((resolve, reject) => {
341
+ const chunks = [];
342
+ req.on("data", (chunk) => chunks.push(chunk));
343
+ req.on("end", () => resolve(Buffer.concat(chunks)));
344
+ req.on("error", reject);
345
+ });
346
+ }
347
+ function resolveSafePath(rootDir, urlPath) {
348
+ const decodedPath = decodeURIComponent(urlPath);
349
+ const normalizedPath = path.normalize(decodedPath);
350
+ if (normalizedPath.includes("..")) return null;
351
+ const filePath = path.join(rootDir, normalizedPath);
352
+ if (!filePath.startsWith(rootDir)) return null;
353
+ return filePath;
354
+ }
355
+ async function startStaticServer(options) {
356
+ const { directory, listing = true } = options;
357
+ const rootDir = path.resolve(directory);
358
+ if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
359
+ throw new Error(`Not a directory: ${rootDir}`);
360
+ }
361
+ const server = http.createServer(async (req, res) => {
362
+ setCorsHeaders(res);
363
+ if (req.method === "OPTIONS") {
364
+ res.writeHead(204);
365
+ res.end();
366
+ return;
367
+ }
368
+ const url = new URL(req.url || "/", `http://localhost`);
369
+ const filePath = resolveSafePath(rootDir, url.pathname);
370
+ if (!filePath) {
371
+ res.writeHead(403, { "Content-Type": "text/plain" });
372
+ res.end("Forbidden");
373
+ return;
374
+ }
375
+ if (req.method === "DELETE") {
376
+ try {
377
+ if (!fs.existsSync(filePath)) {
378
+ res.writeHead(404, { "Content-Type": "text/plain" });
379
+ res.end("Not Found");
380
+ return;
381
+ }
382
+ const stat = fs.statSync(filePath);
383
+ if (stat.isDirectory()) {
384
+ fs.rmSync(filePath, { recursive: true, force: true });
385
+ } else {
386
+ fs.unlinkSync(filePath);
387
+ }
388
+ res.writeHead(200, { "Content-Type": "application/json" });
389
+ res.end(JSON.stringify({ deleted: url.pathname }));
390
+ } catch (err) {
391
+ res.writeHead(500, { "Content-Type": "text/plain" });
392
+ res.end(`Delete failed: ${err.message}`);
393
+ }
394
+ return;
395
+ }
396
+ if (req.method === "POST") {
397
+ try {
398
+ const parentDir = path.dirname(filePath);
399
+ fs.mkdirSync(parentDir, { recursive: true });
400
+ const body = await collectBody(req);
401
+ fs.writeFileSync(filePath, body);
402
+ res.writeHead(201, { "Content-Type": "application/json" });
403
+ res.end(JSON.stringify({ uploaded: url.pathname, size: body.length }));
404
+ } catch (err) {
405
+ res.writeHead(500, { "Content-Type": "text/plain" });
406
+ res.end(`Upload failed: ${err.message}`);
407
+ }
408
+ return;
409
+ }
410
+ if (req.method !== "GET" && req.method !== "HEAD") {
411
+ res.writeHead(405, { "Content-Type": "text/plain" });
412
+ res.end("Method Not Allowed");
413
+ return;
414
+ }
415
+ try {
416
+ const stat = fs.statSync(filePath);
417
+ const decodedPath = decodeURIComponent(url.pathname);
418
+ if (stat.isDirectory()) {
419
+ const indexPath = path.join(filePath, "index.html");
420
+ if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
421
+ serveFile(indexPath, req, res);
422
+ } else if (listing) {
423
+ if (!decodedPath.endsWith("/")) {
424
+ res.writeHead(301, { Location: decodedPath + "/" });
425
+ res.end();
426
+ return;
427
+ }
428
+ const html = generateDirectoryListing(filePath, decodedPath);
429
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
430
+ res.end(html);
431
+ } else {
432
+ res.writeHead(404, { "Content-Type": "text/plain" });
433
+ res.end("Not Found");
434
+ }
435
+ } else if (stat.isFile()) {
436
+ serveFile(filePath, req, res);
437
+ } else {
438
+ res.writeHead(404, { "Content-Type": "text/plain" });
439
+ res.end("Not Found");
440
+ }
441
+ } catch {
442
+ res.writeHead(404, { "Content-Type": "text/plain" });
443
+ res.end("Not Found");
444
+ }
445
+ });
446
+ const port = await findAvailablePort(options.port || 18080);
447
+ return new Promise((resolve, reject) => {
448
+ server.listen(port, "127.0.0.1", () => {
449
+ resolve({
450
+ server,
451
+ port,
452
+ close: () => server.close()
453
+ });
454
+ });
455
+ server.on("error", reject);
456
+ });
457
+ }
458
+ function serveFile(filePath, req, res) {
459
+ const stat = fs.statSync(filePath);
460
+ const contentType = getMimeType(filePath);
461
+ res.writeHead(200, {
462
+ "Content-Type": contentType,
463
+ "Content-Length": stat.size,
464
+ "Cache-Control": "no-cache"
465
+ });
466
+ if (req.method === "HEAD") {
467
+ res.end();
468
+ return;
469
+ }
470
+ const stream = fs.createReadStream(filePath);
471
+ stream.pipe(res);
472
+ stream.on("error", () => {
473
+ res.end();
474
+ });
475
+ }
476
+
477
+ export { startStaticServer };
@@ -2,15 +2,20 @@ import * as os from 'os';
2
2
  import { requireSandboxApiEnv } from './api-BRbsyqJ4.mjs';
3
3
  import { WebSocket } from 'ws';
4
4
 
5
+ const PING_INTERVAL_MS = 3e4;
6
+ const PONG_TIMEOUT_MS = 1e4;
7
+ const DEFAULT_MAX_RECONNECT_ATTEMPTS = 20;
5
8
  class TunnelClient {
6
9
  ws = null;
7
10
  options;
8
11
  env;
9
12
  sandboxId;
10
13
  reconnectAttempts = 0;
11
- maxReconnectAttempts = 20;
14
+ maxReconnects;
12
15
  destroyed = false;
13
- pingInterval = null;
16
+ pingTimer = null;
17
+ pongTimeoutTimer = null;
18
+ lastPongAt = 0;
14
19
  requestCount = 0;
15
20
  localWebSockets = /* @__PURE__ */ new Map();
16
21
  // request_id → local WS connection
@@ -22,6 +27,7 @@ class TunnelClient {
22
27
  requestTimeout: 12e4,
23
28
  ...options
24
29
  };
30
+ this.maxReconnects = options.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
25
31
  this.env = options.env || requireSandboxApiEnv();
26
32
  this.sandboxId = options.sandboxId || this.env.sandboxId || `local-${os.hostname()}-${process.pid}`;
27
33
  }
@@ -52,6 +58,7 @@ class TunnelClient {
52
58
  }
53
59
  this.ws.on("open", () => {
54
60
  this.reconnectAttempts = 0;
61
+ this.lastPongAt = Date.now();
55
62
  this.startPingInterval();
56
63
  this.options.onConnect?.();
57
64
  resolve();
@@ -99,6 +106,20 @@ class TunnelClient {
99
106
  get activeWebSockets() {
100
107
  return this.localWebSockets.size;
101
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
+ }
102
123
  // ── Message handling ────────────────────────────────────────────────
103
124
  handleMessage(raw) {
104
125
  let msg;
@@ -112,6 +133,10 @@ class TunnelClient {
112
133
  case "ping":
113
134
  this.send({ type: "pong" });
114
135
  break;
136
+ case "pong":
137
+ this.lastPongAt = Date.now();
138
+ this.clearPongTimeout();
139
+ break;
115
140
  case "request":
116
141
  this.options.onRequest?.(msg);
117
142
  if (msg.has_body) {
@@ -274,20 +299,37 @@ class TunnelClient {
274
299
  }
275
300
  startPingInterval() {
276
301
  this.stopPingInterval();
277
- this.pingInterval = setInterval(() => {
302
+ this.pingTimer = setInterval(() => {
278
303
  this.send({ type: "ping" });
279
- }, 3e4);
304
+ this.schedulePongTimeout();
305
+ }, PING_INTERVAL_MS);
280
306
  }
281
307
  stopPingInterval() {
282
- if (this.pingInterval) {
283
- clearInterval(this.pingInterval);
284
- this.pingInterval = null;
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;
285
327
  }
286
328
  }
287
329
  scheduleReconnect() {
288
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
330
+ if (this.maxReconnects > 0 && this.reconnectAttempts >= this.maxReconnects) {
289
331
  this.options.onError?.(new Error(
290
- `Tunnel disconnected: max reconnect attempts (${this.maxReconnectAttempts}) reached`
332
+ `Tunnel disconnected: max reconnect attempts (${this.maxReconnects}) reached`
291
333
  ));
292
334
  return;
293
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svamp-cli",
3
- "version": "0.1.75",
3
+ "version": "0.1.78",
4
4
  "description": "Svamp CLI — AI workspace daemon on Hypha Cloud",
5
5
  "author": "Amun AI AB",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -20,7 +20,7 @@
20
20
  "scripts": {
21
21
  "build": "rm -rf dist && tsc --noEmit && pkgroll",
22
22
  "typecheck": "tsc --noEmit",
23
- "test": "npx tsx test/test-authorize.mjs && npx tsx test/test-session-helpers.mjs && npx tsx test/test-cli-routing.mjs && npx tsx test/test-security-context.mjs && npx tsx test/test-ralph-loop.mjs && npx tsx test/test-message-helpers.mjs && npx tsx test/test-agent-config.mjs && npx tsx test/test-wrap-command.mjs && npx tsx test/test-credential-staging.mjs && npx tsx test/test-output-formatters.mjs && npx tsx test/test-agent-types.mjs && npx tsx test/test-transport.mjs && npx tsx test/test-session-update-handlers.mjs && npx tsx test/test-session-scanner.mjs && npx tsx test/test-hypha-client.mjs && npx tsx test/test-hook-settings.mjs && npx tsx test/test-session-service-logic.mjs && npx tsx test/test-daemon-persistence.mjs && npx tsx test/test-detect-isolation.mjs && npx tsx test/test-machine-service-logic.mjs && npx tsx test/test-interactive-helpers.mjs && npx tsx test/test-codex-backend.mjs && npx tsx test/test-acp-backend.mjs && npx tsx test/test-acp-bridge.mjs && npx tsx test/test-hook-server.mjs && npx tsx test/test-session-commands.mjs && npx tsx test/test-interactive-console.mjs && npx tsx test/test-session-messages.mjs && npx tsx test/test-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-ralph-loop-integration.mjs && npx tsx test/test-ralph-loop-modes.mjs && npx tsx test/test-machine-list-directory.mjs && npx tsx test/test-service-commands.mjs && npx tsx test/test-supervisor.mjs && npx tsx test/test-clear-detection.mjs && npx tsx test/test-session-consolidation.mjs",
23
+ "test": "npx tsx test/test-authorize.mjs && npx tsx test/test-session-helpers.mjs && npx tsx test/test-cli-routing.mjs && npx tsx test/test-security-context.mjs && npx tsx test/test-ralph-loop.mjs && npx tsx test/test-message-helpers.mjs && npx tsx test/test-agent-config.mjs && npx tsx test/test-wrap-command.mjs && npx tsx test/test-credential-staging.mjs && npx tsx test/test-output-formatters.mjs && npx tsx test/test-agent-types.mjs && npx tsx test/test-transport.mjs && npx tsx test/test-session-update-handlers.mjs && npx tsx test/test-session-scanner.mjs && npx tsx test/test-hypha-client.mjs && npx tsx test/test-hook-settings.mjs && npx tsx test/test-session-service-logic.mjs && npx tsx test/test-daemon-persistence.mjs && npx tsx test/test-detect-isolation.mjs && npx tsx test/test-machine-service-logic.mjs && npx tsx test/test-interactive-helpers.mjs && npx tsx test/test-codex-backend.mjs && npx tsx test/test-acp-backend.mjs && npx tsx test/test-acp-bridge.mjs && npx tsx test/test-hook-server.mjs && npx tsx test/test-session-commands.mjs && npx tsx test/test-interactive-console.mjs && npx tsx test/test-session-messages.mjs && npx tsx test/test-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-ralph-loop-integration.mjs && npx tsx test/test-ralph-loop-modes.mjs && npx tsx test/test-machine-list-directory.mjs && npx tsx test/test-service-commands.mjs && npx tsx test/test-supervisor.mjs && npx tsx test/test-clear-detection.mjs && npx tsx test/test-session-consolidation.mjs && npx tsx test/test-inbox.mjs",
24
24
  "test:hypha": "node --no-warnings test/test-hypha-service.mjs",
25
25
  "dev": "tsx src/cli.ts",
26
26
  "dev:daemon": "tsx src/cli.ts daemon start-sync",
@@ -29,8 +29,8 @@
29
29
  "dependencies": {
30
30
  "@agentclientprotocol/sdk": "^0.14.1",
31
31
  "@modelcontextprotocol/sdk": "^1.25.3",
32
- "hypha-rpc": "0.21.29",
33
- "node-pty": "^1.1.0",
32
+ "hypha-rpc": "0.21.34",
33
+ "node-pty": "1.2.0-beta.11",
34
34
  "ws": "^8.18.0",
35
35
  "yaml": "^2.8.2",
36
36
  "zod": "^3.24.4"