@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
@@ -0,0 +1,240 @@
1
+ import path from "node:path";
2
+ import { DeclarationReader, type MemberFilter } from "./declarationReader";
3
+
4
+ type Show =
5
+ | { kind: "signature" }
6
+ | ({ kind: "members" } & MemberFilter)
7
+ | { kind: "props"; omit?: string[] }
8
+ | { kind: "declaration" };
9
+
10
+ interface Item {
11
+ name: string;
12
+ from?: string;
13
+ file?: string;
14
+ show: Show;
15
+ }
16
+
17
+ interface Section {
18
+ title: string;
19
+ intro?: string;
20
+ items: Item[];
21
+ routeStages?: boolean;
22
+ }
23
+
24
+ const sections: Section[] = [
25
+ {
26
+ title: "Routes (`page/`)",
27
+ items: [
28
+ { name: "page", from: "akanjs/client", show: { kind: "signature" } },
29
+ { name: "layout", from: "akanjs/client", show: { kind: "signature" } },
30
+ { name: "rootLayout", from: "akanjs/client", show: { kind: "signature" } },
31
+ { name: "RouteBaseArgs", file: "client/route/routeArgs.ts", show: { kind: "props" } },
32
+ {
33
+ name: "router",
34
+ from: "akanjs/client",
35
+ show: {
36
+ kind: "members",
37
+ only: [
38
+ "push",
39
+ "replace",
40
+ "back",
41
+ "backOrFallback",
42
+ "refresh",
43
+ "redirect",
44
+ "notFound",
45
+ "canGoBack",
46
+ "getPath",
47
+ ],
48
+ },
49
+ },
50
+ { name: "RouteOptions", file: "client/router.ts", show: { kind: "props" } },
51
+ { name: "Link", from: "akanjs/ui", show: { kind: "signature" } },
52
+ { name: "CommonLinkProps", file: "ui/Link/Anchor.tsx", show: { kind: "props" } },
53
+ { name: "usePageLocation", from: "akanjs/webkit", show: { kind: "signature" } },
54
+ ],
55
+ routeStages: true,
56
+ },
57
+ {
58
+ title: "Services and adapters (`lib/_<service>/`, `srvkit/`)",
59
+ items: [
60
+ { name: "adapt", from: "akanjs/service", show: { kind: "signature" } },
61
+ { name: "serve", from: "akanjs/service", show: { kind: "signature" } },
62
+ { name: "ServiceOptions", file: "service/serve.ts", show: { kind: "props" } },
63
+ ],
64
+ },
65
+ {
66
+ title: "Endpoints (`*.signal.ts`)",
67
+ intro:
68
+ "`endpoint(srv.<module>, ({ query, mutation, pubsub, message }) => ({ … }))`: each builder takes the return type and a `SignalOption`, then chains the stages below and ends in `.exec()`.",
69
+ items: [
70
+ { name: "endpoint", from: "akanjs/signal", show: { kind: "signature" } },
71
+ { name: "BuildEndpoint", file: "signal/endpointInfo.ts", show: { kind: "members", prefix: "" } },
72
+ {
73
+ name: "EndpointInfo",
74
+ file: "signal/endpointInfo.ts",
75
+ show: { kind: "members", only: ["param", "search", "body", "room", "msg", "with", "exec"] },
76
+ },
77
+ { name: "EndpointArgProps", file: "signal/endpointInfo.ts", show: { kind: "props" } },
78
+ {
79
+ name: "SignalOption",
80
+ file: "signal/types.ts",
81
+ show: {
82
+ kind: "props",
83
+ omit: ["live", "lock", "onFailed", "once", "scheduleCron", "scheduleTime", "scheduleType"],
84
+ },
85
+ },
86
+ ],
87
+ },
88
+ {
89
+ title: "Dictionaries (`*.dictionary.ts`)",
90
+ items: [
91
+ { name: "serviceDictionary", from: "akanjs/dictionary", show: { kind: "signature" } },
92
+ { name: "FunctionTranslation", file: "dictionary/dictInfo.ts", show: { kind: "members" } },
93
+ { name: "FieldTranslation", file: "dictionary/dictInfo.ts", show: { kind: "members" } },
94
+ ],
95
+ },
96
+ {
97
+ title: "App config (`akan.config.ts`)",
98
+ items: [
99
+ { name: "AppConfig", from: "akanjs", show: { kind: "declaration" } },
100
+ { name: "AppConfigInput", from: "akanjs", show: { kind: "props" } },
101
+ { name: "AkanWebOption", from: "akanjs", show: { kind: "declaration" } },
102
+ { name: "AkanI18nConfig", file: "common/localeConfig.ts", show: { kind: "props" } },
103
+ ],
104
+ },
105
+ ];
106
+
107
+ const importOnly: Record<string, string[]> = {
108
+ "akanjs/base": ["Any", "Binary", "dayjs", "Float", "ID", "Int", "Upload"],
109
+ "akanjs/signal": ["internal", "None", "Public"],
110
+ };
111
+
112
+ const conventionImports = [
113
+ 'import { fetch, usePage, <Model> } from "@apps/<app>/client"; // pages and UI',
114
+ 'import * as cnst from "../cnst"; // inside lib/<module>/, beside srv and Err',
115
+ 'import { Err } from "../dict";',
116
+ 'import * as srv from "../srv";',
117
+ 'import { BookApi } from "@apps/<app>/srvkit"; // a layer barrel: common, srvkit, ui, webkit',
118
+ 'import { signedIn } from "@libs/shared/webkit"; // a workspace created with --libs true',
119
+ ];
120
+
121
+ const routeInternals = ["assertPattern", "resolveArgs", "toRouteModule"];
122
+
123
+ export class ApiReference {
124
+ static readonly guideline = "akanApi";
125
+
126
+ static target(cliDir: string) {
127
+ return path.join(cliDir, "guidelines", ApiReference.guideline, `${ApiReference.guideline}.instruction.md`);
128
+ }
129
+
130
+ static render(akanjsDir: string): string {
131
+ const reader = new DeclarationReader(akanjsDir);
132
+ const imports = new Map<string, Set<string>>();
133
+ const body: string[] = [];
134
+ for (const section of sections) {
135
+ body.push(`## ${section.title}`, "");
136
+ if (section.intro) body.push(section.intro, "");
137
+ const blocks: string[] = [];
138
+ for (const item of section.items) {
139
+ if (item.from) ApiReference.#addImport(imports, item.from, item.name, reader);
140
+ blocks.push(ApiReference.#renderItem(reader, item));
141
+ }
142
+ if (section.routeStages) blocks.push(ApiReference.#routeStages(reader));
143
+ body.push(...blocks.flatMap((block) => [block, ""]));
144
+ }
145
+ for (const [from, names] of Object.entries(importOnly))
146
+ for (const name of names) ApiReference.#addImport(imports, from, name, reader);
147
+ const importLines = [...imports.entries()]
148
+ .sort(([a], [b]) => a.localeCompare(b))
149
+ .map(([from, names]) => `import { ${[...names].sort((a, b) => a.localeCompare(b)).join(", ")} } from "${from}";`);
150
+ return [
151
+ "# Akan.js API reference",
152
+ "",
153
+ "Generated from the akanjs sources of this release: the import paths and signatures a first app needs, so none of",
154
+ "them has to be looked up in `node_modules`. A stage's return type is the same chain, left out below.",
155
+ "",
156
+ "## Imports",
157
+ "",
158
+ "```ts",
159
+ ...importLines,
160
+ ...conventionImports,
161
+ "```",
162
+ "",
163
+ ...body,
164
+ ]
165
+ .join("\n")
166
+ .trimEnd()
167
+ .concat("\n");
168
+ }
169
+
170
+ static #addImport(imports: Map<string, Set<string>>, from: string, name: string, reader: DeclarationReader) {
171
+ reader.exported(from, name);
172
+ if (from === "akanjs") return;
173
+ const names = imports.get(from) ?? new Set<string>();
174
+ names.add(name);
175
+ imports.set(from, names);
176
+ }
177
+
178
+ static #renderItem(reader: DeclarationReader, item: Item): string {
179
+ const symbol = item.from ? reader.exported(item.from, item.name) : reader.declared(item.file ?? "", item.name);
180
+ const where = item.from ? ` · \`${item.from}\`` : "";
181
+ const heading = `### \`${item.name}\`${where}`;
182
+ if (item.show.kind === "declaration")
183
+ return [heading, "", "```ts", reader.declarationText(symbol), "```"].join("\n");
184
+ if (item.show.kind === "signature")
185
+ return [heading, "", "```ts", ...reader.signatures(symbol, item.name), "```"].join("\n");
186
+ if (item.show.kind === "props")
187
+ return [heading, "", "```ts", ...reader.props(reader.declaredType(symbol), item.show.omit), "```"].join("\n");
188
+ const members = reader.members(reader.instanceType(symbol), item.show);
189
+ return [heading, "", "```ts", ...members.map(([, line]) => line), "```"].join("\n");
190
+ }
191
+
192
+ static #routeStages(reader: DeclarationReader): string {
193
+ const stagesOf = (name: string) =>
194
+ new Map(
195
+ reader.members(reader.instanceType(reader.exported("akanjs/client", name)), {
196
+ omit: name === "page" ? [...routeInternals, "loadApp"] : routeInternals,
197
+ }),
198
+ );
199
+ const page = stagesOf("page");
200
+ const layout = stagesOf("layout");
201
+ const root = stagesOf("rootLayout");
202
+ const shared = [...page.keys()].filter((key) => layout.has(key) && root.has(key));
203
+ const only = (stages: Map<string, string>, without: Map<string, string>[]) =>
204
+ [...stages.entries()].filter(([key]) => without.every((other) => !other.has(key))).map(([, line]) => line);
205
+ return [
206
+ "### Route stages",
207
+ "",
208
+ "Shared by `page()`, `layout()` and `rootLayout()`:",
209
+ "",
210
+ "```ts",
211
+ ...shared.map((key) => page.get(key) ?? ""),
212
+ "```",
213
+ "",
214
+ "Only on `page()`:",
215
+ "",
216
+ "```ts",
217
+ ...only(page, [layout, root]),
218
+ "```",
219
+ "",
220
+ "On `layout()` and `rootLayout()`, where a line replaces its shared form above:",
221
+ "",
222
+ "```ts",
223
+ reader.declarationText(reader.declared("client/route/LayoutDefinition.ts", "LayoutExtra")),
224
+ ...[...layout.entries()].filter(([key, line]) => page.get(key) !== line).map(([, line]) => line),
225
+ "```",
226
+ "",
227
+ "Only on `rootLayout()`, before `.param()` and `.search()`:",
228
+ "",
229
+ "```ts",
230
+ ...only(root, [page, layout]),
231
+ "```",
232
+ ].join("\n");
233
+ }
234
+ }
235
+
236
+ if (import.meta.main) {
237
+ const akanjsDir = path.resolve(import.meta.dir, "../../akanjs");
238
+ const target = ApiReference.target(path.resolve(import.meta.dir, "../cli"));
239
+ await Bun.write(target, ApiReference.render(akanjsDir));
240
+ }
@@ -14,13 +14,14 @@ describe("ApplicationBuildReporter.printSummary", () => {
14
14
  const rawLog = spyOn(Logger, "rawLog").mockImplementation(() => undefined);
15
15
  afterEach(() => rawLog.mockClear());
16
16
 
17
- test("names every variable a deployed build needs when the build is for local", () => {
17
+ test("names the flag and the variables a deployed build needs when the build is for local", () => {
18
18
  ApplicationBuildReporter.printSummary(resultFor("local"));
19
19
 
20
20
  const notice = rawLog.mock.calls.map(([line]) => line).at(-1);
21
21
  expect(notice).toBe(ApplicationBuildReporter.localBuildNotice);
22
- for (const key of ["AKAN_PUBLIC_ENV=main", "AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"])
23
- expect(notice).toContain(key);
22
+ for (const part of ["akan build <app> --env main", "AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"])
23
+ expect(notice).toContain(part);
24
+ expect(notice).not.toContain("dev routes");
24
25
  });
25
26
 
26
27
  test("adds nothing to a build for a deployed environment", () => {
@@ -29,4 +30,25 @@ describe("ApplicationBuildReporter.printSummary", () => {
29
30
  expect(rawLog.mock.calls.map(([line]) => line)).not.toContain(ApplicationBuildReporter.localBuildNotice);
30
31
  expect(rawLog.mock.calls.at(-1)?.[0]).toBe("Done in 1.2s");
31
32
  });
33
+
34
+ test("lists the largest routes' JS before the paths, and how many more it left out", () => {
35
+ const routeJs = Array.from({ length: 12 }, (_, idx) => ({
36
+ routeId: `/:lang/r${idx}`,
37
+ files: 3,
38
+ rawBytes: (12 - idx) * 3 * 1024,
39
+ brotliBytes: (12 - idx) * 1024,
40
+ }));
41
+ ApplicationBuildReporter.printSummary({ ...resultFor("main"), routeJs });
42
+
43
+ const lines = rawLog.mock.calls.map(([line]) => line);
44
+ expect(lines.slice(1, 4)).toEqual([
45
+ "Route JS, largest first (brotli / raw):",
46
+ " /:lang/r0 12.0KB 36.0KB",
47
+ " /:lang/r1 11.0KB 33.0KB",
48
+ ]);
49
+ expect(lines).toContain(" …and 2 more");
50
+ expect(lines.indexOf(" …and 2 more")).toBeLessThan(
51
+ lines.indexOf("Route artifacts: dist/apps/shop/.akan/artifact"),
52
+ );
53
+ });
32
54
  });
@@ -1,6 +1,7 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Logger } from "akanjs/common";
4
+ import { RouteJsBudget, type RouteJsBudgetWarning, type RouteJsSize } from "./routeJsBudget";
4
5
 
5
6
  export interface ApplicationBuildPhaseResult {
6
7
  id: string;
@@ -16,6 +17,8 @@ export interface ApplicationBuildResult {
16
17
  outputDir: string;
17
18
  artifactDir: string;
18
19
  environment: string;
20
+ routeJs?: RouteJsSize[];
21
+ routeJsWarnings?: RouteJsBudgetWarning[];
19
22
  }
20
23
 
21
24
  export interface ApplicationBuildProgressReporter {
@@ -33,6 +36,10 @@ export class ApplicationBuildReporter {
33
36
 
34
37
  static printSummary(result: ApplicationBuildResult) {
35
38
  Logger.rawLog("");
39
+ if (result.routeJs?.length) {
40
+ for (const line of RouteJsBudget.formatTable(result.routeJs)) Logger.rawLog(line);
41
+ Logger.rawLog("");
42
+ }
36
43
  Logger.rawLog(`Route artifacts: ${result.artifactDir}`);
37
44
  Logger.rawLog(`Server output: ${result.outputDir}`);
38
45
  Logger.rawLog(`Done in ${ApplicationBuildReporter.formatDuration(result.durationMs)}`);
@@ -40,7 +47,7 @@ export class ApplicationBuildReporter {
40
47
  }
41
48
 
42
49
  static readonly localBuildNotice =
43
- "Built for local, which serves the dev routes to every caller. For a host others can reach, build with AKAN_PUBLIC_ENV=main, AKAN_PUBLIC_REPO_NAME and AKAN_PUBLIC_SERVE_DOMAIN set.";
50
+ "Built for local: the server runs without endpoint rate limits, and boots on local defaults (Redis on localhost, the first database mode) where a deployment would stop to ask. For a host others can reach, run `akan build <app> --env main` with AKAN_PUBLIC_REPO_NAME and AKAN_PUBLIC_SERVE_DOMAIN set in the shell or the workspace .env.";
44
51
 
45
52
  static formatError(error: unknown, workspaceRoot = process.cwd()): string {
46
53
  if (error instanceof AggregateError) {
@@ -159,7 +159,7 @@ console.info(new SampleService().name);
159
159
  }
160
160
  });
161
161
 
162
- test("bakes into the route bundles the env their server env is, not the shell's that the root .env outranks", async () => {
162
+ test("builds for the shell's AKAN_PUBLIC_ENV over the root .env's, and for --env over both", async () => {
163
163
  const { root, workspace } = await createTempApp("portal");
164
164
  const shellEnv = process.env.AKAN_PUBLIC_ENV;
165
165
  const stoppedBeforePhases = (app: AppExecutor) => {
@@ -171,17 +171,37 @@ console.info(new SampleService().name);
171
171
  const bakedEnv = (app: AppExecutor) =>
172
172
  (bundleDefine(app, "build", "ssr") as { [key: string]: string })["process.env.AKAN_PUBLIC_ENV"];
173
173
  try {
174
+ expect(await readFile(path.join(root, ".env"), "utf8")).toContain("AKAN_PUBLIC_ENV=local\n");
174
175
  process.env.AKAN_PUBLIC_ENV = "main";
175
176
  const app = stoppedBeforePhases(AppExecutor.from(workspace, "portal"));
176
- await expect(new ApplicationBuildRunner(app).build()).rejects.toThrow("stopped before the phases");
177
- expect(bakedEnv(app)).toBe(JSON.stringify("local"));
177
+ const warn = spyOn(app.logger, "warn").mockImplementation(() => undefined);
178
+ const runner = new ApplicationBuildRunner(app);
179
+ await expect(runner.build()).rejects.toThrow("stopped before the phases");
180
+ expect(bakedEnv(app)).toBe(JSON.stringify("main"));
181
+ expect(warn.mock.calls.map(([line]) => line)).toEqual([
182
+ "The shell (or a .env.local) outranks the workspace .env: the bundles and the image take AKAN_PUBLIC_ENV=main (.env: local). Unset it there to build what the .env says.",
183
+ ]);
184
+ await runner.buildAppMeta();
185
+ expect(JSON.parse(await readFile(path.join(app.dist.cwdPath, "akan.env.json"), "utf8"))).toMatchObject({
186
+ AKAN_PUBLIC_ENV: "main",
187
+ });
188
+ expect(await readFile(path.join(app.dist.cwdPath, "Dockerfile"), "utf8")).toContain("ENV AKAN_PUBLIC_ENV=main\n");
178
189
 
179
190
  process.env.AKAN_PUBLIC_ENV = "main";
180
191
  const desktop = stoppedBeforePhases(AppExecutor.from(workspace, "portal"));
192
+ const desktopWarn = spyOn(desktop.logger, "warn").mockImplementation(() => undefined);
181
193
  await expect(new ApplicationBuildRunner(desktop, { environment: "debug" }).build()).rejects.toThrow(
182
194
  "stopped before the phases",
183
195
  );
184
196
  expect(bakedEnv(desktop)).toBe(JSON.stringify("debug"));
197
+ expect(desktopWarn).not.toHaveBeenCalled();
198
+
199
+ process.env.AKAN_PUBLIC_ENV = "";
200
+ const unset = stoppedBeforePhases(AppExecutor.from(workspace, "portal"));
201
+ await expect(new ApplicationBuildRunner(unset, { environment: "debug" }).build()).rejects.toThrow(
202
+ "stopped before the phases",
203
+ );
204
+ expect(bakedEnv(unset)).toBe(JSON.stringify("debug"));
185
205
  } finally {
186
206
  if (shellEnv === undefined) delete process.env.AKAN_PUBLIC_ENV;
187
207
  else process.env.AKAN_PUBLIC_ENV = shellEnv;
@@ -189,7 +209,7 @@ console.info(new SampleService().name);
189
209
  }
190
210
  });
191
211
 
192
- test("bakes into the bundles every AKAN_PUBLIC_* its Dockerfile carries, and names the shell values it outranked", async () => {
212
+ test("bakes into the bundles every AKAN_PUBLIC_* its Dockerfile carries, each the shell's over the root .env's", async () => {
193
213
  const { root, workspace } = await createTempApp("portal");
194
214
  const keys = ["AKAN_PUBLIC_ENV", "AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"] as const;
195
215
  const shellEnv = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
@@ -198,11 +218,8 @@ console.info(new SampleService().name);
198
218
  "AKAN_PUBLIC_ENV=main\nAKAN_PUBLIC_REPO_NAME=repo\nAKAN_PUBLIC_SERVE_DOMAIN=akanjs.com\n",
199
219
  );
200
220
  await Promise.all(["private", "public"].map((dir) => mkdir(path.join(root, "apps", "portal", dir))));
201
- Object.assign(process.env, {
202
- AKAN_PUBLIC_ENV: "local",
203
- AKAN_PUBLIC_REPO_NAME: "shell-repo",
204
- AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
205
- });
221
+ for (const key of keys) delete process.env[key];
222
+ process.env.AKAN_PUBLIC_REPO_NAME = "shell-repo";
206
223
  const app = AppExecutor.from(workspace, "portal");
207
224
  const warn = spyOn(app.logger, "warn").mockImplementation(() => undefined);
208
225
  const runner = new ApplicationBuildRunner(app);
@@ -214,11 +231,14 @@ console.info(new SampleService().name);
214
231
  const define = bundleDefine(app, "build", "csr") as { [key: string]: string };
215
232
  const imageEnv = (await app.getConfig()).getProductionEnv();
216
233
  for (const key of keys) expect(define[`process.env.${key}`]).toBe(JSON.stringify(imageEnv[key]));
217
- expect(imageEnv.AKAN_PUBLIC_SERVE_DOMAIN).toBe("akanjs.com");
218
- expect(warn).toHaveBeenCalledTimes(1);
219
- expect(warn.mock.calls[0]?.[0]).toBe(
220
- "The workspace .env outranks this process's environment: the bundles and the image take AKAN_PUBLIC_ENV=main (not local), AKAN_PUBLIC_REPO_NAME=repo (not shell-repo), AKAN_PUBLIC_SERVE_DOMAIN=akanjs.com (not localhost).",
221
- );
234
+ expect(imageEnv).toMatchObject({
235
+ AKAN_PUBLIC_ENV: "main",
236
+ AKAN_PUBLIC_REPO_NAME: "shell-repo",
237
+ AKAN_PUBLIC_SERVE_DOMAIN: "akanjs.com",
238
+ });
239
+ expect(warn.mock.calls.map(([line]) => line)).toEqual([
240
+ "The shell (or a .env.local) outranks the workspace .env: the bundles and the image take AKAN_PUBLIC_REPO_NAME=shell-repo (.env: repo). Unset it there to build what the .env says.",
241
+ ]);
222
242
  } finally {
223
243
  for (const key of keys) {
224
244
  if (shellEnv[key] === undefined) delete process.env[key];
@@ -9,14 +9,17 @@ import type {
9
9
  ApplicationBuildResult,
10
10
  } from "./applicationBuildReporter";
11
11
  import type { App } from "./commandDecorators";
12
- import { WorkspaceExecutor } from "./executors";
12
+ import { parseEnvFile, WorkspaceExecutor } from "./executors";
13
13
  import {
14
14
  AllRoutesBuilder,
15
15
  CsrArtifactBuilder,
16
16
  FontPruner,
17
+ NARROWABLE_VENDOR_SPECIFIERS,
17
18
  precompressArtifacts,
18
19
  SsrBaseArtifactBuilder,
20
+ VendorImportScanner,
19
21
  } from "./frontendBuild";
22
+ import { RouteJsBudget, type RouteJsBudgetMode, type RouteJsBudgetStatus, type RouteJsSize } from "./routeJsBudget";
20
23
  import { Spinner } from "./spinner";
21
24
  import { createServerEnvPlugin } from "./transforms/serverEnvPlugin";
22
25
  import { ServerEnvProfile } from "./transforms/serverEnvProfile";
@@ -31,7 +34,16 @@ export interface TypecheckOptions {
31
34
  report?: "pretty" | "records";
32
35
  }
33
36
 
34
- export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "assets" | "compress" | "metadata";
37
+ export type BuildPhaseId =
38
+ | "prepare"
39
+ | "typecheck"
40
+ | "backend"
41
+ | "ssr"
42
+ | "csr"
43
+ | "assets"
44
+ | "compress"
45
+ | "budget"
46
+ | "metadata";
35
47
 
36
48
  export type BuildPhaseResult = ApplicationBuildPhaseResult & { id: BuildPhaseId };
37
49
  export type BuildResult = ApplicationBuildResult;
@@ -43,6 +55,8 @@ export interface ApplicationBuildRunnerOptions {
43
55
  environment?: string;
44
56
  /** Bundles `env/env.server.<environment>.<profile>.ts`, which may hold no secret, in place of the environment's file. */
45
57
  profile?: string;
58
+ /** `update` writes akan.budget.json, then enforces it; `report` names an overage without failing the build. */
59
+ budget?: RouteJsBudgetMode;
46
60
  }
47
61
  export interface BuildOptions {
48
62
  spinner?: boolean;
@@ -59,6 +73,7 @@ const BUILD_PHASE_EMOJIS: Record<BuildPhaseId, string> = {
59
73
  csr: "🎨",
60
74
  assets: "✂️",
61
75
  compress: "🗜️",
76
+ budget: "📏",
62
77
  metadata: "📝",
63
78
  };
64
79
 
@@ -86,39 +101,46 @@ export class ApplicationBuildRunner {
86
101
  #reporter?: BuildProgressReporter;
87
102
  #environment?: string;
88
103
  #profile?: string;
104
+ #budget: RouteJsBudgetMode;
105
+ #modulesByRoute?: Record<string, string[]>;
89
106
  #spinner?: boolean;
90
107
  #startedAt = Date.now();
91
108
  #phases: BuildPhaseResult[] = [];
92
109
 
93
- constructor(app: App, { fast = false, reporter, environment, profile }: ApplicationBuildRunnerOptions = {}) {
110
+ constructor(
111
+ app: App,
112
+ { fast = false, reporter, environment, profile, budget = "report" }: ApplicationBuildRunnerOptions = {},
113
+ ) {
94
114
  this.#app = app;
95
115
  this.#fast = fast;
96
116
  this.#reporter = reporter;
97
117
  this.#environment = environment;
98
118
  this.#profile = profile;
119
+ this.#budget = budget;
99
120
  }
100
121
 
101
122
  get #envDir() {
102
123
  return path.join(this.#app.cwdPath, "env");
103
124
  }
104
125
 
105
- //? Bundles bake AKAN_PUBLIC_* from process.env, where the shell outranks the .env that the server env and the
106
- //? Dockerfile read: a page built otherwise drops the image's tokens as foreign or dials another serve domain.
126
+ //? Bundles bake AKAN_PUBLIC_* from process.env, which lacks `--env` and the defaults an unset repo name or serve
127
+ //? domain takes: a page built otherwise drops the image's tokens as foreign or dials another serve domain.
107
128
  #pinImageEnv(baseDevEnv: BaseDevEnv, environment: string) {
108
129
  const imageEnv = {
109
130
  AKAN_PUBLIC_ENV: environment,
110
131
  AKAN_PUBLIC_REPO_NAME: baseDevEnv.repoName,
111
132
  AKAN_PUBLIC_SERVE_DOMAIN: baseDevEnv.serveDomain,
112
133
  };
113
- const outranked = Object.entries(imageEnv).filter(([key, value]) => {
114
- const given = process.env[key];
115
- return !!given && given !== value && !(key === "AKAN_PUBLIC_ENV" && this.#environment);
134
+ const dotenv = parseEnvFile(path.join(this.#app.workspace.workspaceRoot, ".env"));
135
+ const overridden = Object.entries(imageEnv).filter(([key, value]) => {
136
+ if (!dotenv[key] || dotenv[key] === value || WorkspaceExecutor.publicEnvPin?.[key]) return false;
137
+ return !(key === "AKAN_PUBLIC_ENV" && this.#environment);
116
138
  });
117
- if (outranked.length)
139
+ if (overridden.length)
118
140
  this.#app.logger.warn(
119
- `The workspace .env outranks this process's environment: the bundles and the image take ${outranked
120
- .map(([key, value]) => `${key}=${value} (not ${process.env[key]})`)
121
- .join(", ")}.`,
141
+ `The shell (or a .env.local) outranks the workspace .env: the bundles and the image take ${overridden
142
+ .map(([key, value]) => `${key}=${value} (.env: ${dotenv[key]})`)
143
+ .join(", ")}. Unset it there to build what the .env says.`,
122
144
  );
123
145
  Object.assign(process.env, imageEnv);
124
146
  }
@@ -176,9 +198,17 @@ export class ApplicationBuildRunner {
176
198
  ? `${result.files} files, ${ApplicationBuildRunner.formatBytes(result.inputBytes)} -> gzip ${ApplicationBuildRunner.formatBytes(result.outputBytes)} / br ${ApplicationBuildRunner.formatBytes(result.brotliBytes)}`
177
199
  : "no files",
178
200
  );
201
+ const routeJs = await this.#runPhase(
202
+ "budget",
203
+ "Measuring route JS",
204
+ () => this.#measureRouteJs(environment),
205
+ (result) => result?.summary ?? (this.#budget === "off" ? "off for this build" : "no server pages"),
206
+ );
179
207
  await this.#runPhase("metadata", "Writing production metadata", () => this.buildAppMeta());
180
208
  return {
181
209
  phases: this.#phases,
210
+ ...(routeJs ? { routeJs: routeJs.sizes } : {}),
211
+ ...(routeJs?.warnings ? { routeJsWarnings: routeJs.warnings } : {}),
182
212
  durationMs: Date.now() - this.#startedAt,
183
213
  outputDir: this.#app.dist.cwdPath,
184
214
  artifactDir: path.join(this.#app.dist.cwdPath, ".akan/artifact"),
@@ -293,7 +323,6 @@ export class ApplicationBuildRunner {
293
323
  #serverEnvPlugin(baseEnv: string, branches: Iterable<string>): BunPlugin {
294
324
  this.#serverEnv ??= createServerEnvPlugin({
295
325
  envDir: this.#envDir,
296
- //? The root .env wins over the shell in baseDevEnv, so a `--env` build names its own; a desktop app runs it.
297
326
  environment: this.#environment ?? baseEnv,
298
327
  environments: ["local", "testing", ...branches],
299
328
  profile: this.#profile,
@@ -402,13 +431,46 @@ void run().catch((error) => {
402
431
  return null;
403
432
  }
404
433
  const { baseDevEnv, branches } = await this.#app.getConfig();
434
+ //? Routes first: the vendor entries export only the names the route bundles import.
435
+ const routes = new AllRoutesBuilder(this.#app, "build");
436
+ const allRoutes = await routes.build();
437
+ const vendorExports = await VendorImportScanner.exportsUsedIn(
438
+ path.join(this.#app.dist.cwdPath, ".akan/artifact/client"),
439
+ NARROWABLE_VENDOR_SPECIFIERS,
440
+ );
405
441
  const base = await new SsrBaseArtifactBuilder(this.#app, "build", {
406
442
  serverEnv: this.#serverEnvPlugin(baseDevEnv.env, branches),
443
+ vendorExports,
407
444
  }).build();
408
- const allRoutes = await new AllRoutesBuilder(this.#app, base.artifact, "build").build();
445
+ this.#modulesByRoute = (await routes.writeModulePreloads(base.runtimeChunks)).modulesByRoute();
409
446
  return { base, allRoutes };
410
447
  }
411
448
 
449
+ //? After `compress`: a route is measured by the brotli bytes it is served as, read from the `.br` sidecars.
450
+ async #measureRouteJs(environment: string): Promise<(RouteJsBudgetStatus & { sizes: RouteJsSize[] }) | null> {
451
+ if (this.#budget === "off" || !this.#modulesByRoute) return null;
452
+ const sizes = await RouteJsBudget.measure(
453
+ path.join(this.#app.dist.cwdPath, ".akan/artifact"),
454
+ this.#modulesByRoute,
455
+ );
456
+ const budgetPath = path.join(this.#app.cwdPath, RouteJsBudget.fileName);
457
+ const { summary: status, warnings } = await RouteJsBudget.check(sizes, {
458
+ mode: this.#budget,
459
+ budgetPath,
460
+ source: path.relative(this.#app.workspace.workspaceRoot, budgetPath),
461
+ environment,
462
+ });
463
+ const largest = sizes[0];
464
+ const { formatBytes } = ApplicationBuildRunner;
465
+ return {
466
+ sizes,
467
+ summary: largest
468
+ ? `${sizes.length} routes, largest ${largest.routeId} ${formatBytes(largest.brotliBytes)} brotli (${formatBytes(largest.rawBytes)} raw); ${status}`
469
+ : status,
470
+ ...(warnings ? { warnings } : {}),
471
+ };
472
+ }
473
+
412
474
  //? Opt-in and outside `dist/`: a metafile is the size of the module graph, and `dist/` is what the image copies.
413
475
  async #buildOrThrow(label: string, config: Bun.BuildConfig): Promise<Bun.BuildOutput> {
414
476
  const flag = process.env.AKAN_BUILD_METAFILE;
@@ -101,6 +101,48 @@ describe("resolveSsrPageEntries", () => {
101
101
  expect(generatedDictMacro).toContain("export const allDictionary = getAllDictionary();");
102
102
  });
103
103
 
104
+ test("bootstraps the client runtime and store, not the client barrel, at every root boundary", async () => {
105
+ const appRoot = await makeTempRoot();
106
+ const pageRoot = path.join(appRoot, "page");
107
+
108
+ await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
109
+ await write(path.join(appRoot, "lib", "st.ts"), "export const st = {};\n");
110
+ await write(path.join(pageRoot, "(home)", "_layout.tsx"), 'export const theme = "dark";\n');
111
+ await write(path.join(pageRoot, "(admin)", "_layout.tsx"), 'export const theme = "light";\n');
112
+
113
+ const entries = await resolveSsrPageEntries({
114
+ appCwdPath: appRoot,
115
+ appName: "demo",
116
+ pageKeys: ["./(home)/_layout.tsx", "./(home)/_index.tsx", "./(admin)/_layout.tsx", "./(admin)/users.tsx"],
117
+ });
118
+
119
+ const roots = entries.filter((entry) => entry.key.endsWith("__root_layout.tsx"));
120
+ expect(roots.map((entry) => entry.key)).toEqual(["./(admin)/__root_layout.tsx", "./(home)/__root_layout.tsx"]);
121
+ for (const root of roots) {
122
+ const generatedSource = await Bun.file(root.moduleAbsPath).text();
123
+ expect(generatedSource).toContain('import "@apps/demo/lib/useClient";\nimport "@apps/demo/lib/st";\n');
124
+ expect(generatedSource).not.toContain("@apps/demo/client");
125
+ }
126
+ });
127
+
128
+ test("imports no store bootstrap for an app without a store", async () => {
129
+ const appRoot = await makeTempRoot();
130
+ await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
131
+ await write(path.join(appRoot, "page", "_layout.tsx"), 'export const theme = "dark";\n');
132
+
133
+ const entries = await resolveSsrPageEntries({
134
+ appCwdPath: appRoot,
135
+ appName: "demo",
136
+ pageKeys: ["./_layout.tsx", "./_index.tsx"],
137
+ });
138
+
139
+ const root = entries.find((entry) => entry.key === "./__root_layout.tsx");
140
+ const generatedSource = await Bun.file(root?.moduleAbsPath ?? "").text();
141
+ expect(generatedSource).toContain("<System.Provider");
142
+ expect(generatedSource).not.toContain("@apps/demo/lib/");
143
+ expect(generatedSource).not.toContain("@apps/demo/client");
144
+ });
145
+
104
146
  test("awaits an async grouped root layout in the CSR bundle instead of mounting it as JSX", async () => {
105
147
  const appRoot = await makeTempRoot();
106
148
  const pageRoot = path.join(appRoot, "page");