Repository navigation
feat(subscriptions): connectionExpiry and keepAlive options for useSubscriptionsServer - #149
Conversation
…SubscriptionsServer closeOnTokenExpiry closes a connection with 4403 Forbidden when the exp of the token it connected with passes, which ends the subscriptions running on it. Until now an expired token was refused only on the next subscribe, and running subscriptions continued. keepAlive sets graphql-ws's ping interval.
cuzzlor
left a comment
There was a problem hiding this comment.
Dual review summary
Findings: 3 (blocker 0, major 0, minor 3, nit 0)
The change is small and backward-compatible: both options are off or unchanged by default. It also handles the hard cases well: a socket that closes during token verification, delays beyond the setTimeout limit, and unref'd timers. The two reviewers agreed on all three findings and on their severity. The README and JSDoc say a reconnecting client gets a fresh token automatically, but it does not. The expiry timer is cleared in a graphql-ws hook that can run long after the socket closes. And epochSeconds rounds down, which makes the expiry tests flaky.
Findings
- minor: Docs say a reconnecting client gets a fresh token automatically (confirmed)
- minor: Expiry timer can fire after the socket has closed, because
onCloseruns late (confirmed) - minor:
epochSecondsrounds down, so the expiry tests race the second boundary (confirmed)
Generated by dual-review — Claude + litellm/azure/gpt-5.6-sol.
graphql-ws's onClose waits for every subscription on the connection to return, so a subscription waiting for its next event could leave the timer running past a disconnect. The docs now say a retry needs a fresh token, and the expiry tests no longer round exp down to a whole second.
npm audit fix updates proxy-addr, shell-quote and source-map-js within their ranges. @graphql-tools/utils (GHSA-7mx3-vvmw-hjmv) has no fixed 8.x release, so an override moves graphql-middleware's copy to 12.0.4 in this repo's own tree. Consumers supply graphql-shield themselves and are unaffected.
A short-lived token can prove a longer session, such as an assertion a proxy adds to the upgrade request. Closing at the token's exp would cut subscriptions off every few minutes, so the consumer now chooses when a connection ends. Until then the token is not verified again on subscribe. An operation that fails the subscribe-time check is no longer run: onSubscribe returned nothing after closing the socket, so graphql-ws still executed it.
cuzzlor
left a comment
There was a problem hiding this comment.
Dual review summary
Findings: 5 (blocker 0, major 0, minor 3, nit 2)
The change is well structured. The onSubscribe fix correctly stops a refused operation from running, and real-socket tests cover the new connectionExpiry and keepAlive options. The main risks are narrow but real. An expiry that has already passed at connect time is acknowledged and then closed, so graphql-ws clients reconnect without limit. Refusals now pass through the existing, mis-typed onError hook, which logs an error-level line for each refusal. The dependency override pulls in a package that requires Node.js 22.15, but the project declares and tests against Node.js 20. The reviewers raised no common findings and did not disagree: each raised different issues.
Findings
- minor: An expiry already past at connect makes graphql-ws clients reconnect indefinitely (claude-only)
- minor: Every refusal logs "GraphQL subscriptions server error" at error level, with the operation payload as
errors(claude-only) - minor: The
@graphql-tools/utilsoverride needs a newer Node.js than the project supports (gpt-only) - nit: A throwing
connectionExpiryis logged as an established connection with a failed token verification (claude-only) - nit: The
connectionExpirydescription can be read as a callback that runs at disconnect (claude-only)
Generated by dual-review — Claude + litellm/azure/gpt-5.6-sol.
…log refusals once A connection whose connectionExpiry had already passed was acknowledged and then closed, which resets a graphql-ws client's retry count, so the client reconnected without end. It is now refused before the acknowledgement. onError took graphql-ws v5's arguments, so it logged the operation id as the message and the payload as the errors. It now logs the id and the errors, and skips the refusals onSubscribe already logs. A connectionExpiry that throws is logged as such, and refuses the connection. The @graphql-tools/utils override is pinned to 12.0.1: later versions need Node.js 22.15, and this package supports Node.js 20.
Problem
useSubscriptionsSerververifies a connection's token when the connection opens, and again each time the client subscribes on it. That leaves three gaps:onSubscribecloses the socket but returns nothing, so graphql-ws goes on to build the context and execute the operation. A mutation sent over the websocket with an expired token runs.The server's keep-alive is also fixed at graphql-ws's default (a ping every 12 seconds).
Change
Two optional options on
useSubscriptionsServer, and a fix:connectionExpiry(claims) => number | undefined. Given the claimsverifyTokenreturned when the connection opened, it returns the time the connection ends, in milliseconds since the epoch. At that time the connection is closed with4403 Forbidden, which ends every subscription running on it. A time already past refuses the connection. Until then the client may subscribe again without the token being verified again. Returningundefinedkeeps the default behaviour.keepAlive12_000useServer.0turns the pings off.How
connectionExpiryworks:onConnect, afterverifyToken. A connection's token cannot change: it comes from the connection params or the upgrade request, both fixed when the connection opens. So within the connection's lifetime, a subscribe needs no second verification.onSubscribestill checks the time, for an operation that arrives just before the timer fires.closeevent, not in graphql-ws'sonClose.onClosewaits for every subscription on the connection to return, and a subscription waiting for its next event can hold that up indefinitely. The timer isunref'd, and a delay beyondsetTimeout's limit (about 24.8 days) is timed in steps.Failed to compute subscription connection expiry.The fixes.
onSubscribe(no token, a token that no longer verifies, an ended connection) now closes the socket and returns aForbiddenerror, which stops graphql-ws from executing the operation.onErrortook the wrong arguments for graphql-ws v6, so it logged the operation id asmessageand the operation payload aserrors. It now logs{ id, errors }, and skips the refusals thatonSubscribehas already logged.Reconnecting. graphql-ws clients retry
4403by default, but a retry succeeds only with a fresh token:connectionParamsmust be a function that returns a current token, or the upgrade request must be authenticated afresh (a cookie and a proxy, for example). The README and JSDoc say so.All of this is backward compatible: without
connectionExpirythe behaviour is as before, apart from the fix. The version goes from 3.4.0 to 3.5.0.Things to review
jwtClaimsToLog:Subscription connection expired(info): the timer closed a connection.Subscription connection has expired(warn): an operation arrived after the end.Subscription connection has already expired(warn): a connection was refused at connect.Failed to compute subscription connection expiry(error): the function threw.GraphQL subscriptions server errornow carriesidanderrors, in place ofmessage(the operation id) anderrors(the payload). Log queries on the old fields must be updated.Tests
src/subscriptions/server.spec.ts, with a realwsserver and agraphql-wsclient. The test schema gains atickssubscription that never ends, astallssubscription that waits forever after its first event, and abumpmutation that counts its runs.onSubscribereturns nothing.connectionExpiry:verifyTokenreturned.verifyTokenis not called again, even when it would now refuse the token.Date.nowmocked) gets 4403 and does not run.undefinedkeeps the default: the token is verified again on subscribe.keepAlive:50sends at least 3 pings in 280 ms, and0sends none.Verification
npm test: 174 passed. The server tests passed 20 of 20 repeated runs.npm run build(lint, types, rollup andattw): "No problems found".npm run audit: "All good".Dependency vulnerability fixes (commit 3)
CI's
npm run auditfailed on four advisories published since #147. All four are in devDependencies, so none reaches consumers.npm audit fixupdatesproxy-addr,shell-quoteandsource-map-jswithin their existing ranges (lockfile only).@graphql-tools/utils(GHSA-7mx3-vvmw-hjmv, prototype pollution inmergeDeep) affects every version up to 12.0.0, and there is no fixed 8.x release. It comes in throughgraphql-shield→graphql-middleware@6.1.35. Anoverridesentry pins it to 12.0.1 in this repo's own tree, the first fixed version. It depends on@whatwg-node/promise-helpers@^1(Node.js 16 or later), whereas 12.0.4 brings in version 2, which needs Node.js 22.15 and this package supports Node.js 20. Overrides do not reach consumers, who supplygraphql-shieldthemselves.graphql-middleware:src/shield.tsonly callsrule()andshield(). A manual check ofapplyMiddleware(schema, shield(...))on 12.0.1 resolved an allowed field and refused a denied one.