DEV Community

Daniel Pertu
Daniel Pertu

Posted on

One find, three channels, and only one of them is allowed to fail quietly

Notifio watches rental searches and tells you when a new listing appears. On the sites it covers, which have a page each such as Pararius, the gap between a listing appearing and twenty people having replied to it is minutes. So "tells you" is the whole product, and it happens three times per find.

Here is the fan out, from the one function that decides what counts as new.

The dedupe is the fan out

const fresh = finds.add(
  newListings.map((listing) => ({
    id: listing.id,
    siteName: site.name,
    siteUrl: site.url,
    title: listing.title,
    url: listing.url,
  }))
);
if (fresh.length > 0 && config.notifications?.desktop !== false) {
  notifyFinds(site.name, fresh, log);
}
Enter fullscreen mode Exit fullscreen mode

finds.add writes to an on disk ring buffer and returns only the entries that were not already in it. That return value is the point. It means the dedupe and the notification are one decision rather than two, and there is no second place that has to remember the same rule. A listing that is already in the buffer produces no banner, no stream frame, nothing, without any caller having to check.

The opt out is !== false rather than === true, so a config file written before this feature existed, which has no notifications key at all, gets notifications.

Channel one: local, because it costs nothing

// The local channel goes first. It costs no network round trip, so the
// banner is on screen before the email has left the building.
Enter fullscreen mode Exit fullscreen mode

The native notification is built from the fresh array, not from the listings:

const title =
  finds.length === 1 ? `New listing on ${siteName}` : `${finds.length} new listings on ${siteName}`;
const body =
  finds.length === 1
    ? first.title || first.url
    : finds.slice(0, 3).map((f) => f.title || f.url).join('\n');
Enter fullscreen mode Exit fullscreen mode

One notification per search, never one per listing. Five banners for five rooms in the same search is a notification a user turns off, and a notification a user has turned off is worth less than no feature. The body caps at three titles for the same reason.

Two options on the notification itself are deliberate:

const notification = new bits.Notification({
  title, body,
  urgency: 'critical',
  timeoutType: 'never',
});
notification.on('click', () => {
  bits.shell.openExternal(first.url).catch(() => {});
});
Enter fullscreen mode Exit fullscreen mode

timeoutType: 'never' because a banner that auto dismisses after five seconds is useless to someone who stepped away from the desk, which is the entire scenario this product exists for. And the click opens the listing in the user's real browser rather than in an app window, because that is where they are already signed in to the site.

Electron is required lazily, inside a try:

function electron(): ElectronBits | null {
  try {
    return require('electron') as ElectronBits;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

The monitor can be run outside Electron during development, and Notification.isSupported() can be false on a real user's machine. Both paths return quietly. A missing notification is never a reason to fail a poll.

Channel two: the event stream, for a window that may not be open

The app is a tray app, and its window reloads itself every time you reopen it, so the window cannot be the thing that remembers anything. The local HTTP server inside the main process keeps a set of event stream clients and subscribes to the same find event:

const sseClients = new Set<Response>();

finds.onAdd((fresh) => broadcast({ type: 'finds', finds: fresh }));
Enter fullscreen mode Exit fullscreen mode

On connect, a client is sent the backlog before it is added to the set: one frame per buffered log line, then the current status, every search's state, and the current notice. Then it joins the broadcast. That ordering is what makes a reload cheap, because the stream itself carries the initial state rather than the renderer having to fetch it separately and then subscribe.

The listener loop swallows listener errors on purpose:

_listeners.forEach((fn) => {
  try { fn(fresh); } catch { /* a broken listener must not lose the find */ }
});
Enter fullscreen mode Exit fullscreen mode

A find has already been written to disk by the time listeners run. Letting a broken subscriber throw back into the monitor would turn a cosmetic problem into a lost alert.

Channel three: email, and the only one that may throw

The app does not talk to an email provider. It posts to our own API with the licence it was activated with:

const res = await fetch(`${serverUrl}/api/notify`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: license.email, token: license.token, type: 'listings', alerts }),
  signal: AbortSignal.timeout(15_000),
});

if (!res.ok) {
  const body = await res.text();
  throw new Error(`Server returned ${res.status}: ${body}`);
}
Enter fullscreen mode Exit fullscreen mode

Note the throw. The two local channels are wrapped in try/catch and fail silently; this one is allowed to propagate, and the caller is built around that:

if (alerts.length > 0) {
  try {
    await sendListingAlert(alerts, config);
    // Delivery confirmed, so now it is safe to record these listings as seen.
    for (const snap of pendingSnapshots) saveSnapshot(snap.url, snap.listings);
  } catch (err) {
    log(`[notify] Failed to send: ${message}. Keeping listings unseen, will retry next poll`);
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the one ordering decision in the whole feature I would defend hardest. The diff baseline for each search is not written when the listing is found. It is collected into pendingSnapshots and written only after the server has accepted the email. If the send fails, those listings are still "new" on the next cycle, so the user gets a second chance at the alert instead of a silent hole.

The cost is a possible duplicate email, and only that. On the retry cycle those listings are new again against the old baseline, so the mail goes out a second time, but finds.add already holds their ids, returns nothing fresh, and the banner does not repeat. Telling someone twice is a nuisance. Never telling them is the failure this app exists to prevent.

Auto replies are queued during this phase and run after it:

// Auto-replies run after the alert. The user is told about the listing either
// way, so a broken recipe or a blocking site still leaves them able to reply
// by hand. Failing to reply must never mean failing to notify.
Enter fullscreen mode Exit fullscreen mode

The server end: one route, four kinds of mail

/api/notify is the only outbound mail path, and it checks three things in order.

Rate limit first, keyed by licence token rather than IP:

const { success: withinLimit } = await ratelimit.limit(`notify:${token || ip}`);
Enter fullscreen mode Exit fullscreen mode

Keying by token means two users behind the same office NAT each get their own budget instead of competing, and that this route no longer shares a bucket with licence validation. IP is the fallback for a request with no token, which is also a request that is about to be refused anyway.

Then the licence, which has to exist, match the email, and be active:

if (!license || license.email !== email || !license.active) {
  return NextResponse.json({ error: "Invalid or inactive license." }, { status: 403 });
}
Enter fullscreen mode Exit fullscreen mode

Then the type. There are four: new listings, a completed reply, a search that needs you to sign in to the site again, and a search that could not be read this cycle. Each has an HTML build function and a plain text twin.

The reply mail has the detail I like most in this route. A reply attempt can end up in a state where the form was submitted and the site showed nothing we recognise as a confirmation. The payload carries that distinction and the subject line respects it:

subject: `Notifio: ${notice.confirmed ? "Replied to" : "Reply submitted for"} ${
  notice.listingTitle || notice.siteName
}`,
Enter fullscreen mode Exit fullscreen mode

"Reply submitted for" is a weaker claim than "Replied to", and it is the true one. An email that says a landlord was definitely contacted, when the code does not know that, is the sort of thing a user finds out about in the worst possible way.

If the mail provider fails, the route answers 502, which the app logs. The only remote channel is allowed to be the only one that complains.

What this shape is worth

Three channels, one dedupe, one ordering rule: the slow channel decides when state advances, the fast channels just report.

That is why speed of alert is the thing I compare on when I put Notifio next to other tools on the comparison pages, and why the licence is a one time purchase on the pricing page rather than a tier that meters notifications. There is nothing to meter. A find produces three attempts to reach you, two of them local and free, and the one that costs anything is the one holding the line on whether the find counts as delivered.

Top comments (0)