diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d206d0..019eb53d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **`@wavehouse/sdk` `engines.node` floor back to `>=22`, matching the only line we test** (`clients/ts/package.json`, `clients/ts/README.md`, `docs/src/content/docs/sdk/index.mdx`, `docs/src/content/docs/sdk/queries.md`, `pnpm-workspace.yaml`): the floor was relaxed to `>=18` when the browser-first distribution landed (see the entry below), on the reasoning that the runtime needs only `fetch`. Nothing ever tested 18, though — `.nvmrc` pins 22 and `.github/actions/setup-env` consumes it via `node-version-file`, so 22 is the single version CI exercises — and Node 18 and 20 have both since reached upstream end-of-life. Declaring a floor we neither test nor is supported upstream promises more than it can back, so it returns to `>=22`. **Consumer impact:** installing on Node < 22 now warns with `EBADENGINE` under npm, and fails outright under pnpm with `engine-strict` enabled. The SDK README and the docs' Runtime support section state the requirement, which they previously either omitted or quoted as 18. + - **Live SSE events are now projected and serialized once per role instead of once per subscriber** (`internal/stream/hub.go` (new), `internal/stream/{subscriber,bucket,heartbeat,metrics,doc}.go`, `internal/api/stream.go`, `internal/api/hub.go` + `internal/api/transform.go` (both removed — the broadcast hub moves to `internal/stream`, and the orphaned test-only `transformForClient` is dropped), `cmd/wavehouse/main.go`, `docs/src/content/docs/architecture.md`, `AGENTS.md`, plus tests in `internal/stream/{hub,filter,subscriber,bucket,heartbeat}_test.go` and `internal/api/{stream,transform,router,errors}_test.go`): the first PR of the SSE delivery-path throughput epic ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)), building on the `internal/stream` primitives from #346. The broadcast hub moves into `internal/stream` as `Hub`: subscribers register under `(topic, role)`, and `Broadcast` decodes each event **once**, applies each subscribed role's column policy **once**, builds one SSE frame per role, and fans it to every member of that role's `Bucket`. Previously every connection independently ran `json.Unmarshal → policy.Evaluate → filterEventColumns → json.Marshal` (plus a second unmarshal just to read the `id:` timestamp) on the *same* event in its own read loop — byte-identical work repeated N times. For a single-role audience (the public dashboard, every viewer `public`) that collapses N re-projections to 1, moving the measured ~2 270 deliveries/s ceiling toward an events/s ceiling. The `(topic, role)` key is sufficient and claims-independent: column visibility derives only from the role+table policy entry, and the stream path applies no row-level filter (a documented invariant — if row-level filtering is ever added to streaming, the key must take claims into account). The handler's two `select` cases (keepalive vs. per-subscriber event) collapse into one byte-pump over a single `Subscriber.Frames()` queue carrying typed `Frame`s; the subscriber queue grows from cap 1 (keepalive-only) to 64 so live events buffer while the handler is mid-write. Gap-fill replay and `Last-Event-ID`/`?since=` resumption are unchanged (replay stays per-connection via the shared `stream.ReplayFrame`; live frames carry the same `id: `). Slow-consumer drops, silent before, now increment `wavehouse_sse_dropped_frames_total`; an inert `Subscriber.Evicted()` seam is wired for the eviction follow-up. The per-delivery OpenTelemetry span (another #294 item) was already removed in #346. **Deferred to follow-ups:** active slow-consumer eviction (#94) and right-sizing the subscriber buffer + broadcast lock cost (#152). - **CI is now a job DAG instead of one monolithic job, and the docs deploys no longer expose the Cloudflare token to PR-authored code** (`.github/workflows/ci.yml`, `.github/workflows/housekeeping.yml`, `.github/actions/setup-env/action.yml`, `Makefile`, `docs/wrangler.jsonc`, `AGENTS.md`, `docs/src/content/docs/development.md`, `docs/src/content/docs/claude-code.md`, `CONTRIBUTING.md`, `scripts/lint-pr-title.sh`, `.claude/hooks/agent-bash-gate.sh`): closes #305. The single `make ci` job becomes parallel jobs over the *same Makefile targets* (local `make ci` stays the dev mirror): `lint`, `unit`, `integration`, `e2e` (builds its own SDK dist + cover binary via `make -j test-e2e` on a warm per-suite cache and runs the suite exactly like a local run), `coverage` (a dedicated job that merges every suite's `coverage-` fragment and applies every threshold gate via `make cov` — like local `make ci`'s final step, so the gate is decoupled from the e2e suite; it's `needs: changes` only and *polls* for the fragments rather than `needs`-ing the suites, so its setup overlaps them and the merge fires ~10s after the last suite instead of serializing ~50s of setup onto the critical path), and `docs-build` (`make build-docs`, docs-affecting changes only, uploads the docs dist artifact the preview/deploy jobs consume) — public-repo runners are free and 4-core, so the pipeline spreads horizontally instead of queueing in one process. The architecture is documented once, in `.github/workflows/README.md` (DAG diagram, design invariants, cache key policy, add-a-job recipe, and the measured-but-deferred optimizations — e2e sharding among them), and the workflow's logic lives in shellcheck-gated scripts (`scripts/ci/` — `classify-changes.sh`, `check-pr-title.sh`, `docs-preview-comment.sh`, `timing-summary.sh`, `wait-artifact.sh`; over the shared, dependency-free path classifier `scripts/classify-paths.sh`, unit-tested by `scripts/classify-paths.test.sh` via `make test-classify-paths` and reused by the `pre-push` git hook so a docs/prose-only push requires only `make verify`, not a full `make ci` — the same suites CI skips for those changes) rather than inline YAML; caches are owned end-to-end by `setup-env` via nested `actions/cache` (automatic post-job saves — the per-job save-step boilerplate is gone); a non-gating `Timing summary` job writes a per-job wall-clock table to every run's Summary page; and `make verify` gains two leaves that gate the new surface area — `lint-sh` (shellcheck `v0.11.0`, checksum-verified install via `scripts/install-shellcheck.sh`) and `lint-gha` (actionlint `v1.7.12`) — so the CI plumbing is linted like any other source. The workflow also handles `merge_group` events (full suite against the merge-group ref), enabling a **merge queue** on `main`: the queue re-tests each PR against current main at landing time, which replaces the ruleset's "require branches to be up to date" rule — no more manual branch updates after every sibling merge. A new aggregator job named `CI` is the ruleset's **sole required status check** (it fails on any failed/cancelled job and counts skipped jobs as passing), so docs-only PRs skip the Go suites without orphaning the gate and future job changes never require ruleset edits. The PR-title (Conventional Commits) gate moves into the `PR title` job under that aggregator, validated by the same `scripts/lint-pr-title.sh` from a trusted `main` checkout; `PR housekeeping` (`pull_request_target`) drops to non-required and keeps what needs fork-PR write access — path labels, the sticky title-explainer comment, and a new nudge that re-runs the failed `PR title` job when a title edit fixes it (the job re-reads the title from the API, so no new push is needed). The **#305 fix**: docs previews/production deploys run in dedicated `docs-preview`/`docs-deploy` jobs that check out trusted `main` (wrangler, worker source, and config never resolve from the PR tree), consume only the static `docs/dist` artifact, and are the only jobs that reference `CLOUDFLARE_*` secrets; previews now publish right after `docs-build` instead of waiting on the full test pipeline, and the `docs-preview` deploy is **non-gating** — it's not in the `CI` aggregator's `needs` (only `docs-build` gates), so a slow or failed Cloudflare preview reports its own "Docs preview" check but never delays or reds the required check; production (`docs-deploy`, on the post-merge main push) still requires everything green. Per-job least-privilege permissions replace the old workflow-wide `contents: write`, and the Go build cache is partitioned per job (unit/integration/e2e compile with different flags) so each suite stays warm. @@ -53,6 +55,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **SSE row `DateTime` columns now arrive in canonical RFC 3339 UTC, matching `/v1/query`, instead of the producer's spelling** (`internal/discovery/timestamp.go` (new), `internal/discovery/{discovery.go,validation.go}`, `internal/api/{ingest.go,clickhouse_exec.go}`, `internal/ingest/worker.go`, `docs/src/content/docs/{api.md,architecture.md,ingest-pipeline.md,sdk/streaming.md}`, plus tests in `internal/discovery/{timestamp_test.go (new),discovery_test.go}`, `internal/api/{ingest_test.go,boot_chain_test.go}`, `tests/e2e/sdk/streaming.test.ts`): closes #372. The stream fans out the pre-insert payload verbatim, so a row `DateTime` reached SSE subscribers in whatever spelling the producer sent — typically the zone-less ClickHouse-native form — while `/v1/query` rendered the stored value as RFC 3339 `Z`; JavaScript's `Date.parse` reads a zone-less string as **local** time, so an SDK `liveQuery` stitching backfill + live landed the two paths hours apart (sign-flipping with the viewer's UTC offset — broke ordering and the "N min ago" label in the Stats live feed). Ingest now **canonicalizes** every top-level `DateTime`/`DateTime64` column value in the accepted input forms to RFC 3339 UTC (fraction truncated to the column's precision, `time.RFC3339Nano`-trimmed — byte-matching `/v1/query`'s `time.Time` marshaling) after validation and before the NATS publish, so the one payload every consumer shares — SSE subscribers, the ClickHouse insert, the DLQ — carries a single unambiguous spelling for every value it rewrote (a fail-open pass-through keeps the producer's spelling on that shared path, while `/v1/query`, rendering from storage, stays canonical — so for a pass-through ClickHouse accepts, the streamed and queried renderings diverge). Inputs stay liberal but are mirrored per column kind, exactly as ClickHouse reads them (#402 review): RFC 3339 with any offset (`.`-fractions only — ClickHouse has no `,` separator), `YYYY-MM-DD[ T]HH:MM:SS[.fff]`, `YYYY-MM-DD`, a Unix-seconds string of 9–10 digits (a fraction after it is honored only for `DateTime64` columns, parsed as an exact decimal and truncated at nine digits — never a `float64` round-trip, which corrupts nanoseconds and can round across the second; other digit lengths are ClickHouse's own forms — calendar `YYYYMMDD`/`YYYYMMDDhhmmss` or its 13/16/19-digit ms/µs/ns epochs — and pass through), and **integer** JSON numbers read the way ClickHouse reads them: Unix seconds for `DateTime`, **ticks at the column's scale** for `DateTime64` (the ms epoch `1750478400500` into a `DateTime64(3)` is a valid 2025 instant; an epoch-*seconds* number there is a 1970 instant — rewriting either as seconds would change what ClickHouse stores; non-integer numbers pass through, ClickHouse rejects them). Values whose instant lies outside the column type's range also pass through — ClickHouse *saturates* out-of-range values spelling-dependently (local time-of-day is kept while the date clamps; a `DateTime64(9)` column even rejects the insert past the Int64-nanosecond ceiling, a bound WaveHouse's rewrite window conservatively applies at precision ≥ 7), so only the producer's own spelling may be the one that saturates. Go `LoadLocation`'s `''`/`'Local'` environment quirks are rejected as unresolvable zones. A zone-less string is interpreted in the column's declared time zone, else the server's (discovered once per schema refresh via `SELECT timezone()`) — the same rule ClickHouse itself applies, so canonicalization never changes which instant is stored (differentially fuzzed against a live ClickHouse — ~35k generated inputs × six column shapes, raw vs canonicalized inserts, zero divergences — with every divergence class found along the way pinned in `tests/integration/timestamp_canonicalization_test.go`). Each timestamp column's spec (precision + resolved zone) is precomputed at schema discovery and cached on the column, so the per-record ingest path parses no type strings, loads no zones, and takes no locks. Canonicalization is **fail-open** (#402 review): a value outside the accepted forms publishes verbatim — ClickHouse's more liberal `best_effort` parser stays the arbiter of insertability, and a value it too rejects surfaces via the DLQ as before — and an unresolvable zone (no tzdata embedded — named zones resolve from the runtime's zone database, which the bundled distroless images ship) warns and passes through, never a failed refresh or a silent UTC reinterpretation that would move instants (`Etc/UTC` is special-cased to UTC); ingest never rejects a record over its timestamp spelling — fail-closed enforcement of the canonical form is the stream row-filter's (#381). The worker's `INSERT … FORMAT JSONEachRow` pins `date_time_input_format=best_effort` — the server default since ClickHouse 26.5, and on older servers the `basic` default rejects the canonical form's zone suffix (verified live); pre-upgrade messages still in NATS — zone-less strings, Unix numbers — parse exactly as before under either setting (bare digit-strings outside the 9–10-digit Unix-seconds shape are the one divergence: `best_effort` reads them as calendar/epoch forms where `basic` read Unix seconds). Boundaries: `Date`/`Date32` columns pass through untouched (day precision, no zone ambiguity on this path); events published before the upgrade replay in their original spelling until the sweeper retires them; a column with an explicit non-UTC zone renders as `Z` on both paths (`/v1/query` likewise normalizes `DateTime` values to UTC), so the declared zone affects how zone-less inputs are read but never the output spelling — byte-identical regardless of zone or `Nullable` wrapping (`/v1/query` unwraps nullable timestamps to the same UTC form; a SQL `NULL` renders as JSON `null`). +- **The E2E harness enforces its own poll budgets, and no longer inherits an idle pooled connection** (`tests/e2e/sdk/helpers.ts`, `tests/e2e/sdk/helpers.test.ts` (new), `tests/e2e/sdk/setup.ts`, `tests/e2e/sdk/vitest.config.ts`, `tests/e2e/sdk/{batching,cache,dlq,ingest,ndjson,query,stress}.test.ts`, `scripts/orchestrator/main.go`, `docs/src/content/docs/development.md`, `docs/src/content/docs/sdk/reference.md`): closes #440. Two defects, the first of which hid the second. `waitForCondition` checked the clock only on loop entry, so a single slow `fn()` overran the advertised budget without bound — a 10s budget was measured running 28s, past the caller's `testTimeout`, so vitest killed the test first and reported a timeout naming neither the condition nor how long the poll actually waited. It now races `fn()` against the deadline, aborts the in-flight call via an `AbortSignal` handed to `fn`, and reports poll shape on failure (`N poll(s), slowest Xms`) — which separates "the write never landed" (many fast polls) from "the polling itself was starved" (few slow ones). That reporting is what exposed the second defect: `chQuery` used the global `fetch`, which reuses pooled connections, and undici 8.8.0–8.9.0 stalls for seconds before writing a request onto a socket that has been idle a few seconds ([nodejs/undici#5600](https://github.com/nodejs/undici/issues/5600), a scheduling regression in `scheduleIdleSocketValidation()`, fixed in 8.10.0). This suite has multi-second idle gaps by construction — the 5s ingest linger sits between every write and the first poll of its visibility wait — so every visibility wait sat in the triggering window; local `make test-e2e` went from 2 pass/3 fail to 5 pass/0 fail, and every run got faster (115.7–124.5s vs 128.7–137.1s). Node 26 bundles undici 8.9.0 while CI runs Node 22 (undici 6.28.0) via `.nvmrc`, which is why this was invisible to CI and to developers on other Node lines. `chQuery` additionally takes a per-request ceiling (`E2E_CH_QUERY_TIMEOUT_MS`, default 10 000 ms) and honours the caller's signal, threaded through 19 call sites, so an abandoned poll tears its request down rather than running on unobserved. Also here: `batching`'s visibility wait had ~700ms of headroom over the 5s linger where every other wait allows 10s (widened — the `>= 4500ms` lower bound that carries the test's meaning is unchanged); the E2E setup banner prints the active node/undici version and warns when the local major differs from `.nvmrc`; the orchestrator kills any orphaned `wavehouse-cov` before starting, loudly, and aborts only if the kill itself fails (a killed run leaves one, and it corrupts the next run through the shared `tmp/data` and log file, presenting as a dozen unrelated tests failing to see their rows in a log that blames a container which no longer exists); a new `E2E_NO_COVERAGE=1` drops `--coverage` for local debugging, ignored under the gating targets so it cannot green a coverage gate by omission; and `vitest.config.ts` moves from `__dirname` to `import.meta.dirname`, silencing the Vite 8 `configLoader: 'native'` warning. + - **Go module cache stored once instead of once per compile flavor** (`.github/actions/setup-env/action.yml`, `.github/workflows/README.md`, `.github/workflows/publish-dev.yml`, `.github/workflows/release.yml`, `Makefile`): closes [#443](https://github.com/Wave-RF/WaveHouse/issues/443). `setup-env` cached `~/go/pkg/mod` together with `~/.cache/go-build` under a key partitioned by `go-cache-suffix`, but the module cache is a pure function of `go.mod` + `go.sum` and byte-identical for every flavor — so that tree was stored five times over (`-lint`, `-unit`, `-integration`, `-e2e-cov`, `-cov`) — five entries of ~0.9-1.2 GB stored each, ~5.2 GB per generation (the tree is ~1.6 GB on disk; 0.48 GB as a stored archive on a cold save, drifting up as superseded versions accumulate). Two live generations is the steady state (a bump mints a new set while the previous is still warm), so the repo sat near GitHub's hard 10 GB cache cap; the 24-module go-deps bump ([#438](https://github.com/Wave-RF/WaveHouse/pull/438)) tipped it to 10.53 GB and GitHub began LRU-evicting warm entries mid-run. The one cache is now two: `gomod-v1--` on `~/go/pkg/mod`, **unsuffixed** and shared by every `ci.yml` Go job that goes through `setup-env`, and `gobuild-v3--go-` on `~/.cache/go-build` only, still per flavor. Measured after the split: **1.05 GB per generation** (0.48 GB module + 0.57 GB across the five build entries), down from 5.18 GB — a 4.9x reduction. All sizes are stored-archive bytes / 2^30, the unit the README's usage check prints. The `v3` bump is load-bearing — saves fire only on an exact-key miss, so without it the old `v2` entry (still carrying the module cache) would exact-hit forever and the smaller content would never be saved — and `gobuild-v3` drops the bare-prefix restore-key, which existed solely to borrow another flavor's copy of the module cache. Separately, `publish-dev.yml` and `release.yml` now pass `cache: false` to `actions/setup-go` (matching `goreleaser-validate.yml`), which was holding a sixth ~1 GB entry — the module tree `gomod-v1` already keeps once, plus that job's own 8-target cross-compile objects — re-saved on every cache miss. (That entry is keyed on the root `go.mod`: setup-go hashed `go.sum` through v6.2.0 and `go.mod` from v6.3.0, [actions/setup-go#705](https://github.com/actions/setup-go/pull/705).) `publish-dev.yml` re-caches only the half that pays for itself, under `gobuild-v3--go-release-` (~0.5 GB — the bundled entry minus `gomod-v1`'s share): across its last 20 runs GoReleaser takes 36–246 s with the cross-compile objects warm and 401–446 s cold (measured on `setup-go`'s bundled cache, which carried the same `~/.cache/go-build` tree), so dropping the cross-compile objects outright would have cost roughly 2.5–7 minutes on every push to main (mean delta ≈4.8 min). The `-release` suffix keeps those 8-target objects from being restored by CI's native-only flavors and vice versa. Because those timings were taken with setup-go's bundled entry (which also held `~/go/pkg/mod`), `publish-dev` additionally *restores* `gomod-v1` from `main`'s scope via `actions/cache/restore` — read-only, so it costs no budget and cannot write a partial tree to the key every `ci.yml` Go job shares. Without that restore the job would re-download ~112 MB of modules per push and land above the warm range quoted above. Both Go keys now hash `go.mod` alongside `go.sum`, for a different reason each: the GOTOOLCHAIN=auto toolchain lives in `~/go/pkg/mod` and `go.sum` records no entry for it, so a `go`-directive bump would otherwise exact-hit a toolchain-less archive and — saves firing only on an exact-key miss — re-download it every run; and the compiler's build ID keys every build object, so the same bump invalidates `~/.cache/go-build` too, where the failure mode is a permanent cold recompile rather than a re-download. Two guards come with the shared entry: `setup-env` now fails a `go: true` job that passes no `go-cache-suffix` (an empty one yields a restore-key prefix-matching every other flavor), and `make cov` gains the `go-mod-download` prerequisite its siblings already had — CI's coverage job shares the unsuffixed `gomod-v1` and races to save it, but ran only `go run ./scripts/cov report`, so winning that race would have stored a partial `~/go/pkg/mod` that then exact-hit for every other job until the next rotation. The workflows README gains a sizing policy — the 10 GB cap, the two-generations rule, how to check the current footprint, and the rule that lockfile-derived content is keyed once and shared — plus the narrowing-rotation exception to the key-versioning policy. - **A path prefix in the SDK's `baseURL` now survives instead of being silently discarded** (`clients/ts/src/url.ts` (new), `clients/ts/src/http.ts`, `clients/ts/src/stream/sse.ts`, `clients/ts/src/cli/codegen.ts`, `clients/ts/src/url.test.ts` (new), `clients/ts/src/stream/sse.test.ts` (new), `clients/ts/src/{http,client}.test.ts`, `docs/src/content/docs/sdk/index.mdx`, `docs/src/content/docs/reverse-proxy.mdx`): closes #428. Pointing the SDK at a WaveHouse served under a prefix — `createClient({ baseURL: 'https://app.example.com/api/warehouse' })`, the shape you get behind a BFF, an app-server route, or a path-routed ingress — dropped the prefix from every request. Both transports resolved *absolute* request paths against the base (`new URL('/v1/query', base)` in `http.ts`, `new URL('/v1/stream', baseURL)` in `stream/sse.ts`), and per the URL spec an absolute path replaces the base's path entirely, so calls went to the origin root. The failure mode was the bad kind: no error, a client that looks correctly configured, and every request quietly going somewhere else — with no workaround from outside the SDK, since `baseURL` was the only path input and it couldn't survive. Request paths are now joined **onto** the base by a single shared `resolveURL` helper that both transports and the codegen CLI call (previously three separate constructions, one of which — codegen's string concat — already handled prefixes, so they disagreed). The helper normalizes the base to a directory before resolving, so a bare last segment or a stray query/fragment on `baseURL` can't eat the prefix either, and a root-hosted base (`http://localhost:8080`, the overwhelmingly common case) resolves exactly as before. Tests pin a prefixed base end-to-end across both transports. The proxy in front must still strip the prefix before forwarding — WaveHouse has no configurable base path by design — which the reverse-proxy guide now covers with nginx/Caddy snippets. - **Live demo hero feed renders in `event_ts` order instead of SSE arrival order** (`docs/src/components/LiveDemo.astro`): the landing-page live activity feed prepended each streamed row to the top in the order the SSE stream delivered it, but a producer's webhook burst (a single merge-queue cycle fires ~20 events) arrives in no guaranteed order and the stream relays it in ingest order — so a late or out-of-order delivery landed above newer rows (e.g. a `pushed 12m ago` sitting on top of `reviewed a pull request 9m ago`). `addRow` now keeps the feed sorted by `event_ts` descending — it slots each row in before the first strictly-older sibling rather than blind-prepending — so the live tail matches the already-sorted `gh_activity_recent` backfill. The zone-less-SSE-timestamp normalization the sort relies on (`normTs`) was already in place; equal-second rows keep arrival order (`gh_events.event_ts` is only second-granular for CI/checks, so there's no finer tiebreak), and dedup + the `MAX_ROWS` trim are unchanged. Surfaced in dogfooding on `wavehouse.dev`; the client-side analog of the ingest-order reality the SSE stream can't reorder. @@ -107,7 +111,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Browser-first SDK distribution: an IIFE global build, CDN entry points, a `wavehouse-codegen` bin, and a Node 18 floor** (`clients/ts/tsup.config.ts`, `clients/ts/package.json`, `clients/ts/README.md`, `docs/src/content/docs/sdk.md`, `docs/src/content/docs/development.md`, `pnpm-workspace.yaml`): `@wavehouse/sdk` already shipped browser-ready ESM/CJS (zero deps, native `fetch`/`EventSource`) but documented only the `npm install` + bundler path. The build now also emits a minified, self-contained **IIFE bundle** (`dist/index.global.js`) that defines a `WaveHouse` global, wired to new `unpkg`/`jsdelivr` package fields — so `` then `WaveHouse.createClient({ … })` works on a no-build, FTP-deployed page — and the SDK README + `sdk.md` gain a "No build step (CDN)" section covering both the ESM-CDN (`` then `WaveHouse.createClient({ … })` works on a no-build, FTP-deployed page — and the SDK README + `sdk.md` gain a "No build step (CDN)" section covering both the ESM-CDN (`