Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ jobs:
- name: Run go vet
run: go vet ./...

- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: '22'

- name: Run example client tests
run: make test-js

- name: Run govulncheck
# Pinned to match the rest of CI (golangci-lint, trivy, action SHAs).
# Bump in lockstep with Renovate/Dependabot updates.
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Operational and deployment assets:
| [Caddyfile](Caddyfile), [Caddyfile.test](Caddyfile.test) | Reference configurations; `Caddyfile.test` powers the functional stack. |
| [Dockerfile](Dockerfile), [Dockerfile.test](Dockerfile.test) | Production and test images (non-root, uid 10001). |
| [docker-compose.yml](docker-compose.yml) | Functional-test stack (NATS + NUTS built from source). |
| [example/](example/), [example_docker/](example_docker/) | Interactive demos (source build vs. published image). |
| [example/](example/), [example_docker/](example_docker/) | Interactive demos (source build vs. published image). Both pages use the replay-aware client [example/nuts-client.js](example/nuts-client.js), of which `example_docker/` keeps a copy. |
| [ops/prometheus-alerts.yml](ops/prometheus-alerts.yml), [ops/grafana-dashboard.json](ops/grafana-dashboard.json) | Reference alerts and dashboard. |
| [scripts/](scripts/) | Helper scripts (e.g. `setup-dev.sh`). |
| [docs/](docs/) | All in-depth documentation; see the index in [README.md](README.md#further-documentation). |
Expand All @@ -129,6 +129,7 @@ go build ./cmd/caddy # equivalent
| `make test-unit` | Unit/integration tests with embedded NATS (no Docker). |
| `make test-performance` | `TestPerformance_*` plus the named hot-path benchmarks. |
| `make test-functional` | Godog BDD scenarios against the Docker Compose stack. |
| `make test-js` | Node.js's built-in test runner on the example JavaScript client, [example/nuts-client.js](example/nuts-client.js) (Node.js 20+, no packages); also checks that `example_docker/` has the same copy. |
| `make test-functional-stress FUNCTIONAL_TEST_STRESS_COUNT=N` | Repeats the functional suite N times to catch flakes. |
| `make test-functional-matrix` | Runs functional tests against `nats:2.10-alpine` (the supported floor), `nats:2.12-alpine`, `nats:2.14-alpine` (2.14.7+, first release with the multi-filter purge fix) and `nats:2.15-alpine` (matches the embedded `nats-server/v2` major.minor pinned in `go.mod`). |
| `make test` | `test-unit` + `test-functional`. |
Expand Down Expand Up @@ -271,7 +272,8 @@ CI is GitHub Actions ([.github/workflows/ci.yml](.github/workflows/ci.yml),
PRs run, in this order:

1. `gofmt`, `go mod tidy` diff check, golangci-lint, unit tests with coverage,
focused race tests, `go vet`, `govulncheck`.
focused race tests, `go vet`, the example JavaScript client's tests,
`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.
3. Coverage upload to Codecov.
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- `example/nuts-client.js`, a dependency-free ES module wrapping
EventSource for replay-aware pages (#146). It keeps the cursor of every
event that carries one, the `reset` included, in `sessionStorage` or a
storage of your choice, resumes from it after a reload, and opens a new
EventSource when one gives up for good. The demo pages use it, and
`make test-js` runs its tests with Node.js's built-in test runner. The
README and website snippets now keep their cursor in `sessionStorage`,
one per tab: tabs sharing `localStorage` overwrote each other's. The
source-build demo's `start.sh` now names the page's address,
`/example/` (`/example` redirects there), and the image demo pulls the
latest image before it starts.
- `sse_retry <milliseconds>` sends `retry:` with every stream's handshake,
setting EventSource's reconnection delay after ordinary disconnects, and
`transient_retry <seconds>` sets the average of the jittered delay NUTS
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ subset that matches the change when a full local pass would be unreasonable.
- [ ] `make test-functional-matrix` passes for NATS compatibility changes.
- [ ] `make test-performance` passes for formatter, buffering, replay, or
concurrency changes.
- [ ] `make test-js` passes for changes to `example/nuts-client.js`.
- [ ] Production Docker image changes build and pass
`caddy adapt --config /app/Caddyfile` inside the image.
- [ ] Release tooling changes pass `make release-check` and a GoReleaser
Expand Down
4 changes: 4 additions & 0 deletions Caddyfile.test
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@
}
}

# The demo page (example/) imports ./nuts-client.js, which resolves
# next to the page only under /example/.
redir /example /example/

handle /example* {
root * /srv/example
uri strip_prefix /example
Expand Down
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: all build test test-unit test-performance test-functional test-functional-dev test-functional-stress test-functional-matrix release-check docker-up wait-functional-stack docker-down docker-logs mutate mutate-pkg mutate-tools clean website-build website-serve website-clean
.PHONY: all build test test-unit test-performance test-functional test-functional-dev test-functional-stress test-functional-matrix test-js release-check docker-up wait-functional-stack docker-down docker-logs mutate mutate-pkg mutate-tools clean website-build website-serve website-clean

DOCKER_COMPOSE := $(shell if docker compose version >/dev/null 2>&1; then echo docker compose; elif docker-compose version >/dev/null 2>&1; then echo docker-compose; fi)
FUNCTIONAL_TEST_STRESS_COUNT ?= 3
Expand Down Expand Up @@ -32,6 +32,13 @@ test-performance:
go test -run '^TestPerformance_' -timeout 180s .
go test -run '^$$' -bench 'Benchmark(FormatMessageEvent|FormatMessageEventLarge|WriteJSONPayload|IsValidTopic|StreamFeed|SharedFanOut)' -benchmem .

# Run the example JavaScript client's tests with Node.js's built-in runner
# (Node.js 20 or later, no packages), and check that example_docker carries
# the same copy of the client.
test-js:
node --test example/nuts-client.test.mjs
cmp example/nuts-client.js example_docker/nuts-client.js

# Validate GoReleaser config without requiring a local GoReleaser install.
release-check:
docker run --rm -v "$(CURDIR):/workspace" -w /workspace $(GORELEASER_IMAGE) check
Expand Down Expand Up @@ -252,6 +259,7 @@ help:
@echo " test - Run all tests (unit + functional)"
@echo " test-unit - Run unit tests with embedded NATS"
@echo " test-performance - Run performance confidence tests and benchmarks"
@echo " test-js - Run the example JavaScript client's tests with Node.js"
@echo " test-functional - Run functional/BDD tests with Docker"
@echo " test-functional-stress - Run functional/BDD tests repeatedly with Docker"
@echo " test-functional-matrix - Run functional/BDD tests against old and current NATS images"
Expand Down
95 changes: 93 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ A Caddy Server module that bridges NATS.io JetStream messages to Server-Sent Eve
- [JetStream Setup](#jetstream-setup)
- [Client Usage](#client-usage)
- [JavaScript EventSource](#javascript-eventsource)
- [Replay-aware JavaScript helper](#replay-aware-javascript-helper)
- [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)
Expand Down Expand Up @@ -1128,7 +1129,9 @@ const events = new EventSource('/events/my-topic');
// Resume from a stored ID after a page reload. EventSource resends this URL,
// ?last-id= included, on every auto-reconnect, but its own Last-Event-ID
// header is fresher and takes precedence, so reconnects keep moving forward.
const lastId = localStorage.getItem('lastEventId');
// sessionStorage keeps one ID per tab; tabs sharing localStorage would
// overwrite each other's.
const lastId = sessionStorage.getItem('lastEventId');
const url = lastId
? `/events?topic=notifications&last-id=${encodeURIComponent(lastId)}`
: '/events?topic=notifications';
Expand All @@ -1139,7 +1142,7 @@ const events = new EventSource(url);
// back to 0.
function remember(e) {
if (e.lastEventId) {
localStorage.setItem('lastEventId', e.lastEventId);
sessionStorage.setItem('lastEventId', e.lastEventId);
}
}

Expand Down Expand Up @@ -1168,6 +1171,94 @@ events.onerror = (e) => {
};
```

### Replay-aware JavaScript helper

[`example/nuts-client.js`](example/nuts-client.js) wraps `EventSource` with the
replay handling above. It is a dependency-free ES module to copy into your
application, and the demo page in `example/` uses it:

```javascript
import { subscribe } from './nuts-client.js';

const stream = subscribe('/events', {
topics: ['orders', 'invoices'],
on: {
message({ topic, payload }) {
console.log(`[${topic}]`, payload);
},
reset({ reason }) {
// The stream was recreated or restored; it is replayed next.
console.log('Stream replaced:', reason);
},
},
onState(state) {
// connecting, open, retrying, restarting or closed
},
});

// Later:
stream.close();
```

- It takes the cursor from every event that carries one: the `connected`
handshake, messages, and the `reset` event, which sets it back to `0`.
- It keeps the cursor in `sessionStorage`, so a reload resumes after the last
event the tab received.
- When an `EventSource` gives up for good, it opens a new one from the latest
cursor after `restartDelay`. NUTS causes that only with answers that will
not change, such as a `401` or a `400`; a proxy in front of it can cause it
too.

| Option | Default | Description |
| --- | --- | --- |
| `topics` | `[]` | Topics, each sent as `?topic=`. |
| `on` | `{}` | Listeners by event name, called with the parsed data and the `MessageEvent`. `message` is the default event name; `connected` and `reset` are NUTS' own. |
| `storage` | `sessionStorage` | Where the cursor survives reloads, one per tab. `localStorage` shares one between a site's tabs: a reloaded tab resumes where another one is, and misses what arrived in between. `null` keeps it in memory. |
| `key` | `nuts:` and the URL with its topics | The storage key. |
| `raw` | `false` | Pass message data as it is, for [`payload_format raw`](#payload_format). NUTS' own events are still parsed. |
| `withCredentials` | `false` | Send cookies cross-origin, for `subscriber_jwt_cookie`. |
| `restartDelay` | `10000` | Milliseconds before a new `EventSource` once one gave up. |
| `onState` | none | Called with `connecting`, `open` (NUTS accepted the subscription), `retrying` (`EventSource` reconnects by itself), `restarting` and `closed`. |
| `EventSource` | `globalThis.EventSource` | The implementation to use. |

With [`event_type`](#event_type) `topic` or `header`, events are named after
their topic or header value, so list every name the route sends in `on`. The
helper does not see the ID of an event without a listener: a reload would
resume from an earlier one and repeat events, though it never skips any.

Browsers cannot set headers on `EventSource`, so subscriber JWTs travel in a
cookie there ([`subscriber_jwt_cookie`](#subscriber-authentication-and-topic-authorization)).
Elsewhere, pass an implementation that can send headers, such as the
[`eventsource`](https://www.npmjs.com/package/eventsource) package:

```javascript
import { EventSource } from 'eventsource';
import { subscribe } from './nuts-client.mjs';

class AuthorizedEventSource extends EventSource {
constructor(url, init) {
super(url, {
...init,
fetch: (input, request) => fetch(input, {
...request,
headers: { ...request.headers, Authorization: `Bearer ${token}` },
}),
});
}
}

subscribe('https://example.com/events', {
topics: ['orders'],
storage: null,
EventSource: AuthorizedEventSource,
on: { message: (data) => console.log(data) },
});
```

In Node.js, save it as `nuts-client.mjs`, or in a package with
`"type": "module"`, so that it loads as an ES module. `make test-js` runs its
tests with Node.js's built-in test runner (Node.js 20 or later, no packages).

### Transient failures and EventSource

A browser `EventSource` gives up for good when a connection or reconnection is
Expand Down
59 changes: 18 additions & 41 deletions example/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ <h1>🥜 NUTS Demo</h1>
<div class="status-dot"><span class="dot wait" id="dot-both"></span> both</div>
</div>

<script>
<script type="module">
import { subscribe } from './nuts-client.js';

const MAX_ENTRIES = 30;
const columns = {
numbers: document.getElementById('col-numbers'),
Expand Down Expand Up @@ -201,57 +203,32 @@ <h1>🥜 NUTS Demo</h1>
dots[name].className = 'dot ' + state;
}

function subscribe(url, name, handler) {
var es;
var lastEventId = '';
function connect() {
// A new EventSource starts without a Last-Event-ID, so the
// cursor of the one that closed travels in the URL instead.
var target = lastEventId ? url + '&last-id=' + encodeURIComponent(lastEventId) : url;
es = new EventSource(target);
es.addEventListener('connected', function(e) {
if (e.lastEventId) { lastEventId = e.lastEventId; }
setDot(name, 'ok');
});
es.addEventListener('message', function(e) {
if (e.lastEventId) { lastEventId = e.lastEventId; }
handler(JSON.parse(e.data));
});
// The stream was recreated: its messages start again from 1,
// so a new EventSource must resume from the start as well.
es.addEventListener('reset', function(e) {
lastEventId = e.lastEventId;
});
es.onerror = function() {
if (es.readyState === EventSource.CLOSED) {
setDot(name, 'err');
// NUTS answers transient failures (NATS outage,
// connection caps) with a retry delay that
// EventSource follows by itself. It only closes on
// errors that will not clear up on their own, or when
// a proxy in front of NUTS fails, so retry slowly and
// resume after the last event received.
setTimeout(connect, 10000);
} else {
setDot(name, 'wait');
}
};
}
connect();
// Each column is a subscription of its own. nuts-client.js keeps its
// cursor in sessionStorage, so a reload resumes after the last event
// the column received, and it opens a new EventSource when one gives
// up for good.
function watch(name, topics, handler) {
subscribe('/events', {
topics: topics,
on: { message: handler },
onState: function(state) {
setDot(name, state === 'open' ? 'ok' : state === 'restarting' ? 'err' : 'wait');
},
});
}

// Column 1: numbers only
subscribe('/events?topic=numbers', 'numbers', function(data) {
watch('numbers', ['numbers'], function(data) {
addEntry('numbers', extractValue(data), data.topic, data.time);
});

// Column 2: letters only
subscribe('/events?topic=letters', 'letters', function(data) {
watch('letters', ['letters'], function(data) {
addEntry('letters', extractValue(data), data.topic, data.time);
});

// Column 3: both topics
subscribe('/events?topic=numbers&topic=letters', 'both', function(data) {
watch('both', ['numbers', 'letters'], function(data) {
addEntry('both', extractValue(data), data.topic, data.time);
});
</script>
Expand Down
Loading
Loading