From c32961e813c9edf322db0bb00b7e42cd204dac72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Pacho=C5=82ek?= Date: Mon, 28 Sep 2026 01:53:18 +0200 Subject: [PATCH 1/4] Update GitHub Actions to their latest major versions actions/checkout v7, actions/setup-go v7, actions/cache v6, codecov/codecov-action v7 and sigstore/cosign-installer v4 everywhere, and actions/setup-node v7 in the website workflow. The other actions are on their latest majors already. cosign-installer v4 can install Cosign v3, whose sign-blob needs --bundle; both workflows keep pinning Cosign v2.6.3, so image and checksum signatures do not change. Supersedes Dependabot's #55, #56 and #88. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 16 ++++++++-------- .github/workflows/fuzz.yml | 4 ++-- .github/workflows/mutation.yml | 6 +++--- .github/workflows/release.yml | 6 +++--- .github/workflows/website.yml | 4 ++-- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e95fb44..30a29b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,10 +19,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true @@ -105,7 +105,7 @@ jobs: run: make test-functional - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: ./coverage.out flags: unittests @@ -124,7 +124,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -172,12 +172,12 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true @@ -240,7 +240,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -334,7 +334,7 @@ jobs: - name: Install Cosign if: startsWith(github.ref, 'refs/tags/v') - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@v4 with: # Match the pin in release.yml so both signing surfaces # (docker image here, checksums.txt in release.yml) use the diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 895240b..58d9fa2 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -40,10 +40,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 92383ce..aee0828 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -23,10 +23,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true @@ -45,7 +45,7 @@ jobs: - name: Cache gremlins binary id: cache_gremlins - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/go/bin/gremlins key: gremlins-${{ runner.os }}-${{ steps.gremlins_version.outputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfaf01c..f4b48fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,12 +62,12 @@ jobs: timeout-minutes: 20 steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true @@ -87,7 +87,7 @@ jobs: uses: anchore/sbom-action/download-syft@v0 - name: Install Cosign for checksum signing - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@v4 with: # Pin cosign itself, not just the installer action. The rest # of the supply-chain tooling pins tightly (golangci-lint diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 11ee859..e5cc063 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -37,7 +37,7 @@ jobs: timeout-minutes: 15 steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -49,7 +49,7 @@ jobs: # 22, not 20: the pinned Wrangler below requires Node >= 22. Keep in step with # website/Dockerfile so `make website-build` still mirrors this build. - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' cache: npm From 3d4c83ad71652f88604371d966ded2faf729aaec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Pacho=C5=82ek?= Date: Mon, 28 Sep 2026 01:54:37 +0200 Subject: [PATCH 2/4] AGENTS.md, RELEASE.md: current capabilities, CI and release surfaces AGENTS.md lists what 0.6.0 adds (shared subscriptions, the stream watch and its reset event, NATS liveness, event_type, payload_format, event_id_format, retry hints, health_details, the JavaScript helper) and what consumer_test.go and docs_test.go now cover. Its CI section names all five workflows, runs the whole unit package under -race rather than focused race tests, and separates pushes to main (latest image) from tags (version image, signing, GoReleaser, website): the website deploys only on tags, and no workflow updates the Docker Hub description. RELEASE.md said checksum signing could be added later; the release workflow has signed checksums.txt with Cosign since 0.4, and the file now shows how to verify them (checked against v0.5.0). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 78 ++++++++++++++++++++++++++++++++++++------------- docs/RELEASE.md | 19 ++++++++++-- 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4168447..5cc353e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,23 @@ to replace it. sequence after a NATS reconnect or consumer loss; a client whose writes stop completing is disconnected at `write_timeout` and resumes via `Last-Event-ID`. +- `shared_subscriptions`: connections caught up with the live stream share + one consumer per topic set, and move to their own when they fall behind. +- A stream watch: stream-info reads, on every stream request and every 10 s, + notice a JetStream stream recreated or restored under open SSE streams. + Those streams end with a `reset` event whose id, `0`, makes EventSource + replay the new stream (`stream_recreated`, `stream_rewound`). +- NATS liveness: pings every `nats_ping_interval` (20 s) declare a server + that stopped answering stale within a minute. Until then, a stream-info + read that times out makes NUTS ping, and while the ping goes unanswered, + requests are told to retry at once. +- Event shaping: `event_type` names events (`message`, `topic`, or + `header `), `payload_format raw` sends the NATS payload itself, and + `event_id_format sequence_time` makes ids `-`, so that + a cursor from a replaced stream is noticed. +- Retry hints: `sse_retry` sends `retry:` with each handshake, and + `transient_retry` sets the average of the jittered delay NUTS asks for + after transient failures. - NATS authentication (credentials file, token, user/password) and TLS / mTLS to the NATS server. - Optional first-party subscriber JWT auth (`subscriber_jwt_key`, @@ -44,10 +61,13 @@ to replace it. - Per-frame write deadlines (`write_timeout`, 30 s by default). `dispatch_timeout` is deprecated and has no effect. - Probes: `live_path` (`/livez`), `ready_path` (`/readyz`), and the legacy - `health_path` (`/healthz`). + `health_path` (`/healthz`); `health_details` adds the NATS server and the + stream's details to the readiness JSON. - Hub discovery via `Link: ; rel="nuts"` when `hub_url` is set. - Prometheus metrics (`nuts_*`) registered via `promauto`, surfaced through Caddy's `/metrics` handler. +- A dependency-free JavaScript helper for replay-aware pages, + [example/nuts-client.js](example/nuts-client.js). A current and complete directive list with defaults, JSON field names, validation rules, and operational notes is in @@ -82,7 +102,7 @@ Tests live alongside the source: | [serve_test.go](serve_test.go) | Request parsing, replay planning, formatting, and stream helper unit tests without live NATS. | | [serve_integration_test.go](serve_integration_test.go) | `ServeHTTP` end to end on an embedded JetStream server: streaming, replay cursors, write failures, probes, hub discovery, heartbeats, topic prefixes, NATS restarts. | | [hardening_test.go](hardening_test.go) | Request hardening: CORS, connection, topic and event-size limits, probe paths, replay caps and windows, oversized cursors. | -| [consumer_test.go](consumer_test.go) | Feed unit tests (ordering, oversize drops, backpressure, failure and stop paths) with a fake iterator; recovery of a deleted consumer. | +| [consumer_test.go](consumer_test.go) | Feed unit tests (ordering, oversize drops, backpressure, failure and stop paths) with a fake iterator; recovery of a deleted consumer; the stream watch (generations, recreation and rewind detection, the poll's continuity check) and the `reset` frame. | | [shared_test.go](shared_test.go) | Shared-subscription mechanics (ring, gap-free joins, fall-behind, lifecycle) and end-to-end behaviour; the delivery contract tests also run in shared mode. | | [helpers_test.go](helpers_test.go) | Helper edge cases: topic and cookie validation, JSON, URL redaction, SSE writes and deadlines, NATS error classification. | | [metrics_test.go](metrics_test.go) | Each metric moves with the event it counts; registration on Caddy's registry. | @@ -92,7 +112,7 @@ Tests live alongside the source: | [caddy_integration_test.go](caddy_integration_test.go) | Caddy-in-the-loop tests via `caddy.Load` (access logs, HTTP metrics, the metrics endpoint). | | [performance_test.go](performance_test.go) | `TestPerformance_*` confidence tests and benchmarks. | | [fuzz_test.go](fuzz_test.go) | Fuzz targets holding the topic, filter and cookie validators to their contracts in both directions, and the subject matchers to a reference; the nightly Fuzz workflow runs every target. | -| [docs_test.go](docs_test.go) | Keeps metric names in README, website, docs and ops files in step with `metrics.go`, and the nightly fuzz matrix in step with the `Fuzz` functions. | +| [docs_test.go](docs_test.go) | Keeps metric names in README, website, docs and ops files in step with `metrics.go`, the defaults documented in `docs/CONFIGURATION.md` in step with `Provision`, and the nightly fuzz matrix in step with the `Fuzz` functions. | | [testutil_test.go](testutil_test.go) | Helpers shared by more than one test file, by theme: embedded NATS servers (and restarts on the same port), handler set-up, response writers, SSE stream readers and assertions, fakes for the stream path, metric and log readers, subscriber JWTs. A helper used by one file lives in that file. | | [functional_test/](functional_test/) | [Godog](https://github.com/cucumber/godog) BDD tests against a real Docker Compose stack. | | [features/](features/) | Gherkin `.feature` files driving the Godog suite. | @@ -265,17 +285,27 @@ that gap before review starts, instead of leaking it into review load. ## CI and release surfaces -CI is GitHub Actions ([.github/workflows/ci.yml](.github/workflows/ci.yml), -[.github/workflows/release.yml](.github/workflows/release.yml), -[.github/workflows/website.yml](.github/workflows/website.yml)). +CI is GitHub Actions: + +| Workflow | Runs on | +| --- | --- | +| [ci.yml](.github/workflows/ci.yml) | Every pull request and every push to `main`/`master` or a `v*` tag. | +| [release.yml](.github/workflows/release.yml) | `v*` tags. | +| [website.yml](.github/workflows/website.yml) | `v*` tags, and `workflow_dispatch` for a manual redeploy. | +| [fuzz.yml](.github/workflows/fuzz.yml) | Nightly at 04:00 UTC: every fuzz target. | +| [mutation.yml](.github/workflows/mutation.yml) | Weekly on Sunday at 03:00 UTC: the full gremlins run. | + +Actions are pinned by major version (`@v7`), and Dependabot proposes the +bumps. PRs run, in this order: 1. `gofmt`, `go mod tidy` diff check, golangci-lint, unit tests with coverage, - focused race tests, `go vet`, the example JavaScript client's tests, - `govulncheck`. + the whole unit package under `-race`, `go vet`, the example JavaScript + client's tests (`make test-js`), `govulncheck`. 2. Functional test matrix (`nats:2.10-alpine`, `nats:2.12-alpine`, - `nats:2.14-alpine`, `nats:2.15-alpine`) and a 3× functional stress pass. + `nats:2.14-alpine`, `nats:2.15-alpine`), a 3× functional stress pass, and + one functional pass under `-race`. 3. Coverage upload to Codecov. 4. Production Docker image build + `caddy adapt` validation + Trivy scan + SBOM (SPDX JSON) artifact. @@ -283,17 +313,24 @@ PRs run, in this order: change ([.goreleaser.yml](.goreleaser.yml), workflows, `Makefile`, `Dockerfile`). -Pushes to `main`/`master` and `v*` tags additionally: +Pushes to `main`/`master` additionally build the multi-arch (`linux/amd64`, +`linux/arm64`) image, scan it with Trivy before and after the push, and push +it as `idcttech/nuts:latest` with provenance and SBOM attestations. + +`v*` tags additionally: + +1. Build, scan and push the image the same way as `idcttech/nuts:` + (a tag never moves `latest`), then sign it keylessly via Cosign + GitHub + OIDC. +2. Once CI has passed on the tagged commit, GoReleaser publishes the GitHub + release: archives, their SBOMs, and `checksums.txt` signed with Cosign + (`checksums.txt.sig`, `checksums.txt.pem`). +3. Build the [website/](website/) Jekyll site and deploy it to Cloudflare at + `https://idct.tech/nuts`. Docs changes merged to `main` go live with the + next tag. -1. Build and push the multi-arch (`linux/amd64`, `linux/arm64`) - `idcttech/nuts` image with provenance and SBOM attestations. -2. On tag: scan the pushed image by digest with Trivy and sign all tags - keyless via Cosign + GitHub OIDC. -3. Update the Docker Hub description from [DOCKERHUB_README.md](DOCKERHUB_README.md). -4. GoReleaser publishes archives and SBOMs for the tag. -5. Build the [website/](website/) Jekyll site and deploy it to Cloudflare at - `https://idct.tech/nuts` ([website.yml](.github/workflows/website.yml); also - runnable on demand via `workflow_dispatch`). +No workflow updates the Docker Hub description; its text is in +[DOCKERHUB_README.md](DOCKERHUB_README.md). Release surfaces and verification commands are documented in [docs/RELEASE.md](docs/RELEASE.md). Operator-facing release notes follow the @@ -306,7 +343,8 @@ The published artefact set: - `idcttech/nuts:` Docker image (multi-arch, non-root uid 10001, Cosign-signed, SBOM-attested for tag releases). `:latest` is mutable — pin to a concrete tag in production. -- GitHub release archives with SHA-256 checksums and SBOMs. +- GitHub release archives with SBOMs, and SHA-256 checksums signed with + Cosign. - Source build via `go build ./cmd/caddy` or `xcaddy build --with github.com/ideaconnect/nuts`. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index a23e3c2..203deb5 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -60,9 +60,22 @@ cosign verify idcttech/nuts: \ --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` -GitHub release archives are covered by SHA-256 checksums and release SBOMs. -Checksum signing can be added later with Cosign `sign-blob` if downstream -consumers require detached signatures for archive artifacts. +GitHub release archives are covered by release SBOMs and by SHA-256 checksums +in `checksums.txt`, which the release workflow signs keylessly with Cosign +`sign-blob` (`checksums.txt.sig`, `checksums.txt.pem`). Verify the checksums' +signature, then an archive against them: + +```bash +cosign verify-blob checksums.txt \ + --signature checksums.txt.sig \ + --certificate checksums.txt.pem \ + --certificate-identity-regexp 'https://github.com/ideaconnect/nuts/.github/workflows/release.yml@refs/tags/v.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +sha256sum --ignore-missing -c checksums.txt +``` + +Both workflows pin Cosign itself (`cosign-release: v2.6.3`): Cosign v3's +`sign-blob` writes a bundle instead, which changes these files. ## Dependency Updates From d433d3c40568df39b99034e2c5f85b4c900b70d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Pacho=C5=82ek?= Date: Mon, 28 Sep 2026 01:55:50 +0200 Subject: [PATCH 3/4] README: the features since 0.5.0, and every section in the contents The feature list gains NATS liveness, recreated streams, shared subscriptions, event names and raw payloads, timed event IDs, retry hints, readiness details and the JavaScript helper. The table of contents lists the eleven sections it skipped, and every in-page link resolves. The message format notes the retry line sse_retry adds and that event_type header reads a header; Development lists make test-js. Co-Authored-By: Claude Opus 5.5 --- README.md | 43 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b196bcc..49baddc 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,25 @@ A Caddy Server module that bridges NATS.io JetStream messages to Server-Sent Eve - **[Message Replay](#message-replay-with-last-id-or-last-event-id)**: Clients can reconnect and replay messages from a specific ID using `?last-id=` or the standard `Last-Event-ID` header. Replay can be bounded by `replay_max_messages` or `replay_window` when configured. - **Multiple Topics**: Subscribe to multiple NATS subjects simultaneously - **Automatic Reconnection**: Built-in NATS reconnection handling; after a reconnect each stream resumes after the last message it delivered, without gaps +- **[NATS Liveness](#nats_ping_interval)**: NUTS pings the NATS server every 20 seconds, so a server that stops answering is found within a minute; in the meantime, once one request's read times out, new requests are told to retry at once +- **[Recreated Streams](#message-format)**: When the JetStream stream is recreated or restored under open streams, clients get a `reset` event and replay the new stream from its start - **[CORS Support](#cors-and-allowed_origins)**: Configurable cross-origin resource sharing - **Heartbeat**: Keep-alive mechanism to prevent connection timeouts - **[Backpressure, Not Drops](#slow-clients-and-replay)**: Each stream pulls from JetStream only as fast as its client reads, so bursts and long replays wait in the stream instead of being dropped or disconnecting the client. A client that stops reading is disconnected by `write_timeout` and resumes from its last event ID. Oversized events can still be rejected by `max_event_size`. +- **[Shared Subscriptions](#shared_subscriptions)**: Optionally, connections caught up with the live stream share one JetStream consumer per topic set - **[NATS Authentication](#with-nats-authentication)**: Credentials file, token, or user/password auth for the NUTS-to-NATS connection - **NATS TLS / mTLS**: Optional `nats_tls_ca`, `nats_tls_cert`, `nats_tls_key` directives for an encrypted and mutually authenticated NATS connection - **[Subscriber JWT Authorization](#subscriber-authentication-and-topic-authorization)**: Optional HMAC-signed JWT auth with per-topic `subscribe` claims, accepted from `Authorization: Bearer` or a configurable cookie - **[Connection Caps](#max_connections)**: `max_connections` bounds concurrent SSE streams; rejected clients receive `429 Too Many Requests` with `Retry-After`, and browser `EventSource` clients are told to [retry](#transient-failures-and-eventsource) - **[Per-frame Write Bounds](#write_timeout)**: `write_timeout` (default 30 s) bounds every SSE write, so a client that stopped reading cannot tie up a handler indefinitely - **Topic Prefixing**: Optional prefix for all NATS subscriptions +- **[Event Names](#event_type) and [Raw Payloads](#payload_format)**: Name SSE events after their topic or a message header, and send the NATS payload itself instead of the JSON envelope +- **[Timed Event IDs](#event_id_format)**: `event_id_format sequence_time` adds each message's stored time to its id, so a client returning with a cursor from a replaced stream replays the new one +- **[Retry Hints](#retry-hints-sse_retry-and-transient_retry)**: `sse_retry` sets EventSource's reconnection delay, and `transient_retry` the delay NUTS asks for after transient failures - **[Prometheus Metrics](#prometheus-metrics)**: Built-in `nuts_*` counters and gauges (active connections, messages delivered, slow-client disconnects, replay stats) -- **[Liveness And Readiness Checks](#liveness-and-readiness-checks)**: `/livez`, `/readyz`, and legacy `/healthz` probe endpoints +- **[Liveness And Readiness Checks](#liveness-and-readiness-checks)**: `/livez`, `/readyz`, and legacy `/healthz` probe endpoints; `health_details` adds the NATS server and stream details - **[Hub Discovery](#hub-discovery)**: Optional `Link` header with `rel="nuts"` for automatic hub detection +- **[JavaScript Helper](#replay-aware-javascript-helper)**: `example/nuts-client.js`, a dependency-free module that keeps the cursor across page reloads ## Table of Contents @@ -52,10 +59,19 @@ A Caddy Server module that bridges NATS.io JetStream messages to Server-Sent Eve - [Configuration](#configuration) - [Caddyfile Syntax](#caddyfile-syntax) - [Path-shorthand and `route`](#path-shorthand-and-route) + - [`topic_prefix`](#topic_prefix) + - [Zero and negative values](#zero-and-negative-values) - [`max_event_size`](#max_event_size) - [`max_connections`](#max_connections) - [`write_timeout`](#write_timeout) + - [`nats_ping_interval`](#nats_ping_interval) + - [JetStream consumers](#jetstream-consumers) + - [`shared_subscriptions`](#shared_subscriptions) + - [Consumer limits on nats-server 2.15](#consumer-limits-on-nats-server-215) - [`replay_max_messages` and `replay_window`](#replay_max_messages-and-replay_window) + - [`event_type`](#event_type) + - [`payload_format`](#payload_format) + - [`event_id_format`](#event_id_format) - [CORS and `allowed_origins`](#cors-and-allowed_origins) - [Subscriber authentication and topic authorization](#subscriber-authentication-and-topic-authorization) - [Liveness And Readiness Checks](#liveness-and-readiness-checks) @@ -65,6 +81,8 @@ A Caddy Server module that bridges NATS.io JetStream messages to Server-Sent Eve - [Client Usage](#client-usage) - [JavaScript EventSource](#javascript-eventsource) - [Replay-aware JavaScript helper](#replay-aware-javascript-helper) + - [Transient failures and EventSource](#transient-failures-and-eventsource) + - [Retry hints: `sse_retry` and `transient_retry`](#retry-hints-sse_retry-and-transient_retry) - [Slow Clients And Replay](#slow-clients-and-replay) - [Message Replay with `last-id` or `Last-Event-ID`](#message-replay-with-last-id-or-last-event-id) - [Message Format](#message-format) @@ -1410,7 +1428,8 @@ Its `id` is the stream position the subscription starts after: the stream's last sequence for a request without a cursor, or the requested cursor for a replay. Treat it like any other event ID. It is omitted when the stream starts in a fallback replay mode, in which case the client keeps its previous -cursor. +cursor. With [`sse_retry`](#retry-hints-sse_retry-and-transient_retry), the +frame starts with a `retry:` line. When the JetStream stream is deleted and created again, or restored from a backup, under an open stream, NUTS ends the stream with a `reset` event: @@ -1426,9 +1445,10 @@ id: 0 The new or restored stream numbers the messages after its start or its backup's end anew, so the event sets the client's last event ID to `0`, and EventSource replays the stream from its start after the `retry` delay. The -`reason` is `stream_recreated` or `stream_rewound`. A page that keeps the last event ID across -page reloads must take it from `reset` events too, as the -[JavaScript example](#javascript-eventsource) does. See +`reason` is `stream_recreated` or `stream_rewound`. A page that keeps the +last event ID across page reloads must take it from `reset` events too, as +the [JavaScript example](#javascript-eventsource) and the +[helper](#replay-aware-javascript-helper) do. See [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md#streams-close-after-the-stream-was-recreated-or-restored). #### Response headers @@ -1458,7 +1478,8 @@ rather than published by an application. NUTS does not forward them: They are counted as `nuts_messages_dropped_total{reason="control_message"}`. Their sequence numbers never appear as event IDs, so IDs can skip them. -Apart from these two headers, NUTS does not look at message headers. +Apart from these two headers and the one [`event_type header`](#event_type) +names, NUTS does not look at message headers. ## Example Scenarios @@ -1603,6 +1624,15 @@ Feature: SSE Streaming with JetStream And the event should have an ID ``` +#### JavaScript Client + +The example client's tests use Node.js's built-in test runner (Node.js 20 or +later, no packages): + +```bash +make test-js +``` + #### All Tests ```bash @@ -1692,6 +1722,7 @@ make build # Build the Caddy binary make test # Run all tests (unit + functional) make test-unit # Run unit tests with embedded NATS make test-functional # Run BDD tests with Docker +make test-js # Test the example JavaScript client with Node.js make mutate-tools # Install the pinned gremlins binary make mutate # Run mutation testing on the whole module make mutate-pkg PKG=… # Run mutation testing scoped to one file/dir From 4249faabbb53258a7377a9a4fa2ae05467389cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Pacho=C5=82ek?= Date: Mon, 28 Sep 2026 01:57:14 +0200 Subject: [PATCH 4/4] Cut release v0.6.0: stamp CHANGELOG A development release on the 0.x line: streams on a recreated or restored JetStream stream end with a reset instead of going silent (#133, #137, #147), requests no longer wait on a NATS server that stopped answering (#134, #139), timed event IDs (#138), event names (#142), raw payloads (#143), readiness details (#144), retry hints (#145) and the JavaScript helper (#146). The Upgrading section covers the new ping default, reset replays and rolling back with timed IDs. PLAN.md ticks the backlog. Tag this commit as v0.6.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++ docs/PLAN.md | 58 ++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a30a2..cb129f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-09-28 + +A development release on the 0.x line. SSE streams on a JetStream stream that +was recreated or restored no longer go silent, and requests no longer wait on +a NATS server that stopped answering. New optional directives name events, +send raw payloads, put the message's time in event IDs, add readiness details +and set the retry hints, and `example/nuts-client.js` gives pages +replay-aware subscriptions. Nothing in an existing configuration has to +change. + +### Upgrading + +**Operator impact.** Upgrade urgency: **high** if you recreate or restore +JetStream streams while NUTS serves them. Open SSE streams skipped the +replaced stream's messages until it passed their old position, while +heartbeats kept them looking healthy (#133). **Medium** otherwise. + +- **Config changes:** none required. Every new directive is optional, and + its default keeps 0.5.0's behaviour. Two changes you may notice: + - NUTS pings the NATS server every 20 seconds (`nats_ping_interval`) + instead of every two minutes, so a server that stops answering is + declared stale within a minute (`disconnected from NATS`, + `stale connection`). + - SSE streams on a replaced stream end with a `reset` event, and + EventSource replays the new stream from its start. Bound those replays + with `replay_max_messages` or `replay_window`; the + `NutsStreamReplacedUnderClients` rule in `ops/prometheus-alerts.yml` + alerts on them. +- **Deployment checks:** each handler reads its stream's info every 10 + seconds. The readiness JSON names servers, clusters and limits only with + `health_details`, which public probe paths should leave off. +- **Rollback:** back to 0.5.0 is safe unless `event_id_format + sequence_time` was on. 0.5.0 does not parse `-` + cursors: it answers such a `?last-id=` with `400` and ignores such a + `Last-Event-ID`, so those clients start from new messages. + +**Compatibility.** Caddy 2.11.x (built with v2.11.4), Go 1.26.8, +nats-server 2.10 or newer (2.14.7 or newer for multi-topic subscriptions), +as in 0.5.0. + ### Added - `example/nuts-client.js`, a dependency-free ES module wrapping EventSource for replay-aware pages (#146). It keeps the cursor of every diff --git a/docs/PLAN.md b/docs/PLAN.md index 2702fb5..d6decd8 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -608,24 +608,45 @@ These are carried over from the v0.4 burn-list (P2 product polish). resume after the old last sequence and stay silent until the new stream passes it, skipping everything before (reproduced in both subscription modes). Done: stream-info reads, on requests and every 10 s, end a stream - generation when the creation time changes (`stream_recreated`; the last - frame resets the client's cursor to 0) or the last sequence is behind on - two reads at least 5 s apart (`stream_rewound`; the cursor falls back to - the retained replay). + generation when the creation time changes (`stream_recreated`) or the last + sequence is behind on two reads at least 5 s apart (`stream_rewound`). + Both end with a `reset` event whose id, 0, makes EventSource replay the + stream ([#147], [#137]); a poll also checks the stored time of the message + at the sequence it last saw, for a rewound stream that caught up ([#137]). - [x] P1 ([#134]): Notice a silently dead NATS server sooner. NUTS kept nats.go's two-minute ping interval, so a server that stopped answering took minutes to declare stale, and meanwhile each new request waited about 7–10 s for JetStream timeouts before it was told to retry (seen in the example stack with a paused NATS container). Done: `nats_ping_interval`, 20 s by default with two pings outstanding, so the outage is noticed within a minute. -- [ ] P2: Optional event-type mapping from topic or metadata. -- [ ] P2: Optional payload envelope customization for raw payload-only events. -- [ ] P2: Expose the NATS server version and stream metadata in health or - debug output, gated appropriately. -- [ ] P2: Configurable retry hints in SSE output. [#105] and Phase 4 deliver - part of this. -- [ ] P2: A sample JavaScript client helper for replay-aware subscriptions, - paired with [#102] and [#105]. +- [x] P1 ([#138]): A client away while its stream was replaced comes back + with a cursor from the old stream. Done: `event_id_format sequence_time` + adds the message's stored time to each id, and a cursor whose time does + not match replays the stream from its start. +- [x] P1 ([#139]): Requests waited about 7 s each on a NATS server that + stopped answering, until it was declared stale. Done: a stream-info read + that times out makes NUTS ping the server, and while the ping goes + unanswered, requests are told to retry at once. +- [x] ([#141]): `make mutate-pkg` failed scoped runs on gremlins' mutant + coverage. Done: it gates on efficacy. +- [ ] ([#140]): Uncalled vulnerable modules in Caddy's dependency tree. chi is + bumped; grpc and cel-go wait on upstream releases. +- [x] P2 ([#142]): Optional event-type mapping from topic or metadata. Done: + `event_type topic` and `event_type header `. +- [x] P2 ([#143]): Optional payload envelope customization for raw + payload-only events. Done: `payload_format raw`. +- [x] P2 ([#144]): Expose the NATS server version and stream metadata in + health or debug output, gated appropriately. Done: `health_details`, off + by default. +- [x] P2 ([#145]): Configurable retry hints in SSE output. Done: `sse_retry` + and `transient_retry`. +- [x] P2 ([#146]): A sample JavaScript client helper for replay-aware + subscriptions, paired with [#102] and [#105]. Done: + `example/nuts-client.js`, tested by `make test-js`. +- [ ] Optional gap signal (D4e, from [#107]): opt-in by decision; no client + has asked for it. + +All of the above shipped in v0.6.0 except the open items. ## Exit criteria @@ -656,7 +677,7 @@ These are carried over from the v0.4 burn-list (P2 product polish). (`TestDeliveryContract_EventSourceReconnectWithURLCursorMakesProgress`); - [x] a recreated stream resumes correctly for a client that reconnects (`TestDeliveryContract_CursorFromARecreatedStreamStillDelivers`); open - streams across a recreation are a known limitation, see the backlog; + streams across a recreation end with a `reset` since [#133]; - [x] a consumer deleted mid-stream is recovered (`TestHandler_ConsumerDeletedMidStream_RecreatesAndResumes`). - [x] The functional matrix is green on every supported server (per D3), @@ -763,3 +784,14 @@ is split between Phases 1 and 5. [#131]: https://github.com/ideaconnect/nuts/issues/131 [#133]: https://github.com/ideaconnect/nuts/issues/133 [#134]: https://github.com/ideaconnect/nuts/issues/134 +[#137]: https://github.com/ideaconnect/nuts/issues/137 +[#138]: https://github.com/ideaconnect/nuts/issues/138 +[#139]: https://github.com/ideaconnect/nuts/issues/139 +[#140]: https://github.com/ideaconnect/nuts/issues/140 +[#141]: https://github.com/ideaconnect/nuts/issues/141 +[#142]: https://github.com/ideaconnect/nuts/issues/142 +[#143]: https://github.com/ideaconnect/nuts/issues/143 +[#144]: https://github.com/ideaconnect/nuts/issues/144 +[#145]: https://github.com/ideaconnect/nuts/issues/145 +[#146]: https://github.com/ideaconnect/nuts/issues/146 +[#147]: https://github.com/ideaconnect/nuts/issues/147