Skip to main content
Leaving The Matrix
nova-dev 8 min read

Three hotfixes for one barrel import: Web Lookup goes to prod

Pillar B 1.1 shipped: members can now research tickers on their phones. The squash-merge to main built clean, deployed clean, and 500'd on first request with ERR_MODULE_NOT_FOUND: playwright. Two hotfixes failed before the real one landed.

#nova#war-story#architecture#lookup

The Web Lookup port shipped last night. Pillar B 1.1 done. Nova members on phones can finally research tickers without leaving the app — same chart, same scoring, same smart-money markers, port of the desktop Lookup down to mobile width. PR #71 squash-merged to main as b347266. Vercel auto-deploys main on push.

Then runtime exploded.

CI green, prod red

The squash-merge was clean. pnpm build ran locally without complaint. Vercel's build picked up the push, ran its own next build, finished green. Deploy went through. The /lookup/[symbol] route 500'd on first request with ERR_MODULE_NOT_FOUND: playwright.

Playwright doesn't ship to web. Web has no use for playwright. Web's bundle should not contain a single byte of playwright code. The fact that a Next.js serverless function on Vercel was trying to load playwright at runtime was the surprise.

Hotfix 1: tracing excludes

First attempt was the obvious one. Next.js's outputFileTracingExcludes in next.config.mjs tells the build to drop specific modules from the per-route trace it bundles into each serverless function. Dropped electron, playwright, electron-builder, esbuild, sharp, plus the entire apps/desktop and apps/ui directories. Commit a00c3e7. Build succeeded. Runtime threw the same error.

Hotfix 2: webpack alias stubs

Second attempt: tell webpack to stub out the offending modules at compile time. config.resolve.alias = { ...alias, playwright: false, electron: false }. Setting an alias to false in webpack-land means "resolve this import as the empty module." Commit 9517d12. Build succeeded. Runtime threw the same error.

The stubs were too late. Some path in the dependency graph was importing playwright before webpack's alias rules took effect — a server-side import that bypassed the bundler's resolution entirely. The bundle had the alias applied where webpack could see it; the runtime path didn't.

The actual fix

apps/web/lib/data/lookup.ts imports from @nova/core. The barrel.

The @nova/core barrel re-exports everything in packages/core/src/index.ts. That includes ./clickcapital/* (uses playwright for headless browser scraping of ClickCapital portfolios), ./voice/* (uses cartesia and whisper for voice synthesis and transcription), the full agent / skills surface, and a half-dozen other modules that target the desktop runtime where Electron and native binaries live.

Even though lookup.ts only references the screener exports, the bundler statically analyzes the barrel and pulls in everything reachable through it. Tree-shaking helps with unused values, but module-level side effects (dynamic imports inside a re-exported module, top-level require calls in a transitive dep) still fire. Tracing excludes and webpack aliases are blunt instruments — they tell the bundler what not to bundle, but they don't tell the source what not to ask for.

The fix is a subpath import. Added an explicit export to packages/core/package.json:

"exports": {
  ".": "./src/index.ts",
  "./screener": "./src/screener/index.ts"
}

Then changed the import in apps/web/lib/data/lookup.ts from @nova/core to @nova/core/screener. The subpath maps directly to ./src/screener/index.ts. The bundler doesn't see the barrel; it sees the screener module and its real dependencies. Playwright never enters the trace. Voice never enters the trace. The Vercel serverless function bundle is what it should be.

Commit f139afe. Build succeeded. Runtime worked.

What this means for the rest of the web app

@nova/core is the shared package the desktop app and the web app both depend on. The desktop app pulls the barrel because it wants the whole surface — every IPC handler, every skill, every data fetcher. The web app needs slices: screener for Lookup, insiders / congress / 13F for the smart-money tables, fund-quality scoring for ETF pages, and a few utilities. Importing the barrel everywhere on web is how playwright ends up in a serverless function.

The pattern, banked: any package shared between two runtimes with different native-dep requirements needs an exports map with explicit subpaths, and the consumer with the narrower runtime imports only those subpaths. Barrel = whole package = whole runtime requirement. Subpath = a slice = the slice's runtime requirement.

Future-self touching apps/web/lib/data/: import from @nova/core/screener, @nova/core/insiders, @nova/core/congress, @nova/core/thirteenf, @nova/core/fund-quality. Add a new subpath when web needs a new module. Don't reach for the barrel. The tracing excludes and the webpack alias stubs from hotfixes 1 and 2 are still in next.config.mjs as belt-and-suspenders, but the subpath import is what actually keeps the runtime clean.

What actually shipped

The feature that all that hotfixing protected, in one paragraph: /lookup/[symbol] route, mobile + desktop responsive, tier-gated. lightweight-charts parity with the desktop chart — candles, EMA cloud, 200-week MA, VWAP, RSI, volume, earnings hex badges with click-to-popover, insider / congress / 13F arrow markers with per-day rollup and USD-weighted sizing and hover tooltips, timeframe picker. Five-tab right rail: Details (12-layer score breakdown, key stats, Wall Street consensus, fundamentals, valuation vs 5y, Nova Fair Value SVG gauge, buy-and-hold returns, options flow, fund profile for ETFs, thesis), News, Earnings, Filings, Insider — accordion-style on mobile. SmartMoneyStrip below the chart. Top-nav symbol search backed by Yahoo, debounced and AbortController-race-safe so stale responses can't overwrite fresh ones. Mobile search modal. PWA install — Android native prompt path, iOS three-step instruction bottom-sheet (Safari has no JS install API). A --color-bone-mute bump from #7a7a78 to #b8b8b5 for AA contrast.

Members paying for the platform can now research tickers on their phones. That's the gap 1.1 was supposed to close.

Slice 1.2 is Web Screener — same data, sortable table, click a row to land on Lookup. The hotfix lessons should make it shorter than 1.1 was.

Free Your Mind · Free weekly newsletter

Liked this? Get the next one in your inbox.

One full ticker through the framework + the lesson behind it, every week. Unsubscribe in one click.

Want the full picture?

Smart-money flow, real conversations, the whole framework.

Leave the Matrix
Leave the Matrix