С появлением React Server Components и Server Actions разработка веб-приложений стала проще, чем когда-либо. Удобно когда у разработчика есть все серверные API прямо внутри веб-приложения, нативно, с типами и полной поддержкой от фреймворка, например Next.js (и других фреймворков, поддерживающих RSC, конечно).
В то же время, Electron является де-факто стандартом для современных настольных приложений, написанных с использованием веб-технологий, особенно когда приложению нужен доступ к файловой системе и другим системным API, и девелопер знает только JS (Tauri заслуживает почётного упоминания, если вы знаете Rust или если вам нужен только простой WebView2 shell).
Я задался вопросом: почему бы не объединить лучшее из обоих миров и не запустить обычное приложение Next.js прямо внутри Electron, чтобы насладиться всеми преимуществами React Server Components?
Демо
Я исследовал все доступные варианты и не нашел подходящего, поэтому написал небольшую библиотеку next-electron-rsc
, которая позволяет связать Next.js и Electron без запуска сервера или открытия портов.
Все, что нужно, чтобы использовать библиотеку — добавить следующее в main.js
в Electron:
import { app, protocol } from 'electron';
import { createHandler } from 'next-electron-rsc';
const appPath = app.getAppPath();
const isDev = process.env.NODE_ENV === 'development';
const { createInterceptor } = createHandler({
standaloneDir: path.join(appPath, '.next', 'standalone'),
localhostUrl: 'http://localhost:3000', // must match Next.js dev server
protocol,
});
if (!isDev) createInterceptor();
И настроить сборку Next.js в next.config.js
:
module.exports = {
output: 'standalone',
experimental: {
outputFileTracingIncludes: {
'*': [
'public/**/*',
'.next/static/**/*'
]
}
}
};
Вот репозиторий: https://github.com/kirill-konshin/next-electron-rsc и демо со всеми файлами.
И вот моя история создания этой библиотеки.
Мотивация использования React Server Components в Electron
Стандартный способ предоставления доступа к системным API в Electron — через IPC или, не дай бог, Electron Remote (который считался даже вредным). Оба подхода всегда были немного неудобными. Задачу конечно можно выполнить: этот и этот типизированные IPC-интерфейсы были лучшими, что я нашел. Но с IPC в больших приложениях вам придется разрабатывать какой-то протокол даже для простого взаимодействия запрос-ответ, обрабатывать ошибки, состояния загрузки и т. д., так что в реальных корпоративных приложениях это быстро становится слишком тяжелым. Это даже близко не стоит к элегантности RSC.
Важное преимущество React Server Components в традиционной клиент-серверной веб-разработке заключается в том же: отсутствие выделенного RESTful API или GraphQL API (если единственным потребителем API является само веб-приложение). Разработчику не нужно проектировать эти API, поддерживать их, и приложение может просто взаимодействовать с бэкендом, как с любой другой асинхронной функцией.
С RSC вся логика может находиться в веб-приложении, так что Electron сам по себе становится очень тонким слоем, который просто открывает окно.
Вот пример: мы используем безопасное хранилище Electron и читаем из/записываем в файловую систему прямо в React Component:
import { safeStorage } from 'electron';
import Preview from './text';
import fs from 'fs/promises';
async function Page({page}) {
const secretText = await safeStorage.decryptString(await fs.readFile('path-to-file'));
async function save(newText) {
fs.writeFile('path-to-file', await safeStorage.encryptString(newText));
}
return <Preview secretText={secretText} save={save} />;
}
Такая колокация позволяет намного быстрее разрабатывать и уменьшать количество работы по поддержке протокола между веб и Electron приложениями. И, конечно, вы можете использовать API Electron прямо из серверных компонентов, так как это тот же процесс Node.js, что исключает необходимость использовать IPC или Remote или какой-либо клиент-серверный API-протокол вроде REST или GQL.
По сути, это убирает границу между Renderer и Main процессами Electron, при этом сохраняя всё безопасным. Кроме того, вы можете переносить выполнение тяжёлых задач из браузера в Node.js, что более гибко в плане распределения нагрузки. Единственная проблема в том, что… нужно запускать RSC-сервер в Electron. Или нет?
Требования
У меня было несколько строгих требований, которые я хотел выполнить:
Без открытых портов! Безопасность прежде всего.
Полная поддержка Next.js: React Server Components, API Routes (App router) и Server Side Rendering, Static Site Rendering и Route Handlers (Pages router) и т.д., с соблюдением устоявшихся паттернов.
Минимальная, простая в использовании, основанная на стандартах, корпоративно-пригодная и готовая для коммерческого использования платформа, использующая широко известные технологии.
Производительность.
После некоторых исследований я нашел очевидный выбор под названием Nextron. К сожалению, похоже, он не использует всю мощь Next.js и не поддерживает SSR (тикет оставался открытым в октябре 2024 года). С другой стороны, есть статьи вроде этой или этой, обе подошли очень близко, за исключением использования сервера с открытым портом. К сожалению, я нашел это только после того, как придумал подход, который собираюсь представить, но так зато статья его подтвердила. К счастью, я нашел её до того, как написал этот пост, поэтому могу выразить благодарность автору здесь.
В итоге я начал исследовать самостоятельно. Оказалось, что подход довольно прост. И все инструменты уже доступны, мне оставалось только связать их нестандартным способом.
Next.js
Первый шаг — собрать приложение Next.js как автономное. Это создаст оптимизированную сборку, содержащую все модули и файлы, которые могут понадобиться во время выполнения, и удалит всё ненужное.
module.exports = {
output: 'standalone',
experimental: {
outputFileTracingIncludes: {
'*': [
'public/**/*',
'.next/static/**/*'
]
}
}
};
И это всё для Next.js.
outputFileTracingIncludes
нужен чтоб Next.js переложил public
и .next/static
файлы в сборку standalone, обычно их выкладывают на CDN, но в этом случае все локально.
Следующий шаг немного сложнее.
Electron
Теперь мне нужно сообщить Electron, что у меня есть Next.js.
Одним из возможных решений является использование Electron Custom Protocol или Schema. Или Protocol Intercept. Я выбрал последний, так как меня вполне устраивает симуляция загрузки веб-страницы с http://localhost
(подчеркну, что не должно быть реального сервера с открытым портом).
Кроме того, это также обеспечивает послабление политики одного “популярного видеосервиса”, который запрещает встраивание страниц, открытых через нестандартные протоколы ?.
Обратите внимание, что далее я намеренно исключил много реального кода, чтобы сосредоточиться на важных моментах для демонстрации концепции.
Для реализации перехвата я добавил следующее:
const localhostUrl = 'http://localhost:3000';
function createInterceptor() {
protocol.interceptStreamProtocol('http', async (request, callback) => {
if (!request.url.startsWith(localhostUrl)) return;
try {
const response = await handleRequest(request);
callback(response);
} catch (e) {
callback(e);
}
});
}
Этот перехватчик обслуживает статические файлы и перенаправляет запросы в Next.js.
Здесь заслуживает упоминания отличная библиотека Electron Serve, которая реализует кастомную схему для обслуживания статических файлов.
Связывание Electron и Next.js
Следующий шаг — создание несуществующего сервера без порта:
import type { ProtocolRequest, ProtocolResponse } from 'electron';
import { IncomingMessage } from 'node:http';
import { Socket } from 'node:net';
function createRequest({ socket, origReq }: { socket: Socket; origReq: ProtocolRequest }): IncomingMessage {
const req = new IncomingMessage(socket);
req.url = origReq.url;
req.method = origReq.method;
req.headers = origReq.headers;
origReq.uploadData?.forEach((item) => {
req.push(item.bytes);
});
req.push(null);
return req;
}
createRequest
использует Socket
для создания экземпляра IncomingMessage
из Node.js, а затем переносит в него информацию из ProtocolRequest
для Electron, включая тело запросов POST|PUT
.
import { ServerResponse, IncomingMessage } from 'node:http';
import { PassThrough } from 'node:stream';
import type { Protocol, ProtocolRequest, ProtocolResponse } from 'electron';
class ReadableServerResponse extends ServerResponse {
private passThrough = new PassThrough();
private promiseResolvers = Promise.withResolvers<ProtocolResponse>();
constructor(req: IncomingMessage) {
super(req);
this.write = this.passThrough.write.bind(this.passThrough);
this.end = this.passThrough.end.bind(this.passThrough);
this.passThrough.on('drain', () => this.emit('drain'));
}
writeHead(statusCode: number, ...args: any): this {
super.writeHead(statusCode, ...args);
this.promiseResolvers.resolve({
statusCode: this.statusCode,
mimeType: this.getHeader('Content-Type') as any,
headers: this.getHeaders() as any,
data: this.passThrough as any,
});
return this;
}
async createProtocolResponse() {
return this.promiseResolvers.promise;
}
}
ReadableServerResponse
— это, по сути, обычный Node.js ServerResponse
, из которого я могу читать тело после завершения обработки в Next.js. createProtocolResponse
преобразует ReadableServerResponse
в ProtocolResponse
для Electron.
Функция createProtocolResponse
создает Promise
который ждет когда будут записаны заголовки и резолвится в ReadableServerResponse
преобразованный из ProtocolResponse
.
Следующий шаг — это, наконец, сам “сервер”.
Без сервера, без портов
import type { ProtocolRequest, ProtocolResponse } from 'electron';
export function createHandler({
standaloneDir,
localhostUrl = 'http://localhost:3000',
protocol,
debug = false,
}) {
const next = require(resolve.sync('next', { basedir: standaloneDir }));
const app = next({
dev: false,
dir: standaloneDir,
}) as NextNodeServer;
const handler = app.getRequestHandler();
const socket = new Socket();
async function handleRequest(origReq: ProtocolRequest): Promise<ProtocolResponse> {
try {
const req = createRequest({ socket, origReq });
const res = new ReadableServerResponse(req);
const url = parse(req.url, true);
handler(req, res, url);
return await res.createProtocolResponse();
} catch (e) {
return e;
}
}
function createInterceptor() { /* ... */ }
return { createInterceptor };
}
Я использую NextServer
из автономной сборки приложения Next.js, чтобы создать handler
, обычный handler в стиле Express, который принимает Request и Response в качестве аргументов.
Ключевая функция здесь — handleRequest
.
Она предоставляет фиктивный Socket
для createRequest
для создания фиктивного IncomingMessage
, создает фиктивный ReadableServerResponse
. Я передаю запрос и ответ обработчику Next.js, чтобы он выполнил свою магию, не зная, что на самом деле нет никакого сервера, только фиктивные объекты. Как только обработчик завершает свою работу, ProtocolResponse
готов для отправки через Electron в браузер. Вот и всё.
Обратите внимание, что я не запускаю Next.js или какой-либо другой сервер, поэтому Требование №1 выполнено, порты не открыты. Вы можете посмотреть документацию Next.js, чтобы узнать больше о стандартном способе настройки кастомного сервера. И поскольку я использую стандартный подход Next.js, Требование №2 также выполнено.
Поскольку этот подход работает хорошо и на загруженных серверах, а с Electron всегда только один пользователь, производительность также Требование №4 достигнута.
Упаковка и публикация
Я предлагаю использовать Electron Builder для упаковки и публикации приложения на Electron. Просто добавьте следующую конфигурацию в electron-builder.yml
:
includeSubNodeModules: true
files:
- build
- from: '.next/standalone/demo/'
to: '.next/standalone/demo/'
Для удобства вы можете добавить следующие скрипты в package.json
:
{
"scripts": {
"build": "yarn build:next && yarn build:electron",
"build:next": "next build",
"build:electron": "electron-builder --config electron-builder.yml",
"start:next": "next dev",
"start:electron": "electron ."
}
}
Для разделения логики я рекомендую хранить исходные файлы Next.js в src
, а исходные файлы Electron в src-electron
, это гарантирует, что Next.js не попытается скомпилировать Electron.
Заключение
Требование №3 выполнено полностью, так как это всего один файл, и он использует только стандартные API.
Я был поражен, когда это взяло и заработало… Я был скептически настроен, что это окажется настолько просто и элегантно.
Теперь я могу полноценно использовать доступ к файловой системе и операционным системам прямо из серверных компонентов Next.js или Route Handler'ов, с использованием всей экосистемы Next.js, общепринятых паттернов и, при этом используя Electron для доставки приложения юзерам, тк его можно упаковать Electron Build.
P.S. Я сделал все возможное и не нашел статей, охватывающих использование Next.js с имитированными запросами и ответами, особенно в сочетании с Electron. Стыдно, если иначе, возможно, я забыл, как гуглить ?… Но даже если я что-то упустил, эта статья должна помочь объяснить, почему этот подход хорош.
P.P.S. MSW немного избыточен и используется для других целей, как и другие библиотеки для имитации HTTP.
P.P.P.S. Пара сомнительных вещей в коде связана с использованием буферов для чтения ответа и синхронным чтением статических файлов, но оба момента можно улучшить с помощью потоков, однако для простоты этого достаточно.