@akanjs/devkit 3.1.2-rc.4 → 3.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +353 -0
  2. package/abstractDoc.ts +1 -1
  3. package/agentAssets.ts +2 -2
  4. package/akanConfig/akanConfig.ts +2 -2
  5. package/akanMcpContract.ts +1 -1
  6. package/apiReference.test.ts +37 -0
  7. package/apiReference.ts +240 -0
  8. package/applicationBuildReporter.test.ts +25 -3
  9. package/applicationBuildReporter.ts +8 -1
  10. package/applicationBuildRunner.test.ts +34 -14
  11. package/applicationBuildRunner.ts +76 -14
  12. package/artifact/implicitRootLayout.test.ts +42 -0
  13. package/artifact/implicitRootLayout.ts +5 -6
  14. package/artifact/storeBootstrap.ts +24 -0
  15. package/cloud/cloudApi.test.ts +70 -7
  16. package/cloud/cloudApi.ts +10 -4
  17. package/cloud/constants.ts +3 -0
  18. package/cloud/globalConfig.test.ts +36 -0
  19. package/cloud/globalConfig.ts +10 -1
  20. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  21. package/codeAgent/skills/akan-page/SKILL.md +16 -7
  22. package/codeAgent/skills/akan-service/SKILL.md +48 -19
  23. package/commandDecorators/command.ts +55 -9
  24. package/commandDecorators/commandDecorators.test.ts +131 -2
  25. package/createTunnel.test.ts +12 -2
  26. package/declarationReader.ts +211 -0
  27. package/executors.test.ts +65 -8
  28. package/executors.ts +5 -3
  29. package/frameworkGraph.test.ts +121 -0
  30. package/frameworkGraph.ts +165 -0
  31. package/frontendBuild/allRoutesBuilder.test.ts +84 -0
  32. package/frontendBuild/allRoutesBuilder.ts +32 -12
  33. package/frontendBuild/buildRouteClient.test.ts +192 -2
  34. package/frontendBuild/bundleDefine.ts +4 -0
  35. package/frontendBuild/clientBuildTypes.ts +7 -1
  36. package/frontendBuild/clientEntriesBundler.test.ts +82 -0
  37. package/frontendBuild/clientEntriesBundler.ts +55 -4
  38. package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
  39. package/frontendBuild/csrArtifactBuilder.ts +2 -1
  40. package/frontendBuild/fontOptimizer.test.ts +54 -0
  41. package/frontendBuild/fontOptimizer.ts +5 -4
  42. package/frontendBuild/index.ts +3 -0
  43. package/frontendBuild/routeClientBuilder.ts +6 -2
  44. package/frontendBuild/routeModulePreloads.test.ts +59 -0
  45. package/frontendBuild/routeModulePreloads.ts +52 -0
  46. package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
  47. package/frontendBuild/ssrDevBundler.test.ts +26 -0
  48. package/frontendBuild/ssrDevBundler.ts +2 -1
  49. package/frontendBuild/vendorImportScanner.test.ts +71 -0
  50. package/frontendBuild/vendorImportScanner.ts +80 -0
  51. package/frontendBuild/vendorNarrowing.test.ts +103 -0
  52. package/frontendBuild/vendorNarrowing.ts +64 -0
  53. package/frontendBuild/vendorSpecifiers.ts +13 -0
  54. package/gitRevision.ts +6 -2
  55. package/incrementalBuilder/builderGraphPayloads.ts +60 -0
  56. package/incrementalBuilder/incrementalBuilder.proc.test.ts +65 -1
  57. package/incrementalBuilder/incrementalBuilder.proc.ts +5 -36
  58. package/integration/devStability.integration.test.ts +1 -1
  59. package/integration/devStabilityHarness.test.ts +1 -1
  60. package/lint/no-deep-internal-import.grit +1 -1
  61. package/maintainabilityBaseline.ts +7 -2
  62. package/maintainabilityContract.test.ts +183 -0
  63. package/maintainabilityContract.ts +145 -30
  64. package/maintainabilityExceptionScanner.test.ts +13 -2
  65. package/maintainabilityExceptionScanner.ts +16 -6
  66. package/maintainabilityLimits.ts +12 -0
  67. package/maintainabilityReport.test.ts +35 -0
  68. package/maintainabilityReport.ts +29 -8
  69. package/mobile/nativePublicEnv.test.ts +5 -1
  70. package/package.json +3 -3
  71. package/preview/PreviewChrome.ts +3 -3
  72. package/preview/PreviewView.ts +26 -8
  73. package/preview/previewView.test.ts +87 -3
  74. package/routeJsBudget.test.ts +177 -0
  75. package/routeJsBudget.ts +222 -0
  76. package/structureScanner.test.ts +167 -0
  77. package/structureScanner.ts +164 -0
  78. package/transforms/barrelAnalyzer.ts +10 -3
  79. package/transforms/barrelImportsPlugin.ts +13 -7
  80. package/transforms/serverEnvPlugin.test.ts +1 -1
  81. package/transforms/serverEnvPlugin.ts +1 -1
  82. package/transforms/transforms.test.ts +64 -12
  83. package/transforms/tsconfigPackageResolver.test.ts +12 -0
  84. package/workflow/plan.ts +79 -17
  85. package/workflow/types.ts +1 -0
  86. package/workspaceGraph.test.ts +58 -0
  87. package/workspaceGraph.ts +32 -3
  88. package/workspaceLayout.ts +1 -0
  89. /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
@@ -5,7 +5,8 @@ export interface PreviewProbeOptions {
5
5
  width?: number;
6
6
  height?: number;
7
7
  engine?: PreviewEngine;
8
- /** Bounds the whole probe: a dev server that is recovering takes the connection and never answers. */
8
+ startTimeoutMs?: number;
9
+ /** Bounds the probe once the browser is up: a dev server that is recovering takes the connection and never answers. */
9
10
  timeoutMs?: number;
10
11
  /** Waited after the load, so the errors effects throw land before the page is read. */
11
12
  settleMs?: number;
@@ -45,6 +46,10 @@ interface PageRead {
45
46
  // Bun.WebView rather than a downloaded browser: on macOS its webkit backend needs no Chromium at all.
46
47
  // It is experimental, so its whole surface stays behind this adapter; a Bun minor that changes it breaks one file.
47
48
  export class PreviewView {
49
+ /** A view's first navigation includes the browser's start, which took ~35 s on a cold CI runner. */
50
+ static readonly startTimeoutMs = 120_000;
51
+ static readonly #stalled = new Set<PreviewEngine>();
52
+
48
53
  static get available() {
49
54
  return PreviewView.isAvailable();
50
55
  }
@@ -66,15 +71,31 @@ export class PreviewView {
66
71
  width = 1280,
67
72
  height = 800,
68
73
  engine = PreviewChrome.backend,
74
+ startTimeoutMs = PreviewView.startTimeoutMs,
69
75
  timeoutMs = 30_000,
70
76
  settleMs = 0,
71
77
  }: PreviewProbeOptions = {},
72
78
  ): Promise<PreviewProbeResult> {
73
79
  const logs: string[] = [];
74
- const deadline = Date.now() + timeoutMs;
75
- const within = <T>(work: Promise<T>, what: string) => PreviewView.#within(work, deadline, `${what} ${url}`);
76
80
  const view = PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) });
77
81
  try {
82
+ //? A browser that missed its start bound may still come up, so a later page retries it within only its own bound.
83
+ const startMs = PreviewView.#stalled.has(engine) ? Math.min(startTimeoutMs, timeoutMs) : startTimeoutMs;
84
+ const stalled = new Error(`Timed out starting the ${engine} browser after ${startMs / 1_000} s.`);
85
+ await PreviewView.#within(view.navigate("about:blank"), Date.now() + startMs, stalled).then(
86
+ () => PreviewView.#stalled.delete(engine),
87
+ (error: unknown) => {
88
+ if (error === stalled) PreviewView.#stalled.add(engine);
89
+ throw error;
90
+ },
91
+ );
92
+ const deadline = Date.now() + timeoutMs;
93
+ const within = <T>(work: Promise<T>, what: string) =>
94
+ PreviewView.#within(
95
+ work,
96
+ deadline,
97
+ new Error(`Timed out ${what} ${url}; the dev server may be rebuilding or stuck.`),
98
+ );
78
99
  let page = await PreviewView.#load(view, url, settleMs, within);
79
100
  //? Right after a save the first load can render nothing (`[rscClient] module not loaded`); the next one has it.
80
101
  if (page.empty) {
@@ -126,13 +147,10 @@ export class PreviewView {
126
147
  return response?.status ?? null;
127
148
  }
128
149
 
129
- static async #within<T>(work: Promise<T>, deadline: number, what: string): Promise<T> {
150
+ static async #within<T>(work: Promise<T>, deadline: number, expiry: Error): Promise<T> {
130
151
  let timer: ReturnType<typeof setTimeout> | undefined;
131
152
  const expired = new Promise<never>((_, reject) => {
132
- timer = setTimeout(
133
- () => reject(new Error(`Timed out ${what}; the dev server may be rebuilding or stuck.`)),
134
- Math.max(0, deadline - Date.now()),
135
- );
153
+ timer = setTimeout(() => reject(expiry), Math.max(0, deadline - Date.now()));
136
154
  });
137
155
  try {
138
156
  return await Promise.race([work, expired]);
@@ -1,9 +1,42 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { PreviewChrome } from "./PreviewChrome";
2
6
  import { PreviewView } from "./PreviewView";
3
7
 
4
8
  const serve = (html: string, status = 200) =>
5
9
  Bun.serve({ port: 0, fetch: () => new Response(html, { status, headers: { "content-type": "text/html" } }) });
6
10
 
11
+ const browserTestMs = PreviewView.startTimeoutMs + 30_000;
12
+ const slowChromeRuns = PreviewView.isAvailable("chrome") && process.platform !== "win32";
13
+
14
+ const withSlowChrome = async (body: string, startSeconds = 3) => {
15
+ const dir = await mkdtemp(path.join(tmpdir(), "akan-slow-chrome-"));
16
+ const chrome = path.join(dir, "chrome");
17
+ await writeFile(chrome, `#!/bin/sh\nsleep ${startSeconds}\nexec "${PreviewChrome.executable()}" "$@"\n`);
18
+ await chmod(chrome, 0o755);
19
+ const script = `const { PreviewView } = await import(${JSON.stringify(path.join(import.meta.dir, "PreviewView.ts"))});\n${body}`;
20
+ //? Chrome is spawned once per process, so only a fresh process takes the slow executable.
21
+ const child = Bun.spawn([process.execPath, "-e", script], {
22
+ env: { ...process.env, [PreviewChrome.pathEnvKey]: chrome },
23
+ stdout: "pipe",
24
+ stderr: "pipe",
25
+ timeout: PreviewView.startTimeoutMs + 15_000,
26
+ });
27
+ try {
28
+ const [out, err, code] = await Promise.all([
29
+ new Response(child.stdout).text(),
30
+ new Response(child.stderr).text(),
31
+ child.exited,
32
+ ]);
33
+ return { code, out: out.trim(), err };
34
+ } finally {
35
+ child.kill();
36
+ await rm(dir, { recursive: true, force: true });
37
+ }
38
+ };
39
+
7
40
  describe("PreviewView", () => {
8
41
  test("reports whether this Bun build can drive a headless page at all", () => {
9
42
  expect(typeof PreviewView.available).toBe("boolean");
@@ -29,7 +62,7 @@ describe("PreviewView", () => {
29
62
  server.stop(true);
30
63
  }
31
64
  },
32
- 30_000,
65
+ browserTestMs,
33
66
  );
34
67
 
35
68
  test.skipIf(!PreviewView.available)(
@@ -56,7 +89,7 @@ describe("PreviewView", () => {
56
89
  server.stop(true);
57
90
  }
58
91
  },
59
- 30_000,
92
+ browserTestMs,
60
93
  );
61
94
 
62
95
  test.skipIf(!PreviewView.available)(
@@ -71,6 +104,57 @@ describe("PreviewView", () => {
71
104
  server.stop(true);
72
105
  }
73
106
  },
74
- 30_000,
107
+ browserTestMs,
108
+ );
109
+
110
+ test.skipIf(!slowChromeRuns)(
111
+ "starts the page's bound once the browser is up, however long Chrome takes to spawn",
112
+ async () => {
113
+ const server = serve("<!doctype html><title>Up</title><body>up</body>");
114
+ try {
115
+ const { code, out, err } = await withSlowChrome(
116
+ `const probe = await PreviewView.probe("http://localhost:${server.port}/", { engine: "chrome", timeoutMs: 1_500 });
117
+ console.log(probe.text);`,
118
+ );
119
+ expect(out || err).toBe("up");
120
+ expect(code).toBe(0);
121
+ } finally {
122
+ server.stop(true);
123
+ }
124
+ },
125
+ browserTestMs,
126
+ );
127
+
128
+ test.skipIf(!slowChromeRuns)(
129
+ "gives a later page only its own bound for a browser that missed its start, and recovers once it is up",
130
+ async () => {
131
+ const { code, out, err } = await withSlowChrome(
132
+ `const open = () =>
133
+ PreviewView.probe("about:blank", { engine: "chrome", startTimeoutMs: 1_500, timeoutMs: 1_000 }).then(
134
+ () => "opened",
135
+ (error) => error.message,
136
+ );
137
+ console.log(await open());
138
+ console.log(await open());
139
+ let late = await open();
140
+ for (let tries = 0; late !== "opened" && tries < 100; tries++) late = await open();
141
+ console.log(late);
142
+ Bun.WebView.closeAll();
143
+ await Bun.sleep(500);
144
+ console.log(await open());
145
+ Bun.WebView.closeAll();`,
146
+ 6,
147
+ );
148
+ expect(out || err).toBe(
149
+ [
150
+ "Timed out starting the chrome browser after 1.5 s.",
151
+ "Timed out starting the chrome browser after 1 s.",
152
+ "opened",
153
+ "Timed out starting the chrome browser after 1.5 s.",
154
+ ].join("\n"),
155
+ );
156
+ expect(code).toBe(0);
157
+ },
158
+ browserTestMs,
75
159
  );
76
160
  });
@@ -0,0 +1,177 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { RouteJsBudget, type RouteJsSize } from "./routeJsBudget";
4
+ import { tempDirs, writeText as write } from "./testHelpers";
5
+
6
+ const makeTempRoot = tempDirs("akan-route-js-");
7
+ const size = (routeId: string, brotliKB: number): RouteJsSize => ({
8
+ routeId,
9
+ files: 1,
10
+ rawBytes: brotliKB * 3 * 1024,
11
+ brotliBytes: brotliKB * 1024,
12
+ });
13
+
14
+ describe("RouteJsBudget", () => {
15
+ test("measures a route by the brotli sidecar of each file it loads, and a file with none by its raw bytes", async () => {
16
+ const artifactDir = await makeTempRoot();
17
+ await write(path.join(artifactDir, "client/rsc.js"), "r".repeat(3000));
18
+ await write(path.join(artifactDir, "client/rsc.js.br"), "b".repeat(900));
19
+ await write(path.join(artifactDir, "client/chunks/tiny.js"), "t".repeat(200));
20
+ await write(path.join(artifactDir, "client/memo.js"), "m".repeat(5000));
21
+ await write(path.join(artifactDir, "client/memo.js.br"), "b".repeat(1200));
22
+
23
+ const sizes = await RouteJsBudget.measure(artifactDir, {
24
+ "/:lang": ["/_akan/client/rsc.js", "/_akan/client/chunks/tiny.js"],
25
+ "/:lang/memo": ["/_akan/client/rsc.js", "/_akan/client/chunks/tiny.js", "/_akan/client/memo.js"],
26
+ });
27
+
28
+ expect(sizes).toEqual([
29
+ { routeId: "/:lang/memo", files: 3, rawBytes: 8200, brotliBytes: 2300 },
30
+ { routeId: "/:lang", files: 2, rawBytes: 3200, brotliBytes: 1100 },
31
+ ]);
32
+ });
33
+
34
+ test("fails only a route past its entry plus the larger of 1% and 4KB, and leaves a route without one to the report", () => {
35
+ const budget = RouteJsBudget.parse(
36
+ JSON.stringify({ routeJsBrotliKB: { "/:lang": 100, "/:lang/memo": 120, "/:lang/feed": 500 } }),
37
+ );
38
+ const sizes = [
39
+ { ...size("/:lang", 100), brotliBytes: 100 * 1024 + 4096 },
40
+ { ...size("/:lang/memo", 120), brotliBytes: 120 * 1024 + 4097 },
41
+ { ...size("/:lang/feed", 500), brotliBytes: 500 * 1024 + 5000 },
42
+ size("/x", 900),
43
+ ];
44
+
45
+ expect(RouteJsBudget.limitOf(500)).toBe(500 * 1024 + 5120);
46
+ expect(budget.overages(sizes)).toEqual([
47
+ { routeId: "/:lang/memo", brotliBytes: 120 * 1024 + 4097, budgetKB: 120, limitBytes: 120 * 1024 + 4096 },
48
+ ]);
49
+ expect(budget.unbudgeted(sizes)).toEqual(["/x"]);
50
+ });
51
+
52
+ test("an update adds a new route, lowers an entry the route fits under, never raises one, and drops a gone route", () => {
53
+ const committed = RouteJsBudget.parse(
54
+ JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang": 100, "/:lang/memo": 120, "/:lang/gone": 50 } }),
55
+ );
56
+ const updated = committed.tightenedBy(
57
+ [
58
+ { ...size("/:lang", 90), brotliBytes: 90 * 1024 - 300 },
59
+ size("/:lang/memo", 130),
60
+ size("/:lang/new", 70),
61
+ size("/:lang/Zeta", 10),
62
+ ],
63
+ "local",
64
+ );
65
+
66
+ expect(Object.fromEntries(updated.kbByRoute)).toEqual({
67
+ "/:lang": 90,
68
+ "/:lang/memo": 120,
69
+ "/:lang/new": 70,
70
+ "/:lang/Zeta": 10,
71
+ });
72
+ expect(updated.stringify()).toBe(
73
+ `{\n "env": "local",\n "routeJsBrotliKB": {\n "/:lang": 90,\n "/:lang/Zeta": 10,\n "/:lang/memo": 120,\n "/:lang/new": 70\n }\n}\n`,
74
+ );
75
+ });
76
+
77
+ test("names the environment a budget was recorded for when the build is for another", () => {
78
+ const budget = RouteJsBudget.parse(JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang": 100 } }));
79
+
80
+ expect(budget.envNote("main")).toBe("");
81
+ expect(budget.envNote("local")).toBe(" (recorded for main, built for local)");
82
+ expect(RouteJsBudget.parse(JSON.stringify({ routeJsBrotliKB: {} })).envNote("local")).toBe("");
83
+ });
84
+
85
+ test("refuses a file that is not a map of route ids to whole KB", () => {
86
+ expect(() => RouteJsBudget.parse("{", "apps/shop/akan.budget.json")).toThrow(
87
+ "apps/shop/akan.budget.json is not valid JSON.",
88
+ );
89
+ for (const text of ["{}", '{"routeJsBrotliKB":[]}', '{"routeJsBrotliKB":{"/":1.5}}', '{"routeJsBrotliKB":{"/":0}}'])
90
+ expect(() => RouteJsBudget.parse(text)).toThrow('"routeJsBrotliKB" maps each route id to a whole number of KB.');
91
+ expect(() => RouteJsBudget.parse('{"env":1,"routeJsBrotliKB":{}}')).toThrow('"env" names the environment');
92
+ });
93
+
94
+ describe("check", () => {
95
+ const sizes = [{ ...size("/:lang/memo", 120), brotliBytes: 130 * 1024 }, size("/:lang/new", 70)];
96
+ const setup = async (text?: string) => {
97
+ const budgetPath = path.join(await makeTempRoot(), "akan.budget.json");
98
+ if (text !== undefined) await write(budgetPath, text);
99
+ return { budgetPath, source: "apps/shop/akan.budget.json", environment: "local" };
100
+ };
101
+
102
+ test("enforce fails a route over its limit and a file it cannot read", async () => {
103
+ const over = await setup(JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang/memo": 120 } }));
104
+ await expect(RouteJsBudget.check(sizes, { ...over, mode: "enforce" })).rejects.toThrow(
105
+ "1 route over the JS budget in apps/shop/akan.budget.json (recorded for main, built for local):",
106
+ );
107
+ const broken = await setup("{");
108
+ await expect(RouteJsBudget.check(sizes, { ...broken, mode: "enforce" })).rejects.toThrow("is not valid JSON");
109
+ });
110
+
111
+ test("report warns of an overage and a file it cannot read, and fails neither", async () => {
112
+ const over = await setup(JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang/memo": 120 } }));
113
+ expect(await RouteJsBudget.check(sizes, { ...over, mode: "report" })).toEqual({
114
+ summary:
115
+ "1 route(s) over apps/shop/akan.budget.json (recorded for main, built for local), reported only: /:lang/memo",
116
+ warnings: [
117
+ {
118
+ file: "apps/shop/akan.budget.json",
119
+ message:
120
+ "/:lang/memo loads 133,120 bytes brotli, over its limit of 126,976 (recorded for main, built for local); akan build fails on it",
121
+ fix: "shrink what the route loads, or raise its entry by hand in the same change",
122
+ },
123
+ ],
124
+ });
125
+ const broken = await setup("{");
126
+ expect(await RouteJsBudget.check(sizes, { ...broken, mode: "report" })).toEqual({
127
+ summary: "apps/shop/akan.budget.json is not valid JSON, not checked",
128
+ warnings: [
129
+ {
130
+ file: "apps/shop/akan.budget.json",
131
+ message: "apps/shop/akan.budget.json is not valid JSON; akan build fails on it",
132
+ fix: "correct it, or rewrite it with akan build <app> --update-budget",
133
+ },
134
+ ],
135
+ });
136
+ expect(await RouteJsBudget.check(sizes, { ...(await setup()), mode: "report" })).toEqual({
137
+ summary: "no akan.budget.json",
138
+ });
139
+ const within = await setup(JSON.stringify({ routeJsBrotliKB: { "/:lang/memo": 130 } }));
140
+ expect(await RouteJsBudget.check(sizes, { ...within, mode: "report" })).toEqual({
141
+ summary: "within apps/shop/akan.budget.json, 1 route(s) without an entry",
142
+ });
143
+ });
144
+
145
+ test("update writes the file for this build's environment, rewriting one it cannot read", async () => {
146
+ const broken = await setup("{");
147
+ expect(await RouteJsBudget.check(sizes, { ...broken, mode: "update" })).toEqual({
148
+ summary: "apps/shop/akan.budget.json rewritten (apps/shop/akan.budget.json is not valid JSON)",
149
+ });
150
+ const written = RouteJsBudget.parse(await Bun.file(broken.budgetPath).text());
151
+ expect(written.env).toBe("local");
152
+ expect(Object.fromEntries(written.kbByRoute)).toEqual({ "/:lang/memo": 130, "/:lang/new": 70 });
153
+ });
154
+
155
+ test("update keeps an entry its route outgrew, and still fails the route", async () => {
156
+ const over = await setup(JSON.stringify({ routeJsBrotliKB: { "/:lang/memo": 120 } }));
157
+ await expect(RouteJsBudget.check(sizes, { ...over, mode: "update" })).rejects.toThrow("/:lang/memo");
158
+ expect(RouteJsBudget.parse(await Bun.file(over.budgetPath).text()).kbByRoute.get("/:lang/memo")).toBe(120);
159
+ });
160
+ });
161
+
162
+ test("names each route over budget, its limit, and how to resolve it", () => {
163
+ expect(
164
+ RouteJsBudget.formatOverages(
165
+ [{ routeId: "/:lang/memo", brotliBytes: 128_000, budgetKB: 120, limitBytes: 126_976 }],
166
+ "apps/shop/akan.budget.json",
167
+ " (recorded for main, built for local)",
168
+ ),
169
+ ).toBe(
170
+ [
171
+ "1 route over the JS budget in apps/shop/akan.budget.json (recorded for main, built for local):",
172
+ " /:lang/memo: 128,000 bytes brotli, over its limit of 126,976 (120KB + the larger of 1% and 4KB)",
173
+ "Shrink what the route loads. If the growth is intended, raise its entry by hand in the same change.",
174
+ ].join("\n"),
175
+ );
176
+ });
177
+ });
@@ -0,0 +1,222 @@
1
+ import path from "node:path";
2
+ import { toError } from "akanjs/common";
3
+ import { FontPruner } from "./frontendBuild/fontPruner";
4
+ import { JsonFormatter } from "./jsonFormatter";
5
+
6
+ export interface RouteJsSize {
7
+ routeId: string;
8
+ files: number;
9
+ rawBytes: number;
10
+ brotliBytes: number;
11
+ }
12
+
13
+ export interface RouteJsOverage {
14
+ routeId: string;
15
+ brotliBytes: number;
16
+ budgetKB: number;
17
+ limitBytes: number;
18
+ }
19
+
20
+ export type RouteJsBudgetMode = "enforce" | "update" | "report" | "off";
21
+
22
+ export interface RouteJsBudgetWarning {
23
+ file: string;
24
+ message: string;
25
+ fix: string;
26
+ }
27
+
28
+ export interface RouteJsBudgetStatus {
29
+ summary: string;
30
+ warnings?: RouteJsBudgetWarning[];
31
+ }
32
+
33
+ export interface RouteJsBudgetCheck {
34
+ mode: Exclude<RouteJsBudgetMode, "off">;
35
+ budgetPath: string;
36
+ source: string;
37
+ environment: string;
38
+ }
39
+
40
+ export class RouteJsBudget {
41
+ static readonly fileName = "akan.budget.json";
42
+ static readonly section = "routeJsBrotliKB";
43
+ static readonly tableRows = 10;
44
+ //? BundleSizeGate's slack: bundles inline `AKAN_PUBLIC_*`, so a local build measures over 1KB off a `--env main` one.
45
+ static readonly #relativeSlack = 0.01;
46
+ static readonly #absoluteSlack = 4096;
47
+
48
+ //? A file under 1 KB has no `.br` sidecar (precompressArtifacts skips it), so it is served, and counted, raw.
49
+ static async measure(artifactDir: string, modulesByRoute: Record<string, string[]>): Promise<RouteJsSize[]> {
50
+ const sizeByUrl = new Map<string, { raw: number; brotli: number }>();
51
+ const sizeOf = async (url: string) => {
52
+ const file = path.join(artifactDir, url.replace(/^\/_akan\//, ""));
53
+ const raw = Bun.file(file).size;
54
+ const sidecar = Bun.file(`${file}.br`);
55
+ return { raw, brotli: (await sidecar.exists()) ? sidecar.size : raw };
56
+ };
57
+ for (const url of new Set(Object.values(modulesByRoute).flat())) sizeByUrl.set(url, await sizeOf(url));
58
+ return Object.entries(modulesByRoute)
59
+ .map(([routeId, urls]) => ({
60
+ routeId,
61
+ files: urls.length,
62
+ rawBytes: urls.reduce((total, url) => total + (sizeByUrl.get(url)?.raw ?? 0), 0),
63
+ brotliBytes: urls.reduce((total, url) => total + (sizeByUrl.get(url)?.brotli ?? 0), 0),
64
+ }))
65
+ .sort((a, b) => b.brotliBytes - a.brotliBytes || RouteJsBudget.#byCodeUnit(a.routeId, b.routeId));
66
+ }
67
+
68
+ static async load(filePath: string, source = filePath): Promise<RouteJsBudget | null> {
69
+ const file = Bun.file(filePath);
70
+ if (!(await file.exists())) return null;
71
+ return RouteJsBudget.parse(await file.text(), source);
72
+ }
73
+
74
+ //? `report` runs inside `akan verify`, where an overage or a broken file is no code error to fix in the edit loop.
75
+ static async check(
76
+ sizes: RouteJsSize[],
77
+ { mode, budgetPath, source, environment }: RouteJsBudgetCheck,
78
+ ): Promise<RouteJsBudgetStatus> {
79
+ const { committed, unreadable } = await RouteJsBudget.#loadFor(mode, budgetPath, source);
80
+ const budget = mode === "update" ? (committed ?? new RouteJsBudget()).tightenedBy(sizes, environment) : committed;
81
+ if (mode === "update" && budget) await Bun.write(budgetPath, budget.stringify());
82
+ const overages = budget?.overages(sizes) ?? [];
83
+ const envNote = committed?.envNote(environment) ?? "";
84
+ if (overages.length && mode !== "report") throw new Error(RouteJsBudget.formatOverages(overages, source, envNote));
85
+ const unbudgeted = budget?.unbudgeted(sizes).length ?? 0;
86
+ if (mode === "update") return { summary: `${source} ${unreadable ? `rewritten (${unreadable})` : "updated"}` };
87
+ if (unreadable)
88
+ return {
89
+ summary: `${unreadable}, not checked`,
90
+ warnings: [
91
+ {
92
+ file: source,
93
+ message: `${unreadable}; akan build fails on it`,
94
+ fix: "correct it, or rewrite it with akan build <app> --update-budget",
95
+ },
96
+ ],
97
+ };
98
+ if (!budget) return { summary: `no ${RouteJsBudget.fileName}` };
99
+ if (overages.length)
100
+ return {
101
+ summary: `${overages.length} route(s) over ${source}${envNote}, reported only: ${overages.map(({ routeId }) => routeId).join(", ")}`,
102
+ warnings: overages.map(({ routeId, brotliBytes, limitBytes }) => ({
103
+ file: source,
104
+ message: `${routeId} loads ${RouteJsBudget.#bytes(brotliBytes)} bytes brotli, over its limit of ${RouteJsBudget.#bytes(limitBytes)}${envNote}; akan build fails on it`,
105
+ fix: "shrink what the route loads, or raise its entry by hand in the same change",
106
+ })),
107
+ };
108
+ return { summary: `within ${source}${envNote}${unbudgeted ? `, ${unbudgeted} route(s) without an entry` : ""}` };
109
+ }
110
+
111
+ static async #loadFor(mode: RouteJsBudgetCheck["mode"], budgetPath: string, source: string) {
112
+ try {
113
+ return { committed: await RouteJsBudget.load(budgetPath, source), unreadable: null };
114
+ } catch (error) {
115
+ if (mode === "enforce") throw error;
116
+ return { committed: null, unreadable: toError(error).message.replace(/\.$/, "") };
117
+ }
118
+ }
119
+
120
+ static parse(text: string, source = RouteJsBudget.fileName): RouteJsBudget {
121
+ let json: unknown;
122
+ try {
123
+ json = JSON.parse(text);
124
+ } catch {
125
+ throw new Error(`${source} is not valid JSON.`);
126
+ }
127
+ const record =
128
+ typeof json === "object" && json !== null && !Array.isArray(json) ? (json as Record<string, unknown>) : {};
129
+ const section = record[RouteJsBudget.section];
130
+ const entries = typeof section === "object" && section !== null && !Array.isArray(section) ? section : null;
131
+ if (!entries || !Object.values(entries).every((kb) => Number.isInteger(kb) && (kb as number) > 0))
132
+ throw new Error(`${source}: "${RouteJsBudget.section}" maps each route id to a whole number of KB.`);
133
+ if (record.env !== undefined && typeof record.env !== "string")
134
+ throw new Error(`${source}: "env" names the environment the entries were measured for.`);
135
+ return new RouteJsBudget(new Map(Object.entries(entries as Record<string, number>)), record.env);
136
+ }
137
+
138
+ static kbOf(bytes: number) {
139
+ return Math.ceil(bytes / 1024);
140
+ }
141
+
142
+ static limitOf(budgetKB: number) {
143
+ const bytes = budgetKB * 1024;
144
+ return bytes + Math.max(Math.ceil(bytes * RouteJsBudget.#relativeSlack), RouteJsBudget.#absoluteSlack);
145
+ }
146
+
147
+ static formatTable(sizes: RouteJsSize[]): string[] {
148
+ const shown = sizes.slice(0, RouteJsBudget.tableRows);
149
+ const width = Math.max(...shown.map(({ routeId }) => routeId.length));
150
+ const { formatBytes } = FontPruner;
151
+ return [
152
+ "Route JS, largest first (brotli / raw):",
153
+ ...shown.map(
154
+ ({ routeId, brotliBytes, rawBytes }) =>
155
+ ` ${routeId.padEnd(width)} ${formatBytes(brotliBytes).padStart(8)} ${formatBytes(rawBytes).padStart(8)}`,
156
+ ),
157
+ ...(sizes.length > shown.length ? [` …and ${sizes.length - shown.length} more`] : []),
158
+ ];
159
+ }
160
+
161
+ static formatOverages(overages: RouteJsOverage[], source: string, envNote = ""): string {
162
+ const bytes = RouteJsBudget.#bytes;
163
+ return [
164
+ `${overages.length} route${overages.length === 1 ? "" : "s"} over the JS budget in ${source}${envNote}:`,
165
+ ...overages.map(
166
+ ({ routeId, brotliBytes, budgetKB, limitBytes }) =>
167
+ ` ${routeId}: ${bytes(brotliBytes)} bytes brotli, over its limit of ${bytes(limitBytes)} (${budgetKB}KB + the larger of 1% and 4KB)`,
168
+ ),
169
+ "Shrink what the route loads. If the growth is intended, raise its entry by hand in the same change.",
170
+ ].join("\n");
171
+ }
172
+
173
+ static #bytes(value: number) {
174
+ return value.toLocaleString("en-US");
175
+ }
176
+
177
+ static #byCodeUnit(a: string, b: string) {
178
+ return a < b ? -1 : a > b ? 1 : 0;
179
+ }
180
+
181
+ constructor(
182
+ readonly kbByRoute: ReadonlyMap<string, number> = new Map(),
183
+ readonly env?: string,
184
+ ) {}
185
+
186
+ overages(sizes: RouteJsSize[]): RouteJsOverage[] {
187
+ return sizes.flatMap(({ routeId, brotliBytes }) => {
188
+ const budgetKB = this.kbByRoute.get(routeId);
189
+ if (budgetKB === undefined) return [];
190
+ const limitBytes = RouteJsBudget.limitOf(budgetKB);
191
+ return brotliBytes > limitBytes ? [{ routeId, brotliBytes, budgetKB, limitBytes }] : [];
192
+ });
193
+ }
194
+
195
+ envNote(environment: string) {
196
+ return this.env && this.env !== environment ? ` (recorded for ${this.env}, built for ${environment})` : "";
197
+ }
198
+
199
+ unbudgeted(sizes: RouteJsSize[]): string[] {
200
+ return sizes.filter(({ routeId }) => !this.kbByRoute.has(routeId)).map(({ routeId }) => routeId);
201
+ }
202
+
203
+ tightenedBy(sizes: RouteJsSize[], env?: string): RouteJsBudget {
204
+ return new RouteJsBudget(
205
+ new Map(
206
+ sizes.map(({ routeId, brotliBytes }) => {
207
+ const measured = RouteJsBudget.kbOf(brotliBytes);
208
+ return [routeId, Math.min(this.kbByRoute.get(routeId) ?? measured, measured)];
209
+ }),
210
+ ),
211
+ env ?? this.env,
212
+ );
213
+ }
214
+
215
+ stringify(): string {
216
+ const entries = [...this.kbByRoute].sort(([a], [b]) => RouteJsBudget.#byCodeUnit(a, b));
217
+ return JsonFormatter.stringify(
218
+ { ...(this.env ? { env: this.env } : {}), [RouteJsBudget.section]: Object.fromEntries(entries) },
219
+ RouteJsBudget.fileName,
220
+ );
221
+ }
222
+ }