feat(mercure): subscribe with a URL Pattern - #10
Open
BySplashGm wants to merge 4 commits into
Open
Conversation
Test server now runs mercure/caddy v1.0.0-alpha.3. The 2.8 builder ships Go 1.23.4 and cannot build it (requires go >= 1.26), so the builder moves to 2.11.4 — the Caddy version mercure/caddy pins — and both plugins are pinned explicitly: unpinned, xcaddy resolved mercure/caddy to the latest stable tag and silently gave 0.24.2. Caddyfile ported to modern mode, directive for directive: flag-form anonymous, `debugger` for the renamed `ui`, and an issuer block replacing the flat publisher_jwt/subscriber_jwt, which now work only under protocol_version_compatibility. resource_identifier is set because an identifier ending in /.well-known/mercure also becomes the base URL relative topics resolve against, and the topics here are rel="self" link values. Client subscribes with `match` instead of the removed `topic` parameter. The tracked topics are those same relative link values, so the exact matcher is the right semantics; match_urlpattern is not exposed yet. Publisher fixtures reminted as RFC 9068 access tokens: typ at+jwt, iss, aud, exp and authorization_details. The legacy `mercure` claim and typ JWT are both rejected in modern mode.
Every listen() opens a fresh connection, so the id of the last update has to travel with the request. The client only set the Last-Event-Id header, which a native EventSource cannot do — resuming worked solely because of the npm eventsource polyfill, even though the README invites callers to pass their own EventSource. The spec is explicit that the hub "MUST then take the union" of the query and body components and that this "applies to the topic matcher parameters and to last_event_id ... alike", so the cursor now goes in the query too. The header is still sent, for implementations that support it.
Collapsing a family of topics into one subscription is the headline feature
of Mercure 1.0, and the client had no way to reach it. A new
`matchUrlPattern` option subscribes with `match_urlpattern` instead of the
exact `rel="self"` topic:
const matchUrlPattern = '/authors/:id'
await mercure('/authors/1', {matchUrlPattern, onUpdate})
await mercure('/authors/2', {matchUrlPattern, onUpdate}) // reuses it
Three changes were needed beyond sending a different query parameter:
Subscriptions are now keyed by matcher — an exact topic, or a pattern — with
a second map from topic to matcher, because a pattern stands for many topics.
close() is reference counted. close('/authors/1') must not tear down a
subscription /authors/2 is still using, so a subscription lives until the
last topic it covers is closed. An exact matcher holds exactly one topic, so
its behaviour is unchanged.
Joining an already-subscribed matcher no longer reconnects. Closing and
reopening the EventSource for a subscription URL that did not change defeats
the point of collapsing the family, so the callbacks are rebound on the live
connection and the most recent registration serves the stream.
listen() also used to append every known topic to the subscription URL
regardless of which hub it belonged to; it now filters by hub. Invisible with
one hub, wrong with two.
The new page and spec assert what the feature promises: two resources of one
family produce a single `match_urlpattern` subscription and no `match`
parameter, an update on a topic that was never fetched still arrives, and the
connection count stays at one. Both are plain module JS — no JSX, no
esm.sh/run, so they cannot rot the way the React pages did.
README rewritten for 1.0: it documented the removed `topic` parameter, and
its example awaited a response and then called .then() on it.
Subscriptions, the resume cursor and the connection lived in module-wide maps. listen() appended every known topic to the subscription URL whatever hub it belonged to, and a single lastEventId meant one hub's cursor could be replayed against another. Both are invisible with one hub and wrong with two. They now live in a per-hub record. A global map still resolves a topic to its hub and matcher, because close(topic) is given nothing else to go on. Two smaller fixes come with it. The subscribe headers are copied before the cursor is written into them: they are the caller's fetch headers, the same object, so writing there leaked Last-Event-Id into every later request. And moving a topic between matchers reconnects once instead of twice, close() being a thin wrapper over a release() that touches no connection. tests/mercure.spec.ts asserted two of its three flags; subscribedToBoth was computed and then dropped.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Depends on #8 (protocol migration) — the commits appear here until it merges.
Exposes
match_urlpatternfrom@api-platform/mercure—collapsing a family of topics into one subscription is the headline feature of
Mercure 1.0, and the client had no way to reach it before this.
Splitting this out on its own because it's not just a new query parameter — it
changes the client's subscription model, and it's worth deciding deliberately
whether that belongs in a low-level, generic client rather than a layer above it.
Exposing it took more than a different query parameter:
pattern stands for many topics, and a topic no longer maps to a subscription of
its own.
close()is reference counted:close('/authors/1')must not tear down asubscription
/authors/2still uses. Exact matchers hold one topic, so theirbehaviour is unchanged.
the
EventSourcefor an unchanged subscription URL would defeat the collapsing,so the callbacks are rebound on the live connection instead.
That reshuffle exposed state that was never really global. Subscriptions, the
resume cursor and the connection now live in a per-hub record instead of
module-wide maps:
listen()used to append every known topic to the subscriptionURL whatever hub it belonged to, and the last event id was shared, so one hub's
cursor could be replayed against another. Both are invisible with a single hub and
wrong with two.
The subscribe headers are also copied before the cursor is written into them. They
are the caller's
fetchheaders — the same object — so writing there leakedLast-Event-Idinto every subsequent request the caller made.Tests
tests-server/mercure-urlpattern.htmlandtests/mercure-urlpattern.spec.tscoverthe pattern path and assert what it promises: two resources of one family produce a
single
match_urlpattern=%2Fauthors%2F%3Aidsubscription and nomatch=parameter,an update on
/authors/3— a topic the page never fetched — still arrives, and theconnection count stays at one. Both are plain module JS, no JSX and no
esm.sh/run,so they cannot rot the way the React pages did.
packages/mercure/README.mddocumentsmatchUrlPatternand the resume-cursorbehaviour from #8.
Verification. Run against a
v1.0.0-alpha.3hub.