The standard way to let an Electron renderer do something privileged is a preload script: you expose a named function over contextBridge, the renderer calls it, ipcMain handles it, and every capability the UI needs becomes a channel you designed, named and now maintain.
Notifio does not have one. The main window's webPreferences is this, in full:
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
No preload, so no bridge, so the renderer has no access to Electron whatsoever. It is a web page. What it loads is a local HTTP server running inside the main process:
mainWindow.loadURL(`http://localhost:${serverPort}`);
There are 28 routes on that server, and they are the entire contract between the UI and the app.
Why a server and not a channel
The app is a monitor: a loop that opens rental search pages, compares them to what they held last time, and emails you about anything new. The window is a control panel for that loop, and the loop is the part that has to be right.
Three things pushed us to HTTP.
The first is that the renderer is genuinely a web app. React, Vite, Tailwind, built into a directory that express.static serves. Its API client opens with a comment and a line that has no Electron in it:
// The renderer is served by the embedded Express server, so talk to it on the
// same origin. This keeps things working if PORT is changed from the default.
const BASE =
typeof window !== "undefined" ? window.location.origin : "http://localhost:3000";
Nothing in the renderer tree imports electron, which means none of it can only be exercised inside a packaged app.
The second is that an IPC surface is a design task that never ends. Every new capability is a channel name, a payload type, a handler, an entry in the preload, and a decision about what the renderer is allowed to ask for. HTTP gave us a vocabulary we did not have to invent: GET /api/sites is the list, POST /api/sites adds one, DELETE /api/sites/:index removes one, POST /api/monitor/stop stops the loop. When the shape of a feature is CRUD, a protocol that is already CRUD is one less thing to think about.
The third is that the window is disposable. It hides to the tray, and it reloads its own renderer every time it is shown again. A UI that reloads constantly must be able to rebuild its entire state from a cold start, with no memory of what it was told before. Behind an HTTP API that property is free, because a fresh page load is just another client doing its first GET.
Live updates come from one of those 28 routes rather than a push channel, and a new client is given four frames immediately on connect so that a reconnect after the window was hidden paints the whole picture without extra requests.
The server runs inside the main process, and the order matters
It is not a child process. main.ts requires it, and the require is deliberately late:
process.env.ELECTRON_APP = '1';
initDataDirs(); // ensure writable dirs + config exist before server loads
require('./server');
Both lines above the require have to happen first. The data directories must exist before any module reads the config out of them, and PLAYWRIGHT_BROWSERS_PATH is set at the very top of the file, before anything can transitively import the scraper, because a browser path resolved at import time with the wrong value is a packaged-only failure you will not see in development.
Being in the same process is also what makes the server boring. There is no serialization boundary between a route handler and the monitor, no second Node process to supervise, and nothing to shut down:
function stopServer(): void {
console.log('[main] Server shutdown handled by process exit');
}
The bind address is the authentication
This is the part worth copying if you copy nothing else. A local HTTP API with no auth is a real exposure, and the comment above the listen call says exactly what it would cost:
// Bind to loopback only. This API exposes the license email/token and full
// monitor control with no auth, so it must never be reachable from the LAN.
const server = app.listen(Number(PORT), '127.0.0.1', () => {
console.log(`Notifio running at http://localhost:${PORT}`);
});
app.listen(PORT) with no host binds to every interface. On a shared office or student house network that would publish a licence token and a remote control for somebody's rental alerts to everyone on the subnet. One extra argument is the whole mitigation, and it is the reason this design is defensible without a token exchange or a per session secret.
The window side has a matching rule. Everything that is not this origin is handed to the system browser and nothing opens inside the app, which also means the one origin the renderer can reach is the one the app serves.
What a server cannot do, and the bridge that answers it
Some things genuinely need Electron: showing a real browser window so a user can log in to a rental site themselves, and recording a reply flow. An Express route cannot open a BrowserWindow. So there is a tiny in-process bridge, registered once by the main process, that route handlers call:
loginBridge.register({
open: (url) => openLoginWindow(url),
confirm: (hostname) => confirmLoginWindow(hostname),
reset: (hostname) => resetLoginWindow(hostname),
});
// Recorder needs BrowserWindow too, so it registers the same way.
registerRecorder();
Three functions, not a channel namespace. The bridge inverts the dependency: server.ts knows there is something that can open a login window, and does not know it is Electron. That is what keeps the server module free of Electron imports, which is what keeps the server module testable.
One window does use IPC, and the reason is the rule
The recorder window is the exception. It loads a third-party rental site and watches the user fill in a contact form, so it needs code running inside that page, which is precisely what a preload script is for:
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
partition,
// Sandbox stays on: this window loads third-party rental sites. The preload
// is written to be self-contained so it works under the sandbox, where only
// a small allow-list of modules can be required.
sandbox: true,
preload: path.join(__dirname, 'recorder-preload.js'),
},
The captured steps come back over ipcMain, and the handler's first job is to throw most of them away:
ipcMain.on('recorder:step', (_event, step: CapturedStep) => {
if (!_session) return;
if (!sameSite(_session.domain, step.url)) {
_session.wentOffsite = true;
return;
}
_session.steps.push(step);
});
The distinction turns out to be simple. Use HTTP when the renderer is asking the app a question about itself. Use IPC and a preload when you need to observe a page that is not yours. Our UI is only ever doing the first thing, and the one window doing the second thing is sandboxed and scoped to a single domain, with the domain lock borrowing the same guard the reply engine uses.
What this looks like from outside
You cannot see any of this directly, which is the usual fate of an architecture decision. What you can see is its consequence: the window is cheap, so closing it costs nothing and reopening it is instant.
The app is at notifio.app/download, the licence is a one-time purchase at notifio.app/pricing, and notifio.app/help covers what the UI can and cannot do while it is hidden. If you want to see what the 28 endpoints are ultimately in service of, the per site pages such as notifio.app/alerts/funda and notifio.app/alerts/openrent describe the loop they control.
Top comments (0)