feat: prepare GoodBuddy 0.8.0
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export const MAX_BROWSER_INPUT_LENGTH = 16_384
|
||||
export const MAX_BROWSER_SELECT_LENGTH = 1_024
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user