Notifio is an Electron desktop app that watches rental listing sites and alerts you when something new appears. It ships a Chromium binary inside the installer, which is why the download page offers three separate files rather than one small one: a Windows installer and two Mac disk images, one per architecture.
Bundling a browser means the app has to tell the library where that browser is before the library goes looking. That single requirement produced two files in this repo where the module system had to be used by hand.
The rule that makes it a problem
ES module import declarations are not statements. They are hoisted: every module in the graph is resolved and evaluated before the first line of the importing module's body runs. So this does not do what it looks like:
process.env.PLAYWRIGHT_BROWSERS_PATH = somewhereElse; // runs second
import { chromium } from 'playwright'; // runs first
Hoisting is not a quirk you can out-order by moving lines around. There is no position in the file where an assignment runs before an import.
CommonJS does not have that property. require() is an ordinary function call that happens where you wrote it, which means you can sequence it. That is why the main process of this app is "type": "commonjs" and compiled with "module": "commonjs", and not because nobody got round to modernising it.
File one: asking the library where its own browser lives
The build stages a browser into the app folder so electron-builder can pack it. The folder it has to copy from is named after a revision, something like chromium-1228, and that number belongs to whichever Playwright version is in package.json. Hardcoding it means the build silently stages the wrong browser the first time that dependency moves.
So the script does not hardcode it. It points the library at the destination, loads the library, and asks:
process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(__dirname, '..', 'playwright-browsers');
const { chromium } = require('playwright');
const exePath = chromium.executablePath();
// e.g. "chromium-1228"
const revisionFolder = exePath.split(path.sep).find(p => p.startsWith('chromium-'));
if (!revisionFolder) {
console.error('[copy-browsers] Could not determine Chromium revision from:', exePath);
process.exit(1);
}
Three lines, and the revision number is now derived rather than typed. The require has to be in that order for executablePath() to resolve against the staging folder instead of the machine's own cache.
The guard matters as much as the derivation. find() returns undefined when nothing matches, and undefined joined into a path produces a plausible-looking string that points nowhere, so a changed naming convention upstream would have turned into a confusing copy failure several steps later instead of one clear message here.
File two: three things that must happen before the server loads
The Electron main process has the same shape at runtime. Its very first executable statement sets the path, and it is wrapped in an IIFE purely so the comment explaining why has somewhere to live:
(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;
})();
Packaged and unpackaged resolve to genuinely different places. Inside a packaged app the browser sits in app.asar.unpacked, because a binary cannot be executed from inside an asar archive at all; in development it is a sibling of the source tree.
The module that needs that variable is then loaded from inside a function, after two other preconditions:
function startServer(): Promise<void> {
return new Promise((resolve, reject) => {
logFile(`[main] PLAYWRIGHT_BROWSERS_PATH = ${process.env.PLAYWRIGHT_BROWSERS_PATH}`);
try {
process.env.ELECTRON_APP = '1';
initDataDirs(); // ensure writable dirs + config exist before server loads
require('./server');
...
initDataDirs() creates the writable directories and seeds a default config on first run. Several modules below ./server read a file path the moment they are loaded, so "the directory exists" is also a load-order precondition, not just a runtime one.
The top of the same file is full of ordinary import statements. That is the distinction worth drawing: imports are fine for every module that does not care when it loads, and the one module that does care gets a require placed deliberately. A file where everything is required by hand has lost the information about which ordering is load-bearing.
What the pattern costs
It is not free, and the costs are the reason to use it twice rather than twenty times.
-
TypeScript stops helping.
require('./server')is typedany. Nothing checks that the module still exports what the caller thinks. -
The dependency is invisible to tooling. No bundler or dead-code pass can see that edge, so the module has to be kept alive by configuration. In this app that means the file globs in
build.filesare what guarantee it is packaged, not the import graph. -
Failures move. A missing module throws at the moment of the call rather than at startup, which is why that
requiresits inside atrywith a logger around it. A packaged Electron app has no terminal to print a stack trace to, so startup failures go to a file in the user data folder instead.
The honest summary
Two files, two require calls, one reason: a library that reads its configuration from the environment at load time. The fix for that is ordering, and only one of the two module systems lets you express ordering at all.
If you want to see the thing all this machinery exists to deliver, the per-site alert pages list what the app watches, and the help page covers what it writes to disk once it is installed.
Top comments (0)