Skip to content

Commit 46faa37

Browse files
authored
fix(WebSocket): resolve relative connection urls (#763)
1 parent c5d1736 commit 46faa37

File tree

10 files changed

+277
-22
lines changed

10 files changed

+277
-22
lines changed

.github/workflows/compat.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ jobs:
2222
uses: actions/setup-node@v4
2323
with:
2424
node-version: 22
25-
cache: 'pnpm'
2625

2726
- name: Install dependencies
2827
run: pnpm install

src/interceptors/WebSocket/WebSocketClientConnection.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ export abstract class WebSocketClientConnectionProtocol {
1919
public abstract close(code?: number, reason?: string): void
2020

2121
public abstract addEventListener<
22-
EventType extends keyof WebSocketClientEventMap
22+
EventType extends keyof WebSocketClientEventMap,
2323
>(
2424
type: EventType,
2525
listener: WebSocketEventListener<WebSocketClientEventMap[EventType]>,
2626
options?: AddEventListenerOptions | boolean
2727
): void
2828

2929
public abstract removeEventListener<
30-
EventType extends keyof WebSocketClientEventMap
30+
EventType extends keyof WebSocketClientEventMap,
3131
>(
3232
event: EventType,
3333
listener: WebSocketEventListener<WebSocketClientEventMap[EventType]>,
@@ -40,9 +40,7 @@ export abstract class WebSocketClientConnectionProtocol {
4040
* client connection. The user can control the connection,
4141
* send and receive events.
4242
*/
43-
export class WebSocketClientConnection
44-
implements WebSocketClientConnectionProtocol
45-
{
43+
export class WebSocketClientConnection implements WebSocketClientConnectionProtocol {
4644
public readonly id: string
4745
public readonly url: URL
4846

src/interceptors/WebSocket/WebSocketOverride.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { invariant } from 'outvariant'
2+
import { DeferredPromise } from '@open-draft/deferred-promise'
23
import type { WebSocketData } from './WebSocketTransport'
34
import { bindEvent } from './utils/bindEvent'
45
import { CloseEvent } from './utils/events'
5-
import { DeferredPromise } from '@open-draft/deferred-promise'
66

77
export type WebSocketEventListener<
8-
EventType extends WebSocketEventMap[keyof WebSocketEventMap] = Event
8+
EventType extends WebSocketEventMap[keyof WebSocketEventMap] = Event,
99
> = (this: WebSocket, event: EventType) => void
1010

1111
const WEBSOCKET_CLOSE_CODE_RANGE_ERROR =
@@ -44,7 +44,7 @@ export class WebSocketOverride extends EventTarget implements WebSocket {
4444

4545
constructor(url: string | URL, protocols?: string | Array<string>) {
4646
super()
47-
this.url = url.toString()
47+
this.url = resolveWebSocketUrl(url)
4848
this.protocol = ''
4949
this.extensions = ''
5050
this.binaryType = 'blob'
@@ -62,8 +62,8 @@ export class WebSocketOverride extends EventTarget implements WebSocket {
6262
typeof protocols === 'string'
6363
? protocols
6464
: Array.isArray(protocols) && protocols.length > 0
65-
? protocols[0]
66-
: ''
65+
? protocols[0]
66+
: ''
6767

6868
/**
6969
* @note Check that nothing has prevented this connection
@@ -249,3 +249,49 @@ function getDataSize(data: WebSocketData): number {
249249

250250
return data.byteLength
251251
}
252+
253+
/**
254+
* Resolve potentially relative WebSocket URLs the same way
255+
* the browser does (replace the protocol, use the origin, etc).
256+
*
257+
* @see https://websockets.spec.whatwg.org//#dom-websocket-websocket
258+
*/
259+
function resolveWebSocketUrl(url: string | URL): string {
260+
if (typeof url === 'string') {
261+
/**
262+
* @note Cast the string to a URL first so the parsing errors
263+
* are thrown as a part of the WebSocket constructor, not consumers.
264+
*/
265+
const urlRecord = new URL(
266+
url,
267+
typeof location !== 'undefined' ? location.href : undefined
268+
)
269+
270+
return resolveWebSocketUrl(urlRecord)
271+
}
272+
273+
if (url.protocol === 'http:') {
274+
url.protocol = 'ws:'
275+
} else if (url.protocol === 'https:') {
276+
url.protocol = 'wss:'
277+
}
278+
279+
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
280+
/**
281+
* @note These errors are modeled after the browser errors.
282+
* The exact error messages aren't provided in the specification.
283+
* Node.js uses more obscure error messages that I don't wish to replicate.
284+
*/
285+
throw new SyntaxError(
286+
`Failed to construct 'WebSocket': The URL's scheme must be either 'http', 'https', 'ws', or 'wss'. '${url.protocol}' is not allowed.`
287+
)
288+
}
289+
290+
if (url.hash !== '') {
291+
throw new SyntaxError(
292+
`Failed to construct 'WebSocket': The URL contains a fragment identifier ('${url.hash}'). Fragment identifiers are not allowed in WebSocket URLs.`
293+
)
294+
}
295+
296+
return url.href
297+
}

src/interceptors/WebSocket/WebSocketServerConnection.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@ export abstract class WebSocketServerConnectionProtocol {
3030
public abstract close(): void
3131

3232
public abstract addEventListener<
33-
EventType extends keyof WebSocketServerEventMap
33+
EventType extends keyof WebSocketServerEventMap,
3434
>(
3535
event: EventType,
3636
listener: WebSocketEventListener<WebSocketServerEventMap[EventType]>,
3737
options?: AddEventListenerOptions | boolean
3838
): void
3939

4040
public abstract removeEventListener<
41-
EventType extends keyof WebSocketServerEventMap
41+
EventType extends keyof WebSocketServerEventMap,
4242
>(
4343
event: EventType,
4444
listener: WebSocketEventListener<WebSocketServerEventMap[EventType]>,
@@ -51,9 +51,7 @@ export abstract class WebSocketServerConnectionProtocol {
5151
* WebSocket server connection. It's idle by default but you can
5252
* establish it by calling `server.connect()`.
5353
*/
54-
export class WebSocketServerConnection
55-
implements WebSocketServerConnectionProtocol
56-
{
54+
export class WebSocketServerConnection implements WebSocketServerConnectionProtocol {
5755
/**
5856
* A WebSocket instance connected to the original server.
5957
*/
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { test, expect } from '../../../playwright.extend'
2+
3+
test('uses the "ws" scheme as-is', async ({ loadExample, page }) => {
4+
await loadExample(require.resolve('../websocket.runtime.js'))
5+
6+
await page.evaluate(() => {
7+
const { interceptor } = window
8+
9+
interceptor.apply()
10+
interceptor.on('connection', () => {})
11+
})
12+
13+
const url = await page.evaluate(() => {
14+
return new WebSocket('ws://localhost:5678/api').url
15+
})
16+
17+
expect(url).toBe('ws://localhost:5678/api')
18+
})
19+
20+
test('uses the "wss" scheme as-is', async ({ loadExample, page }) => {
21+
await loadExample(require.resolve('../websocket.runtime.js'))
22+
23+
await page.evaluate(() => {
24+
const { interceptor } = window
25+
26+
interceptor.apply()
27+
interceptor.on('connection', () => {})
28+
})
29+
30+
const url = await page.evaluate(() => {
31+
return new WebSocket('wss://localhost:5678/api').url
32+
})
33+
34+
expect(url).toBe('wss://localhost:5678/api')
35+
})
36+
37+
test('replaces the "http" scheme with "ws"', async ({ loadExample, page }) => {
38+
await loadExample(require.resolve('../websocket.runtime.js'))
39+
40+
await page.evaluate(() => {
41+
const { interceptor } = window
42+
43+
interceptor.apply()
44+
interceptor.on('connection', () => {})
45+
})
46+
47+
const url = await page.evaluate(() => {
48+
return new WebSocket('http://localhost:5678/api').url
49+
})
50+
51+
expect(url).toBe('ws://localhost:5678/api')
52+
})
53+
54+
test('replaces the "https" scheme with "wss"', async ({
55+
loadExample,
56+
page,
57+
}) => {
58+
await loadExample(require.resolve('../websocket.runtime.js'))
59+
60+
await page.evaluate(() => {
61+
const { interceptor } = window
62+
63+
interceptor.apply()
64+
interceptor.on('connection', () => {})
65+
})
66+
67+
const url = await page.evaluate(() => {
68+
return new WebSocket('https://localhost:5678/api').url
69+
})
70+
71+
expect(url).toBe('wss://localhost:5678/api')
72+
})
73+
74+
test('throws an error on not allowed schemes', async ({
75+
loadExample,
76+
page,
77+
}) => {
78+
await loadExample(require.resolve('../websocket.runtime.js'))
79+
80+
await page.evaluate(() => {
81+
const { interceptor } = window
82+
83+
interceptor.apply()
84+
interceptor.on('connection', () => {})
85+
})
86+
87+
const constructorError = await page.evaluate(() => {
88+
try {
89+
new WebSocket('invalid-protocol://localhost').url
90+
} catch (error) {
91+
if (error instanceof SyntaxError) {
92+
return error.message
93+
}
94+
95+
throw new Error(`Thrown error is not a SyntaxError`)
96+
}
97+
})
98+
99+
expect(constructorError).toBe(
100+
`Failed to construct 'WebSocket': The URL's scheme must be either 'http', 'https', 'ws', or 'wss'. 'invalid-protocol:' is not allowed.`
101+
)
102+
})
103+
104+
test('resolves a relative WebSocket URL against location', async ({
105+
loadExample,
106+
page,
107+
}) => {
108+
const { previewUrl } = await loadExample(
109+
require.resolve('../websocket.runtime.js')
110+
)
111+
112+
await page.evaluate(() => {
113+
const { interceptor } = window
114+
115+
interceptor.apply()
116+
interceptor.on('connection', () => {})
117+
})
118+
119+
const url = await page.evaluate(() => {
120+
return new WebSocket('/api').url
121+
})
122+
123+
expect(url).toBe(`ws://${new URL(previewUrl).host}/api`)
124+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* @vitest-environment node-with-websocket
3+
* @see https://websockets.spec.whatwg.org//#dom-websocket-websocket
4+
*/
5+
import { it, expect, beforeAll, afterAll } from 'vitest'
6+
import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket'
7+
8+
const interceptor = new WebSocketInterceptor()
9+
10+
beforeAll(() => {
11+
interceptor.apply()
12+
})
13+
14+
afterAll(() => {
15+
interceptor.dispose()
16+
})
17+
18+
it('uses the "ws" scheme as-is', () => {
19+
expect(new WebSocket('ws://localhost:5678/api').url).toBe(
20+
'ws://localhost:5678/api'
21+
)
22+
})
23+
24+
it('uses the "wss" scheme as-is', () => {
25+
expect(new WebSocket('wss://localhost:5678/api').url).toBe(
26+
'wss://localhost:5678/api'
27+
)
28+
})
29+
30+
it('replaces the "http" scheme with "ws"', () => {
31+
expect(new WebSocket('http://localhost:5678/api').url).toBe(
32+
'ws://localhost:5678/api'
33+
)
34+
})
35+
36+
it('replaces the "https" scheme with "wss"', () => {
37+
expect(new WebSocket('https://localhost:5678/api').url).toBe(
38+
'wss://localhost:5678/api'
39+
)
40+
})
41+
42+
it('throws an error on not allowed schemes', () => {
43+
expect(() => new WebSocket('invalid-protocol://localhost')).toThrow(
44+
expect.objectContaining({
45+
name: 'SyntaxError',
46+
message: `Failed to construct 'WebSocket': The URL's scheme must be either 'http', 'https', 'ws', or 'wss'. 'invalid-protocol:' is not allowed.`,
47+
})
48+
)
49+
})
50+
51+
it('throws on a relative URL in Node.js', () => {
52+
expect(() => new WebSocket('/not-allowed')).toThrow(
53+
expect.objectContaining({
54+
name: 'TypeError',
55+
code: 'ERR_INVALID_URL',
56+
message: 'Invalid URL',
57+
})
58+
)
59+
})
60+
61+
it('ensures trailing slash where appropriate', () => {
62+
expect(new WebSocket('wss://localhost:5678').url).toBe(
63+
'wss://localhost:5678/'
64+
)
65+
expect(new WebSocket('wss://localhost:5678/').url).toBe(
66+
'wss://localhost:5678/'
67+
)
68+
69+
expect(new WebSocket('wss://127.0.0.1:5678').url).toBe(
70+
'wss://127.0.0.1:5678/'
71+
)
72+
expect(new WebSocket('wss://127.0.0.1:5678/').url).toBe(
73+
'wss://127.0.0.1:5678/'
74+
)
75+
76+
expect(new WebSocket('wss://non-existing.com').url).toBe(
77+
'wss://non-existing.com/'
78+
)
79+
expect(new WebSocket('wss://non-existing.com/').url).toBe(
80+
'wss://non-existing.com/'
81+
)
82+
83+
expect(new WebSocket('wss://localhost:5678/foo').url).toBe(
84+
'wss://localhost:5678/foo'
85+
)
86+
})

test/modules/WebSocket/compliance/websocket.default.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ it('sets the instance ready state properties', () => {
3333
it('sets the "url" property to the passed URL', () => {
3434
expect(new WebSocket('wss://example.com')).toHaveProperty(
3535
'url',
36-
'wss://example.com'
36+
'wss://example.com/'
3737
)
3838
expect(new WebSocket(new URL('wss://example.com'))).toHaveProperty(
3939
'url',
@@ -42,7 +42,7 @@ it('sets the "url" property to the passed URL', () => {
4242

4343
expect(new WebSocket('ws://example.com')).toHaveProperty(
4444
'url',
45-
'ws://example.com'
45+
'ws://example.com/'
4646
)
4747
expect(new WebSocket(new URL('ws://example.com'))).toHaveProperty(
4848
'url',

test/modules/WebSocket/compliance/websocket.events.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ it('emits "message" event on incoming original server data', async () => {
108108
expect(messageEvent.data).toBe('hello')
109109
expect(messageEvent.target).toBe(ws)
110110
expect(messageEvent.currentTarget).toBe(ws)
111-
expect(messageEvent.origin).toBe(ws.url)
111+
expect(messageEvent.origin + '/').toBe(ws.url)
112112
})
113113

114114
it('emits "close" event when the mocked client closes the connection', async () => {

test/modules/WebSocket/compliance/websocket.server.close.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ it('throws if closing the unconnected server', async () => {
4141
const server = await serverPromise
4242

4343
expect(() => server.close()).toThrow(
44-
`Failed to close server connection for "wss://example.com": the connection is not open. Did you forget to call "server.connect()"?`
44+
`Failed to close server connection for "wss://example.com/": the connection is not open. Did you forget to call "server.connect()"?`
4545
)
4646
})
4747

0 commit comments

Comments
 (0)