fix(electron): stage standalone bundle for desktop builds

Prepare a dedicated `.next/electron-standalone` bundle before
running electron-builder so desktop packaging operates on a stable,
Electron-specific server payload.

This also adds a preflight that rejects standalone bundles whose
top-level `node_modules` is a symlink, because electron-builder
preserves `extraResources` symlinks and would otherwise ship an app
that depends on the build machine at runtime.
This commit is contained in:
Kfir Amar
2026-03-15 12:26:23 +02:00
parent 36856b18db
commit 22f9e6f4c0
2 changed files with 149 additions and 18 deletions
+13 -18
View File
@@ -12,13 +12,14 @@
"scripts": {
"start": "electron .",
"dev": "electron . --no-sandbox",
"build": "electron-builder",
"build:win": "electron-builder --win",
"build:mac": "electron-builder --mac",
"build:mac-x64": "electron-builder --mac --x64",
"build:mac-arm64": "electron-builder --mac --arm64",
"build:linux": "electron-builder --linux",
"pack": "electron-builder --dir"
"prepare:bundle": "node ../scripts/prepare-electron-standalone.mjs",
"build": "npm run prepare:bundle && electron-builder",
"build:win": "npm run prepare:bundle && electron-builder --win",
"build:mac": "npm run prepare:bundle && electron-builder --mac",
"build:mac-x64": "npm run prepare:bundle && electron-builder --mac --x64",
"build:mac-arm64": "npm run prepare:bundle && electron-builder --mac --arm64",
"build:linux": "npm run prepare:bundle && electron-builder --linux",
"pack": "npm run prepare:bundle && electron-builder --dir"
},
"dependencies": {
"electron-updater": "^6.8.3"
@@ -47,24 +48,18 @@
],
"extraResources": [
{
"from": "../.next/standalone",
"from": "../.next/electron-standalone",
"to": "app",
"filter": [
"**/*"
]
},
{
"from": "../.next/static",
"to": "app/.next/static",
"from": "assets",
"to": "assets",
"filter": [
"**/*"
]
},
{
"from": "../public",
"to": "app/public",
"filter": [
"**/*"
"icon.png",
"tray-icon.png"
]
}
],
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..");
const STANDALONE_DIR = join(ROOT, ".next", "standalone");
const ELECTRON_STANDALONE_DIR = join(ROOT, ".next", "electron-standalone");
const STATIC_SRC = join(ROOT, ".next", "static");
const STATIC_DEST = join(ELECTRON_STANDALONE_DIR, ".next", "static");
const PUBLIC_SRC = join(ROOT, "public");
const PUBLIC_DEST = join(ELECTRON_STANDALONE_DIR, "public");
function resolveStandaloneBundleDir() {
const directServer = join(STANDALONE_DIR, "server.js");
if (existsSync(directServer)) {
return STANDALONE_DIR;
}
const nestedCandidates = [
join(STANDALONE_DIR, "projects", "OmniRoute"),
join(STANDALONE_DIR, basename(ROOT)),
];
for (const candidate of nestedCandidates) {
if (existsSync(join(candidate, "server.js"))) {
return candidate;
}
}
throw new Error(
`Standalone server bundle not found in ${STANDALONE_DIR}. Run \`npm run build\` first.`
);
}
function basename(filePath) {
return filePath.split(/[\\/]/).filter(Boolean).at(-1) || filePath;
}
function sanitizeBuildPaths(bundleDir) {
const buildRoot = ROOT.replace(/\\/g, "/");
const bundleRoot = bundleDir.replace(/\\/g, "/");
const replacements = [buildRoot, bundleRoot];
const targets = [
join(ELECTRON_STANDALONE_DIR, "server.js"),
join(ELECTRON_STANDALONE_DIR, ".next", "required-server-files.json"),
];
for (const filePath of targets) {
if (!existsSync(filePath)) continue;
let content = readFileSync(filePath, "utf8");
let updated = content;
for (const original of replacements) {
const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
updated = updated.replace(new RegExp(escaped, "g"), ".");
}
if (updated !== content) {
writeFileSync(filePath, updated, "utf8");
}
}
}
function ensurePackage(pkgPath, sourcePath) {
if (existsSync(pkgPath) || !existsSync(sourcePath)) return;
mkdirSync(dirname(pkgPath), { recursive: true });
cpSync(sourcePath, pkgPath, { recursive: true, dereference: true });
}
function assertBundleIsPackagable(bundleDir) {
const nodeModulesPath = join(bundleDir, "node_modules");
if (!existsSync(nodeModulesPath)) return;
if (lstatSync(nodeModulesPath).isSymbolicLink()) {
throw new Error(
[
"Next standalone emitted app/node_modules as a symlink.",
"electron-builder preserves extraResources symlinks, which would make the packaged app",
"depend on the original build machine path at runtime.",
"",
`Offending path: ${nodeModulesPath}`,
"Use a real node_modules directory in the build worktree before packaging Electron.",
].join("\n")
);
}
}
const bundleDir = resolveStandaloneBundleDir();
assertBundleIsPackagable(bundleDir);
rmSync(ELECTRON_STANDALONE_DIR, { recursive: true, force: true });
mkdirSync(ELECTRON_STANDALONE_DIR, { recursive: true });
cpSync(bundleDir, ELECTRON_STANDALONE_DIR, {
recursive: true,
dereference: true,
});
sanitizeBuildPaths(bundleDir);
if (existsSync(STATIC_SRC)) {
mkdirSync(dirname(STATIC_DEST), { recursive: true });
cpSync(STATIC_SRC, STATIC_DEST, { recursive: true, dereference: true });
}
if (existsSync(PUBLIC_SRC)) {
cpSync(PUBLIC_SRC, PUBLIC_DEST, { recursive: true, dereference: true });
}
ensurePackage(
join(ELECTRON_STANDALONE_DIR, "node_modules", "@swc", "helpers"),
join(ROOT, "node_modules", "@swc", "helpers")
);
ensurePackage(
join(ELECTRON_STANDALONE_DIR, "node_modules", "better-sqlite3"),
join(ROOT, "node_modules", "better-sqlite3")
);
console.log(
`[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}`
);