Notifio is a single npm package that produces two programs. One is a Node process: the Electron main process, which starts an Express server in-process and drives a browser. The other is a browser bundle: a React renderer that Vite builds and that Express then serves to the window.
Those two have almost nothing in common. Different module system, different globals, different idea of what document is. They share a src/ directory and a package.json, and the way that is held together is three tsconfig.json files, only two of which any build command reads.
The three files
app/tsconfig.json <- the default position
app/tsconfig.main.json <- what `pnpm compile` reads
app/src/renderer/tsconfig.json <- what nothing reads
And the commands:
{
"compile": "tsc --project tsconfig.main.json",
"renderer": "vite build",
"prebuild": "pnpm clean && pnpm compile && pnpm renderer && pnpm copy:browsers && pnpm fix:symlinks",
"build": "pnpm prebuild && electron-builder --win --mac",
"test": "pnpm test:baseline && pnpm test:backoff && pnpm test:scraper"
}
compile names its project explicitly. vite build does not use a tsconfig for anything that affects output, because Vite strips types with esbuild and never checks them. So the file in the default position is read by neither.
tsconfig.main.json: CommonJS on purpose
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2020", "DOM"],
"outDir": "./dist-ts",
"rootDir": "./src",
"strict": true,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "src/renderer"]
}
Two lines in there are decisions rather than defaults.
"module": "commonjs" matches "type": "commonjs" in the package, and the main process depends on it. Startup sets an environment variable and then loads the server in that order, synchronously:
process.env.PLAYWRIGHT_BROWSERS_PATH = bundled;
// ...
process.env.ELECTRON_APP = '1';
initDataDirs();
require('./server');
The server module resolves the bundled browser path while it loads, so the assignment has to have happened first. With a static import that guarantee is gone: ESM hoists the import above the assignment and the module reads an unset variable. There are ways to write it in ESM, a dynamic await import or a separate side-effect module ordered by import graph, and both are more machinery than require on the one line where order matters. The packaging side has its own opinion too, since the bundled browser lives in asarUnpack and is found by path at runtime rather than by resolution. That part I wrote up separately in Shipping Playwright's Chromium inside a packaged Electron app.
"exclude": ["src/renderer"] is what keeps the two programs apart. Without it, tsc walks into the renderer, finds .tsx with no jsx option set, and fails the build. That exclusion is the only thing in this config that knows the renderer exists.
lib includes DOM in a Node build, and that is a real cost
"lib": ["ES2020", "DOM"]
This is a Node process. There is no window and no document. The DOM lib is there because main-process code passes functions into the browser:
text = await page.evaluate(() => document.body?.innerText?.slice(0, 3000) ?? '');
The callback is serialised, shipped across and run inside the page, so document is correct there and the types have to exist for the file to compile. Playwright's own types do not provide them; the DOM lib does.
The cost is that TypeScript now believes document exists everywhere in the main process. A stray document.querySelector at the top level of a Node module typechecks cleanly and throws document is not defined at runtime, and because this is startup code, the first time you see it may be in a packaged build with no terminal attached. I wrote about the window we show when that happens in A packaged Electron app has no console, so our startup failure is a window made out of a data URL.
The clean fix is to stop widening the whole program for a handful of callbacks: move the in-page functions into their own directory with its own tsconfig that has DOM, and drop DOM from the main one. That is a refactor across several files, so for now the honest description is that the main process compiles against a lie that happens to be true inside evaluate and nowhere else.
src/renderer/tsconfig.json: correct, and never run
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"isolatedModules": true,
"noEmit": true,
"paths": { "@/*": ["./*"] }
},
"include": ["./**/*.ts", "./**/*.tsx"]
}
This is the opposite program described properly. ESM, bundler resolution to match Vite, isolatedModules because esbuild compiles one file at a time and cannot see across them, and noEmit because Vite owns the output. DOM.Iterable is inherited from the Vite React template rather than chosen: nothing in the renderer iterates a NodeList, so it is a line that would cost nothing to delete and has never been worth the edit.
noEmit is the giveaway. A config that emits nothing exists only to be checked, and no script in the package checks it. pnpm build will happily produce an installer from a renderer with type errors in it, because the only thing that reads this file is my editor.
That is one line to fix:
"typecheck:renderer": "tsc --project src/renderer/tsconfig.json --noEmit"
added to prebuild. I am writing it down rather than slipping it in silently because the interesting part is how it happened: the config is right, the tool that would enforce it is installed, and the gap is that nothing calls it. A correct config nobody runs is indistinguishable from no config at all.
The file in the default position
{
"compilerOptions": {
"module": "commonjs",
"outDir": "./dist-ts",
"rootDir": "./src",
"jsx": "react-jsx",
"lib": ["ES2020", "DOM"],
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Compare that to tsconfig.main.json: same output directory, same module system, but include is src/**/* rather than src/**/*.ts, there is no src/renderer exclusion, and jsx is set so the TSX will not stop it. In other words, this config describes compiling the React renderer to CommonJS and writing it into dist-ts next to the main process output. The packaged files list ships dist-ts/**/* wholesale, so a bare tsc in that directory would put a second, broken copy of the renderer inside the installer, and nothing would complain.
It is not dead, though, which is why deleting it is not the answer. tsx reads tsconfig.json from the working directory, and the three test entrypoints sit at the package root:
"test:baseline": "tsx test-baseline.ts",
"test:backoff": "tsx test-backoff.ts",
"test:scraper": "tsx test-scraper.ts"
Those files are outside src/, so include does not cover them, but the compilerOptions still apply when tsx runs them. Editors use the same file for anything the other two configs do not claim. So the default-position config is the one that governs the tooling and none of the output, which is a reasonable thing to be, and is not what its name suggests to anyone reading the directory for the first time.
Vite's half of the split
export default defineConfig({
plugins: [react(), tailwindcss()],
root: "src/renderer",
base: "./",
build: {
outDir: path.resolve(__dirname, "public"),
emptyOutDir: true,
},
resolve: { alias: { "@": path.resolve(__dirname, "src/renderer") } },
});
root points at the renderer so index.html resolves next to its entry. outDir is public/, which is what the embedded Express server serves:
app.use(express.static(path.join(__dirname, '..', 'public')));
base: "./" makes asset URLs relative. The window actually loads http://localhost:3000, where absolute paths would resolve fine, so this is insurance rather than a requirement: it is what makes the same bundle work if the window is ever pointed at a file instead of a port. emptyOutDir: true is load bearing in the other direction, because public/ is a build output that lives in the source tree and a stale file in there gets packaged.
One more thing the two halves disagree about, harmlessly. tsconfig.main.json sets "sourceMap": true, so tsc writes .js.map next to every output file, and the electron-builder config then throws them away along with the sources:
"!**/*.map",
"!**/*.ts"
Which is the right call, since a sourcemap pointing at .ts files that are not in the package maps to nothing. The maps exist for the development run and are stripped from the installer, and the logFile trail is what debugging a packaged build actually relies on.
What I would tell someone starting this
Three tsconfigs for two programs is not too many. The failure mode is not the count, it is that the relationship between config files and commands is invisible: you cannot tell by looking at the directory that compile reads the second file, that vite build reads none of them, that tsx reads the first, and that the third is read by nothing. Every one of those is a fact about a script, not about a config.
So the thing worth doing early is writing the mapping down, as a comment in each file saying which command reads it. It takes a minute, and it is the artefact that would have made "the renderer is never typechecked" obvious on the day the config was written instead of a year later.
The result of all this is at notifio.app/download, which is one installer per platform. If something in it does not start, the log path in the fallback window and the contact form at notifio.app/help are the whole support pipeline.
Top comments (0)