Skip to content

Commit 247b528

Browse files
authored
fix: shrink npm tarball by excluding sharp and pruning standalone (#136)
The published package was 20.3 MB packed / 56.4 MB unpacked / 1761 files, dominated by .next/standalone/ (55.9 MB). This cuts it to ~4 MB packed / ~19 MB unpacked / ~1340 files without changing runtime behavior. - next.config.ts: add outputFileTracingExcludes for node_modules/@img and node_modules/sharp. Image optimization is already disabled globally (images.unoptimized: true), so the 34 MB of sharp native binaries NFT was tracing in as an optional dep is dead weight. - scripts/prune-standalone.mjs: post-build pass that (a) removes @img/sharp belt-and-suspenders, (b) strips .md/.map/test dirs/changelogs from bundled node_modules, and (c) deletes over-traced project artifacts (docs/, src/, scripts/, dist/, bin/, examples/, agent configs, root markdown, build/lint/test configs, bun.lock) that Next.js NFT pulls into standalone but the runtime never reads. - package.json: append `&& node scripts/prune-standalone.mjs` to the build script so it runs after `next build` + the static copy step. Verified: dashboard still boots (node .next/standalone/server.js serves /policies and /projects with HTTP 200), 965 unit tests pass, 207 e2e tests pass, Docker clean-install smoke test succeeds.
1 parent cb3e5e3 commit 247b528

4 files changed

Lines changed: 136 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
### Fixes
99
- Stop stderr leakage from workflow policies (`require-push-before-stop`, `require-pr-before-stop`, `require-ci-green-before-stop`, etc.): git probes that are expected to sometimes fail no longer leak "fatal: Needed a single revision" or similar messages to the user's terminal (#132)
1010
- `block-read-outside-cwd` now uses `CLAUDE_PROJECT_DIR` (the stable project root) instead of the live hook `cwd`, which drifts when Claude `cd`s into a subdirectory. Reads at the project root are no longer wrongly denied after a `cd`. Falls back to `ctx.session.cwd` when that variable is unset (#134)
11+
- Shrink the npm package by excluding sharp from the Next.js standalone build (unused — image optimization is disabled) and stripping docs, tests, and sourcemaps from the bundled `node_modules`. Tarball drops from ~20 MB to under a few MB (#136)
1112

1213
## 0.0.6-beta.2 — 2026-04-21
1314

next.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ const allowedDevOrigins = process.env.FAILPROOFAI_ALLOWED_DEV_ORIGINS
1111
const nextConfig: NextConfig = {
1212
...(allowedDevOrigins ? { allowedDevOrigins } : {}),
1313
output: "standalone",
14+
outputFileTracingExcludes: {
15+
"*": [
16+
"node_modules/@img/**",
17+
"node_modules/sharp/**",
18+
],
19+
},
1420
productionBrowserSourceMaps: false,
1521
turbopack: {
1622
root: __dirname,

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"predev": "bun run build:cli && bun link",
2323
"dev": "FAILPROOFAI_TELEMETRY_DISABLED=1 bun scripts/dev.ts --port 8020",
2424
"build:cli": "bun build --target=node --format=esm --outfile=dist/cli.mjs bin/failproofai.mjs --external posthog-node && node -e \"const fs=require('fs');const c=fs.readFileSync('dist/cli.mjs','utf8');fs.writeFileSync('dist/cli.mjs',c.replace('#!/usr/bin/env bun','#!/usr/bin/env node').replace('// @bun\\n',''))\"",
25-
"build": "bun build --target=node --format=cjs --outfile=dist/index.js src/index.ts && bun run build:cli && bun --bun next build && node -e \"const {cpSync}=require('fs');cpSync('.next/static','.next/standalone/.next/static',{recursive:true});\"",
25+
"build": "bun build --target=node --format=cjs --outfile=dist/index.js src/index.ts && bun run build:cli && bun --bun next build && node -e \"const {cpSync}=require('fs');cpSync('.next/static','.next/standalone/.next/static',{recursive:true});\" && node scripts/prune-standalone.mjs",
2626
"prestart": "bun run build:cli && bun link",
2727
"start": "FAILPROOFAI_TELEMETRY_DISABLED=1 bun scripts/start.ts",
2828
"test": "vitest",

scripts/prune-standalone.mjs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env node
2+
// Prune .next/standalone to shrink the published npm tarball.
3+
// Safe because (a) images.unoptimized: true in next.config.ts, so sharp never loads,
4+
// and (b) we only touch .next/standalone/node_modules — server.js and app code untouched.
5+
6+
import { readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
7+
import { dirname, join, resolve } from "node:path";
8+
import { fileURLToPath } from "node:url";
9+
10+
const __dirname = dirname(fileURLToPath(import.meta.url));
11+
const ROOT = resolve(__dirname, "..");
12+
const STANDALONE = join(ROOT, ".next", "standalone");
13+
const NM = join(STANDALONE, "node_modules");
14+
15+
function exists(p) {
16+
try { statSync(p); return true; } catch { return false; }
17+
}
18+
19+
function measure(dir) {
20+
let bytes = 0, files = 0;
21+
function walk(p) {
22+
let entries;
23+
try { entries = readdirSync(p, { withFileTypes: true }); } catch { return; }
24+
for (const e of entries) {
25+
const child = join(p, e.name);
26+
if (e.isDirectory()) walk(child);
27+
else if (e.isFile()) { bytes += statSync(child).size; files += 1; }
28+
}
29+
}
30+
walk(dir);
31+
return { bytes, files };
32+
}
33+
34+
const JUNK_DIRS = new Set([
35+
"test", "tests", "__tests__",
36+
"doc", "docs",
37+
"example", "examples",
38+
".github", ".vscode", ".idea",
39+
]);
40+
41+
const JUNK_FILE_BASENAMES = new Set([
42+
".npmignore", ".gitignore", ".gitattributes",
43+
".eslintrc", ".eslintrc.js", ".eslintrc.json", ".eslintrc.yml",
44+
".prettierrc", ".prettierrc.js", ".prettierrc.json",
45+
".editorconfig", ".travis.yml", ".nycrc",
46+
"AUTHORS", "CONTRIBUTORS", "HISTORY", "HISTORY.md",
47+
"CHANGELOG", "CHANGELOG.md", "CHANGES", "CHANGES.md",
48+
]);
49+
50+
function isJunkFile(name) {
51+
const lower = name.toLowerCase();
52+
if (JUNK_FILE_BASENAMES.has(name) || JUNK_FILE_BASENAMES.has(lower)) return true;
53+
if (lower.endsWith(".md") || lower.endsWith(".markdown")) return true;
54+
if (lower.endsWith(".map")) return true;
55+
if (lower.endsWith(".ts.map")) return true;
56+
return false;
57+
}
58+
59+
function prune(dir) {
60+
let entries;
61+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
62+
for (const e of entries) {
63+
const p = join(dir, e.name);
64+
if (e.isDirectory()) {
65+
if (JUNK_DIRS.has(e.name)) {
66+
rmSync(p, { recursive: true, force: true });
67+
continue;
68+
}
69+
prune(p);
70+
} else if (e.isFile()) {
71+
if (isJunkFile(e.name)) {
72+
try { unlinkSync(p); } catch { /* ignore */ }
73+
}
74+
}
75+
}
76+
}
77+
78+
if (!exists(STANDALONE)) {
79+
console.error(`[prune-standalone] ${STANDALONE} does not exist — did you run \`next build\`?`);
80+
process.exit(1);
81+
}
82+
83+
const before = measure(STANDALONE);
84+
85+
// 1. Drop sharp — image optimization is disabled globally (next.config.ts).
86+
for (const pkg of ["@img", "sharp"]) {
87+
rmSync(join(NM, pkg), { recursive: true, force: true });
88+
}
89+
90+
// 2. Strip docs / tests / sourcemaps from remaining node_modules.
91+
if (exists(NM)) prune(NM);
92+
93+
// 3. Remove over-traced project artifacts from the standalone root.
94+
// Next.js NFT pulls in too much (tracked warning: "whole project was traced
95+
// unintentionally"). The Next server only actually needs server.js, .next/,
96+
// node_modules/, package.json, public/, and the compiled app code — the rest
97+
// is source/dev/docs that the runtime never reads.
98+
const STANDALONE_ROOT_PRUNE = [
99+
// Doc / dev directories
100+
"docs", "examples", "design-docs", "__tests__",
101+
".claude", ".failproofai", ".github", ".vscode", ".idea",
102+
// Failproofai CLI artifacts — the dashboard never loads these
103+
"bin", "dist", "scripts", "src",
104+
];
105+
const STANDALONE_ROOT_PRUNE_FILES = [
106+
// Top-level markdown / licenses / docs
107+
"README.md", "CHANGELOG.md", "CLAUDE.md", "AGENTS.md", "CONTRIBUTING.md",
108+
"LICENSE", "Dockerfile.docs",
109+
// Build / lint / test config (applied at build time, not runtime)
110+
"tsconfig.json", "eslint.config.mjs", "tailwind.config.ts", "components.json",
111+
"vitest.config.mts", "vitest.config.e2e.mts",
112+
// Lockfiles
113+
"bun.lock", "bun.lockb", "package-lock.json", "yarn.lock",
114+
];
115+
for (const d of STANDALONE_ROOT_PRUNE) {
116+
rmSync(join(STANDALONE, d), { recursive: true, force: true });
117+
}
118+
for (const f of STANDALONE_ROOT_PRUNE_FILES) {
119+
try { unlinkSync(join(STANDALONE, f)); } catch { /* ignore */ }
120+
}
121+
122+
const after = measure(STANDALONE);
123+
const mb = (b) => (b / (1024 * 1024)).toFixed(2);
124+
console.log(
125+
`[prune-standalone] ${before.files} files / ${mb(before.bytes)} MB -> ` +
126+
`${after.files} files / ${mb(after.bytes)} MB ` +
127+
`(saved ${before.files - after.files} files / ${mb(before.bytes - after.bytes)} MB)`
128+
);

0 commit comments

Comments
 (0)