fastdotcom 1.0.0 → 1.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 (2) hide show
  1. package/index.js +149 -62
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -5,9 +5,9 @@
5
5
  // Open Connect) as its backend. Zero dependencies.
6
6
  //
7
7
  // On a TTY it renders a small btop-style TUI: each metric is a gauge scaled
8
- // 0 → max (this run), gradient-filled to the average, with a white tick at the
9
- // minimum and superscript min/avg/max labels above. Piped (or --json) output
10
- // falls back to plain text.
8
+ // 0 → max (this run), gradient-filled to the 90th percentile, with a white tick
9
+ // at the minimum and superscript min/p90/max labels above. Piped (or --json)
10
+ // output falls back to plain text.
11
11
 
12
12
  const https = require('node:https');
13
13
 
@@ -69,31 +69,108 @@ const BOLD = '\x1b[1m';
69
69
  const fg = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`;
70
70
  const visLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length; // width sans ANSI
71
71
 
72
- const C_BORDER = fg(88, 96, 112);
73
- const C_TITLE = fg(122, 222, 255);
74
- const C_LABEL = fg(210, 215, 225);
75
- const C_MUTE = fg(120, 128, 145);
76
- const C_TRACK = fg(48, 52, 64);
77
- const C_TICK = BOLD + fg(255, 255, 255); // the min marker (bright dot)
78
- const C_MAXTICK = fg(96, 104, 122); // the max marker (slightly lighter than track)
79
-
80
- // Border shimmer: while a measurement runs, a soft band of lighter grey sweeps
81
- // left→right across the whole box outline (corners, dashes and vertical bars),
82
- // and through the muted header/footer text.
83
- const SHIMMER_BASE = [88, 96, 112]; // C_BORDER grey (resting colour)
84
- const SHIMMER_GLOW = [165, 176, 198]; // a clear lift over base as the band passes
85
- const SHIMMER_EDGE = 0.4; // dim factor for the vertical edges (a whole column
86
- // lights at once, so full glow reads as a flash)
87
- const SHIMMER_TEXT = [120, 128, 145]; // muted text resting colour (C_MUTE)
88
- const SHIMMER_TEXT_GLOW = [185, 192, 208]; // brightened muted text as the band passes
72
+ // Query the terminal for its background colour via OSC 11. Returns true if
73
+ // the background is light, false if dark (or on detection failure).
74
+ async function detectLightBg() {
75
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
76
+ return new Promise((resolve) => {
77
+ const stdin = process.stdin;
78
+ const prevRaw = stdin.isRaw;
79
+ stdin.setRawMode(true);
80
+ stdin.resume();
81
+
82
+ let buf = '';
83
+ let settled = false;
84
+ const onData = (chunk) => { buf += chunk.toString(); };
85
+ stdin.on('data', onData);
86
+
87
+ // Send OSC 11 query (request background colour).
88
+ process.stdout.write('\x1b]11;?\x07');
89
+
90
+ const done = (light) => {
91
+ if (settled) return;
92
+ settled = true;
93
+ clearInterval(poll);
94
+ clearTimeout(timer);
95
+ stdin.removeListener('data', onData);
96
+ stdin.setRawMode(prevRaw);
97
+ stdin.pause();
98
+ resolve(light);
99
+ };
100
+
101
+ const check = () => {
102
+ const m = buf.match(/\x1b\]11;rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i);
103
+ if (m) {
104
+ const r = parseInt(m[1], 16) >> (m[1].length > 2 ? 8 : 0);
105
+ const g = parseInt(m[2], 16) >> (m[2].length > 2 ? 8 : 0);
106
+ const b = parseInt(m[3], 16) >> (m[3].length > 2 ? 8 : 0);
107
+ done(0.299 * r + 0.587 * g + 0.114 * b > 128);
108
+ }
109
+ };
110
+ const poll = setInterval(check, 10);
111
+ const timer = setTimeout(() => done(false), 150);
112
+ });
113
+ }
114
+
115
+ // Palette: switch colours depending on detected background.
116
+ const DARK = {
117
+ border: [88, 96, 112],
118
+ title: [122, 222, 255],
119
+ label: [210, 215, 225],
120
+ mute: [120, 128, 145],
121
+ track: [48, 52, 64],
122
+ tick: [255, 255, 255],
123
+ maxtick: [96, 104, 122],
124
+ shimmerBase: [88, 96, 112],
125
+ shimmerGlow: [165, 176, 198],
126
+ shimmerText: [120, 128, 145],
127
+ shimmerTextGlow: [185, 192, 208],
128
+ gPing: [[52, 211, 153], [250, 204, 21], [239, 68, 68]],
129
+ gDown: [[196, 181, 253], [167, 139, 250], [139, 92, 246]],
130
+ gUp: [[249, 168, 212], [244, 114, 182], [236, 72, 153]],
131
+ };
132
+
133
+ // Night Owl Light — terminal palette from Sarah Drasner's VS Code theme.
134
+ const LIGHT = {
135
+ border: [147, 161, 161], // white — muted, recedes
136
+ title: [40, 142, 215], // blue — #288ed7, the signature accent
137
+ label: [64, 63, 83], // fg — #403f53, main foreground
138
+ mute: [147, 161, 161], // white — #93A1A1, subdued
139
+ track: [230, 230, 230], // between bg #F6F6F6 and selection #E0E0E0
140
+ tick: [64, 63, 83], // fg — #403f53, strong contrast
141
+ maxtick: [180, 185, 190], // subtle in the track
142
+ shimmerBase: [147, 161, 161], // white resting
143
+ shimmerGlow: [90, 95, 110], // darken toward fg
144
+ shimmerText: [147, 161, 161], // white resting
145
+ shimmerTextGlow: [90, 95, 110], // darken toward fg
146
+ gPing: [[8, 145, 106], [224, 175, 2], [222, 61, 59]], // green→yellow→red
147
+ gDown: [[42, 162, 152], [40, 142, 215], [214, 67, 138]], // cyan→blue→magenta
148
+ gUp: [[224, 175, 2], [214, 67, 138], [222, 61, 59]], // yellow→magenta→red
149
+ };
150
+
151
+ let T; // active theme — set by initTheme()
152
+
153
+ function theme(light) {
154
+ T = light ? LIGHT : DARK;
155
+ }
156
+ theme(false); // default dark; initTheme() overrides before first paint
157
+
158
+ function C_BORDER() { return fg(...T.border); }
159
+ function C_TITLE() { return fg(...T.title); }
160
+ function C_LABEL() { return fg(...T.label); }
161
+ function C_MUTE() { return fg(...T.mute); }
162
+ function C_TRACK() { return fg(...T.track); }
163
+ function C_TICK() { return BOLD + fg(...T.tick); }
164
+ function C_MAXTICK() { return fg(...T.maxtick); }
165
+
166
+ const SHIMMER_EDGE = 0.4;
89
167
 
90
168
  // Gradient stops (left → right across a gauge).
91
- // Bars are scaled 0→max, so a full bar means avg≈max (a consistent reading);
92
- // gradients end green/cool so "fuller = healthier" reads the same everywhere.
93
- const G_PING = [[52, 211, 153], [250, 204, 21], [239, 68, 68]]; // green=low → red=high
94
169
  const PING_SCALE = 150; // ms mapped to the full green→red range
95
- const G_DOWN = [[196, 181, 253], [167, 139, 250], [139, 92, 246]]; // lilac→violet
96
- const G_UP = [[249, 168, 212], [244, 114, 182], [236, 72, 153]]; // pink→rose
170
+
171
+ function G_PING() { return T.gPing; }
172
+ function G_DOWN() { return T.gDown; }
173
+ function G_UP() { return T.gUp; }
97
174
 
98
175
  function gradColor(stops, t) {
99
176
  t = Math.max(0, Math.min(1, t));
@@ -115,27 +192,27 @@ function clampIdx(frac, width) {
115
192
  return Math.max(0, Math.min(width - 1, Math.round(frac * width)));
116
193
  }
117
194
 
118
- // A gauge of `width` columns scaled 0 → scaleMax: gradient-filled to st.avg,
195
+ // A gauge of `width` columns scaled 0 → scaleMax: gradient-filled to st.p90,
119
196
  // with a white tick at st.min; the rest is a dim track. Download and upload
120
197
  // pass a shared scaleMax so their bars are directly comparable.
121
198
  function gaugeStats(st, width, stops, scaleMax) {
122
199
  const max = scaleMax || (st && st.max) || 0;
123
- if (!st || max <= 0) return C_TRACK + DOT.repeat(width) + RESET;
124
- const fill = Math.round(Math.min(1, st.avg / max) * width);
200
+ if (!st || max <= 0) return C_TRACK() + DOT.repeat(width) + RESET;
201
+ const fill = Math.round(Math.min(1, st.p90 / max) * width);
125
202
  const minIdx = clampIdx(Math.min(1, st.min / max), width);
126
203
  const maxIdx = clampIdx(Math.min(1, st.max / max), width);
127
204
  let out = '';
128
205
  for (let i = 0; i < width; i++) {
129
206
  if (i === minIdx) {
130
- out += C_TICK + DOT; // bright min marker
207
+ out += C_TICK() + DOT; // bright min marker
131
208
  } else if (i < fill) {
132
209
  const t = width > 1 ? i / (width - 1) : 0;
133
210
  const [r, g, b] = gradColor(stops, t);
134
211
  out += fg(r, g, b) + DOT;
135
212
  } else if (i === maxIdx) {
136
- out += C_MAXTICK + DOT; // faint max marker in the track
213
+ out += C_MAXTICK() + DOT; // faint max marker in the track
137
214
  } else {
138
- out += C_TRACK + DOT;
215
+ out += C_TRACK() + DOT;
139
216
  }
140
217
  }
141
218
  return out + RESET;
@@ -191,24 +268,24 @@ function placeLabels(cells, items, gap = 1) {
191
268
  }
192
269
  }
193
270
 
194
- // The min/avg/max superscript row that sits above a gauge, positioned on the
271
+ // The min/p90/max superscript row that sits above a gauge, positioned on the
195
272
  // shared scaleMax.
196
273
  function labelRow(st, width, stops, fmtSup, scaleMax) {
197
274
  const cells = newCells(width);
198
275
  const max = scaleMax || (st && st.max) || 0;
199
276
  if (st && max > 0) {
200
- const avgF = Math.min(1, st.avg / max);
201
- const [r, g, b] = gradColor(stops, avgF);
277
+ const p90F = Math.min(1, st.p90 / max);
278
+ const [r, g, b] = gradColor(stops, p90F);
202
279
  // The gradient fills columns 0…fill-1, so the bar visually ends at fill-1.
203
- // min/max have ticks drawn at their clampIdx column; avg has none, so anchor
280
+ // min/max have ticks drawn at their clampIdx column; p90 has none, so anchor
204
281
  // it to the last filled dot rather than the first empty cell past it.
205
- const avgIdx = Math.max(0, Math.round(avgF * width) - 1);
282
+ const p90Idx = Math.max(0, Math.round(p90F * width) - 1);
206
283
  placeLabels(cells, [
207
- // Each label's right edge sits on its own marker (min tick, avg fill end,
284
+ // Each label's right edge sits on its own marker (min tick, p90 fill end,
208
285
  // max tick); placeLabels nudges them apart only if they crowd.
209
- { text: fmtSup(st.min), anchor: clampIdx(Math.min(1, st.min / max), width), align: 'right', color: C_TICK },
210
- { text: fmtSup(st.avg), anchor: avgIdx, align: 'right', color: BOLD + fg(r, g, b) },
211
- { text: fmtSup(st.max), anchor: clampIdx(Math.min(1, st.max / max), width), align: 'right', color: C_MUTE },
286
+ { text: fmtSup(st.min), anchor: clampIdx(Math.min(1, st.min / max), width), align: 'right', color: C_TICK() },
287
+ { text: fmtSup(st.p90), anchor: p90Idx, align: 'right', color: BOLD + fg(r, g, b) },
288
+ { text: fmtSup(st.max), anchor: clampIdx(Math.min(1, st.max / max), width), align: 'right', color: C_MUTE() },
212
289
  ]);
213
290
  }
214
291
  return renderCells(cells);
@@ -534,7 +611,7 @@ function makeTui(doUpload) {
534
611
  // Border cell colour. `gain` dims the sweep — the vertical edges pass < 1 so
535
612
  // the whole column lighting at once doesn't read as a flash.
536
613
  const borderColor = (col, gain = 1) =>
537
- mix(SHIMMER_BASE, SHIMMER_GLOW, sweepK(col) * gain);
614
+ mix(T.shimmerBase, T.shimmerGlow, sweepK(col) * gain);
538
615
 
539
616
  // A run of `count` identical border chars from column `startCol`, swept.
540
617
  function borderRun(ch, count, startCol) {
@@ -547,7 +624,7 @@ function makeTui(doUpload) {
547
624
  function sweptText(text, startCol) {
548
625
  let s = '';
549
626
  for (let i = 0; i < text.length; i++)
550
- s += mix(SHIMMER_TEXT, SHIMMER_TEXT_GLOW, sweepK(startCol + i)) + text[i];
627
+ s += mix(T.shimmerText, T.shimmerTextGlow, sweepK(startCol + i)) + text[i];
551
628
  return s + RESET;
552
629
  }
553
630
 
@@ -562,7 +639,7 @@ function makeTui(doUpload) {
562
639
  const dashes = Math.max(0, inner - visLen(lt) - visLen(rt));
563
640
  return (
564
641
  borderColor(0, SHIMMER_EDGE) + left + RESET +
565
- (lt ? C_TITLE + BOLD + lt + RESET : '') +
642
+ (lt ? C_TITLE() + BOLD + lt + RESET : '') +
566
643
  borderRun('─', dashes, 1 + visLen(lt)) +
567
644
  (rt ? sweptText(rt, W - 1 - visLen(rt)) : '') +
568
645
  borderColor(W - 1, SHIMMER_EDGE) + right + RESET
@@ -579,17 +656,17 @@ function makeTui(doUpload) {
579
656
  const st = state.pingStats;
580
657
  const active = state.phase === 'ping';
581
658
  const cells = newCells(body);
582
- place(cells, pad('PING', labelW), 0, 'left', active ? C_TICK : st ? BOLD + C_LABEL : C_MUTE);
659
+ place(cells, pad('PING', labelW), 0, 'left', active ? C_TICK() : st ? BOLD + C_LABEL() : C_MUTE());
583
660
  if (st) {
584
661
  // Colour the latency by value: green (low) → red (high).
585
- const [r, g, b] = gradColor(G_PING, st.avg / PING_SCALE);
662
+ const [r, g, b] = gradColor(G_PING(), st.avg / PING_SCALE);
586
663
  place(cells, fmtMs(st.avg), labelW + 1, 'left', BOLD + fg(r, g, b));
587
664
  const spread =
588
665
  `${st.min.toFixed(0)} / ${st.max.toFixed(0)} ms` +
589
666
  (state.jitter != null ? ` ±${state.jitter.toFixed(1)}` : '');
590
- place(cells, spread, body - 1, 'right', C_MUTE);
667
+ place(cells, spread, body - 1, 'right', C_MUTE());
591
668
  } else {
592
- place(cells, 'measuring…', labelW + 1, 'left', C_MUTE);
669
+ place(cells, 'measuring…', labelW + 1, 'left', C_MUTE());
593
670
  }
594
671
  return wrap(renderCells(cells));
595
672
  }
@@ -598,12 +675,12 @@ function makeTui(doUpload) {
598
675
  function metricBlock(key, label, st, stops, scaleMax, note) {
599
676
  const active = state.phase === key;
600
677
  const lab =
601
- (active ? C_TICK : st ? BOLD + C_LABEL : C_MUTE) + pad(label, labelW) + RESET;
678
+ (active ? C_TICK() : st ? BOLD + C_LABEL() : C_MUTE()) + pad(label, labelW) + RESET;
602
679
 
603
680
  let labels;
604
681
  if (note && !st) {
605
682
  const cells = newCells(barW);
606
- place(cells, note, barW >> 1, 'center', C_MUTE);
683
+ place(cells, note, barW >> 1, 'center', C_MUTE());
607
684
  labels = renderCells(cells);
608
685
  } else {
609
686
  labels = labelRow(st, barW, stops, supSpeed, scaleMax);
@@ -620,9 +697,12 @@ function makeTui(doUpload) {
620
697
  // A long IP (IPv6 is up to 39 chars) can't share the bottom border with the
621
698
  // download/upload summary, so past this length it drops to its own dimmed,
622
699
  // right-aligned caption line just under the box instead of crowding them out.
623
- const IP_IN_BORDER_MAX = 15; // longest IPv4 ("255.255.255.255")
700
+ // Available space for IP in the bottom border: total inner width minus the
701
+ // peak summary (roughly 30-40 chars depending on speeds), some padding, and
702
+ // a minimum run of dashes for visual breathing room.
703
+ function ipInBorderMax() { return Math.max(15, inner - 45); }
624
704
  function ipCaption(text) {
625
- return ' '.repeat(Math.max(0, W - text.length)) + C_MUTE + text + RESET;
705
+ return ' '.repeat(Math.max(0, W - text.length)) + C_MUTE() + text + RESET;
626
706
  }
627
707
 
628
708
  function buildFrame() {
@@ -631,7 +711,7 @@ function makeTui(doUpload) {
631
711
  ? [m.colo, m.country].filter(Boolean).join(' · ') || state.provider || ''
632
712
  : 'connecting…';
633
713
  const ipText = m && m.clientIp ? m.clientIp : '';
634
- const ipInBorder = ipText.length <= IP_IN_BORDER_MAX;
714
+ const ipInBorder = ipText.length <= ipInBorderMax();
635
715
 
636
716
  // Shared scale: the larger peak of download/upload = a full bar.
637
717
  const scaleMax = Math.max(
@@ -647,17 +727,17 @@ function makeTui(doUpload) {
647
727
  blank(),
648
728
  pingLine(),
649
729
  blank(),
650
- ...metricBlock('down', 'DOWN', state.downStats, G_DOWN, scaleMax),
730
+ ...metricBlock('down', 'DOWN', state.downStats, G_DOWN(), scaleMax),
651
731
  ...metricBlock(
652
732
  'up',
653
733
  'UP',
654
734
  state.doUpload ? state.upStats : null,
655
- G_UP,
735
+ G_UP(),
656
736
  scaleMax,
657
737
  !state.doUpload ? 'skipped' : state.upUnavailable ? 'n/a on fast.com' : undefined
658
738
  ),
659
739
  blank(),
660
- borderLine('╰', '╯', peakSummary(), ipInBorder ? ipText : ''),
740
+ borderLine('╰', '╯', peakSummary(scaleMax), ipInBorder ? ipText : ''),
661
741
  ];
662
742
  if (ipText && !ipInBorder) lines.push(ipCaption(ipText));
663
743
  return lines;
@@ -665,14 +745,18 @@ function makeTui(doUpload) {
665
745
 
666
746
  // Footer summary: each stream's headline speed (90th percentile, near the
667
747
  // sustained peak — how speed tests usually report), coloured to match its
668
- // bar's gradient.
669
- function peakSummary() {
670
- const seg = (stops, text) => BOLD + fg(...gradColor(stops, 0.5)) + text + RESET;
748
+ // bar's gradient at the p90 fill point.
749
+ function peakSummary(scaleMax) {
750
+ const max = scaleMax || 1;
751
+ const seg = (stops, p90, text) => {
752
+ const t = Math.min(1, p90 / max);
753
+ return BOLD + fg(...gradColor(stops, t)) + text + RESET;
754
+ };
671
755
  const parts = [];
672
756
  if (state.downStats?.p90 > 0)
673
- parts.push(seg(G_DOWN, `DOWN ${fmtBits(state.downStats.p90)}`));
757
+ parts.push(seg(G_DOWN(), state.downStats.p90, `DOWN ${fmtBits(state.downStats.p90)}`));
674
758
  if (state.doUpload && state.upStats?.p90 > 0)
675
- parts.push(seg(G_UP, `UP ${state.uploadApprox ? '~' : ''}${fmtBits(state.upStats.p90)}`));
759
+ parts.push(seg(G_UP(), state.upStats.p90, `UP ${state.uploadApprox ? '~' : ''}${fmtBits(state.upStats.p90)}`));
676
760
  return parts.join(' ');
677
761
  }
678
762
 
@@ -728,6 +812,8 @@ function makeTui(doUpload) {
728
812
  }
729
813
 
730
814
  async function runTui(opts) {
815
+ const light = await detectLightBg();
816
+ theme(light);
731
817
  const tui = makeTui(opts.upload);
732
818
  const session = makeSession();
733
819
  let interrupted = false;
@@ -874,8 +960,9 @@ Usage:
874
960
  fast [options]
875
961
 
876
962
  Download and upload share one scale (the run's peak = a full bar) so they're
877
- directly comparable; each bar is filled to its average, with a white tick at
878
- the minimum and superscript min/avg/max above. Ping is shown as a number.
963
+ directly comparable; each bar is filled to its 90th percentile (sustained
964
+ peak), with a white tick at the minimum and superscript min/p90/max above.
965
+ Ping is shown as a number.
879
966
  Upload uses a send-side measurement and reads on the high side (shown ~approx).
880
967
 
881
968
  Options:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fastdotcom",
3
- "version": "1.0.0",
4
- "description": "A tiny zero-dependency CLI that measures your connection speed using fast.com (Netflix) as its backend, with a btop-style live TUI.",
3
+ "version": "1.1.0",
4
+ "description": "A speedtest tool that uses fast.com (Netflix) as its backend.",
5
5
  "bin": {
6
6
  "fast": "index.js"
7
7
  },