Skip to content

feat(mercure): subscribe with a URL Pattern - #10

Open
BySplashGm wants to merge 4 commits into
api-platform:mainfrom
BySplashGm:feat/mercure-url-pattern
Open

feat(mercure): subscribe with a URL Pattern#10
BySplashGm wants to merge 4 commits into
api-platform:mainfrom
BySplashGm:feat/mercure-url-pattern

Conversation

@BySplashGm

Copy link
Copy Markdown
Contributor

Depends on #8 (protocol migration) — the commits appear here until it merges.

Exposes match_urlpattern from @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.

const matchUrlPattern = '/authors/:id'
await mercure('/authors/1', {matchUrlPattern, onUpdate})
await mercure('/authors/2', {matchUrlPattern, onUpdate}) // reuses the subscription

Exposing it took more than a different query parameter:

  • Subscriptions are keyed by matcher — an exact topic or a pattern — because one
    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 a
    subscription /authors/2 still uses. Exact matchers hold one topic, so their
    behaviour is unchanged.
  • Joining an already-subscribed matcher does not reconnect. Closing and reopening
    the EventSource for 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 subscription
URL 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 fetch headers — the same object — so writing there leaked
Last-Event-Id into every subsequent request the caller made.

Tests

tests-server/mercure-urlpattern.html and tests/mercure-urlpattern.spec.ts cover
the pattern path and assert what it promises: two resources of one family produce a
single match_urlpattern=%2Fauthors%2F%3Aid subscription and no match= parameter,
an update on /authors/3 — a topic the page never fetched — still arrives, and the
connection 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.md documents matchUrlPattern and the resume-cursor
behaviour from #8.


Verification. Run against a v1.0.0-alpha.3 hub.

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.
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.

1 participant