DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two things must happen before our server module loads, and a require is all that enforces the order

Notifio is an Electron app whose main process is also its HTTP server. There is no child process: app.whenReady() fires, and then one line pulls an Express application into the same process the windows live in.

That line is a require, inside a function, in a file where every other dependency is a top level import. It looks like an oversight, and it is load bearing. This post is about why, because implicit initialisation order is the kind of thing that works perfectly until somebody tidies it up.

The two constraints

Here is the top of main.ts, trimmed to the parts that matter:

import { app, BrowserWindow, Tray, Menu, nativeImage, shell, session, powerMonitor } from 'electron';
import path from 'path';
import fs from 'fs';
import { initDataDirs, BROWSER_DATA_DIR } from './paths';

// Playwright browser path.
// Must be set before any require('./server') call so that Playwright
// resolves to the bundled binary.
//
// Packaged:  <install>/resources/app.asar.unpacked/playwright-browsers
// Dev:       <repo>/app/playwright-browsers
(function setBrowsersPath() {
  const bundled = app.isPackaged
    ? path.join(process.resourcesPath, 'app.asar.unpacked', 'playwright-browsers')
    : path.join(__dirname, '..', 'playwright-browsers');
  process.env.PLAYWRIGHT_BROWSERS_PATH = bundled;
})();
Enter fullscreen mode Exit fullscreen mode

And here is the function that starts the server, trimmed the same way:

function startServer(): Promise<void> {
  return new Promise((resolve, reject) => {
    try {
      process.env.ELECTRON_APP = '1';
      initDataDirs(); // ensure writable dirs + config exist before server loads
      require('./server');
      // ...
Enter fullscreen mode Exit fullscreen mode

Two things have to be true before the server module is evaluated:

  1. PLAYWRIGHT_BROWSERS_PATH points at the bundled browser, because the app ships its own and must never go looking in a cache directory on the user's machine that may not exist.
  2. The writable directories exist, because the modules the server pulls in read paths at import time.

Neither is checked. Both are satisfied by the order statements appear in, and the thing that makes that order possible is that require is a function call and import is not.

Why a static import would break it

If the top of the file said:

import './server';
Enter fullscreen mode Exit fullscreen mode

then the ES module semantics would hoist that to the top of the module, above the IIFE, above everything. The server and its entire dependency graph would be evaluated before a single line of my initialisation ran. The environment variable would be unset, the directories would not exist, and the failure would land somewhere deep in a module that has no idea why.

This project is "type": "commonjs", so import compiles to a require call anyway and would be hoisted to the same place the transpiler puts it, which is still above the function body. Either way, the only way to defer evaluation to a chosen moment is to call it at that moment. So that is what the code does, and that is why the comment on line 9 names require('./server') specifically rather than saying "before the server starts".

There are three more of these in the file, for the same reason. The module that owns the browser contexts is pulled in with require('./scraper') inside the quit handler and inside the power monitor's suspend handler, each in its own try with a comment noting that the module may not be loaded yet. A static import there would mean that merely closing the app loads a module that the app may never have needed.

The second constraint, in more detail

initDataDirs() is three mkdirSync calls and one conditional copy:

export function initDataDirs(): void {
  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.mkdirSync(BROWSER_DATA_DIR, { recursive: true });
  fs.mkdirSync(LEDGER_DIR, { recursive: true });

  if (!fs.existsSync(CONFIG_PATH)) {
    const bundledConfig = path.join(__dirname, '..', 'config.json');
    if (fs.existsSync(bundledConfig)) {
      fs.copyFileSync(bundledConfig, CONFIG_PATH);
    } else {
      fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2), 'utf8');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The copy is the interesting half. There is a config.json inside the packaged application, and it is read only, because inside an asar archive everything is. So on first run the bundled file is copied out to the user data directory and from then on that copy is the one the app reads and writes. The else branch exists for the development case where nobody has put a bundled config next to the compiled output, and it writes an empty config rather than letting the first request decide what to do about a missing file.

Why this has to happen before the server module loads: server.ts imports CONFIG_PATH from ./paths at the top, and so do nine other modules. The server's own loadConfig() is defensive about a missing or corrupt file, but "defensive" and "creates your directories for you" are different jobs, and one of them belongs at startup.

The module that can never be tested

paths.ts resolves everything at module scope:

export const USER_DATA_DIR    = app.getPath('userData');
export const DATA_DIR         = path.join(USER_DATA_DIR, 'data');
export const BROWSER_DATA_DIR = path.join(USER_DATA_DIR, 'browser-data');
export const CONFIG_PATH      = path.join(USER_DATA_DIR, 'config.json');
Enter fullscreen mode Exit fullscreen mode

app.getPath('userData') runs the instant anything imports this file. That is fine, and deliberately so: userData is one of the paths Electron can resolve before ready fires, which is what lets main.ts import paths at the top of the file and still have a valid value in the IIFE below.

It does mean paths.ts is permanently unimportable outside Electron. The app's testing rule, which I wrote about in Our Electron app has no test framework, and the only rule is that testable files may not import electron, means this file and everything that imports it sits outside the testable set. Ten modules import it. That sounds bad until you notice what is actually in the file: constants, path.join, and four filesystem calls with recursive: true. There is no logic here to get wrong. The file is the boundary, and keeping it dull is what keeps the boundary cheap.

The same reasoning explains the comment at the top:

/**
 * Centralised path resolution for Notifio.
 *
 * Inside a packaged asar, __dirname is read-only. All writable data must live
 * in app.getPath('userData') which resolves to:
 *   Windows: %APPDATA%\Notifio
 *   macOS:   ~/Library/Application Support/Notifio
 */
Enter fullscreen mode Exit fullscreen mode

One file knows the difference between where the code is and where the data is, and nothing else in the project has to think about it. That is also why the privacy policy can be specific about which data never leaves your machine: there is one directory, and it is named in section 2 of the policy.

What this costs

An ordering constraint enforced by comments is a real risk, and I want to be honest about the size of it. The protection is that both constraints live in the same 70 lines of the same file, and the comment above each one names the specific call it must precede. An editor who moves the IIFE has to read past a comment telling them not to.

The alternative shapes are worse for this app. An explicit initialise() that does both and returns a token the server requires is more types for one call site. Moving the server into a child process gives you clean startup ordering and costs you the single-process design that lets a window and a poll loop share memory, which is the thing the whole app is built around. I have written about what that design buys at Four places state lives in our Electron app, sorted by when it is allowed to die, and about the bundled browser half of constraint one at Shipping Playwright's Chromium inside a packaged Electron app.

When the ordering does go wrong, the failure is visible, because startServer() is wrapped and a thrown error puts a window on screen with the stack in it rather than nothing at all. That mechanism is its own post: A packaged Electron app has no console, so our startup failure is a window made out of a data URL.

The app all of this boots is on the download page, and what it does once it is running is the list on the alerts page.

Top comments (0)