feat: support GraphQL subscriptions
- Discussion: #2352
- Closes #285
- Fixes #1509
[!IMPORTANT] If your team can benefit from the GraphQL subscriptions support in MSW, please consider sponsoring the project. This will be a big effort, and your contribution will help to see it through. Thank you.
import { graphql } from 'msw'
const api = graphql.link('https://api.example.com/graphql')
export const handlers = [
// Intercept GraphQL subscriptions by name.
api.subscription('OnPostAdded', ({ subscription }) => {
// Publish mock data or mark subscriptions as complete.
subscription.publish({
data: { postAdded: { id: 'abc-123' } },
})
})
]
Roadmap
- [x] Make
ws.linksupportlogs: false/quiet: trueto disable the default WebSocket handler logging for GraphQL subscriptions (it's an implementation detail).- [ ] Add tests.
- [x] Introduce handler grouping so pubsub and subscription handler are represented in a single
graphql.subscription()call.- [x] Won't work if you reset handlers. The internal symbol will remain
trueand the WebSocket pubsub will not be added. Maybe add them both all the time but dedupe when getting the current handlers? - This entire state is irrelevant now because
GraphQLSubscriptionHandlerextendsWebSocketHandlerand just propagates the[kDispatchEvent]to the underlying link and its own subscription handler. They don't have to be present in thehandlersarray.
- [x] Won't work if you reset handlers. The internal symbol will remain
- [x] Forward path parameters from the pubsub link to the public subscription handler.
- [x] Implement nice
handler.info.headerforGraphQLSubscriptionHandlerso it can be observed nicely with.listHandlers(). - [x] Support bypassing subscriptions (connect the WebSocket to prod, intercept incoming responses, augment/prevent them).
- [x] Support publishing to a
subscriptionfrom anywhere (proposal). You should be able to publish to a subscription within a query/mutation handler, or any other http/ws handler.- You can use any pubsub in a combination with
subscription.from(asyncIterable)to provide the intercepted subscription with a stream of data.
- You can use any pubsub in a combination with
- [ ] Revise if you can publish non-data payload to subscriptions (e.g. errors or extensions). If you can't, the
.publish({ data })nesting might be redundant. - [ ] Refactor
graphql.operation()so it catches subscriptions too. Most likely turn it into a custom handler that composes two underlying handlers and give it__kind: [Http, Event](an array so it participates in both HTTP and WS resolution). - [ ] Types: Support providing the payload query type to have type-safe
subscription.publishandevent.data.payload(in case of bypassed subscriptions). - [ ] Documentation (add to the new one).

@sutt0n, I do quite share your enthusiasm!
is there an update or roadmap here to getting this into a new release, or suggestions on how to mock gql subscriptions in the interm?
@thearchitector there's a Roadmap section in the description of this PR. It will get populated as I find more things that have to be done before this is ready. So far, it's that one point.
You can follow the PR to see the gist of what GraphQL subscriptions support entails, and how you can use the existing ws.link() API to implement it. I wouldn't recommend that given we have this PR open and you can just install it directly.
There's also a message that you can sponsor this effort if your team needs this API sooner. That will give me the budget to work on it, and you will get the API. Everyone wins. Otherwise, I approach this as I do with all my open source work: when I have time and mood.
Bypassing GraphQL subscriptions
It occurred to me that I've never designed a way to bypass a subscription. Here's a proposal:
const api = graphql.link('https://api.example.com/graphql')
server.use(
api.subscription('OnCommentAdded', async ({ subscription }) => {
// This creates a NEW subscription for the same event in the actual server.
// - There is no client events to prevent! Subscription intent is sent ONCE.
const onCommentAddedSubscription = subscription.passthrough()
// Event listeners here are modeled after the subscription protocol:
// - acknowledge, server confirmed the subscription.
// - next, server is sending data to the client.
// - error, error happened (is this event a thing?). Server connection errors!
// - complete, server has completed this subscription.
onCommentAddedSubscription.addEventListener('next', (event) => {
// You can still prevent messages from the original server.
// By default, they are forwarded to the client, just like with mocking WebSockets.
event.preventDefault()
// The event data is already parsed to drop the implementation details of the subscription.
subscription.publish({
data: event.data.payload,
})
})
// You can unsubscribe from the original server subscription at any time.
onCommentAddedSubscription.unsubscribe()
}),
)
I believe this gives you good ergonomics, supports the existing WebSocket mocking defaults (such as automatic server-to-client forwarding once you establish the connection), and scopes the actual subscription to the handler so you can unsubscribe at any time.
If anyone has any feedback on this, please let me know! Thanks.
The tests are failing because I haven't procured the original (test) GraphQL server to emulate bypassing subscriptions. Stuck in the infinite hell of ws not being an ESM, which messes up @epic-web/test-server type definitions.
✅ Extraneous publishes
It should be possible to publish to a subscription (or data source) from anywhere (e.g. from a different handler). That's a common expectation for real-time systems like WebSockets or subscriptions that are based on that.
I want to be able to do this:
const pubsub = new SomePubSub()
api.subscription('OnCommentAdded', ({ subscription }) => {
// Attach an AsyncIterable to this subscription.
subscription.from(pubsub.subscribe('COMMENTS'))
})
api.mutation('AddComment', ({ variables }) => {
pubsub.publish('COMMENTS', variables.comment)
})
As usual, I should research best practices and user expectations around this pattern. I think I'm fairly close with the example above but still.
It would be even better to support extraneous PubSub (BYO). I don't think MSW should be responsible for creating them, really, unless those pubsub packages ship Node.js dependencies and you cannot use them in the browser.
Solution: You can use any PubSub with MSW via subscription.from() that supports the outputs of pubsubs (generators) as an argument and hooks those into the underlying mock.