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.
Files changed (2) hide show
  1. package/dist/bin.js +217 -43
  2. 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 relay = options.relay ?? DEFAULT_RELAY;
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(relay, cryptoKey, keyFragment, pty, options.background);
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
- const shutdown = () => {
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
- pty.kill();
224
- if (options.background) removeSession(process.pid);
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.on("SIGINT", shutdown);
227
- process.on("SIGTERM", shutdown);
280
+ process.once("SIGINT", () => exitNow(0));
281
+ process.once("SIGTERM", () => exitNow(0));
228
282
  await new Promise((resolve) => {
229
283
  pty.onExit(() => {
230
- bridge.destroy();
231
- if (options.background) removeSession(process.pid);
284
+ cleanup();
232
285
  resolve();
233
286
  });
234
287
  });
235
288
  }
236
- async function createSession(relay) {
237
- const res = await fetch(`${relay}/api/sessions`, { method: "POST" });
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(relay, key, keyFragment, pty, silent = false) {
247
- this.relay = relay;
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.relay);
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 url = `${this.relay}/s/${this.sessionId}#${this.keyFragment}`;
277
- onUrl?.(url);
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 = this.relay.replace(/^http/, "ws") + `/api/sessions/${this.sessionId}/ws?role=host`;
288
- const ws = new WebSocket(wsUrl);
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 { cols, rows } = JSON.parse(this.decoder.decode(plaintext));
320
- this.pty.resize(cols, rows);
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
- const background = await confirm("Run in background?");
530
- if (background) {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fied",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Share your tmux session in the browser with end-to-end encryption",
5
5
  "type": "module",
6
6
  "bin": {