Skip to content

Commit 8582d0d

Browse files
committed
perf: lazy load daemon handlers
1 parent 5083d04 commit 8582d0d

7 files changed

Lines changed: 735 additions & 72 deletions

File tree

.github/workflows/size.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Size
2+
3+
on:
4+
pull_request:
5+
6+
permissions:
7+
contents: read
8+
pull-requests: write
9+
10+
concurrency:
11+
group: size-${{ github.workflow }}-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
bundle-size:
16+
name: Bundle Size
17+
if: github.event.pull_request.head.repo.full_name == github.repository
18+
runs-on: ubuntu-latest
19+
timeout-minutes: 10
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
23+
with:
24+
fetch-depth: 0
25+
26+
- name: Setup toolchain
27+
uses: ./.github/actions/setup-node-pnpm
28+
29+
- name: Preserve report script
30+
run: cp scripts/size-report.mjs /tmp/agent-device-size-report.mjs
31+
32+
- name: Measure base size
33+
run: |
34+
git checkout --detach "${{ github.event.pull_request.base.sha }}"
35+
pnpm install --frozen-lockfile
36+
pnpm build
37+
node /tmp/agent-device-size-report.mjs --json /tmp/agent-device-size-base.json
38+
39+
- name: Measure PR size
40+
run: |
41+
git checkout --detach "${{ github.event.pull_request.head.sha }}"
42+
pnpm install --frozen-lockfile
43+
pnpm build
44+
node scripts/size-report.mjs \
45+
--compare /tmp/agent-device-size-base.json \
46+
--json .tmp/size-report.json \
47+
--markdown .tmp/size-report.md
48+
49+
- name: Add job summary
50+
run: cat .tmp/size-report.md >> "$GITHUB_STEP_SUMMARY"
51+
52+
- name: Comment on PR
53+
env:
54+
GITHUB_TOKEN: ${{ github.token }}
55+
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
56+
run: node scripts/size-report.mjs --post-comment .tmp/size-report.md

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@
9696
"build:macos-helper": "swift build -c release --package-path macos-helper",
9797
"build:all": "pnpm build:node && pnpm build:xcuitest",
9898
"ad": "node bin/agent-device.mjs",
99+
"size": "node scripts/size-report.mjs",
100+
"size:markdown": "node scripts/size-report.mjs --json .tmp/size-report.json --markdown .tmp/size-report.md",
99101
"lint": "oxlint . --deny-warnings",
100102
"format": "oxfmt --write src test skills package.json tsconfig.json tsconfig.lib.json rslib.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
101103
"fallow": "fallow --summary",

scripts/size-report.mjs

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
#!/usr/bin/env node
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { execFileSync } from 'node:child_process';
5+
import { gzipSync } from 'node:zlib';
6+
7+
const COMMENT_MARKER = '<!-- agent-device-size-report -->';
8+
const VALUE_ARGS = new Map([
9+
['--cwd', 'cwd'],
10+
['--json', 'json'],
11+
['--markdown', 'markdown'],
12+
['--compare', 'compare'],
13+
['--post-comment', 'postComment'],
14+
['--pr', 'pr'],
15+
]);
16+
17+
const args = parseArgs(process.argv.slice(2));
18+
const cwd = path.resolve(args.cwd ?? process.cwd());
19+
20+
if (args.postComment) {
21+
await postGitHubComment(args.postComment, args.pr);
22+
process.exit(0);
23+
}
24+
25+
const report = collectReport(cwd);
26+
const baseReport = args.compare ? JSON.parse(fs.readFileSync(args.compare, 'utf8')) : null;
27+
28+
if (args.json) {
29+
writeFile(args.json, `${JSON.stringify(report, null, 2)}\n`);
30+
}
31+
32+
const markdown = formatMarkdown(report, baseReport);
33+
34+
if (args.markdown) {
35+
writeFile(args.markdown, markdown);
36+
} else {
37+
process.stdout.write(markdown);
38+
}
39+
40+
function parseArgs(argv) {
41+
const parsed = {};
42+
for (let index = 0; index < argv.length; index += 1) {
43+
const arg = argv[index];
44+
if (assignValueArg(parsed, arg, argv, index)) index += 1;
45+
else if (isHelpArg(arg)) printHelpAndExit();
46+
else throw new Error(`Unknown argument: ${arg}`);
47+
}
48+
return parsed;
49+
}
50+
51+
function assignValueArg(parsed, arg, argv, index) {
52+
const key = VALUE_ARGS.get(arg);
53+
if (!key) return false;
54+
parsed[key] = readValue(argv, index + 1, arg);
55+
return true;
56+
}
57+
58+
function isHelpArg(arg) {
59+
return arg === '--help' || arg === '-h';
60+
}
61+
62+
function printHelpAndExit() {
63+
process.stdout.write(`Usage: node scripts/size-report.mjs [options]
64+
65+
Options:
66+
--cwd <path> Project root to measure. Defaults to cwd.
67+
--json <path> Write the raw size report JSON.
68+
--markdown <path> Write the markdown report.
69+
--compare <path> Compare against a previously written JSON report.
70+
--post-comment <path> Post or update the markdown report on the current PR.
71+
--pr <number> Pull request number for --post-comment.
72+
`);
73+
process.exit(0);
74+
}
75+
76+
function readValue(argv, index, flag) {
77+
const value = argv[index];
78+
if (!value || value.startsWith('--')) {
79+
throw new Error(`${flag} requires a value`);
80+
}
81+
return value;
82+
}
83+
84+
function collectReport(root) {
85+
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
86+
const jsFiles = walk(path.join(root, 'dist', 'src')).filter((file) => file.endsWith('.js'));
87+
if (jsFiles.length === 0) {
88+
throw new Error('No dist/src JavaScript files found. Run `pnpm build` before measuring size.');
89+
}
90+
91+
const chunks = jsFiles
92+
.map((file) => {
93+
const buffer = fs.readFileSync(file);
94+
return {
95+
path: path.relative(root, file),
96+
rawBytes: buffer.byteLength,
97+
gzipBytes: gzipSync(buffer, { level: 9 }).byteLength,
98+
};
99+
})
100+
.sort((left, right) => right.rawBytes - left.rawBytes);
101+
102+
const js = chunks.reduce(
103+
(total, chunk) => ({
104+
files: total.files + 1,
105+
rawBytes: total.rawBytes + chunk.rawBytes,
106+
gzipBytes: total.gzipBytes + chunk.gzipBytes,
107+
}),
108+
{ files: 0, rawBytes: 0, gzipBytes: 0 },
109+
);
110+
111+
return {
112+
packageName: packageJson.name,
113+
version: packageJson.version,
114+
generatedAt: new Date().toISOString(),
115+
js,
116+
npmPack: collectNpmPack(root),
117+
chunks: chunks.slice(0, 20),
118+
};
119+
}
120+
121+
function walk(root) {
122+
if (!fs.existsSync(root)) return [];
123+
const entries = fs.readdirSync(root, { withFileTypes: true });
124+
return entries.flatMap((entry) => {
125+
const entryPath = path.join(root, entry.name);
126+
return entry.isDirectory() ? walk(entryPath) : [entryPath];
127+
});
128+
}
129+
130+
function collectNpmPack(root) {
131+
const cachePath = path.join(root, '.tmp', 'npm-cache');
132+
fs.mkdirSync(cachePath, { recursive: true });
133+
const stdout = execFileSync(
134+
'npm',
135+
['pack', '--dry-run', '--ignore-scripts', '--json', '--cache', cachePath],
136+
{ cwd: root, encoding: 'utf8' },
137+
);
138+
const pack = parseNpmPackOutput(stdout);
139+
return {
140+
filename: pack.filename,
141+
tarballBytes: pack.size,
142+
unpackedBytes: pack.unpackedSize,
143+
files: countNpmPackEntries(pack),
144+
};
145+
}
146+
147+
function parseNpmPackOutput(stdout) {
148+
const parsed = JSON.parse(stdout);
149+
return Array.isArray(parsed) ? parsed[0] : parsed;
150+
}
151+
152+
function countNpmPackEntries(pack) {
153+
if (typeof pack.entryCount === 'number') return pack.entryCount;
154+
return Array.isArray(pack.files) ? pack.files.length : 0;
155+
}
156+
157+
function formatMarkdown(report, baseReport) {
158+
const rows = [
159+
metricRow('JS raw', baseReport?.js.rawBytes, report.js.rawBytes),
160+
metricRow('JS gzip', baseReport?.js.gzipBytes, report.js.gzipBytes),
161+
metricRow('npm tarball', baseReport?.npmPack.tarballBytes, report.npmPack.tarballBytes),
162+
metricRow('npm unpacked', baseReport?.npmPack.unpackedBytes, report.npmPack.unpackedBytes),
163+
];
164+
165+
const changedChunks = baseReport
166+
? formatChangedChunks(report.chunks, baseReport.chunks ?? [])
167+
: formatTopChunks(report.chunks);
168+
169+
return `${COMMENT_MARKER}
170+
## Size Report
171+
172+
| Metric | Base | Current | Diff |
173+
|---|---:|---:|---:|
174+
${rows.join('\n')}
175+
176+
${changedChunks}
177+
`;
178+
}
179+
180+
function metricRow(label, base, current) {
181+
return `| ${label} | ${formatMaybeBytes(base)} | ${formatBytes(current)} | ${formatDiff(base, current)} |`;
182+
}
183+
184+
function formatTopChunks(chunks) {
185+
const rows = chunks.slice(0, 5).map((chunk) => {
186+
return `| \`${chunk.path}\` | ${formatBytes(chunk.rawBytes)} | ${formatBytes(chunk.gzipBytes)} |`;
187+
});
188+
return `Top chunks:
189+
190+
| Chunk | Raw | Gzip |
191+
|---|---:|---:|
192+
${rows.join('\n')}
193+
`;
194+
}
195+
196+
function formatChangedChunks(currentChunks, baseChunks) {
197+
const baseByPath = new Map(baseChunks.map((chunk) => [chunk.path, chunk]));
198+
const rows = currentChunks
199+
.map((chunk) => {
200+
const base = baseByPath.get(chunk.path);
201+
return {
202+
path: chunk.path,
203+
rawDiff: base ? chunk.rawBytes - base.rawBytes : chunk.rawBytes,
204+
gzipDiff: base ? chunk.gzipBytes - base.gzipBytes : chunk.gzipBytes,
205+
};
206+
})
207+
.filter((chunk) => chunk.rawDiff !== 0 || chunk.gzipDiff !== 0)
208+
.sort((left, right) => Math.abs(right.gzipDiff) - Math.abs(left.gzipDiff))
209+
.slice(0, 5)
210+
.map((chunk) => {
211+
return `| \`${chunk.path}\` | ${formatSignedBytes(chunk.rawDiff)} | ${formatSignedBytes(chunk.gzipDiff)} |`;
212+
});
213+
214+
if (rows.length === 0) {
215+
return 'Top changed chunks: no changes in the largest emitted chunks.\n';
216+
}
217+
218+
return `Top changed chunks:
219+
220+
| Chunk | Raw diff | Gzip diff |
221+
|---|---:|---:|
222+
${rows.join('\n')}
223+
`;
224+
}
225+
226+
function formatMaybeBytes(value) {
227+
return typeof value === 'number' ? formatBytes(value) : '-';
228+
}
229+
230+
function formatDiff(base, current) {
231+
return typeof base === 'number' ? formatSignedBytes(current - base) : '-';
232+
}
233+
234+
function formatBytes(value) {
235+
const absoluteValue = Math.abs(value);
236+
if (absoluteValue < 1000) return `${value} B`;
237+
if (absoluteValue < 1000 * 1000) return `${(value / 1000).toFixed(1)} kB`;
238+
return `${(value / (1000 * 1000)).toFixed(1)} MB`;
239+
}
240+
241+
function formatSignedBytes(value) {
242+
if (value === 0) return '0 B';
243+
const sign = value > 0 ? '+' : '-';
244+
return `${sign}${formatBytes(Math.abs(value))}`;
245+
}
246+
247+
function writeFile(filePath, contents) {
248+
fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true });
249+
fs.writeFileSync(filePath, contents);
250+
}
251+
252+
async function postGitHubComment(markdownPath, explicitPrNumber) {
253+
const config = readGitHubCommentConfig(explicitPrNumber);
254+
const body = fs.readFileSync(markdownPath, 'utf8');
255+
const commentsUrl = buildCommentsUrl(config.repository, config.prNumber);
256+
const comments = await listGitHubComments(commentsUrl, config.headers);
257+
const existing = comments.find((comment) => comment.body?.includes(COMMENT_MARKER));
258+
await writeGitHubComment(commentsUrl, config.headers, body, existing?.url);
259+
}
260+
261+
function readGitHubCommentConfig(explicitPrNumber) {
262+
const token = process.env.GITHUB_TOKEN;
263+
const repository = process.env.GITHUB_REPOSITORY;
264+
const prNumber = explicitPrNumber ?? process.env.GITHUB_PR_NUMBER;
265+
assertGitHubCommentConfig(token, repository, prNumber);
266+
return {
267+
repository,
268+
prNumber,
269+
headers: buildGitHubHeaders(token),
270+
};
271+
}
272+
273+
function assertGitHubCommentConfig(token, repository, prNumber) {
274+
for (const value of [token, repository, prNumber]) {
275+
if (!value) {
276+
throw new Error('GITHUB_TOKEN, GITHUB_REPOSITORY, and PR number are required to post a comment.');
277+
}
278+
}
279+
}
280+
281+
function buildGitHubHeaders(token) {
282+
return {
283+
accept: 'application/vnd.github+json',
284+
authorization: `Bearer ${token}`,
285+
'content-type': 'application/json',
286+
'x-github-api-version': '2022-11-28',
287+
};
288+
}
289+
290+
function buildCommentsUrl(repository, prNumber) {
291+
const [owner, repo] = repository.split('/');
292+
return `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`;
293+
}
294+
295+
async function listGitHubComments(commentsUrl, headers) {
296+
const response = await fetch(`${commentsUrl}?per_page=100`, { headers });
297+
if (!response.ok) {
298+
throw new Error(`Failed to list PR comments: ${response.status} ${await response.text()}`);
299+
}
300+
return await response.json();
301+
}
302+
303+
async function writeGitHubComment(commentsUrl, headers, body, existingUrl) {
304+
const target = commentWriteTarget(commentsUrl, existingUrl);
305+
const response = await fetch(target.url, {
306+
method: target.method,
307+
headers,
308+
body: JSON.stringify({ body }),
309+
});
310+
await assertGitHubWriteResponse(response, target.action);
311+
}
312+
313+
function commentWriteTarget(commentsUrl, existingUrl) {
314+
if (existingUrl) {
315+
return { url: existingUrl, method: 'PATCH', action: 'update' };
316+
}
317+
return { url: commentsUrl, method: 'POST', action: 'create' };
318+
}
319+
320+
async function assertGitHubWriteResponse(response, action) {
321+
if (!response.ok) {
322+
throw new Error(`Failed to ${action} PR comment: ${response.status} ${await response.text()}`);
323+
}
324+
}

0 commit comments

Comments
 (0)