10 Commits
Author SHA1 Message Date
clawbot 067a34f56d feat(frontend): post collected samples to /api/v1/reports (closes #53)
check / check (push) Successful in 12s
A Reporter beside AppState POSTs each host's unreported, non-paused
samples to /api/v1/reports every reportInterval (default 60s).
buildReport is an exported pure function of host state; init() runs only
when #app exists, so a test can import the module. A per-host mark
advances only on a delivered POST. At most one report POST is pending at
a time and it is abandoned after half the interval, so a slow POST never
overlaps the next report and a mark never moves backwards. The samples of
an abandoned POST are sent again at the next interval, so a backend that
stored them but answered late receives them twice. Failure is quiet and
never blocks probing. vite.config.js proxies /api for yarn dev.

Model: opus-5-5
2026-09-28 21:10:27 +00:00
clawbot 503399e020 fix(backend): report ingest correctness: 500 on a refused report, 413 on oversize, global body cap (closes #23)
check / check (push) Successful in 10s
A report the buffer refuses now returns 500 instead of a false `ok`.
Reports reach disk later, so a failed disk write is still answered 200
and shows in the log, and at shutdown as a failed stop with a non-zero
exit. An over-limit body returns 413; malformed JSON stays 400. A
MaxBodyBytes middleware caps every route at 1 MiB; a route group can
only lower that limit. The raw geo blob is no longer logged; client_id,
timestamp and decode error text are cut to 128 bytes before logging.
Panic recovery logs the panic value and stack through slog.

Model: opus-5-5
2026-09-28 20:39:39 +02:00
clawbot 7a1ee6e5a8 lint: adopt org-standard .golangci.yml and golangci-lint v2.12.2 (closes #14)
check / check (push) Failing after 1s
The old backend/.golangci.yml declared version "2" but used v1 schema
keys, so under v2 it never validated and its thresholds were inert: the
linter ran at defaults. Replace it verbatim with the org-standard file,
repin the Dockerfile.backend lint stage to golangci-lint v2.12.2, and
assert the config's sha256 as the first step of the backend lint target
so it cannot silently drift again -- a local hash check, no network.

The standard config surfaces findings only in the tests: the repeated
IP literals in middleware_test.go become named constants (goconst) and
its request switches to NewRequestWithContext (noctx). reportbuf.go's
gosec suppression gains a plain justification comment. The rest of the
backend, including the fx-based server lifecycle, is already clean.
TODO.md updated.

Model: opus-4-8
2026-09-21 19:30:06 +02:00
clawbot d7cf010e00 fix(server): shut down through fx so buffered reports flush (closes #22)
check / check (push) Successful in 1m11s
The server ran os.Exit at the end of its own goroutine, racing fx's
teardown and sometimes killing the process before reportbuf's OnStop
flushed — silently losing a full flush window of telemetry on every
restart, at exit 0. Shutdown now goes through fx.Shutdowner, so every
OnStop runs in order.

The http.Server is built synchronously in OnStart before the serving
goroutine, so shutdown can no longer race or nil-deref it. A listen
failure exits non-zero via fx.ExitCode(1). reportbuf's OnStop is guarded
by sync.Once. writeTimeout now exceeds the chi per-request budget so that
budget is reachable. Dead startupTime, exitCode, and cancelFunc fields
are gone. A new test asserts a buffered report reaches disk after the
lifecycle stops.

Model: opus-4-8
2026-09-21 18:47:12 +02:00
clawbot 14eb376d79 test: automated responsive-layout harness (closes #13)
check / check (push) Failing after 1s
`make frontend-viewport-test` builds `dist/`, serves it from the same
digest-pinned nginx image and nginx.conf the shipping container uses, and
drives a digest-pinned headless Chrome over CDP. Viewport widths are derived
from the app's own @media breakpoints rather than a list of phone models: each
breakpoint is tested one pixel below, on, and above, plus four anchor
viewports. Assertions are on computed layout — horizontal overflow, off-screen
elements, clipped text, 44x44 tap targets, host-row reflow — not screenshots,
and each check declares the minimum elements it must find so a stale selector
fails instead of passing blind against a page it is not measuring. Kept out of
`make check`: it needs Docker and takes minutes. Proven able to fail before
being trusted.

Model: opus-4-8
2026-09-21 18:29:20 +02:00
clawbot f3895789d2 feat(backend): server hardening: timeouts, security headers, trusted-proxy client IP (closes #19)
check / check (push) Failing after 1s
Add ReadHeaderTimeout and IdleTimeout to the http.Server as named constants beside the existing timeouts. Add a SecurityHeaders middleware (HSTS, a JSON-API CSP of default-src 'none'; frame-ancestors 'none', X-Frame-Options DENY, nosniff, Referrer-Policy, Permissions-Policy), registered before CORS so preflight responses carry it. Resolve the client IP from X-Forwarded-For / X-Real-IP only when the direct peer is in the trusted-proxy allowlist (loopback plus RFC1918 by default, configurable via TRUSTED_PROXIES); an untrusted peer's forwarded headers are ignored. Uses net/netip; no new dependency.

Model: opus-4-8 (implementation and review); claude-fable-5 (merge)
2026-09-21 15:05:25 +02:00
clawbot f7c7f92e27 fix(frontend): meet the 44x44 minimum tap target on every control (closes #43)
check / check (push) Successful in 10s
2026-08-10 16:12:00 +02:00
clawbot 852a11eec2 fix: wrap per-host status line so 320px viewport does not scroll (closes #42)
check / check (push) Has been cancelled
2026-08-10 16:07:28 +02:00
clawbot 25a852d35c build: Dockerfile.backend multistage lint stage (closes #17)
check / check (push) Has been cancelled
2026-08-10 16:04:48 +02:00
clawbot a644efe9ff chore: root .editorconfig and hardened .gitignore (closes #15)
check / check (push) Successful in 45s
2026-08-10 15:47:49 +02:00
37 changed files with 2983 additions and 237 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules node_modules
dist dist
tmp
.DS_Store .DS_Store
*.log *.log
.claude .claude
+26 -2
View File
@@ -1,4 +1,28 @@
node_modules/ # OS
dist/
.DS_Store .DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Node
node_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Build output
dist/
tmp/
# Logs
*.log *.log
+1
View File
@@ -1,5 +1,6 @@
backend/ backend/
dist/ dist/
node_modules/ node_modules/
tmp/
yarn.lock yarn.lock
.claude/ .claude/
+33 -8
View File
@@ -1,25 +1,50 @@
# Lint stage — fast feedback on formatting and lint issues. The
# golangci/golangci-lint image ships Go, gofmt, make and the linter, so
# nothing is installed here.
# golangci/golangci-lint:v2.12.2 (2026-08-10)
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.25-alpine (2026-02-27) # golang:1.25-alpine (2026-02-27)
FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
RUN apk add --no-cache git make gcc musl-dev RUN apk add --no-cache make
# golangci-lint v2.7.2 (2026-02-27) WORKDIR /src
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@9f61b0f53f80672872fced07b6874397c3ed197b
# Force BuildKit to run the lint stage before proceeding. BuildKit runs
# stages in parallel by default; without this no-op copy a lint failure
# would not gate compilation.
COPY --from=lint /src/go.sum /dev/null
WORKDIR /repo/backend
COPY backend/go.mod backend/go.sum ./ COPY backend/go.mod backend/go.sum ./
RUN go mod download RUN go mod download
COPY .git /repo/.git
COPY backend/ . COPY backend/ .
RUN make check RUN make test
RUN make build
# The build is driven through the Makefile so there is exactly one
# definition of the build command; it expands to
# CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=... -X main.Buildarch=..."
# VERSION is handed over in the environment rather than as a make
# variable so it still reaches the build if the target is ever turned
# into a shim around a script.
ARG VERSION=dev
RUN VERSION="${VERSION}" make build
# Runtime stage
# alpine:3.23 (2026-02-27) # alpine:3.23 (2026-02-27)
FROM alpine:3.23@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 FROM alpine:3.23@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
RUN apk add --no-cache ca-certificates RUN apk add --no-cache ca-certificates
COPY --from=builder /repo/backend/netwatch-server /usr/local/bin/netwatch-server COPY --from=builder /src/netwatch-server /usr/local/bin/netwatch-server
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["netwatch-server"] ENTRYPOINT ["netwatch-server"]
+8 -1
View File
@@ -1,4 +1,5 @@
.PHONY: bootstrap setup dev test lint fmt fmt-check check docker hooks .PHONY: bootstrap setup dev test lint fmt fmt-check check \
frontend-viewport-test docker hooks
# Standard targets are thin shims; the implementations live in script/ # Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -28,6 +29,12 @@ fmt-check:
check: check:
@script/check @script/check
# Responsive-layout verification in a containerised browser. Kept out of
# check: it needs Docker and takes minutes, where make test has to stay
# under 20 seconds.
frontend-viewport-test:
@script/frontend-viewport-test
docker: docker:
@script/docker @script/docker
+29
View File
@@ -23,6 +23,9 @@ docker build -t netwatch .
docker run -p 8080:8080 netwatch docker run -p 8080:8080 netwatch
``` ```
`yarn dev` proxies `/api` to `http://127.0.0.1:8080`, so a locally running
`netwatch-server` (see `backend/`) receives the reports the page posts.
## Entrypoints ## Entrypoints
This repository adheres to the This repository adheres to the
@@ -41,11 +44,25 @@ provide:
- `script/fmt` — format all files (writes) - `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check - `script/check` — run test, lint, and fmt-check
- `script/frontend-viewport-test` — responsive-layout verification of the built
frontend in a containerised headless Chrome (see
[test/viewport/README.md](test/viewport/README.md)). Not part of
`script/check`: it needs Docker and takes minutes.
- `script/docker` — build the Docker image tagged via `script/projectname` - `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .` - `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/precommit` — run by the git pre-commit hook; runs `script/check`
- `script/install-precommit` — install the git pre-commit hook - `script/install-precommit` — install the git pre-commit hook
## Responsive layout
The narrow-viewport layout lives in the `max-width: 768px` media block in
`src/styles.css`. It is verified automatically by `make frontend-viewport-test`,
which drives a digest-pinned headless Chrome against the built `dist/` and
asserts on computed layout at widths derived from that CSS — one pixel either
side of every breakpoint it declares, plus a 320px floor, a desktop baseline and
two landscape sizes. See [test/viewport/README.md](test/viewport/README.md) for
what it covers and what it genuinely cannot.
## Rationale ## Rationale
When debugging network issues, it's useful to have a persistent at-a-glance view When debugging network issues, it's useful to have a persistent at-a-glance view
@@ -71,6 +88,18 @@ code lives in `src/main.js` with a class-based architecture:
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`, - **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`,
pushes samples, redraws UI. When paused, pushes blank markers (no probes, no pushes samples, redraws UI. When paused, pushes blank markers (no probes, no
false outage) false outage)
- **`Reporter`**: Posts collected samples to the backend
### Reporting
Every `reportInterval` (default 60s) the page POSTs a JSON report to the
same-origin path `/api/v1/reports`: a random per-browser `clientId` kept in
`localStorage`, `geo` sent as null, and each host's unreported, non-paused
samples (timestamp, latency, error). A per-host high-water mark makes every
report a delta, so only new samples are sent; the mark advances only on a
delivered report, and while paused nothing is sent. Delivery failure is quiet —
one debug-log line per outage, retried at the next interval, never blocking
probing. The report-building step is a pure function of host state.
### Monitoring targets ### Monitoring targets
+84 -7
View File
@@ -10,23 +10,91 @@
# Status # Status
pre-1.0. No git tags. Backend work in flight on feat/reportbuf-storage (dirty: pre-1.0. No git tags. `feat/reportbuf-storage` is merged; the backend, the CI
src/main.js). Frontend is functional; backend is new and unmerged. workflow, and the backend repo standard files are all on `main`. Frontend and
backend are both functional. Working toward the 1.0.0 milestone by closing the
remaining repo-compliance issues on the tracker.
# Next Step # Next Step
Land feat/reportbuf-storage: finish the in-progress src/main.js change, get make Confirm the `.gitea/workflows/check.yml` run is green (main always green
check green, and merge the branch to main. The branch adds the backend (buffered policy). The workflow file is already on `main`; what is unverified is that its
zstd-compressed report storage), the CI workflow, and backend repo standard latest run passes.
files, so merging it also closes most compliance gaps.
# Completed Steps # Completed Steps
- 2026-09-28: frontend reporting client (issue #53): a `Reporter` class posts
collected samples to `/api/v1/reports` every `reportInterval` (default 60s) as
a per-host delta, with the report-building step a pure exported function of
host state; a per-host mark advances only on a delivered POST; at most one
report POST is pending at a time and it is abandoned after half the interval,
so a slow POST never overlaps the next report and a mark never moves
backwards; the samples of an abandoned POST are sent again at the next
interval, so a backend that stored them but answered late receives them twice;
the per-browser client id works in insecure (plain-HTTP) contexts;
`vite.config.js` proxies `/api` to the local backend for `yarn dev`
- 2026-09-28: report ingest correctness (issue #23): a storage failure now
returns 500 instead of a false `ok`; oversize bodies return 413 (distinguished
from malformed JSON, which stays 400); a `MaxBodyBytes` middleware caps every
route, not just the report route; the raw attacker-controlled `geo` blob is no
longer logged (only its length), and `client_id`, `timestamp` and decode error
text are length-bounded before logging; a `decodeJSON` handler helper was
added; panic recovery now routes the stack through slog instead of chi's
plain-text stderr; and writing a report file now returns its error, so a
failed final flush on shutdown makes the process exit non-zero instead of
losing the buffered reports silently
- 2026-09-21: shutdown lifecycle correctness. The process now shuts down through
fx instead of `os.Exit`, so every component's `OnStop` runs and buffered
reports are flushed to disk on `SIGTERM` — previously a full flush window of
telemetry was silently lost on every restart. The `http.Server` is now built
before its serving goroutine starts, so shutdown can no longer race or
nil-deref it; a listen failure exits non-zero via `fx.Shutdowner`; `reportbuf`
`OnStop` is idempotent; and `writeTimeout` now exceeds the chi per-request
budget so that budget is actually reachable. Dead `startupTime`, `exitCode`,
and `cancelFunc` fields were removed
- 2026-09-21: backend HTTP hardening (issue #19): added `ReadHeaderTimeout` and
`IdleTimeout` to the server, a `SecurityHeaders` middleware (HSTS, tight CSP,
frame/sniff/referrer/permissions headers) registered before CORS, and
trusted-proxy client IP resolution honouring `X-Forwarded-For` / `X-Real-IP`
only from a `TRUSTED_PROXIES` allowlist (loopback plus RFC1918 by default)
- 2026-08-10: adopted the org-standard `backend/.golangci.yml` verbatim and
moved the pinned golangci-lint from v2.7.2 to v2.12.2 (the `lint` stage of
`Dockerfile.backend` now pins the `golangci/golangci-lint:v2.12.2` image by
digest); the previous config declared `version: "2"` but used v1 schema keys,
so every threshold in it was inert and its green result was meaningless.
`backend/Makefile`'s `lint` target now asserts the config's sha256 against the
canonical file first, so drift from the org standard fails the build instead
of silently degrading to defaults
- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap
target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
viewports, `#pause-btn`). The pin button's hit area grows via matching
negative margins, so its layout footprint and row density are unchanged
- 2026-08-10: per-host status line wraps below the 768px breakpoint instead of
forcing horizontal page scroll at 320px
- 2026-08-09: `Dockerfile.backend` reworked to the mandated Go multistage
lint-stage pattern: separate `lint` stage on the hash-pinned
`golangci/golangci-lint` image, `COPY --from=lint` stage dependency,
`CGO_ENABLED=0` static build driven by `ARG VERSION`, and no more `COPY .git`
- 2026-08-09: dotfile compliance — lifted `backend/.editorconfig` to the repo
root so `root = true` covers the frontend too, and replaced `.gitignore` with
the org model (OS, editor, node, and environment/secrets sections) plus this
repo's `dist/` and `*.log`. `.env`, `.env.*`, `*.pem`, and `*.key` are now
ignored repo-wide, not just under `backend/`. Excluding `.git` from
`.dockerignore` stays deferred: both images read git metadata at build time
(`COPY .git` in `Dockerfile.backend`, `git rev-parse` in `vite.config.js`)
- 2026-08-09: automated responsive-layout harness
(`make frontend-viewport-test`): digest-pinned headless Chrome driven over CDP
against the built `dist/`, viewport widths derived from the breakpoints in
`src/styles.css` ([#13](https://git.eeqj.de/sneak/netwatch/issues/13)). Every
check carries a presence guard so none of them can pass against a page it is
not actually measuring. Found two real layout defects, filed as
[#42](https://git.eeqj.de/sneak/netwatch/issues/42) and
[#43](https://git.eeqj.de/sneak/netwatch/issues/43)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section shims, README Entrypoints section
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow - 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
and backend repo standard files; backend Dockerfile fixed (Go 1.25, and backend repo standard files; backend Dockerfile fixed (Go 1.25,
golangci-lint) and moved to repo root (feat/reportbuf-storage, unmerged) golangci-lint) and moved to repo root (feat/reportbuf-storage)
- 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing - 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing
fixes; nginx config extracted; port hardcoded to 8080 fixes; nginx config extracted; port hardcoded to 8080
- 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix, - 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix,
@@ -39,9 +107,18 @@ files, so merging it also closes most compliance gaps.
# Future Steps # Future Steps
- Wire `script/frontend-viewport-test` into CI as its own step (deliberately not
part of `make check` today; the decision has real CI-runtime cost and is
tracked separately)
- Compliance top-up as one small commit: add .editorconfig and add the hooks - Compliance top-up as one small commit: add .editorconfig and add the hooks
target to the Makefile target to the Makefile
- After merge, confirm .gitea/workflows/check.yml is on main and CI is green - After merge, confirm .gitea/workflows/check.yml is on main and CI is green
(main always green policy) (main always green policy)
- Decide what to do with untracked resume.sh: commit it, gitignore it, or delete - Decide what to do with untracked resume.sh: commit it, gitignore it, or delete
it it
- Upstream fix needed in `sneak/prompts`: the org-standard `.golangci.yml`
enables `gomodguard`, which golangci-lint v2.12.2 reports as deprecated since
v2.12.0 and replaced by `gomodguard_v2`, so every backend lint run prints a
deprecation warning. The file is standardized and must never be edited in this
repo, so nothing can be done here beyond tracking it — tracked at
<https://git.eeqj.de/sneak/netwatch/issues/41>
+5 -3
View File
@@ -1,5 +1,9 @@
version: "2" version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run: run:
timeout: 5m timeout: 5m
modules-download-mode: readonly modules-download-mode: readonly
@@ -14,8 +18,7 @@ linters:
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings:
linters-settings:
lll: lll:
line-length: 88 line-length: 88
funlen: funlen:
@@ -27,6 +30,5 @@ linters-settings:
threshold: 100 threshold: 100
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
+27 -11
View File
@@ -1,30 +1,46 @@
UNAME_S := $(shell uname -s) # VERSION is overridable (the Dockerfile passes its ARG VERSION in) and
VERSION := $(shell git describe --always --dirty) # degrades to "dev" when git is unavailable or there is no .git — the
# build must not depend on the repository history being in the build
# context.
VERSION ?= $(shell { git describe --always --dirty; } 2>/dev/null || echo dev)
BUILDARCH := $(shell uname -m) BUILDARCH := $(shell uname -m)
BINARY := netwatch-server BINARY := netwatch-server
GOLDFLAGS += -s -w
GOLDFLAGS += -X main.Version=$(VERSION) GOLDFLAGS += -X main.Version=$(VERSION)
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH) GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
ifeq ($(UNAME_S),Darwin) # macOS ships shasum rather than sha256sum.
GOFLAGS := -ldflags "$(GOLDFLAGS)" SHA256SUM := $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo shasum -a 256)
else
GOFLAGS = -ldflags "-linkmode external -extldflags -static $(GOLDFLAGS)" # .golangci.yml is standardized org-wide and must never be edited here
endif # (REPO_POLICIES.md). Its last silent drift replaced the v2 schema with
# v1 keys, which left every threshold in the file inert while the build
# stayed green. The lint target therefore asserts the file still matches
# the canonical copy byte for byte. The check is a local hash comparison:
# no network, no remote schema, nothing unpinned in the build path.
GOLANGCI_CONFIG_SHA256 := 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb
.PHONY: all build test lint fmt fmt-check check docker hooks run clean .PHONY: all build test lint fmt fmt-check check docker hooks run clean
all: build all: build
build: ./$(BINARY) build:
CGO_ENABLED=0 go build -trimpath -ldflags "$(GOLDFLAGS)" \
./$(BINARY): $(shell find . -name '*.go' -type f) go.mod go.sum -o ./$(BINARY) ./cmd/netwatch-server/
go build -o $@ $(GOFLAGS) ./cmd/netwatch-server/
test: test:
timeout 30 go test ./... timeout 30 go test ./...
lint: lint:
@actual=$$($(SHA256SUM) .golangci.yml | cut -d' ' -f1); \
if [ "$$actual" != "$(GOLANGCI_CONFIG_SHA256)" ]; then \
echo ".golangci.yml has drifted from the org standard."; \
echo " expected $(GOLANGCI_CONFIG_SHA256)"; \
echo " actual $$actual"; \
echo "Restore it verbatim from sneak/prompts; do not edit it."; \
exit 1; \
fi
golangci-lint run ./... golangci-lint run ./...
fmt: fmt:
+7 -1
View File
@@ -43,10 +43,16 @@ Internal packages in `internal/` follow standard Go project layout:
### Configuration ### Configuration
| Variable | Default | Description | | Variable | Default | Description |
| ---------- | ------------------ | --------------------------------- | | ----------------- | -------------------- | -------------------------------------------------------------------------------------------------------- |
| `PORT` | `8080` | HTTP listen port | | `PORT` | `8080` | HTTP listen port |
| `DATA_DIR` | `./data/reports` | Directory for compressed reports | | `DATA_DIR` | `./data/reports` | Directory for compressed reports |
| `DEBUG` | `false` | Enable debug logging | | `DEBUG` | `false` | Enable debug logging |
| `TRUSTED_PROXIES` | loopback + RFC1918 | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution |
`TRUSTED_PROXIES` defaults to `127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`.
The loopback entries cover the reverse proxy that shares the container; the
RFC1918 ranges match `nginx.conf`. A request whose direct peer is outside this
set has its forwarded headers ignored, and the direct peer is logged instead.
### Report storage ### Report storage
+28
View File
@@ -5,6 +5,7 @@ package config
import ( import (
"errors" "errors"
"log/slog" "log/slog"
"strings"
"sneak.berlin/go/netwatch/internal/globals" "sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger" "sneak.berlin/go/netwatch/internal/logger"
@@ -14,6 +15,14 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
) )
// defaultTrustedProxies lists the networks whose forwarded
// headers are honoured by default. It covers the RFC1918
// ranges (to match nginx.conf) plus IPv4 and IPv6 loopback,
// because the reverse proxy shares the container and reaches
// the backend over loopback.
const defaultTrustedProxies = "127.0.0.1/32,::1/128," +
"10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
// Params defines the dependencies for Config. // Params defines the dependencies for Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -30,6 +39,7 @@ type Config struct {
MetricsUsername string MetricsUsername string
Port int Port int
SentryDSN string SentryDSN string
TrustedProxies []string
log *slog.Logger log *slog.Logger
params *Params params *Params
} }
@@ -56,6 +66,7 @@ func New(
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("TRUSTED_PROXIES", defaultTrustedProxies)
err := viper.ReadInConfig() err := viper.ReadInConfig()
if err != nil { if err != nil {
@@ -73,6 +84,7 @@ func New(
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
Port: viper.GetInt("PORT"), Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"), SentryDSN: viper.GetString("SENTRY_DSN"),
TrustedProxies: splitList(viper.GetString("TRUSTED_PROXIES")),
log: log, log: log,
params: &params, params: &params,
} }
@@ -84,3 +96,19 @@ func New(
return s, nil return s, nil
} }
// splitList turns a comma-separated setting into a trimmed
// slice, dropping empty entries.
func splitList(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
+13
View File
@@ -0,0 +1,13 @@
package handlers
import "log/slog"
// MaxLoggedFieldBytes exposes the log bound to the external tests.
const MaxLoggedFieldBytes = maxLoggedFieldBytes
// NewForTest builds a Handlers around a report sink and logger,
// bypassing the fx graph so handler behaviour (including the
// storage failure path) is exercisable in unit tests.
func NewForTest(buf reportAppender, log *slog.Logger) *Handlers {
return &Handlers{buf: buf, log: log}
}
+20 -1
View File
@@ -18,6 +18,13 @@ import (
const jsonContentType = "application/json; charset=utf-8" const jsonContentType = "application/json; charset=utf-8"
// reportAppender is the subset of the report buffer the handlers
// depend on. Defining it here keeps the storage failure path
// exercisable with a stub in tests.
type reportAppender interface {
Append(v any) error
}
// Params defines the dependencies for Handlers. // Params defines the dependencies for Handlers.
type Params struct { type Params struct {
fx.In fx.In
@@ -30,7 +37,7 @@ type Params struct {
// Handlers provides HTTP handler factories for all endpoints. // Handlers provides HTTP handler factories for all endpoints.
type Handlers struct { type Handlers struct {
buf *reportbuf.Buffer buf reportAppender
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
log *slog.Logger log *slog.Logger
params *Params params *Params
@@ -72,3 +79,15 @@ func (s *Handlers) respondJSON(
} }
} }
} }
// decodeJSON decodes the request body into v. The body is
// expected to already be bounded by the body-size middleware, so
// a caller can distinguish an over-limit body from malformed
// JSON by testing the returned error for *http.MaxBytesError.
func (s *Handlers) decodeJSON(
_ http.ResponseWriter,
r *http.Request,
v any,
) error {
return json.NewDecoder(r.Body).Decode(v)
}
+59 -23
View File
@@ -2,10 +2,14 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
) )
const maxReportBodyBytes = 1 << 20 // 1 MiB // maxLoggedFieldBytes bounds untrusted text (string fields,
// decode error text) before it is logged, so a caller cannot
// inflate log volume with an oversized value.
const maxLoggedFieldBytes = 128
type reportSample struct { type reportSample struct {
T int64 `json:"t"` T int64 `json:"t"`
@@ -35,48 +39,80 @@ func (s *Handlers) HandleReport() http.HandlerFunc {
} }
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(
w, r.Body, maxReportBodyBytes,
)
var rpt report var rpt report
err := json.NewDecoder(r.Body).Decode(&rpt) err := s.decodeJSON(w, r, &rpt)
if err != nil { if err != nil {
s.log.Error("failed to decode report",
"error", err,
)
s.respondJSON(w, r, s.respondJSON(w, r,
&response{Status: "error"}, &response{Status: "error"},
http.StatusBadRequest, s.decodeErrorStatus(err),
) )
return return
} }
s.logReportReceived(rpt)
err = s.buf.Append(rpt)
if err != nil {
s.log.Error("failed to buffer report", "error", err)
s.respondJSON(w, r,
&response{Status: "error"},
http.StatusInternalServerError,
)
return
}
s.respondJSON(w, r, &response{Status: "ok"}, http.StatusOK)
}
}
// decodeErrorStatus logs a report decode failure and returns the
// status to send: 413 when the body exceeded the size limit,
// otherwise 400 for malformed JSON.
func (s *Handlers) decodeErrorStatus(err error) int {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
s.log.Warn("report body too large", "limit_bytes", tooLarge.Limit)
return http.StatusRequestEntityTooLarge
}
// The decoder's error text can quote request bytes (a whole
// oversized number, for example), so it is bounded too.
s.log.Error("failed to decode report",
"error", boundedForLog(err.Error()),
)
return http.StatusBadRequest
}
// logReportReceived logs an accepted report. Untrusted fields are
// bounded (client_id, timestamp) or reduced to a length
// (geo_bytes) so the raw attacker-controlled body never reaches
// the log.
func (s *Handlers) logReportReceived(rpt report) {
totalSamples := 0 totalSamples := 0
for _, h := range rpt.Hosts { for _, h := range rpt.Hosts {
totalSamples += len(h.History) totalSamples += len(h.History)
} }
s.log.Info("report received", s.log.Info("report received",
"client_id", rpt.ClientID, "client_id", boundedForLog(rpt.ClientID),
"timestamp", rpt.Timestamp, "timestamp", boundedForLog(rpt.Timestamp),
"host_count", len(rpt.Hosts), "host_count", len(rpt.Hosts),
"total_samples", totalSamples, "total_samples", totalSamples,
"geo", string(rpt.Geo), "geo_bytes", len(rpt.Geo),
) )
}
bufErr := s.buf.Append(rpt) // boundedForLog truncates an untrusted string to a fixed byte
if bufErr != nil { // bound so an attacker-controlled field cannot dominate the log.
s.log.Error("failed to buffer report", func boundedForLog(s string) string {
"error", bufErr, if len(s) > maxLoggedFieldBytes {
) return s[:maxLoggedFieldBytes]
} }
s.respondJSON(w, r, return s
&response{Status: "ok"},
http.StatusOK,
)
}
} }
+212
View File
@@ -0,0 +1,212 @@
package handlers_test
import (
"bytes"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/middleware"
)
var errStorageFailed = errors.New("storage failed")
// stubAppender drives the storage success/failure path without a
// real buffer or disk.
type stubAppender struct {
err error
}
func (s stubAppender) Append(any) error { return s.err }
func newTestHandlers(buf stubAppender, out io.Writer) *handlers.Handlers {
return handlers.NewForTest(buf, slog.New(slog.NewJSONHandler(out, nil)))
}
func decodeStatus(t *testing.T, body []byte) string {
t.Helper()
var resp struct {
Status string `json:"status"`
}
err := json.Unmarshal(body, &resp)
if err != nil {
t.Fatalf("response body not JSON: %v (%q)", err, body)
}
return resp.Status
}
func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
t.Parallel()
h := newTestHandlers(stubAppender{err: errStorageFailed}, io.Discard)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
)
h.HandleReport().ServeHTTP(rec, req)
if rec.Code < 500 {
t.Fatalf("storage failure status = %d, want a 5xx", rec.Code)
}
if got := decodeStatus(t, rec.Body.Bytes()); got != "error" {
t.Fatalf("status field = %q, want %q", got, "error")
}
}
func TestHandleReportMalformedJSONIs400(t *testing.T) {
t.Parallel()
h := newTestHandlers(stubAppender{}, io.Discard)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodPost, "/api/v1/reports",
strings.NewReader(`{not json`),
)
h.HandleReport().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("malformed status = %d, want %d",
rec.Code, http.StatusBadRequest)
}
}
func TestHandleReportOversizeIs413(t *testing.T) {
t.Parallel()
const limit = 32
h := newTestHandlers(stubAppender{}, io.Discard)
handler := (&middleware.Middleware{}).MaxBodyBytes(limit)(
h.HandleReport(),
)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"clientId":"`+strings.Repeat("x", 200)+`"}`),
)
// No declared length, so only the middleware's read cap can
// stop this body.
req.ContentLength = -1
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversize status = %d, want %d",
rec.Code, http.StatusRequestEntityTooLarge)
}
}
func TestHandleReportDoesNotLogRawGeo(t *testing.T) {
t.Parallel()
const sentinel = "SENSITIVE-GEO-BLOB"
var logbuf bytes.Buffer
h := newTestHandlers(stubAppender{}, &logbuf)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodPost, "/api/v1/reports",
strings.NewReader(
`{"clientId":"c1","geo":{"raw":"`+sentinel+`"},"hosts":[]}`,
),
)
h.HandleReport().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if strings.Contains(logbuf.String(), sentinel) {
t.Fatal("raw geo bytes were written to the log")
}
if !strings.Contains(logbuf.String(), "geo_bytes") {
t.Fatal("expected a bounded geo_bytes field in the log")
}
}
func TestHandleReportLogsClientIDCutToBound(t *testing.T) {
t.Parallel()
long := strings.Repeat("c", 2*handlers.MaxLoggedFieldBytes)
var logbuf bytes.Buffer
h := newTestHandlers(stubAppender{}, &logbuf)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodPost, "/api/v1/reports",
strings.NewReader(
`{"clientId":"`+long+`","timestamp":"`+long+`","hosts":[]}`,
),
)
h.HandleReport().ServeHTTP(rec, req)
var logged map[string]any
err := json.Unmarshal(logbuf.Bytes(), &logged)
if err != nil {
t.Fatalf("log line not JSON: %v (%q)", err, logbuf.String())
}
want := long[:handlers.MaxLoggedFieldBytes]
if logged["client_id"] != want {
t.Fatalf("logged client_id not cut to %d bytes: %q",
handlers.MaxLoggedFieldBytes, logged["client_id"])
}
if logged["timestamp"] != want {
t.Fatalf("logged timestamp not cut to %d bytes: %q",
handlers.MaxLoggedFieldBytes, logged["timestamp"])
}
}
func TestHandleReportDecodeErrorLogIsBounded(t *testing.T) {
t.Parallel()
// A number too large for its int64 field makes the decoder's
// error text quote the whole number.
huge := strings.Repeat("9", 2*handlers.MaxLoggedFieldBytes)
var logbuf bytes.Buffer
h := newTestHandlers(stubAppender{}, &logbuf)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"hosts":[{"history":[{"t":`+huge+`}]}]}`),
)
h.HandleReport().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if strings.Contains(logbuf.String(), huge) {
t.Fatal("the whole oversized number was written to the log")
}
}
@@ -0,0 +1,28 @@
package middleware
import (
"log/slog"
"net/http"
"net/netip"
)
// Test-only wrappers exposing unexported helpers to the
// external middleware_test package.
// NewWithLogger builds a Middleware around a logger for tests
// that exercise the logging paths without the fx graph.
func NewWithLogger(log *slog.Logger) *Middleware {
return &Middleware{log: log}
}
func ClientIP(
remoteAddr string,
header http.Header,
trusted []netip.Prefix,
) string {
return clientIP(remoteAddr, header, trusted)
}
func ParseTrustedProxies(cidrs []string) ([]netip.Prefix, error) {
return parseTrustedProxies(cidrs)
}
+212 -1
View File
@@ -3,9 +3,15 @@
package middleware package middleware
import ( import (
"errors"
"fmt"
"io"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"net/netip"
"runtime/debug"
"strings"
"time" "time"
"sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/config"
@@ -19,6 +25,23 @@ import (
const corsMaxAgeSec = 300 const corsMaxAgeSec = 300
// jsonErrorBody is the body written for errors raised inside
// middleware, matching the {"status":"error"} shape the handlers
// return so clients see one error contract across the API.
const (
jsonContentType = "application/json; charset=utf-8"
jsonErrorBody = "{\"status\":\"error\"}\n"
)
// Security header values. The backend is a JSON API with no
// HTML surface, so the CSP forbids every resource type and
// framing outright.
const (
hstsValue = "max-age=31536000; includeSubDomains"
cspValue = "default-src 'none'; frame-ancestors 'none'"
permissionsPolicyValue = "camera=(), microphone=(), geolocation=()"
)
// Params defines the dependencies for Middleware. // Params defines the dependencies for Middleware.
type Params struct { type Params struct {
fx.In fx.In
@@ -32,6 +55,7 @@ type Params struct {
type Middleware struct { type Middleware struct {
log *slog.Logger log *slog.Logger
params *Params params *Params
trustedProxies []netip.Prefix
} }
// New creates a Middleware instance. // New creates a Middleware instance.
@@ -39,13 +63,38 @@ func New(
_ fx.Lifecycle, _ fx.Lifecycle,
params Params, params Params,
) (*Middleware, error) { ) (*Middleware, error) {
trusted, err := parseTrustedProxies(params.Config.TrustedProxies)
if err != nil {
return nil, err
}
s := new(Middleware) s := new(Middleware)
s.params = &params s.params = &params
s.log = params.Logger.Get() s.log = params.Logger.Get()
s.trustedProxies = trusted
return s, nil return s, nil
} }
// parseTrustedProxies converts CIDR strings into prefixes,
// failing fast on any malformed entry.
func parseTrustedProxies(cidrs []string) ([]netip.Prefix, error) {
prefixes := make([]netip.Prefix, 0, len(cidrs))
for _, cidr := range cidrs {
prefix, err := netip.ParsePrefix(cidr)
if err != nil {
return nil, fmt.Errorf(
"trusted proxy %q: %w", cidr, err,
)
}
prefixes = append(prefixes, prefix.Masked())
}
return prefixes, nil
}
type loggingResponseWriter struct { type loggingResponseWriter struct {
http.ResponseWriter http.ResponseWriter
@@ -72,6 +121,70 @@ func ipFromHostPort(hostPort string) string {
return host return host
} }
// clientIP resolves the caller's address. X-Forwarded-For and
// X-Real-IP are honoured only when the direct peer is a
// trusted proxy; otherwise the direct peer is returned so a
// spoofed header cannot forge the logged address.
func clientIP(
remoteAddr string,
header http.Header,
trusted []netip.Prefix,
) string {
peer := ipFromHostPort(remoteAddr)
if !addrInAny(peer, trusted) {
return peer
}
if xff := firstForwardedFor(header.Get("X-Forwarded-For")); xff != "" {
return xff
}
if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) {
return xr
}
return peer
}
// firstForwardedFor returns the left-most valid address in an
// X-Forwarded-For list (the original client), or "" if none.
func firstForwardedFor(value string) string {
for part := range strings.SplitSeq(value, ",") {
candidate := strings.TrimSpace(part)
if validIP(candidate) {
return candidate
}
}
return ""
}
func validIP(s string) bool {
_, err := netip.ParseAddr(s)
return err == nil
}
// addrInAny reports whether s parses as an address contained
// in any of the trusted prefixes.
func addrInAny(s string, trusted []netip.Prefix) bool {
addr, err := netip.ParseAddr(s)
if err != nil {
return false
}
addr = addr.Unmap()
for _, prefix := range trusted {
if prefix.Contains(addr) {
return true
}
}
return false
}
// Logging returns middleware that logs each request with // Logging returns middleware that logs each request with
// timing, status code, and client information. // timing, status code, and client information.
func (s *Middleware) Logging() func(http.Handler) http.Handler { func (s *Middleware) Logging() func(http.Handler) http.Handler {
@@ -96,7 +209,11 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
"referer", r.Referer(), "referer", r.Referer(),
"proto", r.Proto, "proto", r.Proto,
"remote_ip", "remote_ip",
ipFromHostPort(r.RemoteAddr), clientIP(
r.RemoteAddr,
r.Header,
s.trustedProxies,
),
"status", lrw.statusCode, "status", lrw.statusCode,
"latency_ms", "latency_ms",
latency.Milliseconds(), latency.Milliseconds(),
@@ -109,6 +226,100 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
} }
} }
// SecurityHeaders returns middleware that sets response
// security headers. It runs before CORS so the headers are
// present on preflight responses the CORS handler writes.
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Strict-Transport-Security", hstsValue)
h.Set("Content-Security-Policy", cspValue)
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Permissions-Policy", permissionsPolicyValue)
next.ServeHTTP(w, r)
},
)
}
}
// writeJSONError writes the shared JSON error body with the
// given status. Used where middleware must reject a request
// before it reaches a handler.
func writeJSONError(w http.ResponseWriter, status int) {
w.Header().Set("Content-Type", jsonContentType)
w.WriteHeader(status)
_, _ = io.WriteString(w, jsonErrorBody)
}
// MaxBodyBytes returns middleware that caps the request body at
// limit bytes. A declared Content-Length over the limit is
// rejected immediately with 413. Bodies without a declared
// length (or that understate it) are capped as they are read, so
// a handler that reads the body sees a *http.MaxBytesError it can
// map to 413. Mounted again on a route group, it can only lower
// the limit: a cap applied earlier in the chain still holds.
func (s *Middleware) MaxBodyBytes(
limit int64,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > limit {
writeJSONError(
w,
http.StatusRequestEntityTooLarge,
)
return
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
next.ServeHTTP(w, r)
},
)
}
}
// Recoverer returns middleware that recovers from a panic in a
// downstream handler, logs the panic and stack trace through
// slog, and responds 500 with no body. http.ErrAbortHandler is
// re-panicked so the server can abort the response as intended.
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
err, ok := rec.(error)
if ok && errors.Is(err, http.ErrAbortHandler) {
panic(rec)
}
s.log.ErrorContext(r.Context(),
"panic recovered",
"panic", fmt.Sprintf("%v", rec),
"stack", string(debug.Stack()),
)
w.WriteHeader(http.StatusInternalServerError)
}()
next.ServeHTTP(w, r)
},
)
}
}
// CORS returns middleware that adds permissive CORS headers. // CORS returns middleware that adds permissive CORS headers.
func (s *Middleware) CORS() func(http.Handler) http.Handler { func (s *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ return cors.Handler(cors.Options{
@@ -0,0 +1,302 @@
package middleware_test
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"sneak.berlin/go/netwatch/internal/middleware"
)
const (
// loopbackPeer is a remote address inside the trusted-proxy allowlist.
loopbackPeer = "127.0.0.1:5000"
// forwardedIP is the client address presented via X-Forwarded-For.
forwardedIP = "203.0.113.7"
// realIP is the client address presented via X-Real-IP.
realIP = "203.0.113.9"
)
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
t.Helper()
prefixes, err := middleware.ParseTrustedProxies(cidrs)
if err != nil {
t.Fatalf("ParseTrustedProxies(%v): %v", cidrs, err)
}
return prefixes
}
func TestParseTrustedProxiesRejectsMalformed(t *testing.T) {
t.Parallel()
_, err := middleware.ParseTrustedProxies([]string{"not-a-cidr"})
if err == nil {
t.Fatal("expected error for malformed CIDR, got nil")
}
}
type clientIPCase struct {
name string
remoteAddr string
xff string
xRealIP string
want string
}
func clientIPCases() []clientIPCase {
return []clientIPCase{
{
name: "trusted proxy uses forwarded-for",
remoteAddr: loopbackPeer,
xff: forwardedIP,
want: forwardedIP,
},
{
name: "trusted proxy uses left-most of chain",
remoteAddr: "10.1.2.3:5000",
xff: forwardedIP + ", 10.1.2.3",
want: forwardedIP,
},
{
name: "trusted proxy falls back to x-real-ip",
remoteAddr: loopbackPeer,
xRealIP: realIP,
want: realIP,
},
{
name: "untrusted peer ignores forwarded-for",
remoteAddr: "198.51.100.4:5000",
xff: forwardedIP,
want: "198.51.100.4",
},
{
name: "untrusted peer ignores x-real-ip",
remoteAddr: "198.51.100.4:5000",
xRealIP: realIP,
want: "198.51.100.4",
},
{
name: "trusted proxy with no headers uses peer",
remoteAddr: "10.1.2.3:5000",
want: "10.1.2.3",
},
{
name: "trusted proxy with garbage header uses peer",
remoteAddr: loopbackPeer,
xff: "not-an-ip",
want: "127.0.0.1",
},
}
}
func TestClientIP(t *testing.T) {
t.Parallel()
trusted := mustPrefixes(t, "127.0.0.1/32", "::1/128", "10.0.0.0/8")
for _, tc := range clientIPCases() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
header := http.Header{}
if tc.xff != "" {
header.Set("X-Forwarded-For", tc.xff)
}
if tc.xRealIP != "" {
header.Set("X-Real-IP", tc.xRealIP)
}
got := middleware.ClientIP(tc.remoteAddr, header, trusted)
if got != tc.want {
t.Errorf("ClientIP() = %q, want %q", got, tc.want)
}
})
}
}
func TestSecurityHeaders(t *testing.T) {
t.Parallel()
handler := (&middleware.Middleware{}).SecurityHeaders()(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}),
)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
handler.ServeHTTP(rec, req)
want := map[string]string{
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
}
for name, value := range want {
if got := rec.Header().Get(name); got != value {
t.Errorf("header %s = %q, want %q", name, got, value)
}
}
}
// TestMaxBodyBytesRejectsOversizeOnNonReadingRoute confirms the
// limit is enforced even for a handler that never reads the body
// (for example the health check), via the Content-Length check.
func TestMaxBodyBytesRejectsOversizeOnNonReadingRoute(t *testing.T) {
t.Parallel()
const limit = 16
called := false
handler := (&middleware.Middleware{}).MaxBodyBytes(limit)(
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}),
)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodPost, "/.well-known/healthcheck",
strings.NewReader(strings.Repeat("x", limit+1)),
)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d",
rec.Code, http.StatusRequestEntityTooLarge)
}
if called {
t.Fatal("handler ran despite oversize body")
}
if got := rec.Body.String(); got != "{\"status\":\"error\"}\n" {
t.Errorf("body = %q, want %q", got, "{\"status\":\"error\"}\n")
}
got := rec.Header().Get("Content-Type")
if got != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q, want a JSON content type", got)
}
}
func TestMaxBodyBytesAllowsWithinLimit(t *testing.T) {
t.Parallel()
const limit = 64
handler := (&middleware.Middleware{}).MaxBodyBytes(limit)(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}),
)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"clientId":"c1"}`),
)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestRecovererReturns500AndLogsThroughSlog(t *testing.T) {
t.Parallel()
var logbuf bytes.Buffer
mw := middleware.NewWithLogger(
slog.New(slog.NewJSONHandler(&logbuf, nil)),
)
handler := mw.Recoverer()(
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
panic("boom")
}),
)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d",
rec.Code, http.StatusInternalServerError)
}
var record map[string]any
err := json.Unmarshal(logbuf.Bytes(), &record)
if err != nil {
t.Fatalf("panic log is not one JSON record: %v (%q)", err, logbuf.String())
}
if record["msg"] != "panic recovered" || record["level"] != "ERROR" {
t.Errorf("log record = %v, want msg %q at level ERROR",
record, "panic recovered")
}
if record["panic"] != "boom" {
t.Errorf("panic field = %v, want %q", record["panic"], "boom")
}
stack, _ := record["stack"].(string)
if !strings.HasPrefix(stack, "goroutine ") {
t.Errorf("stack field = %q, want a stack trace", stack)
}
}
// TestRecovererRepanicsOnAbortHandler checks that a handler aborting
// with http.ErrAbortHandler is not treated as a crash: Recoverer
// panics again so the server aborts the response, and logs nothing.
func TestRecovererRepanicsOnAbortHandler(t *testing.T) {
t.Parallel()
var logbuf bytes.Buffer
mw := middleware.NewWithLogger(
slog.New(slog.NewJSONHandler(&logbuf, nil)),
)
handler := mw.Recoverer()(
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
panic(http.ErrAbortHandler)
}),
)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
var recovered any
func() {
defer func() { recovered = recover() }()
handler.ServeHTTP(rec, req)
}()
err, _ := recovered.(error)
if !errors.Is(err, http.ErrAbortHandler) {
t.Errorf("Recoverer panicked with %v, want http.ErrAbortHandler", recovered)
}
if logbuf.Len() != 0 {
t.Errorf("abort was logged: %q", logbuf.String())
}
}
+48 -24
View File
@@ -45,6 +45,7 @@ type Buffer struct {
done chan struct{} done chan struct{}
log *slog.Logger log *slog.Logger
mu sync.Mutex mu sync.Mutex
stopOnce sync.Once
} }
// New creates a Buffer and registers lifecycle hooks to // New creates a Buffer and registers lifecycle hooks to
@@ -76,10 +77,19 @@ func New(
return nil return nil
}, },
OnStop: func(_ context.Context) error { OnStop: func(_ context.Context) error {
close(b.done) // stopOnce makes OnStop idempotent: a second
b.flushLocked() // invocation must not close an already-closed channel
// (which would panic) or flush again.
var err error
return nil b.stopOnce.Do(func() {
close(b.done)
err = b.flushLocked()
})
// A failed final flush fails the stop, so the process
// exits non-zero.
return err
}, },
}) })
@@ -103,7 +113,12 @@ func (b *Buffer) Append(v any) error {
data := b.drainBuf() data := b.drainBuf()
b.mu.Unlock() b.mu.Unlock()
go b.writeFile(data) go func() {
writeErr := b.writeFile(data)
if writeErr != nil {
b.log.Error("flush reports failed", "error", writeErr)
}
}()
return nil return nil
} }
@@ -122,7 +137,10 @@ func (b *Buffer) flushLoop() {
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
b.flushLocked() err := b.flushLocked()
if err != nil {
b.log.Error("flush reports failed", "error", err)
}
case <-b.done: case <-b.done:
return return
} }
@@ -131,19 +149,19 @@ func (b *Buffer) flushLoop() {
// flushLocked acquires the lock, drains the buffer, and // flushLocked acquires the lock, drains the buffer, and
// writes the data to a compressed file. // writes the data to a compressed file.
func (b *Buffer) flushLocked() { func (b *Buffer) flushLocked() error {
b.mu.Lock() b.mu.Lock()
if b.buf.Len() == 0 { if b.buf.Len() == 0 {
b.mu.Unlock() b.mu.Unlock()
return return nil
} }
data := b.drainBuf() data := b.drainBuf()
b.mu.Unlock() b.mu.Unlock()
b.writeFile(data) return b.writeFile(data)
} }
// drainBuf copies the buffer contents and resets it. // drainBuf copies the buffer contents and resets it.
@@ -158,42 +176,48 @@ func (b *Buffer) drainBuf() []byte {
// writeFile creates a timestamped zstd-compressed JSONL file // writeFile creates a timestamped zstd-compressed JSONL file
// in the data directory. // in the data directory.
func (b *Buffer) writeFile(data []byte) { func (b *Buffer) writeFile(data []byte) error {
ts := time.Now().UTC().Format("2006-01-02T15-04-05.000Z") ts := time.Now().UTC().Format("2006-01-02T15-04-05.000Z")
name := fmt.Sprintf("reports-%s.jsonl.zst", ts) name := fmt.Sprintf("reports-%s.jsonl.zst", ts)
path := filepath.Join(b.dataDir, name) path := filepath.Join(b.dataDir, name)
f, err := os.OpenFile( //nolint:gosec // path built from controlled dataDir + timestamp // path is built from the operator-supplied dataDir plus a
// generated timestamp, so it carries no external input.
f, err := os.OpenFile( //nolint:gosec // see comment above
path, path,
os.O_WRONLY|os.O_CREATE|os.O_EXCL, os.O_WRONLY|os.O_CREATE|os.O_EXCL,
filePerms, filePerms,
) )
if err != nil { if err != nil {
b.log.Error("create report file", "error", err) return fmt.Errorf("create report file: %w", err)
return
} }
// Closes the file on the early returns below. The success
// path closes it explicitly to check the error; closing it
// a second time here is harmless.
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
enc, err := zstd.NewWriter(f) enc, err := zstd.NewWriter(f)
if err != nil { if err != nil {
b.log.Error("create zstd encoder", "error", err) return fmt.Errorf("create zstd encoder: %w", err)
return
} }
_, writeErr := enc.Write(data) _, err = enc.Write(data)
if writeErr != nil { if err != nil {
b.log.Error("write compressed data", "error", writeErr)
_ = enc.Close() _ = enc.Close()
return return fmt.Errorf("write compressed data: %w", err)
} }
closeErr := enc.Close() err = enc.Close()
if closeErr != nil { if err != nil {
b.log.Error("close zstd encoder", "error", closeErr) return fmt.Errorf("close zstd encoder: %w", err)
} }
err = f.Close()
if err != nil {
return fmt.Errorf("close report file: %w", err)
}
return nil
} }
+111 -5
View File
@@ -1,13 +1,119 @@
package reportbuf_test package reportbuf_test
import ( import (
"errors"
"io/fs"
"os"
"strings"
"testing" "testing"
_ "sneak.berlin/go/netwatch/internal/reportbuf" "sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/reportbuf"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
) )
func TestImport(t *testing.T) { // TestFlushOnShutdown proves the flush-on-shutdown path: a
t.Parallel() // report appended after start but before the periodic flush
// Compilation check — verifies the package parses // window must reach disk when the fx lifecycle stops. This is
// and all imports resolve. // the exact case that silent data loss on restart used to
// destroy.
func TestFlushOnShutdown(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
var buf *reportbuf.Buffer
app := fxtest.New(t,
fx.Provide(
globals.New,
logger.New,
config.New,
reportbuf.New,
),
fx.Populate(&buf),
)
app.RequireStart()
err := buf.Append(map[string]string{"probe": "shutdown"})
if err != nil {
t.Fatalf("append report: %v", err)
}
// RequireStop runs the reportbuf OnStop hook, which is the
// only code path that flushes buffered reports on shutdown.
app.RequireStop()
if !hasReportFile(t, dir) {
t.Fatal("no report file on disk after shutdown; " +
"the buffered report was lost")
}
}
// TestFailedFinalFlushFailsStop proves a final flush that cannot
// write its file makes the stop fail, which makes the process
// exit non-zero instead of dropping the buffered reports silently.
func TestFailedFinalFlushFailsStop(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
var buf *reportbuf.Buffer
app := fxtest.New(t,
fx.Provide(
globals.New,
logger.New,
config.New,
reportbuf.New,
),
fx.Populate(&buf),
)
app.RequireStart()
err := buf.Append(map[string]string{"probe": "shutdown"})
if err != nil {
t.Fatalf("append report: %v", err)
}
// Removing the data directory leaves the final flush nowhere to
// write. A read-only directory would not do: tests run as root
// in the backend image, and root ignores the read-only bit.
err = os.RemoveAll(dir)
if err != nil {
t.Fatalf("remove data dir: %v", err)
}
err = app.Stop(t.Context())
if !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("stop error = %v, want the final flush's error", err)
}
}
func hasReportFile(t *testing.T, dir string) bool {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read data dir: %v", err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".jsonl.zst") {
info, statErr := e.Info()
if statErr != nil {
t.Fatalf("stat %s: %v", e.Name(), statErr)
}
if info.Size() > 0 {
return true
}
}
}
return false
} }
+5
View File
@@ -0,0 +1,5 @@
package server
// MaxRequestBodyBytes exposes the router-wide body limit to the
// external tests.
const MaxRequestBodyBytes = maxRequestBodyBytes
+33 -10
View File
@@ -5,39 +5,62 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
"go.uber.org/fx"
) )
const ( const (
readTimeout = 10 * time.Second readTimeout = 10 * time.Second
writeTimeout = 10 * time.Second readHeaderTimeout = 5 * time.Second
idleTimeout = 60 * time.Second
maxHeaderBytes = 1 << 20 // 1 MiB maxHeaderBytes = 1 << 20 // 1 MiB
// requestTimeout (routes.go) is the single per-request
// processing budget, enforced by chi's middleware.Timeout.
// writeTimeout must exceed that budget so a handler can write
// its 503 when the chi timeout fires; if it were shorter the
// server would abort the write first and the chi budget would
// be unreachable dead configuration.
writeTimeout = requestTimeout + 5*time.Second
) )
func (s *Server) serveUntilShutdown() { // newHTTPServer constructs the http.Server. It performs no I/O
// and does not start listening.
func (s *Server) newHTTPServer() *http.Server {
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port) listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
s.httpServer = &http.Server{ return &http.Server{
Addr: listenAddr, Addr: listenAddr,
Handler: s, Handler: s,
MaxHeaderBytes: maxHeaderBytes, MaxHeaderBytes: maxHeaderBytes,
ReadTimeout: readTimeout, ReadTimeout: readTimeout,
ReadHeaderTimeout: readHeaderTimeout,
WriteTimeout: writeTimeout, WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
} }
}
s.SetupRoutes() // listenAndServe runs the listener until the server is shut
// down. A genuine listen failure (not the expected
// ErrServerClosed from a clean shutdown) requests process
// shutdown through fx with a non-zero exit code, so the failure
// is visible to any supervisor.
func (s *Server) listenAndServe() {
s.log.Info("http begin listen", s.log.Info("http begin listen",
"listenaddr", listenAddr, "listenaddr", s.httpServer.Addr,
"version", s.params.Globals.Version, "version", s.params.Globals.Version,
"buildarch", s.params.Globals.Buildarch, "buildarch", s.params.Globals.Buildarch,
) )
err := s.httpServer.ListenAndServe() err := s.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) { if err == nil || errors.Is(err, http.ErrServerClosed) {
return
}
s.log.Error("listen error", "error", err) s.log.Error("listen error", "error", err)
if s.cancelFunc != nil { shutdownErr := s.shutdowner.Shutdown(fx.ExitCode(1))
s.cancelFunc() if shutdownErr != nil {
} s.log.Error("request shutdown failed", "error", shutdownErr)
} }
} }
+11 -2
View File
@@ -7,17 +7,26 @@ import (
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
) )
const requestTimeout = 60 * time.Second const (
requestTimeout = 60 * time.Second
// maxRequestBodyBytes caps every request body. A route group
// can mount s.mw.MaxBodyBytes with a smaller value to lower
// its bound, but cannot raise it: this cap runs first.
maxRequestBodyBytes int64 = 1 << 20 // 1 MiB
)
// SetupRoutes configures the chi router with middleware and // SetupRoutes configures the chi router with middleware and
// all application routes. // all application routes.
func (s *Server) SetupRoutes() { func (s *Server) SetupRoutes() {
s.router = chi.NewRouter() s.router = chi.NewRouter()
s.router.Use(middleware.Recoverer) s.router.Use(s.mw.Recoverer())
s.router.Use(middleware.RequestID) s.router.Use(middleware.RequestID)
s.router.Use(s.mw.Logging()) s.router.Use(s.mw.Logging())
s.router.Use(s.mw.SecurityHeaders())
s.router.Use(s.mw.CORS()) s.router.Use(s.mw.CORS())
s.router.Use(s.mw.MaxBodyBytes(maxRequestBodyBytes))
s.router.Use(middleware.Timeout(requestTimeout)) s.router.Use(middleware.Timeout(requestTimeout))
s.router.Get( s.router.Get(
+76
View File
@@ -0,0 +1,76 @@
package server_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/healthcheck"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/middleware"
"sneak.berlin/go/netwatch/internal/reportbuf"
"sneak.berlin/go/netwatch/internal/server"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
// TestHealthCheckRejectsOversizeBody sends the health check, which
// never reads its body, a body one byte over the limit. Only the
// router-wide body limit can reject it.
func TestHealthCheckRejectsOversizeBody(t *testing.T) {
t.Parallel()
var srv *server.Server
// The same constructors as main, never started: SetupRoutes is
// called directly, so nothing listens.
app := fxtest.New(t,
fx.Provide(
config.New,
globals.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
reportbuf.New,
server.New,
),
fx.Populate(&srv),
)
err := app.Err()
if err != nil {
t.Fatalf("build server: %v", err)
}
srv.SetupRoutes()
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodGet, "/.well-known/healthcheck",
strings.NewReader(
strings.Repeat("x", int(server.MaxRequestBodyBytes)+1),
),
)
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d",
rec.Code, http.StatusRequestEntityTooLarge)
}
if got := rec.Body.String(); got != "{\"status\":\"error\"}\n" {
t.Errorf("body = %q, want %q", got, "{\"status\":\"error\"}\n")
}
got := rec.Header().Get("Content-Type")
if got != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q, want a JSON content type", got)
}
}
+27 -71
View File
@@ -1,16 +1,14 @@
// Package server provides the HTTP server lifecycle, // Package server provides the HTTP server lifecycle,
// including startup, routing, signal handling, and graceful // including startup, routing, and graceful shutdown. The
// shutdown. // process lifetime is owned by fx: shutdown is requested
// through fx.Shutdowner so every component's OnStop hook runs
// in dependency order.
package server package server
import ( import (
"context" "context"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"os/signal"
"syscall"
"time"
"sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals" "sneak.berlin/go/netwatch/internal/globals"
@@ -31,19 +29,18 @@ type Params struct {
Handlers *handlers.Handlers Handlers *handlers.Handlers
Logger *logger.Logger Logger *logger.Logger
Middleware *middleware.Middleware Middleware *middleware.Middleware
Shutdowner fx.Shutdowner
} }
// Server is the top-level HTTP server orchestrator. // Server is the top-level HTTP server orchestrator.
type Server struct { type Server struct {
cancelFunc context.CancelFunc
exitCode int
h *handlers.Handlers h *handlers.Handlers
httpServer *http.Server httpServer *http.Server
log *slog.Logger log *slog.Logger
mw *middleware.Middleware mw *middleware.Middleware
params Params params Params
router *chi.Mux router *chi.Mux
startupTime time.Time shutdowner fx.Shutdowner
} }
// New creates a Server and registers lifecycle hooks for // New creates a Server and registers lifecycle hooks for
@@ -57,23 +54,25 @@ func New(
s.mw = params.Middleware s.mw = params.Middleware
s.h = params.Handlers s.h = params.Handlers
s.log = params.Logger.Get() s.log = params.Logger.Get()
s.shutdowner = params.Shutdowner
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(_ context.Context) error {
s.startupTime = time.Now().UTC() // Build the router and http.Server synchronously
// here, before spawning the serving goroutine, so
// httpServer is fully constructed by the time OnStop
// (or an early signal) can read it. fx guarantees
// OnStart returns before OnStop runs, so no
// synchronization or nil check is needed at shutdown.
s.SetupRoutes()
s.httpServer = s.newHTTPServer()
go func() { //nolint:contextcheck // fx OnStart ctx is startup-only; run() creates its own go s.listenAndServe()
s.run()
}()
return nil return nil
}, },
OnStop: func(_ context.Context) error { OnStop: func(ctx context.Context) error {
if s.cancelFunc != nil { return s.shutdown(ctx)
s.cancelFunc()
}
return nil
}, },
}) })
@@ -88,60 +87,17 @@ func (s *Server) ServeHTTP(
s.router.ServeHTTP(w, r) s.router.ServeHTTP(w, r)
} }
func (s *Server) run() { // shutdown gracefully stops the HTTP server within the
exitCode := s.serve() // deadline of the context fx provides for OnStop.
os.Exit(exitCode) func (s *Server) shutdown(ctx context.Context) error {
} err := s.httpServer.Shutdown(ctx)
func (s *Server) serve() int {
var ctx context.Context //nolint:wsl // ctx must be declared before multi-assign
ctx, s.cancelFunc = context.WithCancel(
context.Background(),
)
go func() {
c := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
sig := <-c
s.log.Info("signal received", "signal", sig)
if s.cancelFunc != nil {
s.cancelFunc()
}
}()
go func() {
s.serveUntilShutdown()
}()
<-ctx.Done()
s.cleanShutdown()
return s.exitCode
}
const shutdownTimeout = 5 * time.Second
func (s *Server) cleanShutdown() {
s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(
context.Background(),
shutdownTimeout,
)
defer shutdownCancel()
err := s.httpServer.Shutdown(ctxShutdown)
if err != nil { if err != nil {
s.log.Error( s.log.Error("server clean shutdown failed", "error", err)
"server clean shutdown failed",
"error", err, return err
)
} }
s.log.Info("server stopped") s.log.Info("server stopped")
return nil
} }
+1
View File
@@ -14,6 +14,7 @@
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"puppeteer-core": "25.5.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"vite": "^7.3.1" "vite": "^7.3.1"
} }
+102
View File
@@ -0,0 +1,102 @@
#!/bin/sh
# script/frontend-viewport-test: verify the responsive layout of the built
# frontend in a real browser engine.
#
# Builds dist/, serves it with the same nginx image and the same nginx.conf
# the shipping container uses, points a containerised headless Chrome at it
# over CDP, and asserts on computed layout at every viewport width derived
# from the app's own CSS. See test/viewport/README.md for what this covers
# and what it cannot.
#
# Deliberately not part of script/check: it needs Docker and takes far
# longer than the 20s budget make test has to stay inside.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# chromedp/headless-shell 151.0.7922.109, 2026-08-09
BROWSER_IMAGE="chromedp/headless-shell@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04"
# nginx:stable-alpine, 2026-02-22 (the digest Dockerfile ships)
SERVER_IMAGE="nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab"
# node:22-alpine, 2026-02-22 (the digest Dockerfile builds with)
NODE_IMAGE="node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34"
RUN_ID="$$-$(date +%s)"
NETWORK="netwatch-viewport-$RUN_ID"
SERVER="netwatch-viewport-server-$RUN_ID"
BROWSER="netwatch-viewport-browser-$RUN_ID"
HARNESS="netwatch-viewport-harness-$RUN_ID"
ARTIFACT_DIR="$ROOT/tmp/viewport"
# Every container is named and removed here, including the harness itself:
# `timeout` below kills the `docker run` client, not the container it
# started, and an unnamed survivor keeps the --internal network in use so
# `docker network rm` fails too. This host runs many sessions at once and
# neither may be left behind.
cleanup() {
docker rm -f "$HARNESS" > /dev/null 2>&1 || true
docker rm -f "$BROWSER" > /dev/null 2>&1 || true
docker rm -f "$SERVER" > /dev/null 2>&1 || true
docker network rm "$NETWORK" > /dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
main() {
cd "$ROOT"
# Test what ships: the production build, not a dev server.
"$ROOT/script/test"
if [ ! -f "$ROOT/dist/index.html" ]; then
echo "frontend-viewport-test: dist/index.html missing after build" >&2
exit 1
fi
mkdir -p "$ARTIFACT_DIR"
# An --internal network has no route off the host, so the browser
# cannot reach the real internet no matter what the page asks for.
# Latency probes are answered by the harness instead. This also means
# no port can be published from it, which is why the harness itself
# runs as a third container on the same network rather than on the
# host.
docker network create --internal "$NETWORK" > /dev/null
docker run -d --rm --name "$SERVER" \
--network "$NETWORK" --network-alias netwatch \
-v "$ROOT/dist:/usr/share/nginx/html:ro" \
-v "$ROOT/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \
"$SERVER_IMAGE" > /dev/null
# The image's own entrypoint already exposes CDP on 9222 and passes
# --no-sandbox, so only extra flags belong here; re-specifying the
# debugging port collides with it and leaves the endpoint bound to
# loopback only. --hide-scrollbars keeps innerWidth equal to
# clientWidth, so the overflow assertion has no scrollbar-sized slack
# to hide behind, and matches the overlay scrollbars phones use.
docker run -d --rm --name "$BROWSER" --init --shm-size=1g \
--network "$NETWORK" \
"$BROWSER_IMAGE" \
--hide-scrollbars \
> /dev/null
# Chrome refuses DevTools requests whose Host header is neither
# localhost nor an IP address, so dial the container by address rather
# than by its network alias.
browser_ip="$(docker inspect \
-f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
"$BROWSER")"
timeout 900 docker run --rm --init --name "$HARNESS" \
--network "$NETWORK" \
--user "$(id -u):$(id -g)" \
-v "$ROOT:/app" \
-w /app \
-e NETWATCH_ROOT=/app \
-e NETWATCH_BASE_URL=http://netwatch:8080 \
-e "NETWATCH_CDP_URL=http://$browser_ip:9222" \
-e NETWATCH_ARTIFACT_DIR=/app/tmp/viewport \
"$NODE_IMAGE" \
node test/viewport/harness.js
}
main "$@"
+214 -22
View File
@@ -7,9 +7,11 @@ import "./styles.css";
// graphMaxLatency — values above it pin to the top of the chart but still // graphMaxLatency — values above it pin to the top of the chart but still
// display their real value in the latency figure. The history buffer holds // display their real value in the latency figure. The history buffer holds
// maxHistoryPoints samples (historyDuration / updateInterval). // maxHistoryPoints samples (historyDuration / updateInterval).
// reportInterval is how often collected samples are POSTed to the backend.
const CONFIG = { const CONFIG = {
updateInterval: 3000, updateInterval: 3000,
maxHistoryPoints: 100, maxHistoryPoints: 100,
reportInterval: 60000,
get historyDuration() { get historyDuration() {
return (this.maxHistoryPoints * this.updateInterval) / 1000; return (this.maxHistoryPoints * this.updateInterval) / 1000;
}, },
@@ -346,6 +348,159 @@ class AppState {
} }
} }
// --- Reporting ---------------------------------------------------------------
// A random UUIDv4. `crypto.randomUUID` exists only in secure contexts
// (HTTPS or localhost); over plain HTTP to any other host — the normal LAN
// deployment — it is undefined, so feature-detect it and otherwise build the
// id from `crypto.getRandomValues`, which is available in insecure contexts.
function randomId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0"));
return (
hex.slice(0, 4).join("") +
"-" +
hex.slice(4, 6).join("") +
"-" +
hex.slice(6, 8).join("") +
"-" +
hex.slice(8, 10).join("") +
"-" +
hex.slice(10, 16).join("")
);
}
// A random id identifying this browser across reports. Generated once and
// kept in localStorage; if storage is unavailable (e.g. private mode) a
// fresh id is used for this session only.
function getClientId() {
const key = "netwatch-client-id";
try {
let id = localStorage.getItem(key);
if (!id) {
id = randomId();
localStorage.setItem(key, id);
}
return id;
} catch {
return randomId();
}
}
// Build the delta report body the backend decodes, plus the new per-host
// high-water marks. Pure function of the passed state: `hosts` is an array
// of { name, url, status, history }, `since` maps a host url to the Unix-ms
// timestamp of the last sample already reported for it, and `now` is a Date.
// Only non-paused samples newer than the mark are included. Returns null
// when no host has an unreported sample.
export function buildReport(hosts, clientId, now, since) {
const reportHosts = [];
const marks = new Map();
for (const host of hosts) {
const mark = since.get(host.url) ?? 0;
const samples = [];
let high = mark;
for (const p of host.history) {
if (p.paused) continue;
if (p.timestamp <= mark) continue;
samples.push({
t: p.timestamp,
latency: p.latency,
error: p.error ?? null,
});
if (p.timestamp > high) high = p.timestamp;
}
if (samples.length === 0) continue;
reportHosts.push({
name: host.name,
url: host.url,
status: host.status,
history: samples,
});
marks.set(host.url, high);
}
if (reportHosts.length === 0) return null;
return {
body: {
clientId,
geo: null,
hosts: reportHosts,
timestamp: now.toISOString(),
},
marks,
};
}
// Periodically POSTs unreported samples to the same-origin backend. Holds
// the per-host high-water marks so each report is a delta; marks only
// advance on a delivered report, so a failed POST simply re-sends those
// samples next interval (bounded by the history window — whatever has since
// fallen out is dropped). Failure is quiet: one debug line per outage, one
// on recovery, never an alert, never a tight retry loop.
//
// Only one report is ever in flight, and it is abandoned after half the
// interval, so a slow POST can neither overlap the next report (which would
// carry the same samples) nor stall reporting for good.
class Reporter {
constructor(state, clientId, intervalMs) {
this.state = state;
this.clientId = clientId;
this.intervalMs = intervalMs;
this.marks = new Map();
this.failing = false;
this.sending = false;
this.timerId = null;
}
start() {
if (this.timerId) return;
this.timerId = setInterval(() => this.flush(), this.intervalMs);
}
async flush() {
if (this.sending || this.state.paused) return;
const report = buildReport(
this.state.allHosts,
this.clientId,
new Date(),
this.marks,
);
if (!report) return;
this.sending = true;
try {
const resp = await fetch("/api/v1/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "omit",
body: JSON.stringify(report.body),
signal: AbortSignal.timeout(this.intervalMs / 2),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
for (const [url, t] of report.marks) {
const current = this.marks.get(url) ?? 0;
this.marks.set(url, Math.max(current, t));
}
if (this.failing) {
log.debug("Report delivery recovered");
this.failing = false;
}
} catch (err) {
if (!this.failing) {
log.debug(`Report delivery failed: ${err.message}`);
this.failing = true;
}
} finally {
this.sending = false;
}
}
}
// --- Latency Measurement ----------------------------------------------------- // --- Latency Measurement -----------------------------------------------------
async function measureLatency(url) { async function measureLatency(url) {
@@ -537,6 +692,12 @@ class SparklineRenderer {
// --- UI Renderer ------------------------------------------------------------- // --- UI Renderer -------------------------------------------------------------
// The per-host status line must stay wrappable: its populated content is
// wider than the host column at a 320px viewport, and `whitespace-nowrap`
// here overflows the element and forces the whole document to scroll
// horizontally.
const STATUS_TEXT_CLASS = "status-text text-xs text-right col-span-2 mt-5";
function hostRowHTML(host, index, showPin = true) { function hostRowHTML(host, index, showPin = true) {
const pinColor = host.pinned const pinColor = host.pinned
? "text-blue-500" ? "text-blue-500"
@@ -562,7 +723,7 @@ function hostRowHTML(host, index, showPin = true) {
<span class="text-gray-500">---</span> <span class="text-gray-500">---</span>
</div> </div>
<a href="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 truncate block col-span-2 -mt-2">${host.url}</a> <a href="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 truncate block col-span-2 -mt-2">${host.url}</a>
<div class="status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5" data-host="${index}">waiting...</div> <div class="${STATUS_TEXT_CLASS} text-gray-500" data-host="${index}">waiting...</div>
</div> </div>
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30"> <div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
<canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas> <canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas>
@@ -671,7 +832,7 @@ function buildUI(state) {
</p> </p>
<p class="mt-2"><a href="https://git.eeqj.de/sneak/netwatch/commit/${__COMMIT_FULL__}" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-400">${__COMMIT_HASH__}</a></p> <p class="mt-2"><a href="https://git.eeqj.de/sneak/netwatch/commit/${__COMMIT_FULL__}" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-400">${__COMMIT_HASH__}</a></p>
<p class="mt-2"> <p class="mt-2">
<label class="cursor-pointer"> <label class="debug-toggle-label cursor-pointer">
<input type="checkbox" id="debug-toggle" class="mr-1"> <input type="checkbox" id="debug-toggle" class="mr-1">
<span>Debug log</span> <span>Debug log</span>
</label> </label>
@@ -688,6 +849,26 @@ function buildUI(state) {
// --- UI Updaters ------------------------------------------------------------- // --- UI Updaters -------------------------------------------------------------
// Renders `min 1ms / med 2ms / avg 3ms / max 4ms`. Each label, value and
// trailing separator is one unbreakable unit, so wrapping only ever happens
// between stats and a wrapped line never starts with a separator.
function statusStatsHTML(stats) {
return stats
.map(([label, value], i) => {
const sep =
i < stats.length - 1
? ` <span class="text-gray-500">/</span>`
: "";
return (
`<span class="whitespace-nowrap">` +
`<span class="text-gray-400">${label} </span>` +
`<span class="${latencyClass(value, "online")}">${value}ms</span>` +
`${sep}</span>`
);
})
.join(" ");
}
function updateHostRow(host, index) { function updateHostRow(host, index) {
const latencyEl = document.querySelector( const latencyEl = document.querySelector(
`.latency-value[data-host="${index}"]`, `.latency-value[data-host="${index}"]`,
@@ -712,28 +893,22 @@ function updateHostRow(host, index) {
const min = host.minLatency(); const min = host.minLatency();
const max = host.maxLatency(); const max = host.maxLatency();
if (host.status === "online" && avg !== null) { if (host.status === "online" && avg !== null) {
statusEl.innerHTML = statusEl.innerHTML = statusStatsHTML([
`<span class="text-gray-400">min </span><span class="${latencyClass(min, "online")}">${min}ms</span>` + ["min", min],
` <span class="text-gray-500">/</span> ` + ["med", med],
`<span class="text-gray-400">med </span><span class="${latencyClass(med, "online")}">${med}ms</span>` + ["avg", avg],
` <span class="text-gray-500">/</span> ` + ["max", max],
`<span class="text-gray-400">avg </span><span class="${latencyClass(avg, "online")}">${avg}ms</span>` + ]);
` <span class="text-gray-500">/</span> ` + statusEl.className = STATUS_TEXT_CLASS;
`<span class="text-gray-400">max </span><span class="${latencyClass(max, "online")}">${max}ms</span>`;
statusEl.className =
"status-text text-xs whitespace-nowrap text-right col-span-2 mt-5";
} else if (host.status === "offline") { } else if (host.status === "offline") {
statusEl.textContent = "unreachable"; statusEl.textContent = "unreachable";
statusEl.className = statusEl.className = `${STATUS_TEXT_CLASS} text-red-400`;
"status-text text-xs text-red-400 whitespace-nowrap text-right col-span-2 mt-5";
} else if (host.status === "error") { } else if (host.status === "error") {
statusEl.textContent = "timeout"; statusEl.textContent = "timeout";
statusEl.className = statusEl.className = `${STATUS_TEXT_CLASS} text-orange-400`;
"status-text text-xs text-orange-400 whitespace-nowrap text-right col-span-2 mt-5";
} else { } else {
statusEl.textContent = "connecting..."; statusEl.textContent = "connecting...";
statusEl.className = statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
} }
SparklineRenderer.draw(canvas, host.history); SparklineRenderer.draw(canvas, host.history);
@@ -1044,8 +1219,7 @@ function greyOutUI(state) {
} }
if (statusEl) { if (statusEl) {
statusEl.textContent = "paused"; statusEl.textContent = "paused";
statusEl.className = statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
} }
// Grey out the status dot // Grey out the status dot
const row = document.querySelector(`.host-row[data-index="${i}"]`); const row = document.querySelector(`.host-row[data-index="${i}"]`);
@@ -1143,6 +1317,19 @@ async function init() {
buildUI(state); buildUI(state);
log.info("UI built, starting tick loop"); log.info("UI built, starting tick loop");
// Reporting is best-effort: any failure setting it up (e.g. no usable
// crypto for the client id) must never stop the monitor from probing.
try {
const reporter = new Reporter(
state,
getClientId(),
CONFIG.reportInterval,
);
reporter.start();
} catch (err) {
log.error(`Reporting disabled: ${err.message}`);
}
document document
.getElementById("pause-btn") .getElementById("pause-btn")
.addEventListener("click", () => togglePause(state)); .addEventListener("click", () => togglePause(state));
@@ -1255,8 +1442,13 @@ async function init() {
setTimeout(() => handleResize(state), 100); setTimeout(() => handleResize(state), 100);
} }
if (document.readyState === "loading") { // Bootstrap only when loaded as the page: a real DOM containing the #app
// mount point this module renders into. Importing the module in a unit test
// (which has no #app) runs nothing, so buildReport can be tested in isolation.
if (typeof document !== "undefined" && document.getElementById("app")) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init); document.addEventListener("DOMContentLoaded", init);
} else { } else {
init(); init();
}
} }
+34 -1
View File
@@ -14,6 +14,38 @@ body {
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
} }
/* ---- Minimum tap targets ----------------------------------------------
Every interactive control is at least 44x44 CSS px (Apple HIG, WCAG 2.2
SC 2.5.5). Not scoped to a breakpoint or to `pointer: coarse`: a large
phone in landscape is above the 768px breakpoint and still a touch
device. */
/* The button grows to 44x44 while the negative margins keep its layout
footprint at the 16x16 of the icon inside it, so row height and the
icon's position are unchanged. */
.pin-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
margin: -0.875rem;
}
/* A select paints its own background and border, so it has to actually be
44 tall rather than borrow the trick above. */
#interval-select {
min-height: 2.75rem;
}
/* The tappable target for #debug-toggle is the label wrapping it. */
.debug-toggle-label {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.75rem;
}
.sparkline-container { .sparkline-container {
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
@@ -38,9 +70,10 @@ body {
gap: 0.75rem; gap: 0.75rem;
} }
/* Pause button: smaller on mobile */ /* Pause button: smaller on mobile, but not below the tap-target floor */
#pause-btn { #pause-btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
min-height: 2.75rem;
} }
#pause-btn svg { #pause-btn svg {
+112
View File
@@ -0,0 +1,112 @@
# Responsive-layout harness
Automated verification of the responsive layout that landed in #5. Run it with:
```bash
make frontend-viewport-test
```
It builds `dist/`, serves it from the same digest-pinned `nginx` image and the
same `nginx.conf` the shipping container uses, drives a digest-pinned headless
Chrome against it over CDP, and asserts on computed layout at every viewport
width derived from the app's own CSS. Screenshots land in `tmp/viewport/`
alongside a `results.json`; they are artifacts for a human to look at when
something fails, not the evidence. The assertions are the evidence.
The target is deliberately outside `make check`: it needs Docker and takes
minutes, and `make test` has to stay under 20 seconds.
## How the widths are chosen
Not from a list of phone models. `viewports.js` parses the `@media` conditions
out of `src/styles.css` and scans `src/main.js` and `index.html` for Tailwind
responsive prefixes, then tests every breakpoint it finds at one pixel below it,
exactly on it, and one pixel above it. A generic 375px "phone" test sails
straight past an off-by-one at a media query boundary; `max-width: 768px`
matches _at_ 768, and the sweep pins down which side of that line each layout is
on.
Nothing hardcodes 768. Add a second media block or start using `md:` classes and
the new breakpoint is covered without this directory being touched. The app is
desktop-first today (all narrow rules live in `max-width` blocks); a
`min-width`-only, mobile-first set is handled as its inverse, and a set that
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
- **app-rendered** — enough host rows exist and enough of them show a numeric
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. 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
44x44 CSS px. That is the figure in Apple's Human Interface Guidelines and in
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 browser container runs on an `--internal` docker network and has no route to
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; 24 identical `---` placeholders would not exercise it.
## What this cannot verify
Real limits, so nobody re-parks this issue as needing hardware:
- **Non-Chromium engines.** This is Chrome. iOS Safari is WebKit and cannot be
emulated by it; Safari-specific bugs (viewport units under a collapsing URL
bar, `-webkit-fill-available`, form control metrics) will not show up here.
- **Real touch input.** `hasTouch` emulation changes what the page is told, not
how a finger behaves. Gesture handling, scroll momentum, double-tap zoom and
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
anything overflow, is content clipped, are the controls big enough — is a
function of viewport width and CSS, and is covered above.
## Relation to the unit test framework (#21)
Complementary layers, not two stacks. `vitest` (#21) will exercise module-level
logic in-process with no browser. This harness exercises rendered layout in a
real engine and is the only thing here that can see a media query. Neither
replaces the other; assertions about computed styles and element geometry belong
here, assertions about functions belong in `vitest`.
+245
View File
@@ -0,0 +1,245 @@
// Pass/fail decisions for the responsive-layout harness.
//
// Kept in node rather than in the page so that a failure can be reported
// with the measurements that produced it. Every check runs at every
// viewport; none of them short-circuits, so one failure does not hide the
// rest.
// Minimum tap target, in CSS pixels. 44x44 is the figure in Apple's Human
// Interface Guidelines and in 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. Held at
// 44 deliberately.
export const MIN_TAP_TARGET_PX = 44;
// The controls named in the definition of done, plus the pause button.
// Each carries the smallest number of *visible* instances the page has to
// contain for the tap-target oracle to be measuring anything at all.
//
// Without those floors the check is inert: `undersized` is empty both when
// every control is large enough and when the selectors have gone stale and
// matched nothing, and the pass condition cannot tell those apart. A single
// combined floor would not be enough either — 26 pin buttons would cover
// for all three singleton controls vanishing at once — so the floor is per
// selector, and one stale selector out of four fails the check.
export const INTERACTIVE_CONTROLS = [
{ selector: "#pause-btn", minCount: 1 },
{ selector: "#interval-select", minCount: 1 },
// One per pinnable host row. `app-rendered` already requires at least
// 10 host rows, so a count below that means the pin buttons stopped
// being rendered per row rather than that there were fewer hosts.
{ selector: ".pin-btn", minCount: 10 },
{ selector: "#debug-toggle", minCount: 1 },
];
export const INTERACTIVE_SELECTORS = INTERACTIVE_CONTROLS.map(
(control) => control.selector,
);
// A host row is only "reflowed" if it stacked *and* went full width.
// A row that merely shrank its 420px info column would keep
// flex-direction: row, and a row that stacked but left the info column at
// its fixed width would fail the width test.
const FULL_WIDTH_FRACTION = 0.9;
function summarise(items, format, limit = 3) {
const shown = items.slice(0, limit).map(format).join("; ");
const rest = items.length > limit ? ` (+${items.length - limit} more)` : "";
return shown + rest;
}
// Collapse an overflow report to the elements actually responsible.
// Identical elements (24 host rows all doing the same thing) are counted
// rather than listed, and the deepest ones come first, since every
// ancestor of an overflowing element also reports as overflowing.
function deepestOffenders(entries) {
const byElement = new Map();
for (const entry of entries) {
const reach = entry.reach ?? entry.right;
const existing = byElement.get(entry.el);
if (existing) {
existing.count += 1;
existing.reach = Math.max(existing.reach, reach);
} else {
byElement.set(entry.el, { ...entry, reach, count: 1 });
}
}
return [...byElement.values()].sort(
(a, b) => b.depth - a.depth || b.reach - a.reach,
);
}
function checkRowLayout(row, expectStacked) {
if (expectStacked) {
if (row.flexDirection !== "column") {
return `row ${row.index}: flex-direction is ${row.flexDirection}, expected column`;
}
if (row.sparkline.top < row.info.bottom - 1) {
return `row ${row.index}: sparkline top ${row.sparkline.top} is above info bottom ${row.info.bottom} — still side by side`;
}
const minWidth = row.containerWidth * FULL_WIDTH_FRACTION;
if (row.info.width < minWidth) {
return `row ${row.index}: info block is ${row.info.width}px of ${row.containerWidth}px — shrunk, not reflowed`;
}
if (row.sparkline.width < minWidth) {
return `row ${row.index}: sparkline is ${row.sparkline.width}px of ${row.containerWidth}px — shrunk, not reflowed`;
}
return null;
}
if (row.flexDirection !== "row") {
return `row ${row.index}: flex-direction is ${row.flexDirection}, expected row`;
}
if (row.sparkline.left < row.info.right - 1) {
return `row ${row.index}: sparkline left ${row.sparkline.left} overlaps info right ${row.info.right} — not side by side`;
}
return null;
}
export function evaluateChecks(facts, viewport, probes) {
const checks = [];
const check = (name, ok, detail) => checks.push({ name, ok, detail });
// Guard against the whole harness passing vacuously because the page
// never rendered. Everything below is only meaningful if this holds.
check(
"app-rendered",
facts.rowCount >= 10 && facts.numericLatencies >= 5,
`${facts.rowCount} host rows, ${facts.numericLatencies} showing a numeric latency`,
);
const viewportWidth = Math.min(facts.innerWidth, facts.documentClientWidth);
const culprits = deepestOffenders([
...facts.overflowing,
...facts.contentOverflowing,
]);
check(
"no-horizontal-overflow",
facts.documentScrollWidth <= viewportWidth,
`documentElement.scrollWidth ${facts.documentScrollWidth} vs viewport ${viewportWidth}` +
(culprits.length === 0
? ""
: "; widest content: " +
summarise(
culprits,
(c) =>
`${c.el} reaches ${Math.round(c.reach)}px${c.count > 1 ? ` (x${c.count})` : ""}`,
)),
);
check(
"nothing-past-viewport-edge",
facts.overflowing.length === 0,
facts.overflowing.length === 0
? "no element extends past the viewport"
: `${facts.overflowing.length} element(s) past the edge: ` +
summarise(
facts.overflowing,
(o) => `${o.el} spans ${o.left}..${o.right}`,
),
);
check(
"no-clipped-text",
facts.clipped.length === 0,
facts.clipped.length === 0
? "no element hides text behind overflow (deliberate ellipsis excluded)"
: `${facts.clipped.length} element(s) clipping text: ` +
summarise(
facts.clipped,
(c) =>
`${c.el} scrollWidth ${c.scrollWidth} > clientWidth ${c.clientWidth}`,
),
);
if (viewport.touch) {
// Presence first: a selector that matches nothing contributes no
// undersized targets, so without this the check would report
// "all 0 controls are at least 44x44" and pass.
const seen = new Map();
for (const target of facts.tapTargets) {
seen.set(target.selector, (seen.get(target.selector) ?? 0) + 1);
}
const missing = INTERACTIVE_CONTROLS.filter(
(control) => (seen.get(control.selector) ?? 0) < control.minCount,
);
const undersized = facts.tapTargets.filter(
(t) => t.width < MIN_TAP_TARGET_PX || t.height < MIN_TAP_TARGET_PX,
);
const bySelector = new Map();
for (const target of undersized) {
const existing = bySelector.get(target.selector);
if (!existing || target.width * target.height < existing.area) {
bySelector.set(target.selector, {
...target,
area: target.width * target.height,
count: (existing?.count ?? 0) + 1,
});
} else {
existing.count += 1;
}
}
const detail = [];
if (missing.length > 0) {
detail.push(
"oracle is not measuring the page: " +
summarise(
missing,
(c) =>
`${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount}`,
4,
),
);
}
detail.push(
undersized.length === 0
? `${facts.tapTargets.length} controls measured, all at least ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}`
: `${undersized.length} of ${facts.tapTargets.length} controls below ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}: ` +
summarise(
[...bySelector.values()],
(t) =>
`${t.selector} ${t.width}x${t.height}${t.count > 1 ? ` (x${t.count})` : ""}`,
4,
),
);
check(
`tap-targets-${MIN_TAP_TARGET_PX}px`,
missing.length === 0 && undersized.length === 0,
detail.join("; "),
);
}
const badRows = facts.rows
.map((row) => checkRowLayout(row, viewport.expectStacked))
.filter(Boolean);
check(
viewport.expectStacked ? "host-rows-stacked" : "host-rows-side-by-side",
facts.rows.length > 0 && badRows.length === 0,
facts.rows.length === 0
? "no host rows were measured"
: badRows.length === 0
? `all ${facts.rows.length} rows laid out as expected`
: `${badRows.length} of ${facts.rows.length} rows wrong: ` +
summarise(badRows, (r) => r),
);
// The mobile early-return path proposed in #8 was rejected: narrow
// viewports must keep probing and keep detecting the gateway, not
// quietly skip work.
check(
"probing-still-runs",
probes.attempted > 0,
`${probes.attempted} outbound probe requests issued`,
);
check(
"gateway-detection-still-runs",
facts.gatewayDetected,
facts.gatewayDetected
? "Local Gateway row present"
: "no Local Gateway row — gateway detection did not run or did not complete",
);
return checks;
}
+184
View File
@@ -0,0 +1,184 @@
// Layout facts collected from inside the page.
//
// This function is serialised and evaluated in the browser, so it must be
// entirely self-contained: no imports, no closures over module scope. It
// only *measures*; every pass/fail decision is made back in node by
// checks.js, so failures can be reported with real numbers attached.
export function collectLayoutFacts(options) {
const describe = (el) => {
const id = el.id ? "#" + el.id : "";
const classes =
typeof el.className === "string" && el.className.trim()
? "." + el.className.trim().split(/\s+/).slice(0, 3).join(".")
: "";
return el.tagName.toLowerCase() + id + classes;
};
const round = (n) => Math.round(n * 10) / 10;
// Overflow propagates up every ancestor, so a single wide element
// reports as body, #app, the row, and so on. Depth lets the report
// name the deepest — that is, the actual — offender.
const depthOf = (el) => {
let depth = 0;
for (let node = el.parentElement; node; node = node.parentElement) {
depth++;
}
return depth;
};
const isVisible = (el) => {
const style = getComputedStyle(el);
if (style.display === "none") return false;
if (style.visibility === "hidden") return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const innerWidth = window.innerWidth;
const clientWidth = document.documentElement.clientWidth;
// Under mobile emulation Chrome lets window.innerWidth *grow* to the
// width of overflowing content, exactly as a phone zooms out to fit a
// too-wide page. Measuring against it would therefore hide the
// overflow it is supposed to expose: at a 320px device width a page
// that spills to 350 reports innerWidth 350 and looks clean. Every
// comparison below is against the layout viewport instead.
const viewportWidth = Math.min(innerWidth, clientWidth);
const elements = Array.from(document.querySelectorAll("body *"));
// Elements sticking out past the right (or left) edge of the viewport.
// The document-level scrollWidth check says *that* the page overflows;
// this says *what* is doing it.
const overflowing = [];
// Elements clipping their own text. Deliberate ellipsis truncation
// (Tailwind's `truncate`) is opt-in and excluded: it is a design
// choice, not breakage.
const clipped = [];
// Elements whose content spills out of their own box without being
// clipped, past the right edge of the viewport. A block element is
// only ever as wide as its container, so text overflowing it has no
// element rect of its own to catch — but it is exactly what drags
// documentElement.scrollWidth past the viewport width, so without
// this the page-level overflow failure has nothing to point at.
const contentOverflowing = [];
for (const el of elements) {
if (!isVisible(el)) continue;
const rect = el.getBoundingClientRect();
if (rect.right > viewportWidth + 1 || rect.left < -1) {
overflowing.push({
el: describe(el),
depth: depthOf(el),
left: round(rect.left),
right: round(rect.right),
});
}
const style = getComputedStyle(el);
const clips =
style.overflowX === "hidden" || style.overflowX === "clip";
const ellipsis = style.textOverflow === "ellipsis";
const hasText = el.textContent.trim().length > 0;
const spills =
el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 1;
if (clips && !ellipsis && hasText && spills) {
clipped.push({
el: describe(el),
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
});
}
if (
!clips &&
spills &&
rect.left + el.scrollWidth > viewportWidth + 1
) {
contentOverflowing.push({
el: describe(el),
depth: depthOf(el),
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
reach: round(rect.left + el.scrollWidth),
});
}
}
// Interactive controls. The measured target is the nearest thing that
// is genuinely tappable — for a checkbox that is the <label> wrapping
// it, which is larger than the box itself and is what a finger hits.
const tapTargets = [];
for (const selector of options.interactiveSelectors) {
for (const el of document.querySelectorAll(selector)) {
if (!isVisible(el)) continue;
const target = el.closest("button, a, label, select") || el;
const rect = target.getBoundingClientRect();
tapTargets.push({
selector,
el: describe(target),
width: round(rect.width),
height: round(rect.height),
});
}
}
// Host rows. The question is not "did it get narrower" but "did it
// reflow": the info block and the sparkline must end up stacked
// vertically and full width in the narrow layout, and side by side in
// the wide one. Both the computed flex-direction and the actual
// geometry are recorded so a row that claims to be a column but is
// still laid out side by side cannot slip through.
const rows = [];
for (const row of document.querySelectorAll(".host-row")) {
const inner = row.firstElementChild;
if (!inner) continue;
const sparkline = inner.querySelector(".sparkline-container");
const info = sparkline ? sparkline.previousElementSibling : null;
if (!sparkline || !info) continue;
const innerStyle = getComputedStyle(inner);
const innerRect = inner.getBoundingClientRect();
const infoRect = info.getBoundingClientRect();
const sparkRect = sparkline.getBoundingClientRect();
rows.push({
index: row.dataset.index,
flexDirection: innerStyle.flexDirection,
containerWidth: round(innerRect.width),
info: {
left: round(infoRect.left),
right: round(infoRect.right),
bottom: round(infoRect.bottom),
width: round(infoRect.width),
},
sparkline: {
left: round(sparkRect.left),
top: round(sparkRect.top),
width: round(sparkRect.width),
},
});
}
const localRows = Array.from(
document.querySelectorAll("#local-hosts .host-row"),
);
return {
innerWidth,
// innerWidth includes any classic scrollbar, clientWidth does not.
// Reported separately so the overflow check can hold itself to the
// narrower of the two rather than to whichever one is more
// forgiving.
documentClientWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
overflowing,
contentOverflowing,
clipped,
tapTargets,
rows,
rowCount: document.querySelectorAll(".host-row").length,
numericLatencies: Array.from(
document.querySelectorAll(".latency-value"),
).filter((el) => /\d/.test(el.textContent)).length,
gatewayDetected: localRows.some((row) =>
row.textContent.includes("Local Gateway"),
),
};
}
+258
View File
@@ -0,0 +1,258 @@
// Responsive-layout harness.
//
// Drives the built frontend in a real, containerised, digest-pinned Chrome
// over CDP and asserts on computed layout at every viewport width derived
// from the app's own CSS. Screenshots are written alongside as artifacts;
// they are not the evidence, the assertions are.
//
// This is not meant to be run by hand. `make frontend-viewport-test` brings
// up the browser and the web server and then runs this; every input it
// needs arrives in the environment.
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import puppeteer from "puppeteer-core";
import { collectLayoutFacts } from "./facts.js";
import { evaluateChecks, INTERACTIVE_SELECTORS } from "./checks.js";
import { deriveViewports } from "./viewports.js";
function required(name) {
const value = process.env[name];
if (!value) {
throw new Error(
`${name} is not set; run this via script/frontend-viewport-test`,
);
}
return value;
}
const ROOT = required("NETWATCH_ROOT");
const BASE_URL = required("NETWATCH_BASE_URL");
const CDP_URL = required("NETWATCH_CDP_URL");
const ARTIFACT_DIR = required("NETWATCH_ARTIFACT_DIR");
const BROWSER_TIMEOUT_MS = 60000;
const PAGE_TIMEOUT_MS = 30000;
// Canned responses for the app's outbound latency probes. The browser
// container sits on an --internal docker network and physically cannot
// reach the internet, so nothing here is about blocking traffic; it is
// about determinism. Real probes would render 24 rows of whatever the
// network happened to be doing. These delays make the rows show a
// realistic spread of value widths — one, two and three digit latencies,
// plus some unreachable rows — because that spread is what the layout has
// to survive.
const PROBE_DELAYS_MS = [2, 45, 123, 456, 780];
// One in every UNREACHABLE_MODULUS probes is failed outright so that the
// offline row rendering is exercised too.
const UNREACHABLE_MODULUS = 7;
// The gateway candidate that "answers", so gateway detection succeeds and
// the Local Gateway row renders. Matches GATEWAY_CANDIDATES in src/main.js.
const RESPONSIVE_GATEWAY = "http://192.168.1.1";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function stableHash(text) {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = (hash * 31 + text.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
async function connectBrowser() {
const deadline = Date.now() + BROWSER_TIMEOUT_MS;
let lastError;
for (;;) {
try {
const response = await fetch(`${CDP_URL}/json/version`);
const info = await response.json();
// The endpoint advertises whatever Host it was reached on;
// pin it back to the address we actually dialled.
const endpoint = new URL(info.webSocketDebuggerUrl);
endpoint.host = new URL(CDP_URL).host;
const browser = await puppeteer.connect({
browserWSEndpoint: endpoint.toString(),
protocolTimeout: BROWSER_TIMEOUT_MS,
});
return { browser, version: info.Browser };
} catch (error) {
lastError = error;
if (Date.now() > deadline) {
throw new Error(`browser never came up: ${lastError}`);
}
await sleep(250);
}
}
}
function installProbeResponder(page, probes) {
const respond = (request, delayMs) =>
sleep(delayMs).then(() =>
request.respond({
status: 200,
contentType: "text/plain",
body: "",
}),
);
page.on("request", (request) => {
const url = request.url();
const settle = async () => {
if (url.startsWith(BASE_URL) || url.startsWith("data:")) {
return request.continue();
}
probes.attempted++;
if (url.startsWith(RESPONSIVE_GATEWAY)) {
probes.fulfilled++;
return respond(request, 5);
}
const hash = stableHash(url);
if (hash % UNREACHABLE_MODULUS === 0) {
probes.failed++;
return request.abort("connectionfailed");
}
probes.fulfilled++;
return respond(
request,
PROBE_DELAYS_MS[hash % PROBE_DELAYS_MS.length],
);
};
// The page may be torn down while a delayed response is pending;
// that is not a harness failure.
settle().catch(() => {});
});
}
async function runViewport(browser, viewport) {
const page = await browser.newPage();
const probes = { attempted: 0, fulfilled: 0, failed: 0 };
try {
page.setDefaultTimeout(PAGE_TIMEOUT_MS);
await page.setRequestInterception(true);
installProbeResponder(page, probes);
await page.setViewport({
width: viewport.width,
height: viewport.height,
deviceScaleFactor: viewport.deviceScaleFactor,
isMobile: viewport.touch,
hasTouch: viewport.touch,
isLandscape: viewport.width > viewport.height,
});
await page.goto(BASE_URL, { waitUntil: "load" });
await page.waitForSelector(".host-row");
// The app discards its first tick as a cold start, so rows only
// carry real values from the second one. Changing the interval
// restarts the loop at 1s, which reaches a populated UI without
// waiting out two default 3s intervals — and exercises the
// interval dropdown while we are at it.
await page.select("#interval-select", "1000");
await page.waitForFunction(
() =>
Array.from(document.querySelectorAll(".latency-value")).filter(
(el) => /\d/.test(el.textContent),
).length >= 5,
);
// Let the resize/redraw handlers settle before measuring.
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
),
);
const facts = await page.evaluate(collectLayoutFacts, {
interactiveSelectors: INTERACTIVE_SELECTORS,
});
const screenshot = join(
ARTIFACT_DIR,
`${viewport.width}x${viewport.height}-${viewport.name}.png`,
);
await page.screenshot({ path: screenshot, fullPage: true });
return {
viewport,
probes,
facts,
screenshot,
checks: evaluateChecks(facts, viewport, probes),
};
} finally {
await page.close().catch(() => {});
}
}
function report(results, conditions, browserVersion) {
const label = (viewport) =>
`${viewport.width}x${viewport.height}`.padEnd(9) +
" " +
viewport.name.padEnd(24);
console.log(`browser: ${browserVersion}`);
console.log(`served from: ${BASE_URL} (built dist/)`);
console.log(
"breakpoints: " +
conditions
.map((c) => `${c.type}-width ${c.px}px (${c.source})`)
.join(", "),
);
console.log("");
let passed = 0;
let failed = 0;
for (const result of results) {
const bad = result.checks.filter((c) => !c.ok);
passed += result.checks.length - bad.length;
failed += bad.length;
// Passing viewports get one line. Detail is for failures.
console.log(
`${bad.length === 0 ? "PASS" : "FAIL"} ${label(result.viewport)} ` +
`${result.checks.length - bad.length}/${result.checks.length} checks` +
`${result.viewport.expectStacked ? " [narrow layout expected]" : ""}`,
);
for (const check of bad) {
console.log(` ${check.name}: ${check.detail}`);
}
if (bad.length > 0) {
console.log(` why this width: ${result.viewport.why}`);
console.log(` screenshot: ${result.screenshot}`);
}
}
console.log("");
console.log(
`${results.length} viewports, ${passed + failed} checks: ` +
`${passed} passed, ${failed} failed`,
);
console.log(`artifacts: ${ARTIFACT_DIR}`);
return failed;
}
async function main() {
const { conditions, viewports } = deriveViewports(ROOT);
mkdirSync(ARTIFACT_DIR, { recursive: true });
const { browser, version } = await connectBrowser();
const results = [];
try {
for (const viewport of viewports) {
results.push(await runViewport(browser, viewport));
}
} finally {
await browser.disconnect().catch(() => {});
}
writeFileSync(
join(ARTIFACT_DIR, "results.json"),
JSON.stringify({ browser: version, conditions, results }, null, 2) +
"\n",
);
const failed = report(results, conditions, version);
process.exitCode = failed === 0 ? 0 : 1;
}
await main();
+224
View File
@@ -0,0 +1,224 @@
// Viewport derivation for the responsive-layout harness.
//
// The widths tested are read out of the CSS the application actually
// ships, not taken from a list of popular phone models. A generic 375px
// "phone" test sails straight past an off-by-one error at a media query
// boundary, which is the classic way a responsive layout breaks, so
// every breakpoint found in the sources is probed three times: one pixel
// below it, exactly on it, and one pixel above it.
//
// Nothing here hardcodes 768. If someone adds a second media block or
// starts using Tailwind responsive prefixes, that breakpoint starts
// being covered without this file being edited.
import { readFileSync } from "node:fs";
import { join } from "node:path";
// Tailwind CSS v4 default breakpoints, in rem. The app currently uses
// none of these prefixes, so the whole table is inert until someone
// writes an `md:`-prefixed utility class.
const TAILWIND_BREAKPOINT_REM = {
sm: 40,
md: 48,
lg: 64,
xl: 80,
"2xl": 96,
};
// The app does not override the root font size, so rem and em in media
// queries resolve against the browser default.
const ROOT_FONT_SIZE_PX = 16;
// Extract every min-width / max-width condition from the @media blocks in
// a stylesheet. Returns e.g. [{ type: "max", px: 768, source: "..." }].
export function mediaConditionsFromCss(css, source) {
const conditions = [];
for (const block of css.matchAll(/@media([^{]+)\{/g)) {
const features = block[1].matchAll(
/\(\s*(min|max)-width\s*:\s*([\d.]+)(px|rem|em)\s*\)/g,
);
for (const feature of features) {
const scale = feature[3] === "px" ? 1 : ROOT_FONT_SIZE_PX;
conditions.push({
type: feature[1],
px: Math.round(Number(feature[2]) * scale),
source,
});
}
}
return conditions;
}
// Extract the breakpoints implied by Tailwind responsive prefixes used in
// markup. A prefix only counts when it opens a utility class, so `text-sm`
// does not masquerade as the `sm:` breakpoint.
export function mediaConditionsFromMarkup(sources) {
const conditions = [];
for (const { path, text } of sources) {
for (const [name, rem] of Object.entries(TAILWIND_BREAKPOINT_REM)) {
const used = new RegExp(
`(^|["'\\s])${name}:[a-z0-9[\\](),_./%-]+`,
"m",
).test(text);
if (used) {
conditions.push({
type: "min",
px: rem * ROOT_FONT_SIZE_PX,
source: path,
});
}
}
}
return conditions;
}
// Whether a given width should be rendering the app's narrow (stacked)
// layout.
//
// Two breakpoint styles can be answered from the condition list alone:
//
// - Desktop-first, which is what the app ships today: the wide layout is
// unconditional and every narrow rule lives in a `max-width` block, so
// a width is narrow exactly when one of those blocks matches. Note that
// `max-width: 768px` matches *at* 768 — getting this inclusive boundary
// wrong in either direction is what the three-widths-per-breakpoint
// sweep exists to catch.
// - Mobile-first, which is what Tailwind's `sm:`/`md:` prefixes are: the
// stacked layout is the unconditional base and a `min-width` block is
// what widens it, so a width is narrow exactly when it sits below every
// `min-width` breakpoint.
//
// A mix of the two cannot be resolved from the breakpoints alone — which
// block owns the host-row reflow is a property of the rules inside it, not
// of the condition — so this throws rather than guessing. Guessing is how
// the wrong expectation gets applied at the right widths and the whole
// sweep quietly verifies nothing.
export function expectsStackedLayout(width, conditions) {
const kinds = new Set(conditions.map((c) => c.type));
for (const kind of kinds) {
if (kind !== "max" && kind !== "min") {
throw new Error(
`unsupported media condition type "${kind}" in ` +
"expectsStackedLayout (test/viewport/viewports.js)",
);
}
}
if (kinds.has("max") && kinds.has("min")) {
throw new Error(
"the app now mixes max-width and min-width breakpoints (" +
conditions
.map((c) => `${c.type}-width ${c.px}px in ${c.source}`)
.join(", ") +
"), so which layout a width should be showing can no longer " +
"be inferred from the breakpoint list; teach " +
"expectsStackedLayout in test/viewport/viewports.js which " +
"block owns the host-row reflow",
);
}
if (kinds.has("min")) {
return !conditions.some((c) => width >= c.px);
}
return conditions.some((c) => width <= c.px);
}
// Viewports that are not derived from a breakpoint. Each one is here for
// a stated reason; none of them is a stand-in for "a phone".
const ANCHOR_VIEWPORTS = [
{
name: "floor-portrait",
width: 320,
height: 568,
deviceScaleFactor: 2,
touch: true,
why: "320px is the narrowest viewport still in mainstream use; nothing has to work below it",
},
{
name: "phone-landscape-narrow",
width: 667,
height: 375,
deviceScaleFactor: 2,
touch: true,
why: "phone rotated to landscape, still inside the narrow layout",
},
{
name: "phone-landscape-wide",
width: 844,
height: 390,
deviceScaleFactor: 3,
touch: true,
why: "large phone rotated to landscape: crosses into the wide layout while still being a touch device",
},
{
name: "desktop",
width: 1280,
height: 800,
deviceScaleFactor: 1,
touch: false,
why: "desktop baseline",
},
];
export function deriveViewports(root) {
const conditions = [
...mediaConditionsFromCss(
readFileSync(join(root, "src/styles.css"), "utf8"),
"src/styles.css",
),
...mediaConditionsFromMarkup([
{
path: "src/main.js",
text: readFileSync(join(root, "src/main.js"), "utf8"),
},
{
path: "index.html",
text: readFileSync(join(root, "index.html"), "utf8"),
},
]),
];
if (conditions.length === 0) {
throw new Error(
"no responsive breakpoints found in src/styles.css, src/main.js or " +
"index.html — either the responsive layout was deleted or this " +
"derivation has stopped matching the sources",
);
}
const viewports = new Map();
const add = (viewport) => {
const key = `${viewport.width}x${viewport.height}`;
if (!viewports.has(key)) viewports.set(key, viewport);
};
for (const condition of conditions) {
for (const [offset, label] of [
[-1, "below"],
[0, "at"],
[+1, "above"],
]) {
const width = condition.px + offset;
add({
name: `${condition.type}-width-${condition.px}-${label}`,
width,
// Tall enough that the whole app is laid out in one column
// without the viewport height influencing wrapping.
height: 1024,
deviceScaleFactor: 2,
touch: true,
why: `${offset === 0 ? "exactly on" : `1px ${label}`} the ${condition.type}-width: ${condition.px}px breakpoint declared in ${condition.source}`,
});
}
}
for (const anchor of ANCHOR_VIEWPORTS) add(anchor);
return {
conditions,
viewports: [...viewports.values()]
.map((viewport) => ({
...viewport,
expectStacked: expectsStackedLayout(viewport.width, conditions),
}))
.sort((a, b) => a.width - b.width || a.height - b.height),
};
}
+7
View File
@@ -7,6 +7,13 @@ const commitFull = execSync("git rev-parse HEAD").toString().trim();
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
server: {
// Proxy /api to a locally running netwatch-server so `yarn dev`
// exercises the real report-posting path.
proxy: {
"/api": "http://127.0.0.1:8080",
},
},
define: { define: {
__COMMIT_HASH__: JSON.stringify(commitHash), __COMMIT_HASH__: JSON.stringify(commitHash),
__COMMIT_FULL__: JSON.stringify(commitFull), __COMMIT_FULL__: JSON.stringify(commitFull),
+153 -1
View File
@@ -197,6 +197,14 @@
"@emnapi/runtime" "^1.7.1" "@emnapi/runtime" "^1.7.1"
"@tybys/wasm-util" "^0.10.1" "@tybys/wasm-util" "^0.10.1"
"@puppeteer/browsers@3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-3.1.0.tgz#5728ae0bc649263133ac1f8bd5d360eb75d11748"
integrity sha512-RDLpio3fH/qrj5k4DVY6eyiN8tCS0Zovd/6jW//n605oeqkWcUjn+3k+9ZtZBnbwMpsu0F7xDIiKXvVmG5c5Bw==
dependencies:
modern-tar "^0.7.6"
yargs "^18.0.0"
"@rollup/rollup-android-arm-eabi@4.57.0": "@rollup/rollup-android-arm-eabi@4.57.0":
version "4.57.0" version "4.57.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz#f762035679a6b168138c94c960fda0b0cdb00d98" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz#f762035679a6b168138c94c960fda0b0cdb00d98"
@@ -441,6 +449,16 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
ansi-regex@^6.2.2:
version "6.2.2"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1"
integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==
ansi-styles@^6.2.1:
version "6.2.3"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041"
integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==
autoprefixer@^10.4.23: autoprefixer@^10.4.23:
version "10.4.23" version "10.4.23"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.23.tgz#c6aa6db8e7376fcd900f9fd79d143ceebad8c4e6" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.23.tgz#c6aa6db8e7376fcd900f9fd79d143ceebad8c4e6"
@@ -473,16 +491,43 @@ caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001760:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a"
integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA== integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==
chromium-bidi@17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-17.0.2.tgz#921a586deecd0c2d8b9242c4c1b73c3aa39ff77c"
integrity sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==
dependencies:
mitt "^3.0.1"
zod "^3.24.1"
cliui@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291"
integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==
dependencies:
string-width "^7.2.0"
strip-ansi "^7.1.0"
wrap-ansi "^9.0.0"
detect-libc@^2.0.3: detect-libc@^2.0.3:
version "2.1.2" version "2.1.2"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
devtools-protocol@0.0.1653615:
version "0.0.1653615"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz#c600e0c619612156b2422a66d958ba188d87dbe8"
integrity sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==
electron-to-chromium@^1.5.263: electron-to-chromium@^1.5.263:
version "1.5.282" version "1.5.282"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.282.tgz#6695816e5b170210d6aa07561546ed7d97347630" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.282.tgz#6695816e5b170210d6aa07561546ed7d97347630"
integrity sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ== integrity sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ==
emoji-regex@^10.3.0:
version "10.6.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d"
integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==
enhanced-resolve@^5.18.3: enhanced-resolve@^5.18.3:
version "5.18.4" version "5.18.4"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz#c22d33055f3952035ce6a144ce092447c525f828" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz#c22d33055f3952035ce6a144ce092447c525f828"
@@ -523,7 +568,7 @@ esbuild@^0.27.0:
"@esbuild/win32-ia32" "0.27.2" "@esbuild/win32-ia32" "0.27.2"
"@esbuild/win32-x64" "0.27.2" "@esbuild/win32-x64" "0.27.2"
escalade@^3.2.0: escalade@^3.1.1, escalade@^3.2.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
@@ -543,6 +588,16 @@ fsevents@~2.3.2, fsevents@~2.3.3:
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
get-caller-file@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-east-asian-width@^1.0.0, get-east-asian-width@^1.5.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz#216900f91df11a8b2c198c3e1d93d6c035a776b9"
integrity sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==
graceful-fs@^4.2.4: graceful-fs@^4.2.4:
version "4.2.11" version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
@@ -634,6 +689,16 @@ magic-string@^0.30.21:
dependencies: dependencies:
"@jridgewell/sourcemap-codec" "^1.5.5" "@jridgewell/sourcemap-codec" "^1.5.5"
mitt@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1"
integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==
modern-tar@^0.7.6:
version "0.7.7"
resolved "https://registry.yarnpkg.com/modern-tar/-/modern-tar-0.7.7.tgz#ca71d79603630076b10733b0751ccab284bbc1ef"
integrity sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==
nanoid@^3.3.11: nanoid@^3.3.11:
version "3.3.11" version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
@@ -673,6 +738,18 @@ prettier@^3.8.1:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
puppeteer-core@25.5.0:
version "25.5.0"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-25.5.0.tgz#a41b14d582056b998e0bc3561ac47c7615d38a53"
integrity sha512-XPNT0dQJtphqQ4I29zxlG4IIPbg1iEHAQKWuQgtMJGXjACV77pZSmJvDi51IIIfd+DTKICcopJwUx4upVQ4XbA==
dependencies:
"@puppeteer/browsers" "3.1.0"
chromium-bidi "17.0.2"
devtools-protocol "0.0.1653615"
typed-query-selector "^2.12.2"
webdriver-bidi-protocol "0.4.2"
ws "^8.21.1"
rollup@^4.43.0: rollup@^4.43.0:
version "4.57.0" version "4.57.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853"
@@ -712,6 +789,30 @@ source-map-js@^1.2.1:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
string-width@^7.0.0, string-width@^7.2.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc"
integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==
dependencies:
emoji-regex "^10.3.0"
get-east-asian-width "^1.0.0"
strip-ansi "^7.1.0"
string-width@^8.2.1:
version "8.2.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-8.2.2.tgz#7310516493df575742fe98af6fae87d85d5ed0ac"
integrity sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==
dependencies:
get-east-asian-width "^1.5.0"
strip-ansi "^7.1.2"
strip-ansi@^7.1.0, strip-ansi@^7.1.2:
version "7.2.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3"
integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==
dependencies:
ansi-regex "^6.2.2"
tailwindcss@4.1.18, tailwindcss@^4.1.18: tailwindcss@4.1.18, tailwindcss@^4.1.18:
version "4.1.18" version "4.1.18"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.1.18.tgz#f488ba47853abdb5354daf9679d3e7791fc4f4e3" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.1.18.tgz#f488ba47853abdb5354daf9679d3e7791fc4f4e3"
@@ -735,6 +836,11 @@ tslib@^2.4.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
typed-query-selector@^2.12.2:
version "2.12.2"
resolved "https://registry.yarnpkg.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz#65e2462ac6b0aecfae1bfac1a4f3027070dbabaa"
integrity sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==
update-browserslist-db@^1.2.0: update-browserslist-db@^1.2.0:
version "1.2.3" version "1.2.3"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
@@ -756,3 +862,49 @@ vite@^7.3.1:
tinyglobby "^0.2.15" tinyglobby "^0.2.15"
optionalDependencies: optionalDependencies:
fsevents "~2.3.3" fsevents "~2.3.3"
webdriver-bidi-protocol@0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz#f51bb71c2606e90e3d5727607c728b25d617b58b"
integrity sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==
wrap-ansi@^9.0.0:
version "9.0.2"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98"
integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==
dependencies:
ansi-styles "^6.2.1"
string-width "^7.0.0"
strip-ansi "^7.1.0"
ws@^8.21.1:
version "8.21.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc"
integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==
y18n@^5.0.5:
version "5.0.8"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yargs-parser@^22.0.0:
version "22.0.0"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8"
integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==
yargs@^18.0.0:
version "18.1.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.1.0.tgz#cd7e98c703ef51695bbbf062ed58f28e94291b56"
integrity sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==
dependencies:
cliui "^9.0.1"
escalade "^3.1.1"
get-caller-file "^2.0.5"
string-width "^8.2.1"
y18n "^5.0.5"
yargs-parser "^22.0.0"
zod@^3.24.1:
version "3.25.76"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==