pongspeedtest 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/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # pong
2
+
3
+ Fast internet speed test from your terminal. Ping, download, upload, jitter, bufferbloat, and experience scores.
4
+
5
+ Zero dependencies. Works with Node.js 18+.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g pong-speedtest
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ # Full speed test
17
+ pong
18
+
19
+ # Choose a server
20
+ pong --server lax
21
+
22
+ # JSON output (for scripts and piping)
23
+ pong --json
24
+
25
+ # Compact one-line output
26
+ pong --simple
27
+
28
+ # Skip phases you don't need
29
+ pong --skip upload,bloat
30
+
31
+ # List available servers
32
+ pong --list-servers
33
+ ```
34
+
35
+ ## Output
36
+
37
+ ```
38
+ ▸ pong speed test — Auto (Cloudflare Edge)
39
+ ────────────────────────────────────────────────
40
+
41
+ Ping 26.2 ms (Good)
42
+ Jitter 2.1 ms (Excellent)
43
+
44
+ Download 302 Mbps (Excellent)
45
+
46
+ Upload 36.3 Mbps (Good)
47
+
48
+ Bloat Grade A (+3ms under load)
49
+
50
+ ────────────────────────────────────────────────
51
+ Experience Scores
52
+
53
+ 4K Video ████████████████████ 100/100
54
+ Video Call ██████████████████░░ 89/100
55
+ Gaming ██████████████████░░ 89/100
56
+ Web ███████████████████░ 96/100
57
+
58
+ Share results: https://pong.com/results?src=cli&...
59
+
60
+ Powered by pong.com
61
+ ```
62
+
63
+ ## JSON Output
64
+
65
+ ```bash
66
+ pong --json | jq .download
67
+ ```
68
+
69
+ ```json
70
+ {
71
+ "server": { "code": "auto", "name": "Auto (Cloudflare Edge)" },
72
+ "ping": 26.2,
73
+ "jitter": 2.1,
74
+ "download": 302.4,
75
+ "upload": 36.3,
76
+ "bufferbloat": { "grade": "A", "bloatMs": 3.2 },
77
+ "scores": { "video4k": 100, "videoCall": 89, "gaming": 89, "web": 96 },
78
+ "resultsUrl": "https://pong.com/results?src=cli&..."
79
+ }
80
+ ```
81
+
82
+ ## Servers
83
+
84
+ | Code | Location |
85
+ |------|----------|
86
+ | auto | Cloudflare Edge (nearest, default) |
87
+ | ewr | Newark, US |
88
+ | lax | Los Angeles, US |
89
+ | yyz | Toronto, CA |
90
+ | lhr | London, GB |
91
+ | fra | Frankfurt, DE |
92
+ | nrt | Tokyo, JP |
93
+ | sin | Singapore, SG |
94
+ | bom | Mumbai, IN |
95
+ | syd | Sydney, AU |
96
+ | gru | Sao Paulo, BR |
97
+
98
+ ## What's Measured
99
+
100
+ - **Ping**: Round-trip latency (trimmed mean of 20 samples)
101
+ - **Jitter**: Latency variation (mean absolute deviation)
102
+ - **Download**: Multi-stream download throughput
103
+ - **Upload**: Multi-stream upload throughput
104
+ - **Bufferbloat**: Latency increase under load (graded A through F)
105
+ - **Experience Scores**: 4K Video, Video Call, Gaming, Web Browsing (0 to 100)
106
+
107
+ ## License
108
+
109
+ MIT. By [pong.com](https://pong.com).
package/bin/pong.js ADDED
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ═══════════════════════════════════════════════════════════════════
4
+ // pong — fast internet speed test from your terminal
5
+ // https://pong.com
6
+ // ═══════════════════════════════════════════════════════════════════
7
+
8
+ import {
9
+ SERVERS,
10
+ runPing,
11
+ runDownload,
12
+ runUpload,
13
+ runBufferbloat,
14
+ scoreVideo4k,
15
+ scoreVideoCall,
16
+ scoreGaming,
17
+ scoreWeb,
18
+ } from "../lib/speedtest.js";
19
+
20
+ import {
21
+ c,
22
+ startSpinner,
23
+ stopSpinner,
24
+ progressBar,
25
+ ratingColor,
26
+ ratingLabel,
27
+ bloatColor,
28
+ scoreColor,
29
+ box,
30
+ } from "../lib/ui.js";
31
+
32
+ // ── Version ───────────────────────────────────────────────────────
33
+ const VERSION = "1.0.0";
34
+
35
+ // ── Argument parsing ──────────────────────────────────────────────
36
+ const args = process.argv.slice(2);
37
+ const flags = {
38
+ help: args.includes("--help") || args.includes("-h"),
39
+ version: args.includes("--version") || args.includes("-v"),
40
+ json: args.includes("--json"),
41
+ simple: args.includes("--simple"),
42
+ listServers: args.includes("--list-servers") || args.includes("--servers"),
43
+ noColor: args.includes("--no-color"),
44
+ server: null,
45
+ skip: new Set(),
46
+ };
47
+
48
+ // Parse --server <code>
49
+ const serverIdx = args.findIndex((a) => a === "--server" || a === "-s");
50
+ if (serverIdx !== -1 && args[serverIdx + 1]) {
51
+ flags.server = args[serverIdx + 1].toLowerCase();
52
+ }
53
+
54
+ // Parse --skip <phases>
55
+ const skipIdx = args.findIndex((a) => a === "--skip");
56
+ if (skipIdx !== -1 && args[skipIdx + 1]) {
57
+ args[skipIdx + 1].split(",").forEach((s) => flags.skip.add(s.trim().toLowerCase()));
58
+ }
59
+
60
+ // ── Help ──────────────────────────────────────────────────────────
61
+ if (flags.help) {
62
+ console.log(`
63
+ ${c.bold}${c.cyan}pong${c.reset} ${c.dim}v${VERSION}${c.reset} — fast internet speed test
64
+
65
+ ${c.bold}USAGE${c.reset}
66
+ pong Run full speed test
67
+ pong --server lax Use specific server
68
+ pong --json Output results as JSON
69
+ pong --simple Compact single-line output
70
+ pong --list-servers Show available servers
71
+ pong --skip upload Skip phases (ping,download,upload,bloat)
72
+
73
+ ${c.bold}OPTIONS${c.reset}
74
+ -s, --server <code> Server code (default: auto)
75
+ --json JSON output (for scripts)
76
+ --simple One-line summary
77
+ --skip <phases> Comma-separated phases to skip
78
+ --list-servers List available test servers
79
+ --no-color Disable colors
80
+ -v, --version Show version
81
+ -h, --help Show this help
82
+
83
+ ${c.bold}EXAMPLES${c.reset}
84
+ ${c.dim}$${c.reset} pong
85
+ ${c.dim}$${c.reset} pong --server nrt --json
86
+ ${c.dim}$${c.reset} pong --skip bloat,upload --simple
87
+
88
+ ${c.dim}https://pong.com${c.reset}
89
+ `);
90
+ process.exit(0);
91
+ }
92
+
93
+ if (flags.version) {
94
+ console.log(VERSION);
95
+ process.exit(0);
96
+ }
97
+
98
+ // ── List servers ──────────────────────────────────────────────────
99
+ if (flags.listServers) {
100
+ console.log(`\n${c.bold} Available servers${c.reset}\n`);
101
+ for (const [code, srv] of Object.entries(SERVERS)) {
102
+ const tag = code === "auto" ? `${c.green}(default)${c.reset}` : "";
103
+ console.log(` ${c.cyan}${code.padEnd(6)}${c.reset} ${srv.name} ${tag}`);
104
+ }
105
+ console.log();
106
+ process.exit(0);
107
+ }
108
+
109
+ // ── Resolve server ────────────────────────────────────────────────
110
+ function resolveServer(code) {
111
+ if (!code || code === "auto") return SERVERS.auto;
112
+ const srv = SERVERS[code];
113
+ if (!srv) {
114
+ console.error(
115
+ `${c.red}Error:${c.reset} Unknown server "${code}". Use ${c.cyan}--list-servers${c.reset} to see options.`
116
+ );
117
+ process.exit(1);
118
+ }
119
+ return srv;
120
+ }
121
+
122
+ // ── Main ──────────────────────────────────────────────────────────
123
+ async function main() {
124
+ const server = resolveServer(flags.server);
125
+ const base = server.url;
126
+ const ac = new AbortController();
127
+ const { signal } = ac;
128
+
129
+ // Handle Ctrl+C gracefully
130
+ process.on("SIGINT", () => {
131
+ ac.abort();
132
+ stopSpinner();
133
+ console.log(`\n\n ${c.dim}Test cancelled.${c.reset}\n`);
134
+ process.exit(0);
135
+ });
136
+
137
+ const results = {
138
+ server: { code: server.code, name: server.name },
139
+ ping: null,
140
+ jitter: null,
141
+ download: null,
142
+ upload: null,
143
+ bufferbloat: null,
144
+ scores: null,
145
+ timestamp: new Date().toISOString(),
146
+ };
147
+
148
+ if (!flags.json && !flags.simple) {
149
+ console.log();
150
+ console.log(
151
+ ` ${c.bold}${c.cyan}▸ pong${c.reset} ${c.dim}speed test${c.reset} ${c.dim}—${c.reset} ${c.dim}${server.name}${c.reset}`
152
+ );
153
+ console.log(` ${c.dim}${"─".repeat(48)}${c.reset}`);
154
+ console.log();
155
+ }
156
+
157
+ // ── Ping ──────────────────────────────────────────────────────
158
+ if (!flags.skip.has("ping")) {
159
+ if (!flags.json && !flags.simple) startSpinner("Measuring latency...");
160
+ let pingStarted = false;
161
+
162
+ const pingResult = await runPing(base, signal, (ms, i, total) => {
163
+ if (!flags.json && !flags.simple) {
164
+ if (!pingStarted) { stopSpinner(); pingStarted = true; }
165
+ progressBar("Ping", ms, "ms", 100);
166
+ }
167
+ });
168
+
169
+ stopSpinner();
170
+
171
+ if (pingResult) {
172
+ results.ping = Math.round(pingResult.ping * 100) / 100;
173
+ results.jitter = Math.round(pingResult.jitter * 100) / 100;
174
+
175
+ if (!flags.json && !flags.simple) {
176
+ const pc = ratingColor("ping", results.ping);
177
+ const jc = ratingColor("jitter", results.jitter);
178
+ console.log(
179
+ ` ${c.cyan}Ping ${c.reset} ${pc}${c.bold}${results.ping.toFixed(1)}${c.reset} ${c.dim}ms${c.reset} ${c.dim}(${ratingLabel("ping", results.ping)})${c.reset}`
180
+ );
181
+ console.log(
182
+ ` ${c.cyan}Jitter ${c.reset} ${jc}${c.bold}${results.jitter.toFixed(1)}${c.reset} ${c.dim}ms${c.reset} ${c.dim}(${ratingLabel("jitter", results.jitter)})${c.reset}`
183
+ );
184
+ console.log();
185
+ }
186
+ }
187
+ }
188
+
189
+ // ── Download ──────────────────────────────────────────────────
190
+ if (!flags.skip.has("download")) {
191
+ if (!flags.json && !flags.simple) startSpinner("Testing download speed...");
192
+ let dlStarted = false;
193
+
194
+ const dlResult = await runDownload(base, signal, (mbps) => {
195
+ if (!flags.json && !flags.simple) {
196
+ if (!dlStarted) { stopSpinner(); dlStarted = true; }
197
+ progressBar("Download", mbps, "Mbps", 500);
198
+ }
199
+ });
200
+
201
+ stopSpinner();
202
+
203
+ if (dlResult) {
204
+ results.download = Math.round(dlResult * 100) / 100;
205
+
206
+ if (!flags.json && !flags.simple) {
207
+ const dc = ratingColor("speed", results.download);
208
+ console.log(
209
+ ` ${c.cyan}Download ${c.reset} ${dc}${c.bold}${formatSpeed(results.download)}${c.reset} ${c.dim}Mbps${c.reset} ${c.dim}(${ratingLabel("speed", results.download)})${c.reset}`
210
+ );
211
+ console.log();
212
+ }
213
+ }
214
+ }
215
+
216
+ // ── Upload ────────────────────────────────────────────────────
217
+ if (!flags.skip.has("upload")) {
218
+ if (!flags.json && !flags.simple) startSpinner("Testing upload speed...");
219
+ let ulStarted = false;
220
+
221
+ const ulResult = await runUpload(base, signal, (mbps) => {
222
+ if (!flags.json && !flags.simple) {
223
+ if (!ulStarted) { stopSpinner(); ulStarted = true; }
224
+ progressBar("Upload", mbps, "Mbps", 200);
225
+ }
226
+ });
227
+
228
+ stopSpinner();
229
+
230
+ if (ulResult) {
231
+ results.upload = Math.round(ulResult * 100) / 100;
232
+
233
+ if (!flags.json && !flags.simple) {
234
+ const uc = ratingColor("speed", results.upload);
235
+ console.log(
236
+ ` ${c.cyan}Upload ${c.reset} ${uc}${c.bold}${formatSpeed(results.upload)}${c.reset} ${c.dim}Mbps${c.reset} ${c.dim}(${ratingLabel("speed", results.upload)})${c.reset}`
237
+ );
238
+ console.log();
239
+ }
240
+ }
241
+ }
242
+
243
+ // ── Bufferbloat ───────────────────────────────────────────────
244
+ if (!flags.skip.has("bloat")) {
245
+ if (!flags.json && !flags.simple) startSpinner("Checking bufferbloat...");
246
+
247
+ const bloatResult = await runBufferbloat(base, signal, (phase, value) => {
248
+ if (!flags.json && !flags.simple) {
249
+ if (phase === "idle") {
250
+ stopSpinner();
251
+ }
252
+ }
253
+ });
254
+
255
+ stopSpinner();
256
+
257
+ if (bloatResult) {
258
+ results.bufferbloat = {
259
+ idleLatency: Math.round(bloatResult.idleLatency * 100) / 100,
260
+ loadedLatency: Math.round(bloatResult.loadedLatency * 100) / 100,
261
+ bloatMs: Math.round(bloatResult.bloatMs * 100) / 100,
262
+ grade: bloatResult.grade,
263
+ };
264
+
265
+ if (!flags.json && !flags.simple) {
266
+ const bc = bloatColor(bloatResult.grade);
267
+ console.log(
268
+ ` ${c.cyan}Bloat ${c.reset} ${bc}${c.bold}Grade ${bloatResult.grade}${c.reset} ${c.dim}(+${Math.round(bloatResult.bloatMs)}ms under load)${c.reset}`
269
+ );
270
+ console.log();
271
+ }
272
+ }
273
+ }
274
+
275
+ // ── Experience Scores ─────────────────────────────────────────
276
+ if (results.ping != null && results.download != null) {
277
+ const dl = results.download;
278
+ const ul = results.upload ?? 0;
279
+ const ping = results.ping;
280
+ const jitter = results.jitter ?? 0;
281
+
282
+ results.scores = {
283
+ video4k: scoreVideo4k(dl, ping, jitter),
284
+ videoCall: scoreVideoCall(dl, ul, ping, jitter),
285
+ gaming: scoreGaming(ping, jitter, dl),
286
+ web: scoreWeb(dl, ping),
287
+ };
288
+
289
+ if (!flags.json && !flags.simple) {
290
+ console.log(` ${c.dim}${"─".repeat(48)}${c.reset}`);
291
+ console.log(` ${c.bold}Experience Scores${c.reset}`);
292
+ console.log();
293
+
294
+ const scoreEntries = [
295
+ ["4K Video", results.scores.video4k],
296
+ ["Video Call", results.scores.videoCall],
297
+ ["Gaming", results.scores.gaming],
298
+ ["Web", results.scores.web],
299
+ ];
300
+
301
+ for (const [label, score] of scoreEntries) {
302
+ const sc = scoreColor(score);
303
+ const bar = scoreBar(score);
304
+ console.log(
305
+ ` ${label.padEnd(12)} ${sc}${bar}${c.reset} ${sc}${c.bold}${score}${c.reset}${c.dim}/100${c.reset}`
306
+ );
307
+ }
308
+ console.log();
309
+ }
310
+ }
311
+
312
+ // ── Results URL ───────────────────────────────────────────────
313
+ const resultsUrl = buildResultsUrl(results);
314
+ results.resultsUrl = resultsUrl;
315
+
316
+ // ── Output ────────────────────────────────────────────────────
317
+ if (flags.json) {
318
+ console.log(JSON.stringify(results, null, 2));
319
+ } else if (flags.simple) {
320
+ const parts = [];
321
+ if (results.ping != null) parts.push(`Ping: ${results.ping.toFixed(1)}ms`);
322
+ if (results.download != null) parts.push(`DL: ${formatSpeed(results.download)} Mbps`);
323
+ if (results.upload != null) parts.push(`UL: ${formatSpeed(results.upload)} Mbps`);
324
+ if (results.jitter != null) parts.push(`Jitter: ${results.jitter.toFixed(1)}ms`);
325
+ if (results.bufferbloat) parts.push(`Bloat: ${results.bufferbloat.grade}`);
326
+ console.log(parts.join(" | "));
327
+ } else {
328
+ // Show results URL
329
+ console.log(` ${c.dim}Share results:${c.reset} ${c.cyan}${resultsUrl}${c.reset}`);
330
+ console.log();
331
+ console.log(` ${c.dim}Powered by pong.com${c.reset}`);
332
+ console.log();
333
+ }
334
+ }
335
+
336
+ // ── Helpers ───────────────────────────────────────────────────────
337
+ function formatSpeed(mbps) {
338
+ if (mbps >= 100) return mbps.toFixed(0);
339
+ if (mbps >= 10) return mbps.toFixed(1);
340
+ return mbps.toFixed(2);
341
+ }
342
+
343
+ function scoreBar(score) {
344
+ const width = 20;
345
+ const filled = Math.round((score / 100) * width);
346
+ const empty = width - filled;
347
+ return "█".repeat(filled) + "░".repeat(empty);
348
+ }
349
+
350
+ function buildResultsUrl(results) {
351
+ const params = new URLSearchParams();
352
+ params.set("src", "cli");
353
+ if (results.ping != null) params.set("p", results.ping.toFixed(1));
354
+ if (results.jitter != null) params.set("j", results.jitter.toFixed(1));
355
+ if (results.download != null) params.set("dl", results.download.toFixed(1));
356
+ if (results.upload != null) params.set("ul", results.upload.toFixed(1));
357
+ if (results.bufferbloat) params.set("bb", results.bufferbloat.grade);
358
+ if (results.server.code !== "auto") params.set("srv", results.server.code);
359
+ return `https://pong.com/results?${params.toString()}`;
360
+ }
361
+
362
+ // ── Run ───────────────────────────────────────────────────────────
363
+ main().catch((err) => {
364
+ stopSpinner();
365
+ if (err.name === "AbortError") {
366
+ process.exit(0);
367
+ }
368
+ console.error(`\n${c.red}Error:${c.reset} ${err.message}\n`);
369
+ process.exit(1);
370
+ });
@@ -0,0 +1,274 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // Speed test core (zero dependencies, Node 18+ built-in fetch)
3
+ // Mirrors the logic from the Chrome extension popup.js
4
+ // ═══════════════════════════════════════════════════════════════════
5
+
6
+ import { progressBar, stopSpinner } from "./ui.js";
7
+
8
+ // ── Servers ──────────────────────────────────────────────────────
9
+ export const SERVERS = {
10
+ auto: { code: "auto", name: "Auto (Cloudflare Edge)", url: "https://speed.pong.com" },
11
+ ewr: { code: "ewr", name: "Newark, US", url: "https://speed-ewr.pong.com" },
12
+ lax: { code: "lax", name: "Los Angeles, US", url: "https://speed-lax.pong.com" },
13
+ yyz: { code: "yyz", name: "Toronto, CA", url: "https://speed-yyz.pong.com" },
14
+ lhr: { code: "lhr", name: "London, GB", url: "https://speed-lhr.pong.com" },
15
+ fra: { code: "fra", name: "Frankfurt, DE", url: "https://speed-fra.pong.com" },
16
+ nrt: { code: "nrt", name: "Tokyo, JP", url: "https://speed-nrt.pong.com" },
17
+ sin: { code: "sin", name: "Singapore, SG", url: "https://speed-sin.pong.com" },
18
+ bom: { code: "bom", name: "Mumbai, IN", url: "https://speed-bom.pong.com" },
19
+ syd: { code: "syd", name: "Sydney, AU", url: "https://speed-syd.pong.com" },
20
+ gru: { code: "gru", name: "Sao Paulo, BR", url: "https://speed-gru.pong.com" },
21
+ };
22
+
23
+ // ── Warm up connections ──────────────────────────────────────────
24
+ async function warmUp(base, signal, count = 3) {
25
+ const fetches = Array.from({ length: count }, () =>
26
+ fetch(`${base}/ping?t=${Date.now()}&warm=1`, { signal }).catch(() => null)
27
+ );
28
+ await Promise.all(fetches);
29
+ }
30
+
31
+ // ── Ping ─────────────────────────────────────────────────────────
32
+ export async function runPing(base, signal, onProgress) {
33
+ await warmUp(base, signal, 1);
34
+ const pings = [];
35
+
36
+ for (let i = 0; i < 20; i++) {
37
+ if (signal.aborted) return null;
38
+ const start = performance.now();
39
+ try {
40
+ await fetch(`${base}/ping?t=${Date.now()}`, { signal });
41
+ const ms = performance.now() - start;
42
+ pings.push(ms);
43
+ if (onProgress) onProgress(ms, i + 1, 20);
44
+ } catch {
45
+ if (signal.aborted) return null;
46
+ }
47
+ }
48
+
49
+ if (pings.length === 0) return null;
50
+
51
+ const sorted = [...pings].sort((a, b) => a - b);
52
+ const trim = Math.max(1, Math.floor(sorted.length * 0.1));
53
+ const trimmed = sorted.slice(trim, sorted.length - trim);
54
+ const avg = trimmed.reduce((a, b) => a + b, 0) / trimmed.length;
55
+ const jitter = trimmed.reduce((s, p) => s + Math.abs(p - avg), 0) / trimmed.length;
56
+
57
+ return { ping: avg, jitter, min: sorted[0], max: sorted[sorted.length - 1] };
58
+ }
59
+
60
+ // ── Download ─────────────────────────────────────────────────────
61
+ export async function runDownload(base, signal, onProgress) {
62
+ await warmUp(base, signal, 8);
63
+
64
+ const phases = [
65
+ { chunkSize: 4_000_000, streams: 6 },
66
+ { chunkSize: 16_000_000, streams: 8 },
67
+ { chunkSize: 25_000_000, streams: 8 },
68
+ ];
69
+
70
+ let totalBytes = 0;
71
+ const globalStart = performance.now();
72
+ let lastReport = globalStart;
73
+
74
+ for (const phase of phases) {
75
+ if (signal.aborted) return null;
76
+
77
+ const downloads = Array.from({ length: phase.streams }, async () => {
78
+ if (signal.aborted) return;
79
+ try {
80
+ const res = await fetch(
81
+ `${base}/download?size=${phase.chunkSize}&t=${Date.now()}`,
82
+ { signal }
83
+ );
84
+ const reader = res.body?.getReader();
85
+ if (!reader) return;
86
+
87
+ while (true) {
88
+ const { done, value } = await reader.read();
89
+ if (done || signal.aborted) break;
90
+ totalBytes += value.byteLength;
91
+
92
+ const now = performance.now();
93
+ if (now - lastReport > 150) {
94
+ const elapsed = (now - globalStart) / 1000;
95
+ const mbps = (totalBytes * 8) / (elapsed * 1_000_000);
96
+ if (onProgress) onProgress(mbps);
97
+ lastReport = now;
98
+ }
99
+ }
100
+ } catch {
101
+ // stream error
102
+ }
103
+ });
104
+
105
+ await Promise.all(downloads);
106
+ }
107
+
108
+ const totalTime = (performance.now() - globalStart) / 1000;
109
+ return totalBytes > 0 ? (totalBytes * 8) / (totalTime * 1_000_000) : null;
110
+ }
111
+
112
+ // ── Upload ───────────────────────────────────────────────────────
113
+ export async function runUpload(base, signal, onProgress) {
114
+ const UPLOAD_DURATION = 10_000;
115
+ const CHUNK_SIZE = 4_000_000;
116
+ const UPLOAD_STREAMS = 6;
117
+ const uploadData = new Uint8Array(CHUNK_SIZE);
118
+
119
+ let totalBytes = 0;
120
+ const globalStart = performance.now();
121
+ let lastReport = globalStart;
122
+ let running = true;
123
+
124
+ const timer = setTimeout(() => { running = false; }, UPLOAD_DURATION);
125
+
126
+ const workers = Array.from({ length: UPLOAD_STREAMS }, async () => {
127
+ while (running && !signal.aborted) {
128
+ try {
129
+ await fetch(`${base}/upload?t=${Date.now()}`, {
130
+ method: "POST",
131
+ body: uploadData,
132
+ signal,
133
+ });
134
+ totalBytes += CHUNK_SIZE;
135
+
136
+ const now = performance.now();
137
+ if (now - lastReport > 150) {
138
+ const elapsed = (now - globalStart) / 1000;
139
+ const mbps = (totalBytes * 8) / (elapsed * 1_000_000);
140
+ if (onProgress) onProgress(mbps);
141
+ lastReport = now;
142
+ }
143
+ } catch {
144
+ if (signal.aborted) break;
145
+ await new Promise((r) => setTimeout(r, 100));
146
+ }
147
+ }
148
+ });
149
+
150
+ await Promise.all(workers);
151
+ clearTimeout(timer);
152
+
153
+ const totalTime = (performance.now() - globalStart) / 1000;
154
+ return totalBytes > 0 ? (totalBytes * 8) / (totalTime * 1_000_000) : null;
155
+ }
156
+
157
+ // ── Bufferbloat ──────────────────────────────────────────────────
158
+ export async function runBufferbloat(base, signal, onProgress) {
159
+ // Measure idle latency
160
+ const idlePings = [];
161
+ for (let i = 0; i < 5; i++) {
162
+ if (signal.aborted) return null;
163
+ const start = performance.now();
164
+ try {
165
+ await fetch(`${base}/ping?t=${Date.now()}`, { signal });
166
+ idlePings.push(performance.now() - start);
167
+ } catch {
168
+ if (signal.aborted) return null;
169
+ }
170
+ }
171
+ if (idlePings.length === 0) return null;
172
+ const idleLatency = idlePings.sort((a, b) => a - b)[Math.floor(idlePings.length / 2)];
173
+
174
+ if (onProgress) onProgress("idle", idleLatency);
175
+
176
+ // Start background load
177
+ const loadCtrl = new AbortController();
178
+ signal.addEventListener("abort", () => loadCtrl.abort());
179
+
180
+ for (let i = 0; i < 4; i++) {
181
+ (async () => {
182
+ try {
183
+ const res = await fetch(`${base}/download?size=32000000&t=${Date.now()}-${i}`, {
184
+ signal: loadCtrl.signal,
185
+ });
186
+ const reader = res.body?.getReader();
187
+ if (!reader) return;
188
+ while (true) {
189
+ const { done } = await reader.read();
190
+ if (done) break;
191
+ }
192
+ } catch { /* expected abort */ }
193
+ })();
194
+ }
195
+
196
+ await new Promise((r) => setTimeout(r, 1500));
197
+
198
+ // Measure loaded latency
199
+ const loadedPings = [];
200
+ for (let i = 0; i < 10; i++) {
201
+ if (signal.aborted) { loadCtrl.abort(); return null; }
202
+ const start = performance.now();
203
+ try {
204
+ await fetch(`${base}/ping?t=${Date.now()}-load`, { signal });
205
+ const ms = performance.now() - start;
206
+ loadedPings.push(ms);
207
+ if (onProgress) onProgress("loaded", ms, i + 1, 10);
208
+ } catch {
209
+ if (signal.aborted) { loadCtrl.abort(); return null; }
210
+ }
211
+ }
212
+
213
+ loadCtrl.abort();
214
+
215
+ if (loadedPings.length === 0) return null;
216
+ const loadedLatency = loadedPings.sort((a, b) => a - b)[Math.floor(loadedPings.length / 2)];
217
+ const bloat = Math.max(0, loadedLatency - idleLatency);
218
+
219
+ let grade;
220
+ if (bloat <= 5) grade = "A";
221
+ else if (bloat <= 30) grade = "B";
222
+ else if (bloat <= 60) grade = "C";
223
+ else if (bloat <= 200) grade = "D";
224
+ else grade = "F";
225
+
226
+ return { idleLatency, loadedLatency, bloatMs: bloat, grade };
227
+ }
228
+
229
+ // ── Experience Scores (matches pong.com scoring) ─────────────────
230
+ function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
231
+
232
+ export function scoreVideo4k(dl, ping, jitter) {
233
+ let s = 0;
234
+ if (dl >= 50) s += 60; else if (dl >= 25) s += 40 + ((dl - 25) / 25) * 20;
235
+ else if (dl >= 10) s += 15 + ((dl - 10) / 15) * 25; else s += (dl / 10) * 15;
236
+ if (ping <= 30) s += 25; else if (ping <= 60) s += 15 + ((60 - ping) / 30) * 10;
237
+ else s += Math.max(0, 15 - (ping - 60) / 10);
238
+ if (jitter <= 5) s += 15; else if (jitter <= 15) s += 8 + ((15 - jitter) / 10) * 7;
239
+ else s += Math.max(0, 8 - (jitter - 15) / 5);
240
+ return clamp(Math.round(s), 0, 100);
241
+ }
242
+
243
+ export function scoreVideoCall(dl, ul, ping, jitter) {
244
+ const min = Math.min(dl, ul);
245
+ let s = 0;
246
+ if (min >= 10) s += 35; else if (min >= 5) s += 20 + ((min - 5) / 5) * 15;
247
+ else if (min >= 2) s += 8 + ((min - 2) / 3) * 12; else s += (min / 2) * 8;
248
+ if (ping <= 30) s += 30; else if (ping <= 60) s += 18 + ((60 - ping) / 30) * 12;
249
+ else if (ping <= 150) s += 5 + ((150 - ping) / 90) * 13; else s += Math.max(0, 5 - (ping - 150) / 50);
250
+ if (jitter <= 5) s += 20; else if (jitter <= 15) s += 10 + ((15 - jitter) / 10) * 10;
251
+ else if (jitter <= 30) s += 3 + ((30 - jitter) / 15) * 7; else s += Math.max(0, 3 - (jitter - 30) / 10);
252
+ if (ul >= dl * 0.5) s += 15; else s += (ul / (dl * 0.5)) * 15;
253
+ return clamp(Math.round(s), 0, 100);
254
+ }
255
+
256
+ export function scoreGaming(ping, jitter, dl) {
257
+ let s = 0;
258
+ if (ping <= 15) s += 45; else if (ping <= 30) s += 30 + ((30 - ping) / 15) * 15;
259
+ else if (ping <= 60) s += 15 + ((60 - ping) / 30) * 15; else s += Math.max(0, 15 - (ping - 60) / 10);
260
+ if (jitter <= 3) s += 30; else if (jitter <= 10) s += 18 + ((10 - jitter) / 7) * 12;
261
+ else if (jitter <= 20) s += 8 + ((20 - jitter) / 10) * 10; else s += Math.max(0, 8 - (jitter - 20) / 5);
262
+ if (dl >= 25) s += 25; else if (dl >= 10) s += 15 + ((dl - 10) / 15) * 10;
263
+ else s += (dl / 10) * 15;
264
+ return clamp(Math.round(s), 0, 100);
265
+ }
266
+
267
+ export function scoreWeb(dl, ping) {
268
+ let s = 0;
269
+ if (dl >= 50) s += 50; else if (dl >= 10) s += 25 + ((dl - 10) / 40) * 25;
270
+ else s += (dl / 10) * 25;
271
+ if (ping <= 20) s += 50; else if (ping <= 50) s += 30 + ((50 - ping) / 30) * 20;
272
+ else if (ping <= 100) s += 15 + ((100 - ping) / 50) * 15; else s += Math.max(0, 15 - (ping - 100) / 20);
273
+ return clamp(Math.round(s), 0, 100);
274
+ }
package/lib/ui.js ADDED
@@ -0,0 +1,131 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // Terminal UI helpers (zero dependencies, pure ANSI)
3
+ // ═══════════════════════════════════════════════════════════════════
4
+
5
+ const isColorSupported =
6
+ process.env.FORCE_COLOR !== "0" &&
7
+ (process.env.FORCE_COLOR || process.stdout.isTTY);
8
+
9
+ const c = isColorSupported
10
+ ? {
11
+ reset: "\x1b[0m",
12
+ bold: "\x1b[1m",
13
+ dim: "\x1b[2m",
14
+ cyan: "\x1b[36m",
15
+ green: "\x1b[32m",
16
+ yellow: "\x1b[33m",
17
+ red: "\x1b[31m",
18
+ white: "\x1b[97m",
19
+ gray: "\x1b[90m",
20
+ bgCyan: "\x1b[46m",
21
+ bgGreen: "\x1b[42m",
22
+ bgYellow: "\x1b[43m",
23
+ bgRed: "\x1b[41m",
24
+ black: "\x1b[30m",
25
+ hide: "\x1b[?25l",
26
+ show: "\x1b[?25h",
27
+ clearLine: "\x1b[2K",
28
+ up: "\x1b[1A",
29
+ }
30
+ : Object.fromEntries(
31
+ [
32
+ "reset","bold","dim","cyan","green","yellow","red","white",
33
+ "gray","bgCyan","bgGreen","bgYellow","bgRed","black",
34
+ "hide","show","clearLine","up",
35
+ ].map((k) => [k, ""])
36
+ );
37
+
38
+ export { c };
39
+
40
+ // ── Spinner ──────────────────────────────────────────────────────
41
+ const SPINNER_FRAMES = [" ", ". ", ".. ", "..."];
42
+ let spinnerInterval = null;
43
+ let spinnerFrame = 0;
44
+
45
+ export function startSpinner(text) {
46
+ process.stdout.write(c.hide);
47
+ spinnerFrame = 0;
48
+ spinnerInterval = setInterval(() => {
49
+ spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
50
+ process.stdout.write(
51
+ `\r${c.clearLine} ${c.cyan}${SPINNER_FRAMES[spinnerFrame]}${c.reset} ${c.dim}${text}${c.reset}`
52
+ );
53
+ }, 250);
54
+ }
55
+
56
+ export function stopSpinner() {
57
+ if (spinnerInterval) {
58
+ clearInterval(spinnerInterval);
59
+ spinnerInterval = null;
60
+ }
61
+ process.stdout.write(`\r${c.clearLine}${c.show}`);
62
+ }
63
+
64
+ // ── Progress bar ─────────────────────────────────────────────────
65
+ export function progressBar(label, value, unit, maxVal) {
66
+ const barWidth = 30;
67
+ const pct = Math.min(value / maxVal, 1);
68
+ const filled = Math.round(barWidth * pct);
69
+ const empty = barWidth - filled;
70
+
71
+ const bar = `${"█".repeat(filled)}${"░".repeat(empty)}`;
72
+ const valStr =
73
+ value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2);
74
+
75
+ process.stdout.write(
76
+ `\r${c.clearLine} ${c.cyan}${label.padEnd(10)}${c.reset} ${c.dim}${bar}${c.reset} ${c.bold}${c.white}${valStr}${c.reset} ${c.dim}${unit}${c.reset}`
77
+ );
78
+ }
79
+
80
+ // ── Rating helpers ───────────────────────────────────────────────
81
+ export function ratingColor(type, value) {
82
+ if (type === "ping" || type === "jitter") {
83
+ if (value <= 10) return c.green;
84
+ if (value <= 30) return c.cyan;
85
+ if (value <= 60) return c.yellow;
86
+ return c.red;
87
+ }
88
+ if (value >= 100) return c.green;
89
+ if (value >= 25) return c.cyan;
90
+ if (value >= 10) return c.yellow;
91
+ return c.red;
92
+ }
93
+
94
+ export function ratingLabel(type, value) {
95
+ if (type === "ping" || type === "jitter") {
96
+ if (value <= 10) return "Excellent";
97
+ if (value <= 30) return "Good";
98
+ if (value <= 60) return "Fair";
99
+ return "Poor";
100
+ }
101
+ if (value >= 100) return "Excellent";
102
+ if (value >= 25) return "Good";
103
+ if (value >= 10) return "Fair";
104
+ return "Poor";
105
+ }
106
+
107
+ export function bloatColor(grade) {
108
+ if (grade <= "B") return c.green;
109
+ if (grade === "C") return c.yellow;
110
+ return c.red;
111
+ }
112
+
113
+ export function scoreColor(score) {
114
+ if (score >= 80) return c.green;
115
+ if (score >= 60) return c.cyan;
116
+ if (score >= 40) return c.yellow;
117
+ return c.red;
118
+ }
119
+
120
+ // ── Box drawing ──────────────────────────────────────────────────
121
+ export function box(lines, width = 52) {
122
+ const top = ` ${c.dim}╭${"─".repeat(width)}╮${c.reset}`;
123
+ const bot = ` ${c.dim}╰${"─".repeat(width)}╯${c.reset}`;
124
+ const rows = lines.map((l) => {
125
+ // Strip ANSI for length calculation
126
+ const stripped = l.replace(/\x1b\[[0-9;]*m/g, "");
127
+ const pad = Math.max(0, width - 2 - stripped.length);
128
+ return ` ${c.dim}│${c.reset} ${l}${" ".repeat(pad)} ${c.dim}│${c.reset}`;
129
+ });
130
+ return [top, ...rows, bot].join("\n");
131
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "pongspeedtest",
3
+ "version": "1.0.0",
4
+ "description": "Fast internet speed test from your terminal. Ping, download, upload, jitter & bufferbloat.",
5
+ "bin": {
6
+ "pong": "bin/pong.js"
7
+ },
8
+ "type": "module",
9
+ "engines": {
10
+ "node": ">=18.0.0"
11
+ },
12
+ "keywords": [
13
+ "speedtest",
14
+ "speed-test",
15
+ "internet",
16
+ "bandwidth",
17
+ "ping",
18
+ "download",
19
+ "upload",
20
+ "jitter",
21
+ "bufferbloat",
22
+ "network",
23
+ "cli",
24
+ "pong"
25
+ ],
26
+ "author": "pong.com",
27
+ "license": "MIT",
28
+ "homepage": "https://pong.com",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/pongcom/cli.git"
32
+ }
33
+ }