š¦ The ultimate Electron app with Next.js and React Server Components2024-10-21
With the emergence of React Server Components and Server Actions writing Web apps became easier than ever. The simplicity when developer has all server APIs right inside the Web app, natively, with types and full support from Next.js framework for example (and other RSC frameworks too, of course) is astonishing.
At the same time, Electron is a de-facto standard for modern desktop apps written using web technologies, especially when application must have filesystem and other system API access, while being written in JS (Tauri receives an honorable mention here if you know Rust or if you only need a simple WebView2 shell).
I asked myself, why not to combine best of both worlds, and run usual Next.js application right inside the Electron and enjoy all benefits that comes with React Server Components?
Demo
I have explored all options available and havenāt found a suitable one, so I wrote a small lib next-electron-rsc
that can bridge the gap between Next.js and Electron without running a server or opening any ports.
All you need to use the lib is to add following to your main.js
in 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();
And configure your Next.js build in next.config.js
:
module.exports = {
output: 'standalone',
experimental: {
outputFileTracingIncludes: {
'*': ['public/**/*', '.next/static/**/*'],
},
},
};
Hereās the repository: https://github.com/kirill-konshin/next-electron-rsc and the demo with all files.
And hereās my journey to create this lib.
Motivation to use React Server Components in Electron
Electronās native way of providing access to system APIs is via IPC or, god forbid, Electron Remote (which was considered even harmful). Both were always a bit cumbersome. Donāt get me wrong, you can get the job done: this and this typed IPC interfaces were the best I found. But with IPC in large apps youāll end up designing some handshake protocol for simple request-response interaction, handling of errors and loading states and so on, so in real enterprise grade applications it will quickly become too heavy. Not even close to elegance of RSC.
Important benefit of React Server Components in traditional client-server web development has same nature: absence of dedicated RESTful API or GraphQL API (if the only API consumer is the website itself). So developer does not need to design these APIs, maintain them, and app can just and just talk to backend as if itās just another async function.
With RSC application all logic can be colocated in the Web app, so Electron itself becomes a very thin layer, that just opens a window.
Hereās an example, we use Electronās safe storage and read from/write to file system right in the 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} />;
}
Such colocation allows much more rapid development and much less maintenance of the protocol between Web and Electron apps. And of course you can use Electron APIs directly from server components, as itās the same Node.js process, thus removing the necessity to use IPC or Remote, or any sort of client-server API protocol like REST or GQL.
Basically, this magically removes the boundary between Electronās Renderer and Main processes, while still keeping everything secure. Besides you can shift execution of heavy tasks from browser to Node.js, which is more flexible how you distribute the load. The only problem isā¦ you need to run an RSC server in Electron. Or do you?
Requirements
I had a few and very strict requirements that I wanted to achieve:
- No open ports! Safety first.
- Complete support Next.js: React Server Components, API Routes (App router) and Server Side Rendering, Static Site Rendering and Route Handlers (Pages router), you name it, with strict adherence established patterns
- Minimal, easy to use, based on standards, basically an enterprise-grade, production ready stack for commercial use, mature and well known set of technologies
- Performance
After some research I found an obvious choice called Nextron. Unfortunately, seems like it does not utilize the full power of Next.js, and does not support SSR (ticket remained open in Oct 2024). On the other hand there are articles like this or this, both very close, except for usage of server with an open port. Unfortunately I only found it after I came up with the approach Iām about to present, but the article validated it. Luckily I found it before writing this post, so I can give kudos to the author here.
So I started exploring on my own. Turned out, the approach is pretty simple. And all the tools are already available, I only needed to wire them together in some unorthodox way.
Next.js
First step would be to build Next.js app as a standalone. This will create an optimized build which contains all modules and files that can possibly be required in runtime, and removes everything thatās unnecessary.
module.exports = {
output: 'standalone',
experimental: {
outputFileTracingIncludes: {
'*': ['public/**/*', '.next/static/**/*'],
},
},
};
Aaand, this is it for Next.js.
outputFileTracingIncludes
is needed so that optional public
and .next/static
folders will be copied to standalone build. Next.js assumes you should publish this to CDN, but in this case everything is local.
Next step is a little trickier.
Electron
Now I need to let Electron know that I have Next.js.
One possible solution is Electronās Custom Protocol or Schema. Or a Protocol Intercept. I chose the latter as Iām perfectly fine to pretend to load web from http://localhost
(emphasis on pretend as there should be no real server with an open port).
Besides, this also ensures relaxed policy of one āpopular video serviceā, that forbids embedding on pages served via custom protocols š .
Please note that I purposely excluded a lot of unnecessary code to focus on what matters to show the concept.
To implement the intercept I added following:
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);
}
});
}
This interceptor serves static files and forwards requests to Next.js.
Honorable mention here goes to awesome Electron Serve, which implements a custom schema for serving static files.
Bridging Electron and Next.js
Next step would be to create a file to provide some convenience to use the non-existing port-less āserverā:
import type { ProtocolRequest } 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
uses a Socket
to create an instance of Node.js IncomingMessage
, then it transfers the information from Electronās ProtocolRequest
into the IncomingMessage
, including the body of POST|PUT
requests.
import type { Protocol, ProtocolResponse } from 'electron';
import { ServerResponse, IncomingMessage } from 'node:http';
import { PassThrough } from 'node:stream';
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
is basically just a regular Node.js ServerResponse
from which I can read the body once Next.js finishes the processing. createProtocolResponse
converts the ReadableServerResponse
into Electronās ProtocolResponse
.
createProtocolResponse
method returns a Promise
which waits for the body and resolves into converted ReadableServerResponse
as ProtocolResponse
.
Next step is finally the āserverā itself.
No server, no ports
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 };
}
I use the NextServer
from Next.js appās standalone build to create a handler
, a regular Express-like route handler which takes Request and Response as arguments.
Key function here is handleRequest
.
It provides a dummy Socket
to createRequest
to create a dummy IncomingMessage
, creates a dummy ReadableServerResponse
. I feed both request and response to Next.jsās handler
, so Next.js can work its magic, not knowing that thereās no actual server, just dummy mocks. Once handler
finishes its job the ProtocolResponse
is ready for Electron to send to browser. And this is it.
Note that I donāt actually start the Next.js or any other server anywhere, so Requirement #1 is achieved, no ports are open. You can take a look at Next.js documentation to learn more about regular way of setting up a handler with the server. And since I use regular Next.js way, Requirement #2 is achieved.
And since this whole approach works fine on highly loaded servers, and with Electron thereās just one user at any time, the performance Requirement #4 is achieved as well.
Bundling and publishing
I suggest to use Electron Builder to bundle the Electron app. Just add some configuration toĀ electron-builder.yml
:
includeSubNodeModules: true
files:
- build # your Electron's build dir if you use TS or src-electron if you use JS
- .next/standalone/demo
For convenience, you can add following scripts toĀ 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 ."
}
}
For separation of concerns I recommend to keep Next.js sources inĀ src
Ā of and Electron soures in andĀ src-electron
, this ensures Next.js does not try to compile Electron.
Conclusion
Requirement #3 is achieved in full glory since itās just one file, and it only uses standard APIs.
I was amazed when it actually workedā¦ I was quite skeptical that it would be this simple and yet so elegant.
Now I can enjoy full access to file & operating systems directly from Next.js Server Components or Route Handlers, with all the benefits of Next.js ecosystem, established patterns, and while using Electron to deliver the complete app experience to users, since the app can be bundled and published.
P.S. I have done my due diligence and I have not found any articles that cover usage of Next.js with mocked requests and responses, especially in conjunction with Electron. Shame on me if otherwise, I must have forgotten how to Google š¤ā¦ But even if I missed something, this article should help to explain why this approach is good.
P.P.S. MSW is a bit overkill and is used for different purposes, like other HTTP mocking libraries.
P.P.P.S. Few shady things in the code are using buffers to read response and synchronous reading static files, both can be improved with streaming, but for simplicity itās good enough.