Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions .github/scripts/__tests__/bundle-size.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
computeRouteMetrics,
compareReport,
clearSizeCache,
readTurbopackEntries,
} from '../bundle-size.mts';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -663,6 +664,152 @@ describe('compareReport', () => {
});
});

// ---------------------------------------------------------------------------
// readTurbopackEntries
// ---------------------------------------------------------------------------

describe('readTurbopackEntries', () => {
// Helper: create a minimal _client-reference-manifest.js fixture
function makeManifestContent(
routes: Record<string, Record<string, { chunks: string[] }>>,
): string {
const manifest: Record<string, { clientModules: Record<string, { chunks: string[] }> }> = {};

for (const [routeKey, modules] of Object.entries(routes)) {
manifest[routeKey] = { clientModules: modules };
}

return `globalThis.__RSC_MANIFEST = ${JSON.stringify(manifest)};`;
}

it('reads chunk paths from a single manifest and normalizes /_next/ prefix', () => {
const dir = join(testDir, `turbopack-basic-${Date.now()}`);

mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'page_client-reference-manifest.js'),
makeManifestContent({
'/products/page': {
'mod-a': { chunks: ['/_next/static/chunks/a.js'] },
'mod-b': { chunks: ['/_next/static/chunks/b.js'] },
},
}),
);

const entries = readTurbopackEntries(dir);

assert.ok(entries['/products/page'], 'should have /products/page entry');
assert.ok(entries['/products/page'].includes('static/chunks/a.js'), 'should normalize /_next/ prefix');
assert.ok(entries['/products/page'].includes('static/chunks/b.js'));
assert.ok(!entries['/products/page'].some((c) => c.startsWith('/_next/')), 'no chunk should start with /_next/');
});

it('filters out non-/page routes (layouts, route handlers)', () => {
const dir = join(testDir, `turbopack-filter-${Date.now()}`);

mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'page_client-reference-manifest.js'),
makeManifestContent({
'/app/layout': { 'mod-a': { chunks: ['/_next/static/chunks/layout.js'] } },
'/app/route': { 'mod-b': { chunks: ['/_next/static/chunks/route.js'] } },
'/app/page': { 'mod-c': { chunks: ['/_next/static/chunks/page.js'] } },
}),
);

const entries = readTurbopackEntries(dir);

assert.ok(entries['/app/page'], 'should include /page route');
assert.ok(!entries['/app/layout'], 'should exclude /layout route');
assert.ok(!entries['/app/route'], 'should exclude /route handler');
});

it('deduplicates chunks appearing in multiple modules', () => {
const dir = join(testDir, `turbopack-dedup-${Date.now()}`);

mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'page_client-reference-manifest.js'),
makeManifestContent({
'/shop/page': {
'mod-a': { chunks: ['/_next/static/chunks/shared.js', '/_next/static/chunks/a.js'] },
'mod-b': { chunks: ['/_next/static/chunks/shared.js', '/_next/static/chunks/b.js'] },
},
}),
);

const entries = readTurbopackEntries(dir);
const chunks = entries['/shop/page'];

assert.ok(chunks, 'should have /shop/page entry');

const sharedCount = chunks.filter((c) => c === 'static/chunks/shared.js').length;

assert.equal(sharedCount, 1, 'shared chunk should appear exactly once');
assert.equal(chunks.length, 3, 'should have 3 unique chunks');
});

it('scans subdirectories recursively', () => {
const dir = join(testDir, `turbopack-recursive-${Date.now()}`);

mkdirSync(join(dir, 'nested', 'deep'), { recursive: true });
writeFileSync(
join(dir, 'nested', 'deep', 'page_client-reference-manifest.js'),
makeManifestContent({
'/nested/deep/page': { 'mod-a': { chunks: ['/_next/static/chunks/deep.js'] } },
}),
);

const entries = readTurbopackEntries(dir);

assert.ok(entries['/nested/deep/page'], 'should find manifest in nested directory');
});

it('skips malformed manifest files gracefully', () => {
const dir = join(testDir, `turbopack-malformed-${Date.now()}`);

mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'bad_client-reference-manifest.js'), 'this is not valid JS {{{');
writeFileSync(
join(dir, 'good_client-reference-manifest.js'),
makeManifestContent({
'/valid/page': { 'mod-a': { chunks: ['/_next/static/chunks/valid.js'] } },
}),
);

// Should not throw, and should still return valid entries
assert.doesNotThrow(() => readTurbopackEntries(dir));

const entries = readTurbopackEntries(dir);

assert.ok(entries['/valid/page'], 'should return valid entries even when another file is malformed');
});

it('returns empty object when no manifest files exist', () => {
const dir = join(testDir, `turbopack-empty-${Date.now()}`);

mkdirSync(dir, { recursive: true });

const entries = readTurbopackEntries(dir);

assert.deepEqual(entries, {});
});

it('returns empty object when manifests have no __RSC_MANIFEST', () => {
const dir = join(testDir, `turbopack-no-rsc-${Date.now()}`);

mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'page_client-reference-manifest.js'),
'globalThis.somethingElse = {};',
);

const entries = readTurbopackEntries(dir);

assert.deepEqual(entries, {});
});
});

// ---------------------------------------------------------------------------
// getGzipSize
// ---------------------------------------------------------------------------
Expand Down
79 changes: 72 additions & 7 deletions .github/scripts/bundle-size.mts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node
/* eslint-disable no-console, no-restricted-syntax, no-plusplus, no-continue */

import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
Expand Down Expand Up @@ -190,6 +190,59 @@ function computeRouteMetrics(
return routes;
}

function readTurbopackEntries(serverAppDir: string): Record<string, string[]> {
const entries: Record<string, string[]> = {};

function scanDir(dir: string): void {
const items = readdirSync(dir, { withFileTypes: true });

for (const item of items) {
const fullPath = join(dir, item.name);

if (item.isDirectory()) {
scanDir(fullPath);
} else if (item.name.endsWith("_client-reference-manifest.js")) {
try {
const content = readFileSync(fullPath, "utf-8");
const g: Record<string, unknown> = {};
// eslint-disable-next-line no-new-func
const fn = new Function("globalThis", "self", `${content}\nreturn globalThis;`);
const result = fn(g, g) as {
__RSC_MANIFEST?: Record<
string,
{ clientModules?: Record<string, { chunks?: string[] }> }
>;
};
const manifest = result.__RSC_MANIFEST;

if (!manifest) continue;

for (const [routeKey, entry] of Object.entries(manifest)) {
if (!routeKey.endsWith("/page")) continue;

const chunks = new Set<string>();

for (const mod of Object.values(entry.clientModules ?? {})) {
for (const chunk of mod.chunks ?? []) {
// Normalize: "/_next/static/chunks/xxx.js" → "static/chunks/xxx.js"
chunks.add(chunk.replace(/^\/_next\//, ""));
}
}

entries[routeKey] = [...chunks];
}
} catch {
// Skip malformed manifest files
}
}
}
}

scanDir(serverAppDir);

return entries;
}

function compareReport(
baseline: BundleReport,
current: BundleReport,
Expand Down Expand Up @@ -320,17 +373,18 @@ function generate(
): void {
const appManifestPath = join(nextDir, "app-build-manifest.json");
const buildManifestPath = join(nextDir, "build-manifest.json");
const serverAppDir = join(nextDir, "server", "app");

if (!existsSync(appManifestPath)) {
const isWebpack = existsSync(appManifestPath);
const isTurbopack = !isWebpack && existsSync(serverAppDir);

if (!isWebpack && !isTurbopack) {
console.error(
"Error: .next/app-build-manifest.json not found. Run `next build` first.",
"Error: No build output found (.next/app-build-manifest.json or .next/server/app/). Run `next build` first.",
);
process.exit(1);
}

const appManifest = JSON.parse(readFileSync(appManifestPath, "utf-8")) as {
pages?: Record<string, string[]>;
};
const buildManifest = JSON.parse(
readFileSync(buildManifestPath, "utf-8"),
) as {
Expand All @@ -342,7 +396,17 @@ function generate(
const polyfillFiles = new Set(buildManifest.polyfillFiles ?? []);
const sharedChunks = new Set([...rootMainFiles, ...polyfillFiles]);

const entries = appManifest.pages ?? {};
let entries: Record<string, string[]>;

if (isWebpack) {
const appManifest = JSON.parse(readFileSync(appManifestPath, "utf-8")) as {
pages?: Record<string, string[]>;
};

entries = appManifest.pages ?? {};
} else {
entries = readTurbopackEntries(serverAppDir);
}
const { layouts, pages } = parseManifestEntries(entries);

// Shared JS = sum of rootMainFiles gzipped sizes
Expand Down Expand Up @@ -440,6 +504,7 @@ export {
computeRouteMetrics,
compareReport,
clearSizeCache,
readTurbopackEntries,
};

export type { BundleReport, RouteMetric, ChunkSizes, CompareOptions };
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"build": "dotenv -e .env.local -- turbo run build",
"lint": "dotenv -e .env.local -- turbo lint",
"test": "turbo run test",
"test:scripts": "node --test .github/scripts/__tests__/*.test.mjs",
"test:scripts": "node --test .github/scripts/__tests__/*.test.mts",
"typecheck": "turbo typecheck"
},
"devDependencies": {
Expand Down
Loading