Skip to content

Commit 97f6f45

Browse files
committed
perf: lazy load daemon handlers
1 parent 0eef23e commit 97f6f45

7 files changed

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

src/bin.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,53 @@
11
const argv = process.argv.slice(2);
22

3-
if (argv[0] === 'mcp' && !argv.includes('--help') && !argv.includes('-h')) {
3+
if (runFastPath(argv)) {
4+
// Fast path owns process output and exit behavior.
5+
} else if (argv[0] === 'mcp' && !argv.includes('--help') && !argv.includes('-h')) {
46
import('./mcp/server.ts')
57
.then(({ runAgentDeviceMcpServer }) => runAgentDeviceMcpServer())
68
.catch(handleStartupError);
79
} else {
10+
runCli(argv);
11+
}
12+
13+
function runFastPath(argv: string[]): boolean {
14+
if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-V')) {
15+
import('./utils/version.ts')
16+
.then(({ readVersion }) => {
17+
process.stdout.write(`${readVersion()}\n`);
18+
})
19+
.catch(handleStartupError);
20+
return true;
21+
}
22+
23+
const helpTarget = resolveSimpleHelpTarget(argv);
24+
if (helpTarget === undefined) return false;
25+
26+
import('./utils/args.ts')
27+
.then(({ usage, usageForCommand }) => {
28+
if (helpTarget === null) {
29+
process.stdout.write(`${usage()}\n`);
30+
return;
31+
}
32+
const commandHelp = usageForCommand(helpTarget);
33+
if (commandHelp) {
34+
process.stdout.write(commandHelp);
35+
return;
36+
}
37+
runCli(argv);
38+
})
39+
.catch(handleStartupError);
40+
return true;
41+
}
42+
43+
function resolveSimpleHelpTarget(argv: string[]): string | null | undefined {
44+
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) return null;
45+
if (argv[0] === 'help' && argv.length <= 2) return argv[1] ?? null;
46+
if (argv.length === 2 && (argv[1] === '--help' || argv[1] === '-h')) return argv[0] ?? null;
47+
return undefined;
48+
}
49+
50+
function runCli(argv: string[]): void {
851
import('./cli.ts').then(({ runCli }) => runCli(argv)).catch(handleStartupError);
952
}
1053

0 commit comments

Comments
 (0)