fastdotcom 1.0.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.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/index.js +905 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Long Sien
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# fast
|
|
2
|
+
|
|
3
|
+
Check your internet speed from the terminal. `fast` measures your ping,
|
|
4
|
+
download, and upload using [fast.com](https://fast.com) (Netflix) and shows it
|
|
5
|
+
all on a live, animated display.
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="https://raw.githubusercontent.com/longsien/fastdotcom/main/docs/demo.gif"
|
|
9
|
+
alt="fast running in a terminal: ping, then download and upload gauges filling live"
|
|
10
|
+
width="760">
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Run it once, no install needed:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx fastdotcom
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install it for good:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install -g fastdotcom # then run: fast
|
|
25
|
+
brew install longsien/tap/fast
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
fast # run a full speed test
|
|
32
|
+
fast --no-upload # skip the upload test
|
|
33
|
+
fast --json # print results as JSON
|
|
34
|
+
fast --help # all options
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That's it. No account, no config, no tracking.
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
[MIT](LICENSE) © Long Sien
|
package/index.js
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// fast — a tiny CLI that measures your connection using fast.com (Netflix
|
|
5
|
+
// Open Connect) as its backend. Zero dependencies.
|
|
6
|
+
//
|
|
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.
|
|
11
|
+
|
|
12
|
+
const https = require('node:https');
|
|
13
|
+
|
|
14
|
+
// ---- formatting ------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
function fmtBits(bitsPerSec) {
|
|
17
|
+
const mbps = bitsPerSec / 1e6;
|
|
18
|
+
if (mbps >= 1000) return (mbps / 1000).toFixed(2) + ' Gbps';
|
|
19
|
+
if (mbps < 1) return (bitsPerSec / 1e3).toFixed(0) + ' kbps';
|
|
20
|
+
return mbps.toFixed(1) + ' Mbps';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fmtMs(ms) {
|
|
24
|
+
return ms.toFixed(1) + ' ms';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function median(xs) {
|
|
28
|
+
if (!xs.length) return NaN;
|
|
29
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
30
|
+
const mid = s.length >> 1;
|
|
31
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Nearest-rank percentile (p in [0,1]). Speed tests headline a high percentile
|
|
35
|
+
// of their throughput samples, which sits near the sustained peak.
|
|
36
|
+
function percentile(xs, p) {
|
|
37
|
+
if (!xs || !xs.length) return NaN;
|
|
38
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
39
|
+
const idx = Math.min(s.length - 1, Math.max(0, Math.ceil(p * s.length) - 1));
|
|
40
|
+
return s[idx];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stats(xs) {
|
|
44
|
+
if (!xs || !xs.length) return null;
|
|
45
|
+
let min = Infinity;
|
|
46
|
+
let max = -Infinity;
|
|
47
|
+
let sum = 0;
|
|
48
|
+
for (const x of xs) {
|
|
49
|
+
if (x < min) min = x;
|
|
50
|
+
if (x > max) max = x;
|
|
51
|
+
sum += x;
|
|
52
|
+
}
|
|
53
|
+
return { min, avg: sum / xs.length, max, p90: percentile(xs, 0.9) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Superscript "micro" digits for the inline labels above each bar.
|
|
57
|
+
const SUP = { 0: '⁰', 1: '¹', 2: '²', 3: '³', 4: '⁴', 5: '⁵', 6: '⁶', 7: '⁷', 8: '⁸', 9: '⁹' };
|
|
58
|
+
function sup(n) {
|
|
59
|
+
return String(Math.round(n))
|
|
60
|
+
.split('')
|
|
61
|
+
.map((c) => SUP[c] || c)
|
|
62
|
+
.join('');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---- ANSI / colour ---------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const RESET = '\x1b[0m';
|
|
68
|
+
const BOLD = '\x1b[1m';
|
|
69
|
+
const fg = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`;
|
|
70
|
+
const visLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length; // width sans ANSI
|
|
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
|
|
89
|
+
|
|
90
|
+
// 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
|
+
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
|
|
97
|
+
|
|
98
|
+
function gradColor(stops, t) {
|
|
99
|
+
t = Math.max(0, Math.min(1, t));
|
|
100
|
+
const seg = (stops.length - 1) * t;
|
|
101
|
+
const i = Math.min(Math.floor(seg), stops.length - 2);
|
|
102
|
+
const f = seg - i;
|
|
103
|
+
const a = stops[i];
|
|
104
|
+
const b = stops[i + 1];
|
|
105
|
+
return [
|
|
106
|
+
Math.round(a[0] + (b[0] - a[0]) * f),
|
|
107
|
+
Math.round(a[1] + (b[1] - a[1]) * f),
|
|
108
|
+
Math.round(a[2] + (b[2] - a[2]) * f),
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const DOT = '⠿'; // braille all-6-dots (U+283F)
|
|
113
|
+
|
|
114
|
+
function clampIdx(frac, width) {
|
|
115
|
+
return Math.max(0, Math.min(width - 1, Math.round(frac * width)));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A gauge of `width` columns scaled 0 → scaleMax: gradient-filled to st.avg,
|
|
119
|
+
// with a white tick at st.min; the rest is a dim track. Download and upload
|
|
120
|
+
// pass a shared scaleMax so their bars are directly comparable.
|
|
121
|
+
function gaugeStats(st, width, stops, scaleMax) {
|
|
122
|
+
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);
|
|
125
|
+
const minIdx = clampIdx(Math.min(1, st.min / max), width);
|
|
126
|
+
const maxIdx = clampIdx(Math.min(1, st.max / max), width);
|
|
127
|
+
let out = '';
|
|
128
|
+
for (let i = 0; i < width; i++) {
|
|
129
|
+
if (i === minIdx) {
|
|
130
|
+
out += C_TICK + DOT; // bright min marker
|
|
131
|
+
} else if (i < fill) {
|
|
132
|
+
const t = width > 1 ? i / (width - 1) : 0;
|
|
133
|
+
const [r, g, b] = gradColor(stops, t);
|
|
134
|
+
out += fg(r, g, b) + DOT;
|
|
135
|
+
} else if (i === maxIdx) {
|
|
136
|
+
out += C_MAXTICK + DOT; // faint max marker in the track
|
|
137
|
+
} else {
|
|
138
|
+
out += C_TRACK + DOT;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out + RESET;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- cell-grid helpers (for the superscript label row) ---------------------
|
|
145
|
+
|
|
146
|
+
// Each cell is exactly one display column, so widths stay aligned.
|
|
147
|
+
function newCells(width) {
|
|
148
|
+
return Array.from({ length: width }, () => ({ ch: ' ', color: null }));
|
|
149
|
+
}
|
|
150
|
+
function place(cells, text, idx, align, color) {
|
|
151
|
+
const w = cells.length;
|
|
152
|
+
if (text.length > w) text = text.slice(0, w); // never overflow / widen the row
|
|
153
|
+
const len = text.length;
|
|
154
|
+
let start =
|
|
155
|
+
align === 'left' ? idx : align === 'right' ? idx - len + 1 : idx - ((len - 1) >> 1);
|
|
156
|
+
start = Math.max(0, Math.min(w - len, start));
|
|
157
|
+
for (let i = 0; i < len; i++) cells[start + i] = { ch: text[i], color };
|
|
158
|
+
}
|
|
159
|
+
function renderCells(cells) {
|
|
160
|
+
let out = '';
|
|
161
|
+
for (const c of cells) out += c.color && c.ch !== ' ' ? c.color + c.ch : c.ch;
|
|
162
|
+
return out + RESET;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Write a left-to-right ordered list of labels into `cells`, nudging them apart
|
|
166
|
+
// so they never overlap (at least `gap` blank columns between them). Positions
|
|
167
|
+
// can drift from their ideal anchor, but we never collide.
|
|
168
|
+
function placeLabels(cells, items, gap = 1) {
|
|
169
|
+
const width = cells.length;
|
|
170
|
+
const L = items.map((it) => it.text.length);
|
|
171
|
+
const start = items.map((it, i) =>
|
|
172
|
+
it.align === 'left'
|
|
173
|
+
? it.anchor
|
|
174
|
+
: it.align === 'right'
|
|
175
|
+
? it.anchor - L[i] + 1
|
|
176
|
+
: it.anchor - ((L[i] - 1) >> 1)
|
|
177
|
+
);
|
|
178
|
+
// Forward: keep order and spacing.
|
|
179
|
+
for (let i = 1; i < items.length; i++)
|
|
180
|
+
start[i] = Math.max(start[i], start[i - 1] + L[i - 1] + gap);
|
|
181
|
+
// Pull the last one in-bounds, then push leftward neighbours back.
|
|
182
|
+
const last = items.length - 1;
|
|
183
|
+
if (start[last] + L[last] > width) start[last] = width - L[last];
|
|
184
|
+
for (let i = last - 1; i >= 0; i--)
|
|
185
|
+
if (start[i] + L[i] + gap > start[i + 1]) start[i] = start[i + 1] - gap - L[i];
|
|
186
|
+
// Final clamp + write.
|
|
187
|
+
for (let i = 0; i < items.length; i++) {
|
|
188
|
+
const s = Math.max(0, Math.min(width - L[i], start[i]));
|
|
189
|
+
for (let k = 0; k < L[i] && s + k < width; k++)
|
|
190
|
+
cells[s + k] = { ch: items[i].text[k], color: items[i].color };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The min/avg/max superscript row that sits above a gauge, positioned on the
|
|
195
|
+
// shared scaleMax.
|
|
196
|
+
function labelRow(st, width, stops, fmtSup, scaleMax) {
|
|
197
|
+
const cells = newCells(width);
|
|
198
|
+
const max = scaleMax || (st && st.max) || 0;
|
|
199
|
+
if (st && max > 0) {
|
|
200
|
+
const avgF = Math.min(1, st.avg / max);
|
|
201
|
+
const [r, g, b] = gradColor(stops, avgF);
|
|
202
|
+
// 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
|
|
204
|
+
// 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);
|
|
206
|
+
placeLabels(cells, [
|
|
207
|
+
// Each label's right edge sits on its own marker (min tick, avg fill end,
|
|
208
|
+
// 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 },
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
return renderCells(cells);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---- measurements ----------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
// Turn a non-OK speed-endpoint status into a readable message. A 1-byte 429
|
|
220
|
+
// body otherwise gets timed as ~0 bps and shows up as a bogus 0/0/0 reading.
|
|
221
|
+
function httpReason(status, phase) {
|
|
222
|
+
if (status === 429)
|
|
223
|
+
return `${phase} rate-limited (HTTP 429)`;
|
|
224
|
+
return `${phase} failed: HTTP ${status}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Generic parallel-throughput sampler. Runs `streams` worker loops in parallel;
|
|
228
|
+
// each worker repeatedly transfers data and calls `credit(bytes)` for every
|
|
229
|
+
// chunk/request. We aggregate credited bytes across ALL workers into fixed
|
|
230
|
+
// time windows (JS is single-threaded, so the shared counters need no locking)
|
|
231
|
+
// and discard the first `warmup` windows as ramp-up. Returns min/avg/max/p90.
|
|
232
|
+
async function sampleThroughput(opts, worker, onTick) {
|
|
233
|
+
const { streams, window: WINDOW, warmup: WARMUP, maxDur: MAXDUR } = opts;
|
|
234
|
+
const samples = [];
|
|
235
|
+
const start = performance.now();
|
|
236
|
+
const ctl = { stopped: false, controller: new AbortController() };
|
|
237
|
+
let winBytes = 0;
|
|
238
|
+
let winStart = start;
|
|
239
|
+
let widx = 0;
|
|
240
|
+
|
|
241
|
+
function credit(len) {
|
|
242
|
+
winBytes += len;
|
|
243
|
+
const now = performance.now();
|
|
244
|
+
if (now - winStart >= WINDOW) {
|
|
245
|
+
const bps = (winBytes * 8) / ((now - winStart) / 1000);
|
|
246
|
+
if (widx >= WARMUP) samples.push(bps);
|
|
247
|
+
widx++;
|
|
248
|
+
winBytes = 0;
|
|
249
|
+
winStart = now;
|
|
250
|
+
if (onTick) onTick(stats(samples.length ? samples : [bps]));
|
|
251
|
+
if (now - start >= MAXDUR) {
|
|
252
|
+
ctl.stopped = true;
|
|
253
|
+
ctl.controller.abort();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const elapsed = () => performance.now() - start;
|
|
258
|
+
|
|
259
|
+
await Promise.all(
|
|
260
|
+
Array.from({ length: streams }, (_, idx) =>
|
|
261
|
+
worker({ idx, credit, ctl, elapsed, maxDur: MAXDUR })
|
|
262
|
+
)
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// Record the final partial window so a short tail isn't dropped. Require at
|
|
266
|
+
// least half a window so a tiny sliver can't divide out to a wild bps.
|
|
267
|
+
const tail = performance.now() - winStart;
|
|
268
|
+
if (winBytes > 0 && tail >= WINDOW / 2 && widx >= WARMUP) {
|
|
269
|
+
samples.push((winBytes * 8) / (tail / 1000));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const s = stats(samples);
|
|
273
|
+
if (onTick) onTick(s);
|
|
274
|
+
return s;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Stream a sized GET repeatedly, crediting every chunk; `url(streamIdx)` lets
|
|
278
|
+
// each worker hit its own fast.com target.
|
|
279
|
+
function downloadWorker(url) {
|
|
280
|
+
return async ({ idx, credit, ctl, elapsed, maxDur }) => {
|
|
281
|
+
while (!ctl.stopped && elapsed() < maxDur) {
|
|
282
|
+
let res;
|
|
283
|
+
try {
|
|
284
|
+
res = await fetch(url(idx), { signal: ctl.controller.signal, cache: 'no-store' });
|
|
285
|
+
} catch (e) {
|
|
286
|
+
if (e.name === 'AbortError') return;
|
|
287
|
+
throw e;
|
|
288
|
+
}
|
|
289
|
+
if (!res.ok) throw new Error(httpReason(res.status, 'download'));
|
|
290
|
+
try {
|
|
291
|
+
for await (const chunk of res.body) {
|
|
292
|
+
credit(chunk.length);
|
|
293
|
+
if (ctl.stopped) break;
|
|
294
|
+
}
|
|
295
|
+
} catch (e) {
|
|
296
|
+
if (e.name !== 'AbortError') throw e;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Send-side upload POST over a FRESH connection, crediting bytes as the kernel
|
|
303
|
+
// drains them (the write callback fires on flush; backpressure gates the loop to
|
|
304
|
+
// the socket's ACK rate). `fetch` can't observe on-wire upload progress, so we
|
|
305
|
+
// drop to node:https here. Used for fast.com, whose Open Connect endpoints 400
|
|
306
|
+
// on a reused keep-alive socket (hence agent:false) and don't behave as a clean
|
|
307
|
+
// throughput sink — so this reads high and is treated as approximate. `chunk`
|
|
308
|
+
// must be a whole multiple of the buffer length so Content-Length matches.
|
|
309
|
+
function rawUploadPost(url, chunk, buf, credit, ctl) {
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
const u = new URL(url);
|
|
312
|
+
const signal = ctl.controller.signal;
|
|
313
|
+
let settled = false;
|
|
314
|
+
let req;
|
|
315
|
+
const finish = (v) => {
|
|
316
|
+
if (settled) return;
|
|
317
|
+
settled = true;
|
|
318
|
+
signal.removeEventListener('abort', onAbort);
|
|
319
|
+
resolve(v);
|
|
320
|
+
};
|
|
321
|
+
// fetch-based download cancels via the shared signal; node:https must be
|
|
322
|
+
// torn down explicitly, so honour the same signal here.
|
|
323
|
+
const onAbort = () => {
|
|
324
|
+
if (req) req.destroy();
|
|
325
|
+
finish(-1);
|
|
326
|
+
};
|
|
327
|
+
req = https.request(
|
|
328
|
+
{
|
|
329
|
+
method: 'POST',
|
|
330
|
+
hostname: u.hostname,
|
|
331
|
+
path: u.pathname + u.search,
|
|
332
|
+
agent: false, // fresh connection — reuse triggers 400s on Open Connect
|
|
333
|
+
headers: { 'content-type': 'application/octet-stream', 'content-length': chunk },
|
|
334
|
+
},
|
|
335
|
+
(res) => {
|
|
336
|
+
res.on('data', () => {});
|
|
337
|
+
res.on('end', () => finish(res.statusCode));
|
|
338
|
+
res.on('error', () => finish(0));
|
|
339
|
+
}
|
|
340
|
+
);
|
|
341
|
+
req.on('error', () => finish(0));
|
|
342
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
343
|
+
if (signal.aborted) return onAbort();
|
|
344
|
+
let sent = 0;
|
|
345
|
+
const pump = () => {
|
|
346
|
+
while (sent < chunk) {
|
|
347
|
+
if (ctl.stopped) {
|
|
348
|
+
req.destroy();
|
|
349
|
+
return finish(-1);
|
|
350
|
+
}
|
|
351
|
+
const ok = req.write(buf, () => credit(buf.length));
|
|
352
|
+
sent += buf.length;
|
|
353
|
+
if (!ok) {
|
|
354
|
+
req.once('drain', pump); // wait for the socket to drain (backpressure)
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
req.end();
|
|
359
|
+
};
|
|
360
|
+
pump();
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---- provider: fast.com ----------------------------------------------------
|
|
365
|
+
|
|
366
|
+
// fast.com (Netflix Open Connect) is the sole backend. It hands back several
|
|
367
|
+
// distinct CDN server URLs (so download parallelises nicely) and reliably
|
|
368
|
+
// serves download + latency; upload is the approximate send-side measurement
|
|
369
|
+
// (see rawUploadPost). The token is scraped from fast.com's JS bundle
|
|
370
|
+
// (unofficial — can change), so discovery is lazy and any failure surfaces as
|
|
371
|
+
// a normal error.
|
|
372
|
+
function makeFastCom() {
|
|
373
|
+
let inited = null;
|
|
374
|
+
let targets = [];
|
|
375
|
+
let client = null;
|
|
376
|
+
|
|
377
|
+
async function discover() {
|
|
378
|
+
const home = await (await fetch('https://fast.com/')).text();
|
|
379
|
+
const sm = home.match(/<script src="(\/app-[^"]+\.js)"/);
|
|
380
|
+
if (!sm) throw new Error('fast.com: app bundle not found');
|
|
381
|
+
const js = await (await fetch('https://fast.com' + sm[1])).text();
|
|
382
|
+
const tm = js.match(/token:"([^"]+)"/);
|
|
383
|
+
if (!tm) throw new Error('fast.com: token not found');
|
|
384
|
+
const res = await fetch(
|
|
385
|
+
`https://api.fast.com/netflix/speedtest/v2?https=true&token=${tm[1]}&urlCount=5`
|
|
386
|
+
);
|
|
387
|
+
if (!res.ok) throw new Error(httpReason(res.status, 'fast.com discovery'));
|
|
388
|
+
const data = await res.json();
|
|
389
|
+
if (!data.targets?.length) throw new Error('fast.com: no targets returned');
|
|
390
|
+
targets = data.targets.map((t) => t.url);
|
|
391
|
+
client = data.client || null;
|
|
392
|
+
}
|
|
393
|
+
// Cache discovery, but clear the cache on failure so the next phase retries a
|
|
394
|
+
// transient error instead of being stuck on a permanently-rejected promise.
|
|
395
|
+
const ensure = () =>
|
|
396
|
+
(inited ||= discover().catch((e) => {
|
|
397
|
+
inited = null;
|
|
398
|
+
throw e;
|
|
399
|
+
}));
|
|
400
|
+
|
|
401
|
+
// Turn a target URL (…/speedtest?query) into a sized range request.
|
|
402
|
+
const ranged = (url, bytes) => {
|
|
403
|
+
const [base, query] = url.split('?');
|
|
404
|
+
return `${base}/range/0-${bytes}${query ? '?' + query : ''}`;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
name: 'fast.com',
|
|
409
|
+
async getMeta() {
|
|
410
|
+
await ensure();
|
|
411
|
+
return {
|
|
412
|
+
clientIp: client?.ip || null,
|
|
413
|
+
colo: client?.location?.city || null, // header shows "City · Country"
|
|
414
|
+
country: client?.location?.country || null,
|
|
415
|
+
city: client?.location?.city || null,
|
|
416
|
+
};
|
|
417
|
+
},
|
|
418
|
+
async latency(samples, onTick) {
|
|
419
|
+
await ensure();
|
|
420
|
+
const url = ranged(targets[0], 0); // one target so the connection is reused
|
|
421
|
+
const times = [];
|
|
422
|
+
for (let i = 0; i < samples; i++) {
|
|
423
|
+
const t0 = performance.now();
|
|
424
|
+
const res = await fetch(url, { cache: 'no-store' });
|
|
425
|
+
await res.arrayBuffer();
|
|
426
|
+
times.push(performance.now() - t0);
|
|
427
|
+
if (onTick) onTick(stats(times));
|
|
428
|
+
}
|
|
429
|
+
const s = stats(times);
|
|
430
|
+
const jitter = median(times.map((t) => Math.abs(t - s.avg)));
|
|
431
|
+
return { stats: s, jitter };
|
|
432
|
+
},
|
|
433
|
+
async download(onTick) {
|
|
434
|
+
await ensure();
|
|
435
|
+
const CHUNK = 26e6;
|
|
436
|
+
return sampleThroughput(
|
|
437
|
+
{ streams: targets.length, window: 200, warmup: 2, maxDur: 6000 },
|
|
438
|
+
downloadWorker((idx) => ranged(targets[idx], CHUNK)),
|
|
439
|
+
onTick
|
|
440
|
+
);
|
|
441
|
+
},
|
|
442
|
+
// Upload via the send-side raw-socket method (see rawUploadPost). `fetch`
|
|
443
|
+
// with a Buffer body can't see on-wire progress and over-reads wildly (it
|
|
444
|
+
// times the response, which the OCA early-ACKs). Measuring the kernel drain
|
|
445
|
+
// rate is far better, but Open Connect's ingest buffers are large enough
|
|
446
|
+
// that the reading still runs high and scales with offered load — so we use
|
|
447
|
+
// a deliberately modest config and flag it approximate (see `approx`).
|
|
448
|
+
approxUpload: true,
|
|
449
|
+
async upload(onTick) {
|
|
450
|
+
await ensure();
|
|
451
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
452
|
+
const CHUNK = 400 * buf.length; // 25 MiB, exact multiple of buf
|
|
453
|
+
const streams = Math.min(4, targets.length); // modest load → closer to real
|
|
454
|
+
return sampleThroughput(
|
|
455
|
+
{ streams, window: 200, warmup: 3, maxDur: 6500 },
|
|
456
|
+
async ({ idx, credit, ctl, elapsed, maxDur }) => {
|
|
457
|
+
const url = ranged(targets[idx % targets.length], CHUNK);
|
|
458
|
+
while (!ctl.stopped && elapsed() < maxDur) {
|
|
459
|
+
const status = await rawUploadPost(url, CHUNK, buf, credit, ctl);
|
|
460
|
+
// status: HTTP code, 0 on socket error, -1 when stopped/aborted.
|
|
461
|
+
// A real HTTP rejection (Open Connect 400/429) would otherwise be
|
|
462
|
+
// credited as throughput, so surface it like the download path.
|
|
463
|
+
if (status >= 400) throw new Error(httpReason(status, 'upload'));
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
onTick
|
|
467
|
+
);
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// fast.com is the only backend, so the "session" is a thin wrapper around it —
|
|
473
|
+
// kept so the runners stay backend-agnostic and the upload-approx flag is
|
|
474
|
+
// surfaced the same way for output labelling.
|
|
475
|
+
function makeSession() {
|
|
476
|
+
const p = makeFastCom();
|
|
477
|
+
return {
|
|
478
|
+
name: () => p.name,
|
|
479
|
+
uploadName: () => p.name,
|
|
480
|
+
uploadApprox: () => !!p.approxUpload,
|
|
481
|
+
getMeta: (...a) => p.getMeta(...a),
|
|
482
|
+
latency: (...a) => p.latency(...a),
|
|
483
|
+
download: (...a) => p.download(...a),
|
|
484
|
+
upload: (...a) => p.upload(...a),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---- TUI -------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
function makeTui(doUpload) {
|
|
491
|
+
// Layout dims, recomputed on terminal resize (see resize()).
|
|
492
|
+
const labelW = 5;
|
|
493
|
+
let W, inner, body, barW;
|
|
494
|
+
function computeDims() {
|
|
495
|
+
const cols = process.stdout.columns || 64;
|
|
496
|
+
W = Math.max(46, Math.min(120, cols)); // total box width (responsive, capped)
|
|
497
|
+
inner = W - 2; // between borders
|
|
498
|
+
body = W - 4; // between the pad spaces
|
|
499
|
+
barW = body - labelW - 1;
|
|
500
|
+
}
|
|
501
|
+
computeDims();
|
|
502
|
+
|
|
503
|
+
const supSpeed = (v) => sup(v / 1e6);
|
|
504
|
+
|
|
505
|
+
const state = {
|
|
506
|
+
meta: null,
|
|
507
|
+
provider: null, // backend name (fast.com)
|
|
508
|
+
upUnavailable: false, // set when the active provider can't measure upload
|
|
509
|
+
uploadApprox: false, // set when the upload figure is a rough reading (fast.com)
|
|
510
|
+
pingStats: null,
|
|
511
|
+
downStats: null,
|
|
512
|
+
upStats: null,
|
|
513
|
+
jitter: null,
|
|
514
|
+
phase: 'init', // init|ping|down|up|done
|
|
515
|
+
doUpload,
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
let firstPaint = true;
|
|
519
|
+
let lastPaint = 0;
|
|
520
|
+
let lastLines = 0; // height of the last painted frame (varies with IP caption)
|
|
521
|
+
let shimmer = null; // current sweep phase [0,1), or null when idle/done
|
|
522
|
+
|
|
523
|
+
const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
|
|
524
|
+
|
|
525
|
+
// How strongly the sweep lands on column `col` (0 = resting, 1 = peak).
|
|
526
|
+
function sweepK(col) {
|
|
527
|
+
if (shimmer == null) return 0;
|
|
528
|
+
const head = shimmer * (W + 12) - 6; // band centre travels off both ends
|
|
529
|
+
return Math.max(0, 1 - Math.abs(col - head) / 6); // falloff over 6 cols
|
|
530
|
+
}
|
|
531
|
+
const mix = (base, glow, k) =>
|
|
532
|
+
fg(...base.map((c, j) => Math.round(c + (glow[j] - c) * k)));
|
|
533
|
+
|
|
534
|
+
// Border cell colour. `gain` dims the sweep — the vertical edges pass < 1 so
|
|
535
|
+
// the whole column lighting at once doesn't read as a flash.
|
|
536
|
+
const borderColor = (col, gain = 1) =>
|
|
537
|
+
mix(SHIMMER_BASE, SHIMMER_GLOW, sweepK(col) * gain);
|
|
538
|
+
|
|
539
|
+
// A run of `count` identical border chars from column `startCol`, swept.
|
|
540
|
+
function borderRun(ch, count, startCol) {
|
|
541
|
+
let s = '';
|
|
542
|
+
for (let i = 0; i < count; i++) s += borderColor(startCol + i) + ch;
|
|
543
|
+
return s + RESET;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Muted header/footer text (location, IP) that also catches the sweep.
|
|
547
|
+
function sweptText(text, startCol) {
|
|
548
|
+
let s = '';
|
|
549
|
+
for (let i = 0; i < text.length; i++)
|
|
550
|
+
s += mix(SHIMMER_TEXT, SHIMMER_TEXT_GLOW, sweepK(startCol + i)) + text[i];
|
|
551
|
+
return s + RESET;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function wrap(content) {
|
|
555
|
+
return borderColor(0, SHIMMER_EDGE) + '│' + RESET + ' ' + content + ' ' +
|
|
556
|
+
borderColor(W - 1, SHIMMER_EDGE) + '│' + RESET;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function borderLine(left, right, leftText, rightText) {
|
|
560
|
+
const lt = leftText ? ' ' + leftText + ' ' : '';
|
|
561
|
+
const rt = rightText ? ' ' + rightText + ' ' : '';
|
|
562
|
+
const dashes = Math.max(0, inner - visLen(lt) - visLen(rt));
|
|
563
|
+
return (
|
|
564
|
+
borderColor(0, SHIMMER_EDGE) + left + RESET +
|
|
565
|
+
(lt ? C_TITLE + BOLD + lt + RESET : '') +
|
|
566
|
+
borderRun('─', dashes, 1 + visLen(lt)) +
|
|
567
|
+
(rt ? sweptText(rt, W - 1 - visLen(rt)) : '') +
|
|
568
|
+
borderColor(W - 1, SHIMMER_EDGE) + right + RESET
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function blank() {
|
|
573
|
+
return borderColor(0, SHIMMER_EDGE) + '│' + RESET + ' '.repeat(inner) +
|
|
574
|
+
borderColor(W - 1, SHIMMER_EDGE) + '│' + RESET;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Ping is a single numeric line (no gauge): avg, the min/max spread, jitter.
|
|
578
|
+
function pingLine() {
|
|
579
|
+
const st = state.pingStats;
|
|
580
|
+
const active = state.phase === 'ping';
|
|
581
|
+
const cells = newCells(body);
|
|
582
|
+
place(cells, pad('PING', labelW), 0, 'left', active ? C_TICK : st ? BOLD + C_LABEL : C_MUTE);
|
|
583
|
+
if (st) {
|
|
584
|
+
// Colour the latency by value: green (low) → red (high).
|
|
585
|
+
const [r, g, b] = gradColor(G_PING, st.avg / PING_SCALE);
|
|
586
|
+
place(cells, fmtMs(st.avg), labelW + 1, 'left', BOLD + fg(r, g, b));
|
|
587
|
+
const spread =
|
|
588
|
+
`${st.min.toFixed(0)} / ${st.max.toFixed(0)} ms` +
|
|
589
|
+
(state.jitter != null ? ` ±${state.jitter.toFixed(1)}` : '');
|
|
590
|
+
place(cells, spread, body - 1, 'right', C_MUTE);
|
|
591
|
+
} else {
|
|
592
|
+
place(cells, 'measuring…', labelW + 1, 'left', C_MUTE);
|
|
593
|
+
}
|
|
594
|
+
return wrap(renderCells(cells));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Two lines: superscript labels above, the gauge below (shared scaleMax).
|
|
598
|
+
function metricBlock(key, label, st, stops, scaleMax, note) {
|
|
599
|
+
const active = state.phase === key;
|
|
600
|
+
const lab =
|
|
601
|
+
(active ? C_TICK : st ? BOLD + C_LABEL : C_MUTE) + pad(label, labelW) + RESET;
|
|
602
|
+
|
|
603
|
+
let labels;
|
|
604
|
+
if (note && !st) {
|
|
605
|
+
const cells = newCells(barW);
|
|
606
|
+
place(cells, note, barW >> 1, 'center', C_MUTE);
|
|
607
|
+
labels = renderCells(cells);
|
|
608
|
+
} else {
|
|
609
|
+
labels = labelRow(st, barW, stops, supSpeed, scaleMax);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Labels go below the bar: superscript glyphs sit high in the cell, so
|
|
613
|
+
// they hug the gauge above them.
|
|
614
|
+
return [
|
|
615
|
+
wrap(lab + ' ' + gaugeStats(st, barW, stops, scaleMax)),
|
|
616
|
+
wrap(' '.repeat(labelW + 1) + labels),
|
|
617
|
+
];
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// A long IP (IPv6 is up to 39 chars) can't share the bottom border with the
|
|
621
|
+
// download/upload summary, so past this length it drops to its own dimmed,
|
|
622
|
+
// 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")
|
|
624
|
+
function ipCaption(text) {
|
|
625
|
+
return ' '.repeat(Math.max(0, W - text.length)) + C_MUTE + text + RESET;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function buildFrame() {
|
|
629
|
+
const m = state.meta;
|
|
630
|
+
const loc = m
|
|
631
|
+
? [m.colo, m.country].filter(Boolean).join(' · ') || state.provider || ''
|
|
632
|
+
: 'connecting…';
|
|
633
|
+
const ipText = m && m.clientIp ? m.clientIp : '';
|
|
634
|
+
const ipInBorder = ipText.length <= IP_IN_BORDER_MAX;
|
|
635
|
+
|
|
636
|
+
// Shared scale: the larger peak of download/upload = a full bar.
|
|
637
|
+
const scaleMax = Math.max(
|
|
638
|
+
state.downStats?.max || 0,
|
|
639
|
+
(state.doUpload ? state.upStats?.max : 0) || 0
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
const busy = state.phase === 'ping' || state.phase === 'down' || state.phase === 'up';
|
|
643
|
+
shimmer = busy ? (performance.now() / 1600) % 1 : null;
|
|
644
|
+
|
|
645
|
+
const lines = [
|
|
646
|
+
borderLine('╭', '╮', '', loc),
|
|
647
|
+
blank(),
|
|
648
|
+
pingLine(),
|
|
649
|
+
blank(),
|
|
650
|
+
...metricBlock('down', 'DOWN', state.downStats, G_DOWN, scaleMax),
|
|
651
|
+
...metricBlock(
|
|
652
|
+
'up',
|
|
653
|
+
'UP',
|
|
654
|
+
state.doUpload ? state.upStats : null,
|
|
655
|
+
G_UP,
|
|
656
|
+
scaleMax,
|
|
657
|
+
!state.doUpload ? 'skipped' : state.upUnavailable ? 'n/a on fast.com' : undefined
|
|
658
|
+
),
|
|
659
|
+
blank(),
|
|
660
|
+
borderLine('╰', '╯', peakSummary(), ipInBorder ? ipText : ''),
|
|
661
|
+
];
|
|
662
|
+
if (ipText && !ipInBorder) lines.push(ipCaption(ipText));
|
|
663
|
+
return lines;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Footer summary: each stream's headline speed (90th percentile, near the
|
|
667
|
+
// 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;
|
|
671
|
+
const parts = [];
|
|
672
|
+
if (state.downStats?.p90 > 0)
|
|
673
|
+
parts.push(seg(G_DOWN, `DOWN ${fmtBits(state.downStats.p90)}`));
|
|
674
|
+
if (state.doUpload && state.upStats?.p90 > 0)
|
|
675
|
+
parts.push(seg(G_UP, `UP ${state.uploadApprox ? '~' : ''}${fmtBits(state.upStats.p90)}`));
|
|
676
|
+
return parts.join(' ');
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function paint(force) {
|
|
680
|
+
const now = performance.now();
|
|
681
|
+
if (!force && now - lastPaint < 40) return;
|
|
682
|
+
lastPaint = now;
|
|
683
|
+
const lines = buildFrame();
|
|
684
|
+
let s = '';
|
|
685
|
+
// Move up by the PREVIOUS frame's height (the IP caption can change the line
|
|
686
|
+
// count once meta loads), then rewrite every line.
|
|
687
|
+
if (!firstPaint && lastLines > 0) s += `\x1b[${lastLines - 1}A`;
|
|
688
|
+
s += lines.map((l) => '\r\x1b[K' + l).join('\n');
|
|
689
|
+
// If the frame shrank, wipe the now-orphaned lines below and step back up to
|
|
690
|
+
// the last content line so the next repaint's cursor math stays aligned.
|
|
691
|
+
if (lines.length < lastLines) {
|
|
692
|
+
const extra = lastLines - lines.length;
|
|
693
|
+
s += '\n\r\x1b[K'.repeat(extra) + `\x1b[${extra}A`;
|
|
694
|
+
}
|
|
695
|
+
process.stdout.write(s);
|
|
696
|
+
firstPaint = false;
|
|
697
|
+
lastLines = lines.length;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return {
|
|
701
|
+
state,
|
|
702
|
+
paint,
|
|
703
|
+
start() {
|
|
704
|
+
process.stdout.write('\x1b[?25l'); // hide cursor
|
|
705
|
+
paint(true);
|
|
706
|
+
},
|
|
707
|
+
resize() {
|
|
708
|
+
computeDims();
|
|
709
|
+
// Jump to the frame's top and wipe everything below (clears any lines the
|
|
710
|
+
// old, wider frame may have wrapped onto), then repaint fresh.
|
|
711
|
+
if (!firstPaint && lastLines > 0) {
|
|
712
|
+
const up = lastLines > 1 ? `\x1b[${lastLines - 1}A` : '';
|
|
713
|
+
process.stdout.write(up + '\r\x1b[J');
|
|
714
|
+
}
|
|
715
|
+
firstPaint = true;
|
|
716
|
+
lastLines = 0;
|
|
717
|
+
paint(true);
|
|
718
|
+
},
|
|
719
|
+
finish() {
|
|
720
|
+
state.phase = 'done';
|
|
721
|
+
paint(true);
|
|
722
|
+
process.stdout.write('\n\x1b[?25h'); // newline + show cursor
|
|
723
|
+
},
|
|
724
|
+
abort() {
|
|
725
|
+
process.stdout.write('\n\x1b[?25h');
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function runTui(opts) {
|
|
731
|
+
const tui = makeTui(opts.upload);
|
|
732
|
+
const session = makeSession();
|
|
733
|
+
let interrupted = false;
|
|
734
|
+
const onSig = () => {
|
|
735
|
+
interrupted = true;
|
|
736
|
+
tui.abort();
|
|
737
|
+
process.exit(130);
|
|
738
|
+
};
|
|
739
|
+
process.on('SIGINT', onSig);
|
|
740
|
+
const onResize = () => tui.resize();
|
|
741
|
+
process.stdout.on('resize', onResize);
|
|
742
|
+
tui.start();
|
|
743
|
+
// Drive the header shimmer independently of data ticks (ping is sparse).
|
|
744
|
+
const anim = setInterval(() => tui.paint(), 90);
|
|
745
|
+
|
|
746
|
+
try {
|
|
747
|
+
tui.state.phase = 'ping';
|
|
748
|
+
tui.state.meta = await session.getMeta().catch(() => null);
|
|
749
|
+
tui.state.provider = session.name();
|
|
750
|
+
tui.paint(true);
|
|
751
|
+
|
|
752
|
+
const ping = await session.latency(12, (st) => {
|
|
753
|
+
tui.state.pingStats = st;
|
|
754
|
+
tui.paint();
|
|
755
|
+
});
|
|
756
|
+
tui.state.pingStats = ping.stats;
|
|
757
|
+
tui.state.jitter = ping.jitter;
|
|
758
|
+
|
|
759
|
+
tui.state.phase = 'down';
|
|
760
|
+
tui.paint(true);
|
|
761
|
+
tui.state.downStats = await session.download((st) => {
|
|
762
|
+
tui.state.downStats = st;
|
|
763
|
+
tui.paint();
|
|
764
|
+
});
|
|
765
|
+
tui.state.provider = session.name(); // may have failed over mid-run
|
|
766
|
+
|
|
767
|
+
if (opts.upload) {
|
|
768
|
+
tui.state.phase = 'up';
|
|
769
|
+
tui.paint(true);
|
|
770
|
+
tui.state.upStats = await session.upload((st) => {
|
|
771
|
+
tui.state.upStats = st;
|
|
772
|
+
tui.paint();
|
|
773
|
+
});
|
|
774
|
+
tui.state.provider = session.name();
|
|
775
|
+
if (!tui.state.upStats) tui.state.upUnavailable = true; // provider can't measure it
|
|
776
|
+
tui.state.uploadApprox = session.uploadApprox();
|
|
777
|
+
tui.paint(true);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (!interrupted) tui.finish();
|
|
781
|
+
} catch (err) {
|
|
782
|
+
tui.abort();
|
|
783
|
+
throw err;
|
|
784
|
+
} finally {
|
|
785
|
+
clearInterval(anim);
|
|
786
|
+
process.off('SIGINT', onSig);
|
|
787
|
+
process.stdout.off('resize', onResize);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ---- plain output ----------------------------------------------------------
|
|
792
|
+
|
|
793
|
+
function range(st, fmt) {
|
|
794
|
+
return `${fmt(st.avg)} avg (min ${fmt(st.min)} / max ${fmt(st.max)})`;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Throughput headline: lead with the 90th percentile (near sustained peak, as
|
|
798
|
+
// speed tests report), with the spread behind it.
|
|
799
|
+
function speedRange(st, fmt) {
|
|
800
|
+
return `${fmt(st.p90)} (avg ${fmt(st.avg)} / max ${fmt(st.max)})`;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function runPlain(opts) {
|
|
804
|
+
const session = makeSession();
|
|
805
|
+
const meta = await session.getMeta().catch(() => null);
|
|
806
|
+
|
|
807
|
+
const ping = await session.latency(12);
|
|
808
|
+
const down = await session.download();
|
|
809
|
+
const up = opts.upload ? await session.upload() : null;
|
|
810
|
+
|
|
811
|
+
if (opts.json) {
|
|
812
|
+
const pack = (s) =>
|
|
813
|
+
s ? { min: s.min, avg: s.avg, max: s.max, p90: s.p90 } : null;
|
|
814
|
+
console.log(
|
|
815
|
+
JSON.stringify(
|
|
816
|
+
{
|
|
817
|
+
provider: session.name(),
|
|
818
|
+
uploadProvider: up ? session.uploadName() : null,
|
|
819
|
+
uploadApprox: up ? session.uploadApprox() : null,
|
|
820
|
+
latencyMs: ping.stats
|
|
821
|
+
? { ...pack(ping.stats), jitter: ping.jitter }
|
|
822
|
+
: null,
|
|
823
|
+
downloadBps: pack(down),
|
|
824
|
+
uploadBps: pack(up),
|
|
825
|
+
colo: meta?.colo ?? null,
|
|
826
|
+
city: meta?.city ?? null,
|
|
827
|
+
country: meta?.country ?? null,
|
|
828
|
+
ip: meta?.clientIp ?? null,
|
|
829
|
+
},
|
|
830
|
+
null,
|
|
831
|
+
2
|
|
832
|
+
)
|
|
833
|
+
);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const out = [];
|
|
838
|
+
if (meta) {
|
|
839
|
+
const where = [meta.colo, meta.country].filter(Boolean).join(' · ');
|
|
840
|
+
out.push(`Testing via ${session.name()}${where ? ' ' + where : ''}`);
|
|
841
|
+
}
|
|
842
|
+
if (ping.stats)
|
|
843
|
+
out.push(` latency ${range(ping.stats, fmtMs)} jitter ${fmtMs(ping.jitter)}`);
|
|
844
|
+
if (down) out.push(` download ${speedRange(down, fmtBits)}`);
|
|
845
|
+
if (up) {
|
|
846
|
+
const tags = [];
|
|
847
|
+
if (session.uploadName() !== session.name()) tags.push(`via ${session.uploadName()}`);
|
|
848
|
+
if (session.uploadApprox()) tags.push('approx');
|
|
849
|
+
const suffix = tags.length ? ` (${tags.join(', ')})` : '';
|
|
850
|
+
out.push(` upload ${speedRange(up, fmtBits)}${suffix}`);
|
|
851
|
+
} else if (opts.upload) {
|
|
852
|
+
out.push(` upload unavailable (no usable backend for this connection)`);
|
|
853
|
+
}
|
|
854
|
+
console.log(out.join('\n'));
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ---- entry -----------------------------------------------------------------
|
|
858
|
+
|
|
859
|
+
function parseArgs(argv) {
|
|
860
|
+
const opts = { json: false, upload: true, tui: true };
|
|
861
|
+
for (const a of argv) {
|
|
862
|
+
if (a === '--json') opts.json = true;
|
|
863
|
+
else if (a === '--no-upload') opts.upload = false;
|
|
864
|
+
else if (a === '--no-tui') opts.tui = false;
|
|
865
|
+
else if (a === '-h' || a === '--help') opts.help = true;
|
|
866
|
+
}
|
|
867
|
+
return opts;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function help() {
|
|
871
|
+
console.log(`fast — fast.com (Netflix) speedtest from the command line
|
|
872
|
+
|
|
873
|
+
Usage:
|
|
874
|
+
fast [options]
|
|
875
|
+
|
|
876
|
+
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.
|
|
879
|
+
Upload uses a send-side measurement and reads on the high side (shown ~approx).
|
|
880
|
+
|
|
881
|
+
Options:
|
|
882
|
+
--json Output results as JSON (implies plain output)
|
|
883
|
+
--no-upload Skip the upload test
|
|
884
|
+
--no-tui Force plain line output instead of the TUI
|
|
885
|
+
-h, --help Show this help`);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function main() {
|
|
889
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
890
|
+
if (opts.help) return help();
|
|
891
|
+
|
|
892
|
+
const useTui = opts.tui && !opts.json && process.stdout.isTTY;
|
|
893
|
+
if (useTui) await runTui(opts);
|
|
894
|
+
else await runPlain(opts);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (require.main === module) {
|
|
898
|
+
main().catch((err) => {
|
|
899
|
+
process.stdout.write('\x1b[?25h'); // ensure cursor restored
|
|
900
|
+
console.error('\nfast: ' + (err?.message || err));
|
|
901
|
+
process.exit(1);
|
|
902
|
+
});
|
|
903
|
+
} else {
|
|
904
|
+
module.exports = { makeTui, gaugeStats, labelRow, stats, sup, fmtBits, fmtMs, median, percentile };
|
|
905
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"fast": "index.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node index.js",
|
|
17
|
+
"test": "node --test"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"speedtest",
|
|
21
|
+
"speed-test",
|
|
22
|
+
"fast.com",
|
|
23
|
+
"netflix",
|
|
24
|
+
"bandwidth",
|
|
25
|
+
"internet-speed",
|
|
26
|
+
"download",
|
|
27
|
+
"upload",
|
|
28
|
+
"latency",
|
|
29
|
+
"ping",
|
|
30
|
+
"cli",
|
|
31
|
+
"tui",
|
|
32
|
+
"network"
|
|
33
|
+
],
|
|
34
|
+
"author": "Long Sien <longsien@gmail.com>",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/longsien/fastdotcom.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/longsien/fastdotcom/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/longsien/fastdotcom#readme"
|
|
44
|
+
}
|