Repository: websockets/ws
Pull request #1754 (2020-05-05 17:51:18)

Add support for the EventTarget `once` option
As per the documentation this is something not browser related and should be supported to allow transparent polyfill usage

https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener

```js
import WebSocket from 'ws'

new WebSocket().addEventListener('message', () => {}, { once: true })
```

lpinca: Well, the whole `EventTarget` is browser related. Also it doesn't make much sense to use `once` for the `'open'`, `'error'`, and `'close'` events as they are emitted only once.

Do you really need this feature? In Node.js you can use `ws.once()`.
Sceat: well to build an hybrid node/web client you wouldn't know if you can use `.once` as it doesn't exist in the browser :/ this option allow to be transparent and not care about which WebSocket object you use
lpinca: Also this need tests.
lpinca: The documentation should also be updated https://github.com/websockets/ws/blob/master/doc/ws.md#websocketaddeventlistenertype-listener.

I think it does not worth the effort as it is only useful for the `'message'` event and the same can be achieved by removing the listener from the inside of the listener itself but I have no objections.

Anyway if you want to add it we have to do it properly.
Sceat: > Also this need tests (also for `removeEventListener` to make it sure that is works for `once` events added with `addEventListener`).

Resolved the previous issues, but i'm kinda not very good at testing stuff :fearful: 
Sceat: rip coverage

Patch:
diff --git a/doc/ws.md b/doc/ws.md
index ef4ec0fb..6317f9be 100644
--- a/doc/ws.md
+++ b/doc/ws.md
@@ -26,7 +26,7 @@
   - [Event: 'pong'](#event-pong)
   - [Event: 'unexpected-response'](#event-unexpected-response)
   - [Event: 'upgrade'](#event-upgrade)
-  - [websocket.addEventListener(type, listener)](#websocketaddeventlistenertype-listener)
+  - [websocket.addEventListener(type, listener[, options])](#websocketaddeventlistenertype-listener-options)
   - [websocket.binaryType](#websocketbinarytype)
   - [websocket.bufferedAmount](#websocketbufferedamount)
   - [websocket.close([code[, reason]])](#websocketclosecode-reason)
@@ -89,7 +89,7 @@ is provided with a single argument then that is:
   - `secure` {Boolean} `true` if `req.connection.authorized` or
     `req.connection.encrypted` is set.
 
-The return value (Boolean) of the function determines whether or not to accept
+The return value (`Boolean`) of the function determines whether or not to accept
 the handshake.
 
 if `verifyClient` is provided with two arguments then those are:
@@ -336,10 +336,14 @@ Emitted when response headers are received from the server as part of the
 handshake. This allows you to read headers from the server, for example
 'set-cookie' headers.
 
-### websocket.addEventListener(type, listener)
+### websocket.addEventListener(type, listener[, options])
 
 - `type` {String} A string representing the event type to listen for.
 - `listener` {Function} The listener to add.
+- `options` {Object}
+  - `once` {Boolean} A `Boolean` indicating that the listener should be invoked
+    at most once after being added. If `true`, the listener would be
+    automatically removed when invoked.
 
 Register an event listener emulating the `EventTarget` interface.
 
diff --git a/lib/event-target.js b/lib/event-target.js
index c060d63a..d38e1f2d 100644
--- a/lib/event-target.js
+++ b/lib/event-target.js
@@ -109,11 +109,16 @@ const EventTarget = {
   /**
    * Register an event listener.
    *
-   * @param {String} method A string representing the event type to listen for
+   * @param {String} type A string representing the event type to listen for
    * @param {Function} listener The listener to add
+   * @param {Object} options An options object specifies characteristics about
+   *     the event listener
+   * @param {Boolean} options.once A `Boolean`` indicating that the listener
+   *     should be invoked at most once after being added. If `true`, the
+   *     listener would be automatically removed when invoked.
    * @public
    */
-  addEventListener(method, listener) {
+  addEventListener(type, listener, options) {
     if (typeof listener !== 'function') return;
 
     function onMessage(data) {
@@ -132,36 +137,38 @@ const EventTarget = {
       listener.call(this, new OpenEvent(this));
     }
 
-    if (method === 'message') {
+    const method = options && options.once ? 'once' : 'on';
+
+    if (type === 'message') {
       onMessage._listener = listener;
-      this.on(method, onMessage);
-    } else if (method === 'close') {
+      this[method](type, onMessage);
+    } else if (type === 'close') {
       onClose._listener = listener;
-      this.on(method, onClose);
-    } else if (method === 'error') {
+      this[method](type, onClose);
+    } else if (type === 'error') {
       onError._listener = listener;
-      this.on(method, onError);
-    } else if (method === 'open') {
+      this[method](type, onError);
+    } else if (type === 'open') {
       onOpen._listener = listener;
-      this.on(method, onOpen);
+      this[method](type, onOpen);
     } else {
-      this.on(method, listener);
+      this[method](type, listener);
     }
   },
 
   /**
    * Remove an event listener.
    *
-   * @param {String} method A string representing the event type to remove
+   * @param {String} type A string representing the event type to remove
    * @param {Function} listener The listener to remove
    * @public
    */
-  removeEventListener(method, listener) {
-    const listeners = this.listeners(method);
+  removeEventListener(type, listener) {
+    const listeners = this.listeners(type);
 
     for (let i = 0; i < listeners.length; i++) {
       if (listeners[i] === listener || listeners[i]._listener === listener) {
-        this.removeListener(method, listeners[i]);
+        this.removeListener(type, listeners[i]);
       }
     }
   }


Test patch:
diff --git a/test/websocket.test.js b/test/websocket.test.js
index e87cef39..d02aa504 100644
--- a/test/websocket.test.js
+++ b/test/websocket.test.js
@@ -1809,6 +1809,22 @@ describe('WebSocket', () => {
       assert.strictEqual(ws.listeners('bar').length, 0);
     });
 
+    it('allows to add one time listeners with `addEventListener`', (done) => {
+      const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() });
+
+      ws.addEventListener(
+        'foo',
+        () => {
+          assert.strictEqual(ws.listenerCount('foo'), 0);
+          done();
+        },
+        { once: true }
+      );
+
+      assert.strictEqual(ws.listenerCount('foo'), 1);
+      ws.emit('foo');
+    });
+
     it('supports the `removeEventListener` method', () => {
       const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() });
 
@@ -1831,6 +1847,26 @@ describe('WebSocket', () => {
       assert.strictEqual(ws.listenerCount('message'), 0);
       assert.strictEqual(ws.listenerCount('open'), 0);
       assert.strictEqual(ws.listenerCount('foo'), 0);
+
+      ws.addEventListener('message', NOOP, { once: true });
+      ws.addEventListener('open', NOOP, { once: true });
+      ws.addEventListener('foo', NOOP, { once: true });
+
+      assert.strictEqual(ws.listeners('message')[0]._listener, NOOP);
+      assert.strictEqual(ws.listeners('open')[0]._listener, NOOP);
+      assert.strictEqual(ws.listeners('foo')[0], NOOP);
+
+      ws.removeEventListener('message', () => {});
+
+      assert.strictEqual(ws.listeners('message')[0]._listener, NOOP);
+
+      ws.removeEventListener('message', NOOP);
+      ws.removeEventListener('open', NOOP);
+      ws.removeEventListener('foo', NOOP);
+
+      assert.strictEqual(ws.listenerCount('message'), 0);
+      assert.strictEqual(ws.listenerCount('open'), 0);
+      assert.strictEqual(ws.listenerCount('foo'), 0);
     });
 
     it('wraps text data in a `MessageEvent`', (done) => {