Files
geo/apps/desktop-client/src/main/network-observer.ts
T

448 lines
11 KiB
TypeScript
Raw Normal View History

import { randomUUID } from 'node:crypto'
import type { Session, WebContents } from 'electron/main'
import { app } from 'electron/main'
import type {
DesktopObservedRequest,
DesktopObservedRequestKind,
DesktopObservedRequestSnapshot,
DesktopObservedRequestSource,
} from '../shared/network-debug'
interface ActiveObservedRequest {
id: string
startedAt: number
kind: DesktopObservedRequestKind
source: DesktopObservedRequestSource
label: string
method: string
url: string
partition: string | null
resourceType: string | null
}
interface StartObservedRequestOptions {
kind?: DesktopObservedRequestKind
source?: DesktopObservedRequestSource
label: string
method?: string
url: string
partition?: string | null
resourceType?: string | null
}
const observedSessions = new WeakSet<Session>()
const activeRequests = new Map<string, ActiveObservedRequest>()
const observedRecords: DesktopObservedRequest[] = []
const maxObservedRecords = 240
const requestFilter = {
urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'],
}
const redactedQueryKeys = /token|signature|sig|auth|key|password|session/i
const networkObserverEnabled = !app.isPackaged
let rendererTarget: WebContents | null = null
let originalFetch: typeof globalThis.fetch | null = null
export function installObservedGlobalFetch(): void {
if (!networkObserverEnabled) {
return
}
if (originalFetch || typeof globalThis.fetch !== 'function') {
return
}
originalFetch = globalThis.fetch.bind(globalThis)
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = requestURL(input)
if (!url || !isInspectableURL(url) || !originalFetch) {
return originalFetch!(input, init)
}
const method = requestMethod(input, init)
const requestId = startObservedRequest({
kind: inferKindFromHeaders(input, init),
source: 'main',
label: 'main.fetch',
method,
url,
})
try {
const response = await originalFetch(input, init)
resolveObservedRequest(requestId, {
phase: 'response',
status: response.status,
})
return response
} catch (error) {
failObservedRequest(requestId, error)
throw error
}
}) as typeof globalThis.fetch
}
export function registerObservedRequestRendererTarget(webContents: WebContents): void {
if (!networkObserverEnabled) {
return
}
rendererTarget = webContents
webContents.once('destroyed', () => {
if (rendererTarget === webContents) {
rendererTarget = null
}
})
}
export function observeSessionRequests(
target: Session,
options: { label: string; partition?: string | null },
): void {
if (!networkObserverEnabled) {
return
}
if (observedSessions.has(target)) {
return
}
observedSessions.add(target)
target.webRequest.onBeforeRequest(requestFilter, (details, callback) => {
if (!isInspectableURL(details.url)) {
callback({})
return
}
const requestId = sessionRequestId(details.id, options.partition ?? null)
activeRequests.set(requestId, {
id: requestId,
startedAt: Date.now(),
kind: details.resourceType === 'webSocket' ? 'ws' : 'http',
source: 'session',
label: options.label,
method: normalizeMethod(details.method),
url: sanitizeURL(details.url),
partition: options.partition ?? null,
resourceType: normalizeResourceType(details.resourceType),
})
pushRecord(
buildRecord(activeRequests.get(requestId)!, {
at: Date.now(),
phase: 'request',
}),
)
callback({})
})
target.webRequest.onCompleted(requestFilter, (details) => {
if (!isInspectableURL(details.url)) {
return
}
const requestId = sessionRequestId(details.id, options.partition ?? null)
const active =
activeRequests.get(requestId) ??
createFallbackActiveRequest(requestId, {
source: 'session',
label: options.label,
method: normalizeMethod(details.method),
url: details.url,
partition: options.partition ?? null,
resourceType: normalizeResourceType(details.resourceType),
})
pushRecord(
buildRecord(active, {
at: Date.now(),
phase: 'response',
status: details.statusCode,
durationMs: Date.now() - active.startedAt,
}),
)
activeRequests.delete(requestId)
})
target.webRequest.onErrorOccurred(requestFilter, (details) => {
if (!isInspectableURL(details.url)) {
return
}
const requestId = sessionRequestId(details.id, options.partition ?? null)
const active =
activeRequests.get(requestId) ??
createFallbackActiveRequest(requestId, {
source: 'session',
label: options.label,
method: normalizeMethod(details.method),
url: details.url,
partition: options.partition ?? null,
resourceType: normalizeResourceType(details.resourceType),
})
pushRecord(
buildRecord(active, {
at: Date.now(),
phase: 'error',
durationMs: Date.now() - active.startedAt,
error: normalizeErrorMessage(details.error),
}),
)
activeRequests.delete(requestId)
})
}
export function startObservedRequest(options: StartObservedRequestOptions): string {
if (!networkObserverEnabled) {
return ''
}
const id = `main:${randomUUID()}`
const active: ActiveObservedRequest = {
id,
startedAt: Date.now(),
kind: options.kind ?? 'http',
source: options.source ?? 'main',
label: options.label,
method: normalizeMethod(options.method),
url: sanitizeURL(options.url),
partition: options.partition ?? null,
resourceType: options.resourceType ?? null,
}
activeRequests.set(id, active)
pushRecord(
buildRecord(active, {
at: active.startedAt,
phase: 'request',
}),
)
return id
}
export function resolveObservedRequest(
id: string,
options: {
phase?: 'response' | 'close'
status?: number | null
error?: string | null
keepActive?: boolean
} = {},
): void {
const active = activeRequests.get(id)
if (!active) {
return
}
pushRecord(
buildRecord(active, {
at: Date.now(),
phase: options.phase ?? 'response',
status: options.status ?? null,
durationMs: Date.now() - active.startedAt,
error: options.error ?? null,
}),
)
if (!options.keepActive) {
activeRequests.delete(id)
}
}
export function failObservedRequest(id: string, error: unknown): void {
const active = activeRequests.get(id)
if (!active) {
return
}
pushRecord(
buildRecord(active, {
at: Date.now(),
phase: 'error',
durationMs: Date.now() - active.startedAt,
error: normalizeErrorMessage(error),
}),
)
activeRequests.delete(id)
}
export function getObservedRequestSnapshot(): DesktopObservedRequestSnapshot {
if (!networkObserverEnabled) {
return {
rendererAttached: false,
activeCount: 0,
recent: [],
}
}
return {
rendererAttached: Boolean(rendererTarget && !rendererTarget.isDestroyed()),
activeCount: activeRequests.size,
recent: observedRecords.slice(-80),
}
}
function requestURL(input: RequestInfo | URL): string | null {
if (typeof input === 'string') {
return input
}
if (input instanceof URL) {
return input.toString()
}
return typeof input.url === 'string' ? input.url : null
}
function requestMethod(input: RequestInfo | URL, init?: RequestInit): string {
if (init?.method) {
return normalizeMethod(init.method)
}
if (typeof Request !== 'undefined' && input instanceof Request) {
return normalizeMethod(input.method)
}
return 'GET'
}
function inferKindFromHeaders(
input: RequestInfo | URL,
init?: RequestInit,
): DesktopObservedRequestKind {
const headers = new Headers(init?.headers)
if (!headers.has('accept') && typeof Request !== 'undefined' && input instanceof Request) {
const requestAccept = input.headers.get('accept')
if (requestAccept) {
headers.set('accept', requestAccept)
}
}
const accept = headers.get('accept')?.toLowerCase() ?? ''
if (accept.includes('text/event-stream')) {
return 'sse'
}
return 'http'
}
function isInspectableURL(url: string): boolean {
try {
const protocol = new URL(url).protocol
return (
protocol === 'http:' || protocol === 'https:' || protocol === 'ws:' || protocol === 'wss:'
)
} catch {
return false
}
}
function sanitizeURL(url: string): string {
try {
const parsed = new URL(url)
for (const key of parsed.searchParams.keys()) {
if (redactedQueryKeys.test(key)) {
parsed.searchParams.set(key, '[REDACTED]')
}
}
return parsed.toString()
} catch {
return url
}
}
function normalizeMethod(value: string | undefined | null): string {
return typeof value === 'string' && value.trim() ? value.trim().toUpperCase() : 'GET'
}
function normalizeResourceType(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value : null
}
function normalizeErrorMessage(error: unknown): string {
if (typeof error === 'string' && error.trim()) {
return error
}
if (error instanceof Error) {
return error.message || error.name || 'request_failed'
}
if (error && typeof error === 'object' && 'message' in error) {
const message = (error as { message?: unknown }).message
if (typeof message === 'string' && message.trim()) {
return message
}
}
return 'request_failed'
}
function sessionRequestId(id: number, partition: string | null): string {
return `session:${partition ?? 'default'}:${id}`
}
function createFallbackActiveRequest(
id: string,
options: {
source: DesktopObservedRequestSource
label: string
method?: string
url: string
partition?: string | null
resourceType?: string | null
},
): ActiveObservedRequest {
return {
id,
startedAt: Date.now(),
kind: options.resourceType === 'webSocket' ? 'ws' : 'http',
source: options.source,
label: options.label,
method: normalizeMethod(options.method),
url: sanitizeURL(options.url),
partition: options.partition ?? null,
resourceType: options.resourceType ?? null,
}
}
function buildRecord(
active: ActiveObservedRequest,
input: {
at: number
phase: DesktopObservedRequest['phase']
status?: number | null
durationMs?: number | null
error?: string | null
},
): DesktopObservedRequest {
return {
id: active.id,
at: input.at,
phase: input.phase,
kind: active.kind,
source: active.source,
label: active.label,
method: active.method,
url: active.url,
partition: active.partition,
resourceType: active.resourceType,
status: input.status ?? null,
durationMs: input.durationMs ?? null,
error: input.error ?? null,
}
}
function pushRecord(record: DesktopObservedRequest): void {
observedRecords.push(record)
if (observedRecords.length > maxObservedRecords) {
observedRecords.splice(0, observedRecords.length - maxObservedRecords)
}
if (!rendererTarget || rendererTarget.isDestroyed()) {
return
}
try {
rendererTarget.send('desktop:network-observed', record)
} catch {
// Best-effort diagnostics only.
}
}