Reduce App package sizes (#453)

## Summary
- Trim Electron app packaging to only include runtime shell assets and
remove unused workspace runtime dependencies.
- Copy server resources explicitly, filtering stale build artifacts and
pruning known non-runtime dependency files.
- Bundle only the Node executable instead of the full Node distribution.

## Validation
- Built macOS Electron artifacts with `npm run build:mac --workspace
@neuralnomads/codenomad-electron-app`.
- Ran packaged server CLI smoke test: `dist/bin.js --version` returned
`0.16.0`.
- Ran packaged dependency import smoke test for pruned runtime
dependencies.

## Size Impact
- macOS x64 zip: 161M
- macOS arm64 zip: 161M
- macOS arm64 app bundle: 435M
- Packaged server node_modules: 25M
This commit is contained in:
Shantur Rathore 2026-05-15 19:13:13 +01:00 committed by GitHub
parent f019fcc2b7
commit 3ccdd36ad8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 387 additions and 135 deletions

2
package-lock.json generated
View file

@ -13409,8 +13409,6 @@
"version": "0.16.0",
"license": "MIT",
"dependencies": {
"@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server",
"yaml": "^2.4.2"
},
"devDependencies": {

View file

@ -80,9 +80,9 @@ export default defineConfig({
port: 3000,
},
build: {
minify: false,
cssMinify: false,
sourcemap: true,
minify: true,
cssMinify: true,
sourcemap: false,
outDir: resolve(__dirname, "dist/renderer"),
rollupOptions: {
input: {

View file

@ -37,11 +37,10 @@
"build:all": "node scripts/build.js all",
"package:mac": "node scripts/build.js mac",
"package:win": "node scripts/build.js win",
"package:linux": "node scripts/build.js linux"
"package:linux": "node scripts/build.js linux",
"smoke:resources": "node ../../scripts/smoke-packaged-resources.cjs --resources electron/resources --loading dist/renderer"
},
"dependencies": {
"@neuralnomads/codenomad": "file:../server",
"@codenomad/ui": "file:../ui",
"yaml": "^2.4.2"
},
"devDependencies": {
@ -66,7 +65,10 @@
"buildResources": "electron/resources"
},
"files": [
"dist/**/*",
"dist/main/**/*",
"dist/preload/**/*",
"dist/renderer/loading.html",
"dist/renderer/assets/**/*",
"package.json"
],
"extraResources": [

View file

@ -141,6 +141,20 @@ async function build(platform) {
env: { NODE_PATH: workspaceNodeModulesPath, CODENOMAD_NODE_TARGET: job.nodeTarget },
})
console.log(`\n🔎 Validating resources for ${job.nodeTarget}...\n`)
await run(process.execPath, [
join(workspaceRoot, "scripts", "smoke-packaged-resources.cjs"),
"--resources",
join(appDir, "electron", "resources"),
"--loading",
join(appDir, "dist", "renderer"),
"--target",
job.nodeTarget,
], {
cwd: workspaceRoot,
shell: false,
})
console.log(`\n📦 Packaging ${job.nodeTarget}...\n`)
await run(npxCmd, ["electron-builder", "--publish=never", ...job.args], {
env: { CODENOMAD_NODE_TARGET: job.nodeTarget },

View file

@ -16,8 +16,8 @@ const serverDest = join(resourcesRoot, "server")
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
const { prepareBundledNodeRuntime } = require(join(workspaceRoot, "scripts", "prepare-node-runtime.cjs"))
const { copyPackagedServerResources } = require(join(workspaceRoot, "scripts", "desktop-server-resources.cjs"))
const serverSources = ["dist", "public", "node_modules", "package.json"]
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
function log(message) {
@ -68,65 +68,10 @@ function ensureServerDependencies() {
}
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
for (const name of serverSources) {
const from = join(serverRoot, name)
const to = join(serverDest, name)
if (!fs.existsSync(from)) {
throw new Error(`Missing required server artifact: ${from}`)
}
fs.cpSync(from, to, { recursive: true, dereference: true })
log(`copied ${name} to Electron resources`)
}
}
function stripNodeModuleBins() {
const root = join(serverDest, "node_modules")
if (!fs.existsSync(root)) {
return
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = join(current, entry.name)
if (entry.name === ".bin") {
fs.rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
if (removed > 0) {
log(`removed ${removed} node_modules/.bin directories`)
}
}
async function main() {
ensureServerBuild()
ensureServerDependencies()
copyServerArtifacts()
stripNodeModuleBins()
copyPackagedServerResources({ serverRoot, serverDest, log })
await prepareBundledNodeRuntime({ resourcesRoot })
}

View file

@ -11,7 +11,8 @@
"sync:version": "node ./scripts/sync-tauri-version.js",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild",
"build": "tauri build"
"build": "tauri build",
"smoke:resources": "node ../../scripts/smoke-packaged-resources.cjs --resources src-tauri/resources --loading src-tauri/resources/ui-loading"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"

View file

@ -13,8 +13,7 @@ const serverDest = path.resolve(root, "src-tauri", "resources", "server")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
const resourcesRoot = path.resolve(root, "src-tauri", "resources")
const { prepareBundledNodeRuntime } = require(path.join(workspaceRoot, "scripts", "prepare-node-runtime.cjs"))
const sources = ["dist", "public", "node_modules", "package.json"]
const { copyPackagedServerResources } = require(path.join(workspaceRoot, "scripts", "desktop-server-resources.cjs"))
const serverInstallCommand =
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
@ -248,60 +247,6 @@ function ensureEsbuildPlatformBinary() {
})
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
for (const name of sources) {
const from = path.join(serverRoot, name)
const to = path.join(serverDest, name)
if (!fs.existsSync(from)) {
console.warn(`[prebuild] skipped missing ${from}`)
continue
}
fs.cpSync(from, to, { recursive: true, dereference: true })
console.log(`[prebuild] copied ${from} -> ${to}`)
}
}
function stripNodeModuleBins() {
const root = path.join(serverDest, "node_modules")
if (!fs.existsSync(root)) {
return
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.name === ".bin") {
fs.rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
if (removed > 0) {
console.log(`[prebuild] removed ${removed} node_modules/.bin directories`)
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
@ -332,10 +277,20 @@ function copyUiLoadingAssets() {
ensureServerDependencies()
ensureUiBuild()
syncServerUiBundle()
copyServerArtifacts()
stripNodeModuleBins()
copyPackagedServerResources({
serverRoot,
serverDest,
log: (message) => console.log(`[prebuild] ${message}`),
})
copyUiLoadingAssets()
await prepareBundledNodeRuntime({ resourcesRoot })
execSync(
`${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(workspaceRoot, "scripts", "smoke-packaged-resources.cjs"))} --resources ${JSON.stringify(resourcesRoot)} --loading ${JSON.stringify(uiLoadingDest)}`,
{
cwd: workspaceRoot,
stdio: "inherit",
},
)
})().catch((err) => {
console.error("[prebuild] failed:", err)
process.exit(1)

View file

@ -0,0 +1,212 @@
const fs = require("fs")
const path = require("path")
const excludedDistRoots = new Set(["codenomad-server", "opencode-config", "opencode-config-template", "opencode-config.js"])
function copyPackagedServerResources(options) {
const { serverRoot, serverDest, log = () => {} } = options
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
copyRequiredArtifact(serverRoot, serverDest, "package.json", log)
copyRequiredArtifact(serverRoot, serverDest, "public", log)
copyRequiredArtifact(serverRoot, serverDest, "node_modules", log)
copyServerDist(serverRoot, serverDest, log)
stripNodeModuleBins(path.join(serverDest, "node_modules"), log)
pruneKnownServerDependencies(path.join(serverDest, "node_modules"), log)
}
function copyRequiredArtifact(serverRoot, serverDest, name, log) {
const from = path.join(serverRoot, name)
const to = path.join(serverDest, name)
if (!fs.existsSync(from)) {
throw new Error(`Missing required server artifact: ${from}`)
}
fs.cpSync(from, to, { recursive: true, dereference: true })
log(`copied ${name}`)
}
function copyServerDist(serverRoot, serverDest, log) {
const from = path.join(serverRoot, "dist")
const to = path.join(serverDest, "dist")
if (!fs.existsSync(from)) {
throw new Error(`Missing required server artifact: ${from}`)
}
fs.cpSync(from, to, {
recursive: true,
dereference: true,
filter(source) {
const relative = path.relative(from, source)
if (!relative) return true
const [root] = relative.split(path.sep)
if (excludedDistRoots.has(root)) return false
return !/\.test\.js$/.test(path.basename(relative))
},
})
log("copied filtered dist")
}
function stripNodeModuleBins(root, log) {
if (!fs.existsSync(root)) return
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.name === ".bin") {
fs.rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
if (removed > 0) {
log(`removed ${removed} node_modules/.bin directories`)
}
}
function removeIfExists(target) {
if (!fs.existsSync(target)) return 0
fs.rmSync(target, { recursive: true, force: true })
return 1
}
function removeFilesMatching(root, patterns) {
if (!fs.existsSync(root)) return 0
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.isDirectory()) {
stack.push(full)
continue
}
if (entry.isFile() && patterns.some((pattern) => pattern.test(entry.name))) {
fs.rmSync(full, { force: true })
removed += 1
}
}
}
return removed
}
function prunePackage(root, options) {
if (!fs.existsSync(root)) return 0
let removed = 0
for (const relativePath of options.remove ?? []) {
removed += removeIfExists(path.join(root, relativePath))
}
if (options.filePatterns?.length) {
removed += removeFilesMatching(root, options.filePatterns)
}
return removed
}
function pruneKnownServerDependencies(root, log) {
if (!fs.existsSync(root)) return
let removed = 0
const declarationAndMaps = [/\.d\.[cm]?ts$/, /\.map$/]
const packageDocs = [/\.md$/i, /\.markdown$/i]
removed += prunePackage(path.join(root, "openai"), {
remove: ["CHANGELOG.md", "README.md", "bin", "src"],
filePatterns: [...declarationAndMaps],
})
removed += prunePackage(path.join(root, "fastify"), {
remove: ["docs", "examples", "integration", "test", "types", "build", "fastify.d.ts"],
filePatterns: [...packageDocs],
})
removed += prunePackage(path.join(root, "@fastify", "cors"), {
remove: ["bench.js", "benchmark", "test", "types"],
filePatterns: [...packageDocs],
})
removed += prunePackage(path.join(root, "@fastify", "reply-from"), {
remove: ["examples", "test", "types"],
filePatterns: [...packageDocs],
})
removed += prunePackage(path.join(root, "@fastify", "static"), {
remove: ["example", "test", "types", "tsconfig.eslint.json"],
filePatterns: [...packageDocs],
})
removed += prunePackage(path.join(root, "pino"), {
remove: [
"benchmarks",
"browser.js",
"build",
"docs",
"docsify",
"examples",
"favicon-16x16.png",
"favicon-32x32.png",
"favicon.ico",
"index.html",
"pino-banner.png",
"pino-logo-hire.png",
"pino-tree.png",
"pino.d.ts",
"pretty-demo.png",
"test",
"tsconfig.json",
],
filePatterns: [...packageDocs],
})
removed += prunePackage(path.join(root, "undici"), {
remove: ["docs", "index.d.ts", "scripts", "types"],
filePatterns: [...packageDocs],
})
removed += prunePackage(path.join(root, "zod"), {
remove: ["README.md", "src"],
filePatterns: [...declarationAndMaps],
})
removed += prunePackage(path.join(root, "yaml"), {
remove: ["README.md", "bin.mjs", "browser"],
filePatterns: [...declarationAndMaps],
})
removed += prunePackage(path.join(root, "node-forge"), {
remove: ["README.md", "flash"],
})
if (removed > 0) {
log(`removed ${removed} known non-runtime files/directories from server dependencies`)
}
}
module.exports = {
copyPackagedServerResources,
}

View file

@ -127,15 +127,13 @@ function extractArchive(archivePath, destination) {
run("tar", ["-xzf", archivePath, "-C", destination])
}
function pruneForRuntime(sourceRoot, destinationRoot) {
fs.cpSync(sourceRoot, destinationRoot, { recursive: true, dereference: true })
for (const name of ["CHANGELOG.md", "LICENSE", "README.md", "corepack", "npm", "npx"]) {
fs.rmSync(path.join(destinationRoot, "bin", name), { recursive: true, force: true })
}
fs.rmSync(path.join(destinationRoot, "lib", "node_modules", "npm"), { recursive: true, force: true })
fs.rmSync(path.join(destinationRoot, "lib", "node_modules", "corepack"), { recursive: true, force: true })
fs.rmSync(path.join(destinationRoot, "node_modules", "npm"), { recursive: true, force: true })
fs.rmSync(path.join(destinationRoot, "node_modules", "corepack"), { recursive: true, force: true })
function pruneForRuntime(sourceRoot, destinationRoot, binaryRelativePath) {
const sourceBinary = path.join(sourceRoot, binaryRelativePath)
const destinationBinary = path.join(destinationRoot, binaryRelativePath)
fs.rmSync(destinationRoot, { recursive: true, force: true })
fs.mkdirSync(path.dirname(destinationBinary), { recursive: true })
fs.copyFileSync(sourceBinary, destinationBinary)
}
async function prepareBundledNodeRuntime(options) {
@ -175,7 +173,7 @@ async function prepareBundledNodeRuntime(options) {
throw new Error(`Node binary missing after extraction: ${path.join(extractedRoot, spec.binary)}`)
}
pruneForRuntime(extractedRoot, runtimeRoot)
pruneForRuntime(extractedRoot, runtimeRoot, spec.binary)
if (!target.startsWith("win32-")) {
fs.chmodSync(runtimeBinary, 0o755)
}

View file

@ -0,0 +1,127 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { spawnSync } = require("child_process")
const requiredPackages = [
"yaml",
"fastify",
"@fastify/static",
"@fastify/cors",
"@fastify/reply-from",
"openai",
"pino",
"undici",
"zod",
"node-forge",
]
function parseArgs(argv) {
const options = {}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === "--resources") options.resources = argv[++index]
else if (arg === "--loading") options.loading = argv[++index]
else if (arg === "--target") options.target = argv[++index]
else throw new Error(`Unknown argument: ${arg}`)
}
if (!options.resources) throw new Error("Missing --resources <path>")
return options
}
function platformDirName() {
const platform = process.platform === "darwin" ? "darwin" : process.platform
const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch
return `${platform}-${arch}`
}
function targetParts(target) {
const [platform, ...archParts] = target.split("-")
const arch = archParts.join("-")
if (!platform || !arch) throw new Error(`Invalid target: ${target}`)
return { platform, arch }
}
function nodeBinary(resourcesRoot, target) {
const { platform } = targetParts(target)
const executable = platform === "win32" ? "node.exe" : path.join("bin", "node")
return path.join(resourcesRoot, "node", target, executable)
}
function canExecuteTarget(target) {
return target === platformDirName()
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, { stdio: "inherit", ...options })
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(`${command} exited with code ${result.status ?? 1}`)
}
}
function smokeServer(resourcesRoot, target) {
const serverRoot = path.join(resourcesRoot, "server")
const entrypoint = path.join(serverRoot, "dist", "bin.js")
const node = nodeBinary(resourcesRoot, target)
for (const requiredPath of [node, entrypoint, path.join(serverRoot, "node_modules")]) {
if (!fs.existsSync(requiredPath)) throw new Error(`Missing packaged runtime path: ${requiredPath}`)
}
for (const packageName of requiredPackages) {
const packageRoot = path.join(serverRoot, "node_modules", ...packageName.split("/"))
if (!fs.existsSync(packageRoot)) throw new Error(`Missing packaged dependency: ${packageName}`)
}
console.log(`packaged server static checks ok for ${target}`)
if (!canExecuteTarget(target)) {
console.log(`skipping executable smoke for ${target} on host ${platformDirName()}`)
return
}
run(node, [entrypoint, "--version"])
const requireScript = [
"import { createRequire } from 'module';",
"import path from 'path';",
`const root = ${JSON.stringify(serverRoot)};`,
"const req = createRequire(path.join(root, 'dist/bin.js'));",
`${JSON.stringify(requiredPackages)}.forEach((name) => req(name));`,
"console.log('packaged dependency imports ok');",
].join(" ")
run(node, ["--input-type=module", "-e", requireScript])
}
function smokeLoadingAssets(loadingRoot) {
if (!loadingRoot) return
const htmlPath = path.join(loadingRoot, "loading.html")
if (!fs.existsSync(htmlPath)) throw new Error(`Missing loading HTML: ${htmlPath}`)
const html = fs.readFileSync(htmlPath, "utf8")
const refs = [...html.matchAll(/(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1])
for (const ref of refs) {
if (/^(?:https?:)?\/\//.test(ref)) continue
if (!ref.startsWith("/assets/") && !ref.startsWith("assets/")) continue
const relative = ref.replace(/^\//, "")
const target = path.join(loadingRoot, relative)
if (!fs.existsSync(target)) throw new Error(`Missing loading asset referenced by ${htmlPath}: ${ref}`)
}
console.log("loading asset references ok")
}
try {
const options = parseArgs(process.argv.slice(2))
const resourcesRoot = path.resolve(options.resources)
const target = options.target ?? platformDirName()
smokeServer(resourcesRoot, target)
smokeLoadingAssets(options.loading ? path.resolve(options.loading) : null)
} catch (error) {
console.error("[smoke-packaged-resources] failed:", error)
process.exit(1)
}