stoat.run 0.1.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.
Files changed (3) hide show
  1. package/README.md +23 -0
  2. package/dist/bin.cjs +754 -0
  3. package/package.json +55 -0
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # stoat.run
2
+
3
+ Share your localhost in one command.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i -g stoat.run
9
+ pnpm add -g stoat.run
10
+ yarn global add stoat.run
11
+ bun add -g stoat.run
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```bash
17
+ stoat http 3000
18
+ stoat status
19
+ ```
20
+
21
+ ## Environment
22
+
23
+ - `STOAT_CONTROL_PLANE_URL` (default: `https://cp.discova.us`)
package/dist/bin.cjs ADDED
@@ -0,0 +1,754 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/bin.ts
27
+ var import_commander = require("commander");
28
+
29
+ // src/tunnel.ts
30
+ var import_ws = __toESM(require("ws"), 1);
31
+
32
+ // src/protocol.ts
33
+ var MIN_FRAME_SIZE = 8;
34
+ function encodeFrame(type, streamId, payload, flags = 0) {
35
+ const header = Buffer.alloc(MIN_FRAME_SIZE);
36
+ header.writeUInt8(type, 0);
37
+ header.writeUInt8(flags, 1);
38
+ header.writeUInt16BE(streamId, 2);
39
+ header.writeUInt32BE(payload.length, 4);
40
+ return Buffer.concat([header, payload]);
41
+ }
42
+ function decodeFrame(buf) {
43
+ if (buf.length < MIN_FRAME_SIZE) {
44
+ throw new Error(`Frame too short: ${buf.length} bytes (minimum ${MIN_FRAME_SIZE})`);
45
+ }
46
+ const type = buf.readUInt8(0);
47
+ const flags = buf.readUInt8(1);
48
+ const streamId = buf.readUInt16BE(2);
49
+ const payloadLength = buf.readUInt32BE(4);
50
+ const payload = buf.subarray(MIN_FRAME_SIZE, MIN_FRAME_SIZE + payloadLength);
51
+ return { type, flags, streamId, payload };
52
+ }
53
+ function encodeJsonPayload(data) {
54
+ return Buffer.from(JSON.stringify(data), "utf-8");
55
+ }
56
+ function decodeJsonPayload(payload) {
57
+ return JSON.parse(payload.toString("utf-8"));
58
+ }
59
+
60
+ // src/heartbeat.ts
61
+ var PING_INTERVAL_MS = 25e3;
62
+ var MAX_MISSED = 2;
63
+ var HeartbeatManager = class {
64
+ timer = null;
65
+ missedPongs = 0;
66
+ lastPingSentAt = 0;
67
+ ws = null;
68
+ opts;
69
+ constructor(opts) {
70
+ this.opts = opts;
71
+ }
72
+ start(ws) {
73
+ this.ws = ws;
74
+ this.missedPongs = 0;
75
+ this.timer = setInterval(() => {
76
+ this.sendPing();
77
+ }, PING_INTERVAL_MS);
78
+ }
79
+ stop() {
80
+ if (this.timer !== null) {
81
+ clearInterval(this.timer);
82
+ this.timer = null;
83
+ }
84
+ this.ws = null;
85
+ }
86
+ receivedPong(payload) {
87
+ this.missedPongs = 0;
88
+ const sentAt = Number(payload.readBigInt64BE(0));
89
+ const latency = Date.now() - sentAt;
90
+ this.opts.onLatency?.(latency);
91
+ void sentAt;
92
+ void this.lastPingSentAt;
93
+ }
94
+ sendPing() {
95
+ if (!this.ws) return;
96
+ this.missedPongs += 1;
97
+ if (this.missedPongs > MAX_MISSED) {
98
+ this.opts.onDisconnect();
99
+ return;
100
+ }
101
+ const ts = BigInt(Date.now());
102
+ const payload = Buffer.alloc(8);
103
+ payload.writeBigInt64BE(ts, 0);
104
+ this.lastPingSentAt = Date.now();
105
+ const frame = encodeFrame(6 /* PING */, 0, payload);
106
+ try {
107
+ this.ws.send(frame);
108
+ } catch {
109
+ }
110
+ }
111
+ };
112
+
113
+ // src/local-proxy.ts
114
+ var import_undici = require("undici");
115
+ var import_stream = require("stream");
116
+ var CHUNK_SIZE = 1024 * 1024;
117
+ var LocalProxy = class {
118
+ port;
119
+ ws;
120
+ onRequest;
121
+ constructor(opts) {
122
+ this.port = opts.localPort;
123
+ this.ws = opts.ws;
124
+ this.onRequest = opts.onRequest;
125
+ }
126
+ async handleStream(streamId, meta, requestBody) {
127
+ const startMs = Date.now();
128
+ const url = `http://localhost:${this.port}${meta.url}`;
129
+ try {
130
+ const headers = {};
131
+ for (const [k, v] of Object.entries(meta.headers)) {
132
+ headers[k] = Array.isArray(v) ? v.join(", ") : v;
133
+ }
134
+ const { statusCode, headers: resHeaders, body } = await (0, import_undici.request)(url, {
135
+ method: meta.method,
136
+ headers,
137
+ body: requestBody,
138
+ bodyTimeout: 3e4,
139
+ headersTimeout: 1e4
140
+ });
141
+ const initPayload = {
142
+ statusCode,
143
+ headers: resHeaders
144
+ };
145
+ this.ws.send(
146
+ encodeFrame(5 /* RESPONSE_INIT */, streamId, encodeJsonPayload(initPayload))
147
+ );
148
+ if (body instanceof import_stream.Readable) {
149
+ for await (const chunk of body) {
150
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
151
+ for (let offset = 0; offset < buf.length; offset += CHUNK_SIZE) {
152
+ const slice = buf.subarray(offset, offset + CHUNK_SIZE);
153
+ this.ws.send(encodeFrame(2 /* STREAM_DATA */, streamId, slice));
154
+ }
155
+ }
156
+ }
157
+ this.ws.send(encodeFrame(3 /* STREAM_END */, streamId, Buffer.alloc(0)));
158
+ const elapsedMs = Date.now() - startMs;
159
+ this.onRequest?.(meta.method, meta.url, statusCode, elapsedMs);
160
+ } catch (err) {
161
+ const isConnectionRefused = err instanceof Error && (err.message.includes("ECONNREFUSED") || err.message.includes("connect"));
162
+ const rstPayload = {
163
+ code: isConnectionRefused ? 502 : 500,
164
+ reason: isConnectionRefused ? "Local server not responding" : "Internal proxy error"
165
+ };
166
+ try {
167
+ this.ws.send(
168
+ encodeFrame(4 /* STREAM_RST */, streamId, encodeJsonPayload(rstPayload))
169
+ );
170
+ } catch {
171
+ }
172
+ const elapsedMs = Date.now() - startMs;
173
+ this.onRequest?.(meta.method, meta.url, rstPayload.code, elapsedMs);
174
+ }
175
+ }
176
+ };
177
+
178
+ // src/display.ts
179
+ var import_node_process = __toESM(require("process"), 1);
180
+ function info(message) {
181
+ import_node_process.default.stdout.write(` ${message}
182
+ `);
183
+ }
184
+ function ok(message) {
185
+ import_node_process.default.stdout.write(` \u2713 ${message}
186
+ `);
187
+ }
188
+ function warn(message) {
189
+ import_node_process.default.stdout.write(` ! ${message}
190
+ `);
191
+ }
192
+ function printBanner(publicUrl, localPort, slug, expiresAt) {
193
+ const expiresDate = new Date(expiresAt);
194
+ const nowMs = Date.now();
195
+ const diffMs = expiresDate.getTime() - nowMs;
196
+ const diffHours = Math.floor(diffMs / (1e3 * 60 * 60));
197
+ const diffMins = Math.floor(diffMs % (1e3 * 60 * 60) / (1e3 * 60));
198
+ const expiresStr = diffHours > 0 ? `in ${diffHours} hours` : `in ${diffMins} minutes`;
199
+ import_node_process.default.stdout.write("\n \u{1F43E} Stoat.run v0.1.0\n\n");
200
+ info(`\u279C Public URL: ${publicUrl}`);
201
+ info(`\u279C Local: http://localhost:${localPort}`);
202
+ info(`\u279C Slug: ${slug}`);
203
+ info(`\u279C Expires: ${expiresStr}`);
204
+ info("\u279C Viewers: 0");
205
+ import_node_process.default.stdout.write(
206
+ "\n Shortcuts: [L]ink [C]opy [P]ause [R]econnect [Q]uit\n\n"
207
+ );
208
+ }
209
+ function printRequest(method, path, status, ms) {
210
+ info(`\u2190 ${method} ${path} ${status} ${ms}ms`);
211
+ }
212
+ function updateViewerCount(count) {
213
+ info(`\u279C Viewers: ${count}`);
214
+ }
215
+ function printReconnecting() {
216
+ warn("Reconnecting...");
217
+ }
218
+ function printReconnected() {
219
+ ok("Reconnected");
220
+ }
221
+ function printError(msg) {
222
+ import_node_process.default.stderr.write(` \u2717 ${msg}
223
+ `);
224
+ }
225
+
226
+ // src/tunnel.ts
227
+ var VERSION = "0.1.0";
228
+ function rawDataToBuffer(data) {
229
+ if (Buffer.isBuffer(data)) {
230
+ return data;
231
+ }
232
+ if (Array.isArray(data)) {
233
+ return Buffer.concat(data);
234
+ }
235
+ if (data instanceof ArrayBuffer) {
236
+ return Buffer.from(data);
237
+ }
238
+ throw new TypeError("Unsupported websocket message payload type");
239
+ }
240
+ var TunnelClient = class {
241
+ constructor(opts) {
242
+ this.opts = opts;
243
+ this.heartbeat = new HeartbeatManager({
244
+ onDisconnect: () => {
245
+ printReconnecting();
246
+ void this.reconnect();
247
+ },
248
+ onLatency: (ms) => {
249
+ process.stdout.write(` Heartbeat latency: ${ms} ms
250
+ `);
251
+ }
252
+ });
253
+ }
254
+ ws = null;
255
+ heartbeat;
256
+ proxy = null;
257
+ reconnectAttempt = 0;
258
+ reconnectTimer = null;
259
+ stopped = false;
260
+ paused = false;
261
+ pendingStreams = /* @__PURE__ */ new Map();
262
+ connect() {
263
+ return new Promise((resolve, reject) => {
264
+ const ws = new import_ws.default(this.opts.edgeUrl, {
265
+ perMessageDeflate: false
266
+ });
267
+ this.ws = ws;
268
+ ws.on("open", () => {
269
+ const authPayload = {
270
+ slug: this.opts.slug,
271
+ token: this.opts.token,
272
+ version: VERSION
273
+ };
274
+ ws.send(encodeFrame(8 /* AUTH */, 0, encodeJsonPayload(authPayload)));
275
+ });
276
+ ws.on("message", (data) => {
277
+ const buf = rawDataToBuffer(data);
278
+ let frame;
279
+ try {
280
+ frame = decodeFrame(buf);
281
+ } catch {
282
+ return;
283
+ }
284
+ if (frame.type === 9 /* AUTH_OK */) {
285
+ const info2 = decodeJsonPayload(frame.payload);
286
+ this.reconnectAttempt = 0;
287
+ this.proxy = new LocalProxy({
288
+ localPort: this.opts.localPort,
289
+ ws,
290
+ onRequest: (method, path, status, ms) => {
291
+ printRequest(method, path, status, ms);
292
+ }
293
+ });
294
+ this.heartbeat.start(ws);
295
+ ws.removeAllListeners("message");
296
+ ws.on("message", (d) => {
297
+ void this.handleFrame(rawDataToBuffer(d));
298
+ });
299
+ this.opts.onAuthOk?.(info2);
300
+ resolve(info2);
301
+ } else if (frame.type === 10 /* AUTH_ERR */) {
302
+ const err = decodeJsonPayload(frame.payload);
303
+ ws.close();
304
+ reject(new Error(`Auth failed: ${err.message}`));
305
+ }
306
+ });
307
+ ws.on("error", (err) => {
308
+ reject(err);
309
+ });
310
+ ws.on("close", () => {
311
+ this.heartbeat.stop();
312
+ if (!this.stopped && !this.paused) {
313
+ printReconnecting();
314
+ void this.reconnect();
315
+ }
316
+ this.opts.onDisconnect?.();
317
+ });
318
+ });
319
+ }
320
+ async handleFrame(buf) {
321
+ let frame;
322
+ try {
323
+ frame = decodeFrame(buf);
324
+ } catch {
325
+ return;
326
+ }
327
+ switch (frame.type) {
328
+ case 1 /* STREAM_OPEN */: {
329
+ const meta = decodeJsonPayload(frame.payload);
330
+ this.pendingStreams.set(frame.streamId, { meta, chunks: [] });
331
+ break;
332
+ }
333
+ case 2 /* STREAM_DATA */: {
334
+ const pending = this.pendingStreams.get(frame.streamId);
335
+ if (pending) {
336
+ pending.chunks.push(Buffer.from(frame.payload));
337
+ }
338
+ break;
339
+ }
340
+ case 3 /* STREAM_END */: {
341
+ const pending = this.pendingStreams.get(frame.streamId);
342
+ if (pending) {
343
+ this.pendingStreams.delete(frame.streamId);
344
+ const body = pending.chunks.length > 0 ? Buffer.concat(pending.chunks) : void 0;
345
+ void this.proxy?.handleStream(frame.streamId, pending.meta, body);
346
+ }
347
+ break;
348
+ }
349
+ case 4 /* STREAM_RST */: {
350
+ this.pendingStreams.delete(frame.streamId);
351
+ break;
352
+ }
353
+ case 7 /* PONG */: {
354
+ this.heartbeat.receivedPong(frame.payload);
355
+ break;
356
+ }
357
+ case 6 /* PING */: {
358
+ this.ws?.send(encodeFrame(7 /* PONG */, 0, frame.payload));
359
+ break;
360
+ }
361
+ case 11 /* VIEWER_COUNT */: {
362
+ const { count } = decodeJsonPayload(frame.payload);
363
+ updateViewerCount(count);
364
+ this.opts.onViewerCount?.(count);
365
+ break;
366
+ }
367
+ case 12 /* GO_AWAY */: {
368
+ const payload = decodeJsonPayload(frame.payload);
369
+ this.opts.onGoAway?.(payload);
370
+ if (payload.reason === "session_expired") {
371
+ printError("Session expired. Tunnel closed.");
372
+ this.stop();
373
+ process.exit(0);
374
+ }
375
+ break;
376
+ }
377
+ }
378
+ }
379
+ async reconnect() {
380
+ if (this.stopped || this.paused) return;
381
+ this.heartbeat.stop();
382
+ this.ws?.terminate();
383
+ const backoffMs = Math.min(
384
+ 1e3 * Math.pow(2, this.reconnectAttempt),
385
+ 3e4
386
+ );
387
+ this.reconnectAttempt++;
388
+ await new Promise((res) => {
389
+ this.reconnectTimer = setTimeout(res, backoffMs);
390
+ });
391
+ if (this.stopped || this.paused) return;
392
+ try {
393
+ await this.connect();
394
+ printReconnected();
395
+ } catch (err) {
396
+ printError(`Reconnect failed: ${err instanceof Error ? err.message : String(err)}`);
397
+ void this.reconnect();
398
+ }
399
+ }
400
+ sendGoAway(reason) {
401
+ if (this.ws?.readyState === import_ws.default.OPEN) {
402
+ this.ws.send(
403
+ encodeFrame(12 /* GO_AWAY */, 0, encodeJsonPayload({ reason }))
404
+ );
405
+ }
406
+ }
407
+ pause() {
408
+ if (this.stopped || this.paused) return;
409
+ this.paused = true;
410
+ this.heartbeat.stop();
411
+ this.pendingStreams.clear();
412
+ this.sendGoAway("user_paused");
413
+ this.ws?.close();
414
+ }
415
+ resume() {
416
+ if (this.stopped || !this.paused) return;
417
+ this.paused = false;
418
+ void this.reconnect();
419
+ }
420
+ stop() {
421
+ this.stopped = true;
422
+ this.heartbeat.stop();
423
+ this.pendingStreams.clear();
424
+ if (this.reconnectTimer !== null) {
425
+ clearTimeout(this.reconnectTimer);
426
+ }
427
+ this.ws?.close();
428
+ }
429
+ };
430
+
431
+ // src/session.ts
432
+ var import_fs = require("fs");
433
+ var import_path = require("path");
434
+ var import_os = require("os");
435
+ var STOAT_DIR = (0, import_path.join)((0, import_os.homedir)(), ".stoat");
436
+ var STATE_FILE = (0, import_path.join)(STOAT_DIR, "p.json");
437
+ function saveSession(session) {
438
+ (0, import_fs.mkdirSync)(STOAT_DIR, { recursive: true });
439
+ (0, import_fs.writeFileSync)(STATE_FILE, JSON.stringify(session, null, 2));
440
+ }
441
+ function loadSession() {
442
+ try {
443
+ return JSON.parse((0, import_fs.readFileSync)(STATE_FILE, "utf-8"));
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+ function clearSession() {
449
+ try {
450
+ (0, import_fs.unlinkSync)(STATE_FILE);
451
+ } catch {
452
+ }
453
+ }
454
+
455
+ // src/interactive.ts
456
+ var import_child_process = require("child_process");
457
+ var InteractiveMode = class {
458
+ opts;
459
+ paused = false;
460
+ constructor(opts) {
461
+ this.opts = opts;
462
+ }
463
+ start() {
464
+ if (!process.stdin.isTTY) return;
465
+ process.stdin.setRawMode(true);
466
+ process.stdin.resume();
467
+ process.stdin.setEncoding("utf-8");
468
+ process.stdin.on("data", (key) => {
469
+ const ch = key.toUpperCase();
470
+ switch (ch) {
471
+ case "L":
472
+ process.stdout.write(` ${this.opts.publicUrl}
473
+ `);
474
+ break;
475
+ case "C":
476
+ this.copyToClipboard(this.opts.publicUrl);
477
+ break;
478
+ case "P":
479
+ this.togglePause();
480
+ break;
481
+ case "R":
482
+ void this.opts.tunnel.reconnect();
483
+ break;
484
+ case "Q":
485
+ case "":
486
+ this.opts.onQuit();
487
+ break;
488
+ }
489
+ });
490
+ }
491
+ stop() {
492
+ if (!process.stdin.isTTY) return;
493
+ try {
494
+ process.stdin.setRawMode(false);
495
+ } catch {
496
+ }
497
+ process.stdin.pause();
498
+ }
499
+ copyToClipboard(text) {
500
+ const platform = process.platform;
501
+ const run = (cmd, args = []) => {
502
+ const result = (0, import_child_process.spawnSync)(cmd, args, {
503
+ input: text,
504
+ stdio: ["pipe", "ignore", "ignore"]
505
+ });
506
+ return result.status === 0;
507
+ };
508
+ const commandExists = (cmd) => {
509
+ const result = (0, import_child_process.spawnSync)("sh", ["-c", `command -v ${cmd}`], {
510
+ stdio: "ignore"
511
+ });
512
+ return result.status === 0;
513
+ };
514
+ if (platform === "darwin" && commandExists("pbcopy") && run("pbcopy")) {
515
+ process.stdout.write(" \u2713 Copied to clipboard\n");
516
+ return;
517
+ }
518
+ if (platform === "linux") {
519
+ const isWayland = Boolean(process.env["WAYLAND_DISPLAY"]) || process.env["XDG_SESSION_TYPE"] === "wayland";
520
+ if (isWayland && commandExists("wl-copy") && run("wl-copy")) {
521
+ process.stdout.write(" \u2713 Copied to clipboard\n");
522
+ return;
523
+ }
524
+ if (commandExists("xclip") && run("xclip", ["-selection", "clipboard"])) {
525
+ process.stdout.write(" \u2713 Copied to clipboard\n");
526
+ return;
527
+ }
528
+ if (commandExists("xsel") && run("xsel", ["--clipboard", "--input"])) {
529
+ process.stdout.write(" \u2713 Copied to clipboard\n");
530
+ return;
531
+ }
532
+ if (commandExists("termux-clipboard-set") && run("termux-clipboard-set")) {
533
+ process.stdout.write(" \u2713 Copied to clipboard\n");
534
+ return;
535
+ }
536
+ }
537
+ if (platform === "win32" && (commandExists("clip.exe") && run("clip.exe") || commandExists("powershell.exe") && (0, import_child_process.spawnSync)(
538
+ "powershell.exe",
539
+ ["-NoProfile", "-Command", "Set-Clipboard -Value ([Console]::In.ReadToEnd())"],
540
+ { input: text, stdio: ["pipe", "ignore", "ignore"] }
541
+ ).status === 0)) {
542
+ process.stdout.write(" \u2713 Copied to clipboard\n");
543
+ return;
544
+ }
545
+ printError(
546
+ "Could not copy to clipboard (install wl-clipboard, xclip, or xsel on Linux)"
547
+ );
548
+ }
549
+ togglePause() {
550
+ this.paused = !this.paused;
551
+ if (this.paused) {
552
+ this.opts.tunnel.pause();
553
+ process.stdout.write(" ! Tunnel paused\n");
554
+ } else {
555
+ this.opts.tunnel.resume();
556
+ process.stdout.write(" ! Resuming tunnel...\n");
557
+ }
558
+ }
559
+ };
560
+
561
+ // src/commands/http.ts
562
+ var CONTROL_PLANE_URL = process.env["STOAT_CONTROL_PLANE_URL"] ?? "https://cp.discova.us";
563
+ async function httpCommand(port, options) {
564
+ const localPort = parseInt(port, 10);
565
+ if (isNaN(localPort) || localPort < 1 || localPort > 65535) {
566
+ printError(`Invalid port: ${port}`);
567
+ process.exit(1);
568
+ }
569
+ let basicAuth = null;
570
+ if (options.auth) {
571
+ const parts = options.auth.split(":");
572
+ if (parts.length < 2) {
573
+ printError("--auth must be in format user:pass");
574
+ process.exit(1);
575
+ }
576
+ basicAuth = { user: parts[0], pass: parts.slice(1).join(":") };
577
+ }
578
+ const expiresIn = options.expiry ? parseInt(options.expiry, 10) : 86400;
579
+ let sessionResp;
580
+ try {
581
+ const res = await fetch(`${CONTROL_PLANE_URL}/sessions`, {
582
+ method: "POST",
583
+ headers: { "Content-Type": "application/json" },
584
+ body: JSON.stringify({
585
+ localPort,
586
+ desiredSlug: options.slug ?? null,
587
+ basicAuth,
588
+ expiresIn
589
+ })
590
+ });
591
+ if (!res.ok) {
592
+ const body = await res.json();
593
+ printError(`Control plane error: ${body.error ?? res.statusText}`);
594
+ process.exit(1);
595
+ }
596
+ sessionResp = await res.json();
597
+ } catch (err) {
598
+ printError(
599
+ `Cannot reach control plane at ${CONTROL_PLANE_URL}: ${err instanceof Error ? err.message : String(err)}`
600
+ );
601
+ process.exit(1);
602
+ }
603
+ const { slug, token, expiresAt } = sessionResp;
604
+ const edgeUrl = sessionResp.edgeUrl.trim();
605
+ const publicUrl = sessionResp.publicUrl.replace(/\s+/g, "");
606
+ saveSession({
607
+ slug,
608
+ token,
609
+ localPort,
610
+ publicUrl,
611
+ edgeUrl,
612
+ expiresAt,
613
+ pid: process.pid,
614
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
615
+ });
616
+ const tunnel = new TunnelClient({
617
+ edgeUrl,
618
+ slug,
619
+ token,
620
+ localPort,
621
+ onAuthOk: (info2) => {
622
+ printBanner(publicUrl, localPort, info2.slug, info2.expiresAt);
623
+ },
624
+ onGoAway: (payload) => {
625
+ if (payload.reason === "session_expired") {
626
+ clearSession();
627
+ }
628
+ }
629
+ });
630
+ const interactive = new InteractiveMode({
631
+ publicUrl,
632
+ tunnel,
633
+ onQuit: () => {
634
+ process.stdout.write(" \u{1F43E} Goodbye\n");
635
+ tunnel.sendGoAway("user_quit");
636
+ tunnel.stop();
637
+ clearSession();
638
+ interactive.stop();
639
+ process.exit(0);
640
+ }
641
+ });
642
+ process.on("SIGINT", () => {
643
+ process.stdout.write(" \u{1F43E} Goodbye\n");
644
+ tunnel.sendGoAway("user_quit");
645
+ tunnel.stop();
646
+ clearSession();
647
+ interactive.stop();
648
+ process.exit(0);
649
+ });
650
+ process.on("SIGTERM", () => {
651
+ tunnel.sendGoAway("server_shutdown");
652
+ tunnel.stop();
653
+ clearSession();
654
+ process.exit(0);
655
+ });
656
+ process.stdout.write(" Connecting tunnel...\n");
657
+ try {
658
+ await tunnel.connect();
659
+ process.stdout.write(" \u2713 Tunnel connected\n");
660
+ interactive.start();
661
+ } catch (err) {
662
+ process.stdout.write(" \u2717 Tunnel connection failed\n");
663
+ printError(
664
+ `Failed to connect: ${err instanceof Error ? err.message : String(err)}`
665
+ );
666
+ clearSession();
667
+ process.exit(1);
668
+ }
669
+ }
670
+
671
+ // src/commands/status.ts
672
+ var CONTROL_PLANE_URL2 = process.env["STOAT_CONTROL_PLANE_URL"] ?? "https://cp.discova.us";
673
+ function isProcessAlive(pid) {
674
+ try {
675
+ process.kill(pid, 0);
676
+ return true;
677
+ } catch {
678
+ return false;
679
+ }
680
+ }
681
+ async function statusCommand() {
682
+ const session = loadSession();
683
+ if (!session) {
684
+ process.stdout.write("No active tunnel.\n");
685
+ return;
686
+ }
687
+ const localProcessAlive = isProcessAlive(session.pid);
688
+ let controlPlaneActive = false;
689
+ let controlPlaneError = null;
690
+ let expiresAt = session.expiresAt;
691
+ try {
692
+ const url = new URL(
693
+ `${CONTROL_PLANE_URL2}/sessions/${encodeURIComponent(session.slug)}`
694
+ );
695
+ url.searchParams.set("token", session.token);
696
+ const res = await fetch(url);
697
+ if (res.ok) {
698
+ const body = await res.json();
699
+ controlPlaneActive = body.active;
700
+ expiresAt = body.expiresAt;
701
+ } else {
702
+ controlPlaneError = `${res.status} ${res.statusText}`;
703
+ }
704
+ } catch (err) {
705
+ controlPlaneError = err instanceof Error ? err.message : String(err);
706
+ }
707
+ const now = Date.now();
708
+ const expiryMs = new Date(expiresAt).getTime();
709
+ const expired = Number.isFinite(expiryMs) ? expiryMs <= now : false;
710
+ const overallStatus = localProcessAlive && controlPlaneActive && !expired ? "online" : "offline";
711
+ process.stdout.write("Stoat.run Tunnel Status\n");
712
+ process.stdout.write("====================\n");
713
+ process.stdout.write(`Slug: ${session.slug}
714
+ `);
715
+ process.stdout.write(`Public URL: ${session.publicUrl}
716
+ `);
717
+ process.stdout.write(`Local Port: ${session.localPort}
718
+ `);
719
+ process.stdout.write(`CLI PID: ${session.pid}
720
+ `);
721
+ process.stdout.write(
722
+ `CLI Process: ${localProcessAlive ? "running" : "not running"}
723
+ `
724
+ );
725
+ process.stdout.write(
726
+ `Control Plane: ${controlPlaneActive ? "active" : "inactive"}
727
+ `
728
+ );
729
+ process.stdout.write(`Expires At: ${expiresAt}
730
+ `);
731
+ process.stdout.write(`Overall Status: ${overallStatus}
732
+ `);
733
+ if (controlPlaneError) {
734
+ process.stdout.write(`Control Plane Err: ${controlPlaneError}
735
+ `);
736
+ }
737
+ }
738
+
739
+ // src/bin.ts
740
+ var program = new import_commander.Command();
741
+ program.name("stoat").description("Share your localhost in one command").version("0.1.0");
742
+ program.command("http").description("Expose a local HTTP server").argument("<port>", "local port to expose").option("--slug <slug>", "request a specific slug").option("--auth <user:pass>", "require basic auth for public access").option("--expiry <seconds>", "session expiry in seconds").action(
743
+ async (port, options) => {
744
+ await httpCommand(port, {
745
+ slug: options.slug,
746
+ auth: options.auth,
747
+ expiry: options.expiry
748
+ });
749
+ }
750
+ );
751
+ program.command("status").description("Show the current tunnel status").action(async () => {
752
+ await statusCommand();
753
+ });
754
+ void program.parseAsync(process.argv);
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "stoat.run",
3
+ "version": "0.1.0",
4
+ "description": "Share your localhost in one command",
5
+ "license": "MIT",
6
+ "author": "sic-em",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/sic-em/stoat.run.git",
10
+ "directory": "packages/cli"
11
+ },
12
+ "homepage": "https://github.com/sic-em/stoat.run/tree/main/packages/cli",
13
+ "bugs": {
14
+ "url": "https://github.com/sic-em/stoat.run/issues"
15
+ },
16
+ "keywords": [
17
+ "tunnel",
18
+ "localhost",
19
+ "cli",
20
+ "proxy",
21
+ "websocket"
22
+ ],
23
+ "bin": {
24
+ "stoat": "dist/bin.cjs"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "type": "module",
37
+ "scripts": {
38
+ "build": "tsup",
39
+ "dev": "tsup --watch",
40
+ "typecheck": "tsc --noEmit",
41
+ "lint": "eslint src",
42
+ "test": "node --test dist/protocol.test.js"
43
+ },
44
+ "dependencies": {
45
+ "commander": "^12.0.0",
46
+ "undici": "^7.0.0",
47
+ "ws": "^8.18.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^20.0.0",
51
+ "@types/ws": "^8.5.0",
52
+ "tsup": "^8.0.0",
53
+ "typescript": "^5.5.0"
54
+ }
55
+ }