Notifio's desktop UI is a React app served over HTTP by an Express server running inside the Electron main process. There is no preload script and no IPC: the renderer talks to 28 endpoints on 127.0.0.1 like any web page talks to any API.
Everything the UI asks for goes through one helper:
// 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";
async function request<T>(
path: string,
options?: RequestInit
): Promise<{ ok: boolean; data: T; status: number }> {
const res = await fetch(`${BASE}${path}`, options);
const data = await res.json().catch(() => ({}));
return { ok: res.ok, data, status: res.status };
}
Thirty-one exported functions sit on top of that, one per endpoint, and the contract they advertise is that a failure is a value rather than an exception:
export const getSites = () => request<Site[]>("/api/sites");
export const addSite = (name: string, url: string) =>
request<{ ok: boolean; loggedIn: boolean; hostname: string; error?: string }>(
"/api/sites",
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, url }) }
);
Callers destructure. No try, no error boundary, no rejected promise anywhere in the tree:
const { ok, data } = await addSite(name, url);
if (!ok) return { ok: false, error: data.error, loggedIn: false };
I like this shape a lot. It is the right call for a UI whose whole job is to display the state of a background process. What I had not done is check whether the contract is actually true.
It converts every failure except the first line
res.json() has a .catch. fetch does not. A transport failure, which is the one failure mode the helper exists to absorb, rejects the promise and lands in the caller.
So I counted the guards. There are twelve try blocks in the entire renderer, and ten of them are about parsing a string:
try {
new URL(url);
} catch {
setAddError("Invalid URL.");
return;
}
export function hostOf(url: string): string {
try {
return new URL(url).hostname;
} catch {
return "";
}
}
Nine of those wrap new URL, because a search URL is typed or pasted by a person and new URL throws on a bad one. One wraps JSON.parse of an event stream message. That leaves two that guard a call to the server, and both of them guard the same endpoint:
// SiteSetupList.tsx, inside a Promise.all over hosts
try {
const { data } = await getLoginStatus(site.url);
return [host, data.loggedIn === true] as const;
} catch {
// AppState.tsx, the same question asked one host at a time
for (const host of hosts) {
try {
const { data } = await getLoginStatus(site.url);
logins[host] = data.loggedIn === true;
} catch {
logins[host] = false;
}
}
One endpoint, defended twice, and I know exactly why: that call is the one that reads a site's saved session, and Chromium only lets one process hold a browser profile at a time, so asking twice at once used to produce a wrong "logged out". The guards are scar tissue from one real incident rather than a policy. Every other call in the UI is unguarded, including the one that runs most often.
The one that runs most often
useEffect(() => {
void (async () => {
const [sitesRes, statusRes, findsRes, configRes] = await Promise.all([
getSites(),
getMonitorStatus(),
getFinds(),
getConfig(),
]);
// ... four dispatches ...
await refreshLogins();
})();
}, [refreshLogins]);
Four requests, one Promise.all, no catch, and a void in front of the async function so nothing downstream is waiting on it. One rejection and the whole effect stops: no sites, no status, no finds, no config, and a window sitting at its initial state with an unhandled rejection in a console that a packaged build does not have.
And this effect runs more than any other code in the app, because the window reloads itself every time it is shown from the tray:
mainWindow.on('show', () => {
mainWindow?.webContents.reload();
});
That reload is deliberate and I have defended it before: the renderer is not allowed to own anything, so throwing it away is cheaper than keeping it correct. The consequence is that the app's least guarded four lines are its most executed four lines.
Why it has not bitten
Because of where the server is. It is not a separate process: main.ts requires it into the main process and awaits startup before the window is created at all, so by the time the renderer can run a line of JavaScript, the thing it is fetching from is already listening on loopback in the same process. A connection refused in a shipped build is close to unreachable.
Close to. The reachable version is development, where the UI stays open against a server you have just restarted, and where the symptom is a window that renders its empty state and tells you nothing. That is not a user-facing bug. It is a bad hour for whoever is debugging something else at the time.
The second lossy line, and the discipline paying for it
const data = await res.json().catch(() => ({}));
That swallow is load-bearing, because not every error response is JSON. The local API has 32 routes and 20 of them have no try inside the handler, and there is no Express error-handling middleware in the file. An uncaught synchronous throw in any of those 20 produces Express's default HTML error page, which means res.json() rejects, which means data is {} with ok: false and no reason in it.
The thing that saves the UI from showing a blank error is a convention at every display site. All eleven of them:
setError(data.error || "Activation failed. Please try again.");
showToast(data.error || "Could not save");
setStatusMsg("Error: " + (data.error || "Could not open browser."));
setAddError(result.error || "Error adding search.");
Eleven || and eleven fallback sentences, which is a 100% hit rate held up by nothing except habit. One missing || renders the word "undefined" into a toast. There is one place where the error crosses a layer boundary with no fallback at all, in the action that adds a search, and it survives only because the component that calls it adds the fallback one frame later.
Five lines that make the contract true
async function request<T>(
path: string,
options?: RequestInit
): Promise<{ ok: boolean; data: T; status: number }> {
try {
const res = await fetch(`${BASE}${path}`, options);
// A non-JSON body is an error page, so carry the status instead of {}.
const data = await res
.json()
.catch(() => ({ error: `${res.status} ${res.statusText}` }));
return { ok: res.ok, data: data as T, status: res.status };
} catch {
// Never reached the server at all. status 0 says so without lying about a code.
return {
ok: false,
status: 0,
data: { error: "Could not reach the app. Is it still running?" } as T,
};
}
}
Two changes. The try makes the promise the helper hands out genuinely non-rejecting, which is what thirty-one wrappers and every component already assume. And the non-JSON fallback carries the status text, so the || chain at every display site has something true to say instead of relying on my fallback sentence being right about what went wrong.
The general version of this, which I keep relearning: a wrapper that converts exceptions into values is one of the best trades available in a UI, and it is only worth anything if it converts all of them. A helper that handles the failure you were thinking about and passes through the one you were not has not removed the try from your callers. It has hidden the fact that they still need one.
The window all of this paints is on the download page for Mac and Windows, it is a one-time licence rather than a subscription, the tabs and what each one shows are described in the help pages, and the searches it watches are set up per site, with the sign-in step that produced those two guards documented on each one: Kamernet, Rightmove, WG-Gesucht and the rest.
Top comments (0)