šŸ“¦ The ultimate Electron app with Next.js and React Server Components2024-10-21

šŸ“¦ 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();

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:

  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/**/*'],
    },
  },
};

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.