feat: bounded SSE feeds for offer & auction state machines

New text/event-stream endpoints — /o/{offer_id}/events (buyer/seller only)
and /a/{auction_id}/events (public). Each polls the row ~every 1.5s, emits
a `data: {json}` frame on connect and whenever the state-machine state
changes, sends a heartbeat comment, then closes after ~25s so the browser
EventSource reconnects — "bounded" because uWSGI is sync (~16 worker
threads) and a long-lived SSE would starve the pool. Shared helper
lib/sse.py (sse_response / event_stream); it uses its own short-lived DB
session per poll (request.dbsession is already closed by pyramid_tm by the
time the streaming generator runs). Timings come from settings
(app.sse.hold_seconds / app.sse.poll_interval_seconds; test.ini sets them
tiny so the streaming tests finish in ~0.06s).

Client: auction.js opens the EventSource and feeds each frame into its
existing applyState(); it falls back to polling /a/{id}.json every 5s
where EventSource is unavailable. offer.js opens the EventSource on the
offer page and reload()s on a state change (the whole layout depends on
state / can_act). offer.j2 carries data-offer-state. Caddy auto-detects
text/event-stream and stops buffering — no Salt change.

Tests: 4 new functional tests (both endpoints stream the right
content-type + a state frame; 404 for outsiders / unknown ids). 994 passed.
This commit is contained in:
russell@unturf.com 2026-05-12 21:03:50 -04:00
parent 93c0facbbc
commit 157e6f769a
No known key found for this signature in database
12 changed files with 344 additions and 10 deletions

View file

@ -78,7 +78,8 @@ window means 1-min granularity is fine.
```
GET /a/{auction_id} live page (auction.j2 + auction.js)
GET /a/{auction_id}.json JSON state for poll
GET /a/{auction_id}.json JSON state (one-shot; fallback poll)
GET /a/{auction_id}/events bounded SSE feed of auction state (public)
POST /a/{auction_id}/bid place bid (login required, no self-bid)
POST /a/{auction_id}/buy-now end auction at buy_now (mode 2)
POST /a/{auction_id}/watch toggle watcher
@ -96,7 +97,14 @@ gift-card-purchases. After standard cart payment success,
## Live UI (`static/js/auction.js`)
- Countdown clock ticks every 1s (data-end-timestamp attribute)
- Polls `/a/{id}.json` every 5s for state changes
- Live state via a **bounded SSE feed** `/a/{id}/events` (`auction_events`
view → `lib/sse.py`): polls the row ~every 1.5s, emits a `data:` frame
on connect and on bid/soft-close/state change, heartbeats, then closes
after ~25s so `EventSource` reconnects (uWSGI sync workers can't hold
long-lived connections). `auction.js` calls `applyState()` per frame.
Where `EventSource` is unavailable it falls back to polling
`/a/{id}.json` every 5s. The countdown is NOT part of the SSE change
signal — the client derives it from `end_timestamp`.
- AJAX bid submit; success/error flash without page reload
The page works fully without JS (capability-driven presentation): `bid`,

View file

@ -112,8 +112,26 @@ POST /o/{offer_id}/decline decline current amount (terminal)
POST /o/{offer_id}/withdraw buyer-only terminal pull
POST /o/{offer_id}/checkout buyer pays accepted offer
GET /s/{shop_id}/offers operator inbox of all offers for the shop
GET /o/{offer_id}/events bounded SSE feed of the offer's state (buyer/seller only)
```
### Live updates (bounded SSE)
`/o/{offer_id}/events` is a `text/event-stream` that polls the offer row
every ~1.5s, emits a `data: {json}` frame on connect and whenever the
state-machine state changes, sends a heartbeat comment, then closes after
~25s — the browser `EventSource` reconnects. This caps the worker-thread
hold per client (uWSGI is sync, ~16 threads; a truly long-lived SSE would
starve the pool). Shared helper: `lib/sse.py` (`sse_response` /
`event_stream`). Timings are settings (`app.sse.hold_seconds`,
`app.sse.poll_interval_seconds`; `test.ini` sets them tiny). The
generator uses its **own** short-lived DB session per poll (not
`request.dbsession`, which pyramid_tm has already closed by then).
`offer.js` opens the `EventSource` on the offer page and `reload()`s on a
state change (the whole page layout depends on state/`can_act`). Auctions
have the same: `/a/{auction_id}/events` (public) + `auction.js` calls
`applyState()` on each frame.
`offer_open` is registered before the `product_slug` catch-all so
`/p/{id}/offer` is not shadowed.