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

429 lines
11 KiB
TypeScript
Raw Normal View History

import { randomUUID } from "node:crypto";
import { app } from "electron/main";
import type { Session, WebContents } 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.
}
}