| 1 | #!/usr/bin/env node |
| 2 | import { createHash } from "node:crypto"; |
| 3 | import { execFileSync } from "node:child_process"; |
| 4 | import { lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, writeFileSync } from "node:fs"; |
| 5 | import { basename, join, relative, resolve } from "node:path"; |
| 6 | import { pathToFileURL } from "node:url"; |
| 7 | |
| 8 | function toolVersion(command, args) { |
| 9 | try { |
| 10 | return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split(/\r?\n/)[0]; |
| 11 | } catch { |
| 12 | return "unavailable"; |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | function category(name) { |
| 17 | const path = name.toLowerCase(); |
| 18 | if (/(^|\/)(reasonix-desktop)(\.exe)?$/.test(path)) return "service"; |
| 19 | if (/electron framework|\/macos\/reasonix$|(^|\/)reasonix\.exe$/.test(path)) return "electron"; |
| 20 | if (/(^|\/)(reasonix|reasonix-cli)(\.exe)?$/.test(path)) return "cli"; |
| 21 | if (/helper|launcher|migrator|guard|uninstall/.test(path)) return "helper"; |
| 22 | if (/app\.asar|\/app\/|index\.html|\.cjs$/.test(path)) return "frontend"; |
| 23 | return "resources"; |
| 24 | } |
| 25 | |
| 26 | function sha256(path) { |
| 27 | return createHash("sha256").update(readFileSync(path)).digest("hex"); |
| 28 | } |
| 29 | |
| 30 | function bundledBuildInfo(root) { |
| 31 | const candidates = []; |
| 32 | const walk = (directory) => { |
| 33 | for (const entry of readdirSync(directory, { withFileTypes: true })) { |
| 34 | const path = join(directory, entry.name); |
| 35 | if (entry.isDirectory()) walk(path); |
| 36 | else if (entry.isFile() && entry.name === "build.json") candidates.push(path); |
| 37 | } |
| 38 | }; |
| 39 | walk(root); |
| 40 | for (const path of candidates.sort()) { |
| 41 | try { |
| 42 | const value = JSON.parse(readFileSync(path, "utf8")); |
| 43 | if (value && typeof value === "object" && value.schemaVersion === 1) return value; |
| 44 | } catch { |
| 45 | // A non-Reasonix build.json remains visible in the file report. |
| 46 | } |
| 47 | } |
| 48 | return null; |
| 49 | } |
| 50 | |
| 51 | export function inspectTree(root) { |
| 52 | const files = []; |
| 53 | const symlinks = []; |
| 54 | const walk = (directory) => { |
| 55 | for (const entry of readdirSync(directory, { withFileTypes: true })) { |
| 56 | const path = join(directory, entry.name); |
| 57 | const name = relative(root, path).replaceAll("\\", "/"); |
| 58 | const stat = lstatSync(path); |
| 59 | if (stat.isSymbolicLink()) { |
| 60 | symlinks.push({ path: name, target: readlinkSync(path) }); |
| 61 | } else if (stat.isDirectory()) { |
| 62 | walk(path); |
| 63 | } else if (stat.isFile()) { |
| 64 | files.push({ |
| 65 | path: name, |
| 66 | bytes: stat.size, |
| 67 | diskBytes: typeof stat.blocks === "number" ? stat.blocks * 512 : null, |
| 68 | sha256: sha256(path), |
| 69 | category: category(name), |
| 70 | }); |
| 71 | } |
| 72 | } |
| 73 | }; |
| 74 | walk(root); |
| 75 | const categories = {}; |
| 76 | for (const file of files) categories[file.category] = (categories[file.category] ?? 0) + file.bytes; |
| 77 | const duplicates = Object.values(Object.groupBy(files, (file) => file.sha256)) |
| 78 | .filter((group) => group.length > 1) |
| 79 | .map((group) => ({ sha256: group[0].sha256, bytesEach: group[0].bytes, paths: group.map((file) => file.path) })) |
| 80 | .sort((a, b) => (b.bytesEach * b.paths.length) - (a.bytesEach * a.paths.length)); |
| 81 | return { |
| 82 | logicalBytes: files.reduce((sum, file) => sum + file.bytes, 0), |
| 83 | diskBytes: files.every((file) => file.diskBytes !== null) ? files.reduce((sum, file) => sum + file.diskBytes, 0) : null, |
| 84 | fileCount: files.length, |
| 85 | categories, |
| 86 | largestFiles: [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25), |
| 87 | duplicates, |
| 88 | symlinks, |
| 89 | }; |
| 90 | } |
| 91 | |
| 92 | function parseArgs(argv) { |
| 93 | const result = {}; |
| 94 | for (let i = 0; i < argv.length; i += 2) { |
| 95 | if (!argv[i]?.startsWith("--") || argv[i + 1] === undefined) throw new Error(`invalid argument ${argv[i] ?? ""}`); |
| 96 | result[argv[i].slice(2)] = argv[i + 1]; |
| 97 | } |
| 98 | for (const key of ["platform", "version", "bundle", "dist", "output"]) { |
| 99 | if (!result[key]) throw new Error(`missing --${key}`); |
| 100 | } |
| 101 | return result; |
| 102 | } |
| 103 | |
| 104 | function markdown(report) { |
| 105 | const comparison = report.comparison ? [ |
| 106 | "## Comparison", |
| 107 | "", |
| 108 | `- Baseline: ${report.comparison.baseline}`, |
| 109 | `- Download bytes delta: ${report.comparison.downloadBytesDelta ?? "unavailable"}`, |
| 110 | `- Bundle logical bytes delta: ${report.comparison.bundleLogicalBytesDelta ?? "unavailable"}`, |
| 111 | `- Bundle disk bytes delta: ${report.comparison.bundleDiskBytesDelta ?? "unavailable"}`, |
| 112 | ...report.comparison.artifactDeltas.map((item) => `- ${item.name}: ${item.bytesDelta ?? "no matching baseline"} bytes`), |
| 113 | "", |
| 114 | ] : []; |
| 115 | const lines = [ |
| 116 | `# Reasonix package size report: ${report.platform}`, |
| 117 | "", |
| 118 | `- Version: \`${report.version}\``, |
| 119 | `- Source: \`${report.sourceSHA}\``, |
| 120 | `- Bundle logical size: ${report.bundle.logicalBytes} bytes`, |
| 121 | `- Bundle disk usage: ${report.bundle.diskBytes ?? "unavailable"} bytes`, |
| 122 | `- Build duration: ${report.metrics.buildSeconds ?? "unavailable"} seconds`, |
| 123 | `- Install/extract duration: ${report.metrics.installSeconds ?? "unavailable"} seconds`, |
| 124 | `- Temporary disk peak: ${report.metrics.temporaryPeakBytes ?? "unavailable"} bytes`, |
| 125 | "", |
| 126 | "## Download artifacts", |
| 127 | "", |
| 128 | "| File | Bytes | SHA-256 |", |
| 129 | "| --- | ---: | --- |", |
| 130 | ...report.artifacts.map((item) => `| ${item.name} | ${item.bytes} | \`${item.sha256}\` |`), |
| 131 | "", |
| 132 | "## Bundle categories", |
| 133 | "", |
| 134 | "| Category | Bytes |", |
| 135 | "| --- | ---: |", |
| 136 | ...Object.entries(report.bundle.categories).sort().map(([name, bytes]) => `| ${name} | ${bytes} |`), |
| 137 | "", |
| 138 | "## Largest files", |
| 139 | "", |
| 140 | "| Path | Bytes | Category |", |
| 141 | "| --- | ---: | --- |", |
| 142 | ...report.bundle.largestFiles.map((item) => `| ${item.path} | ${item.bytes} | ${item.category} |`), |
| 143 | "", |
| 144 | "## Symbolic links", |
| 145 | "", |
| 146 | ...(report.bundle.symlinks.length ? report.bundle.symlinks.map((item) => `- \`${item.path}\` → \`${item.target}\``) : ["None."]), |
| 147 | "", |
| 148 | "## Duplicate content", |
| 149 | "", |
| 150 | ...(report.bundle.duplicates.length ? report.bundle.duplicates.map((item) => `- ${item.bytesEach} bytes × ${item.paths.length}: ${item.paths.map((path) => `\`${path}\``).join(", ")}`) : ["None."]), |
| 151 | "", |
| 152 | ...comparison, |
| 153 | ]; |
| 154 | return lines.join("\n"); |
| 155 | } |
| 156 | |
| 157 | export function generateReport(options) { |
| 158 | const distEntries = readdirSync(options.dist, { withFileTypes: true }); |
| 159 | const platformName = options.platform.replace("/", "-"); |
| 160 | const artifacts = distEntries |
| 161 | .filter((entry) => entry.isFile() && !entry.name.endsWith(".minisig") && entry.name.startsWith(`Reasonix-${platformName}`)) |
| 162 | .map((entry) => { |
| 163 | const path = join(options.dist, entry.name); |
| 164 | return { name: entry.name, bytes: lstatSync(path).size, sha256: sha256(path) }; |
| 165 | }) |
| 166 | .sort((a, b) => a.name.localeCompare(b.name)); |
| 167 | const buildInfo = bundledBuildInfo(options.bundle); |
| 168 | const report = { |
| 169 | schemaVersion: 1, |
| 170 | version: options.version, |
| 171 | sourceSHA: options.sourceSHA || toolVersion("git", ["rev-parse", "HEAD"]), |
| 172 | platform: options.platform, |
| 173 | tools: { |
| 174 | electron: options.electronVersion || buildInfo?.electron || "unavailable", |
| 175 | go: toolVersion("go", ["version"]), |
| 176 | node: process.version, |
| 177 | }, |
| 178 | artifacts, |
| 179 | bundle: inspectTree(options.bundle), |
| 180 | metrics: { |
| 181 | buildSeconds: options.buildSeconds ? Number(options.buildSeconds) : null, |
| 182 | installSeconds: options.installSeconds ? Number(options.installSeconds) : null, |
| 183 | temporaryPeakBytes: options.temporaryPeakBytes ? Number(options.temporaryPeakBytes) : null, |
| 184 | }, |
| 185 | buildInfo, |
| 186 | }; |
| 187 | if (options.baseline) { |
| 188 | const baseline = JSON.parse(readFileSync(options.baseline, "utf8")); |
| 189 | const baselineArtifacts = new Map((baseline.artifacts ?? []).map((artifact) => [artifact.name, artifact])); |
| 190 | const currentDownloadBytes = report.artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0); |
| 191 | const baselineDownloadBytes = (baseline.artifacts ?? []).reduce((sum, artifact) => sum + Number(artifact.bytes ?? 0), 0); |
| 192 | report.comparison = { |
| 193 | baseline: options.baselineLabel || `${baseline.version ?? "unknown"} ${baseline.platform ?? ""}`.trim(), |
| 194 | artifactDeltas: report.artifacts.map((artifact) => ({ |
| 195 | name: artifact.name, |
| 196 | baselineBytes: baselineArtifacts.get(artifact.name)?.bytes ?? null, |
| 197 | bytesDelta: baselineArtifacts.has(artifact.name) ? artifact.bytes - Number(baselineArtifacts.get(artifact.name).bytes) : null, |
| 198 | })), |
| 199 | downloadBytesDelta: report.artifacts.length === (baseline.artifacts ?? []).length && report.artifacts.every((artifact) => baselineArtifacts.has(artifact.name)) |
| 200 | ? currentDownloadBytes - baselineDownloadBytes |
| 201 | : null, |
| 202 | bundleLogicalBytesDelta: baseline.bundle?.logicalBytes !== undefined |
| 203 | ? report.bundle.logicalBytes - Number(baseline.bundle.logicalBytes) |
| 204 | : null, |
| 205 | bundleDiskBytesDelta: report.bundle.diskBytes !== null && baseline.bundle?.diskBytes !== null |
| 206 | ? report.bundle.diskBytes - Number(baseline.bundle?.diskBytes ?? 0) |
| 207 | : null, |
| 208 | }; |
| 209 | } |
| 210 | mkdirSync(options.output, { recursive: true }); |
| 211 | writeFileSync(join(options.output, "size-report.json"), JSON.stringify(report, null, 2) + "\n"); |
| 212 | writeFileSync(join(options.output, "size-report.md"), markdown(report)); |
| 213 | return report; |
| 214 | } |
| 215 | |
| 216 | if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { |
| 217 | const args = parseArgs(process.argv.slice(2)); |
| 218 | generateReport({ |
| 219 | ...args, |
| 220 | bundle: resolve(args.bundle), |
| 221 | dist: resolve(args.dist), |
| 222 | output: resolve(args.output), |
| 223 | sourceSHA: process.env.REASONIX_COMMIT, |
| 224 | buildSeconds: process.env.REASONIX_BUILD_SECONDS, |
| 225 | installSeconds: process.env.REASONIX_INSTALL_SECONDS, |
| 226 | temporaryPeakBytes: process.env.REASONIX_TEMPORARY_PEAK_BYTES, |
| 227 | baseline: args.baseline || process.env.REASONIX_SIZE_BASELINE_REPORT, |
| 228 | baselineLabel: args["baseline-label"] || process.env.REASONIX_SIZE_BASELINE_LABEL, |
| 229 | }); |
| 230 | } |
| 231 |