Blog
Programming challenges, experiments, and build notes behind short videos.
Programming challenges, experiments, and build notes behind short videos.

I recently shipped the first version of icecut, a focus tool that doesn't block distracting sites — it logs every visit through a forced, timed, reason-gated "breach protocol." I wrote about why it works that way already; this post is the other half: how it's built.
The interesting problem in icecut isn't any single surface — it's that there are three of them and they have to agree. A browser extension decides in real time whether a URL is a flagged site and enforces the delay. An API stores the breach log and will, eventually, sync it across devices. A dashboard shows you your own reasons counted back to you. The moment the extension and the server disagree about what counts as a breach — or how long the delay should be, or what a valid reason is — the log quietly starts lying. So the whole architecture is organized around one goal: make the three surfaces unable to drift.
icecut is a single Bun-workspaces monorepo:
icecut/
├─ apps/
│ ├─ extension/ # WXT MV3 — breach overlay + background worker
│ ├─ web/ # Next.js 16 + Chakra v3 — landing + dashboard
│ └─ server/ # Express 5 + Prisma 7 — the API
└─ packages/
├─ core/ # Zod contract + pure domain logic (no React, no node-only)
├─ db/ # Prisma schema + adapter-pg client
└─ ui/ # Chakra cyberpunk system + tokens.css
Bun is both the package manager and the runtime. The three apps/ are the surfaces; the three packages/ are what they share. The cheapest way to keep three surfaces honest is to make them import the same code — so the rules that must not diverge live in packages/, and everything else is just a consumer.
@icecut/core has no React and no Node-only dependencies — just Zod schemas and pure functions. The extension imports it, the server imports it, the web app imports it. It is the single source of truth for every shape that crosses a boundary. Here's the one that matters most, a logged breach:
export const BreachInput = z.object({
clientId: z.string().uuid(), // extension-generated -> idempotent
daemonDomain: z.string().min(3), // registrable domain
runId: z.string().uuid().optional(),
reason: z.string().trim().min(REASON_MIN_LEN),
durationMs: z.number().int().nonnegative(),
occurredAt: z.string().datetime(),
});
export const BreachBatch = z.object({
breaches: z.array(BreachInput).max(200),
});
That clientId line is small and does a lot. The extension mints a UUID for every breach the instant it happens. When a queued batch is later flushed, the server upserts on that id — so a retried request, a double-tap, or a reconnect after a dropped response can never create a duplicate cut. Idempotency isn't a server feature bolted on later; it's decided right here in the schema, and both sides read the same definition.
The most dangerous place for drift is the question "is this URL a flagged site?" If the content script and the server answer it differently, breaches get logged against the wrong domain or missed entirely. So that logic lives in core too, built on tldts:
export function matchDaemon(url: string, daemons: DaemonLike[]): DaemonLike | null {
const extracted = extractDaemon(url); // tldts -> registrable domain
if (!extracted) return null;
return daemons.find((d) => d.domain === extracted.registrableDomain) ?? null;
}Matching is on the registrable domain, never the raw hostname — so m.instagram.com and instagram.com/reels/… both resolve to the same instagram.com daemon, while chrome://, about:, and file:// URLs resolve to nothing (they can never be daemons). The source comment on this function is blunt: it MUST behave identically in the extension and on the server. Keeping it in one package is how that promise is kept instead of merely hoped for.
The forced delay is icecut's teeth, so it has to be deterministic and shared — the extension enforces it now, and any future server-side check has to re-derive the exact same number:
// 5s at severity 1, +1s per step, capped at 14s (severity 10).
export function breachDelayMs(severity: number): number {
return 5000 + (clamp(severity) - 1) * 1000;
}Five seconds at the mildest setting, plus a second per severity step, capped at fourteen. The default daemons ship opinionated: Instagram and X are severity 8, so a twelve-second hold; TikTok is a nine. A sibling function turns that same severity into the interstitial's copy — at severity 8 and up it reads "BLACK ICE. This node has flatlined you before." One input, one curve, one voice, defined once.
The extension is built with WXT on Manifest V3. The breach overlay is a content script injected at document_start, so the gate is up before the distracting feed can paint. It renders into a shadow root with its CSS injected inline, which means the host page's stylesheet can't reach in to override, hide, or race it — a site can't CSS its way out of being witnessed.
The reason field is validated with the very same Zod schema the server uses: at least ten characters after trimming, which quietly rejects the empty string, stray whitespace, and "n/a." There is no skip button anywhere in the flow. Aborting a breach doesn't bypass it — it just closes the tab, and nothing is logged, because you never went in.
Focus can't wait for a network round-trip, so the extension never blocks on one. Every breach is written to chrome.storage.local first, and the UI — today's count, the reason history it confronts you with — reads straight from there:
export async function addBreach(record: BreachRecord): Promise<void> {
const breaches = await getBreaches();
breaches.push(record);
await chrome.storage.local.set({ [KEY_BREACHES]: breaches });
}
// Replace a breach by clientId -> write back the finalized duration.
export async function updateBreach(record: BreachRecord): Promise<void> {
const breaches = await getBreaches();
const idx = breaches.findIndex((b) => b.clientId === record.clientId);
if (idx === -1) return;
breaches[idx] = record;
await chrome.storage.local.set({ [KEY_BREACHES]: breaches });
}updateBreach finds the record by its clientId and writes back the finalized duration once the timer resolves — the same id that later makes the server sync idempotent. The queue is built for a flaky world: accumulate locally, flush in batches of up to 200, let the server dedupe on clientId.
Here's the honest seam between v0 and v1. Right now the background service worker runs a thirty-second alarm that does exactly one thing — reports how many breaches are queued:
// v0: a 30-second tick that just reports queue depth.
// v1 wires this to a batch-flush POST /api/breaches (idempotent via clientId).
chrome.alarms.create('icecut:sync', { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener(() => {
void getBreaches().then((breaches) => {
console.debug('[icecut] ' + breaches.length + ' breaches queued locally');
});
});The contract for the real sync — the batch shape, the idempotency key, the heartbeat that will drive "ICE offline" detection — is already defined and tested; the pipe that carries it to POST /api/breaches is the v1 wire-up. I'd rather ship a v0 that's genuinely useful offline and leave a clean, typed seam for sync than fake a backend that isn't ready.
The server is Express 5 with Prisma 7, and Prisma is the part that usually fights Bun. The trick is the prisma-client generator plus @prisma/adapter-pg: no native query engine, so there's nothing platform-specific to break under Bun. And the server code stays deliberately runtime-agnostic — if Bun and Prisma ever fall out, the server alone can drop to node:24 + tsx without the extension or the contract noticing.
Every response uses one envelope, shared from core, so no consumer has to guess the error shape:
export type ApiEnvelope<T> =
| { success: true; data: T }
| { success: false; error: { code: string; message: string } };And the two functions that must match across surfaces — domain matching and the severity curve — are pure and unit-tested precisely because "identical behavior in two places" is a promise you have to actively keep. The server's tests are real integration tests against Postgres (CI spins up a Postgres service container), not mocks, so the seam between the API and the database gets exercised for real.
To be clear about status: v0 is the local-only extension (breach protocol, local log, popup), the cyberpunk landing page with a working waitlist, and the package + server foundation. The product endpoints on the server are still 501 stubs. Auth (jose with Google OAuth), extension-to-server sync, and the dashboard — the breach log, focus runs, uptime — are v1, and every one of them will be built on the contract that's already in place.
That's the bet of this architecture: get the shared core right first, keep the three surfaces importing it, and the features become plumbing instead of guesswork. If you want the product story behind it, it's here; the tool itself is at icecut.app.

