fied 0.2.0 → 0.2.2
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/bin.js +217 -43
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -52,6 +52,22 @@ function toBase64Url(bytes) {
|
|
|
52
52
|
}
|
|
53
53
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
54
54
|
}
|
|
55
|
+
function fromBase64Url(str) {
|
|
56
|
+
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
57
|
+
while (base64.length % 4 !== 0) {
|
|
58
|
+
base64 += "=";
|
|
59
|
+
}
|
|
60
|
+
if (typeof atob === "function") {
|
|
61
|
+
const binary = atob(base64);
|
|
62
|
+
const bytes = new Uint8Array(binary.length);
|
|
63
|
+
for (let i = 0; i < binary.length; i++) {
|
|
64
|
+
bytes[i] = binary.charCodeAt(i);
|
|
65
|
+
}
|
|
66
|
+
return bytes;
|
|
67
|
+
} else {
|
|
68
|
+
return new Uint8Array(Buffer.from(base64, "base64"));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
55
71
|
|
|
56
72
|
// ../crypto/dist/protocol.js
|
|
57
73
|
var IV_LENGTH2 = 12;
|
|
@@ -166,10 +182,19 @@ var DEFAULT_RELAY = "https://fied.app";
|
|
|
166
182
|
var MSG_TERMINAL_OUTPUT = 1;
|
|
167
183
|
var MSG_TERMINAL_INPUT = 2;
|
|
168
184
|
var MSG_RESIZE = 3;
|
|
185
|
+
var VIEWER_JOINED = "__fied_viewer_joined__";
|
|
186
|
+
var RESIZE_MIN_COLS = 20;
|
|
187
|
+
var RESIZE_MAX_COLS = 1e3;
|
|
188
|
+
var RESIZE_MIN_ROWS = 5;
|
|
189
|
+
var RESIZE_MAX_ROWS = 300;
|
|
190
|
+
var MAX_INVALID_RESIZE_FRAMES = 5;
|
|
169
191
|
var RECONNECT_BASE_MS = 1e3;
|
|
170
192
|
var RECONNECT_MAX_MS = 3e4;
|
|
171
193
|
async function share(options) {
|
|
172
|
-
const
|
|
194
|
+
const relayTarget = parseRelayTarget(
|
|
195
|
+
options.relay ?? DEFAULT_RELAY,
|
|
196
|
+
options.allowInsecureRelay ?? process.env.FIED_ALLOW_INSECURE_RELAY === "1"
|
|
197
|
+
);
|
|
173
198
|
const sessions = listSessions();
|
|
174
199
|
if (sessions.length === 0) {
|
|
175
200
|
console.error("No tmux sessions found. Start one with: tmux new -s mysession");
|
|
@@ -193,9 +218,9 @@ async function share(options) {
|
|
|
193
218
|
}
|
|
194
219
|
const cols = options.cols ?? process.stdout.columns ?? 80;
|
|
195
220
|
const rows = options.rows ?? process.stdout.rows ?? 24;
|
|
196
|
-
const rawKey = await generateKey();
|
|
221
|
+
const rawKey = options.keyBase64Url ? fromBase64Url(options.keyBase64Url) : await generateKey();
|
|
197
222
|
const cryptoKey = await importKey(rawKey);
|
|
198
|
-
const keyFragment = toBase64Url(rawKey);
|
|
223
|
+
const keyFragment = options.keyBase64Url ?? toBase64Url(rawKey);
|
|
199
224
|
const pty = attachSession(targetSession, cols, rows);
|
|
200
225
|
if (!options.background) {
|
|
201
226
|
console.log("");
|
|
@@ -205,36 +230,92 @@ async function share(options) {
|
|
|
205
230
|
console.log(` Size: ${cols}x${rows}`);
|
|
206
231
|
console.log("");
|
|
207
232
|
}
|
|
208
|
-
const bridge = new RelayBridge(
|
|
233
|
+
const bridge = new RelayBridge(relayTarget, cryptoKey, keyFragment, pty, options.background, options.sessionId);
|
|
234
|
+
let handoffRequested = false;
|
|
209
235
|
const onUrl = (url) => {
|
|
210
236
|
if (options.background) {
|
|
211
237
|
addSession({
|
|
212
238
|
pid: process.pid,
|
|
213
239
|
tmuxSession: targetSession,
|
|
214
240
|
url,
|
|
215
|
-
relay,
|
|
241
|
+
relay: relayTarget.httpBase.toString(),
|
|
216
242
|
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
217
243
|
});
|
|
218
244
|
}
|
|
245
|
+
if (options.onShareUrl) {
|
|
246
|
+
return Promise.resolve(options.onShareUrl(url)).then((action) => {
|
|
247
|
+
if (action === "handoff") {
|
|
248
|
+
handoffRequested = true;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return Promise.resolve();
|
|
219
253
|
};
|
|
220
254
|
await bridge.connect(onUrl);
|
|
221
|
-
|
|
255
|
+
if (handoffRequested) {
|
|
256
|
+
bridge.destroy();
|
|
257
|
+
try {
|
|
258
|
+
pty.kill();
|
|
259
|
+
} catch {
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
let closed = false;
|
|
264
|
+
const cleanup = () => {
|
|
265
|
+
if (closed) return;
|
|
266
|
+
closed = true;
|
|
222
267
|
bridge.destroy();
|
|
223
|
-
|
|
224
|
-
|
|
268
|
+
try {
|
|
269
|
+
pty.kill();
|
|
270
|
+
} catch {
|
|
271
|
+
}
|
|
272
|
+
if (options.background) {
|
|
273
|
+
removeSession(process.pid);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
const exitNow = (code) => {
|
|
277
|
+
cleanup();
|
|
278
|
+
process.exit(code);
|
|
225
279
|
};
|
|
226
|
-
process.
|
|
227
|
-
process.
|
|
280
|
+
process.once("SIGINT", () => exitNow(0));
|
|
281
|
+
process.once("SIGTERM", () => exitNow(0));
|
|
228
282
|
await new Promise((resolve) => {
|
|
229
283
|
pty.onExit(() => {
|
|
230
|
-
|
|
231
|
-
if (options.background) removeSession(process.pid);
|
|
284
|
+
cleanup();
|
|
232
285
|
resolve();
|
|
233
286
|
});
|
|
234
287
|
});
|
|
235
288
|
}
|
|
236
|
-
|
|
237
|
-
|
|
289
|
+
function parseRelayTarget(relay, allowInsecureRelay) {
|
|
290
|
+
let parsed;
|
|
291
|
+
try {
|
|
292
|
+
parsed = new URL(relay);
|
|
293
|
+
} catch {
|
|
294
|
+
throw new Error(`Invalid relay URL: ${relay}`);
|
|
295
|
+
}
|
|
296
|
+
const isHttps = parsed.protocol === "https:";
|
|
297
|
+
const isHttp = parsed.protocol === "http:";
|
|
298
|
+
const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1";
|
|
299
|
+
if (!isHttps) {
|
|
300
|
+
if (!(isHttp && isLocalhost && allowInsecureRelay)) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
"Relay must use https://. For local development, use http://localhost with --allow-insecure-relay or FIED_ALLOW_INSECURE_RELAY=1"
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const httpBase = new URL(parsed.toString());
|
|
307
|
+
httpBase.hash = "";
|
|
308
|
+
httpBase.search = "";
|
|
309
|
+
if (!httpBase.pathname.endsWith("/")) {
|
|
310
|
+
httpBase.pathname = `${httpBase.pathname}/`;
|
|
311
|
+
}
|
|
312
|
+
const wsBase = new URL(httpBase.toString());
|
|
313
|
+
wsBase.protocol = httpBase.protocol === "https:" ? "wss:" : "ws:";
|
|
314
|
+
return { httpBase, wsBase };
|
|
315
|
+
}
|
|
316
|
+
async function createSession(relayHttpBase) {
|
|
317
|
+
const url = new URL("api/sessions", relayHttpBase);
|
|
318
|
+
const res = await fetch(url.toString(), { method: "POST" });
|
|
238
319
|
if (!res.ok) {
|
|
239
320
|
throw new Error(`Failed to create session: ${res.status} ${res.statusText}`);
|
|
240
321
|
}
|
|
@@ -243,12 +324,13 @@ async function createSession(relay) {
|
|
|
243
324
|
}
|
|
244
325
|
var WS_CONNECT_TIMEOUT_MS = 1e4;
|
|
245
326
|
var RelayBridge = class {
|
|
246
|
-
constructor(
|
|
247
|
-
this.
|
|
327
|
+
constructor(relayTarget, key, keyFragment, pty, silent = false, sessionId) {
|
|
328
|
+
this.relayTarget = relayTarget;
|
|
248
329
|
this.key = key;
|
|
249
330
|
this.keyFragment = keyFragment;
|
|
250
331
|
this.pty = pty;
|
|
251
332
|
this.silent = silent;
|
|
333
|
+
this.sessionId = sessionId ?? null;
|
|
252
334
|
this.pty.onData((data) => {
|
|
253
335
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
254
336
|
this.sendEncrypted(MSG_TERMINAL_OUTPUT, this.encoder.encode(data));
|
|
@@ -263,18 +345,23 @@ var RelayBridge = class {
|
|
|
263
345
|
encoder = new TextEncoder();
|
|
264
346
|
decoder = new TextDecoder();
|
|
265
347
|
sessionId = null;
|
|
348
|
+
onUrl = null;
|
|
349
|
+
invalidResizeFrames = 0;
|
|
266
350
|
async connect(onUrl) {
|
|
267
351
|
if (this.destroyed) return;
|
|
352
|
+
if (onUrl) {
|
|
353
|
+
this.onUrl = onUrl;
|
|
354
|
+
}
|
|
268
355
|
if (!this.sessionId) {
|
|
269
356
|
try {
|
|
270
|
-
this.sessionId = await createSession(this.
|
|
357
|
+
this.sessionId = await createSession(this.relayTarget.httpBase);
|
|
271
358
|
} catch {
|
|
272
359
|
if (!this.silent) console.error(" \x1B[31mRelay unreachable, retrying...\x1B[0m");
|
|
273
360
|
this.scheduleReconnect();
|
|
274
361
|
return;
|
|
275
362
|
}
|
|
276
|
-
const
|
|
277
|
-
|
|
363
|
+
const shareUrl = new URL(`s/${this.sessionId}`, this.relayTarget.httpBase);
|
|
364
|
+
const url = `${shareUrl.toString()}#${this.keyFragment}`;
|
|
278
365
|
if (!this.silent) {
|
|
279
366
|
console.log(` \x1B[1mShare this link:\x1B[0m`);
|
|
280
367
|
console.log(` \x1B[4m\x1B[36m${url}\x1B[0m`);
|
|
@@ -283,9 +370,11 @@ var RelayBridge = class {
|
|
|
283
370
|
console.log(" \x1B[2mPress Ctrl+C to stop sharing.\x1B[0m");
|
|
284
371
|
console.log("");
|
|
285
372
|
}
|
|
373
|
+
await this.onUrl?.(url);
|
|
286
374
|
}
|
|
287
|
-
const wsUrl =
|
|
288
|
-
|
|
375
|
+
const wsUrl = new URL(`api/sessions/${this.sessionId}/ws`, this.relayTarget.wsBase);
|
|
376
|
+
wsUrl.searchParams.set("role", "host");
|
|
377
|
+
const ws = new WebSocket(wsUrl.toString());
|
|
289
378
|
ws.binaryType = "arraybuffer";
|
|
290
379
|
this.ws = ws;
|
|
291
380
|
this.connectTimeout = setTimeout(() => {
|
|
@@ -305,6 +394,8 @@ var RelayBridge = class {
|
|
|
305
394
|
const text = this.decoder.decode(raw);
|
|
306
395
|
if (text === "__fied_ping__") {
|
|
307
396
|
ws.send("__fied_pong__");
|
|
397
|
+
} else if (text === VIEWER_JOINED) {
|
|
398
|
+
this.pty.write("\f");
|
|
308
399
|
}
|
|
309
400
|
return;
|
|
310
401
|
}
|
|
@@ -316,10 +407,22 @@ var RelayBridge = class {
|
|
|
316
407
|
this.pty.write(this.decoder.decode(plaintext));
|
|
317
408
|
} else if (frame.type === MSG_RESIZE) {
|
|
318
409
|
const plaintext = await decrypt(this.key, frame.iv, frame.ciphertext);
|
|
319
|
-
const
|
|
320
|
-
|
|
410
|
+
const resize = parseResizePayload(this.decoder.decode(plaintext));
|
|
411
|
+
if (!resize) {
|
|
412
|
+
this.invalidResizeFrames += 1;
|
|
413
|
+
if (this.invalidResizeFrames >= MAX_INVALID_RESIZE_FRAMES) {
|
|
414
|
+
ws.close(1008, "invalid resize frames");
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
this.invalidResizeFrames = 0;
|
|
419
|
+
this.pty.resize(resize.cols, resize.rows);
|
|
420
|
+
}
|
|
421
|
+
} catch (err) {
|
|
422
|
+
if (!this.silent) {
|
|
423
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
424
|
+
console.error(` \x1B[33mIncoming frame rejected:\x1B[0m ${detail}`);
|
|
321
425
|
}
|
|
322
|
-
} catch {
|
|
323
426
|
}
|
|
324
427
|
});
|
|
325
428
|
ws.on("close", () => {
|
|
@@ -365,10 +468,30 @@ var RelayBridge = class {
|
|
|
365
468
|
const { iv, ciphertext } = await encrypt(this.key, plaintext);
|
|
366
469
|
const frame = frameMessage(type, iv, ciphertext);
|
|
367
470
|
this.ws.send(frame);
|
|
368
|
-
} catch {
|
|
471
|
+
} catch (err) {
|
|
472
|
+
if (!this.silent) {
|
|
473
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
474
|
+
console.error(` \x1B[33mEncryption failed:\x1B[0m ${detail}`);
|
|
475
|
+
}
|
|
369
476
|
}
|
|
370
477
|
}
|
|
371
478
|
};
|
|
479
|
+
function parseResizePayload(payload) {
|
|
480
|
+
let parsed;
|
|
481
|
+
try {
|
|
482
|
+
parsed = JSON.parse(payload);
|
|
483
|
+
} catch {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
487
|
+
const typed = parsed;
|
|
488
|
+
if (!Number.isInteger(typed.cols) || !Number.isInteger(typed.rows)) return null;
|
|
489
|
+
const cols = typed.cols;
|
|
490
|
+
const rows = typed.rows;
|
|
491
|
+
if (cols < RESIZE_MIN_COLS || cols > RESIZE_MAX_COLS) return null;
|
|
492
|
+
if (rows < RESIZE_MIN_ROWS || rows > RESIZE_MAX_ROWS) return null;
|
|
493
|
+
return { cols, rows };
|
|
494
|
+
}
|
|
372
495
|
|
|
373
496
|
// src/prompt.ts
|
|
374
497
|
import { createInterface } from "node:readline";
|
|
@@ -438,6 +561,7 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
438
561
|
\x1B[1mOptions:\x1B[0m
|
|
439
562
|
--session, -s <name> tmux session to share (auto-detected if only one)
|
|
440
563
|
--relay <url> relay server URL (default: https://fied.app)
|
|
564
|
+
--allow-insecure-relay allow http://localhost relay (dev only)
|
|
441
565
|
--help, -h show this help
|
|
442
566
|
|
|
443
567
|
\x1B[1mExamples:\x1B[0m
|
|
@@ -454,6 +578,12 @@ if (args.includes("--__daemon")) {
|
|
|
454
578
|
options.session = args[++i];
|
|
455
579
|
} else if (args[i] === "--relay" && args[i + 1]) {
|
|
456
580
|
options.relay = args[++i];
|
|
581
|
+
} else if (args[i] === "--allow-insecure-relay") {
|
|
582
|
+
options.allowInsecureRelay = true;
|
|
583
|
+
} else if (args[i] === "--__session-id" && args[i + 1]) {
|
|
584
|
+
options.sessionId = args[++i];
|
|
585
|
+
} else if (args[i] === "--__key" && args[i + 1]) {
|
|
586
|
+
options.keyBase64Url = args[++i];
|
|
457
587
|
}
|
|
458
588
|
}
|
|
459
589
|
share({ ...options, background: true }).catch(() => process.exit(1));
|
|
@@ -466,11 +596,14 @@ if (args.includes("--__daemon")) {
|
|
|
466
596
|
async function main() {
|
|
467
597
|
let relay;
|
|
468
598
|
let session;
|
|
599
|
+
let allowInsecureRelay = false;
|
|
469
600
|
for (let i = 0; i < args.length; i++) {
|
|
470
601
|
if ((args[i] === "--session" || args[i] === "-s") && args[i + 1]) {
|
|
471
602
|
session = args[++i];
|
|
472
603
|
} else if (args[i] === "--relay" && args[i + 1]) {
|
|
473
604
|
relay = args[++i];
|
|
605
|
+
} else if (args[i] === "--allow-insecure-relay") {
|
|
606
|
+
allowInsecureRelay = true;
|
|
474
607
|
} else if (!args[i].startsWith("-")) {
|
|
475
608
|
continue;
|
|
476
609
|
} else {
|
|
@@ -526,25 +659,66 @@ async function main() {
|
|
|
526
659
|
session = session.split(" \u2014 ")[0].trim();
|
|
527
660
|
}
|
|
528
661
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const binPath = fileURLToPath(import.meta.url);
|
|
532
|
-
const childArgs = ["--__daemon", "--session", session];
|
|
533
|
-
if (relay) childArgs.push("--relay", relay);
|
|
534
|
-
const child = spawnChild(process.execPath, [binPath, ...childArgs], {
|
|
535
|
-
detached: true,
|
|
536
|
-
stdio: "ignore"
|
|
537
|
-
});
|
|
538
|
-
child.unref();
|
|
539
|
-
console.error("");
|
|
540
|
-
console.error(` \x1B[1m\x1B[32mfied\x1B[0m \u2014 started in background (PID ${child.pid})`);
|
|
541
|
-
console.error(` Session: ${session}`);
|
|
542
|
-
console.error(" Run \x1B[1mnpx fied\x1B[0m again to manage.");
|
|
543
|
-
console.error("");
|
|
544
|
-
setTimeout(() => process.exit(0), 500);
|
|
545
|
-
} else {
|
|
546
|
-
await share({ session, relay });
|
|
662
|
+
if (!session) {
|
|
663
|
+
throw new Error("No tmux session selected");
|
|
547
664
|
}
|
|
665
|
+
await share({
|
|
666
|
+
session,
|
|
667
|
+
relay,
|
|
668
|
+
allowInsecureRelay,
|
|
669
|
+
onShareUrl: async (url) => {
|
|
670
|
+
const background = await confirm("Run in background?");
|
|
671
|
+
if (!background) {
|
|
672
|
+
return "continue";
|
|
673
|
+
}
|
|
674
|
+
const parsed = parseShareUrl(url);
|
|
675
|
+
const child = spawnBackground({
|
|
676
|
+
session,
|
|
677
|
+
relay,
|
|
678
|
+
allowInsecureRelay,
|
|
679
|
+
sessionId: parsed.sessionId,
|
|
680
|
+
keyBase64Url: parsed.keyBase64Url
|
|
681
|
+
});
|
|
682
|
+
console.error("");
|
|
683
|
+
console.error(` \x1B[1m\x1B[32mfied\x1B[0m \u2014 moved to background (PID ${child.pid})`);
|
|
684
|
+
console.error(` Session: ${session}`);
|
|
685
|
+
console.error(" Same share link stays active.");
|
|
686
|
+
console.error(" Run \x1B[1mnpx fied\x1B[0m again to manage.");
|
|
687
|
+
console.error("");
|
|
688
|
+
return "handoff";
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
function spawnBackground(options) {
|
|
693
|
+
const binPath = fileURLToPath(import.meta.url);
|
|
694
|
+
const childArgs = [
|
|
695
|
+
"--__daemon",
|
|
696
|
+
"--session",
|
|
697
|
+
options.session,
|
|
698
|
+
"--__session-id",
|
|
699
|
+
options.sessionId,
|
|
700
|
+
"--__key",
|
|
701
|
+
options.keyBase64Url
|
|
702
|
+
];
|
|
703
|
+
if (options.relay) childArgs.push("--relay", options.relay);
|
|
704
|
+
if (options.allowInsecureRelay) childArgs.push("--allow-insecure-relay");
|
|
705
|
+
const child = spawnChild(process.execPath, [binPath, ...childArgs], {
|
|
706
|
+
detached: true,
|
|
707
|
+
stdio: "ignore"
|
|
708
|
+
});
|
|
709
|
+
child.unref();
|
|
710
|
+
return child;
|
|
711
|
+
}
|
|
712
|
+
function parseShareUrl(url) {
|
|
713
|
+
const parsed = new URL(url);
|
|
714
|
+
const match = parsed.pathname.match(/^\/s\/([A-Za-z0-9_-]{8,64})$/);
|
|
715
|
+
if (!match || !parsed.hash) {
|
|
716
|
+
throw new Error("Invalid share URL");
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
sessionId: match[1],
|
|
720
|
+
keyBase64Url: parsed.hash.slice(1)
|
|
721
|
+
};
|
|
548
722
|
}
|
|
549
723
|
function timeSince(date) {
|
|
550
724
|
const seconds = Math.floor((Date.now() - date.getTime()) / 1e3);
|