├── 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 |
--------------------------------------------------------------------------------