-
Notifications
You must be signed in to change notification settings - Fork 3.2k
/
prerequests.ts
159 lines (130 loc) · 5.22 KB
/
prerequests.ts
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
import type {
CypressIncomingRequest,
BrowserPreRequest,
} from '@packages/proxy'
import Debug from 'debug'
const debug = Debug('cypress:proxy:http:util:prerequests')
const debugVerbose = Debug('cypress-verbose:proxy:http:util:prerequests')
const metrics: any = {
browserPreRequestsReceived: 0,
proxyRequestsReceived: 0,
immediatelyMatchedRequests: 0,
unmatchedRequests: 0,
unmatchedPreRequests: 0,
}
process.once('exit', () => {
debug('metrics: %o', metrics)
})
export type GetPreRequestCb = (browserPreRequest?: BrowserPreRequest) => void
type PendingRequest = {
ctxDebug
callback: GetPreRequestCb
timeout: NodeJS.Timeout
}
type PendingPreRequest = {
browserPreRequest: BrowserPreRequest
timestamp: number
}
/**
* Data structure that organizes items with duplicated keys into stacks.
*/
class StackMap<T> {
private stacks: Record<string, Array<T>> = {}
push (stackKey: string, value: T) {
if (!this.stacks[stackKey]) this.stacks[stackKey] = []
this.stacks[stackKey].push(value)
}
pop (stackKey: string): T | undefined {
const stack = this.stacks[stackKey]
if (!stack) return
const item = stack.pop()
if (stack.length === 0) delete this.stacks[stackKey]
return item
}
removeMatching (filterFn: (value: T) => boolean) {
Object.entries(this.stacks).forEach(([stackKey, stack]) => {
this.stacks[stackKey] = stack.filter(filterFn)
if (this.stacks[stackKey].length === 0) delete this.stacks[stackKey]
})
}
removeExact (stackKey: string, value: T) {
const i = this.stacks[stackKey].findIndex((v) => v === value)
this.stacks[stackKey].splice(i, 1)
if (this.stacks[stackKey].length === 0) delete this.stacks[stackKey]
}
get length () {
return Object.values(this.stacks).reduce((prev, cur) => prev + cur.length, 0)
}
}
// This class' purpose is to match up incoming "requests" (requests from the browser received by the http proxy)
// with "pre-requests" (events received by our browser extension indicating that the browser is about to make a request).
// Because these come from different sources, they can be out of sync, arriving in either order.
// Basically, when requests come in, we want to provide additional data read from the pre-request. but if no pre-request
// ever comes in, we don't want to block proxied requests indefinitely.
export class PreRequests {
requestTimeout: number
pendingPreRequests = new StackMap<PendingPreRequest>()
pendingRequests = new StackMap<PendingRequest>()
sweepInterval: ReturnType<typeof setInterval>
constructor (requestTimeout = 500) {
// If a request comes in and we don't have a matching pre-request after this timeout,
// we invoke the request callback to tell the server to proceed (we don't want to block
// user requests indefinitely).
this.requestTimeout = requestTimeout
// Discarding prerequests on the other hand is not urgent, so we do it on a regular interval
// rather than with a separate timer for each one.
// 2 times the requestTimeout is arbitrary, chosen to give plenty of time and
// make sure we don't discard any pre-requests prematurely but that we don't leak memory over time
// if a large number of pre-requests don't match up
// fixes: https://github.com/cypress-io/cypress/issues/17853
this.sweepInterval = setInterval(() => {
const now = Date.now()
this.pendingPreRequests.removeMatching(({ timestamp, browserPreRequest }) => {
if (timestamp + this.requestTimeout * 2 < now) {
debugVerbose('timed out unmatched pre-request: %o', browserPreRequest)
metrics.unmatchedPreRequests++
return false
}
return true
})
}, this.requestTimeout * 2)
}
addPending (browserPreRequest: BrowserPreRequest) {
metrics.browserPreRequestsReceived++
const key = `${browserPreRequest.method}-${browserPreRequest.url}`
const pendingRequest = this.pendingRequests.pop(key)
if (pendingRequest) {
debugVerbose('Incoming pre-request %s matches pending request. %o', key, browserPreRequest)
clearTimeout(pendingRequest.timeout)
pendingRequest.callback(browserPreRequest)
return
}
debugVerbose('Caching pre-request %s to be matched later. %o', key, browserPreRequest)
this.pendingPreRequests.push(key, {
browserPreRequest,
timestamp: Date.now(),
})
}
get (req: CypressIncomingRequest, ctxDebug, callback: GetPreRequestCb) {
metrics.proxyRequestsReceived++
const key = `${req.method}-${req.proxiedUrl}`
const pendingPreRequest = this.pendingPreRequests.pop(key)
if (pendingPreRequest) {
metrics.immediatelyMatchedRequests++
ctxDebug('Incoming request %s matches known pre-request: %o', key, pendingPreRequest)
callback(pendingPreRequest.browserPreRequest)
return
}
const pendingRequest: PendingRequest = {
ctxDebug,
callback,
timeout: setTimeout(() => {
ctxDebug('Never received pre-request for request %s after waiting %sms. Continuing without one.', key, this.requestTimeout)
metrics.unmatchedRequests++
this.pendingRequests.removeExact(key, pendingRequest)
callback()
}, this.requestTimeout),
}
this.pendingRequests.push(key, pendingRequest)
}
}