DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our electron-builder config ships three paths that do not exist, and every build is green

Notifio is a desktop app that watches rental search pages and emails you the moment a new listing appears. It ships as two macOS DMGs and one Windows installer from the download page, built with electron-builder out of a 140-line package.json.

I went through that file line by line last week, not looking for anything in particular, and found four names in it that refer to nothing. Every one of them has been there for months. Every build since has succeeded.

That is the interesting part. Not that the config has dead entries, which is ordinary, but that there is no layer anywhere in the stack whose job it is to notice.

The three paths

Here is the files array, trimmed to the includes:

"files": [
  "dist-ts/**/*",
  "public/**/*",
  "assets/**/*",
  "config.json",
  "data",
  "browser-data",
  "playwright-browsers/**/*",
  "node_modules/**/*",
  "package.json",
  ...
]
Enter fullscreen mode Exit fullscreen mode

And asarUnpack, which names three of the same things again:

"asarUnpack": [
  "node_modules/playwright/**/*",
  "playwright-browsers/**/*",
  "config.json",
  "data",
  "browser-data"
]
Enter fullscreen mode Exit fullscreen mode

config.json, data and browser-data do not exist in the project. Not ignored, not generated by the prebuild step, not produced by any of the four scripts in scripts/. They are simply absent, and have been since the app stopped seeding itself from a bundled file.

A glob that matches nothing is not an error in electron-builder, and it should not be: "dist-ts/**/*" legitimately matches nothing before pnpm compile runs, and failing the build on an empty match would make the config order-dependent in a way nobody wants. So three entries in files copy zero bytes, and three entries in asarUnpack unpack zero files, and the build prints nothing about any of it.

What used to be there

All three were real. They are the three things the app writes to, and in the first version of the app it wrote to them next to the executable. Then it was packaged, and a packaged Electron app lives inside a read-only asar, so all of that had to move:

/**
 * Centralised path resolution for Notifio.
 *
 * Inside a packaged asar, __dirname is read-only. All writable data must live
 * in app.getPath('userData') which resolves to:
 *   Windows: %APPDATA%\Notifio
 *   macOS:   ~/Library/Application Support/Notifio
 */
export const USER_DATA_DIR    = app.getPath('userData');
export const DATA_DIR         = path.join(USER_DATA_DIR, 'data');
export const BROWSER_DATA_DIR = path.join(USER_DATA_DIR, 'browser-data');
export const CONFIG_PATH      = path.join(USER_DATA_DIR, 'config.json');
Enter fullscreen mode Exit fullscreen mode

That is the whole migration. The names survived, the locations changed, and the asarUnpack entries stayed behind pointing at where those names used to live. asarUnpack exists so that a path inside the package can be written to or executed, and all three of these are now outside the package entirely. There is nothing left for it to do for them.

Ten modules import from paths.ts. Not one of them resolves a path relative to __dirname except for one survivor, which is the next thing.

The branch that always wins

initDataDirs() runs once at startup, and it still believes a bundled config might be there:

export function initDataDirs(): void {
  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.mkdirSync(BROWSER_DATA_DIR, { recursive: true });
  fs.mkdirSync(LEDGER_DIR, { recursive: true });

  if (!fs.existsSync(CONFIG_PATH)) {
    const bundledConfig = path.join(__dirname, '..', 'config.json');
    if (fs.existsSync(bundledConfig)) {
      fs.copyFileSync(bundledConfig, CONFIG_PATH);
    } else {
      fs.writeFileSync(
        CONFIG_PATH,
        JSON.stringify(DEFAULT_CONFIG, null, 2),
        'utf8'
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

__dirname here is dist-ts/, so bundledConfig resolves to the project root, which is exactly where the files entry would have put it. The two halves agree with each other perfectly. They are both describing a thing that stopped being true.

In practice fs.existsSync(bundledConfig) has returned false on every first run of every build we have ever shipped, and the else branch writes a two-field default:

const DEFAULT_CONFIG = {
  email: { to: '' },
  sites: [] as Array<{ name: string; url: string; enabled: boolean }>,
};
Enter fullscreen mode Exit fullscreen mode

I am not deleting the if. A dead branch that writes a correct default when the thing it looks for is missing is not a bug, it is a seam, and if we ever want to ship a build preloaded with a search for one of the fifteen sites we support the seam is already cut. But it should say so, because right now it reads as live code.

The script that points at a deleted folder

"server": "tsx src/main/server.ts",
Enter fullscreen mode Exit fullscreen mode

There is no src/main. There has not been for a long time. The file is src/server.ts, and the five scripts that actually matter reach it through tsc, not tsx.

This one is different from the globs, because it is not a glob: it is a path to a specific file, typed as a string, inside a JSON file, in a language with a compiler that checks paths for a living. TypeScript checks every import in the project and refuses to build if one is wrong. It does not read package.json scripts, because why would it. So the one place in the repo where a stale path produces no error at all is the place where paths are written as plain strings.

The failure mode is the mildest possible one: somebody types pnpm server, gets ENOENT, and figures it out in ten seconds. Which is precisely why it survived. Nothing that fails loudly on the first attempt ever gets prioritised.

The dependency nothing imports

"sharp": "0.35.3",
Enter fullscreen mode Exit fullscreen mode

In devDependencies. A grep for sharp across src/ and scripts/ returns one hit, and it is that line.

What makes this one funny rather than just untidy is that the same package.json goes to some trouble to keep it out of the build:

"!node_modules/.pnpm/sharp*/**/*",
"!node_modules/.pnpm/@img*/**/*",
Enter fullscreen mode Exit fullscreen mode

Two of the thirty-three exclude globs exist to prevent a library from being packaged that nothing imports. @img is sharp's platform binary namespace, and those prebuilt binaries are large enough that excluding them matters, which is presumably why somebody added the rule rather than removing the dependency. The rule is doing real work: without it, node_modules/**/* would sweep sharp's native binaries into an installer that is already about 600MB. It is just doing real work on a problem that pnpm remove sharp would also have solved.

What I actually changed

Nothing yet, and that is deliberate. Four dead names, and the correct response to each is different:

Thing Risk if wrong Action
config.json in files None. Matches nothing. Keep, with the seam documented
data, browser-data in asarUnpack None. Unpacks nothing. Delete
pnpm server Ten seconds of confusion Fix the path
sharp Two exclude globs defending against it Remove, then remove the globs

The thing I want to remember from this is not the list. It is that package.json is the only file in the project where a wrong path is silent, and it is also the file that decides what gets into the installer a user downloads. Those two facts belong next to each other.

The app is at notifio.app if you want to see what the 600MB ends up doing.

Top comments (0)