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.
- package/index.js +149 -62
- 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
|
|
9
|
-
// minimum and superscript min/
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
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
|
|
201
|
-
const [r, g, b] = gradColor(stops,
|
|
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;
|
|
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
|
|
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,
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 <=
|
|
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
|
|
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
|
|
878
|
-
the minimum and superscript min/
|
|
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.
|
|
4
|
-
"description": "A
|
|
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
|
},
|