Logo

dev-resources.site

for different kinds of informations.

The ultimate Electron app with Next.js and React Server Components

Published at
10/21/2024
Categories
nextjs
electron
react
rsc
Author
kirillkonshin
Categories
4 categories in total
nextjs
open
electron
open
react
open
rsc
open
Author
13 person written this
kirillkonshin
open
The ultimate Electron app with Next.js and React Server Components

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();
Enter fullscreen mode Exit fullscreen mode

And configure your Next.js build in next.config.js:

module.exports = {
  output: 'standalone',
  experimental: {
    outputFileTracingIncludes: {
      '*': [
          'public/**/*',
          '.next/static/**/*',
      ],
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. No open ports! Safety first.
  2. 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
  3. Minimal, easy to use, based on standards, basically an enterprise-grade, production ready stack for commercial use, mature and well known set of technologies
  4. 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/**/*',
      ],
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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);
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

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, 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;
}
Enter fullscreen mode Exit fullscreen mode

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 { 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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
  - from: '.next/standalone/demo/'
    to: '.next/standalone/demo/'
Enter fullscreen mode Exit fullscreen mode

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 ."
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

electron Article's
30 articles in total
Favicon
First thoughts on Electron
Favicon
Let's build website builder
Favicon
Study With Me 1.0
Favicon
[Boost]
Favicon
Electric Bus Pantograph Market: Trends, Challenges, Drivers, and Insights Through 2033
Favicon
Keyboard Sounds ā€” Make any keyboard sound mechanical
Favicon
Electron
Favicon
I Hate Website Builders ā€“ So I Built My Own
Favicon
Is the browser always the right tool for the job?
Favicon
E-House Market Insights: Compact Solutions for Modern Power Challenges
Favicon
.NET Cross-Platform Web Desktop App Frameworks as Electron Alternatives
Favicon
How to remotely EV code-sign a windows application using ssl.com
Favicon
Configuring webpack to handle multiple browser windows in Electron
Favicon
Using native modules in Electron
Favicon
Requesting camera and microphone permission in an Electron app
Favicon
šŸš€Building a Multi-Step Loading Screen with Electron
Favicon
Building deep-links in Electron application
Favicon
MaweJS: Editor for plantsers
Favicon
Handling TypeORM migrations in Electron apps
Favicon
Unicode-Search - my first Electron app!
Favicon
Creating a synchronized store between main and renderer process in Electron
Favicon
The ultimate Electron app with Next.js and React Server Components
Favicon
Electric Bikes And Coding
Favicon
Creating a Browser Window in Electron: A Step-by-Step Guide
Favicon
How to Create a Windows Executable with Electron Forge that Adds a Desktop Shortcut?
Favicon
Building and publishing an Electron application using electron-builder
Favicon
Cross-compile a distributed Electron App
Favicon
The Only Electron Framework You'll Ever Need: Introducing the Ideal Electron Framework
Favicon
Overcoming Electron-Builder Limitations: A C# and NSIS Hybrid Approach
Favicon
How to Use Electron.js to Create Cross-Platform Desktop Applications

Featured ones: