Skip to content
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

Support mocking WebSocket APIs #156

Open
Doesntmeananything opened this issue May 13, 2020 · 58 comments · May be fixed by #2011
Open

Support mocking WebSocket APIs #156

Doesntmeananything opened this issue May 13, 2020 · 58 comments · May be fixed by #2011

Comments

@Doesntmeananything
Copy link

Is it possible to use msw to mock server-sent events (SSE) and WebSocket (WS) connections?

My use case is to have a somewhat full client-side mocking story (including REST, SSE, and WS functionality), and since msw is such a joy to use when mocking out REST APIs, I was wondering if it makes sense to use it to mock more specialised server interactions.

Have you thought about this? I admit that I haven't looked much into whether it's possible just to use custom request handlers to add this functionality, emulating SSE and WS behaviour in some way. I wanted to know if you already had something in mind regarding this question. Thanks!

@kettanaito
Copy link
Member

Hey, @Doesntmeananything, thanks for bringing this topic up. I'd love to bring SSE and WS support to the users of MSW. I admit I haven't researched the topic yet, but would use this thread for this.

Technically, it comes down to the ability of Service Worker to intercept those requests/events. If the spec supports it, there shouldn't be much changes needed on the MSW side.

Here's some useful resources:

Could you please try to set up a proof of concept, if those events can be intercepted in the worker's fetch event?

You're right about the custom request handler, we can use it to log all intercepted requests:

setupWorker(
  {
    predicate(req) {
      console.log(req)
      // log captured requests, but bypass them
      return false
    },
    resolver: () => null
  }
)

If we confirm it working, I'd be glad to discuss the API for a dedicated request handler for WebSocket/SSE. I can read on their specification meanwhile.

@Doesntmeananything
Copy link
Author

Sounds like a plan! At a cursory glance, it does indeed seem quite doable. Let me PoC this, and I'll get back to you with my results as soon as I can.

@Doesntmeananything
Copy link
Author

Hi, @kettanaito! I've set up a (very quick and dirty) repository to test these interactions over at https://github.com/Doesntmeananything/msw-sse-ws.

My initial findings are the following:

  • I am able to intercept SSE connections, however this occurs only when a stream ends. For example, if a stream consists of 3 messages, logging happens only after all 3 messages have been received, including the final end event.
  • I am not able to intercept WS connections. I tried to make sure that the WS connection is established from the client only after the mock service worker had been initialised, but it didn't seem to help. I want to investigate this further.

I'm a bit concerned about WS events, although I hope that with some additional work it'd possible to intercept them.

@kettanaito
Copy link
Member

kettanaito commented May 14, 2020

@Doesntmeananything, thank you for the investigation! I'm excited to hear that SSE can be intercepted! Wonder if there's anything we can do it intercept events as they go.

I'm currently working on a NodeJS support, but can switch to this issue to help you once I'm done. I'm always open to questions or discussions, so please don't hesitate to raise those here.

Also, if you don't mind, we could then move your proof of concept repo under "msw" to serve as an example how to work with SSE/WS. That'd be awesome.

@kettanaito
Copy link
Member

kettanaito commented May 15, 2020

I'm trying to get my head around the SSE example. It seems MSW should intercept the hi from client event, so then it can mock the server response to it. I can see the once the WS connection is established, all the messages are inspectable live in DevTools. However, the webSocket.send("hi from client") is not intercepted by the Service Worker. I'm reading through w3c/ServiceWorker#947, trying to figure out if it's technically possible to access WS communication in a service worker.

API-wise, I think there should be at least two types of request handlers: event-based handler, and persistent handler (pulsing back messages to the client, like you have in your example using AsyncIterator).

@kettanaito
Copy link
Member

kettanaito commented May 17, 2020

One of the most useful pieces of code I've found in the w3c discussion (w3c/ServiceWorker#947 (comment)) was that the Service Worker file can establish a WebSocket connection. It appears that the WS events are not subjected to be intercepted in the fetch event, but one can establish a socket connection an (?) intercept events that way.

If it comes down to the manual WS connection, I'd suggest to do that on the client's side, not in the worker file. There's no technical reason to move this logic to worker, at least as of how I understand such implementation now.

@Doesntmeananything
Copy link
Author

Thanks very much for taking the time to look further into this!

Since I've hit the wall in regards to intercepting WS connections, your suggestions come in handy. Will definitely look into this.

To be clear, are you saying that mocking WS connections falls strictly outside of MSW concerns? My investigations lead me to believe this, and I would certainly not want to push for something that doesn't make sense neither on technical nor on conceptual level.

@kettanaito
Copy link
Member

Not necessarily. What I was trying to say is that a WebSocket event is not intercepted by the fetch event in a Service Worker. That's a per-spec behavior. However, I've mentioned an example above, that creates a WS connection within the worker file, which I suppose allows to intervene the communication in some way. I haven't tried that approach out, whether it's actually possible to mock the response of an event.

@kettanaito kettanaito changed the title Using msw to mock server-sent events and WebSocket connections WebSocket / Server-sent Events support May 20, 2020
@kettanaito kettanaito pinned this issue May 20, 2020
@kettanaito kettanaito added help wanted Extra attention is needed needs:discussion labels May 20, 2020
@kettanaito
Copy link
Member

I've received a suggestion to look at mock-socket. We may get some inspiration from how it's implemented, and see if a similar approach can be done in MSW.

@kettanaito
Copy link
Member

Update: I've started with the WebSocket support and will keep you updated as I progress. For those interested I will post some technical insights into what that support means, what technical challenges I've faced, and what API to expect as the result.

Session 1: It's all about sockets

No service for the worker

Unfortunately, WebSocket events cannot be intercepted in the fetch event of the Service Worker. That is an intentional limitation and there's no way to circumvent it. This means a few things:

  • WebSocket events won't be visible in the "Network" tab;
  • WebSocket support does not require the worker and can live outside of setupWorker context.
  • WebSocket events interception should be done by patching the underlying logic (i.e. a WebSocket class).

Goodbye, handlers!

WebSocket operates with events, not requests, making the concept of request handler in this context redundant. Instead, you should be able to receive and send messages from ws anywhere in your app, including your mock definition.

import { rest, ws, setupWorker } from 'msw'

// Create an interception "server" at the given URL.
const todos = ws.link('wss://api.github.com/todos')

setupWorker(
  rest.put('/todo', (req, res, ctx) => {
    const nextTodos = prevTodos.concat(req.body)
    
    // Send the data to all WebSocket clients,
    // for example from within a request handler.
    todos.send(nextTodos)
    return res(ctx.json(nextTodos))
  })
)

// Or as a part of arbitrary logic.
setInterval(() => todos.send(Math.random()), 5000)

URL that got away

When constructing a WebSocket instance you must provide a URL that points to a WebSocket server. Providing a URL to a non-existing server yields and exception that you cannot circumvent by patching WebSocket class, as the URL validation lives in its constructor.

I've ended up re-implementing a WebSocket class, effectively making a polyfill out of it. That way it can have its custom constructor that would tolerate non-existing URLs if there is a ws.link interception server declared for that URL.

Persisting WebSocket clients

The entire idea of WebSocket is to sync data between multiple clients in real time. When you dispatch a mocked ws.send event to send some data to all clients, you need to let all the clients know they should receive the data (trigger their message event listener). However, there's no way to know and persist a list of WebSocket clients on runtime, since each page has its own runtime.

Usually a solution to this kind of problems is to lift the state up and maintain a record of clients in the upper context shared with all the clients (pages). However, in JavaScript there isn't that may ways to share and persist data between clients. In case of WebSocket clients one needs to store references to WebSocket instances—basically, object references. I've considered:

  • localStorage/sessionStorage. Good for sharing textual data, not that suitable for storing objects with prototypes. Objects flushed here effectively lose their references, making them completely different objects.
  • Web Worker/Service Worker. A great way to have a detached process in a browser that can control and communicate with multiple pages. However, as stated before, worker API cannot intercept WebSocket events, and using it only for the sake of storing some records is utterly redundant (I don't wish to ask people to copy any more worker scripts). Utilizing an existing mock worker may be an option, however, I'm afraid it would put too many logic into it, increasing its maintenance, and violating its purpose of containing a bare minimum logic that you seldom need to update.
  • BroadcastChannel. Turns out the API that allows workers to communicate with clients exists standalone and it's awesome. You can create a broadcast channel as a part of page's runtime, and as long as another page on the same host creates a channel with the same name they can send data between them.
const channel = new BroadcastChannel('ws-support')

// One client sends a data.
channel.send('some-data')

// All clients can react to it.
channel.addEventListener('message', (event) => {
  event.data // "some-data"
})

I find BroadcastChannel a great choice to mimic the real time data synchronization functionality of WebSocket. I've chosen it to spawn a single channel between all clients and notify them when they should trigger their message event listeners.

@kettanaito kettanaito self-assigned this Sep 16, 2020
@kettanaito kettanaito removed the help wanted Extra attention is needed label Sep 16, 2020
@Sun-2
Copy link

Sun-2 commented Sep 26, 2020

@kettanaito

URL that got away
When constructing a WebSocket instance you must provide a URL that points to a WebSocket server. Providing a URL to a non-existing server yields and exception that you cannot circumvent by patching WebSocket class, as the URL validation lives in its constructor.
I've ended up re-implementing a WebSocket class, effectively making a polyfill out of it. That way it can have its custom constructor that would tolerate non-existing URLs if there is a ws.link interception server declared for that URL.

You could use an ES6 Proxy. It can mess with ctors.

Link.

@BlackGlory
Copy link
Contributor

SSE and WebSockets are different issues.
If msw supports response streams (such as ReadableStream), it can support SSE.

@kettanaito
Copy link
Member

kettanaito commented Dec 5, 2020

@BlackGlory, MSW should support ReadableStream as the mocked response body. Would you have some time to try to which extent that's true, and whether SSE would be supported now?

@BlackGlory
Copy link
Contributor

@kettanaito Although ctx.body supports ReadableStream, it does not seem to work.

export const worker = setupWorker(
  rest.get('/sse', (req, res, ctx) => {
    return res(
      ctx.status(200)
    , ctx.set('Content-Type', 'text/event-stream')
    , ctx.body(sse(function* () {
        yield 'message1'
        yield 'message2'
      }))
    )
  })
)

function sse(gfn) {
  let iter
  return new ReadableStream({
    start() {
      iter = gfn()
    }
  , pull(controller) {
      controller.enqueue(`data: ${iter.next().value}\n\n`)
    }
  })
}

Browser:

[MSW] Request handler function for "GET http://localhost:8080/sse" has thrown the following exception:

DOMException: Failed to execute 'postMessage' on 'MessagePort': ReadableStream object could not be cloned.
(see more detailed error stack trace in the mocked response body)

Node.js:

TypeError: The first argument must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object. Received an instance of ReadableStreamTypeError [ERR_INVALID_ARG_TYPE]: The first argument must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object. Received an instance of ReadableStream

      at ClientRequestOverride.<anonymous> (node_modules/node-request-interceptor/src/interceptors/ClientRequest/ClientRequestOverride.ts:216:34)
      at step (node_modules/node-request-interceptor/lib/interceptors/ClientRequest/ClientRequestOverride.js:33:23)
      at Object.next (node_modules/node-request-interceptor/lib/interceptors/ClientRequest/ClientRequestOverride.js:14:53)
      at fulfilled (node_modules/node-request-interceptor/lib/interceptors/ClientRequest/ClientRequestOverride.js:5:58)

@kettanaito kettanaito added this to the WebSocket support milestone Feb 5, 2021
@kettanaito
Copy link
Member

Hey, @BlackGlory. Could you please report this as a separate issue? Thanks.

@kettanaito
Copy link
Member

kettanaito commented Mar 31, 2022

What about if someone hasn't handled the io.on events?

io in the ws handler's context represents a server. MSW cannot argue about whether a server event is handled or not because for that it'd actually had to occur on the real server to see whether it was handled or not. MSW simply doesn't have access to the internals of an actual server.

A server may handle incoming events with side-effects that do not face the connected client. For example, it may send an analytics call upon the "greet" event and don't communicate back to the client. In cases like this, MSW has no means to find out whether you have that event handled or not.

It's important to keep in mind that the ws API represents a mock WebSocket server with which your actual client will communicate. The same way as rest and graphql represent servers. Asserting server events effectively means testing the server, which is not the purpose of this library.

Although, to be fair, I'd like to have an API to emulate client-sent events so you could test your WebSocket server implementation. It'd be a separate API nonetheless, as it'd flip the domain upside down, giving you a mock socket instance to emulate connection/messaging/etc. Maybe it's something we will consider in the future but it doesn't change all I've said above.

@james-reed-toshi
Copy link

james-reed-toshi commented Mar 31, 2022

io in the ws handler's context represents a server.

Ah I see, ok.

Testing a server via a mock client sounds cool!

That all makes sense then to me - interested in seeing this come live!

@kettanaito
Copy link
Member

The scope of WebSocket support has grown tremendously since the last time I've looked into it. It's not really a matter of spying on the window.WebSocket. I've learned much since then, especially about frameworks like socket.io that introduce their own abstraction. We will use custom extensions internally for those abstractions to work. I already have a few passing tests for socket.io interception and it behaves as expected.

I've also learn that there are multiple ways to implement web sockets in your application. The SocketIO itself illustrates them rather descriptively with their transports:

  • Via window.WebSocket.
  • Via HTTP polling.
  • Via XMLHttpRequest.

And all transports must be accounted for if we wish for MSW to intercept WebSocket events reliably regardless of implementation detail. The WebSocket class is too low level to rely upon in this case.

I've split the work into chunks, and now I'm working on the transports that utilize window.WebSocket. While it's going well, I'm concerned about the other two options: polling and XHR. It looks like we'd have to mix interceptors in some way, so the WebSocket interceptor could also intercept regular HTTP calls and check if those are related to the resource in any way. On top of that, we'd much want to intercept those HTTP calls via Service Worker in the browser, the logic for which lives in the msw package directly and is not consumable anywhere outside of MSW.

I haven't assessed yet what changes would be required to support all transports. I've anticipated that we'd have to rework the way we establish interceptors but the concept that a single interceptor can accept requests from various origins (http/xhr/service worker) has never been explored. Until now, all interceptors have been self-contained.

@Jhony0311
Copy link
Sponsor

Looks great, on my end I feel the API looks good and really resembles how I would use a sockets server. I do have a use case that I wanted to ask for. Let's say I have a message that needs to be sent after X amount of time after a REST endpoint is hit, I handle the REST endpoint on my MSW mocks, but how would I generate a WS response for my client based on that REST endpoint being hit first. Seems to me that the current API is not capable of doing that with ease.

@kettanaito
Copy link
Member

kettanaito commented Apr 1, 2022

@Jhony0311, that's a great use case!

Originally, I planned for the ws API to be standalone, leaving outside of your handlers:

import { ws } from 'msw'

export const service = ws.link('wss://my-service.com')

This way you can reference it in any other handler you like, for example, a REST handler:

import { service } from './ws.mocks'

rest.post('/user', (req, res, ctx) => {
  // Emit event from the WebSocket server upon hitting this REST API route.
  service.send(`welcome a new user ${req.body.username}`)
})

My concern with this is that the WebSocket handlers suddenly become their own special thing, while I'd much prefer them to be alongside the rest of your handlers, keeping the entire network behavior collocated.

At the same time, WebSocket handlers fall out of the response resolution loop anyway: they won't be signaled incoming requests because they are event-based. So, from the user experience, I feel it'd be great to colocate all handlers together. Internally, WebSocket handlers are special because they represent a mock server of an entirely different protocol.

I'll try to summarize my points below.

Why I think ws should be among other handlers

  1. Collocation.
  2. Predictability.
  3. Response resolution logic becomes in control of when to apply the handler. This is tricky but I'd like for it to behave this way.

Why I think ws shouldn't be among other handlers

  1. It's a de-facto special thing and falls out of the default response resolution loop. ws handlers won't have predicate, neither will they have resolver. It's just not a RequestHandler and rightfully so.
  2. It must establish some sort of instance to reference so that you could send WebSocket events from anywhere (i.e. other handlers, including other WebSocket handlers).

@joaopcartier
Copy link

I was able to successfully mock a SSE!!!

Here's how:

  1. Your mocked endpoint must have the following properties:
export const sseMock = rest.get(
    `http://localhost:3000/stream`,
    (_, res, ctx) => {
        console.log('mock SSE server hit')
        return res(
            ctx.status(200),
            ctx.set('Connection', 'keep-alive'),
            ctx.set('Content-Type', 'text/event-stream'),
            ctx.body(`data: SUCCESS\n\n`)
        )
    }
)
  1. Comment out the following from the mockServiceWorker.js file:
// Bypass server-sent events.
    // if (accept.includes('text/event-stream')) {
    //     return
    // }

And that's it! 👍

After that just insert your mocked response under setupWorker:

export const worker = setupWorker( someOtherMockResponse, sseMock )

How would you consume this?
I am trying with EventSource and it is throwing me a 404 code.
If I try to fetch the same endpoint it works

@jbcpollak
Copy link

How would you consume this?
I am trying with EventSource and it is throwing me a 404 code.
If I try to fetch the same endpoint it works

the suggestion by @mariothedev works for me, for a single message. After that, the connection drops and I get an 'error' event on the client side. Still hoping to be able to stream events or at least keep the connection open for a while.

I don't do much other than new EventSource(url)

@jbcpollak
Copy link

note if you modify mockServiceWorker.js to comment out the following code as @mariothedev suggested:

// Bypass server-sent events.
    // if (accept.includes('text/event-stream')) {
    //     return
    // }

make sure to remove the msw.workerDirectory setup in package.json, or it will overwrite your change above every time you install dependencies.

In other words, remove this config from package.json if you have it:

"msw": {
    "workerDirectory": "public"
}

this will allow you to manually choose when to update the service worker.

@asherccohen
Copy link

Hey guys, thanks for the hard work into supporting more than just rest and graphql.

May I ask if there's something we can start using (even if experimental) to explore the current API and use in our projects?

We'd love to help with testing this feature!

@kettanaito
Copy link
Member

Hey, folks. I wanted to give a quick update on the state of the WebSocket support in MSW.

Short, the browser side of this feature is implemented in mswjs/interceptors#236. The Node side remains as there's no WebSocket API in Node, and we need to find a suitable way to implement request interception provisioned by third-party libraries without shipping any library-specific code. This likely means diving into net and other low-level modules and figuring out how much we can utilize those.

I'm not planning on working on WebSocket support in the foreseeable future. My focus goes to the Fetch API support (#1436) because it's something absolutely everybody using the library will benefit from. Help is welcome to make WebSocket happen!

@SerkanSipahi
Copy link

@kettanaito it is possible to merge the browser part into main branch so this can be consumed by NPM?

@kettanaito
Copy link
Member

@SerkanSipahi, the issue is that only browser-side interception is not a finite feature. I wouldn't merge things that don't make sense on their own into main. With MSW (and with the Interceptors), we're establishing across-environment support, so it's a requirement to guarantee compatibility with both browsers and at least a limited set of Node.js versions. The latter is missing in the current implementation of the WebSocket support so it doesn't belong in the main.

The best I can do is release the browser implementation under a beta flag for Interceptors but it'd still require a bump in MSW, including the release of the ws() API which may not be fully ready at this point.

Honestly, this makes little sense to me, and if you wish to give this a try, consider using GitHub pull requests as dependencies in your project. Add the @mswjs/interceptors resolution on top of MSW and you should get the things installed correctly. But before that, check on the status of the #396 API because I don't remember if it's in working condition at the moment.

Contributing to the ws API is also something I'd much appreciate, as that's a wrapper around the Interceptors and doesn't concern itself much with how to intercept requests, rather how to consume this new interceptor from MSW.

@wade-gooch-kr
Copy link

Hello @kettanaito! My team and I have been following this thread, and we saw that the change has been merged in so that msw will support EventSource. I was hoping to inquire when an official release might be happening that includes this update? Thank you in advance!

@kettanaito
Copy link
Member

Hey, @wade-gooch-kr. Excited to hear that. I'm in the middle of some test rewrites, I will publish that branch when I have a minute. Meanwhile, you can specify that PR as your dependency in package.json and should be able to try it out.

@Stackustack
Copy link

Hey, whats the status on this? It seems like some part of the job was done in this merged PR 🤔 Should this be marked closed / done or is this still WIP?

@kettanaito
Copy link
Member

September 2023 Status Update

mswjs/interceptors#236 (comment)

@Stackustack, supporting SSE is unfortunately not enough to ship WebSocket support. See the status update in the linked comment above.

@kettanaito
Copy link
Member

kettanaito commented Jan 31, 2024

Update

I've had some success implementing a WebSocket class-based interceptor (mswjs/interceptors#501). This means that the WebSocket support is coming to MSW rather soon! The browser test suite is currently passing. The Node.js test suite using Undici's WebSocket as a global is also passing!

Now, before anyone gets overly excited about this, let me clarify a few things.

  1. MSW will only support intercepting a WebSocket communication established by using the global WebSocket class (i.e. the WHATWG WebSocket standard present globally). This means that third-party libraries that implement WebSockets by other means (e.g. using custom transports) will not be supported. I see no future in supporting contrived transports from third-parties—that is an unreliable strategy. I'd much rather see (and even help) those third-parties migrate to use the standard, as it's also landing in Node.js later this year.
  2. The API to intercept and work with WebSockets will respect the WHATWG WebSocket Standard. This means you will receive outgoing MessageEvent and send back data that will be translated to an incoming MessageEvent.

What's left?

Feedback. You can help land this API sooner by helping with the following:

Meanwhile, I will improve the test coverage of the interceptor to make sure it's fully compatible with the standard when you're using it.

@kettanaito
Copy link
Member

Turns out that the initial WebSocket implementation will support SocketIO also!

If you want to be able to mock SocketIO with MSW, please read and upvote this:
socketio/socket.io-parser#129

Thank you.

@kettanaito
Copy link
Member

WebSocket Support Beta

Please participate in the discussion about the upcoming WebSocket API to help us shape it and ship it faster:

Thank you!

@kettanaito kettanaito linked a pull request Feb 3, 2024 that will close this issue
20 tasks
@kettanaito kettanaito changed the title WebSocket / Server-sent Events support Support mocking WebSocket APIs Feb 18, 2024
@kettanaito
Copy link
Member

I'm renaming this issue so it focuses on the WebSocket API mocking exclusively.

Server-Sent Events (SSE) are quite different, and to my best knowledge, they can be intercepted by the Service Worker. Their implementation will be different. If someone needs it, I encourage you to open a new feature proposal and describe it in more detail (e.g. how you are using SSE in your application, how you imagine mocking to look like, etc).

@kettanaito
Copy link
Member

Update: Give the RC a try!

You can install the RC with the WebSocket support today: npm i msw@next.

Please participate and share your feedback! The more feedback we get, the faster and better the end API will be released. Thank you!

@95th
Copy link
Collaborator

95th commented Mar 19, 2024

@kettanaito I am getting when using websocket mocks:

ReferenceError: BroadcastChannel is not defined
    at file:///<project>/node_modules/.pnpm/msw@2.3.0-ws.rc-1_typescript@4.8.4/node_modules/msw/src/core/ws/ws.ts:14:28
    at ModuleJob.run (node:internal/modules/esm/module_job:193:25)

Code I am trying:

    const api = ws.link("wss://foo.bar.com/baz");
    const apiHandler = api.on("connection", ({ client }) => {
        client.addEventListener("message", event => {
            // Echo the message back to the client
            client.send(event.data);
        });
    });

Platform: Node

@kettanaito
Copy link
Member

@95th, hi! What version of Node.js are you running? It looks like it's older than v18.

MSW itself doesn't support Node.js <18. Please update and have the global BroadcastChannel API available!

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

Successfully merging a pull request may close this issue.