DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two directories called public in one repo, and one of them is deleted on every build

Notifio is two things in one repository: a Next.js website at server/, and an Electron desktop app at app/. Each has a directory called public. They mean opposite things, and I did not notice until I went looking for where an icon file lived and found two candidates.

The one that is a promise

server/public/ is the Next.js convention: whatever is in it is served from the root of the site, unprocessed. Six files, all committed:

b7989e6abc9208a182d57a642804601a.txt       32 bytes
icon-dark.svg                             552 bytes
icon-light.svg                            550 bytes
icon.png                                8,818 bytes
icon.svg                                  578 bytes
og-image.png                          214,951 bytes
Enter fullscreen mode Exit fullscreen mode

Every one of these is referenced by a URL somewhere, and that URL is the entire contract. /icon.png is in the page metadata and in the Organization JSON-LD. /og-image.png is what Twitter and Slack unfurl, declared in the metadata as 1200 by 630 while the file itself is 2400 by 1260, which is deliberate: the consumers downscale, and a retina unfurl is worth 200KB on a URL that is only fetched by crawlers.

The 32 byte text file is my favourite object on the whole site. It is the IndexNow key, and the protocol's design is that the file's name is the key and the file's contents are the key as well. Confirming you own a domain by putting a secret in the one directory whose purpose is being public is a slightly funny idea the first time you meet it, and it is the correct idea: the only way to put a file at that URL is to control what serves that URL. It also does not mean your submissions worked, but that is a different post.

The common thread is that I typed all six, and losing one of them would break something that is checkable from outside. This is the public/ that most people have in their heads.

The one that is a consequence

app/public/ is where Vite puts the compiled renderer:

export default defineConfig({
  plugins: [react(), tailwindcss()],
  root: "src/renderer",
  base: "./",
  build: {
    outDir: path.resolve(__dirname, "public"),
    emptyOutDir: true,
  },
  // ...
});
Enter fullscreen mode Exit fullscreen mode

The contents right now:

public/index.html                         675 bytes
public/assets/index-BVnJWuyv.js       242,426 bytes
public/assets/index-BusBxegY.css       18,988 bytes
Enter fullscreen mode Exit fullscreen mode

None of it is in git:

# Vite renderer build output, regenerated via `pnpm renderer`
app/public/
Enter fullscreen mode Exit fullscreen mode

So in one half of this repository, public/ is six hand-written files under version control that are served as they are. In the other half, public/ is three generated files, excluded from version control, and emptyOutDir: true means the directory is emptied at the start of every build.

Same word. One is the most durable directory in the project and the other is the least.

Vite has its own public/ convention too, incidentally, and it means the first thing: static assets copied through to the output untouched. Because our Vite root is src/renderer, Vite's version of public/ would be src/renderer/public/, which does not exist. So the repository contains the word public used in the Next.js sense, in the Vite-output sense, and with the Vite convention sense sitting unused right next to the second one.

Why this is a trap and not a joke

Put a logo in server/public/ and it is on the website. Put the same logo in app/public/ and it is gone the next time anybody runs pnpm renderer. No warning, no error, no diff to review, because the directory is ignored. The only trace would be the thing that stopped working.

The name is load-bearing in exactly four places:

// app/src/server.ts
app.use(express.static(path.join(__dirname, '..', 'public')));
Enter fullscreen mode Exit fullscreen mode
// app/package.json, electron-builder files
"public/**/*",
Enter fullscreen mode Exit fullscreen mode

plus the outDir above and the .gitignore line. Four references, one of which is a glob in a build config, one of which is a path assembled at runtime from __dirname. Renaming it to dist-renderer/, which is what it actually is, is a ten minute job and would make the repository honest. I have not done it, and when I ask myself why, the real answer is that express.static(..., 'public') reads so naturally that I have stopped seeing it.

That is the part worth generalising. A directory name is an interface, and the two biggest toolchains in this repo both have an opinion about this particular name. Neither tool can warn you, because from inside each one, nothing is wrong.

While I was in there: base and the missing favicon

Two small things in the same config.

base: "./",
Enter fullscreen mode Exit fullscreen mode

The renderer is served by an Express server inside the app itself, on localhost, from the root path, so absolute URLs would work fine today. Relative ones also survive the day somebody loads index.html from the filesystem instead, which is what a fallback window or a diagnostic page would do. It costs nothing and removes a class of "works in dev, blank window in the package", which in Electron is an especially unpleasant failure to debug because there is no console to look at.

And the source index.html, all nine lines of it, contains a comment longer than the tag it explains:

<!-- The window uses the app icon, so there is no favicon to serve. Pointing
     at an empty data URL stops the browser asking for one and logging a 404
     into a console that is otherwise useful for spotting real problems. -->
<link rel="icon" href="data:," />
Enter fullscreen mode Exit fullscreen mode

A desktop window has no tab, so a favicon is meaningless here. But Chromium still asks for /favicon.ico, and the Express server still answers 404, and the renderer's console is a thing I actually read while developing. data:, is a valid, empty data URL: the browser resolves it to nothing and asks nobody. One line to keep one line of noise out of a log.

That is the same instinct as the rest of this post, really. The renderer's console is a surface I use, so I keep it clean. The directory name is a surface I stopped reading, so it drifted into meaning two incompatible things. Noise you look at gets fixed. Noise you have learned to skip is the dangerous kind.

What I would take from this

If a monorepo has two frameworks in it, grep for the directory names each one treats as magic before you pick any of them for something else. public, static, assets, dist, build and out are all claimed by somebody, and the collision is silent by construction: each tool only looks inside its own half.

And if you point emptyOutDir at a directory with a conventional name, assume that one day somebody will put a file they care about in it. That person will be you, in about eight months, and you will spend an hour looking for the bug before you remember you wrote the line that deletes it.

The website half of all this is at notifio.app, with guides and a page per site under alerts. The desktop half, 242KB of compiled renderer and all, is on the download page.

Top comments (0)