Skip to content

Events Query Modular Implementation - #1364

Draft
CxRes wants to merge 14 commits into
dunglas:mainfrom
CxRes:feat/eqp
Draft

Events Query Modular Implementation#1364
CxRes wants to merge 14 commits into
dunglas:mainfrom
CxRes:feat/eqp

Conversation

@CxRes

@CxRes CxRes commented Aug 20, 2026

Copy link
Copy Markdown

A more detailed and (hopefully) instructive Events Query implementation.

AI (Claude Opus 5) has been used to develop this code. I have checked verified all changes/additions to the code, but have not verified all the tests.

The implementation proceeds in three stages:

  1. Refactor of code without changing baseline functionality. I have made one or two changes by copying code from Kevin's PR.
  2. Implementation of Events Query using existing form-encoded format for subscriptions, header field and content negotiation.
  3. Implementation of JSON parser and Multipart Digest encoder.

The only change to existing functionality is a subtle fixes to Accept and Content-Type handling that is now consistent with RFC9110 and RFC10008 respectively. Otherwise existing functionality is essentially untouched. Events Query is operated under a flag as designed by Kevin and is off by default.

There are no docs in this PR; as it is this took a lot of time.

CxRes and others added 14 commits August 17, 2026 10:33
t.Cleanup removed the database file before closing the transport that
still held it open. POSIX permits unlinking an open file, so this passes
on Linux CI; Windows refuses, failing every test built on
createBoltTransport. Closing first is correct on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
registerSubscriber both registered a subscriber and began answering it.
Those are separate concerns, and only the second belongs to the handler that
goes on to hold the connection open -- which is also the only place with a
framing to answer in, once there is more than one.

Sending the headers immediately after registerSubscriber returns keeps the
preamble on the same side of setDefaultWriteDeadline as before, so nothing
about the response changes. The subscriber log and the connected metric now
run before the headers rather than after, neither being observable to the
subscriber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is a refactor of the Event Stream encoder in preperation of Events Query implementation.

eventStreamEncoder holds Server-Sent Events pieces that were spread across two functions in subscribe.go:
+ the media type in sendHeaders,
+ the comment it writes to flush the headers,
+ the comment the loop sends as a keep-alive, and
+ the update serialization the loop passes to write.

A heartbeat is not sent when the streamEncoder generates an empty heartbeat string. Some comments changed with the code they describe. TestSubscribeContentType checks for the media type the hub answers with It goes through SubscribeHandler, so it covers sendHeaders using the encoder's value and not just the value itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refactors the subscription parsing, to accomodate multiple request parsers
in preparation for Events Query.

Request parsing has been moved out from `subscribeValues` in **subscribe.go**
to `parseSubscribeRequest` in **parser.go**. `parseSubscribeRequest` also
resolves lastEventID which was also previously handled by **subscribe.go**.

The parsing of a request body is moved to **parser_urlencoded.go**. This will
enable the reuse of the parser for Events Query.

The malformed-body test has been moved out of **subscribe_test.go** to
**parser_test.go**.

Behaviour is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Mercure hub expects QUERY to be form-encoded, so a request naming another
media type is answered with HTTP error code 415 and one naming none at all is
answered with HTTP error code 400, as specified in RFC 10008, Section 2.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A subscription answers with Mercure-Last-Event-Id, the cursor a subscriber
resumes from. CORS exposed only Link, so a browser using fetch could not
read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements the subscription data model of the HTTP Events Query Internet-Draft
(draft-gupta-httpapi-events-query) as an opt-in alternative to EventSource,
enabled with WithEventsQuery() or the `events_query` Caddy directive, and
advertised as `mercure_events_query`.

The implementation still uses URL form-encoding: but it does not take parameters
from the URL and the body is checked for the `events` property. The response is
still text/event-stream, as the draft does not impose a response media type.

Nothing changes for Event Source, nor for a QUERY on a hub without the option.

Co-Authored-By: Kévin Dunglas <kevin@dunglas.fr>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Accept-Query (RFC 10008) advertises the media type of the subscription requests
sent using the QUERY method supported by the hub, currently just form-encoded.

A hub with Events Query enabled derives the header field from directly from
the list of registered parsers; the list will evolve automatically as new
parsers are added.

The field is exposed to cross-origin subscribers, which cannot otherwise read
it.

Co-Authored-By: Kévin Dunglas <kevin@dunglas.fr>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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. It is only sent when the subscription response intends to send
updates.

The field is exposed to cross-origin subscribers.

Co-Authored-By: Kévin Dunglas <kevin@dunglas.fr>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The media type of the subscription response is driven by content negotiation.
In particular, the negotiated media type selects the encoder to frame
notifications sent from the hub. `text/event-stream` is the only response format
currently implemented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When processing an Events Query request, the parser reads the `duration`
property of the `Events` request field, which states the period a subscriber
wants updates for. The response controller uses it as guidance to determine the
response duration — it may only shorten the period the hub would have served — and communicates it back with the same property on the response.

The disconnection timer is armed on that stopping time rather than on the connection's write deadline, which a request deliberately leaves alone. A hub with no deadline of its own now stops when a subscription said to, instead of advertising a bound it would not have enforced.

Events is allowed and exposed to cross-origin subscribers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hub can read an application/events+json formatted request that expresses
the Events Query data model as JSON. The request reuses the "url" member
carrying the matcher parameters and "last_event_id" meber from the Mercure
specification. The only operative difference is that the "events" property is
used to ask for a stream of notifications as per Section 6 of
draft-gupta-httpapi-events-query. That is, the parser decodes the same values
a form-encoded request does, so one implementation validates both and the two
cannot drift apart.

Registering the parser is all it takes for the media type to be offered:
Accept-Query is derived from the same list and now advertises
application/events+json ahead of the form-encoded media type. It is only read
by a hub with Events Query enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Events Query notifications are sent in a multipart/digest response by default.
Individual notifications are sent as message/rfc822, the default part format in
a multipart/digest response (RFC 2046, Section 5.1.5). Each notification is a
message with the event's fields as header fields and its data as the body,
declaring a Content-Length so a payload containing the boundary cannot end the
part early.

An encoder is now built per connection rather than shared, a multipart one
carrying a boundary of its own, so the registry holds constructors. The
framing also gains a trailer: written on the clean exit paths only, with a
write deadline of its own, since the response ends precisely because the
connection's deadline arrived.

The content negotiation added previously allows an Events Query subscriber to
select between the Multipart Digest and Event Stream response formats.
Event Stream response is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Publishers can declare the media type of the data payload in a `content_type`
form field. The value is validated as a media type and rejected with a 400
otherwise, since it reaches the wire as a header field where a malformed value
could inject others. It is stored with the update, so a transport serving
history carries it too.

An Events Query notification declares it as the `Content-Type` of its message,
in place of the `text/plain; charset=utf-8` the hub assumes of data no publisher
described. Nothing changes for Event Source: `text/event-stream` defines no
field for a media type.

Co-Authored-By: Kévin Dunglas <kevin@dunglas.fr>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Aug 20, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

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