Эмуляция сервера для приложений на 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
, вы увидете следующее окно:
Библиотека
Для переиспользования этого подхода я опубликовал библиотеку, делающую все эти шаги за вас. Для начала установите ее в свой проект:
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 тоже должен сработать. И, скорее всего, другие библиотеки для создания серверов.
Если нашли баги или имеете предложения об улучшении, прошу открыть вопрос на странице репозитория.