Опубликовано 16 мая 2025 г.

Разработка

Эмуляция сервера для приложений на Electron

При разработке приложений на Electron часто приходится иметь дело с межпроцессовым взаимодействием (inter-process communication, IPC). Но есть альтернатива: можно использовать знакомую с веб-программирования архитектуру клиент-сервер. Не запуская локальный сервер.

Этот подход позволит стать частично независимым от инструментов. Например, всегда можно будет портировать приложение для использования в обычном браузере, коммуницирующим с нормальным сервером.

Исходный код доступен в моем репозитории. Решение также доступно как модуль для Node.js на npm. В конце статьи есть краткий пример использования библиотеки.

Структура

  • Сервер: обслуживает API-запросы и, возможно, предоставляет фронтенд-код;

  • Electron: вход в приложение, как обычно;

  • Прокси: перехватывает запросы к “серверу” и эмулирует ответы.

Зависимости

Для создания сервера я воспользуюсь Express.js, а для эмуляции запросов — light-my-request:

npm install express light-my-request

Сервер

Для простоты у нашего сервера будет только одна конечная точка API: /api/ping, отвечающая строкой "Pong".

// server.js
import express from "express"

export const server = express()
server.get("/api/ping", (request, response) => {
    response.setHeader("Content-Type", "text/html")
    response.send("Pong")
})

Можно также настроить сервер, чтобы он предоставлял файлы фронтенда или даже отправлял запросы к серверу вашего сборщика (например, Vite).

Electron

Пока еще у нас не реализован прокси, но мы хотим сначала понять, как он будет использован. Сейчас пойдет шаблонный код, но обратите внимание на вызов protocol.handle. Он позволяет перехватить запросы, использующие конкретный протокол (в нашем случае HTTP).

// app.js
import { app, protocol, BrowserWindow } from "electron"
import { createProxy } from "./proxy.js"
import { server } from "./server.js"

const baseURL = new URL("http://example-app.local")

function createMainWindow() {
    const window = new BrowserWindow()
    const url = new URL("./api/ping", baseURL)
    window.loadURL(url.toString())
}

function onAppReady() {
    // Здесь мы регистрируем наш прокси
    protocol.handle(
        // Мы не хотим, чтобы в конце стояло двоеточие
        baseURL.protocol.slice(0, -1),
        createProxy(baseURL, server)
    )

    createMainWindow()
}

function onAllWindowsClosed() {
    if (process.platform !== "darwin") {
        app.quit()
    }
}

function onActivated() {
    if (BrowserWindow.getAllWindows().length === 0) {
        createMainWindow()
    }
}

app.whenReady().then(onAppReady).catch(console.error)
app.on("window-all-closed", onAllWindowsClosed)
app.on("activate", onActivated)

Прокси

Задачи нашего прокси:

  • эмуляция запроса к бэкенду, если хост в URL соответствует URL нашего “сервера”;

  • выполнить нормальный запрос в другом случае.

Метод createProxy вернет другой метод для обслуживания каждого HTTP запроса. Ответ будет объектом типа Response. Вот общий скелет:

// proxy.js

// Чтобы выполнить нормальный запрос
import { net } from "electron"
// Для эмуляции запроса к бэкенду
import inject from "light-my-request"

export function createProxy(baseURL, server) {
    return request => {
        const url = new URL(request.url)

        // Если URL хоста не соответствует адресу нашего эмулируемого сервера
        if (url.hostname !== baseURL.hostname) {
            // Выполняем нормальный запрос.
            return net.fetch(request)
        }

        // Иначе мы хотим эмулировать запрос к серверу.
        return emulateRequest({ server, baseURL, url, request })
    }
}

Теперь пора определить метод для эмуляции самого запроса. Он просто вызывает inject из библиотеки light-my-request, преобразовывая типы агрументов и ответа для совместимости.

const cacheDisablingHeaders = {
    "Cache-Control": "no-cache, no-store, must-revalidate",
    "Pragma": "no-cache",
    "Expires": 0,
    "If-None-Match": null
}

async function emulateRequest({ server, baseURL, url, request }) {
    // Эмулируем запрос.
    const response = await inject(server, {
        // Создаем объект с параметрами для библиотеки 
        // на основе объекта Response
        method: request.method,
        url: request.url,
        query: url.search,
        remoteAddress: new URL("/", baseURL).toString(),
        body: Buffer.from(await request.arrayBuffer()),
        // Нам надо преобразовать заголовки запроса
        // в нужный формат и отключить кэширования
        // (смотреть ниже).
        headers: {
            ...Object.fromEntries([...request.headers]),
            ...cacheDisablingHeaders
        }
    }).end()

    // Преобразовываем ответ в объект типа Response.
    return new Response(response.body, {
        headers: response.headers,
        status: response.statusCode,
        statusText: response.statusMessage
    })
}

Как вы заметили, мы отключаем кэширование для этих локальных запросов. Это потому что Node.js выдает ошибку, когда создается объект Response со статусом 304 Not Modified. Не уверен, есть ли лучший метод обойти эту ошибку, но указанное решение работает.

Результат

Если выполните npx electron app.js, вы увидете следующее окно:

loading-ag-132

Библиотека

Для переиспользования этого подхода я опубликовал библиотеку, делающую все эти шаги за вас. Для начала установите ее в свой проект:

npm install electron-client-server electron

Затем импортируйте в свой главный файл:

import { applyProxy } from "electron-client-server"

Определите, по какому адресу будет находится эмулируемый сервер:

const baseURL = new URL("http://localhost:5173")

И, наконец, примените прокси, когда приложение будет готово к выполнению:

app.whenReady().then(() => {
    applyProxy("http", baseURL, server)
    // ...
})

Я протестировал это решение с Express. Fastify тоже должен сработать. И, скорее всего, другие библиотеки для создания серверов.

Если нашли баги или имеете предложения об улучшении, прошу открыть вопрос на странице репозитория.