├── version.ts ├── deno.json ├── LICENSE ├── main.ts ├── README.md ├── server.ts └── client.ts /version.ts: -------------------------------------------------------------------------------- 1 | export const VERSION = "1.1.0"; 2 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "lock": false, 3 | "tasks": { 4 | "check": "deno fmt --check && deno lint", 5 | "main": "deno run --allow-net --allow-read main.ts", 6 | "server": "deno run --allow-net server.ts", 7 | "www": "deno run --allow-net --allow-read www.ts" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Karel Klíma 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. 22 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "https://deno.land/x/hono@v3.7.2/mod.ts"; 2 | import { CSS, render } from "https://deno.land/x/gfm@0.2.5/mod.ts"; 3 | import { VERSION } from "./version.ts"; 4 | import { server } from "./server.ts"; 5 | 6 | const app = new Hono(server); 7 | 8 | app.get("/", (ctx) => { 9 | const markdown = Deno.readTextFileSync( 10 | new URL(import.meta.resolve("./README.md")), 11 | ); 12 | const body = render(markdown); 13 | 14 | const html = ` 15 | 16 | 17 | 18 | 19 | 20 | 27 | 28 | 29 |
30 | ${body} 31 |
32 | 33 | 34 | `; 35 | 36 | return ctx.html(html); 37 | }); 38 | 39 | app.get( 40 | "/server.ts", 41 | (ctx) => 42 | ctx.redirect(`https://deno.land/x/speedtesting@${VERSION}/server.ts`, 307), 43 | ); 44 | app.get( 45 | "/client.ts", 46 | (ctx) => 47 | ctx.redirect(`https://deno.land/x/speedtesting@${VERSION}/client.ts`, 307), 48 | ); 49 | 50 | // Start the speed test server and documentation server 51 | Deno.serve(app.fetch); 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # speedtesting 2 | 3 | Fast speed test server and client for Deno runtime. Measures latency, download 4 | speed and upload speed. 5 | 6 | To use this software, 7 | [install Deno](https://docs.deno.com/runtime/manual/getting_started/installation). 8 | 9 | ## Running the server 10 | 11 | Run the following command: 12 | 13 | ``` 14 | deno run -A https://speedtesting.deno.dev/server.ts 15 | ``` 16 | 17 | Congratulation, you now have a running speed test server! You can specify a 18 | custom port number: 19 | 20 | ``` 21 | deno run -A https://speedtesting.deno.dev/server.ts 1234 22 | ``` 23 | 24 | ## Running the client 25 | 26 | To run a speed test against the default speed test server, run: 27 | 28 | ``` 29 | deno run -A https://speedtesting.deno.dev/client.ts 30 | ``` 31 | 32 | That command will measure latency, download speed and upload speed against 33 | `https://speedtesting.deno.dev` server. 34 | 35 | You can set following flags to customize the execution: 36 | 37 | - `server`: speed test server URL (defaults to `https://speedtesting.deno.dev`) 38 | - `pingCount`: how many pings to do to measure latency (defaults to 100) 39 | - `downloadMegabytes`: how many megabytes of data to download during download 40 | test (defaults to 50) 41 | - `uploadMegabytes`: how many megabytes of data to upload during upload test 42 | (defaults to 50) 43 | - `deadlineSeconds`: maximum execution timeout for each subtest (defaults to 30 44 | seconds) 45 | 46 | ## Deno module 47 | 48 | Speed test server and client are published as Deno modules at 49 | [https://deno.land/x/speedtesting](https://deno.land/x/speedtesting). 50 | 51 | As such, both server and client may be used programatically within Deno 52 | environment outside of CLI. 53 | 54 | ## Source code 55 | 56 | Source code is available at 57 | [https://github.com/karelklima/speedtesting](https://github.com/karelklima/speedtesting). 58 | 59 | ## License 60 | 61 | [MIT License](./LICENSE) 62 | 63 | Copyright © 2023 [Karel Klima](https://karelklima.com) 64 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Status, 3 | STATUS_TEXT, 4 | } from "https://deno.land/std@0.202.0/http/http_status.ts"; 5 | import { Hono, HTTPException } from "https://deno.land/x/hono@v3.7.2/mod.ts"; 6 | import { VERSION } from "./version.ts"; 7 | 8 | // Dummy chunk of data to send in download response 9 | const DATA_CHUNK_SIZE = 1 << 16; // 64 KB 10 | const DATA_CHUNK = new Uint8Array(DATA_CHUNK_SIZE); 11 | 12 | // Download ReadableStream inner queue size 13 | const HIGH_WATER_MARK = DATA_CHUNK_SIZE << 3; // 512 KB 14 | 15 | // Enforce upload limit to prevent taking down the speed test server 16 | const MAX_UPLOAD_LIMIT = 1 << 20; // 1 MB 17 | 18 | export const server = new Hono(); 19 | 20 | /** 21 | * WebSocket endpoint - sends "pong" message when "ping" message is received 22 | * to allow for latency calculation 23 | */ 24 | server.get("/ws", (ctx) => { 25 | if (ctx.req.header("upgrade") !== "websocket") { 26 | throw new HTTPException(Status.BadRequest); 27 | } 28 | 29 | const { socket, response } = Deno.upgradeWebSocket(ctx.req.raw); 30 | 31 | socket.addEventListener("message", (event) => { 32 | if (event.data === "ping") { 33 | socket.send("pong"); 34 | } 35 | }); 36 | 37 | return response; 38 | }); 39 | 40 | /** 41 | * Download endpoint - sends content of required size to clients. 42 | * Content size is specified using `size` URL param and should contain 43 | * the number of 64 KB chunks to be sent to client. 44 | */ 45 | server.get("/download/:size{[1-9][0-9]*}", (ctx) => { 46 | const chunkCount = Number(ctx.req.param().size); 47 | const contentLength = chunkCount * DATA_CHUNK_SIZE; 48 | let enqueuedChunks = 0; 49 | 50 | const body = new ReadableStream({ 51 | pull(controller) { 52 | controller.enqueue(DATA_CHUNK); 53 | enqueuedChunks++; 54 | if (enqueuedChunks === chunkCount) { 55 | controller.close(); 56 | } 57 | }, 58 | autoAllocateChunkSize: DATA_CHUNK_SIZE, 59 | }, { 60 | highWaterMark: HIGH_WATER_MARK, // let the controller buffer chunks upfront 61 | }); 62 | 63 | ctx.header("Content-Length", String(contentLength)); 64 | return ctx.body(body); 65 | }); 66 | 67 | /** 68 | * Upload endpoint - accepts incoming data and returns number of KBs read 69 | */ 70 | server.post("/upload", async (ctx) => { 71 | let readBytes = 0; 72 | 73 | const sink = new WritableStream({ 74 | write(chunk) { 75 | readBytes += chunk.length; 76 | if (readBytes > MAX_UPLOAD_LIMIT) { 77 | throw new HTTPException(Status.BadRequest); 78 | } 79 | }, 80 | }); 81 | 82 | await ctx.req.raw.body?.pipeTo(sink); // wait to fully download the content 83 | 84 | return ctx.text(`${readBytes >> 10}`); // total content read in KB 85 | }); 86 | 87 | /** 88 | * Status endpoint - returns system information about the speed test server 89 | */ 90 | server.get("/status", (ctx) => { 91 | const status = { 92 | status: "OK", 93 | version: { 94 | speedtest: VERSION, 95 | ...Deno.version, 96 | }, 97 | memoryUsage: Deno.memoryUsage(), 98 | }; 99 | 100 | return ctx.json(status); 101 | }); 102 | 103 | /** 104 | * Error handler 105 | */ 106 | server.onError((err) => { 107 | const status: Status = err instanceof HTTPException 108 | ? err.status 109 | : Status.InternalServerError; 110 | return new Response(`${status} ${STATUS_TEXT[status]}`, { status }); 111 | }); 112 | 113 | // Start the speed test server if this module is called directly 114 | if (import.meta.main) { 115 | console.log("SPEED TEST SERVER"); 116 | // The first argument is an optional port number 117 | const port = Deno.args.length === 1 ? Number(Deno.args[0]) : 8000; 118 | if (!Number.isInteger(port)) { 119 | console.error(`Invalid port number specified: "${Deno.args[0]}"`); 120 | Deno.exit(1); 121 | } 122 | Deno.serve({ port }, server.fetch); 123 | } 124 | -------------------------------------------------------------------------------- /client.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "https://deno.land/std@0.202.0/flags/mod.ts"; 2 | import { z } from "https://deno.land/x/zod@v3.22.2/mod.ts"; 3 | 4 | const PositiveIntegerSchema = z.coerce.number().int().positive(); 5 | const SpeedTestOptionsSchema = z.object({ 6 | server: z.string().url().default("https://speedtesting.deno.dev"), 7 | pingCount: PositiveIntegerSchema.default(100), 8 | downloadMegabytes: PositiveIntegerSchema.default(50), 9 | uploadMegabytes: PositiveIntegerSchema.default(50), 10 | deadlineSeconds: PositiveIntegerSchema.default(30), 11 | }); 12 | 13 | export type SpeedTestOptions = z.input; 14 | type SpeedTestConfig = z.infer; 15 | 16 | // Definition of tests contained in the main speed test 17 | const TESTS = { 18 | latency: testLatency, 19 | download: testDownload, 20 | upload: testUpload, 21 | }; 22 | 23 | // Convenience type of the cumulative result of the speed test 24 | export type ErrorTestSubResult = { ok: false; error: string }; 25 | export type SpeedTestResult = { 26 | [K in keyof typeof TESTS]: 27 | | Awaited> 28 | | ErrorTestSubResult; 29 | }; 30 | 31 | /** 32 | * Speed test client. Measures latency, download speed and upload speed 33 | * agains a predefined speed test server. 34 | */ 35 | export async function speedTest(options: SpeedTestOptions) { 36 | const config = SpeedTestOptionsSchema.parse(options); 37 | 38 | console.log("SPEED TEST CONFIGURATION"); 39 | console.log(config); 40 | 41 | const deadlineMs = config.deadlineSeconds * 1000; 42 | 43 | const result = {} as Record; 44 | for (const testName of Object.keys(TESTS)) { 45 | try { 46 | const testFunction = TESTS[testName as keyof typeof TESTS]; 47 | const testResult = await deadline(testFunction(config), deadlineMs); 48 | result[testName] = testResult; 49 | } catch (err) { 50 | result[testName] = { 51 | ok: false, 52 | error: err?.message ?? err, 53 | }; 54 | } 55 | } 56 | 57 | return result as SpeedTestResult; 58 | } 59 | 60 | /** 61 | * Latency measurement test that gauges latency using sending WebSocket 62 | * messages between client and server. Average latency equals to average 63 | * roundtrip of the messages. 64 | */ 65 | function testLatency({ server, pingCount }: SpeedTestConfig) { 66 | console.log("Measuring latency"); 67 | 68 | type LatencyResponse = { 69 | ok: true; 70 | durationMs: number; 71 | latencyMs: number; 72 | pingCount: number; 73 | }; 74 | return new Promise((resolve, reject) => { 75 | const socket = new WebSocket(`${server}/ws`); 76 | 77 | let start = 0; 78 | let counter = 0; 79 | 80 | socket.addEventListener("message", (event) => { 81 | if (event.data === "pong") { 82 | counter++; 83 | if (counter === pingCount) { 84 | const durationMs = performance.now() - start; 85 | const latencyMs = durationMs / pingCount; 86 | socket.close(); 87 | resolve({ 88 | ok: true, 89 | durationMs, 90 | latencyMs, 91 | pingCount, 92 | }); 93 | } else { 94 | socket.send("ping"); // ping and wait for response 95 | } 96 | } 97 | }); 98 | 99 | socket.addEventListener("open", (_event) => { 100 | start = performance.now(); // start measuring 101 | socket.send("ping"); 102 | }); 103 | 104 | socket.addEventListener("error", (event: Event) => { 105 | reject(new Error((event as ErrorEvent).message)); 106 | }); 107 | }); 108 | } 109 | 110 | /** 111 | * Test download speed by requesting the speed test server to send 112 | * chunks of data. Time to receive the data is measured for each chunk. 113 | */ 114 | async function testDownload({ server, downloadMegabytes }: SpeedTestConfig) { 115 | console.log("Measuring download speed"); 116 | const size = 1 << 4; // 16 ~ Number of 64 KB chunks to download = 1 MB per iteration 117 | let durationMs = 0; 118 | for (let i = 0; i < downloadMegabytes; i++) { 119 | const start = performance.now(); 120 | await fetch(`${server}/download/${size}?nocache=${crypto.randomUUID()}`); 121 | const partialDuration = performance.now() - start; 122 | durationMs += partialDuration; 123 | } 124 | const downloadSpeedMbps = calculateMbps(downloadMegabytes, durationMs); 125 | return { 126 | ok: true, 127 | durationMs, 128 | downloadMegabytes, 129 | downloadSpeedMbps, 130 | }; 131 | } 132 | 133 | /** 134 | * Test upload speed by sending chunks of data to speed test server. 135 | * Time to server aknowledging the upload is measured for each chunk. 136 | */ 137 | async function testUpload({ server, uploadMegabytes }: SpeedTestConfig) { 138 | console.log("Measuring upload speed"); 139 | let durationMs = 0; 140 | 141 | const dataChunk = new Uint8Array(1 << 20); // 1 MB 142 | 143 | for (let i = 0; i < uploadMegabytes; i++) { 144 | const start = performance.now(); 145 | await fetch(`${server}/upload?nocache=${crypto.randomUUID()}`, { 146 | method: "POST", 147 | body: dataChunk, 148 | }); 149 | const partialDuration = performance.now() - start; 150 | durationMs += partialDuration; 151 | } 152 | const uploadSpeedMbps = calculateMbps(uploadMegabytes, durationMs); 153 | return { 154 | ok: true, 155 | durationMs, 156 | uploadMegabytes, 157 | uploadSpeedMbps, 158 | }; 159 | } 160 | 161 | /** 162 | * Helper function to calculate network speed in megabits per second 163 | */ 164 | function calculateMbps(megabytes: number, milliseconds: number) { 165 | const megabits = megabytes << 3; 166 | const seconds = milliseconds / 1000; 167 | return megabits / seconds; 168 | } 169 | 170 | /** 171 | * Helper function to limit execution time of tests 172 | */ 173 | function deadline(promise: T, ms: number): Promise { 174 | const stop = new Promise((_resolve, reject) => { 175 | setTimeout(() => reject(new Error("Deadline reached")), ms); 176 | }); 177 | return Promise.race([promise, stop]); 178 | } 179 | 180 | // Start the speed test server if this module is called directly 181 | if (import.meta.main) { 182 | // The first argument is an optional port number 183 | const flags = parse(Deno.args); 184 | try { 185 | const result = await speedTest(flags as SpeedTestOptions); 186 | console.log("SPEED TEST RESULT", result); 187 | Deno.exit(); 188 | } catch (err) { 189 | console.log("SPEED TEST FAILED"); 190 | console.error(err.message ?? err); 191 | Deno.exit(1); 192 | } 193 | } 194 | --------------------------------------------------------------------------------