Repository: nodejs/undici

Problem statement:
WebSockets do not fire 'close' event if the connection failed to be established
Consider the following code:
```js
const webSocket = new undici.WebSocket("wss://invalid-domain.example.com/");
webSocket.onopen = () => { console.log("open"); };
webSocket.onclose = () => { console.log("close"); };
webSocket.onerror = () => { console.log("error"); };
```
It outputs:
```
error
```

However, a standard-compliant WebSocket implementation would output:
```
error
close
```

This is because [establish a WebSocket connection](https://websockets.spec.whatwg.org/#concept-websocket-establish) algorithm would invoke [fail the WebSocket Connection](https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.7) algorithm in step 11.1, which would [Close the WebSocket Connection](https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.1). The WebSocket standard states:

> When [the WebSocket connection is closed](https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.4), possibly cleanly, the user agent must [queue a task](https://html.spec.whatwg.org/multipage/webappapis.html#queue-a-task) to run the following substeps:
> ...
> 3. [Fire an event](https://dom.spec.whatwg.org/#concept-event-fire) named close at the [WebSocket](https://websockets.spec.whatwg.org/#websocket) object.

This bug is reproducible on commit [4b8958a](https://github.com/nodejs/undici/commit/4b8958a228aae55366dfba1dc70f03f08bc2d48d).



Hints:
Is it connected to #3506 and maybe already fixed in main but not released yet?
The `close` event is never fired even though the `readyState` is set to 3 (`WebSocket.CLOSED`). Tested on latest commit (4b8958a228aae55366dfba1dc70f03f08bc2d48d) on main.

Code
```typescript
import { WebSocket } from 'undici';

const webSocket = new WebSocket("wss://invalid-domain.example.com/");
webSocket.onopen = () => { console.log("open"); };
webSocket.onclose = () => { console.log("close"); };
webSocket.onerror = () => {
  console.log("error");
  console.log("readyState", webSocket.readyState);
};
```
Console output
```
➜  test-issue git:(main) ✗ yarn ts-node src/index.ts
yarn run v1.22.22
$ /Users/exhumer/test-issue/node_modules/.bin/ts-node src/index.ts
error
readyState 3
✨  Done in 1.00s.
```

On `v6.x` branch (latest stable release branch), the `readyState` is instead at 0 (`WebSocket.CONNECTING`) at error. Still no close event is fired.
Trying the code in a local HTML page on a browser, a `close` event is fired after the `error` event.

`test-issue-browser.html`
```
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body></body>
  <script>
    const webSocket = new WebSocket('ws://localhost:8080')
    webSocket.onopen = () => { console.log("open"); };
    webSocket.onclose = () => { console.log("close"); };
    webSocket.onerror = () => {
      console.log("error");
      console.log("readyState", webSocket.readyState);
    };
  </script>
</html>
```
Console output
<img width="1440" alt="Screenshot 2024-09-04 at 3 07 40 PM" src="https://github.com/user-attachments/assets/7be3661d-7749-4201-acf6-ff0b44e034c7">



This can be fixed by changing the the following.

https://github.com/nodejs/undici/blob/4b8958a228aae55366dfba1dc70f03f08bc2d48d/lib/web/websocket/websocket.js#L527-L533

First, adding a `this.#handler.onSocketClose()` or `this.#onSocketClose()` after the `if` block to fire the `error` event.

And then changing this line

https://github.com/nodejs/undici/blob/4b8958a228aae55366dfba1dc70f03f08bc2d48d/lib/web/websocket/websocket.js#L669

to `const result = this.#parser?.closingInfo` as `this.#parser` is `undefined` when it is accessed before a connection is established.

I can open a PR to fix with the mentioned changes if it is considered acceptable fix.

Patch:
diff --git a/lib/web/websocket/websocket.js b/lib/web/websocket/websocket.js
--- a/lib/web/websocket/websocket.js
+++ b/lib/web/websocket/websocket.js
@@ -537,6 +537,14 @@ class WebSocket extends EventTarget {
         message: reason
       })
     }
+
+    if (!this.#parser && !this.#handler.receivedClose) {
+      fireEvent('close', this, (type, init) => new CloseEvent(type, init), {
+        wasClean: false,
+        code: 1006,
+        reason
+      })
+    }
   }
 
   #onMessage (type, data) {


Test patch:
diff --git a/test/websocket/issue-3546.js b/test/websocket/issue-3546.js
--- a/test/websocket/issue-3546.js
+++ b/test/websocket/issue-3546.js
@@ -5,7 +5,7 @@ const { WebSocket } = require('../..')
 const { tspl } = require('@matteo.collina/tspl')
 
 test('first error than close event is fired on failed connection', async (t) => {
-  const { completed, strictEqual } = tspl(t, { plan: 2 })
+  const { completed, strictEqual } = tspl(t, { plan: 4 })
   const ws = new WebSocket('ws://localhost:1')
 
   let orderOfEvents = 0