refactor: migrate external HTTP requests from Node.js to Chromium network stack

(cherry picked from commit ad96c558a3d46c79848e2c9e469673c7e7f68e25)
This commit is contained in:
ezequielnick 2025-10-29 19:35:19 +08:00
parent 98be9d3065
commit 80b59fc9de
12 changed files with 296 additions and 54 deletions

3
.gitignore vendored
View File

@ -8,4 +8,5 @@ out
*.log*
.idea
*.ttf
party.md
party.md
CLAUDE.md

View File

@ -2,7 +2,7 @@ import { overrideConfigPath, overridePath } from '../utils/dirs'
import { getControledMihomoConfig } from './controledMihomo'
import { readFile, writeFile, rm } from 'fs/promises'
import { existsSync } from 'fs'
import axios from 'axios'
import * as chromeRequest from '../utils/chromeRequest'
import { parse, stringify } from '../utils/yaml'
let overrideConfig: IOverrideConfig // override.yaml
@ -70,7 +70,7 @@ export async function createOverride(item: Partial<IOverrideItem>): Promise<IOve
case 'remote': {
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
if (!item.url) throw new Error('Empty URL')
const res = await axios.get(item.url, {
const res = await chromeRequest.get(item.url, {
proxy: {
protocol: 'http',
host: '127.0.0.1',

View File

@ -5,7 +5,7 @@ import { readFile, rm, writeFile } from 'fs/promises'
import { restartCore } from '../core/manager'
import { getAppConfig } from './app'
import { existsSync } from 'fs'
import axios, { AxiosResponse } from 'axios'
import * as chromeRequest from '../utils/chromeRequest'
import { parse, stringify } from '../utils/yaml'
import { defaultProfile } from '../utils/template'
import { subStorePort } from '../resolve/server'
@ -148,7 +148,7 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
const { userAgent, subscriptionTimeout = 30000 } = await getAppConfig()
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
if (!item.url) throw new Error('Empty URL')
let res: AxiosResponse
let res: chromeRequest.Response<string>
if (newItem.substore) {
const urlObj = new URL(`http://127.0.0.1:${subStorePort}${item.url}`)
urlObj.searchParams.set('target', 'ClashMeta')
@ -158,7 +158,7 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
} else {
urlObj.searchParams.delete('proxy')
}
res = await axios.get(urlObj.toString(), {
res = await chromeRequest.get(urlObj.toString(), {
headers: {
'User-Agent': userAgent || `mihomo.party/v${app.getVersion()} (clash.meta)`
},
@ -166,7 +166,7 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
timeout: subscriptionTimeout
})
} else {
res = await axios.get(item.url, {
res = await chromeRequest.get(item.url, {
proxy: newItem.useProxy
? {
protocol: 'http',

View File

@ -1,17 +1,17 @@
import axios from 'axios'
import * as chromeRequest from '../utils/chromeRequest'
import { subStorePort } from '../resolve/server'
import { getAppConfig } from '../config'
export async function subStoreSubs(): Promise<ISubStoreSub[]> {
const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig()
const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}`
const res = await axios.get(`${baseUrl}/api/subs`, { responseType: 'json' })
const res = await chromeRequest.get(`${baseUrl}/api/subs`, { responseType: 'json' })
return res.data.data as ISubStoreSub[]
}
export async function subStoreCollections(): Promise<ISubStoreSub[]> {
const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig()
const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}`
const res = await axios.get(`${baseUrl}/api/collections`, { responseType: 'json' })
const res = await chromeRequest.get(`${baseUrl}/api/collections`, { responseType: 'json' })
return res.data.data as ISubStoreSub[]
}

View File

@ -1,4 +1,4 @@
import axios from 'axios'
import * as chromeRequest from '../utils/chromeRequest'
import { parse } from '../utils/yaml'
import { app, shell } from 'electron'
import { getControledMihomoConfig } from '../config'
@ -14,7 +14,7 @@ import { checkAdminPrivileges } from '../core/manager'
export async function checkUpdate(): Promise<IAppVersion | undefined> {
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const res = await axios.get(
const res = await chromeRequest.get(
'https://github.com/mihomo-party-org/mihomo-party/releases/latest/download/latest.yml',
{
headers: { 'Content-Type': 'application/octet-stream' },
@ -83,7 +83,7 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
}
try {
if (!existsSync(path.join(dataDir(), file))) {
const res = await axios.get(`${baseUrl}${file}`, {
const res = await chromeRequest.get(`${baseUrl}${file}`, {
responseType: 'arraybuffer',
proxy: {
protocol: 'http',

View File

@ -1,4 +1,4 @@
import axios from 'axios'
import * as chromeRequest from '../utils/chromeRequest'
import { getAppConfig, getControledMihomoConfig } from '../config'
import { getRuntimeConfigStr } from '../core/factory'
@ -10,7 +10,7 @@ interface GistInfo {
async function listGists(token: string): Promise<GistInfo[]> {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
const res = await axios.get('https://api.github.com/gists', {
const res = await chromeRequest.get('https://api.github.com/gists', {
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
@ -28,7 +28,7 @@ async function listGists(token: string): Promise<GistInfo[]> {
async function createGist(token: string, content: string): Promise<void> {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
return await axios.post(
await chromeRequest.post(
'https://api.github.com/gists',
{
description: 'Auto Synced Clash Party Runtime Config',
@ -52,7 +52,7 @@ async function createGist(token: string, content: string): Promise<void> {
async function updateGist(token: string, id: string, content: string): Promise<void> {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
return await axios.patch(
await chromeRequest.patch(
`https://api.github.com/gists/${id}`,
{
description: 'Auto Synced Clash Party Runtime Config',

View File

@ -9,7 +9,7 @@ import net from 'net'
import path from 'path'
import { nativeImage } from 'electron'
import express from 'express'
import axios from 'axios'
import * as chromeRequest from '../utils/chromeRequest'
import AdmZip from 'adm-zip'
import { systemLogger } from '../utils/logger'
@ -155,7 +155,7 @@ export async function downloadSubStore(): Promise<void> {
// 下载后端文件
const tempBackendPath = path.join(tempDir, 'sub-store.bundle.cjs')
const backendRes = await axios.get(
const backendRes = await chromeRequest.get(
'https://github.com/sub-store-org/Sub-Store/releases/latest/download/sub-store.bundle.js',
{
responseType: 'arraybuffer',
@ -167,9 +167,9 @@ export async function downloadSubStore(): Promise<void> {
}
}
)
await writeFile(tempBackendPath, Buffer.from(backendRes.data))
await writeFile(tempBackendPath, Buffer.from(backendRes.data as Buffer))
// 下载前端文件
const frontendRes = await axios.get(
const frontendRes = await chromeRequest.get(
'https://github.com/sub-store-org/Sub-Store-Front-End/releases/latest/download/dist.zip',
{
responseType: 'arraybuffer',
@ -182,7 +182,7 @@ export async function downloadSubStore(): Promise<void> {
}
)
// 先解压到临时目录
const zip = new AdmZip(Buffer.from(frontendRes.data))
const zip = new AdmZip(Buffer.from(frontendRes.data as Buffer))
zip.extractAllTo(tempDir, true)
await cp(tempBackendPath, backendPath)
if (existsSync(frontendDir)) {

View File

@ -1,7 +1,7 @@
import { copyFile, readdir, readFile, writeFile } from 'fs/promises'
import { themesDir } from '../utils/dirs'
import path from 'path'
import axios from 'axios'
import * as chromeRequest from '../utils/chromeRequest'
import AdmZip from 'adm-zip'
import { getControledMihomoConfig } from '../config'
import { existsSync } from 'fs'
@ -36,7 +36,7 @@ export async function resolveThemes(): Promise<{ key: string; label: string }[]>
export async function fetchThemes(): Promise<void> {
const zipUrl = 'https://github.com/mihomo-party-org/theme-hub/releases/download/latest/themes.zip'
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const zipData = await axios.get(zipUrl, {
const zipData = await chromeRequest.get(zipUrl, {
responseType: 'arraybuffer',
headers: { 'Content-Type': 'application/octet-stream' },
proxy: {
@ -45,7 +45,7 @@ export async function fetchThemes(): Promise<void> {
port: mixedPort
}
})
const zip = new AdmZip(zipData.data as Buffer)
const zip = new AdmZip(Buffer.from(zipData.data as Buffer))
zip.extractAllTo(themesDir(), true)
}

View File

@ -0,0 +1,252 @@
import { net, session } from 'electron'
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
headers?: Record<string, string>
body?: string | Buffer
proxy?: {
protocol: 'http' | 'https' | 'socks5'
host: string
port: number
} | false
timeout?: number
responseType?: 'text' | 'json' | 'arraybuffer'
followRedirect?: boolean
maxRedirects?: number
}
export interface Response<T = any> {
data: T
status: number
statusText: string
headers: Record<string, string>
url: string
}
/**
* Make HTTP request using Chromium's network stack (via electron.net)
* This provides better compatibility, HTTP/2 support, and system certificate integration
*/
export async function request<T = any>(
url: string,
options: RequestOptions = {}
): Promise<Response<T>> {
const {
method = 'GET',
headers = {},
body,
proxy,
timeout = 30000,
responseType = 'text',
followRedirect = true,
maxRedirects = 20
} = options
return new Promise((resolve, reject) => {
let sessionToUse = session.defaultSession
let tempPartition: string | null = null
// Set up proxy if specified
const setupProxy = async (): Promise<void> => {
if (proxy) {
// Create temporary session partition to avoid affecting global proxy settings
tempPartition = `temp-request-${Date.now()}-${Math.random()}`
sessionToUse = session.fromPartition(tempPartition, { cache: false })
const proxyUrl = `${proxy.protocol}://${proxy.host}:${proxy.port}`
await sessionToUse.setProxy({ proxyRules: proxyUrl })
}
}
const cleanup = (): void => {
// Cleanup temporary session if created
if (tempPartition) {
// Note: Electron doesn't provide session.destroy(), but temporary sessions
// will be garbage collected when no longer referenced
sessionToUse = null as any
}
}
setupProxy()
.then(() => {
const req = net.request({
method,
url,
session: sessionToUse,
redirect: followRedirect ? 'follow' : 'manual',
useSessionCookies: true
})
// Set request headers
Object.entries(headers).forEach(([key, value]) => {
req.setHeader(key, value)
})
// Timeout handling
let timeoutId: NodeJS.Timeout | undefined
if (timeout > 0) {
timeoutId = setTimeout(() => {
req.abort()
cleanup()
reject(new Error(`Request timeout after ${timeout}ms`))
}, timeout)
}
const chunks: Buffer[] = []
let redirectCount = 0
req.on('redirect', () => {
redirectCount++
if (redirectCount > maxRedirects) {
req.abort()
cleanup()
if (timeoutId) clearTimeout(timeoutId)
reject(new Error(`Too many redirects (>${maxRedirects})`))
}
})
req.on('response', (res) => {
const { statusCode, statusMessage } = res
// Extract response headers
const responseHeaders: Record<string, string> = {}
const rawHeaders = res.rawHeaders || []
for (let i = 0; i < rawHeaders.length; i += 2) {
responseHeaders[rawHeaders[i].toLowerCase()] = rawHeaders[i + 1]
}
res.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})
res.on('end', () => {
cleanup()
if (timeoutId) clearTimeout(timeoutId)
const buffer = Buffer.concat(chunks)
let data: any
try {
switch (responseType) {
case 'json':
data = JSON.parse(buffer.toString('utf-8'))
break
case 'arraybuffer':
data = buffer
break
case 'text':
default:
data = buffer.toString('utf-8')
}
resolve({
data,
status: statusCode,
statusText: statusMessage,
headers: responseHeaders,
url: url
})
} catch (error) {
reject(new Error(`Failed to parse response: ${error}`))
}
})
res.on('error', (error) => {
cleanup()
if (timeoutId) clearTimeout(timeoutId)
reject(error)
})
})
req.on('error', (error) => {
cleanup()
if (timeoutId) clearTimeout(timeoutId)
reject(error)
})
req.on('abort', () => {
cleanup()
if (timeoutId) clearTimeout(timeoutId)
reject(new Error('Request aborted'))
})
// Send request body
if (body) {
if (typeof body === 'string') {
req.write(body, 'utf-8')
} else {
req.write(body)
}
}
req.end()
})
.catch((error) => {
cleanup()
reject(new Error(`Failed to setup proxy: ${error}`))
})
})
}
/**
* Convenience method for GET requests
*/
export const get = <T = any>(
url: string,
options?: Omit<RequestOptions, 'method' | 'body'>
): Promise<Response<T>> => request<T>(url, { ...options, method: 'GET' })
/**
* Convenience method for POST requests
*/
export const post = <T = any>(
url: string,
data: any,
options?: Omit<RequestOptions, 'method' | 'body'>
): Promise<Response<T>> => {
const body = typeof data === 'string' ? data : JSON.stringify(data)
const headers = options?.headers || {}
if (typeof data !== 'string' && !headers['content-type']) {
headers['content-type'] = 'application/json'
}
return request<T>(url, { ...options, method: 'POST', body, headers })
}
/**
* Convenience method for PUT requests
*/
export const put = <T = any>(
url: string,
data: any,
options?: Omit<RequestOptions, 'method' | 'body'>
): Promise<Response<T>> => {
const body = typeof data === 'string' ? data : JSON.stringify(data)
const headers = options?.headers || {}
if (typeof data !== 'string' && !headers['content-type']) {
headers['content-type'] = 'application/json'
}
return request<T>(url, { ...options, method: 'PUT', body, headers })
}
/**
* Convenience method for DELETE requests
*/
export const del = <T = any>(
url: string,
options?: Omit<RequestOptions, 'method' | 'body'>
): Promise<Response<T>> => request<T>(url, { ...options, method: 'DELETE' })
/**
* Convenience method for PATCH requests
*/
export const patch = <T = any>(
url: string,
data: any,
options?: Omit<RequestOptions, 'method' | 'body'>
): Promise<Response<T>> => {
const body = typeof data === 'string' ? data : JSON.stringify(data)
const headers = options?.headers || {}
if (typeof data !== 'string' && !headers['content-type']) {
headers['content-type'] = 'application/json'
}
return request<T>(url, { ...options, method: 'PATCH', body, headers })
}

View File

@ -1,5 +1,6 @@
import axios from 'axios'
import * as chromeRequest from './chromeRequest'
import { createWriteStream, createReadStream } from 'fs'
import { writeFile } from 'fs/promises'
import { mihomoCoreDir } from './dirs'
import AdmZip from 'adm-zip'
import { execSync } from 'child_process'
@ -62,29 +63,30 @@ export async function getGitHubTags(owner: string, repo: string, forceRefresh =
try {
console.log(`[GitHub] Fetching tags for ${owner}/${repo}`)
const response = await axios.get<GitHubTag[]>(
const response = await chromeRequest.get<GitHubTag[]>(
`${GITHUB_API_CONFIG.BASE_URL}/repos/${owner}/${repo}/tags?per_page=${GITHUB_API_CONFIG.TAGS_PER_PAGE}`,
{
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': GITHUB_API_CONFIG.API_VERSION
},
responseType: 'json',
timeout: 10000
}
)
// 更新缓存
versionCache.set(cacheKey, {
data: response.data,
timestamp: Date.now()
})
console.log(`[GitHub] Successfully fetched ${response.data.length} tags for ${owner}/${repo}`)
return response.data
} catch (error) {
console.error(`[GitHub] Failed to fetch tags for ${owner}/${repo}:`, error)
if (axios.isAxiosError(error)) {
throw new Error(`GitHub API error: ${error.response?.status} - ${error.response?.statusText}`)
if (error instanceof Error) {
throw new Error(`GitHub API error: ${error.message}`)
}
throw new Error('Failed to fetch version list')
}
@ -110,30 +112,17 @@ export function clearVersionCache(owner: string, repo: string): void {
async function downloadGitHubAsset(url: string, outputPath: string): Promise<void> {
try {
console.log(`[GitHub] Downloading asset from ${url}`)
const writer = createWriteStream(outputPath)
const response = await axios({
url,
method: 'GET',
responseType: 'stream',
const response = await chromeRequest.get(url, {
responseType: 'arraybuffer',
timeout: 30000
})
response.data.pipe(writer)
return new Promise((resolve, reject) => {
writer.on('finish', () => {
console.log(`[GitHub] Successfully downloaded asset to ${outputPath}`)
resolve()
})
writer.on('error', (error) => {
console.error(`[GitHub] Failed to write asset to ${outputPath}:`, error)
reject(new Error(`Failed to download core file: ${error.message}`))
})
})
await writeFile(outputPath, Buffer.from(response.data as Buffer))
console.log(`[GitHub] Successfully downloaded asset to ${outputPath}`)
} catch (error) {
console.error(`[GitHub] Failed to download asset from ${url}:`, error)
if (axios.isAxiosError(error)) {
throw new Error(`Download error: ${error.response?.status} - ${error.response?.statusText}`)
if (error instanceof Error) {
throw new Error(`Download error: ${error.message}`)
}
throw new Error('Failed to download core file')
}

View File

@ -1,9 +1,9 @@
import axios from 'axios'
import * as chromeRequest from './chromeRequest'
import { getControledMihomoConfig } from '../config'
export async function getImageDataURL(url: string): Promise<string> {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
const res = await axios.get(url, {
const res = await chromeRequest.get(url, {
responseType: 'arraybuffer',
proxy: {
protocol: 'http',
@ -12,6 +12,6 @@ export async function getImageDataURL(url: string): Promise<string> {
}
})
const mimeType = res.headers['content-type']
const dataURL = `data:${mimeType};base64,${Buffer.from(res.data).toString('base64')}`
const dataURL = `data:${mimeType};base64,${Buffer.from(res.data as Buffer).toString('base64')}`
return dataURL
}

View File

@ -291,7 +291,7 @@ export function registerIpcMainHandlers(): void {
})
ipcMain.handle('showMainWindow', showMainWindow)
ipcMain.handle('closeMainWindow', closeMainWindow)
ipcMain.handle('triggerMainWindow', triggerMainWindow)
ipcMain.handle('triggerMainWindow', (_e, force) => triggerMainWindow(force))
ipcMain.handle('showFloatingWindow', () => ipcErrorWrapper(showFloatingWindow)())
ipcMain.handle('closeFloatingWindow', () => ipcErrorWrapper(closeFloatingWindow)())
ipcMain.handle('showContextMenu', () => ipcErrorWrapper(showContextMenu)())