10 Commits
Author SHA1 Message Date
clawbot 28317f428f README.md: rework against the fourth review
check / check (push) Canceled after 0s
CORS: the built page needs no entry. DEBUG: every value is accepted.
Model: fable-5-1
2026-10-26 05:43:31 +00:00
clawbot 49fdca1c96 README.md: rework against the third review
The symbolic link rule, the console line, fx and the Makefile name
are fixed as the review asked. The full pass fixed nine more
sentences: a split subject, a late verb or a claim broader than the
code.

Model: fable-5-1
2026-10-26 05:41:11 +00:00
clawbot ddd3a83fdf README.md: rework against the second review
The four findings: only a recorded failed check is logged, and the
discarded round, given-up checks and the recovery probe log nothing;
prepare-data-dir gives directories, regular files and symbolic links to
the netwatch user, and a hard link keeps its owner and is logged; the
cli entry names what it runs in three short sentences; the 80% timeout
and the early-round rule have their reasons back, so rounds never
overlap.

The full pass also fixed claims broader than the code (a row always
shows its stats; a report is posted every 60 seconds; a file is written
every minute; each refusal answers JSON; every answer to a report
carries rate-limit headers; every setting applies in the image, where
BIND_ADDRESS has no effect; fmt formats all files), six sentences whose
subject was split from its verb (the recovery probe, the forwarded
entry with a port, the failed write's retry and its file, both rate
limits, the nvm archive), and the viewport test's "same ... as"
comparison.

Model: fable-5-1
2026-10-26 05:41:11 +00:00
clawbot 5788ea43e3 README.md: rework against the review findings
The check sentence names HEAD for the S3 regions up front. The rate-limit
sentence states the safe rate plainly: an address that sends at most half
the limit in any 60 seconds is never refused. The upaas `TRUSTED_PROXIES`
entry again warns that a client connecting from a trusted address can
write its own `X-Forwarded-For`, that a connection through a published
Docker port can arrive from the Docker network's gateway, which the
default includes, and that set and empty, every client behind the proxy
shares one allowance. `TODO.md` gets the Completed Steps entry for
#189, now that
#196 has merged. For
#195.

Model: fable-5-1
2026-10-26 05:41:11 +00:00
clawbot 09cc003a01 README.md: name the rounds in which the rows are sorted
Model: fable-5-1
2026-10-26 05:41:11 +00:00
clawbot 2513303b3c README.md rewritten sentence by sentence for a newcomer
Every sentence is under about 30 words, with one idea each. The page,
its targets and what a figure measures come first, then reporting,
deployment, the settings reference, the design, and development last.
No tables; every identifier in backticks. The code on next decides
what is true.

Model: fable-5-1
2026-10-26 05:41:11 +00:00
clawbot 8ce69b7b40 test/viewport/README.md rewritten sentence by sentence
check / check (push) Canceled after 0s
test/viewport/README.md is rewritten from scratch so that every sentence reads on the first pass. It says what the layout test checks, at which widths, how to run it and what it cannot cover, and drops the history of how the test came about. Part of #189.

Model: fable-5-1 (rewrite, review); opus-5-5 (merge)
Co-authored-by: clawbot <clawbot@noreply.example.org>
2026-10-26 06:40:20 +01:00
clawbot 83a095b572 TODO.md rewritten sentence by sentence
check / check (push) Canceled after 0s
TODO.md is rewritten from scratch so that every sentence reads on the first pass: one idea each, under about 30 words, the verb early. Workflow, Next Step and Future Steps keep their substance. Each Completed Steps entry keeps its date, its issue link and what changed; entries describing a state since changed are in the past tense. Part of #189.

Model: fable-5-1 (rewrite, review); opus-5-5 (merge)
Co-authored-by: clawbot <clawbot@noreply.example.org>
2026-10-26 04:59:21 +01:00
clawbot 8a62f12802 Targets whose home page redirects are checked at /robots.txt (closes #194)
check / check (push) Canceled after 0s
A check follows a redirect and times the request to the new URL too. So the AWS Console, the Google Cloud Console, Akamai, B2 and the eight S3 regions are now checked at /robots.txt on their own host, which answers without a redirect. Before, the S3 and B2 figures also timed a page on another host. The S3 regions are checked with HEAD, because Chrome closes the connection after a GET of their /robots.txt and the figure would then include connection setup every time. README.md says what a figure includes.

Reports name a target by its URL, so these targets' samples are stored under the new URLs.

Model: opus-5-5
2026-10-26 04:11:48 +01:00
clawbot a72797a403 Checks send no Referer header, so Hetzner answers (closes #191)
check / check (push) Canceled after 0s
Each check now sets referrerPolicy: "no-referrer". The Hetzner speed-test servers close the connection without an answer when the request has a Referer header, and the browser sent one unless the page came with the image's Referrer-Policy: no-referrer header, as from a static host or make dev. The checks now send none wherever the page is served from. s3.me-south-1.amazonaws.com still times out from this machine; curl gets no answer from it either.

Model: opus-5-5
2026-10-26 02:10:43 +01:00
6 changed files with 1396 additions and 1215 deletions
+608 -518
View File
File diff suppressed because it is too large Load Diff
+533 -577
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -8,10 +8,9 @@ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" alway
# Scripts and styles load only from the page's own origin. Inline ones # Scripts and styles load only from the page's own origin. Inline ones
# are blocked, style attributes in markup included, so style elements # are blocked, style attributes in markup included, so style elements
# through classes or element.style. data: images are for the favicon # through classes or element.style. data: images are for the favicon
# in web/index.html. connect-src is * because the browser checks each # in web/index.html. connect-src is * because the page checks every
# probe in web/src/main.js against it, and also every redirect the probe # target in web/src/main.js. A list of hosts here would have to change
# follows, and several of those hosts redirect to others; a list of # with every change to those targets. It also covers the reports the
# hosts here would block those probes. It also covers the reports the
# page sends to its own origin. # page sends to its own origin.
add_header Content-Security-Policy "default-src 'self'; connect-src *; img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; add_header Content-Security-Policy "default-src 'self'; connect-src *; img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
+85
View File
@@ -180,6 +180,91 @@ test("a check fetches the target's URL as written, with no query string added",
); );
}); });
test("a check sends no Referer header, wherever the page is served from", async (t) => {
mockTargets(t, () => 10);
const check = measureLatency("https://fsn1-speed.hetzner.com");
t.mock.timers.tick(10);
assert.notEqual(await settled(check), "still waiting");
assert.equal(
fetch.mock.calls[0].arguments[1].referrerPolicy,
"no-referrer",
);
});
// These targets' home pages answer with a redirect. A check follows it and
// times the request to the new URL too, often on another host. The Google
// Cloud Console redirects only requests that do not say they come from
// another site, such as curl's. Each target is checked at /robots.txt on
// its own host, which answers without a redirect. An S3 region answers it
// with 403, and any answer counts as reachable.
test("a target whose home page redirects is checked at /robots.txt on its own host", () => {
const checked = new Map(
new AppState([]).wan.map((host) => [host.name, host.url]),
);
for (const [name, url] of [
["AWS Console", "https://console.aws.amazon.com/robots.txt"],
["Google Cloud Console", "https://console.cloud.google.com/robots.txt"],
["Akamai", "https://www.akamai.com/robots.txt"],
["B2", "https://api.backblazeb2.com/robots.txt"],
[
"S3 af-south-1 (Cape Town)",
"https://s3.af-south-1.amazonaws.com/robots.txt",
],
[
"S3 eu-west-2 (London)",
"https://s3.eu-west-2.amazonaws.com/robots.txt",
],
[
"S3 me-south-1 (Bahrain)",
"https://s3.me-south-1.amazonaws.com/robots.txt",
],
[
"S3 ap-northeast-1 (Tokyo)",
"https://s3.ap-northeast-1.amazonaws.com/robots.txt",
],
[
"S3 ap-southeast-1 (Singapore)",
"https://s3.ap-southeast-1.amazonaws.com/robots.txt",
],
[
"S3 ap-southeast-2 (Sydney)",
"https://s3.ap-southeast-2.amazonaws.com/robots.txt",
],
[
"S3 us-west-2 (Oregon)",
"https://s3.us-west-2.amazonaws.com/robots.txt",
],
[
"S3 sa-east-1 (São Paulo)",
"https://s3.sa-east-1.amazonaws.com/robots.txt",
],
]) {
assert.equal(checked.get(name), url);
}
});
// Chrome keeps the connection to an S3 region open after a HEAD check, but
// closes it after most GET checks. The Hetzner servers do not answer HEAD.
test("a round checks each S3 region with a HEAD request and every other target with a GET request", async (t) => {
const state = new AppState([
{ name: "Local CPE", url: "http://192.168.100.1" },
]);
mockTargets(t, () => 10);
const round = tick(state, new AbortController().signal);
t.mock.timers.tick(10);
assert.notEqual(await settled(round), "still waiting");
const methods = new Map(
fetch.mock.calls.map((call) => [
call.arguments[0],
call.arguments[1].method,
]),
);
for (const host of state.allHosts) {
const method = host.name.startsWith("S3 ") ? "HEAD" : "GET";
assert.equal(methods.get(host.url), method, host.name);
}
});
test("at a 30000ms interval, a target answering after 1000ms shows in its row while another target's check is still waiting", async (t) => { test("at a 30000ms interval, a target answering after 1000ms shows in its row while another target's check is still waiting", async (t) => {
CONFIG.updateInterval = 30000; CONFIG.updateInterval = 30000;
const state = new AppState([ const state = new AppState([
+123 -91
View File
@@ -1,114 +1,146 @@
# Responsive-layout harness # The viewport test
Automated verification of the responsive layout that landed in #5. Run it with: `make frontend-viewport-test` checks the page's layout at several window widths
in a real browser:
```bash ```bash
make frontend-viewport-test make frontend-viewport-test
``` ```
It builds `dist/`, serves it from the same digest-pinned `nginx` image and the First it runs the unit tests. Then it builds the page into `dist/`. It serves
same `configs/nginx.conf` the shipping container uses, drives a digest-pinned `dist/` with the nginx image and the `configs/nginx.conf` that the Docker image
headless Chrome against it over CDP, and asserts on computed layout at every ships. Before the browser starts, it fetches `/` and `/no-such-page`, which
viewport width derived from the app's own CSS. Screenshots land in nginx answers with the page. Both must carry `Cache-Control: no-cache` and a
`tmp/viewport/` alongside a `results.json`; they are artifacts for a human to `Content-Security-Policy` header. Then it starts a headless Chrome in a
look at when something fails, not the evidence. The assertions are the evidence. container and opens the page in it at each width. For each width it measures the
layout inside the page and checks the measurements in Node. The checks decide
pass or fail. Screenshots and a `results.json` land in `tmp/viewport/`, for a
person to look at when a check fails.
The target is deliberately outside `make check`: it takes minutes, and The test is not part of `make check`: it takes minutes, and `make test` has to
`make test` has to stay under 60 seconds. finish in under 60 seconds.
## How the widths are chosen ## The widths
Not from a list of phone models. `viewports.js` parses the `@media` conditions The breakpoints are read from the page's own files. `viewports.js` reads the
out of `web/src/styles.css` and scans `web/src/main.js` and `web/index.html` for `min-width` and `max-width` conditions out of the `@media` blocks in
Tailwind responsive prefixes, then tests every breakpoint it finds at one pixel `web/src/styles.css`. It also looks in `web/src/main.js` and `web/index.html`
below it, exactly on it, and one pixel above it. A generic 375px "phone" test for Tailwind's responsive class prefixes, such as `md:`, and takes their
sails straight past an off-by-one at a media query boundary; `max-width: 768px` breakpoints. Each breakpoint is tested at one pixel below it, exactly on it, and
matches _at_ 768, and the sweep pins down which side of that line each layout is one pixel above it. Today the CSS has one breakpoint, `max-width: 768px`, and no
on. responsive prefix is used.
Nothing hardcodes 768. Add a second media block or start using `md:` classes and A `max-width: 768px` block matches at 768 pixels. Testing both sides of each
the new breakpoint is covered without this directory being touched. The app is breakpoint catches a layout that switches one pixel too early or too late. A
desktop-first today (all narrow rules live in `max-width` blocks); a second media block, or a first `md:` class, is covered without a change to this
`min-width`-only, mobile-first set is handled as its inverse, and a set that directory.
mixes the two makes the run fail loudly rather than test the right widths with
the wrong expectation. Four further viewports are fixed anchors, each with a
stated reason: a 320px floor, a 1280px desktop baseline, and two phone-landscape
sizes straddling the breakpoint for the rotation case.
## What it asserts The page is desktop-first: the wide layout is the default, and every narrow rule
is in a `max-width` block. So a width is expected to show the narrow layout when
one of those blocks matches it. A page with only `min-width` blocks would be
read the other way round. A page that mixes the two kinds fails the run with a
message, because the right expectation for each width can no longer be read from
the breakpoints alone.
- **app-rendered** — enough host rows exist and enough of them show a numeric Four more widths are fixed, each for a stated reason:
latency. This one exists so the rest cannot pass vacuously against a blank
page.
- **no-horizontal-overflow** — `documentElement.scrollWidth` fits the layout
viewport, with the widest offending element named.
- **nothing-past-viewport-edge** — no visible element's box extends past the
viewport edge.
- **no-clipped-text** — nothing hides text behind `overflow: hidden`. Deliberate
ellipsis truncation (Tailwind's `truncate`, used on host names and URLs) is
excluded: it is a design choice, not breakage.
- **tap-targets-44px** — every interactive control is at least 44x44 CSS px on
touch viewports, _and_ each selector in the control list matched at least the
number of visible elements it declares: one of each single control, and one
pin button per WAN host row. The second half is what stops the check passing
vacuously: with size alone, a renamed class would take its controls out of the
measured set and the check would report "all 0 controls are at least 44x44"
and pass. See below.
- **host-rows-stacked / host-rows-side-by-side** — the rows genuinely reflow.
Computed `flex-direction` _and_ the actual geometry are checked, and in the
narrow layout the info block and the sparkline must each occupy essentially
the full row width. A row that merely shrank its 420px column would fail.
- **probing-still-runs / gateway-detection-still-runs** — narrow viewports keep
probing and keep detecting the gateway. The mobile early-return path proposed
in #8 was rejected; this is what would catch it coming back.
### The tap-target threshold - 320 by 568 pixels: the narrowest width still in common use
- 667 by 375 pixels: a phone in landscape, inside the narrow layout
- 844 by 390 pixels: a large phone in landscape, in the wide layout but still a
touch device
- 1280 by 800 pixels: a desktop window
44x44 CSS px. That is the figure in Apple's Human Interface Guidelines and in Every width but 1280 by 800 is tested as a touch device.
WCAG 2.2 SC 2.5.5 "Target Size (Enhanced)". WCAG 2.2 SC 2.5.8 (level AA) sets a
lower 24x24 floor, but that floor comes with a spacing exception these controls
do not qualify for — the pin buttons sit directly against the host name they
belong to.
## Determinism ## The checks
The browser container runs on an `--internal` docker network and has no route to A failed check does not stop the others.
the internet, so the app's latency probes cannot reach anything real. The
harness answers them itself from a fixed delay table, with a deterministic
fraction failed outright, so the rows render a realistic spread of one-, two-
and three-digit latencies plus some unreachable rows. That spread is what the
layout has to survive; a `---` placeholder in every row would not exercise it.
## What this cannot verify - `app-rendered`: at least 10 WAN host rows exist, and at least 5 rows show a
number as their latency. Without this, the other checks could pass against a
blank page.
- `no-horizontal-overflow`: the document is no wider than the window. On a
failure, the widest content is named.
- `nothing-past-viewport-edge`: no visible element extends past the window's
edge.
- `no-clipped-text`: no element hides its text behind `overflow: hidden` or
`overflow: clip`. Text cut with an ellipsis is allowed: that cut is a design
choice, not a defect. Tailwind's `truncate` cuts host names and URLs that way.
- `tap-targets-44px`, on touch widths: each measured control is at least 44 by
44 CSS pixels. The controls are `#pause-btn`, `#interval-select`,
`#debug-toggle` and the `.pin-btn` in each WAN host row. The check also
demands that each selector matches enough visible elements: one of each single
control, and one pin button per WAN host row. A renamed class would otherwise
take its controls out of the measured set, and the check would pass on
nothing.
- `host-rows-stacked` or `host-rows-side-by-side`: each host row shows the
layout expected at that width. In the narrow layout, the row's
`flex-direction` is `column`, the graph sits below the host's details, and
each takes at least 90% of the row's width. In the wide layout, the direction
is `row` and the graph sits to the right of the details. A row that only
shrank its fixed 420-pixel column fails.
- `probing-still-runs`: the page sent at least one check to a target at this
width. The narrow layout must not skip the checks.
- `gateway-detection-still-runs`: the `Local Gateway` row is present at this
width, so the gateway was looked for and found. The narrow layout must not
skip that search.
Real limits, so nobody re-parks this issue as needing hardware: ### The tap-target size
- **Non-Chromium engines.** This is Chrome. iOS Safari is WebKit and cannot be 44 by 44 CSS pixels is the figure in Apple's Human Interface Guidelines and in
emulated by it; Safari-specific bugs (viewport units under a collapsing URL WCAG 2.2 success criterion 2.5.5, "Target Size (Enhanced)". WCAG 2.2 criterion
bar, `-webkit-fill-available`, form control metrics) will not show up here. 2.5.8 sets a lower minimum of 24 by 24 pixels, with an exception that depends on
- **Real touch input.** `hasTouch` emulation changes what the page is told, not the space around a control. The pin buttons sit against the host name they
how a finger behaves. Gesture handling, scroll momentum, double-tap zoom and belong to, so the test keeps 44.
hover-state fallbacks on touch are out of scope.
- **Physical pixel density and rendering.** `deviceScaleFactor` is set, but
subpixel antialiasing, OLED colour rendering and actual legibility at a given
physical size are not measurable here.
- **Fonts.** The container has DejaVu, not the platform's own UI monospace. Text
metrics are therefore close to, but not identical to, a real device — a layout
that fits here by a few pixels might not there.
- **On-device performance.** Canvas sparkline redraw cost, battery, and
behaviour on a slow radio are not measured.
- **Browser chrome.** The address bar, safe-area insets and notch cutouts are
not simulated.
Everything else this issue was actually about — does the layout reflow, does ## The browser's network
anything overflow, is content clipped, are the controls big enough — is a
function of viewport width and CSS, and is covered above. The browser container runs on an internal Docker network with no route to the
internet, so the page's checks reach no real host. The test answers them itself
from a fixed table of delays: 2, 45, 123, 456 and 780 milliseconds. Which delay
a target gets depends on its URL, and about one target in seven fails outright.
So the rows show one-, two- and three-digit latencies and some unreachable rows,
and the layout is tested against that spread. The request to
`http://192.168.1.1` is answered after 5 milliseconds, so the `Local Gateway`
row appears.
At each width, the test sets the interval to 1 second and waits until at least 5
rows show a latency before it measures. The page discards its first round, so
this reaches a filled page without waiting out two rounds at 3 seconds.
## What the test cannot check
- Other browser engines. The test runs Chrome. Safari uses WebKit, and bugs that
only WebKit has do not show up here.
- Real touch input. Touch emulation changes what the page is told, not how a
finger behaves. Gestures, scroll momentum, double-tap zoom and hover states on
touch are not tested.
- Physical pixels. The device pixel ratio is set, but antialiasing, colour
rendering and legibility at a given physical size are not measured.
- Fonts. The container has DejaVu, not a phone's own monospace font. Text is
about as wide as on a real device, but not exactly. A layout that fits here by
a few pixels might not fit there.
- Performance. The cost of redrawing the graphs, battery use and behaviour on a
slow connection are not measured.
- The browser's own frame. The address bar, safe-area insets and notch cutouts
are not simulated.
## The files
- `viewports.js` derives the widths from the sources.
- `facts.js` is run inside the page and measures the layout. It only measures;
it decides nothing.
- `checks.js` turns the measurements into the pass or fail of each check, with
the numbers that decided it.
- `harness.js` connects to the browser, runs each width, writes the screenshots
and `results.json`, and prints the results. `script/frontend-viewport-test`
starts it; every input it needs arrives in the environment.
## Relation to the unit tests ## Relation to the unit tests
Complementary layers, not two stacks. The unit tests in `test/unit/`, which The unit tests in `test/unit/` call the functions that `web/src/main.js`
`make test` runs with Node's built-in test runner, exercise the functions exports, with no browser. `make test` runs them with Node's built-in test
`web/src/main.js` exports in-process with no browser. This harness exercises runner. This test runs the built page in a real browser engine and is the only
rendered layout in a real engine and is the only thing here that can see a media test here that can see a media query. Neither replaces the other. Checks on
query. Neither replaces the other; assertions about computed styles and element computed styles and element geometry belong here; checks on functions belong in
geometry belong here, assertions about functions belong in `test/unit/`. `test/unit/`.
+44 -25
View File
@@ -67,51 +67,66 @@ export const CONFIG = {
// WAN endpoints to monitor. These are used for the aggregate health/stats // WAN endpoints to monitor. These are used for the aggregate health/stats
// display (min/max/avg). Ordered: personal, LLM APIs, big-3 cloud, then // display (min/max/avg). Ordered: personal, LLM APIs, big-3 cloud, then
// CDN/hosting/other. // CDN/hosting/other. A check follows a redirect and times the request to
// the new URL too. So a target whose home page redirects is checked at
// /robots.txt on its own host. That URL answers without a redirect.
const WAN_HOSTS = [ const WAN_HOSTS = [
{ name: "datavi.be", url: "https://datavi.be" }, { name: "datavi.be", url: "https://datavi.be" },
{ name: "Anthropic API", url: "https://api.anthropic.com" }, { name: "Anthropic API", url: "https://api.anthropic.com" },
{ name: "OpenAI API", url: "https://api.openai.com" }, { name: "OpenAI API", url: "https://api.openai.com" },
{ name: "AWS Console", url: "https://console.aws.amazon.com" }, { name: "AWS Console", url: "https://console.aws.amazon.com/robots.txt" },
{ name: "Google Cloud Console", url: "https://console.cloud.google.com" }, {
name: "Google Cloud Console",
url: "https://console.cloud.google.com/robots.txt",
},
{ name: "Microsoft Azure", url: "https://portal.azure.com" }, { name: "Microsoft Azure", url: "https://portal.azure.com" },
{ name: "Cloudflare", url: "https://www.cloudflare.com" }, { name: "Cloudflare", url: "https://www.cloudflare.com" },
{ name: "Fastly CDN", url: "https://www.fastly.com" }, { name: "Fastly CDN", url: "https://www.fastly.com" },
{ name: "Akamai", url: "https://www.akamai.com" }, { name: "Akamai", url: "https://www.akamai.com/robots.txt" },
{ name: "Google", url: "https://www.google.com" }, { name: "Google", url: "https://www.google.com" },
{ name: "GitHub", url: "https://github.com" }, { name: "GitHub", url: "https://github.com" },
{ name: "B2", url: "https://api.backblazeb2.com" }, { name: "B2", url: "https://api.backblazeb2.com/robots.txt" },
// Chrome keeps the connection to an S3 region open after a HEAD check,
// but closes it after most GET checks.
{ {
name: "S3 af-south-1 (Cape Town)", name: "S3 af-south-1 (Cape Town)",
url: "https://s3.af-south-1.amazonaws.com", url: "https://s3.af-south-1.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 eu-west-2 (London)", name: "S3 eu-west-2 (London)",
url: "https://s3.eu-west-2.amazonaws.com", url: "https://s3.eu-west-2.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 me-south-1 (Bahrain)", name: "S3 me-south-1 (Bahrain)",
url: "https://s3.me-south-1.amazonaws.com", url: "https://s3.me-south-1.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 ap-northeast-1 (Tokyo)", name: "S3 ap-northeast-1 (Tokyo)",
url: "https://s3.ap-northeast-1.amazonaws.com", url: "https://s3.ap-northeast-1.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 ap-southeast-1 (Singapore)", name: "S3 ap-southeast-1 (Singapore)",
url: "https://s3.ap-southeast-1.amazonaws.com", url: "https://s3.ap-southeast-1.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 ap-southeast-2 (Sydney)", name: "S3 ap-southeast-2 (Sydney)",
url: "https://s3.ap-southeast-2.amazonaws.com", url: "https://s3.ap-southeast-2.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 us-west-2 (Oregon)", name: "S3 us-west-2 (Oregon)",
url: "https://s3.us-west-2.amazonaws.com", url: "https://s3.us-west-2.amazonaws.com/robots.txt",
method: "HEAD",
}, },
{ {
name: "S3 sa-east-1 (São Paulo)", name: "S3 sa-east-1 (São Paulo)",
url: "https://s3.sa-east-1.amazonaws.com", url: "https://s3.sa-east-1.amazonaws.com/robots.txt",
method: "HEAD",
}, },
// Hetzner regional speed test servers — genuine per-DC endpoints // Hetzner regional speed test servers — genuine per-DC endpoints
{ {
@@ -271,6 +286,7 @@ export class HostState {
constructor(host, pinned = false) { constructor(host, pinned = false) {
this.name = host.name; this.name = host.name;
this.url = host.url; this.url = host.url;
this.method = host.method;
// Each entry is either a check's result, { timestamp, latency, // Each entry is either a check's result, { timestamp, latency,
// error }, or a round skipped while paused, { timestamp, // error }, or a round skipped while paused, { timestamp,
// latency: null, paused: true }. // latency: null, paused: true }.
@@ -567,16 +583,18 @@ export class Reporter {
// --- Latency Measurement ----------------------------------------------------- // --- Latency Measurement -----------------------------------------------------
// Checks one target. The check times out after CONFIG.requestTimeout; the // Checks one target with the given HTTP method, GET when none is given. The
// caller can give it up sooner through the optional signal, which also ends // check times out after CONFIG.requestTimeout; the caller can give it up
// it as a timeout. A failed check's reason says what went wrong and after // sooner through the optional signal, which also ends it as a timeout. A
// how long; a check that answered has none. // failed check's reason says what went wrong and after how long; a check
// that answered has none.
// //
// The URL is fetched as written, with nothing added to it: the Hetzner // The Hetzner speed-test servers close the connection without an answer
// speed-test servers close the connection without an answer when the URL // when the URL has a query string or the request has a Referer header. So
// has a query string, and cache: "no-store" keeps the browser's cache out // the URL is fetched as written, and referrerPolicy: "no-referrer" sends
// of the measurement. // no Referer header, wherever the page is served from. cache: "no-store"
export async function measureLatency(url, signal) { // keeps the browser's cache out of the measurement.
export async function measureLatency(url, signal, method = "GET") {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout( const timeoutId = setTimeout(
() => controller.abort(), () => controller.abort(),
@@ -588,9 +606,10 @@ export async function measureLatency(url, signal) {
try { try {
await fetch(url, { await fetch(url, {
method: "GET", method,
mode: "no-cors", mode: "no-cors",
cache: "no-store", cache: "no-store",
referrerPolicy: "no-referrer",
signal: controller.signal, signal: controller.signal,
}); });
const latency = Math.round(performance.now() - start); const latency = Math.round(performance.now() - start);
@@ -1290,7 +1309,7 @@ export async function tick(state, signal, onOffline) {
// the round started. // the round started.
await Promise.all( await Promise.all(
state.allHosts.map(async (host) => { state.allHosts.map(async (host) => {
const r = await measureLatency(host.url, signal); const r = await measureLatency(host.url, signal, host.method);
if (state.paused || signal.aborted || state.tickCount === 0) { if (state.paused || signal.aborted || state.tickCount === 0) {
return; return;
} }
@@ -1363,7 +1382,7 @@ function startRecoveryProbe(state, startRounds) {
const checks = new AbortController(); const checks = new AbortController();
state._recoveryProbeChecks = checks; state._recoveryProbeChecks = checks;
for (const host of canaries) { for (const host of canaries) {
measureLatency(host.url, checks.signal).then((r) => { measureLatency(host.url, checks.signal, host.method).then((r) => {
if (r.error !== null || checks.signal.aborted) return; if (r.error !== null || checks.signal.aborted) return;
log.notice("Recovery probe: connectivity detected"); log.notice("Recovery probe: connectivity detected");
stopRecoveryProbe(state); stopRecoveryProbe(state);