mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
472 lines
16 KiB
JavaScript
472 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Measures the size of a workbench app's `/.well-known/workflow/v1/flow` route
|
|
* after that app has been built, and writes a JSON report consumed by
|
|
* render-bundle-size-comment.mjs.
|
|
*
|
|
* Two tiers are reported, because neither supported app emits an isolable
|
|
* function bundle for the flow route:
|
|
*
|
|
* Tier 1 (gated) - the bundle the workflow builders emit before the
|
|
* framework bundles it. One file per metric, deterministic, and it moves
|
|
* only when the SDK moves. This is what the CI gate compares.
|
|
*
|
|
* Tier 2 (informational) - the framework's own deployable output. For
|
|
* Next.js the built route file is a ~1 KB turbopack chunk loader, so the
|
|
* real number is the sum of the chunks it pulls in; those chunks are
|
|
* shared with other routes, so the figure over-counts what the flow route
|
|
* exclusively owns. For nitro the flow handler is inlined into the single
|
|
* server entry, so there is no per-route file at all and the whole server
|
|
* output is reported.
|
|
*
|
|
* The two tiers are NOT comparable to each other, and neither alone is the
|
|
* deployed flow function. Tier 1 is the VM bundle the route carries as an
|
|
* inline string; the code that hosts it, including the world adapter, lives in
|
|
* Tier 2. Each tier is only ever compared against its own baseline.
|
|
*
|
|
* What Tier 1 therefore does NOT cover: the world adapters. Measured on
|
|
* nextjs-turbopack, building with WORKFLOW_TARGET_WORLD=local and =vercel
|
|
* produces byte-identical reports across all three metrics, because every
|
|
* world the app depends on is bundled into the framework output regardless and
|
|
* the choice is made at runtime. A change confined to @workflow/world-vercel
|
|
* will not move the gated numbers.
|
|
*
|
|
* Every failure path here exits non-zero with the path it looked at. A
|
|
* plausible-looking wrong number (the 1 KB chunk loader, a months-old stale
|
|
* bundle) is worse than a red job, because it reports green while measuring
|
|
* nothing.
|
|
*
|
|
* Usage:
|
|
* node .github/scripts/measure-flow-bundle.mjs --app hono --out sizes.json
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import zlib from 'node:zlib';
|
|
|
|
const REPO_ROOT = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
'..'
|
|
);
|
|
|
|
/** Bump when the shape changes incompatibly; the renderer refuses older reports. */
|
|
const SCHEMA_VERSION = 1;
|
|
|
|
/**
|
|
* Build-time env that changes the measured bytes. Recorded into every report
|
|
* so the renderer can refuse to diff two reports that were not produced the
|
|
* same way. Without this, editing the workflow file silently invalidates every
|
|
* historical comparison and nothing tells you.
|
|
*
|
|
* WORKFLOW_SOURCEMAP is the one that dominates the numbers. It defaults to
|
|
* inline outside a production build, and an inline-sourcemap build of
|
|
* nextjs-turbopack measures 5.85 MB against 1.38 MB with sourcemaps off.
|
|
*
|
|
* WORKFLOW_TARGET_WORLD is pinned and fingerprinted despite having no measured
|
|
* effect today: local and vercel builds of nextjs-turbopack come out
|
|
* byte-identical. packages/next/src/index.ts does branch on it, so it is the
|
|
* kind of input that can start mattering; recording it means a build that
|
|
* changes worlds refuses to diff against an old baseline instead of silently
|
|
* reporting the difference as a code change.
|
|
*/
|
|
const FINGERPRINT_ENV = [
|
|
'WORKFLOW_TARGET_WORLD',
|
|
'WORKFLOW_SOURCEMAP',
|
|
'WORKFLOW_PUBLIC_MANIFEST',
|
|
];
|
|
|
|
/**
|
|
* Generated sidecars that sit next to the real bundles and must never be
|
|
* counted. All of these exist in a working tree today, including leftover
|
|
* `steps.tmp.<uuid>.mjs.debug.json` files from interrupted builds.
|
|
*/
|
|
const EXCLUDED_SUFFIXES = ['.debug.json', '.map'];
|
|
const EXCLUDED_PATTERNS = [/\.__wf_tmp\./, /\.tmp\.[0-9a-f-]{8,}\./];
|
|
|
|
/**
|
|
* A build stamp whose mtime marks when the framework build ran. A Tier-1
|
|
* bundle far older than this stamp means the build did not regenerate it and
|
|
* we are about to measure a stale artifact.
|
|
*/
|
|
const BUILD_STAMPS = {
|
|
'nextjs-turbopack': '.next/BUILD_ID',
|
|
hono: '.output/nitro.json',
|
|
};
|
|
|
|
/** How far a Tier-1 bundle may predate the build stamp before we call it stale. */
|
|
const STALENESS_SLACK_MS = 60 * 60 * 1000;
|
|
|
|
class MeasureError extends Error {}
|
|
|
|
function fail(message) {
|
|
throw new MeasureError(message);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = { app: null, out: null, commit: null };
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const flag = argv[i];
|
|
if (flag === '--app') args.app = argv[++i];
|
|
else if (flag === '--out') args.out = argv[++i];
|
|
else if (flag === '--commit') args.commit = argv[++i];
|
|
else fail(`Unknown argument: ${flag}`);
|
|
}
|
|
if (!args.app) fail('Missing required --app <name>');
|
|
if (!args.out) fail('Missing required --out <file>');
|
|
return args;
|
|
}
|
|
|
|
function isExcluded(filePath) {
|
|
const base = path.basename(filePath);
|
|
if (EXCLUDED_SUFFIXES.some((suffix) => base.endsWith(suffix))) return true;
|
|
return EXCLUDED_PATTERNS.some((pattern) => pattern.test(base));
|
|
}
|
|
|
|
/**
|
|
* Sizes one file. gzip uses level 9 so the number is reproducible for a given
|
|
* zlib build; the Node major version is part of the fingerprint because zlib
|
|
* ships with Node and its output can shift across releases.
|
|
*/
|
|
function measureFile(absPath, relPath) {
|
|
let buf;
|
|
try {
|
|
buf = fs.readFileSync(absPath);
|
|
} catch (error) {
|
|
fail(`Could not read ${relPath} (${absPath}): ${error.message}`);
|
|
}
|
|
return {
|
|
path: relPath,
|
|
raw: buf.byteLength,
|
|
gzip: zlib.gzipSync(buf, { level: 9 }).byteLength,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Totals a set of measured files. Per-file gzip is summed rather than gzipping
|
|
* the concatenation: it keeps each file's contribution meaningful and does not
|
|
* change with file order.
|
|
*/
|
|
function total(files, extra = {}) {
|
|
return {
|
|
raw: files.reduce((sum, f) => sum + f.raw, 0),
|
|
gzip: files.reduce((sum, f) => sum + f.gzip, 0),
|
|
files,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function measureFiles(appDir, relPaths) {
|
|
const files = [];
|
|
for (const rel of relPaths) {
|
|
if (isExcluded(rel)) continue;
|
|
const abs = path.join(appDir, rel);
|
|
files.push(measureFile(abs, path.relative(REPO_ROOT, abs)));
|
|
}
|
|
if (files.length === 0) fail(`No files to measure under ${appDir}`);
|
|
return total(files);
|
|
}
|
|
|
|
function walkFiles(dir) {
|
|
const out = [];
|
|
const stack = [dir];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop();
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
} catch (error) {
|
|
fail(`Could not read directory ${current}: ${error.message}`);
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(current, entry.name);
|
|
if (entry.isDirectory()) stack.push(full);
|
|
else if (entry.isFile() && !isExcluded(full)) out.push(full);
|
|
}
|
|
}
|
|
return out.sort();
|
|
}
|
|
|
|
/**
|
|
* Reads the per-app generated bundle paths out of scripts/create-test-matrix.mjs
|
|
* rather than restating them here. That script is the repo's single source of
|
|
* truth for where each framework adapter writes its flow bundle (it feeds the
|
|
* E2E dev-test matrix). It exports nothing and prints `{"app":[...]}` to
|
|
* stdout, so we spawn it. Entries are duplicated across the canary and VM axes
|
|
* with identical paths, so the first name match is fine.
|
|
*/
|
|
function loadMatrixEntry(app) {
|
|
let stdout;
|
|
try {
|
|
stdout = execFileSync('node', ['scripts/create-test-matrix.mjs'], {
|
|
cwd: REPO_ROOT,
|
|
encoding: 'utf8',
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
});
|
|
} catch (error) {
|
|
fail(`Could not run scripts/create-test-matrix.mjs: ${error.message}`);
|
|
}
|
|
|
|
let matrix;
|
|
try {
|
|
matrix = JSON.parse(stdout);
|
|
} catch (error) {
|
|
fail(`scripts/create-test-matrix.mjs did not emit JSON: ${error.message}`);
|
|
}
|
|
|
|
const entry = matrix.app?.find((candidate) => candidate.name === app);
|
|
if (!entry) {
|
|
const known = [...new Set((matrix.app ?? []).map((a) => a.name))].join(
|
|
', '
|
|
);
|
|
fail(`App "${app}" is not in the test matrix. Known apps: ${known}`);
|
|
}
|
|
if (!entry.generatedWorkflowPath || !entry.generatedStepRegistrationPath) {
|
|
fail(
|
|
`Test matrix entry for "${app}" is missing generatedWorkflowPath or ` +
|
|
'generatedStepRegistrationPath; this script cannot locate the flow bundle.'
|
|
);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* Guards against measuring a bundle the build did not regenerate. A working
|
|
* tree can hold a flow bundle from an older nitro layout months out of date
|
|
* next to a fresh `.output`, and it is the same size order of magnitude, so
|
|
* nothing about the number itself looks wrong.
|
|
*/
|
|
function assertFresh(app, appDir, tier1RelPaths) {
|
|
const stampRel = BUILD_STAMPS[app];
|
|
if (!stampRel) return null;
|
|
|
|
const stampAbs = path.join(appDir, stampRel);
|
|
if (!fs.existsSync(stampAbs)) {
|
|
fail(
|
|
`Build stamp ${stampRel} is missing under ${path.relative(REPO_ROOT, appDir)}. ` +
|
|
'The app does not look built; run its build before measuring.'
|
|
);
|
|
}
|
|
const stampMtime = fs.statSync(stampAbs).mtimeMs;
|
|
|
|
for (const rel of tier1RelPaths) {
|
|
const abs = path.join(appDir, rel);
|
|
if (!fs.existsSync(abs)) {
|
|
fail(
|
|
`Flow bundle ${rel} is missing under ${path.relative(REPO_ROOT, appDir)}. ` +
|
|
'Either the build failed or the prebuild step that generates the ' +
|
|
'workflow registry did not run.'
|
|
);
|
|
}
|
|
const age = stampMtime - fs.statSync(abs).mtimeMs;
|
|
if (age > STALENESS_SLACK_MS) {
|
|
fail(
|
|
`Flow bundle ${rel} is ${Math.round(age / 60000)} minutes older than the ` +
|
|
`build stamp ${stampRel}. This is a stale artifact from an earlier ` +
|
|
'build, not the one just produced. Clean the app and rebuild.'
|
|
);
|
|
}
|
|
}
|
|
return stampRel;
|
|
}
|
|
|
|
/**
|
|
* Next.js/turbopack Tier 2. The built route file is a chunk loader:
|
|
*
|
|
* var R=require("../../../../../chunks/[turbopack]_runtime.js")("server/app/.../route.js")
|
|
* R.c("server/chunks/[root-of-the-server]__00shnjs._.js")
|
|
* ... one R.c per chunk ...
|
|
* R.m(628681)
|
|
*
|
|
* `R.c` paths resolve relative to `.next/`; the runtime chunk in the leading
|
|
* `require` resolves relative to the route file. Chunk filenames are
|
|
* content-hashed, so only totals are stable - never key a baseline on a chunk
|
|
* name. The syntax is a Next internal and will break on some future upgrade,
|
|
* which is why this metric is informational and why an unparseable stub is a
|
|
* hard error rather than a 1 KB answer.
|
|
*/
|
|
function nextTurbopackTier2(appDir) {
|
|
const nextDir = path.join(appDir, '.next');
|
|
const stubRel = '.next/server/app/.well-known/workflow/v1/flow/route.js';
|
|
const stubAbs = path.join(appDir, stubRel);
|
|
|
|
if (!fs.existsSync(stubAbs)) {
|
|
fail(
|
|
`Built flow route ${stubRel} is missing. Expected next build to emit it.`
|
|
);
|
|
}
|
|
const stub = fs.readFileSync(stubAbs, 'utf8');
|
|
|
|
const chunkRels = [...stub.matchAll(/R\.c\("([^"]+)"\)/g)].map((m) => m[1]);
|
|
if (chunkRels.length === 0) {
|
|
fail(
|
|
`Could not find any R.c("...") chunk references in ${stubRel}. The ` +
|
|
'turbopack chunk-loader format has probably changed; this metric needs ' +
|
|
'updating rather than trusting the stub size on its own.'
|
|
);
|
|
}
|
|
|
|
const absPaths = new Set([stubAbs]);
|
|
for (const rel of chunkRels) {
|
|
const abs = path.join(nextDir, rel);
|
|
if (!fs.existsSync(abs)) {
|
|
fail(
|
|
`Chunk "${rel}" referenced by ${stubRel} does not resolve under .next/. ` +
|
|
'The chunk path convention has changed.'
|
|
);
|
|
}
|
|
absPaths.add(abs);
|
|
}
|
|
|
|
// The turbopack runtime is pulled in by the leading require(), not an R.c().
|
|
const runtimeMatch = stub.match(/require\("([^"]*_runtime\.js)"\)/);
|
|
if (runtimeMatch) {
|
|
const runtimeAbs = path.resolve(path.dirname(stubAbs), runtimeMatch[1]);
|
|
if (fs.existsSync(runtimeAbs)) absPaths.add(runtimeAbs);
|
|
}
|
|
|
|
const files = [...absPaths]
|
|
.sort()
|
|
.filter((abs) => !isExcluded(abs))
|
|
.map((abs) => measureFile(abs, path.relative(REPO_ROOT, abs)));
|
|
|
|
return total(files, {
|
|
note: `${chunkRels.length} chunks, shared with other routes`,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Nitro Tier 2. The flow handler is inlined into the single server entry, so
|
|
* there is nothing route-specific to isolate: report the whole server output.
|
|
*/
|
|
function nitroOutputTier2(appDir) {
|
|
const serverRel = '.output/server';
|
|
const serverAbs = path.join(appDir, serverRel);
|
|
if (!fs.existsSync(serverAbs)) {
|
|
fail(`Built server output ${serverRel} is missing under ${appDir}.`);
|
|
}
|
|
|
|
const files = walkFiles(serverAbs).map((abs) =>
|
|
measureFile(abs, path.relative(REPO_ROOT, abs))
|
|
);
|
|
if (files.length === 0) fail(`${serverRel} contains no files.`);
|
|
|
|
return total(files, {
|
|
// The per-file list for a whole server tree is long and not useful in a PR
|
|
// comment; keep the total only.
|
|
fileCount: files.length,
|
|
files: [],
|
|
note: `${files.length} files, flow handler inlined into the server entry`,
|
|
});
|
|
}
|
|
|
|
const TIER2_COLLECTORS = {
|
|
'nextjs-turbopack': nextTurbopackTier2,
|
|
hono: nitroOutputTier2,
|
|
};
|
|
|
|
function measureApp(app) {
|
|
const appDir = path.join(REPO_ROOT, 'workbench', app);
|
|
if (!fs.existsSync(appDir)) fail(`No workbench app at workbench/${app}`);
|
|
|
|
const collectTier2 = TIER2_COLLECTORS[app];
|
|
if (!collectTier2) {
|
|
fail(
|
|
`App "${app}" has no Tier 2 collector. Supported: ` +
|
|
`${Object.keys(TIER2_COLLECTORS).join(', ')}.`
|
|
);
|
|
}
|
|
|
|
// The prebuild hook generates this registry; without it the builders
|
|
// discover no workflows and emit a trivially small bundle that still looks
|
|
// like a valid measurement.
|
|
if (!fs.existsSync(path.join(appDir, '_workflows.ts'))) {
|
|
fail(
|
|
`workbench/${app}/_workflows.ts is missing. The prebuild step ` +
|
|
'(generate:workflows) did not run, so the flow bundle would be empty.'
|
|
);
|
|
}
|
|
|
|
const entry = loadMatrixEntry(app);
|
|
const flowRel = entry.generatedWorkflowPath;
|
|
const stepsRel = entry.generatedStepRegistrationPath;
|
|
|
|
assertFresh(app, appDir, [flowRel, stepsRel]);
|
|
|
|
const metrics = [
|
|
{
|
|
id: 'flow-bundle',
|
|
label: 'Flow route bundle',
|
|
tier: 1,
|
|
gated: true,
|
|
...measureFiles(appDir, [flowRel]),
|
|
},
|
|
{
|
|
id: 'step-registrations',
|
|
label: 'Step registrations',
|
|
tier: 1,
|
|
gated: true,
|
|
...measureFiles(appDir, [stepsRel]),
|
|
},
|
|
{
|
|
id: 'framework-output',
|
|
label: 'Framework output',
|
|
tier: 2,
|
|
gated: false,
|
|
...collectTier2(appDir),
|
|
},
|
|
];
|
|
|
|
return metrics;
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const metrics = measureApp(args.app);
|
|
|
|
const report = {
|
|
schemaVersion: SCHEMA_VERSION,
|
|
app: args.app,
|
|
commit: args.commit ?? null,
|
|
fingerprint: {
|
|
// Node ships zlib, so its major version can move the gzip numbers, and
|
|
// the OS moves the raw ones: the same hono build produces 55 files under
|
|
// .output/server on a Linux runner and 54 on macOS. CI always runs
|
|
// ubuntu-latest, so these only ever differ when someone measures
|
|
// somewhere else, which is exactly the comparison worth refusing.
|
|
nodeMajor: process.versions.node.split('.')[0],
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
...Object.fromEntries(
|
|
FINGERPRINT_ENV.map((key) => [key, process.env[key] ?? null])
|
|
),
|
|
},
|
|
metrics,
|
|
};
|
|
|
|
fs.mkdirSync(path.dirname(path.resolve(args.out)), { recursive: true });
|
|
fs.writeFileSync(args.out, `${JSON.stringify(report, null, 2)}\n`);
|
|
|
|
for (const metric of metrics) {
|
|
const kb = (metric.raw / 1024).toFixed(1);
|
|
const gzipKb = (metric.gzip / 1024).toFixed(1);
|
|
console.log(
|
|
`${args.app} ${metric.id}: ${kb} KiB raw, ${gzipKb} KiB gzip` +
|
|
`${metric.note ? ` (${metric.note})` : ''}`
|
|
);
|
|
}
|
|
console.log(`Wrote ${args.out}`);
|
|
}
|
|
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
if (error instanceof MeasureError) {
|
|
console.error(`measure-flow-bundle: ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
throw error;
|
|
}
|