fix(desktop): fail loudly instead of blank-paging when the renderer bundle is missing (#41729)

A packaged desktop app launches to a blank page with a bare
ERR_FILE_NOT_FOUND when dist/index.html isn't in the bundle (#39484).
This happens when the build step fails (e.g. a stale checkout that
fails typecheck) but electron-builder packages anyway, shipping an
empty dist/.

- build-time: scripts/assert-dist-built.cjs runs at the tail of the
  `build` script and aborts before electron-builder if dist/index.html
  or the vite JS bundle is missing/empty. Every packaging path
  (pack, dist*) inherits it via `npm run build &&`.
- runtime: resolveRendererIndex() now logs a clear 'packaged without a
  renderer bundle — rebuild with hermes desktop --force-build' message
  when no index.html exists, instead of silently loading a missing path.
- runtime: resolveWebDist() logs when it falls back to an asar-internal
  dist that isn't a real directory (the dashboard 404 class, #41327/#39472),
  rather than returning an unservable path silently.

Adds scripts/assert-dist-built.test.cjs (node:test) covering the guard.
This commit is contained in:
Teknium 2026-06-07 22:04:39 -07:00 committed by GitHub
parent 53a2ac8f2d
commit bddc5fd087
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 181 additions and 3 deletions

View file

@ -1902,12 +1902,36 @@ function resolveWebDist() {
const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist')
if (directoryExists(unpackedDist)) return unpackedDist
return path.join(APP_ROOT, 'dist')
// Final fallback: APP_ROOT/dist. When packaged with asar:true this lives
// INSIDE app.asar — not a servable filesystem directory — so the embedded
// dashboard backend 404s on static routes (see #41327, #39472). The durable
// fix is unpacking dist/ (PR #41411 adds dist/** to asarUnpack so the tier-2
// unpackedDist above resolves). If we still land here while packaged, log it
// so the cause isn't silent.
const fallback = path.join(APP_ROOT, 'dist')
if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) {
rememberLog(
`[web-dist] dashboard frontend dir resolved to an asar-internal path that ` +
`is not a real directory: ${fallback}. Static routes will 404. ` +
`Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.`
)
}
return fallback
}
function resolveRendererIndex() {
const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')]
return candidates.find(fileExists) || candidates[0]
const found = candidates.find(fileExists)
if (found) return found
// Nothing on disk. A packaged build with no renderer bundle blank-pages with
// a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause
// and the fix before Electron loads the missing file.
rememberLog(
`[renderer] index.html not found — the desktop app was packaged without a ` +
`renderer bundle. Tried: ${candidates.join(', ')}. ` +
`Rebuild with: hermes desktop --force-build`
)
return candidates[0]
}
function resolveHermesCwd() {

View file

@ -18,7 +18,7 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",

View file

@ -0,0 +1,70 @@
"use strict"
// Build-time guard: refuse to hand a half-built renderer to electron-builder.
//
// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`.
// If the `build` step (tsc -b && vite build) fails but packaging proceeds
// anyway — a stale checkout that fails typecheck, an interrupted vite build,
// or npm not short-circuiting `&&` in some shells — electron-builder happily
// packages an app with an empty or missing `dist/`. The result launches but
// blank-pages with `ERR_FILE_NOT_FOUND` for dist/index.html, with no clue why.
//
// This runs at the tail of `build`, after vite build, so any packaging path
// inherits it. It fails loud and early instead of shipping a broken bundle.
// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404).
const fs = require("fs")
const path = require("path")
// Pure check — returns { ok: true } or { ok: false, error: "..." }.
// Kept side-effect-free so it can be unit tested without spawning a process.
function checkDistBuilt(distDir) {
if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) {
return { ok: false, error: `no dist directory at ${distDir}` }
}
const indexHtml = path.join(distDir, "index.html")
if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) {
return { ok: false, error: `dist/index.html is missing at ${indexHtml}` }
}
if (fs.statSync(indexHtml).size === 0) {
return { ok: false, error: `dist/index.html is empty at ${indexHtml}` }
}
// index.html alone isn't enough — vite emits hashed JS into dist/assets.
// An index.html with no script bundle still blank-pages.
const assetsDir = path.join(distDir, "assets")
const hasAssets =
fs.existsSync(assetsDir) &&
fs.statSync(assetsDir).isDirectory() &&
fs.readdirSync(assetsDir).some(name => name.endsWith(".js"))
if (!hasAssets) {
return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` }
}
return { ok: true }
}
function main() {
const desktopRoot = path.resolve(__dirname, "..")
const distDir = path.join(desktopRoot, "dist")
const result = checkDistBuilt(distDir)
if (!result.ok) {
console.error(`\n✗ assert-dist-built: ${result.error}`)
console.error(" The renderer bundle is missing or incomplete, so packaging")
console.error(" would produce an app that launches to a blank page.")
console.error(" Re-run the build and check the tsc/vite output above for the")
console.error(" real failure, then package again:")
console.error(` cd ${desktopRoot} && npm run build\n`)
process.exit(1)
}
console.log("✓ assert-dist-built: dist/index.html + assets present")
}
if (require.main === module) {
main()
}
module.exports = { checkDistBuilt }

View file

@ -0,0 +1,84 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs')
function makeDist(extra) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
const distDir = path.join(tempRoot, 'dist')
fs.mkdirSync(distDir, { recursive: true })
if (extra) extra(distDir)
return { tempRoot, distDir }
}
test('checkDistBuilt passes when index.html + an assets JS bundle exist', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html><div id=root></div>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
assert.deepEqual(checkDistBuilt(distDir), { ok: true })
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when the dist directory is absent', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
try {
const result = checkDistBuilt(path.join(tempRoot, 'dist'))
assert.equal(result.ok, false)
assert.match(result.error, /no dist directory/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when index.html is missing', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /index\.html is missing/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when index.html is empty', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /index\.html is empty/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when assets/ has no JS bundle', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
// CSS only, no JS — still a blank page at runtime.
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.css'), 'body{}', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /no built JS bundle/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})