CustomIcon/speedtest

★ 0Forks 0TypeScriptGitHub ↗Compare

Project website ↗

README

Speed Tortoise — Internet Speed Test

Pixel-perfect clone of speed.cloudflare.com rebranded as Speed Tortoise — Next.js 16, Tailwind CSS v4, shadcn/ui + MapLibre GL + Lottie. Not affiliated with Cloudflare.

Independent speed test with real measurements (download/upload via /api/download & /api/upload, latency via /api/ping, packet loss via /api/packet-loss), network quality scoring, server map with multi-server support, CSV/PDF export.

Tech Stack

  • Next.js 16 — App Router, React 19, TypeScript strict, output: "standalone"
  • Tailwind CSS v4 — oklch Kumo tokens (--kumo-brand: #f6821f)
  • shadcn/ui + Base UI — Radix primitives
  • MapLibre GL — Carto basemap + MapMarker/MarkerTooltip
  • Lottie React — speedtortoise (tortoise, 22fps, orange #f6821f)
  • Takumi PDF — @takumi-rs/helpers + takumi-pdf

Getting Started (local)

Requirements: Node.js 24+

npm install
npm run dev     # http://localhost:3000
npm run build && npm start

Scripts: dev, build, start, lint, typecheck, check (lint + typecheck + build).

Hosting Guide — Docker

Single instance (frontend + API)

# .env or .env.local
NEXT_PUBLIC_SERVER_NAME=speedtortoise.local
# optional: FRONTEND_ENABLED=true (default)

docker compose up app --build   # http://localhost:3000  (prod, standalone)
# health: GET /api/servers  (also /api/ping, /api/server-info)

docker-compose.yml app service builds Dockerfile (node:24-slim, standalone), exposes ${PORT:-3000}:3000, healthcheck: wget /api/servers.

Multi-server (primary frontend + secondary API-only nodes)

Primary frontend lists all test servers via NEXT_PUBLIC_SPEEDTEST_SERVERS (JSON array). Secondaries run with frontend disabled so they expose only /api/* for the primary to hit cross-origin (CORS enabled).

1. Configure primary .env:

Only id, name, url are required — city/country/lat/lon/hostname are auto-fetched from each server's GET /api/server-info (server-side IP geolocation via ip-api.com → ipinfo.io fallback, no hardcoded coords).

NEXT_PUBLIC_SERVER_NAME=Primary — Male
FRONTEND_ENABLED=true
NEXT_PUBLIC_FRONTEND_ENABLED=true
# optional: IPINFO_TOKEN for higher rate limits (ipinfo.io)
# IPINFO_TOKEN=
# "" url = same origin (primary itself) — minimal form
NEXT_PUBLIC_SPEEDTEST_SERVERS='[
  {"id":"primary","name":"Primary — Male","url":""},
  {"id":"us-east","name":"US East","url":"https://us.example.com"},
  {"id":"eu","name":"EU — Frankfurt","url":"https://eu.example.com"}
]'
# also accepted: NEXT_PUBLIC_SERVERS, SERVERS, SPEEDTEST_SERVERS
# full objects still work if you want to override auto-fetched location: {id, name, url, city, country, lat, lon, hostname}

2. Host secondary (API-only, no UI):

# on secondary host .env
FRONTEND_ENABLED=false
NEXT_PUBLIC_FRONTEND_ENABLED=false
NEXT_PUBLIC_SERVER_NAME=US East
SERVER_NAME=us.example.com

# run only API (profile "api" exposes :3002)
docker compose --profile api up api --build
# or plain: FRONTEND_ENABLED=false docker compose up app --build

When FRONTEND_ENABLED=false, middleware.ts blocks /(.*) except /api/*//_next/* and returns 200 text/plain "Frontend disabled … /api/*"; the Next.js API still serves /api/ping, /api/download, /api/upload, /api/packet-loss, /api/server-info, /api/servers with Access-Control-Allow-Origin:*.

3. Frontend behavior:

  • src/lib/servers.ts getServers() parses NEXT_PUBLIC_SPEEDTEST_SERVERS (only id/name/url required) + enrichServers() auto-fetches missing city/country/lat/lon/hostname from each GET {url}/api/server-info (server-side ip-api.com → ipinfo.io); getDefaultServer() is first entry or enriched fallback (no hardcoded Male).
  • src/hooks/use-speed-test.ts exposes servers, selectedServer, selectServer(id) (persisted localStorage:speedtest:selectedServer); all measurements use selectedServer.url as baseUrl (fetch(\${baseUrl}/api/ping`)withapiUrl()helper insrc/hooks/speed-test/measurements.ts`).
  • src/components/ServerLocationPanel.tsx shows dropdown + markers for every server, MarkerTooltip shows NEXT_PUBLIC_SERVER_NAME/hostname on hover, MarkerLabel city; map center follows selected server; src/app/page.tsx passes sp.servers/selectedServer/selectServer and shows API-only message when NEXT_PUBLIC_FRONTEND_ENABLED=false.

4. Dev with Docker:

docker compose up dev --build    # :3001 with volume mount, hot reload

Env reference (.env.example):

NEXT_PUBLIC_SERVER_NAME=speedtortoise.local
SERVER_NAME=speedtortoise.local
# optional ipinfo token for server geolocation (auto-fallback to ip-api.com, no key needed)
# IPINFO_TOKEN=
FRONTEND_ENABLED=true
NEXT_PUBLIC_FRONTEND_ENABLED=true
# minimal: only id, name, url required — location auto-detected
NEXT_PUBLIC_SPEEDTEST_SERVERS='[{"id":"primary","name":"Primary — Male","url":""}]'

Manual (non-Docker) multi-server

# primary
NEXT_PUBLIC_SPEEDTEST_SERVERS='[...]' npm run build && npm start # :3000

# secondary API-only (different PORT)
FRONTEND_ENABLED=false PORT=3002 NEXT_PUBLIC_SERVER_NAME="US East" npm start # :3002

Set url to secondary's public origin so browser can fetch it (CORS already enabled).

Project Structure

src/
  app/
    page.tsx              # speed test + server selector, FRONTEND_ENABLED gate
    layout.tsx            # Speed Tortoise metadata, themeColor #f6821f
    globals.css           # Kumo tokens (--kumo-brand #f6821f, --brand-gradient)
    api/
      servers/            # GET /api/servers → {frontendEnabled, servers[]}
      ping/ download/ upload/ packet-loss/ server-info/ export-pdf/  # CORS + dynamic
    about/ privacy/ terms/ # internal routes (footer: /about /privacy /terms)
  components/
    SiteHeader.tsx        # imports ../../public/lottie/speedtortoise.json (tortoise, fr 22)
    SiteFooter.tsx        # internal links only, no Cloudflare wordmark
    ServerLocationPanel.tsx # multi-marker Map, select, MarkerTooltip (env server name)
    ResultsPanel.tsx + ControlBar.tsx # Start/Pause/Resume single button + Retest
    NetworkQualityPanel.tsx # Shimmer on value only
    MeasurementPanels.tsx MetricTile.tsx Sparkline.tsx SectionTitle.tsx
    icons.tsx             # Kumo/Phosphor, no Cloudflare comment
    ui/map.tsx            # MapLibre, MapMarker/MarkerTooltip/MarkerLabel
    pdf/                  # Takumi PDF graphs
  hooks/
    use-speed-test.ts     # 321-line orchestrator (re-exports STAGES)
    speed-test/
      stages.ts           # STAGES (12)
      measurements.ts     # measureLatency/downloadActivity/uploadActivity… (baseUrl)
      scoring.ts          # computeAims
      panels.ts           # buildMeasurementPanels
      format.ts           # niceMax/makeTicks/fmtBits
      parse.ts            # parseSize/parsePacketCount…
      index.ts            # barrel for reuse
  lib/
    servers.ts            # SpeedTestServer, getServers(), enrichServers() (ipinfo auto), isFrontendEnabled()
    cors.ts               # corsHeaders()
    empty-result.ts       # Unknown default — location auto-filled via /api/server-info
    export-report.ts      # CSV with BOM + "–"→"" fix
  types/speedtest.ts
public/
  favicon.ico  og-card.png
  lottie/speedtortoise.json + speedtortoise.lottie (tortoise, #f6821f, fr 22)
  images/speedtortoise-*.png/gif
middleware.ts             # blocks non-/api when FRONTEND_ENABLED=false
docker-compose.yml        # app (prod) + dev + api (profile, FRONTEND false)

Features

  • Real measurements (not mock): download/upload streams, latency jitter, packet loss (50 concurrent), AIM scoring (video streaming/gaming/chatting)
  • Single Start/Pause/Resume + Retest in ControlBar (same h-9 bg-card ring-kumo-line style)
  • Map shows selected server dot (bg-kumo-brand) + label + hover tooltip (NEXT_PUBLIC_SERVER_NAME), dropdown when servers.length>1, MapControls showZoom
  • Exports: CSV (\ufeff BOM, –→ empty) and PDF (takumi-pdf)
  • No consent modal; Start is first control row

License

MIT

Contributors

CustomIcon

Issues