Skip to content

feat: add cf speedtest - #391

Open
olipayne wants to merge 1 commit into
cloudflare:mainfrom
olipayne:feat/speedtest
Open

olipayne wants to merge 1 commit into
cloudflare:mainfrom
olipayne:feat/speedtest

Conversation

@olipayne

Copy link
Copy Markdown

Summary

Adds cf speedtest (alias cf speed-test): measure download and upload speed, latency, and jitter between this machine and Cloudflare's network, straight from the CLI. No Cloudflare login is required.

In a terminal, each reading updates live on stderr with a sparkline of its samples and stays on screen once measured. stdout carries only the JSON result, so scripts and agents get the same output as every other cf command:

🍊☁️  cf · v1.0.0-beta.20
─────────────────────────
◇  Latency  15.2 ms ±6.0    ▃▄▄▃▄▄▄▃▃▃▄▃▃▄▃▃▄█▃▃ [0s]
◇  Download 81.9 Mbps       ▄▄▄▄▃▂▇▇▆▇▆▇▇▇▇████▇▇███ [17s]
◇  Upload   40.5 Mbps       ▄▅▃▃▃▅▅▄██▇▇▇▆██▇█ [9s]
{
  "downloadMbps": 81.86,
  "uploadMbps": 40.49,
  "latencyMs": 15.23,
  "jitterMs": 5.99,
  "dataCenter": "SFO"
}

How it measures

It times requests to speed.cloudflare.com's public __down and __up endpoints, following the method of @cloudflare/speedtest:

  • Latency and jitter: 20 __down?bytes=0 requests after one warm-up request that opens the connection. Server time from Server-Timing (cfReqDur, falling back to the cfSpeed* sum) is subtracted from time to first byte. Latency is the median; jitter is the mean difference between consecutive pings.
  • Download and upload: sets of growing requests (100 kB up to 250 MB down and 50 MB up), stopping once every request in a set takes over a second. The result is the interpolated 90th percentile of samples that took at least 10 ms, with the library's 0.5% framing allowance.
  • Data center: the IATA code after the ray ID in cf-ray.

The library is not used directly because it expects a browser: it reads Resource Timing entries (which Node's fetch does not create) and window.location, logs every measurement with console.log (which would land on stdout), and its packet-loss test needs WebRTC. Packet loss and loaded latency are therefore not measured.

Implementation notes

  • cf speedtest is a hand-written root, like cf docs search: there is no platform API operation behind it. Code is in src/commands/speedtest/, registered in hand-written.ts, root-commands.ts, and hand-written-meta.ts, and documented in AGENTS.md.
  • Root aliases: RootHandWrittenCommand gains an optional aliases field, which src/index.ts passes to yargs and also uses so --local is still rejected under an alias. The meta.ts sidecar lists the alias too, as cf access tcp does for its leaf aliases.
  • Live progress: withProgress now passes its task an optional update(message) callback that replaces the spinner message; the last update stays on screen when the task succeeds. Existing callers are unchanged, and updates are ignored when the spinner isn't drawn (non-TTY, CI, CF_QUIET=1).

Open questions

  • Rate limiting: after about five full runs plus probes in roughly an hour, speed.cloudflare.com started answering large __down requests from my IP with HTTP 429, and cf speedtest currently fails with a rate-limit message when that happens. The website sends a Turnstile-backed Authorization token (cfspeedAuthEnabled) and the CLI can't. I couldn't tell whether unauthenticated clients get lower limits, so this probably needs a conversation with the speed.cloudflare.com owners before release. The library retries 429s up to three times using Retry-After; this PR doesn't yet.
  • Naming: cf speedtest sits next to the existing cf speed (Observatory page tests), which could confuse people. I'm happy to rename it.

Testing

  • pnpm check passes.
  • The packages/cli suite passes (147 files, 1963 tests). New tests cover the measurement maths (percentile, jitter, Server-Timing and cf-ray parsing, sparkline), the full command against MSW with a mocked clock (including the per-direction stop rule and that requests carry no Authorization header), the alias, HTTP, rate-limit, network, and timeout errors, and withProgress updates.
  • The Wrangler compatibility suite was not run locally.
  • Real runs against speed.cloudflare.com from a WARP-connected laptop gave results in line with the browser test on the same connection (CLI 64–95 Mbps down, about 16 ms latency; browser 65 Mbps down, 14 ms latency). Speeds varied a lot between runs on that connection.

Note: two tests in src/__tests__/lib/telemetry/dispatcher.test.ts fail when the suite runs inside an AI coding agent session, because agent detection reads variables such as CLAUDECODE and AI_AGENT from the environment. They pass once those are unset; this is unrelated to this change.

🤖 Generated with Claude Code

Measure download and upload speed, latency, and jitter between this
machine and Cloudflare's network with `cf speedtest` (alias
`cf speed-test`). It times requests to speed.cloudflare.com's public
`__down` and `__up` endpoints using the method of `@cloudflare/speedtest`:
server time is subtracted from time to first byte, latency is the median
and jitter the mean consecutive difference of 20 pings, and bandwidth is
the 90th percentile of growing request sets, which stop once a whole set
takes over a second. The library itself expects a browser, and its
WebRTC packet-loss test cannot run in Node, so packet loss is omitted.

In a terminal each reading updates live with a sparkline of its samples;
stdout carries only the JSON result. No Cloudflare login is required.

To support this, `withProgress` now passes its task an `update` callback
that replaces the spinner message, leaving the last update on screen when
the task succeeds, and root hand-written commands can declare yargs
aliases, which their `meta.ts` sidecars also list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Hi @olipayne. Thanks for contributing to cf!

During the open beta, we’re being very intentional about the changes we accept so that we can prioritise stability for GA. We’re limiting pull requests to contributions coordinated with the team in advance, so this PR has been automatically closed.

We’d still love to hear about your use case, so please make sure it’s captured in a cf issue. If the proposed change fits our priorities, we’d be happy to take another look at this PR.

@clanky-the-clanker

clanky-the-clanker Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

💬 Approved with comments — Clean, well-tested hand-written root with no blocking or warning-level issues. Two optional suggestions: a cosmetic sparkline glitch on non-finite samples, and AGENTS.md inventory lists that don't include the new command yet.

Findings: 2 suggestions

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.

🔧 Fix with your agent

Paste the following into your coding agent to investigate and fix these issues.

Fix the following code review issues found in pull request #391.

Before making changes, review each issue and present a brief summary table:
- For each finding, state whether you agree, disagree, or need clarification
- If you disagree (e.g., the fix would require disproportionate changes
  for minimal ROI, or the finding is factually incorrect), explain why
- If you need to clarify something before deciding, ask those questions
- Then share your plan for which issues to tackle and in what order

## SUGGESTION (2)

### [AGENTS.md] Add speedtest to the hand-written root command lists in AGENTS.md (Where to Edit row and the root directory list) and packages/cli/AGENTS.md.
- **File:** AGENTS.md:217

### [Code Quality] sparkline() draws garbled or missing bars when a sample's bps is Infinity (durationMs of 0). Only the live stderr line is affected, not the JSON. Filter with Number.isFinite before drawing.
- **File:** packages/cli/src/commands/speedtest/index.ts:54

📖 AGENTS.md: 💬 1 suggestion

AGENTS.md describes the new command but doesn't add it to the existing hand-written command location lists.

Recommendation: 💬 Optional Nits

  • [SUGGESTION] Add speedtest to the hand-written root command lists in AGENTS.md (Where to Edit row and the root directory list) and packages/cli/AGENTS.md. (AGENTS.md:217)
View full review
  • The new packages/cli/src/commands/speedtest/ directory appears in the layout and in the feature description. It is missing from the "Where to Edit" hand-written command row (AGENTS.md:217), the root list in "Hand-written command directories", and the matching lists in packages/cli/AGENTS.md. Add it there so contributors can find where the command lives.

📋 Code Quality: 💬 1 suggestion

One cosmetic robustness nit in the sparkline helper. The rest of the change is correct and covered by tests.

Recommendation: 💬 Optional Nits

  • [SUGGESTION] sparkline() draws garbled or missing bars when a sample's bps is Infinity (durationMs of 0). Only the live stderr line is affected, not the JSON. Filter with Number.isFinite before drawing. (packages/cli/src/commands/speedtest/index.ts:54)
View full review
  • packages/cli/src/commands/speedtest/index.ts:19-27, 46-56: speedReading passes every sample's bps to sparkline, including non-finite values. measure.ts:215 computes bps as .../(durationMs / 1000), which gives Infinity when durationMs is 0 (possible on a very fast small download with coarse timer resolution). bandwidthBps filters these out of the result, but sparkline does not. With max = Infinity, every other bar comes out as ▁ and the Infinity bar is BARS[NaN], which is undefined and joins as an empty string. This only affects the live progress line on stderr; the JSON result is unaffected. Fix: filter with Number.isFinite before drawing.

Overall, the measurement maths (percentile interpolation, jitter, Server-Timing and cf-ray parsing) follows the library as described, error paths (429, HTTP error, timeout, network) are handled, and the withProgress update callback stays backward compatible.


✅ All other checks passed

🚀 Release: ✅ LGTM

Risk: Low. The changeset is present and its bump type fits a new command. The rate-limit open question in the description (speed.cloudflare.com returns 429 to unauthenticated clients after repeated runs, and there is no Retry-After retry yet) is worth settling with the endpoint owners before a stable release, as the author notes.


🔒 Security: ✅ LGTM

Requests go only to the fixed https://speed.cloudflare.com origin with the default CLI headers, carry no credentials (tests assert there is no Authorization header), and use a per-request timeout. No security concerns.


⚡ Performance: ✅ LGTM

The command is loaded lazily as a root, so it doesn't affect startup. Download bodies are streamed and counted rather than buffered, and each upload set allocates one buffer that all its requests reuse. No issues found.


📝 Documentation: ✅ LGTM

The command help, epilogue, changeset, and AGENTS.md description match the behaviour.


Reviewed commit: 2d68bc61

@petebacondarwin

Copy link
Copy Markdown
Collaborator

clanker review

@pkg-pr-new

pkg-pr-new Bot commented Oct 23, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/cf@391

commit: 2d68bc6

@petebacondarwin petebacondarwin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we actually need the speed-test alias? Can we just use speedtest as this would simplify the PR and remove invasive changes to the general mechanics.

I see in other chats the idea of having speedtest run. We should make that decision before landing this.

In the PR description there is no handling for Retry-After responses. I think we should support this on first landing.

I would actually propose to go further... Would it be possible to make some small changes to the @cloudflare/speed-test package so that it was Node.js/CLI compatible? This would avoid having to add a lot of (duplicated) logic for the speed test client code in cf. (I would really not have cf own that code, and have to keep it maintained and/or in sync with the library).

Comment thread packages/cli/src/index.ts
rootHandWrittenCommands().map(rootCommandName)
rootHandWrittenCommands().flatMap((command) => [
rootCommandName(command),
...(command.aliases ?? []),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to run each of the aliases through rootCommandName too?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we can bundle the alias handling into rootCommandName():

Something like:

export function rootCommandNames(command: RootHandWrittenCommand): string[] {
	return [command.command, ...command.aliases].map(command => command.split(/\s+/)[0] ?? command);
}

Comment thread packages/cli/src/index.ts
command.load as LazyCommandImporter,
command.telemetry
),
aliases: command.aliases,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move the alias handling into the lazyCommand function?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps change lazyCommand so that instead of taking command: string it takes command: string|string[] and treat the extra items as aliases?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So that would give:

lazyCommand(
		[command.command, ...command.aliases ?? []],
		...

@petebacondarwin
petebacondarwin requested review from a team and removed request for dimitropoulos and srikrsna October 23, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants