Skip to content

feat: HTTP Events Query as an opt-in alternative to SSE - #1358

Draft
dunglas wants to merge 11 commits into
mainfrom
feat/events-query
Draft

feat: HTTP Events Query as an opt-in alternative to SSE#1358
dunglas wants to merge 11 commits into
mainfrom
feat/events-query

Conversation

@dunglas

@dunglas dunglas commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Implements the HTTP Events Query Internet-Draft as an opt-in alternative delivery mechanism to Server-Sent Events, plus binary event payloads.

What

With the new events_query directive (WithEventsQuery() in Go), a QUERY subscriber whose Accept header prefers multipart/mixed receives each update as a body part: the raw data as the part body, the event ID in Content-Event-Id (a MIME extension field carrying the ID verbatim), and the publisher-declared media type in Content-Type. Everything else stays byte-identical SSE, so enabling the option cannot regress existing clients.

  • The existing form-encoded QUERY body (topics + last_event_id) serves as the draft's subscription data model realization; no new request format.
  • Events: duration=N (request) caps the connection; the response advertises the effective bound, never longer than write_timeout or the token expiration. Gated on events_query since the field is not standard yet.
  • Resumption is identical on both encodings: Last-Event-ID / last_event_id in, Mercure-Last-Event-Id out (now CORS-exposed, fixing a pre-existing gap for fetch-based clients).
  • Incremental: ?1 and Accept-Query: application/x-www-form-urlencoded are sent on every subscription response: the former is the standardized no-buffering signal SSE benefits from too, the latter advertises the unconditional QUERY support (RFC 10008).
  • No heartbeat inside multipart streams; the bounded duration plays that role and the hub ends the document with a proper close delimiter.
  • Feature advertised via mercure_events_query in the RFC 9728 protected resource metadata.

Binary payloads

The publish endpoint also accepts multipart/form-data bodies (gated on events_query, 415 otherwise). The data part carries raw bytes and its Content-Type header declares the event media type, which travels to multipart subscribers as the part's Content-Type.

  • Delivery: multipart subscribers get the bytes verbatim; SSE subscribers get data base64-encoded, always (even for text payloads), so decoding is deterministic without a per-event metadata slot. This also gives byte-exact round-tripping, which SSE line splitting cannot (CR/CRLF normalize to LF).
  • Persistence: Update.MarshalJSON base64-encodes binary data (encoding/json would silently corrupt invalid UTF-8 to U+FFFD), so the bolt JSON history is lossless.

Spec

spec/mercure.md is untouched. Everything Events Query-specific lives in a new standalone RFC-style extension, spec/mercure-events-query.md (draft-dunglas-mercure-events-query), which updates the core requirements only for hubs implementing it: multipart/mixed negotiation on QUERY subscriptions, the notification encoding (Content-Event-Id rationale included), bounded responses and the Events header, resumption via the Mercure reconciliation model, multipart/form-data publications with the SSE base64 rule, discovery, and the generalized field-injection security considerations. Unlike the core spec, the extension cites the Events Query draft directly.

Design choices

  • No Mercure-invented notification envelope: application/json-seq support was considered and dropped; the multipart realization only uses native slots (a Content-Event-Id MIME extension field, Content-Type, raw body). SSE-specific properties (type, retry) are not represented.
  • The event media type comes from the data part's own Content-Type header — the native MIME slot — rather than a content_type form field (an earlier revision of this branch had one; it was removed to keep a single, native way to declare it).
  • The encoding seam is a small internal streamEncoder interface (sseEncoder, multipartEncoder), one instance per connection; the SSE path is byte-for-byte the previous behavior.
  • WithEventsQuery is marked experimental: individual I-D, subject to change.

dunglas added 11 commits August 14, 2026 15:50
The protocol has no way to express what happened to a topic: an update on a
book URL could be a creation, a status change or a deletion. Document the
envelope options for the opaque data field, with ActivityStreams 2.0 as the
worked example, including the Update-means-full-replacement caveat and the
split between the activity id and the hub-assigned SSE id.
Publishers can declare the media type of the data payload. The value is
validated as a media type (rejected with a 400 otherwise, since it ends up
on the wire as a header of negotiated response encodings) and stored with
the update; text/event-stream defines no field for it, so SSE output is
unchanged.
Implement the HTTP Events Query Internet-Draft
(draft-gupta-httpapi-events-query) as an opt-in alternative to
Server-Sent Events, enabled with WithEventsQuery(). A QUERY subscriber
whose Accept header prefers multipart/mixed receives each update as a
body part: the raw data as the part body, the event ID in Content-ID and
the publisher-declared media type in Content-Type. The response carries
Events: duration (bounded by the write deadline), Incremental: ?1 and
Accept-Query; the Events request header caps the connection duration,
for SSE subscribers too. text/event-stream remains the default for
every other request, and resumption via Last-Event-ID, last_event_id
and Mercure-Last-Event-ID works identically on both encodings.
Expose mercure.WithEventsQuery() as a Caddyfile directive and JSON
option. Experimental.
Add the directive to the configuration table, a subscribing section
showing an HTTP Events Query request/response pair with resumption, and
the content_type row to the publish form fields.
Incremental: ?1 (draft-ietf-httpbis-incremental) is the standardized
equivalent of X-Accel-Buffering: no and benefits SSE responses just as
much as negotiated encodings; Accept-Query (RFC 10008) advertises the
form-encoded QUERY body the hub accepts unconditionally. The
non-standard Events header stays gated on the events_query option.
Content-ID's msg-id syntax requires <left@right>, which cannot hold
arbitrary Mercure IDs, and Content-Location cannot either since IDs may
be non-URI strings by design. Content-Event-Id is a MIME extension
field (RFC 2045 reserves the Content- prefix for those; RFC 2046 gives
body-part meaning to Content-* fields only) carrying the update ID
verbatim, so it round-trips exactly into last_event_id.
Gated behind the events query support. The data part keeps its raw bytes
and its Content-Type header declares the event media type, replacing the
content_type form field. Binary updates are base64-encoded when
serialized to text/event-stream or JSON, and delivered verbatim in
multipart subscription responses.
The core specification stays untouched: everything Events Query-specific
(multipart/mixed subscription responses, the Events header,
Content-Event-Id, multipart/form-data publications and binary payloads)
moves to spec/mercure-events-query.md, a standalone RFC-style extension
that updates the core requirements only for hubs implementing it.
@dunglas
dunglas force-pushed the feat/events-query branch from e3e89a6 to 6ceb923 Compare August 17, 2026 11:56
@huanglinfei091-cmd

Copy link
Copy Markdown

Hi @dunglas — I noticed the lint job failed at Lint Code Base in this GitHub Actions run for this PR.

I’m testing RunBack, an early alpha CLI that reconstructs a failed public Actions run locally with act. For this run, the useful question is whether the same code-base lint diagnostic is reproduced, or whether reconstruction stops with an honest blocked result.

runback doctor
runback https://github.com/dunglas/mercure/actions/runs/32027374001

The Linux amd64 binary and checksum are in the v0.1.0-alpha release. If you try it, the verdict (SAME_FAILURE, DIFFERENT_FAILURE, or REPLAY_BLOCKED), Stage/Cause, and any install friction would be useful. Please do not post tokens or secrets. I’m asking for candid alpha evidence rather than a star or promotion; a failed reproduction is useful too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants