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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/index.js +905 -0
  4. 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
+ }