-
-
Notifications
You must be signed in to change notification settings - Fork 618
Expand file tree
/
Copy pathWebSocketHandler.ts
More file actions
230 lines (196 loc) · 6.55 KB
/
WebSocketHandler.ts
File metadata and controls
230 lines (196 loc) · 6.55 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
import { Emitter } from 'strict-event-emitter'
import { createRequestId, resolveWebSocketUrl } from '@mswjs/interceptors'
import type {
WebSocketClientConnectionProtocol,
WebSocketConnectionData,
WebSocketServerConnectionProtocol,
} from '@mswjs/interceptors/WebSocket'
import {
type Match,
type Path,
type PathParams,
matchRequestUrl,
} from '../utils/matching/matchRequestUrl'
import { getCallFrame } from '../utils/internal/getCallFrame'
import { attachWebSocketLogger } from '../ws/utils/attachWebSocketLogger'
type WebSocketHandlerParsedResult = {
match: Match
}
export type WebSocketHandlerEventMap = {
connection: [args: WebSocketHandlerConnection]
}
export interface WebSocketConnectionFoo {
client: WebSocketClientConnectionProtocol
server: WebSocketServerConnectionProtocol
info: WebSocketConnectionData['info']
}
export interface WebSocketHandlerConnection extends WebSocketConnectionFoo {
params: PathParams
}
export interface WebSocketResolutionContext {
baseUrl?: string
[kAutoConnect]?: boolean
}
export const kEmitter = Symbol('kEmitter')
export const kSender = Symbol('kSender')
export const kConnect = Symbol('kConnect')
export const kAutoConnect = Symbol('kAutoConnect')
const kStopPropagationPatched = Symbol('kStopPropagationPatched')
const KOnStopPropagation = Symbol('KOnStopPropagation')
export class WebSocketHandler {
public id: string
public callFrame?: string
public kind = 'websocket' as const
protected [kEmitter]: Emitter<WebSocketHandlerEventMap>
constructor(protected readonly url: Path) {
this.id = createRequestId()
this[kEmitter] = new Emitter()
this.callFrame = getCallFrame(new Error())
}
public parse(args: {
url: URL
resolutionContext?: WebSocketResolutionContext
}): WebSocketHandlerParsedResult {
const clientUrl = new URL(args.url)
// Resolve the WebSocket handler path:
// - Plain string URLs resolved as per the specification (via Interceptors).
// - String URLs starting with a wildcard are preserved (prepending a scheme there will break them).
// - RegExp paths are preserved.
const resolvedHandlerUrl =
this.url instanceof RegExp || this.url.startsWith('*')
? this.url
: this.#resolveWebSocketUrl(this.url, args.resolutionContext?.baseUrl)
/**
* @note Remove the Socket.IO path prefix from the WebSocket
* client URL. This is an exception to keep the users from
* including the implementation details in their handlers.
*/
clientUrl.pathname = clientUrl.pathname.replace(/^\/socket.io\//, '/')
const match = matchRequestUrl(
clientUrl,
resolvedHandlerUrl,
args.resolutionContext?.baseUrl,
)
return {
match,
}
}
public predicate(args: {
url: URL
parsedResult: WebSocketHandlerParsedResult
}): boolean {
return args.parsedResult.match.matches
}
public async run(
connection: WebSocketConnectionFoo,
resolutionContext?: WebSocketResolutionContext,
): Promise<WebSocketHandlerConnection | null> {
const parsedResult = this.parse({
url: connection.client.url,
resolutionContext,
})
if (!this.predicate({ url: connection.client.url, parsedResult })) {
return null
}
const resolvedConnection: WebSocketHandlerConnection = {
...connection,
params: parsedResult.match.params || {},
}
if (resolutionContext?.[kAutoConnect]) {
if (this[kConnect](resolvedConnection)) {
return resolvedConnection
}
return null
}
return resolvedConnection
}
protected [kConnect](connection: WebSocketHandlerConnection): boolean {
// Support `event.stopPropagation()` for various client/server events.
connection.client.addEventListener(
'message',
createStopPropagationListener(this),
)
connection.client.addEventListener(
'close',
createStopPropagationListener(this),
)
connection.server.addEventListener(
'open',
createStopPropagationListener(this),
)
connection.server.addEventListener(
'message',
createStopPropagationListener(this),
)
connection.server.addEventListener(
'error',
createStopPropagationListener(this),
)
connection.server.addEventListener(
'close',
createStopPropagationListener(this),
)
/**
* @fixme Use "rettime" and await these events to have
* exceptions propagate properly.
*/
return this[kEmitter].emit('connection', connection)
}
public log(
connection: WebSocketConnectionData | WebSocketConnectionFoo,
): () => void {
return attachWebSocketLogger(connection)
}
#resolveWebSocketUrl(url: string, baseUrl?: string): string {
const resolvedUrl = resolveWebSocketUrl(
baseUrl
? /**
* @note Resolve against the base URL preemtively because `resolveWebSocketUrl` only
* resolves against `location.href`, which is missing in Node.js. Base URL allows
* the handler to accept a relative URL in Node.js.
*/
new URL(url, baseUrl)
: url,
)
/**
* @note Omit the trailing slash.
* While the browser always produces a trailing slash at the end of a WebSocket URL,
* having it in as the handler's predicate would mean it is *required* in the actual URL.
*/
return resolvedUrl.replace(/\/$/, '')
}
}
function createStopPropagationListener(handler: WebSocketHandler) {
return function stopPropagationListener(event: Event) {
const propagationStoppedAt = Reflect.get(event, 'kPropagationStoppedAt') as
| string
| undefined
if (propagationStoppedAt && handler.id !== propagationStoppedAt) {
event.stopImmediatePropagation()
return
}
Object.defineProperty(event, KOnStopPropagation, {
value(this: WebSocketHandler) {
Object.defineProperty(event, 'kPropagationStoppedAt', {
value: handler.id,
})
},
configurable: true,
})
// Since the same event instance is shared between all client/server objects,
// make sure to patch its `stopPropagation` method only once.
if (!Reflect.get(event, kStopPropagationPatched)) {
event.stopPropagation = new Proxy(event.stopPropagation, {
apply: (target, thisArg, args) => {
Reflect.get(event, KOnStopPropagation)?.call(handler)
return Reflect.apply(target, thisArg, args)
},
})
Object.defineProperty(event, kStopPropagationPatched, {
value: true,
// If something else attempts to redefine this, throw.
configurable: false,
})
}
}
}