@akanjs/devkit 3.0.0-alpha.9 → 3.0.0-alpha.91

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 (183) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/DEV_RUNTIME_KNOBS.md +27 -0
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/agentsIndex.test.ts +10 -0
  6. package/agentsIndex.ts +21 -1
  7. package/aiEditor.ts +1 -1
  8. package/akanApp/BackendImportGraph.test.ts +120 -0
  9. package/akanApp/BackendImportGraph.ts +167 -0
  10. package/akanApp/akanApp.host.test.ts +16 -678
  11. package/akanApp/akanApp.host.ts +113 -578
  12. package/akanApp/devHostPolicy.test.ts +542 -0
  13. package/akanApp/devHostPolicy.ts +442 -0
  14. package/akanApp/index.ts +2 -0
  15. package/akanConfig/akanConfig.test.ts +182 -14
  16. package/akanConfig/akanConfig.ts +143 -47
  17. package/akanConfig/types.ts +9 -0
  18. package/akanContext.ts +30 -14
  19. package/akanMcpContract.ts +25 -4
  20. package/appSelectionMemory.ts +28 -0
  21. package/applicationBuildRunner.test.ts +1 -1
  22. package/applicationBuildRunner.ts +45 -21
  23. package/artifact/implicitRootLayout.test.ts +67 -0
  24. package/artifact/implicitRootLayout.ts +27 -7
  25. package/artifact/routeSeedIndex.test.ts +1 -0
  26. package/biome.base.json +86 -47
  27. package/biomeBase.ts +6 -5
  28. package/capacitorApp.ts +10 -0
  29. package/cloud/globalConfig.ts +9 -2
  30. package/codegenLock.test.ts +137 -0
  31. package/codegenLock.ts +136 -0
  32. package/commandDecorators/argMeta.ts +9 -1
  33. package/commandDecorators/command.ts +94 -7
  34. package/commandDecorators/commandBuilder.ts +20 -5
  35. package/commandDecorators/commandDecorators.test.ts +115 -3
  36. package/commandDecorators/helpFormatter.ts +4 -0
  37. package/dependencyScanner.test.ts +99 -0
  38. package/dependencyScanner.ts +27 -24
  39. package/devkitUtils.test.ts +0 -104
  40. package/executors.test.ts +129 -4
  41. package/executors.ts +105 -24
  42. package/fileEditor.ts +19 -19
  43. package/fileSys.ts +19 -1
  44. package/formSetterScanner.test.ts +80 -0
  45. package/formSetterScanner.ts +92 -0
  46. package/frontendBuild/autoImportSync.test.ts +58 -0
  47. package/frontendBuild/autoImportSync.ts +7 -2
  48. package/frontendBuild/buildRouteClient.test.ts +47 -24
  49. package/frontendBuild/clientBuildTypes.ts +4 -0
  50. package/frontendBuild/clientEntriesBundler.ts +4 -1
  51. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  52. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  53. package/frontendBuild/cssCompiler.ts +123 -12
  54. package/frontendBuild/cssImportResolver.ts +8 -7
  55. package/frontendBuild/devGeneratedIndexSync.ts +3 -2
  56. package/frontendBuild/fontOptimizer.ts +37 -18
  57. package/frontendBuild/fontPruner.test.ts +220 -0
  58. package/frontendBuild/fontPruner.ts +206 -0
  59. package/frontendBuild/frontendBuild.test.ts +178 -10
  60. package/frontendBuild/hmrWatcher.ts +1 -1
  61. package/frontendBuild/index.ts +1 -1
  62. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  63. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  64. package/frontendBuild/routeClientBuilder.ts +12 -5
  65. package/frontendBuild/sourceMtimeIndex.ts +1 -1
  66. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  67. package/frontendBuild/styleContract.ts +14 -20
  68. package/frontendBuild/themeValidator.ts +22 -17
  69. package/frontendBuild/vendorSpecifiers.ts +1 -0
  70. package/frontendBuild/watchRootResolver.test.ts +67 -0
  71. package/frontendBuild/watchRootResolver.ts +13 -1
  72. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  73. package/incrementalBuilder/devWatchBatch.ts +6 -2
  74. package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
  75. package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
  76. package/index.ts +0 -7
  77. package/integration/devStabilityHarness.ts +2 -10
  78. package/libSource.test.ts +112 -0
  79. package/libSource.ts +126 -0
  80. package/lint/__fixtures__/README.md +40 -0
  81. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  82. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  83. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  84. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  85. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  86. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  87. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  88. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  89. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  90. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  91. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  92. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  93. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  94. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  95. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  96. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  97. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  98. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  99. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  100. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  101. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  102. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  103. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  104. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  105. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  106. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  107. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  108. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  109. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  110. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  111. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  112. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  113. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  114. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  115. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  116. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  117. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  118. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  119. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  120. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  121. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  122. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  123. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  124. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  125. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  126. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  127. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  128. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  129. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  130. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  131. package/lint/gritRules.test.ts +178 -0
  132. package/lint/no-arbitrary-color.grit +1 -1
  133. package/lint/no-async-component-in-ui.grit +35 -0
  134. package/lint/no-bang-comment-in-client.grit +23 -10
  135. package/lint/no-daisyui-legacy-class.grit +26 -9
  136. package/lint/no-deprecated-log-level.grit +17 -0
  137. package/lint/no-init-fetch-in-client.grit +47 -0
  138. package/lint/no-inline-color.grit +10 -8
  139. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  140. package/lint/no-model-type-in-util-zone.grit +58 -0
  141. package/lint/no-raw-palette-class.grit +3 -3
  142. package/lint/no-unpublished-form-setter.grit +41 -0
  143. package/lint/non-scalar-props-restricted.grit +16 -7
  144. package/linter.test.ts +80 -0
  145. package/linter.ts +99 -25
  146. package/package.json +5 -8
  147. package/prompter.ts +9 -4
  148. package/qualityScanner.test.ts +116 -0
  149. package/qualityScanner.ts +89 -21
  150. package/recipeScanner.ts +4 -1
  151. package/scanInfo.ts +32 -26
  152. package/semver.test.ts +26 -0
  153. package/semver.ts +31 -0
  154. package/slicePlanner.test.ts +182 -0
  155. package/slicePlanner.ts +235 -0
  156. package/ssrScanner.test.ts +301 -0
  157. package/subspace.test.ts +418 -0
  158. package/subspace.ts +723 -0
  159. package/subspaceConfig.ts +76 -0
  160. package/transforms/asyncDefaultExportDetector.ts +103 -0
  161. package/transforms/barrelImportsPlugin.ts +1 -1
  162. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  163. package/transforms/transforms.test.ts +5 -5
  164. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  165. package/tsconfig.json +6 -2
  166. package/typeChecker.ts +1 -1
  167. package/types.ts +1 -0
  168. package/uploadRelease.ts +2 -2
  169. package/workflow/executor.test.ts +146 -0
  170. package/workspaceLayout.test.ts +56 -4
  171. package/workspaceLayout.ts +48 -2
  172. package/builder.ts +0 -164
  173. package/extractDeps.ts +0 -86
  174. package/frontendBuild/styleGuard.test.ts +0 -165
  175. package/frontendBuild/styleGuard.ts +0 -322
  176. package/getCredentials.ts +0 -19
  177. package/getModelFileData.ts +0 -62
  178. package/src/capacitorApp.ts +0 -282
  179. package/streamAi.ts +0 -45
  180. package/ui/MultiScrollList.tsx +0 -242
  181. package/ui/ScrollList.tsx +0 -107
  182. package/ui/index.ts +0 -2
  183. package/useStdoutDimensions.ts +0 -20
package/akanContext.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  workflowRunArtifactPath,
16
16
  workflowSyncDir,
17
17
  } from "./workflow";
18
- import { appRootAllowedDirs, appRootAllowedFiles, isScannedAppRootEntry } from "./workspaceLayout";
18
+ import { isScannedRootEntry, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
19
19
 
20
20
  export type AkanContextFormat = "json" | "markdown";
21
21
  export type AkanModuleKind = "domain" | "service" | "scalar";
@@ -130,15 +130,29 @@ export type CursorMcpConfig = {
130
130
  mcpServers?: Record<string, unknown>;
131
131
  };
132
132
 
133
- export const resourceList = [
133
+ export const guidelineResourceUri = (name: string) => `akan://guidelines/${name}`;
134
+
135
+ /**
136
+ * Resources that exist whatever the workspace holds. The guideline entries are **not** here: they are
137
+ * one per directory under `cli/guidelines/`, and a hardcoded pair listed 2 of 31 while `readResource`
138
+ * threw on the other 29. `buildResourceList` is the only place the two halves meet.
139
+ */
140
+ const staticResourceList = [
134
141
  { uri: "akan://docs/framework", name: "Akan framework guide", mimeType: "text/markdown" },
135
- { uri: "akan://guidelines/framework", name: "Framework guideline", mimeType: "text/markdown" },
136
- { uri: "akan://guidelines/modelSignal", name: "Model signal guideline", mimeType: "text/markdown" },
137
142
  { uri: "akan://workspace/summary", name: "Workspace summary", mimeType: "application/json" },
138
143
  { uri: "akan://workspace/apps", name: "Workspace apps", mimeType: "application/json" },
139
144
  { uri: "akan://workspace/modules", name: "Workspace modules", mimeType: "application/json" },
140
145
  ];
141
146
 
147
+ export const buildResourceList = (guidelineNames: readonly string[]) => [
148
+ ...staticResourceList,
149
+ ...guidelineNames.map((name) => ({
150
+ uri: guidelineResourceUri(name),
151
+ name: `${name} guideline`,
152
+ mimeType: "text/markdown",
153
+ })),
154
+ ];
155
+
142
156
  export const cursorMcpConfigPath = ".cursor/mcp.json";
143
157
  // Claude Code reads project-scoped MCP servers from `.mcp.json` at the workspace root.
144
158
  export const claudeMcpConfigPath = ".mcp.json";
@@ -666,23 +680,25 @@ export class AkanContextAnalyzer {
666
680
  ),
667
681
  ];
668
682
 
669
- for (const app of context.apps) {
670
- const appPath = path.join(workspace.workspaceRoot, app.path);
671
- for (const entry of await safeReadDir(appPath)) {
672
- if (!isScannedAppRootEntry(entry.name)) continue;
673
- const allowed = entry.isDirectory() ? appRootAllowedDirs.has(entry.name) : appRootAllowedFiles.has(entry.name);
683
+ for (const sys of [...context.apps, ...context.libs]) {
684
+ const sysPath = path.join(workspace.workspaceRoot, sys.path);
685
+ for (const entry of await safeReadDir(sysPath)) {
686
+ if (!isScannedRootEntry(sys.type, entry.name)) continue;
687
+ const allowed = entry.isDirectory()
688
+ ? rootAllowedDirs[sys.type].has(entry.name)
689
+ : rootAllowedFiles[sys.type].has(entry.name);
674
690
  if (!allowed) {
675
691
  const action = repairAction(
676
692
  "module-shape",
677
- `akan repair module-shape --app ${app.name}`,
678
- "Review app root shape and remove or move the unknown entry.",
693
+ `akan repair module-shape --app ${sys.name}`,
694
+ `Review ${sys.type} root shape and remove or move the unknown entry.`,
679
695
  false,
680
696
  );
681
697
  diagnostics.push({
682
698
  severity: "error",
683
- code: "app-root-unknown-entry",
684
- path: `${app.path}/${entry.name}`,
685
- message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in app root: ${app.path}/${entry.name}`,
699
+ code: `${sys.type}-root-unknown-entry`,
700
+ path: `${sys.path}/${entry.name}`,
701
+ message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in ${sys.type} root: ${sys.path}/${entry.name}`,
686
702
  repairActions: [action],
687
703
  });
688
704
  repairActions.push(action);
@@ -391,10 +391,31 @@ export const applyMcpTools: McpToolDefinition[] = [
391
391
  },
392
392
  ];
393
393
 
394
- export const listAkanMcpTools = (mode: "readonly" | "plan" | "apply" = "readonly") => {
395
- if (mode === "readonly") return readonlyMcpTools;
396
- if (mode === "plan") return [...readonlyMcpTools, ...planMcpTools];
397
- return [...readonlyMcpTools, ...planMcpTools, ...applyMcpTools];
394
+ /**
395
+ * `guidelineNames` fills the `get_guideline` enum. Without it the argument is a bare string and a model
396
+ * has to guess one of the ~30 directory names, which it cannot see from here — the names are a
397
+ * filesystem listing, so they can only arrive from a caller that already read it.
398
+ */
399
+ export const listAkanMcpTools = (
400
+ mode: "readonly" | "plan" | "apply" = "readonly",
401
+ { guidelineNames = [] }: { guidelineNames?: readonly string[] } = {},
402
+ ) => {
403
+ const tools =
404
+ mode === "readonly"
405
+ ? readonlyMcpTools
406
+ : mode === "plan"
407
+ ? [...readonlyMcpTools, ...planMcpTools]
408
+ : [...readonlyMcpTools, ...planMcpTools, ...applyMcpTools];
409
+ if (guidelineNames.length === 0) return tools;
410
+ return tools.map((tool) =>
411
+ tool.name === "get_guideline"
412
+ ? {
413
+ ...tool,
414
+ description: `${tool.description ?? ""} One of: ${guidelineNames.join(", ")}.`.trim(),
415
+ inputSchema: { ...tool.inputSchema, properties: { name: { type: "string", enum: [...guidelineNames] } } },
416
+ }
417
+ : tool,
418
+ );
398
419
  };
399
420
 
400
421
  export const createAkanValidationContract = (listTools = listAkanMcpTools) => ({
@@ -0,0 +1,28 @@
1
+ import path from "node:path";
2
+ import { FileSys } from "./fileSys";
3
+
4
+ interface StoredSelection {
5
+ apps: string[];
6
+ }
7
+
8
+ /**
9
+ * The apps the last interactive pick chose, so `akan start` + Enter repeats a multi-app session instead
10
+ * of re-ticking it. Advisory only: a missing, unreadable or stale file just means nothing is pre-ticked.
11
+ */
12
+ export class AppSelectionMemory {
13
+ static pathIn(workspaceRoot: string) {
14
+ return path.join(workspaceRoot, "local", ".akan", "lastStart.json");
15
+ }
16
+ static async read(workspaceRoot: string): Promise<string[]> {
17
+ const stored = await Bun.file(AppSelectionMemory.pathIn(workspaceRoot))
18
+ .json()
19
+ .catch(() => null);
20
+ const apps = (stored as StoredSelection | null)?.apps;
21
+ return Array.isArray(apps) ? apps.filter((name): name is string => typeof name === "string") : [];
22
+ }
23
+ static async write(workspaceRoot: string, apps: string[]) {
24
+ await FileSys.writeJson(AppSelectionMemory.pathIn(workspaceRoot), { apps } satisfies StoredSelection).catch(
25
+ () => undefined,
26
+ );
27
+ }
28
+ }
@@ -4,7 +4,7 @@ import { AKAN_OPTIONAL_BACKEND_EXTERNALS } from "./applicationBuildRunner";
4
4
  describe("ApplicationBuildRunner", () => {
5
5
  test("externalizes Akan optional backend dependencies", () => {
6
6
  expect(AKAN_OPTIONAL_BACKEND_EXTERNALS).toEqual(
7
- expect.arrayContaining(["@libsql/client", "bullmq", "croner", "ioredis", "postgres", "protobufjs"]),
7
+ expect.arrayContaining(["@libsql/client", "bullmq", "ioredis", "postgres", "protobufjs"]),
8
8
  );
9
9
  });
10
10
  });
@@ -7,7 +7,13 @@ import type {
7
7
  ApplicationBuildResult,
8
8
  } from "./applicationBuildReporter";
9
9
  import type { App } from "./commandDecorators";
10
- import { AllRoutesBuilder, CsrArtifactBuilder, precompressArtifacts, SsrBaseArtifactBuilder } from "./frontendBuild";
10
+ import {
11
+ AllRoutesBuilder,
12
+ CsrArtifactBuilder,
13
+ FontPruner,
14
+ precompressArtifacts,
15
+ SsrBaseArtifactBuilder,
16
+ } from "./frontendBuild";
11
17
  import { Spinner } from "./spinner";
12
18
 
13
19
  export interface TypecheckOptions {
@@ -15,7 +21,7 @@ export interface TypecheckOptions {
15
21
  incremental?: boolean;
16
22
  }
17
23
 
18
- export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "compress" | "metadata";
24
+ export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "assets" | "compress" | "metadata";
19
25
 
20
26
  export type BuildPhaseResult = ApplicationBuildPhaseResult & { id: BuildPhaseId };
21
27
  export type BuildResult = ApplicationBuildResult;
@@ -37,6 +43,7 @@ const BUILD_PHASE_EMOJIS: Record<BuildPhaseId, string> = {
37
43
  backend: "📦",
38
44
  ssr: "🧭",
39
45
  csr: "🎨",
46
+ assets: "✂️",
40
47
  compress: "🗜️",
41
48
  metadata: "📝",
42
49
  };
@@ -56,7 +63,6 @@ const SSR_RENDER_EXTERNALS = [
56
63
  export const AKAN_OPTIONAL_BACKEND_EXTERNALS = [
57
64
  "@libsql/client",
58
65
  "bullmq",
59
- "croner",
60
66
  "ioredis",
61
67
  "postgres",
62
68
  "protobufjs",
@@ -78,6 +84,7 @@ export class ApplicationBuildRunner {
78
84
  async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
79
85
  // serial build is needed because of Bun.build is unstable for parallel build
80
86
  const phaseOptions = { spinner };
87
+ const { web, assets } = await this.#app.getConfig();
81
88
  await this.#runPhase("prepare", "Preparing output directory", () => this.#app.prepareCommand("build"), undefined, {
82
89
  spinner,
83
90
  });
@@ -92,18 +99,30 @@ export class ApplicationBuildRunner {
92
99
  await this.#runPhase(
93
100
  "ssr",
94
101
  "Building SSR route artifacts",
95
- () => this.#buildSsr(),
102
+ async () => (web.ssr ? await this.#buildSsr() : null),
96
103
  (result) =>
97
104
  result
98
105
  ? `${result.allRoutes.manifest.routeIds.length} routes, ${result.allRoutes.manifest.knownEntries.length} entries`
99
- : "skipped",
106
+ : web.ssr
107
+ ? "skipped"
108
+ : "disabled by akan.config.ts web.ssr",
100
109
  phaseOptions,
101
110
  );
102
111
  await this.#runPhase(
103
112
  "csr",
104
113
  "Building CSR assets",
105
- () => this.#buildCsr(),
106
- (result) => result?.outputDir ?? "skipped",
114
+ async () => (web.csr ? await this.#buildCsr() : null),
115
+ (result) => result?.outputDir ?? (web.csr ? "skipped" : "disabled by akan.config.ts web.csr"),
116
+ phaseOptions,
117
+ );
118
+ await this.#runPhase(
119
+ "assets",
120
+ "Trimming unread static assets",
121
+ async () => (assets.pruneFonts ? await new FontPruner(this.#app, assets).prune() : null),
122
+ (result) =>
123
+ result
124
+ ? `${result.removed.length} font file(s) dropped, ${ApplicationBuildRunner.formatBytes(result.freedBytes)} freed; ${result.kept.length} kept`
125
+ : "disabled by akan.config.ts assets.pruneFonts",
107
126
  phaseOptions,
108
127
  );
109
128
  await this.#runPhase(
@@ -166,7 +185,7 @@ export class ApplicationBuildRunner {
166
185
  const akanConfig = await this.#app.getConfig();
167
186
  await Promise.all([
168
187
  this.#app.dist.writeJson("package.json", akanConfig.getProductionPackageJson()),
169
- this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, akanConfig.docker.content),
188
+ this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, akanConfig.dockerfile),
170
189
  ]);
171
190
  }
172
191
 
@@ -175,6 +194,7 @@ export class ApplicationBuildRunner {
175
194
  const backendExternals = [
176
195
  ...new Set([...akanConfig.externalLibs, ...SSR_RENDER_EXTERNALS, ...AKAN_OPTIONAL_BACKEND_EXTERNALS]),
177
196
  ];
197
+ const { web } = akanConfig;
178
198
  const backendEntryPoints = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
179
199
  for (const entrypoint of backendEntryPoints) {
180
200
  if (!(await Bun.file(entrypoint).exists())) throw new Error(`Backend entrypoint not found: ${entrypoint}`);
@@ -188,17 +208,20 @@ export class ApplicationBuildRunner {
188
208
  define: { "process.env.NODE_ENV": JSON.stringify("production") },
189
209
  plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
190
210
  });
191
- const rscWorkerResult = await this.#buildOrThrow("rsc-worker", {
192
- entrypoints: [this.#resolveRscWorkerBuildEntry()],
193
- outdir: this.#app.dist.cwdPath,
194
- target: "bun",
195
- minify: true,
196
- naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
197
- conditions: ["react-server"],
198
- // `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
199
- define: { "process.env.NODE_ENV": JSON.stringify("production") },
200
- plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
201
- });
211
+ // Nothing spawns the RSC worker without SSR, so an api-only image does not carry it.
212
+ const rscWorkerResult = web.ssr
213
+ ? await this.#buildOrThrow("rsc-worker", {
214
+ entrypoints: [this.#resolveRscWorkerBuildEntry()],
215
+ outdir: this.#app.dist.cwdPath,
216
+ target: "bun",
217
+ minify: true,
218
+ naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
219
+ conditions: ["react-server"],
220
+ // `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
221
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
222
+ plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
223
+ })
224
+ : null;
202
225
  const consoleRuntimeResult = await this.#buildOrThrow("console-runtime", {
203
226
  entrypoints: [this.#resolveConsoleRuntimeBuildEntry()],
204
227
  outdir: this.#app.dist.cwdPath,
@@ -209,8 +232,9 @@ export class ApplicationBuildRunner {
209
232
  });
210
233
  await this.#writeConsoleShim();
211
234
  return {
212
- entrypoints: backendEntryPoints.length + 2,
213
- outputs: backendResult.outputs.length + rscWorkerResult.outputs.length + consoleRuntimeResult.outputs.length + 1,
235
+ entrypoints: backendEntryPoints.length + (rscWorkerResult ? 2 : 1),
236
+ outputs:
237
+ backendResult.outputs.length + (rscWorkerResult?.outputs.length ?? 0) + consoleRuntimeResult.outputs.length + 1,
214
238
  };
215
239
  }
216
240
 
@@ -102,4 +102,71 @@ describe("resolveSsrPageEntries", () => {
102
102
  );
103
103
  expect(generatedDictMacro).toContain("export const allDictionary = getAllDictionary();");
104
104
  });
105
+
106
+ test("awaits an async grouped root layout in the CSR bundle instead of mounting it as JSX", async () => {
107
+ const appRoot = await makeTempRoot();
108
+ const pageRoot = path.join(appRoot, "page");
109
+
110
+ await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
111
+ await write(
112
+ path.join(pageRoot, "(user)", "_layout.tsx"),
113
+ "export default async function Layout({ children }) { await Promise.resolve(); return <div>{children}</div>; }\n",
114
+ );
115
+ await write(
116
+ path.join(pageRoot, "(sign)", "_layout.tsx"),
117
+ "export default function Layout({ children }) { return children; }\n",
118
+ );
119
+
120
+ const entries = await resolveSsrPageEntries({
121
+ appCwdPath: appRoot,
122
+ appName: "demo",
123
+ pageKeys: ["./(user)/_layout.tsx", "./(user)/self/_index.tsx", "./(sign)/_layout.tsx", "./(sign)/signin.tsx"],
124
+ });
125
+
126
+ const userRoot = entries.find((entry) => entry.key === "./(user)/__root_layout.tsx");
127
+ const userSource = await Bun.file(userRoot?.moduleAbsPath ?? "").text();
128
+ expect(userSource).toContain("<System.Provider");
129
+ expect(userSource).toContain("export default async function GeneratedLayout(");
130
+ expect(userSource).toContain('process.env.AKAN_PUBLIC_RENDER_ENV === "csr"');
131
+ expect(userSource).toContain("? await UserLayout({ params, searchParams, children })");
132
+ expect(userSource).toContain(": <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>;");
133
+ expect(userSource).toContain(" {layout}\n </System.Provider>");
134
+
135
+ const signRoot = entries.find((entry) => entry.key === "./(sign)/__root_layout.tsx");
136
+ const signSource = await Bun.file(signRoot?.moduleAbsPath ?? "").text();
137
+ expect(signSource).toContain("<System.Provider");
138
+ expect(signSource).toContain("export default function GeneratedLayout(");
139
+ expect(signSource).not.toContain("await UserLayout(");
140
+ expect(signSource).toContain(
141
+ " <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>\n </System.Provider>",
142
+ );
143
+ });
144
+
145
+ test("returns the awaited node directly for an async nested root boundary", async () => {
146
+ const appRoot = await makeTempRoot();
147
+ const pageRoot = path.join(appRoot, "page");
148
+
149
+ await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
150
+ await write(
151
+ path.join(pageRoot, "_layout.tsx"),
152
+ "export default function Layout({ children }) { return children; }\n",
153
+ );
154
+ await write(
155
+ path.join(pageRoot, "(home)", "_layout.tsx"),
156
+ "const Layout = async ({ children }) => children;\nexport default Layout;\n",
157
+ );
158
+
159
+ const entries = await resolveSsrPageEntries({
160
+ appCwdPath: appRoot,
161
+ appName: "demo",
162
+ pageKeys: ["./_layout.tsx", "./(home)/_layout.tsx", "./(home)/_index.tsx"],
163
+ });
164
+
165
+ const groupedRoot = entries.find((entry) => entry.key === "./(home)/__root_layout.tsx");
166
+ const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
167
+ expect(generatedSource).not.toContain("<System.Provider");
168
+ expect(generatedSource).toContain("export default async function GeneratedLayout(");
169
+ expect(generatedSource).toContain("? await UserLayout({ params, searchParams, children })");
170
+ expect(generatedSource).toContain(" return layout;\n}");
171
+ });
105
172
  });
@@ -1,6 +1,7 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { App } from "../commandDecorators";
4
+ import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
4
5
 
5
6
  export interface PageEntry {
6
7
  key: string;
@@ -55,7 +56,7 @@ interface RootBoundary {
55
56
  segments: string[];
56
57
  }
57
58
 
58
- function getRootBoundarySegments(key: string): string[] | null {
59
+ export function getRootBoundarySegments(key: string): string[] | null {
59
60
  const match = LAYOUT_KEY_RE.exec(key);
60
61
  if (!match) return null;
61
62
  const prefix = match[1]?.replace(/\/$/, "");
@@ -76,7 +77,7 @@ function implicitDictionaryMacroAbsPath(appCwdPath: string): string {
76
77
  return path.join(path.resolve(appCwdPath), IMPLICIT_DICT_DIR, "useDict.ts");
77
78
  }
78
79
 
79
- function isRootBoundarySegments(segments: string[], basePaths: Iterable<string>): boolean {
80
+ export function isRootBoundarySegments(segments: string[], basePaths: Iterable<string>): boolean {
80
81
  const firstVisibleIndex = segments.findIndex((segment) => !/^\(.+\)$/.test(segment));
81
82
  if (firstVisibleIndex === -1) return segments.length <= 1;
82
83
  if (segments.slice(firstVisibleIndex + 1).some((segment) => /^\(.+\)$/.test(segment))) return false;
@@ -191,6 +192,25 @@ async function writeGeneratedRootLayoutFile(opts: {
191
192
  const userImport = sourceSpecifier
192
193
  ? `import UserLayout, * as userLayout from ${JSON.stringify(sourceSpecifier)};\n`
193
194
  : "const UserLayout = ({ children }) => children;\nconst userLayout = {};\n";
195
+ const isAsyncUserLayout = opts.boundary.sourceAbsPath
196
+ ? await AsyncDefaultExportDetector.detect(opts.boundary.sourceAbsPath)
197
+ : false;
198
+ const userLayoutElement = "<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>";
199
+ // React has no async client component, so the CSR bundle calls an async layout and awaits its node the way
200
+ // `RenderLayer` does for pages. The RSC render keeps the element: awaiting there would hold the shell behind
201
+ // the layout's own awaits instead of streaming it as its own Flight chunk.
202
+ const layoutSignature = isAsyncUserLayout
203
+ ? "export default async function GeneratedLayout"
204
+ : "export default function GeneratedLayout";
205
+ const layoutBinding = isAsyncUserLayout
206
+ ? ` const layout =
207
+ process.env.AKAN_PUBLIC_RENDER_ENV === "csr"
208
+ ? await UserLayout({ params, searchParams, children })
209
+ : ${userLayoutElement};
210
+ `
211
+ : "";
212
+ const layoutChild = isAsyncUserLayout ? "{layout}" : userLayoutElement;
213
+ const layoutReturn = isAsyncUserLayout ? "layout" : userLayoutElement;
194
214
  const source = opts.includeSystemProvider
195
215
  ? `import type { LayoutProps, PageProps } from "akanjs/client";
196
216
  import { loadFonts } from "akanjs/client";
@@ -222,8 +242,8 @@ export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
222
242
  export const Error = userLayout.Error ?? inheritedLayout.Error;
223
243
  export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
224
244
 
225
- export default function GeneratedLayout({ children, params, searchParams }: LayoutProps) {
226
- return (
245
+ ${layoutSignature}({ children, params, searchParams }: LayoutProps) {
246
+ ${layoutBinding} return (
227
247
  <System.Provider
228
248
  of={GeneratedLayout as never}
229
249
  appName=${JSON.stringify(opts.appName)}
@@ -239,7 +259,7 @@ export default function GeneratedLayout({ children, params, searchParams }: Layo
239
259
  wsConnect={userLayout.wsConnect ?? inheritedLayout.wsConnect ?? true}
240
260
  allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}
241
261
  >
242
- <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>
262
+ ${layoutChild}
243
263
  </System.Provider>
244
264
  );
245
265
  }
@@ -264,8 +284,8 @@ export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
264
284
  export const Error = userLayout.Error ?? inheritedLayout.Error;
265
285
  export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
266
286
 
267
- export default function GeneratedLayout({ children, params, searchParams }: LayoutProps) {
268
- return <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>;
287
+ ${layoutSignature}({ children, params, searchParams }: LayoutProps) {
288
+ ${layoutBinding} return ${layoutReturn};
269
289
  }
270
290
  `;
271
291
  await Bun.write(absPath, source);
@@ -1,3 +1,4 @@
1
+ import { describe, expect, test } from "bun:test";
1
2
  import { computeRouteSeedIndex, serializeRouteSeedIndexForArtifact } from "./routeSeedIndex";
2
3
 
3
4
  describe("computeRouteSeedIndex", () => {
package/biome.base.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.5.12/schema.json",
3
3
  "vcs": {
4
4
  "enabled": true,
5
5
  "clientKind": "git",
@@ -14,7 +14,7 @@
14
14
  "linter": {
15
15
  "enabled": true,
16
16
  "rules": {
17
- "recommended": true,
17
+ "preset": "recommended",
18
18
  "suspicious": {
19
19
  "noConsole": {
20
20
  "level": "error",
@@ -23,7 +23,10 @@
23
23
  }
24
24
  },
25
25
  "noArrayIndexKey": "off",
26
- "noShadowRestrictedNames": "off"
26
+ "noShadowRestrictedNames": "off",
27
+ "noUnnecessaryConditions": {
28
+ "level": "warn"
29
+ }
27
30
  },
28
31
  "correctness": {
29
32
  "noUnusedFunctionParameters": "off",
@@ -43,9 +46,6 @@
43
46
  "attributes": ["classList"],
44
47
  "functions": ["cn"]
45
48
  }
46
- },
47
- "noUnnecessaryConditions": {
48
- "level": "error"
49
49
  }
50
50
  },
51
51
  "a11y": "off",
@@ -117,7 +117,9 @@
117
117
  "libs/*/client.ts",
118
118
  "libs/*/server.ts",
119
119
  "libs/*/index.ts",
120
- "libs/*/lib/*/index.tsx"
120
+ "libs/*/lib/*/index.tsx",
121
+ "apps/*/lib/__lib/**",
122
+ "libs/*/lib/__lib/**"
121
123
  ],
122
124
  "linter": { "enabled": false },
123
125
  "formatter": { "enabled": false },
@@ -140,28 +142,65 @@
140
142
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-throw-raw-error.grit"]
141
143
  },
142
144
  {
143
- "includes": ["**/page/**/*.ts", "**/page/**/*.tsx", "**/*.Unit.tsx", "**/*.View.tsx"],
145
+ "includes": [
146
+ "{apps,libs}/**/page/**/*.ts",
147
+ "{apps,libs}/**/page/**/*.tsx",
148
+ "{apps,libs}/**/*.Unit.tsx",
149
+ "{apps,libs}/**/*.View.tsx"
150
+ ],
144
151
  "plugins": [
145
152
  "./node_modules/@akanjs/devkit/lint/no-import-client-functions.grit",
146
153
  "./node_modules/@akanjs/devkit/lint/no-use-client-in-server.grit"
147
154
  ]
148
155
  },
149
156
  {
150
- "includes": ["**/page/**/*.ts", "**/page/**/*.tsx"],
157
+ "includes": ["{apps,libs}/**/page/**/*.ts", "{apps,libs}/**/page/**/*.tsx"],
151
158
  "plugins": ["./node_modules/@akanjs/devkit/lint/non-scalar-props-restricted.grit"]
152
159
  },
153
160
  {
154
- "includes": ["**/*.constant.ts", "**/*.document.ts", "**/*.service.ts", "**/*.store.ts"],
161
+ "includes": [
162
+ "{apps,libs}/**/*.constant.ts",
163
+ "{apps,libs}/**/*.document.ts",
164
+ "{apps,libs}/**/*.service.ts",
165
+ "{apps,libs}/**/*.store.ts"
166
+ ],
155
167
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-js-private-class-method.grit"]
156
168
  },
157
169
  {
158
- "includes": ["**/*.store.ts"],
170
+ "includes": ["{apps,libs}/**/*.store.ts"],
159
171
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-return-in-store-action.grit"]
160
172
  },
161
173
  {
162
- "includes": ["**/*.signal.ts"],
174
+ "includes": ["{apps,libs}/**/*.ts", "{apps,libs}/**/*.tsx"],
175
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-deprecated-log-level.grit"]
176
+ },
177
+ {
178
+ "includes": ["{apps,libs}/**/*.signal.ts"],
163
179
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-redeclare-predefined-endpoint.grit"]
164
180
  },
181
+ {
182
+ "includes": ["{apps,libs}/**/*.Util.tsx", "{apps,libs}/**/*.Zone.tsx"],
183
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-model-type-in-util-zone.grit"]
184
+ },
185
+ {
186
+ "includes": ["{apps,libs}/**/ui/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
187
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-async-component-in-ui.grit"]
188
+ },
189
+ {
190
+ "includes": ["{apps,libs}/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
191
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit"]
192
+ },
193
+ {
194
+ "includes": [
195
+ "{apps,libs}/**/*.ts",
196
+ "{apps,libs}/**/*.tsx",
197
+ "!**/*.test.ts",
198
+ "!**/*.test.tsx",
199
+ "!**/*.spec.ts",
200
+ "!**/*.spec.tsx"
201
+ ],
202
+ "plugins": ["./node_modules/@akanjs/devkit/lint/no-init-fetch-in-client.grit"]
203
+ },
165
204
  {
166
205
  "includes": [
167
206
  "**/ui/**/*.ts",
@@ -186,46 +225,46 @@
186
225
  },
187
226
  {
188
227
  "includes": [
189
- "**/*.constant.ts",
190
- "**/*.dictionary.ts",
191
- "**/*.document.ts",
192
- "**/*.service.ts",
193
- "**/*.signal.ts",
194
- "**/*.store.ts",
195
- "**/*.Template.tsx",
196
- "**/*.Unit.tsx",
197
- "**/*.Util.tsx",
198
- "**/*.View.tsx",
199
- "**/*.Zone.tsx"
228
+ "{apps,libs}/**/*.constant.ts",
229
+ "{apps,libs}/**/*.dictionary.ts",
230
+ "{apps,libs}/**/*.document.ts",
231
+ "{apps,libs}/**/*.service.ts",
232
+ "{apps,libs}/**/*.signal.ts",
233
+ "{apps,libs}/**/*.store.ts",
234
+ "{apps,libs}/**/*.Template.tsx",
235
+ "{apps,libs}/**/*.Unit.tsx",
236
+ "{apps,libs}/**/*.Util.tsx",
237
+ "{apps,libs}/**/*.View.tsx",
238
+ "{apps,libs}/**/*.Zone.tsx"
200
239
  ],
201
240
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-deep-internal-import.grit"]
202
241
  },
203
242
  {
204
243
  "includes": [
205
- "**/page/**/*.ts",
206
- "**/page/**/*.tsx",
207
- "**/index.ts",
208
- "**/index.tsx",
209
- "**/cnst.ts",
210
- "**/db.ts",
211
- "**/dict.ts",
212
- "**/option.ts",
213
- "**/sig.ts",
214
- "**/srv.ts",
215
- "**/st.ts",
216
- "**/*.constant.ts",
217
- "**/*.dictionary.ts",
218
- "**/*.document.ts",
219
- "**/*.service.ts",
220
- "**/*.signal.ts",
221
- "**/*.signal.test.ts",
222
- "**/*.service.test.ts",
223
- "**/*.store.ts",
224
- "**/*.Template.tsx",
225
- "**/*.Unit.tsx",
226
- "**/*.Util.tsx",
227
- "**/*.View.tsx",
228
- "**/*.Zone.tsx"
244
+ "{apps,libs}/**/page/**/*.ts",
245
+ "{apps,libs}/**/page/**/*.tsx",
246
+ "{apps,libs}/**/index.ts",
247
+ "{apps,libs}/**/index.tsx",
248
+ "{apps,libs}/**/cnst.ts",
249
+ "{apps,libs}/**/db.ts",
250
+ "{apps,libs}/**/dict.ts",
251
+ "{apps,libs}/**/option.ts",
252
+ "{apps,libs}/**/sig.ts",
253
+ "{apps,libs}/**/srv.ts",
254
+ "{apps,libs}/**/st.ts",
255
+ "{apps,libs}/**/*.constant.ts",
256
+ "{apps,libs}/**/*.dictionary.ts",
257
+ "{apps,libs}/**/*.document.ts",
258
+ "{apps,libs}/**/*.service.ts",
259
+ "{apps,libs}/**/*.signal.ts",
260
+ "{apps,libs}/**/*.signal.test.ts",
261
+ "{apps,libs}/**/*.service.test.ts",
262
+ "{apps,libs}/**/*.store.ts",
263
+ "{apps,libs}/**/*.Template.tsx",
264
+ "{apps,libs}/**/*.Unit.tsx",
265
+ "{apps,libs}/**/*.Util.tsx",
266
+ "{apps,libs}/**/*.View.tsx",
267
+ "{apps,libs}/**/*.Zone.tsx"
229
268
  ],
230
269
  "plugins": [
231
270
  "./node_modules/@akanjs/devkit/lint/no-import-external-library.grit",