-
Notifications
You must be signed in to change notification settings - Fork 190
Expand file tree
/
Copy pathpinnedProxy.ts
More file actions
332 lines (308 loc) · 11.7 KB
/
Copy pathpinnedProxy.ts
File metadata and controls
332 lines (308 loc) · 11.7 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
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
// WebSocket translation proxy that lets a recent @playwright/test client (1.61) drive an
// older `playwright run-server` (1.40). Two layers of translation:
//
// 1) HTTP upgrade — the 1.40 server's User-Agent version check rejects mismatched clients
// with HTTP 428. We rewrite the upgrade request's User-Agent so the check passes.
//
// 2) JSON-RPC — once connected, the recent client validates server messages strictly and
// sends commands using the recent schema. We patch __create__ initializers (server→client)
// and command parameters (client→server) where the schemas diverge between versions.
//
// Patches were derived from a diff of the JSON-RPC protocol schema in packages/protocol/src
// (`protocol.yml` up to v1.59.1, `channels.d.ts` from v1.60.0 onward — `protocol.yml` was
// removed in 1.60) between v1.40.1 and v1.61.0 — only the divergences exercised by this
// repo's e2e tests are translated.
//
// Usage: node pinnedProxy.ts --listen 5400 --upstream 127.0.0.1:5401
import http from 'node:http'
import process from 'node:process'
import { Buffer } from 'node:buffer'
import { WebSocketServer, WebSocket, type RawData } from 'ws'
interface JsonRpcMessage {
method?: string
guid?: string
params?: Record<string, unknown> & {
type?: string
guid?: string
initializer?: Record<string, unknown>
}
}
const args = Object.fromEntries(
process.argv
.slice(2)
.map((a, i, arr): [string, string] | null => (a.startsWith('--') ? [a.slice(2), arr[i + 1]] : null))
.filter((entry): entry is [string, string] => entry !== null)
)
const LISTEN = Number(args.listen || 5400)
const UPSTREAM = args.upstream || '127.0.0.1:5401'
const SPOOF_UA = args['spoof-ua'] || 'Playwright/1.40.1 (arm64; macos 14.5) node/22.0.0'
const TRACE = process.env.PINNED_PROXY_TRACE === '1'
const httpServer = http.createServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' })
res.end('pinnedProxy')
})
const wss = new WebSocketServer({ noServer: true })
httpServer.on('upgrade', (req, socket, head) => {
req.headers['user-agent'] = SPOOF_UA
wss.handleUpgrade(req, socket, head, (clientWs) => {
const upstreamUrl = `ws://${UPSTREAM}${req.url || '/'}`
const upstreamWs = new WebSocket(upstreamUrl, {
headers: {
'user-agent': SPOOF_UA,
...forwardHeader(req.headers, 'x-playwright-browser'),
...forwardHeader(req.headers, 'x-playwright-launch-options'),
...forwardHeader(req.headers, 'x-playwright-proxy'),
},
})
// guid -> channel type, populated from server __create__ messages so we know how to
// translate client commands like `Frame.waitForTimeout` based on the target object.
const guidTypes = new Map<string, string>()
// Request guid -> URL, used to inject requestUrl into Route.fulfill for 1.40 compat.
const requestUrls = new Map<string, string>()
// Route guid -> Request guid, to look up the URL when Route.fulfill is called.
const routeRequestGuids = new Map<string, string>()
let queueToUpstream: string[] = []
upstreamWs.on('open', () => {
for (const msg of queueToUpstream) {
upstreamWs.send(msg)
}
queueToUpstream = []
})
clientWs.on('message', (data: RawData) => {
const text = rawDataToString(data)
if (TRACE) {
console.log('C->S', text.slice(0, 500))
}
const rewritten = rewriteClientToServer(text, guidTypes, routeRequestGuids, requestUrls)
if (rewritten === null) {
return
}
if (upstreamWs.readyState === WebSocket.OPEN) {
upstreamWs.send(rewritten)
} else {
queueToUpstream.push(rewritten)
}
})
upstreamWs.on('message', (data: RawData) => {
const text = rawDataToString(data)
if (TRACE) {
console.log('S->C', text.slice(0, 800))
}
const rewritten = rewriteServerToClient(text, guidTypes, requestUrls, routeRequestGuids)
if (rewritten === null) {
return
}
for (const out of rewritten) {
clientWs.send(out)
}
})
const closeBoth = () => {
try {
clientWs.close()
} catch {
// ignore close errors
}
try {
upstreamWs.close()
} catch {
// ignore close errors
}
}
clientWs.on('close', closeBoth)
upstreamWs.on('close', closeBoth)
clientWs.on('error', closeBoth)
upstreamWs.on('error', (err) => {
console.error('[pinnedProxy] upstream error:', err.message)
closeBoth()
})
})
})
httpServer.listen(LISTEN, '127.0.0.1', () => {
console.log(`[pinnedProxy] listening on ws://127.0.0.1:${LISTEN}/ -> ws://${UPSTREAM}/`)
})
function rawDataToString(data: RawData): string {
if (Array.isArray(data)) {
return Buffer.concat(data).toString('utf8')
}
if (Buffer.isBuffer(data)) {
return data.toString('utf8')
}
return Buffer.from(data).toString('utf8')
}
function forwardHeader(headers: http.IncomingHttpHeaders, name: string): Record<string, string> {
const value = headers[name]
return typeof value === 'string' ? { [name]: value } : {}
}
// Server (1.40) -> Client (1.60). Returns one or more messages to forward to the client, or
// null to drop the upstream message. Returning multiple messages allows synthesising channels
// that newer client schemas require but the older server doesn't emit (e.g. Debugger).
function rewriteServerToClient(
text: string,
guidTypes: Map<string, string>,
requestUrls: Map<string, string>,
routeRequestGuids: Map<string, string>
): string[] | null {
let msg: JsonRpcMessage
try {
msg = JSON.parse(text) as JsonRpcMessage
} catch {
return [text]
}
// BrowserContextConsoleEvent requires `timestamp` (tFloat, ms since epoch) in 1.59. The 1.40
// server emits `console` without that field — inject a best-effort wall time.
if (
msg.method === 'console' &&
msg.params &&
msg.params.timestamp === undefined &&
msg.guid !== undefined &&
guidTypes.get(msg.guid) === 'BrowserContext'
) {
msg.params.timestamp = Date.now()
return [JSON.stringify(msg)]
}
// BrowserContextPageErrorEvent gained a required `location` ({ url, line, column }) in 1.60
// (backing the new `webError.location()` API). The 1.40 server emits `pageError` without it,
// so the 1.60 client's strict validator drops the event and uncaught exceptions / unhandled
// rejections / runtime errors never surface. Inject a best-effort stub so the event is
// delivered — our tests assert on the error captured by the SDK, not on the wire location.
if (
msg.method === 'pageError' &&
msg.params &&
msg.params.location === undefined &&
msg.guid !== undefined &&
guidTypes.get(msg.guid) === 'BrowserContext'
) {
msg.params.location = { url: '', line: 0, column: 0 }
return [JSON.stringify(msg)]
}
if (msg.method === '__create__' && msg.params) {
const type = msg.params.type
if (msg.params.guid && type) {
guidTypes.set(msg.params.guid, type)
}
const init = msg.params.initializer || {}
// Track Request URL so Route.fulfill can inject requestUrl for 1.40 compat.
if (type === 'Request' && msg.params.guid && typeof init.url === 'string') {
requestUrls.set(msg.params.guid, init.url)
}
// Track Route -> Request guid association.
if (
type === 'Route' &&
msg.params.guid &&
init.request &&
typeof (init.request as { guid?: string }).guid === 'string'
) {
routeRequestGuids.set(msg.params.guid, (init.request as { guid: string }).guid)
}
// Selectors channel was removed; drop the __create__ and strip references from Playwright.
if (type === 'Selectors') {
return null
}
if (type === 'Playwright') {
delete init.selectors
}
// BrowserContextInitializer requires `options` in 1.58 (sub-fields all optional).
if (type === 'BrowserContext' && init.options === undefined) {
init.options = {}
}
// RequestInitializer requires `hasResponse` in 1.58.
if (type === 'Request' && init.hasResponse === undefined) {
init.hasResponse = false
}
// BrowserInitializer requires `browserName` in 1.59. In 1.40 the `name` field already
// held the browser type ("chromium" | "firefox" | "webkit"), so copy it over.
if (type === 'Browser' && init.browserName === undefined && typeof init.name === 'string') {
init.browserName = init.name
}
// BrowserContextInitializer requires a `debugger` Debugger channel in 1.59. The 1.40 server
// has no Debugger channel, so synthesise one (parented to the same Browser as the context)
// and inject the reference. DebuggerInitializer is `{}` in 1.59, so an empty initializer is
// valid. The client only uses BrowserContext.debugger when debug controller features are
// requested, which our tests don't do.
if (type === 'BrowserContext' && init.debugger === undefined && msg.params.guid) {
const debuggerGuid = `debugger@synthetic-${msg.params.guid}`
guidTypes.set(debuggerGuid, 'Debugger')
const debuggerCreate = {
guid: msg.guid,
method: '__create__',
params: { type: 'Debugger', initializer: {}, guid: debuggerGuid },
}
init.debugger = { guid: debuggerGuid }
msg.params.initializer = init
return [JSON.stringify(debuggerCreate), JSON.stringify(msg)]
}
msg.params.initializer = init
}
return [JSON.stringify(msg)]
}
// Client (1.60) -> Server (1.40)
function rewriteClientToServer(
text: string,
guidTypes: Map<string, string>,
routeRequestGuids: Map<string, string>,
requestUrls: Map<string, string>
): string | null {
let msg: JsonRpcMessage
try {
msg = JSON.parse(text) as JsonRpcMessage
} catch {
return text
}
if (typeof msg.method !== 'string') {
return text
}
// __waitInfo__ is a fire-and-forget instrumentation message added in 1.61 (replacing the
// old EventTargetChannel.waitForEventInfo pattern). The client never registers a callback
// for it, so if the 1.40 server receives it and sends back an error response, the client
// throws "Cannot find command to respond: <id>". Drop it instead of forwarding.
if (msg.method === '__waitInfo__') {
return null
}
const type = msg.guid ? guidTypes.get(msg.guid) : undefined
const params = msg.params || {}
// Frame.waitForTimeout: param renamed `timeout` -> `waitTimeout` in 1.58.
if (type === 'Frame' && msg.method === 'waitForTimeout') {
if (params.waitTimeout !== undefined && params.timeout === undefined) {
params.timeout = params.waitTimeout
delete params.waitTimeout
}
}
// 1.40 required `noWaitAfter` on these mutation methods; 1.58 dropped it.
// Inject a default so the server's required-param validator is satisfied.
const noWaitAfterMethods: Record<string, Set<string>> = {
Frame: new Set([
'check',
'dblclick',
'dragAndDrop',
'fill',
'hover',
'selectOption',
'setInputFiles',
'tap',
'type',
'uncheck',
]),
ElementHandle: new Set([
'check',
'dblclick',
'fill',
'hover',
'selectOption',
'setInputFiles',
'tap',
'type',
'uncheck',
]),
}
if (type && noWaitAfterMethods[type]?.has(msg.method) && params.noWaitAfter === undefined) {
params.noWaitAfter = false
}
// Route.fulfill: 1.40 required `requestUrl: string` (used for CORS header injection);
// 1.58 dropped it. Inject the URL of the intercepted request to satisfy the validator.
if (type === 'Route' && msg.method === 'fulfill' && params.requestUrl === undefined) {
const reqGuid = msg.guid ? routeRequestGuids.get(msg.guid) : undefined
params.requestUrl = (reqGuid ? requestUrls.get(reqGuid) : undefined) ?? ''
}
msg.params = params
return JSON.stringify(msg)
}