feat: prepare GoodBuddy 0.8.0

This commit is contained in:
lofyer
2026-08-05 19:40:40 +08:00
parent 38ac2206f2
commit 39a457ded8
96 changed files with 14221 additions and 1027 deletions
+2
View File
@@ -0,0 +1,2 @@
export const MAX_BROWSER_INPUT_LENGTH = 16_384
export const MAX_BROWSER_SELECT_LENGTH = 1_024
+5 -5
View File
@@ -33,8 +33,8 @@ function createService(): BrowserToolService {
})),
screenshot: vi.fn(async () => ({
type: 'image' as const,
mimeType: 'image/png' as const,
data: 'iVBORw0KGgo='
mimeType: 'image/jpeg' as const,
data: '/9j/2Q=='
})),
releaseConversation: vi.fn(async () => undefined)
}
@@ -180,11 +180,11 @@ describe('BrowserModelTools', () => {
parts: [
{
type: 'image',
mimeType: 'image/png',
data: 'iVBORw0KGgo='
mimeType: 'image/jpeg',
data: '/9j/2Q=='
}
],
contextBytes: Buffer.byteLength('iVBORw0KGgo=')
contextBytes: Buffer.byteLength('/9j/2Q==')
})
await tools.release()
expect(service.releaseConversation).toHaveBeenCalledWith('conversation')
+4 -2
View File
@@ -8,10 +8,12 @@ import type {
import type { RuntimeApprovalRequest } from '../agent/runtime'
import { canonicalizeBrowserUrl } from './browser-url-policy'
import type { BrowserService } from './browser-service'
import {
MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH,
MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH
} from './browser-limits'
const MAX_REF_LENGTH = 64
const MAX_INPUT_LENGTH = 16_384
const MAX_SELECT_LENGTH = 1_024
const refSchema = z
.string()
+11
View File
@@ -0,0 +1,11 @@
export {
BOUNDED_JPEG_QUALITIES as BROWSER_JPEG_QUALITIES,
isValidJpeg as isValidBrowserJpeg,
MAX_BOUNDED_JPEG_BYTES as MAX_BROWSER_SCREENSHOT_BYTES
} from '../bounded-jpeg'
export type BrowserScreenshot = {
type: 'image'
mimeType: 'image/jpeg'
data: string
}
+3 -3
View File
@@ -69,8 +69,8 @@ function createHarness(options: {
}),
screenshot: vi.fn(async () => ({
type: 'image' as const,
mimeType: 'image/png' as const,
data: 'iVBORw0KGgo='
mimeType: 'image/jpeg' as const,
data: '/9j/2Q=='
})),
dispose: vi.fn()
}
@@ -132,7 +132,7 @@ describe('BrowserService', () => {
'stopped'
])
expect(states.find((state) => state.status === 'ready')?.frameDataUrl).toBe(
'data:image/png;base64,iVBORw0KGgo='
'data:image/jpeg;base64,/9j/2Q=='
)
expect(states.at(-1)?.frameDataUrl).toBeUndefined()
const replayed: string[] = []
+4 -2
View File
@@ -2,9 +2,9 @@ import { BrowserUrlPolicy, canonicalizeBrowserUrl } from './browser-url-policy'
import {
CdpBrowserDriver,
type BrowserHistoryTarget,
type BrowserScreenshot,
type BrowserSnapshot
} from './cdp-browser-driver'
import type { BrowserScreenshot } from './browser-screenshot'
import {
ElectronBrowserSession,
type BrowserWebContents
@@ -718,7 +718,9 @@ export class BrowserService {
async (slot, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(slot)
const screenshot =
await slot.driver.screenshot(effectiveSignal)
slot.session.captureScreenshot
? await slot.session.captureScreenshot(effectiveSignal)
: await slot.driver.screenshot(effectiveSignal)
await this.captureFrame(
conversationId,
slot,
+272 -13
View File
@@ -157,9 +157,7 @@ function standardCommand(
}
if (method === 'Page.captureScreenshot') {
return Promise.resolve({
data: Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]).toString('base64')
data: Buffer.from([0xff, 0xd8, 0xff, 0xd9]).toString('base64')
})
}
return Promise.resolve({})
@@ -211,6 +209,109 @@ function selectCommand(
}
describe('CdpBrowserDriver', () => {
it('waits for the requested main-frame commit instead of incumbent about:blank readiness', async () => {
let readinessChecks = 0
const harness = createHarness(async (method, parameters) => {
if (method === 'Page.navigate') {
return { frameId: 'main', loaderId: 'loader-1' }
}
if (
method === 'Runtime.evaluate' &&
parameters?.expression === 'document.readyState'
) {
readinessChecks += 1
return { result: { value: 'complete' } }
}
return standardCommand(method, parameters)
})
harness.setUrl('about:blank')
const driver = new CdpBrowserDriver(harness.webContents)
const navigation = driver.navigate(
'https://example.com/page',
new AbortController().signal
)
await vi.waitFor(() =>
expect(harness.sendCommand).toHaveBeenCalledWith(
'Page.navigate',
{ url: 'https://example.com/page' }
)
)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(readinessChecks).toBe(0)
harness.contentEvents.emit(
'did-navigate-in-page',
{},
'https://example.com/frame',
false
)
await new Promise((resolve) => setTimeout(resolve, 10))
expect(readinessChecks).toBe(0)
harness.setUrl('https://example.com/page')
harness.contentEvents.emit(
'did-navigate',
{},
'https://example.com/page'
)
await expect(navigation).resolves.toEqual({
url: 'https://example.com/page'
})
expect(readinessChecks).toBe(1)
driver.dispose()
})
it('fails when the requested main frame never commits', async () => {
const harness = createHarness(async (method, parameters) =>
method === 'Page.navigate'
? { frameId: 'main', loaderId: 'loader-1' }
: standardCommand(method, parameters)
)
harness.setUrl('about:blank')
const driver = new CdpBrowserDriver(harness.webContents, {
timeoutMs: 30
})
await expect(
driver.navigate(
'https://example.com/page',
new AbortController().signal
)
).rejects.toThrow('未在安全期限内提交')
driver.dispose()
})
it('surfaces a main-frame load failure before reporting ready', async () => {
const harness = createHarness(async (method, parameters) =>
method === 'Page.navigate'
? { frameId: 'main', loaderId: 'loader-1' }
: standardCommand(method, parameters)
)
const driver = new CdpBrowserDriver(harness.webContents)
const navigation = driver.navigate(
'https://example.com/page',
new AbortController().signal
)
await vi.waitFor(() =>
expect(harness.sendCommand).toHaveBeenCalledWith(
'Page.navigate',
{ url: 'https://example.com/page' }
)
)
harness.contentEvents.emit(
'did-fail-load',
{},
-105,
'NAME_NOT_RESOLVED',
'https://example.com/page',
true
)
await expect(navigation).rejects.toThrow('NAME_NOT_RESOLVED')
driver.dispose()
})
it('creates opaque refs and redacts editable and protected values', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
@@ -232,15 +333,127 @@ describe('CdpBrowserDriver', () => {
driver.dispose()
})
it('rejects accessibility trees above the configured byte limit', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents, {
maximumAxBytes: 100
it('truncates very large accessibility trees without failing', async () => {
const largeNodes = [
{
nodeId: 'root',
backendDOMNodeId: 100,
role: { value: 'RootWebArea' },
name: { value: 'Large page' }
},
...Array.from({ length: 2_000 }, (_, index) => ({
nodeId: `node-${index}`,
parentId: 'root',
backendDOMNodeId: index + 101,
role: { value: 'button' },
name: { value: `Item ${index} ${'x'.repeat(2_000)}` }
}))
]
const harness = createHarness((method, parameters) =>
method === 'Accessibility.getFullAXTree'
? Promise.resolve({ nodes: largeNodes })
: standardCommand(method, parameters)
)
const driver = new CdpBrowserDriver(harness.webContents)
const snapshot = await driver.snapshot(new AbortController().signal)
expect(snapshot.truncated).toBe(true)
expect(snapshot.nodes.length).toBeGreaterThan(0)
expect(snapshot.nodes.length).toBeLessThan(500)
expect(Buffer.byteLength(JSON.stringify(snapshot))).toBeLessThanOrEqual(
128 * 1024
)
driver.dispose()
})
it('rejects a snapshot crossed by main-frame navigation', async () => {
const harness = createHarness(async (method, parameters) => {
if (
method === 'Runtime.evaluate' &&
parameters?.expression !== 'document.readyState'
) {
harness.contentEvents.emit(
'did-start-navigation',
{},
'https://example.com/changed',
false,
true
)
}
return standardCommand(method, parameters)
})
const driver = new CdpBrowserDriver(harness.webContents)
await expect(
driver.snapshot(new AbortController().signal)
).rejects.toThrow('可访问性树超过安全限制')
).rejects.toThrow('生成快照时发生变化')
driver.dispose()
})
it('retries a transient CDP navigation race while taking a snapshot', async () => {
let metadataAttempts = 0
const harness = createHarness(async (method, parameters) => {
if (
method === 'Runtime.evaluate' &&
parameters?.expression !== 'document.readyState'
) {
metadataAttempts += 1
if (metadataAttempts === 1) {
throw new Error('Inspected target navigated or closed')
}
}
return standardCommand(method, parameters)
})
const driver = new CdpBrowserDriver(harness.webContents)
await expect(
driver.snapshot(new AbortController().signal)
).resolves.toMatchObject({ title: 'Example' })
expect(metadataAttempts).toBe(2)
driver.dispose()
})
it('waits briefly for a placeholder challenge document to populate', async () => {
let snapshotAttempts = 0
const harness = createHarness(async (method, parameters) => {
if (method === 'Accessibility.getFullAXTree') {
snapshotAttempts += 1
return snapshotAttempts === 1
? {
nodes: [
{
nodeId: 'root',
backendDOMNodeId: 10,
role: { value: 'RootWebArea' },
name: { value: '' }
}
]
}
: standardCommand(method, parameters)
}
if (
method === 'Runtime.evaluate' &&
parameters?.expression !== 'document.readyState' &&
snapshotAttempts === 1
) {
return {
result: {
value: {
title: '',
url: 'https://example.com/challenge'
}
}
}
}
return standardCommand(method, parameters)
})
const driver = new CdpBrowserDriver(harness.webContents)
await expect(
driver.snapshot(new AbortController().signal)
).resolves.toMatchObject({ title: 'Example' })
expect(snapshotAttempts).toBe(2)
driver.dispose()
})
@@ -417,15 +630,24 @@ describe('CdpBrowserDriver', () => {
driver.dispose()
})
it('bounds screenshots and returns only validated PNG data', async () => {
it('bounds screenshots and returns only validated JPEG data', async () => {
const harness = createHarness(standardCommand)
const driver = new CdpBrowserDriver(harness.webContents)
await expect(
driver.screenshot(new AbortController().signal)
).resolves.toMatchObject({
type: 'image',
mimeType: 'image/png'
mimeType: 'image/jpeg'
})
expect(harness.sendCommand).toHaveBeenCalledWith(
'Page.captureScreenshot',
{
format: 'jpeg',
quality: 60,
fromSurface: true,
captureBeyondViewport: false
}
)
harness.sendCommand.mockImplementation(async (method) =>
method === 'Page.captureScreenshot' ? { data: 'bm90LXBuZw==' } : {}
)
@@ -444,9 +666,24 @@ describe('CdpBrowserDriver', () => {
url: 'https://previous.example/'
})
harness.setUrl('https://previous.example/')
await expect(
driver.backTo(target, new AbortController().signal)
).resolves.toEqual({ url: 'https://previous.example/' })
const navigation = driver.backTo(
target,
new AbortController().signal
)
await vi.waitFor(() =>
expect(harness.sendCommand).toHaveBeenCalledWith(
'Page.navigateToHistoryEntry',
{ entryId: 4 }
)
)
harness.contentEvents.emit(
'did-navigate',
{},
'https://previous.example/'
)
await expect(navigation).resolves.toEqual({
url: 'https://previous.example/'
})
expect(harness.sendCommand).toHaveBeenCalledWith(
'Page.navigateToHistoryEntry',
{ entryId: 4 }
@@ -467,4 +704,26 @@ describe('CdpBrowserDriver', () => {
driver.screenshot(new AbortController().signal)
).rejects.toThrow('不可用')
})
it('cancels an uncommitted navigation and removes temporary listeners on disposal', async () => {
const harness = createHarness(async (method, parameters) =>
method === 'Page.navigate'
? { frameId: 'main', loaderId: 'loader-1' }
: standardCommand(method, parameters)
)
const driver = new CdpBrowserDriver(harness.webContents)
const navigation = driver.navigate(
'https://example.com/page',
new AbortController().signal
)
await vi.waitFor(() =>
expect(harness.contentEvents.listenerCount('did-navigate')).toBe(1)
)
driver.dispose()
await expect(navigation).rejects.toThrow('驱动已关闭')
expect(harness.contentEvents.listenerCount('did-navigate')).toBe(0)
expect(harness.contentEvents.listenerCount('did-fail-load')).toBe(0)
})
})
+288 -163
View File
@@ -4,15 +4,21 @@ import type {
BrowserEventListener,
BrowserWebContents
} from './electron-browser-session'
import {
BROWSER_JPEG_QUALITIES,
isValidBrowserJpeg,
MAX_BROWSER_SCREENSHOT_BYTES,
type BrowserScreenshot
} from './browser-screenshot'
import {
MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH,
MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH
} from './browser-limits'
const DEFAULT_TIMEOUT_MS = 15_000
const MAX_AX_NODES = 500
const MAX_AX_DEPTH = 20
const MAX_AX_BYTES = 1024 * 1024
const MAX_SNAPSHOT_BYTES = 128 * 1024
const MAX_SCREENSHOT_BYTES = 512 * 1024
const MAX_INPUT_LENGTH = 16_384
const MAX_SELECT_LENGTH = 1_024
const SELECT_OPTION_FUNCTION = `function (expectedValue) {
const options = Array.from(this.options);
const option = options.find((candidate) => candidate.value === expectedValue);
@@ -66,12 +72,6 @@ export type BrowserSnapshot = {
truncated: boolean
}
export type BrowserScreenshot = {
type: 'image'
mimeType: 'image/png'
data: string
}
export class BrowserStaleReferenceError extends Error {
constructor(message = '浏览器元素引用已失效,请重新获取快照') {
super(message)
@@ -95,7 +95,6 @@ export type CdpBrowserDriverOptions = {
timeoutMs?: number
maximumAxNodes?: number
maximumAxDepth?: number
maximumAxBytes?: number
maximumSnapshotBytes?: number
maximumScreenshotBytes?: number
}
@@ -105,6 +104,11 @@ type ResolvedTarget = {
bounds: { x: number; y: number; width: number; height: number }
}
type NavigationWait = {
promise: Promise<void>
cancel(error: unknown): void
}
function stringValue(value: CdpAxValue | undefined): string {
return typeof value?.value === 'string'
? value.value.slice(0, 2_000)
@@ -159,97 +163,41 @@ function delayAbortable(
})
}
function jsonStringBytes(value: string): number {
let bytes = 2
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (
code === 0x08 ||
code === 0x09 ||
code === 0x0a ||
code === 0x0c ||
code === 0x0d ||
code === 0x22 ||
code === 0x5c
) {
bytes += 2
} else if (code < 0x20) {
bytes += 6
} else if (code < 0x80) {
bytes += 1
} else if (code < 0x800) {
bytes += 2
} else if (
code >= 0xd800 &&
code <= 0xdbff &&
value.charCodeAt(index + 1) >= 0xdc00 &&
value.charCodeAt(index + 1) <= 0xdfff
) {
bytes += 4
index += 1
} else if (code >= 0xd800 && code <= 0xdfff) {
bytes += 6
} else {
bytes += 3
function isTransientNavigationError(error: unknown): boolean {
let current = error
for (let depth = 0; depth < 4; depth += 1) {
if (!(current instanceof Error)) {
return false
}
}
return bytes
}
function exceedsJsonByteLimit(value: unknown, maximumBytes: number): boolean {
let bytes = 0
const stack = [value]
const seen = new WeakSet<object>()
const add = (amount: number): boolean => {
bytes += amount
return bytes > maximumBytes
}
while (stack.length > 0) {
const current = stack.pop()
if (current === null) {
if (add(4)) return true
} else if (typeof current === 'string') {
if (add(jsonStringBytes(current))) return true
} else if (typeof current === 'number') {
if (add(Number.isFinite(current) ? String(current).length : 4)) {
return true
}
} else if (typeof current === 'boolean') {
if (add(current ? 4 : 5)) return true
} else if (Array.isArray(current)) {
if (seen.has(current) || add(current.length > 0 ? current.length + 1 : 2)) {
return true
}
seen.add(current)
for (let index = current.length - 1; index >= 0; index -= 1) {
stack.push(current[index])
}
} else if (typeof current === 'object') {
if (seen.has(current)) return true
seen.add(current)
const entries = Object.entries(current).filter(
([, entryValue]) => entryValue !== undefined
if (
/Inspected target navigated|Execution context was destroyed|Cannot find context/iu.test(
current.message
)
if (add(entries.length > 0 ? entries.length + 1 : 2)) return true
for (let index = entries.length - 1; index >= 0; index -= 1) {
const [key, entryValue] = entries[index]!
if (add(jsonStringBytes(key) + 1)) return true
stack.push(entryValue)
}
} else {
) {
return true
}
current = current.cause
}
return false
}
function isPlaceholderSnapshot(snapshot: BrowserSnapshot): boolean {
return (
snapshot.title.length === 0 &&
snapshot.nodes.length <= 1 &&
snapshot.nodes.every(
(node) =>
node.role.toLowerCase() === 'rootwebarea' &&
node.name.length === 0
)
)
}
export class CdpBrowserDriver {
private readonly debugger: BrowserDebugger
private readonly timeoutMs: number
private readonly maximumAxNodes: number
private readonly maximumAxDepth: number
private readonly maximumAxBytes: number
private readonly maximumSnapshotBytes: number
private readonly maximumScreenshotBytes: number
private readonly refSecret = randomBytes(16)
@@ -259,6 +207,9 @@ export class CdpBrowserDriver {
event: string
listener: BrowserEventListener
}> = []
private readonly navigationCancels = new Set<
(error: unknown) => void
>()
private generation = 0
private disposed = false
@@ -270,11 +221,10 @@ export class CdpBrowserDriver {
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
this.maximumAxNodes = options.maximumAxNodes ?? MAX_AX_NODES
this.maximumAxDepth = options.maximumAxDepth ?? MAX_AX_DEPTH
this.maximumAxBytes = options.maximumAxBytes ?? MAX_AX_BYTES
this.maximumSnapshotBytes =
options.maximumSnapshotBytes ?? MAX_SNAPSHOT_BYTES
this.maximumScreenshotBytes =
options.maximumScreenshotBytes ?? MAX_SCREENSHOT_BYTES
options.maximumScreenshotBytes ?? MAX_BROWSER_SCREENSHOT_BYTES
this.listen(
webContents,
'did-start-navigation',
@@ -363,16 +313,137 @@ export class CdpBrowserDriver {
async navigate(url: string, signal: AbortSignal): Promise<{ url: string }> {
this.invalidate()
const result = await this.command<{
const navigation = this.waitForMainFrameCommit(url, signal)
let result: {
errorText?: string
}>('Page.navigate', { url }, signal)
if (result.errorText) {
throw new Error(`浏览器导航失败:${result.errorText.slice(0, 200)}`)
isDownload?: boolean
}
try {
result = await this.command<{
errorText?: string
isDownload?: boolean
}>('Page.navigate', { url }, signal)
} catch (error) {
navigation.cancel(error)
await navigation.promise.catch(() => undefined)
throw error
}
if (result.errorText) {
const error = new Error(
`浏览器导航失败:${result.errorText.slice(0, 200)}`
)
navigation.cancel(error)
await navigation.promise.catch(() => undefined)
throw error
}
if (result.isDownload) {
const error = new Error('浏览器导航目标是下载文件,未打开页面')
navigation.cancel(error)
await navigation.promise.catch(() => undefined)
throw error
}
await navigation.promise
await this.waitForDocument(signal)
return { url: this.webContents.getURL() || url }
}
private waitForMainFrameCommit(
targetUrl: string,
signal: AbortSignal
): NavigationWait {
let settle:
| { resolve(): void; reject(error: unknown): void }
| undefined
const promise = new Promise<void>((resolve, reject) => {
settle = { resolve, reject }
})
let settled = false
const cleanup = (): void => {
clearTimeout(timer)
signal.removeEventListener('abort', onAbort)
this.webContents.off('did-navigate', onNavigate)
this.webContents.off('did-navigate-in-page', onNavigateInPage)
this.webContents.off('did-fail-load', onFailLoad)
this.webContents.off('render-process-gone', onRenderGone)
this.navigationCancels.delete(reject)
}
const resolve = (): void => {
if (settled) {
return
}
settled = true
cleanup()
settle?.resolve()
}
const reject = (error: unknown): void => {
if (settled) {
return
}
settled = true
cleanup()
settle?.reject(error)
}
const onNavigate = (_event: unknown, committedUrl: string): void => {
if (
targetUrl !== 'about:blank' &&
committedUrl === 'about:blank'
) {
return
}
resolve()
}
const onNavigateInPage = (
_event: unknown,
committedUrl: string,
isMainFrame: boolean | undefined
): void => {
if (
isMainFrame === false ||
(targetUrl !== 'about:blank' &&
committedUrl === 'about:blank')
) {
return
}
resolve()
}
const onFailLoad = (
_event: unknown,
errorCode: number,
errorDescription: string,
failedUrl: string,
isMainFrame: boolean | undefined
): void => {
if (isMainFrame === false) {
return
}
reject(
new Error(
`浏览器导航失败:${String(errorDescription || errorCode).slice(0, 160)}${failedUrl ? `${failedUrl.slice(0, 500)}` : ''}`
)
)
}
const onRenderGone = (): void =>
reject(new Error('浏览器渲染进程在页面提交前退出'))
const onAbort = (): void => reject(signal.reason)
const timer = setTimeout(
() =>
reject(
new Error(`浏览器页面未在安全期限内提交(${this.timeoutMs}ms`)
),
this.timeoutMs
)
this.webContents.on('did-navigate', onNavigate)
this.webContents.on('did-navigate-in-page', onNavigateInPage)
this.webContents.on('did-fail-load', onFailLoad)
this.webContents.on('render-process-gone', onRenderGone)
this.navigationCancels.add(reject)
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) {
onAbort()
}
return { promise, cancel: reject }
}
private async waitForDocument(signal: AbortSignal): Promise<void> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const result = await this.command<{
@@ -408,19 +479,71 @@ export class CdpBrowserDriver {
}
async snapshot(signal: AbortSignal): Promise<BrowserSnapshot> {
let lastError: unknown
for (let attempt = 0; attempt < 5; attempt += 1) {
const expectedGeneration = this.generation + 1
try {
const snapshot = await this.snapshotOnce(signal)
if (attempt < 4 && isPlaceholderSnapshot(snapshot)) {
await delayAbortable(500, signal)
continue
}
return snapshot
} catch (error) {
lastError = error
if (
attempt === 4 ||
(this.generation === expectedGeneration &&
!isTransientNavigationError(error))
) {
throw error
}
await delayAbortable(100, signal)
}
}
throw lastError
}
private async snapshotOnce(
signal: AbortSignal
): Promise<BrowserSnapshot> {
this.invalidate()
const snapshotGeneration = this.generation
const response = await this.command<{ nodes?: CdpAxNode[] }>(
'Accessibility.getFullAXTree',
{ depth: this.maximumAxDepth },
signal
)
if (exceedsJsonByteLimit(response, this.maximumAxBytes)) {
throw new Error('浏览器可访问性树超过安全限制')
const document = await this.command<{
result?: { value?: { title?: unknown; url?: unknown } }
}>(
'Runtime.evaluate',
{
expression: '({title: document.title, url: location.href})',
returnByValue: true,
awaitPromise: false
},
signal
)
if (this.generation !== snapshotGeneration) {
throw new Error('浏览器页面在生成快照时发生变化,请重试')
}
const title =
typeof document.result?.value?.title === 'string'
? document.result.value.title.slice(0, 500)
: ''
const url =
typeof document.result?.value?.url === 'string'
? document.result.value.url.slice(0, 8_192)
: this.webContents.getURL()
const allNodes = response.nodes ?? []
const limited = allNodes.slice(0, this.maximumAxNodes)
const knownDepth = new Map<string, number>()
const output: BrowserSnapshotNode[] = []
let outputBytes = Buffer.byteLength(
JSON.stringify({ url, title, nodes: [], truncated: false })
)
let truncated = allNodes.length > limited.length
for (const node of limited) {
const parentDepth = node.parentId
? knownDepth.get(node.parentId)
@@ -439,12 +562,6 @@ export class CdpBrowserDriver {
const role = stringValue(node.role) || 'unknown'
const ref = this.refFor(node.backendDOMNodeId)
const protectedNode = isProtectedAxNode(node)
this.refs.set(ref, {
backendNodeId: node.backendDOMNodeId,
generation: this.generation,
role,
protected: protectedNode
})
const item: BrowserSnapshotNode = {
ref,
role,
@@ -463,38 +580,28 @@ export class CdpBrowserDriver {
if (value && !redactedValue) {
item.value = value
}
const itemBytes =
Buffer.byteLength(JSON.stringify(item)) +
(output.length > 0 ? 1 : 0)
if (outputBytes + itemBytes > this.maximumSnapshotBytes) {
truncated = true
continue
}
outputBytes += itemBytes
this.refs.set(ref, {
backendNodeId: node.backendDOMNodeId,
generation: this.generation,
role,
protected: protectedNode
})
output.push(item)
}
const document = await this.command<{
result?: { value?: { title?: unknown; url?: unknown } }
}>(
'Runtime.evaluate',
{
expression: '({title: document.title, url: location.href})',
returnByValue: true,
awaitPromise: false
},
signal
)
const title =
typeof document.result?.value?.title === 'string'
? document.result.value.title.slice(0, 500)
: ''
const url =
typeof document.result?.value?.url === 'string'
? document.result.value.url.slice(0, 8_192)
: this.webContents.getURL()
const snapshot = {
return {
url,
title,
nodes: output,
truncated: allNodes.length > limited.length
truncated
}
if (Buffer.byteLength(JSON.stringify(snapshot)) > this.maximumSnapshotBytes) {
this.refs.clear()
throw new Error('浏览器快照超过安全限制')
}
return snapshot
}
private async resolveTarget(
@@ -737,11 +844,19 @@ export class CdpBrowserDriver {
throw new Error('浏览器历史记录已改变,请重试')
}
this.invalidate()
await this.command(
'Page.navigateToHistoryEntry',
{ entryId: target.entryId },
signal
)
const navigation = this.waitForMainFrameCommit(target.url, signal)
try {
await this.command(
'Page.navigateToHistoryEntry',
{ entryId: target.entryId },
signal
)
} catch (error) {
navigation.cancel(error)
await navigation.promise.catch(() => undefined)
throw error
}
await navigation.promise
await this.waitForDocument(signal)
return { url: this.webContents.getURL() }
}
@@ -751,37 +866,43 @@ export class CdpBrowserDriver {
}
async screenshot(signal: AbortSignal): Promise<BrowserScreenshot> {
const result = await this.command<{ data?: string }>(
'Page.captureScreenshot',
{
format: 'png',
fromSurface: true,
captureBeyondViewport: false
},
signal
)
if (
typeof result.data !== 'string' ||
result.data.length === 0 ||
result.data.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
result.data
for (const quality of BROWSER_JPEG_QUALITIES) {
const result = await this.command<{ data?: string }>(
'Page.captureScreenshot',
{
format: 'jpeg',
quality,
fromSurface: true,
captureBeyondViewport: false
},
signal
)
) {
throw new Error('浏览器返回了无效截图')
if (
typeof result.data !== 'string' ||
result.data.length === 0 ||
result.data.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
result.data
)
) {
throw new Error('浏览器返回了无效截图')
}
const data = Buffer.from(result.data, 'base64')
if (
data.toString('base64') !== result.data ||
!isValidBrowserJpeg(data)
) {
throw new Error('浏览器截图无效')
}
if (data.byteLength <= this.maximumScreenshotBytes) {
return {
type: 'image',
mimeType: 'image/jpeg',
data: result.data
}
}
}
const data = Buffer.from(result.data, 'base64')
if (
data.byteLength > this.maximumScreenshotBytes ||
data.byteLength < 8 ||
!data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
) ||
data.toString('base64') !== result.data
) {
throw new Error('浏览器截图无效或超过安全限制')
}
return { type: 'image', mimeType: 'image/png', data: result.data }
throw new Error('浏览器截图超过约 220KB 限制')
}
dispose(): void {
@@ -790,6 +911,10 @@ export class CdpBrowserDriver {
}
this.disposed = true
this.invalidate()
for (const cancel of this.navigationCancels) {
cancel(new Error('浏览器驱动已关闭'))
}
this.navigationCancels.clear()
for (const { target, event, listener } of this.listeners.splice(0)) {
target.off(event, listener)
}
@@ -24,6 +24,12 @@ function createHarness() {
let currentUrl = ''
let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined
const sendCommand = vi.fn(async () => ({}))
const capturedImage = {
getSize: () => ({ width: 1_280, height: 800 }),
resize: vi.fn(),
toJPEG: () => Buffer.from([0xff, 0xd8, 0xff, 0xd9])
}
capturedImage.resize.mockReturnValue(capturedImage)
const webContents: BrowserWebContents = {
debugger: {
attach: vi.fn(),
@@ -54,12 +60,7 @@ function createHarness() {
setWindowOpenHandler: vi.fn((handler) => {
openHandler = handler
}),
capturePage: vi.fn(async () => ({
toPNG: () =>
Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
])
})),
capturePage: vi.fn(async () => capturedImage),
getURL: vi.fn(() => currentUrl),
stop: vi.fn(),
destroy: vi.fn(),
@@ -99,6 +100,7 @@ function createHarness() {
displayMedia = handler
}),
setProxy: vi.fn(async () => undefined),
setUserAgent: vi.fn(),
on: (event, listener) =>
partitionEvents.on(
event,
@@ -167,6 +169,13 @@ describe('ElectronBrowserSession', () => {
proxyRules: 'http://127.0.0.1:12345',
proxyBypassRules: '<-loopback>'
})
expect(harness.partition.setUserAgent).toHaveBeenCalledWith(
expect.stringMatching(/ Chrome\/.+ Safari\/537\.36$/u),
'zh-CN,zh,en'
)
expect(
vi.mocked(harness.partition.setUserAgent!).mock.calls[0]?.[0]
).not.toContain('Electron')
expect(harness.getPermissionCheck()?.()).toBe(false)
const permissionCallback = vi.fn()
harness.getPermissionRequest()?.({}, 'geolocation', permissionCallback, {})
@@ -194,8 +203,8 @@ describe('ElectronBrowserSession', () => {
session.captureScreenshot(new AbortController().signal)
).resolves.toEqual({
type: 'image',
mimeType: 'image/png',
data: 'iVBORw0KGgo='
mimeType: 'image/jpeg',
data: '/9j/2Q=='
})
const downloadEvent = { preventDefault: vi.fn() }
+33 -19
View File
@@ -5,6 +5,8 @@ import {
type ValidatedBrowserUrl
} from './browser-url-policy'
import { FilteringProxy } from './filtering-proxy'
import type { BrowserScreenshot } from './browser-screenshot'
import { encodeBoundedJpeg } from '../bounded-jpeg'
export type BrowserEventListener = (...argumentsValue: never[]) => void
@@ -20,6 +22,15 @@ export type BrowserDebugger = {
off(event: string, listener: BrowserEventListener): unknown
}
export type BrowserCapturedImage = {
getSize(): { width: number; height: number }
resize(options: {
width: number
quality: 'good'
}): BrowserCapturedImage
toJPEG(quality: number): Buffer
}
export type BrowserWebContents = {
debugger: BrowserDebugger
on(event: string, listener: BrowserEventListener): unknown
@@ -27,9 +38,7 @@ export type BrowserWebContents = {
setWindowOpenHandler(
handler: (details: { url: string }) => { action: 'deny' }
): void
capturePage?(): Promise<{
toPNG(): Buffer
}>
capturePage?(): Promise<BrowserCapturedImage>
getURL(): string
stop(): void
close?(options?: { waitForBeforeUnload?: boolean }): void
@@ -67,6 +76,10 @@ export type BrowserPartitionSession = {
proxyRules: string
proxyBypassRules: string
}): Promise<void>
setUserAgent?(
userAgent: string,
acceptLanguages?: string
): void
on(event: string, listener: BrowserEventListener): unknown
off(event: string, listener: BrowserEventListener): unknown
clearData(): Promise<void>
@@ -95,6 +108,16 @@ type Listener = {
listener: BrowserEventListener
}
function managedBrowserUserAgent(): string {
const platform =
process.platform === 'win32'
? 'Windows NT 10.0; Win64; x64'
: process.platform === 'darwin'
? 'Macintosh; Intel Mac OS X 10_15_7'
: `X11; Linux ${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}`
return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${process.versions.chrome ?? '136.0.0.0'} Safari/537.36`
}
async function cleanupIsolatedState(
partitionSession: BrowserPartitionSession | undefined,
proxy: FilteringProxyLike,
@@ -258,6 +281,10 @@ export class ElectronBrowserSession {
partitionSession.setDisplayMediaRequestHandler(
(_request, callback) => callback({})
)
partitionSession.setUserAgent?.(
managedBrowserUserAgent(),
'zh-CN,zh,en'
)
setupStage = '配置网络代理'
await boundedSetup(
partitionSession.setProxy({
@@ -465,11 +492,7 @@ export class ElectronBrowserSession {
async captureScreenshot(
signal: AbortSignal
): Promise<{
type: 'image'
mimeType: 'image/png'
data: string
}> {
): Promise<BrowserScreenshot> {
this.assertOpen()
if (!this.webContents.capturePage) {
throw new Error('浏览器原生画面捕获不可用')
@@ -480,19 +503,10 @@ export class ElectronBrowserSession {
2_000
)
this.assertOpen()
const data = image.toPNG()
if (
data.byteLength < 8 ||
data.byteLength > 5 * 1_024 * 1_024 ||
!data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
throw new Error('浏览器原生画面无效或过大')
}
const data = encodeBoundedJpeg(image)
return {
type: 'image',
mimeType: 'image/png',
mimeType: 'image/jpeg',
data: data.toString('base64')
}
}
+107
View File
@@ -168,6 +168,113 @@ describe('FilteringProxy', () => {
expect(policy.validate).toHaveBeenCalled()
})
it('retries an alternate approved HTTP address after a CDN rejection', async () => {
let upstreamRequests = 0
const rejectedEdge = createHttpServer((_request, response) => {
upstreamRequests += 1
response.writeHead(412)
response.end('rejected edge')
})
const upstreamPort = await listen(rejectedEdge)
disposals.push(() => closeServer(rejectedEdge))
const workingEdge = createHttpServer((_request, response) => {
upstreamRequests += 1
response.end('working edge')
})
await new Promise<void>((resolve, reject) => {
workingEdge.once('error', reject)
workingEdge.listen(upstreamPort, '127.0.0.2', () => {
workingEdge.off('error', reject)
resolve()
})
})
disposals.push(() => closeServer(workingEdge))
const policy = {
validate: vi.fn(async (url: URL) => ({
url,
origin: url.origin,
addresses: [
{ address: '127.0.0.1', family: 4 as const },
{ address: '127.0.0.2', family: 4 as const }
]
}))
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({ policy })
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
const result = await new Promise<{
status: number | undefined
body: string
}>((resolve, reject) => {
const request = httpRequest(
{
host: proxyUrl.hostname,
port: proxyUrl.port,
path: `http://example.com:${upstreamPort}/`
},
(response) => {
let body = ''
response.setEncoding('utf8')
response.on('data', (chunk: string) => {
body += chunk
})
response.on('end', () =>
resolve({ status: response.statusCode, body })
)
}
)
request.once('error', reject)
request.end()
})
expect(result).toEqual({ status: 200, body: 'working edge' })
expect(upstreamRequests).toBe(2)
})
it('retries an alternate approved HTTP address after connection failure', async () => {
const upstream = createHttpServer((_request, response) => {
response.end('fallback connected')
})
const upstreamPort = await listen(upstream)
disposals.push(() => closeServer(upstream))
const policy = {
validate: vi.fn(async (url: URL) => ({
url,
origin: url.origin,
addresses: [
{ address: '127.0.0.2', family: 4 as const },
{ address: '127.0.0.1', family: 4 as const }
]
}))
} as unknown as BrowserUrlPolicy
const proxy = new FilteringProxy({ policy })
disposals.push(() => proxy.dispose())
const proxyUrl = new URL(await proxy.start())
const body = await new Promise<string>((resolve, reject) => {
const request = httpRequest(
{
host: proxyUrl.hostname,
port: proxyUrl.port,
path: `http://example.com:${upstreamPort}/`
},
(response) => {
let value = ''
response.setEncoding('utf8')
response.on('data', (chunk: string) => {
value += chunk
})
response.on('end', () => resolve(value))
}
)
request.once('error', reject)
request.end()
})
expect(body).toBe('fallback connected')
})
it('contains aborted upstream HTTP responses', async () => {
const upstream = createHttpServer((_request, response) => {
response.writeHead(200)
+297 -87
View File
@@ -4,12 +4,20 @@ import { connect as netConnect } from 'node:net'
import type { NetConnectOpts, Socket } from 'node:net'
import type { Duplex } from 'node:stream'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { BrowserUrlPolicy, type ValidatedBrowserUrl } from './browser-url-policy'
import {
BrowserUrlPolicy,
type BrowserResolvedAddress,
type ValidatedBrowserUrl
} from './browser-url-policy'
const MAX_UPSTREAM_ADDRESSES = 8
export type FilteringProxyOptions = {
policy: BrowserUrlPolicy
maximumConnections?: number
maximumRequestBytes?: number
upstreamTimeoutMs?: number
upstreamIdleTimeoutMs?: number
connect?: (options: NetConnectOpts) => Socket
}
@@ -43,10 +51,53 @@ function stripProxyHeaders(
return result
}
function canRetryHttpRequest(request: IncomingMessage): boolean {
if (request.method !== 'GET' && request.method !== 'HEAD') {
return false
}
const contentLength = Number(request.headers['content-length'] ?? 0)
if (
request.headers['transfer-encoding'] !== undefined ||
!Number.isFinite(contentLength) ||
contentLength > 0
) {
return false
}
return ![
'if-match',
'if-unmodified-since',
'if-none-match',
'if-modified-since',
'if-range'
].some((name) => request.headers[name] !== undefined)
}
function shouldRetryHttpStatus(statusCode: number | undefined): boolean {
return statusCode === 412 || statusCode === 421 || statusCode === 425
}
function boundedApprovedAddresses(
target: ValidatedBrowserUrl
): BrowserResolvedAddress[] {
const seen = new Set<string>()
return target.addresses
.filter((address) => {
const key = `${address.family}:${address.address}`
if (seen.has(key)) {
return false
}
seen.add(key)
return true
})
.slice(0, MAX_UPSTREAM_ADDRESSES)
}
export class FilteringProxy {
private readonly policy: BrowserUrlPolicy
private readonly maximumConnections: number
private readonly maximumRequestBytes: number
private readonly upstreamTimeoutMs: number
private readonly upstreamIdleTimeoutMs: number
private readonly connectSocket: (options: NetConnectOpts) => Socket
private readonly controller = new AbortController()
private readonly streams = new Set<ActiveStream>()
@@ -59,7 +110,18 @@ export class FilteringProxy {
this.policy = options.policy
this.maximumConnections = options.maximumConnections ?? 32
this.maximumRequestBytes = options.maximumRequestBytes ?? 1024 * 1024
this.upstreamTimeoutMs = options.upstreamTimeoutMs ?? 3_000
this.upstreamIdleTimeoutMs =
options.upstreamIdleTimeoutMs ?? 15_000
this.connectSocket = options.connect ?? netConnect
if (
!Number.isSafeInteger(this.upstreamTimeoutMs) ||
this.upstreamTimeoutMs < 1 ||
!Number.isSafeInteger(this.upstreamIdleTimeoutMs) ||
this.upstreamIdleTimeoutMs < 1
) {
throw new Error('浏览器过滤代理超时配置无效')
}
}
async start(): Promise<string> {
@@ -149,82 +211,166 @@ export class FilteringProxy {
return
}
const target = await this.validateAtConnect(new URL(incoming.url))
const address = target.addresses[0]
if (!address) {
const addresses = boundedApprovedAddresses(target)
if (addresses.length === 0) {
rejectHttp(response)
return
}
if (
incoming.destroyed ||
(incoming.destroyed && !incoming.complete) ||
response.destroyed ||
response.writableEnded ||
responseClosed
) {
return
}
const request = (
target.url.protocol === 'https:' ? httpsRequest : httpRequest
)(
target.url,
{
method: incoming.method,
headers: {
...stripProxyHeaders(incoming.headers),
host: target.url.host
},
lookup: (_hostname, options, callback) => {
if (options.all) {
callback(null, [
{ address: address.address, family: address.family }
])
} else {
callback(null, address.address, address.family)
}
},
signal: this.controller.signal
},
(upstream) => {
const destroyForward = (): void => {
upstream.destroy()
request.destroy()
if (!response.destroyed) {
response.destroy()
}
}
upstream.once('error', destroyForward)
response.once('error', destroyForward)
response.once('close', () => {
if (!upstream.complete) {
upstream.destroy()
}
})
response.writeHead(
upstream.statusCode ?? 502,
stripProxyHeaders(upstream.headers)
)
upstream.pipe(response)
}
)
this.streams.add(request)
request.once('close', () => this.releaseStream(request))
request.once('error', () => {
if (response.headersSent) {
response.destroy()
} else if (!response.destroyed) {
rejectHttp(response, 502)
}
})
incoming.once('aborted', () => request.destroy())
incoming.once('error', () => request.destroy())
const retryable = canRetryHttpRequest(incoming)
let activeRequest: ActiveStream | undefined
incoming.once('aborted', () => activeRequest?.destroy())
incoming.once('error', () => activeRequest?.destroy())
let bytes = 0
incoming.on('data', (chunk: Buffer) => {
bytes += chunk.byteLength
if (bytes > this.maximumRequestBytes) {
request.destroy(new Error('浏览器请求超过安全限制'))
activeRequest?.destroy(
new Error('浏览器请求超过安全限制')
)
incoming.destroy()
}
})
incoming.pipe(request)
const attempt = (addressIndex: number): void => {
const address = addresses[addressIndex]
if (
!address ||
(incoming.destroyed && !incoming.complete) ||
response.destroyed ||
response.writableEnded ||
responseClosed
) {
if (!response.headersSent && !response.destroyed) {
rejectHttp(response, 502)
}
return
}
let retryStarted = false
let responseReceived = false
const request = (
target.url.protocol === 'https:' ? httpsRequest : httpRequest
)(
target.url,
{
method: incoming.method,
headers: {
...stripProxyHeaders(incoming.headers),
host: target.url.host
},
lookup: (_hostname, options, callback) => {
if (options.all) {
callback(null, [
{ address: address.address, family: address.family }
])
} else {
callback(null, address.address, address.family)
}
},
signal: this.controller.signal
},
(upstream) => {
responseReceived = true
if (headerTimer) {
clearTimeout(headerTimer)
}
const retry = (): boolean => {
if (
!retryStarted &&
retryable &&
addressIndex + 1 < addresses.length
) {
retryStarted = true
upstream.destroy()
request.destroy()
attempt(addressIndex + 1)
return true
}
return false
}
if (
shouldRetryHttpStatus(upstream.statusCode) &&
retry()
) {
return
}
const destroyForward = (): void => {
upstream.destroy()
request.destroy()
if (!response.destroyed) {
response.destroy()
}
}
upstream.setTimeout(
this.upstreamIdleTimeoutMs,
destroyForward
)
upstream.once('error', destroyForward)
response.once('error', destroyForward)
response.once('close', () => {
if (!upstream.complete) {
upstream.destroy()
}
})
response.writeHead(
upstream.statusCode ?? 502,
stripProxyHeaders(upstream.headers)
)
upstream.pipe(response)
}
)
activeRequest = request
this.streams.add(request)
request.once('close', () => this.releaseStream(request))
request.once('error', () => {
if (headerTimer) {
clearTimeout(headerTimer)
}
if (retryStarted) {
return
}
if (
!responseReceived &&
retryable &&
addressIndex + 1 < addresses.length
) {
retryStarted = true
attempt(addressIndex + 1)
} else if (response.headersSent) {
response.destroy()
} else if (!response.destroyed) {
rejectHttp(response, 502)
}
})
const headerTimer = setTimeout(() => {
if (responseReceived || retryStarted) {
return
}
retryStarted = true
request.destroy(new Error('浏览器上游响应超时'))
if (
retryable &&
addressIndex + 1 < addresses.length
) {
attempt(addressIndex + 1)
} else if (!response.headersSent && !response.destroyed) {
rejectHttp(response, 504)
}
}, this.upstreamTimeoutMs)
if (retryable) {
request.end()
} else {
incoming.pipe(request)
}
}
attempt(0)
} catch {
rejectHttp(response)
}
@@ -277,8 +423,8 @@ export class FilteringProxy {
return
}
const target = await this.validateAtConnect(authority)
const address = target.addresses[0]
if (!address) {
const addresses = boundedApprovedAddresses(target)
if (addresses.length === 0) {
client.destroy()
return
}
@@ -290,35 +436,99 @@ export class FilteringProxy {
if (client.destroyed) {
return
}
const connectedUpstream = this.connectSocket({
// Pin the TCP destination to the policy-approved address. The CONNECT
// tunnel remains opaque, so Chromium still verifies TLS against the
// original authority hostname rather than this address.
host: address.address,
port,
family: address.family
})
upstream = connectedUpstream
this.streams.add(connectedUpstream)
const release = (): void => this.releaseStream(connectedUpstream)
connectedUpstream.once('close', release)
connectedUpstream.once('error', destroyTunnel)
if (client.destroyed) {
connectedUpstream.destroy()
return
}
connectedUpstream.once('connect', () => {
const attempt = (addressIndex: number): void => {
const address = addresses[addressIndex]
if (!address || client.destroyed) {
destroyTunnel()
return
}
const connectedUpstream = this.connectSocket({
// Pin the TCP destination to a policy-approved address. The CONNECT
// tunnel remains opaque, so Chromium still verifies TLS against the
// original authority hostname rather than this address.
host: address.address,
port,
family: address.family
})
upstream = connectedUpstream
this.streams.add(connectedUpstream)
let settled = false
const timer = setTimeout(() => {
if (settled) {
return
}
settled = true
connectedUpstream.destroy()
if (addressIndex + 1 < addresses.length) {
attempt(addressIndex + 1)
} else {
destroyTunnel()
}
}, this.upstreamTimeoutMs)
const release = (): void =>
this.releaseStream(connectedUpstream)
connectedUpstream.once('close', release)
connectedUpstream.once('error', () => {
if (settled) {
if (connectedUpstream === upstream) {
destroyTunnel()
}
return
}
settled = true
clearTimeout(timer)
connectedUpstream.destroy()
if (addressIndex + 1 < addresses.length) {
attempt(addressIndex + 1)
} else {
destroyTunnel()
}
})
if (client.destroyed) {
settled = true
clearTimeout(timer)
connectedUpstream.destroy()
return
}
client.write('HTTP/1.1 200 Connection Established\r\n\r\n')
if (head.length > 0) {
connectedUpstream.write(head)
}
connectedUpstream.pipe(client)
client.pipe(connectedUpstream)
})
connectedUpstream.once('connect', () => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
if (client.destroyed) {
connectedUpstream.destroy()
return
}
client.write('HTTP/1.1 200 Connection Established\r\n\r\n')
if (head.length > 0) {
connectedUpstream.write(head)
}
const upstreamWithTimeout = connectedUpstream as Socket & {
setTimeout?(
milliseconds: number,
callback: () => void
): unknown
}
upstreamWithTimeout.setTimeout?.(
this.upstreamIdleTimeoutMs,
destroyTunnel
)
const clientWithTimeout = client as Duplex & {
setTimeout?(
milliseconds: number,
callback: () => void
): unknown
}
clientWithTimeout.setTimeout?.(
this.upstreamIdleTimeoutMs,
destroyTunnel
)
connectedUpstream.pipe(client)
client.pipe(connectedUpstream)
})
}
attempt(0)
} catch {
destroyTunnel()
}