Skip to content

feat: support GraphQL subscriptions #2357

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 39 commits into
base: main
Choose a base branch
from
Draft

Conversation

kettanaito
Copy link
Member

@kettanaito kettanaito commented Nov 14, 2024

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

  • Make ws.link support logs: false / quiet: true to disable the default WebSocket handler logging for GraphQL subscriptions (it's an implementation detail).
    • Add tests.
  • Introduce handler grouping so pubsub and subscription handler are represented in a single graphql.subscription() call.
    • Won't work if you reset handlers. The internal symbol will remain true and 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 GraphQLSubscriptionHandler extends WebSocketHandler and just propagates the [kDispatchEvent] to the underlying link and its own subscription handler. They don't have to be present in the handlers array.
  • Forward path parameters from the pubsub link to the public subscription handler.
  • Implement nice handler.info.header for GraphQLSubscriptionHandler so it can be observed nicely with .listHandlers().
  • Support bypassing subscriptions (connect the WebSocket to prod, intercept incoming responses, augment/prevent them).
  • Support publishing to a subscription from 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.
  • 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.publish and event.data.payload (in case of bypassed subscriptions).
  • Documentation (add to the new one).

@kettanaito kettanaito force-pushed the feat/graphql-subscription branch from 761b4ce to eca6e43 Compare November 14, 2024 18:17
@kettanaito kettanaito force-pushed the feat/graphql-subscription branch from eca6e43 to 1ddc187 Compare November 14, 2024 18:21
@kettanaito kettanaito force-pushed the feat/graphql-subscription branch from 403e143 to e24e6db Compare November 15, 2024 17:23
@sutt0n
Copy link

sutt0n commented Nov 15, 2024

img

@kettanaito
Copy link
Member Author

@sutt0n, I do quite share your enthusiasm!

@thearchitector
Copy link

thearchitector commented Feb 26, 2025

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?

@kettanaito
Copy link
Member Author

kettanaito commented Feb 27, 2025

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

@kettanaito kettanaito added this to the GraphQL support milestone Mar 30, 2025
@kettanaito kettanaito force-pushed the feat/graphql-subscription branch 2 times, most recently from 9cd53be to 585a913 Compare April 23, 2025 17:42
@kettanaito kettanaito force-pushed the feat/graphql-subscription branch from 585a913 to 02bb4d7 Compare April 23, 2025 17:45
@kettanaito kettanaito force-pushed the feat/graphql-subscription branch from 3f659f5 to a6fba86 Compare April 24, 2025 16:40
@kettanaito
Copy link
Member Author

kettanaito commented Apr 24, 2025

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.

@kettanaito kettanaito force-pushed the feat/graphql-subscription branch from 26b1faf to 6af9ab5 Compare April 25, 2025 19:45
@kettanaito
Copy link
Member Author

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.

@kettanaito kettanaito force-pushed the feat/graphql-subscription branch from 3209bc1 to 8387bb0 Compare April 27, 2025 17:01
@kettanaito
Copy link
Member Author

kettanaito commented Apr 27, 2025

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Expose standardGraphQLHandlers to type graphql.link Support GraphQL subscriptions
4 participants