@akanjs/devkit 3.0.0-beta.17 → 3.0.0-beta.19

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 (96) hide show
  1. package/abstractDoc.ts +5 -4
  2. package/akanApp/akanApp.host.ts +23 -9
  3. package/akanConfig/akanConfig.test.ts +55 -30
  4. package/akanConfig/akanConfig.ts +29 -17
  5. package/akanConfig/types.ts +1 -0
  6. package/applicationBuildRunner.test.ts +5 -3
  7. package/applicationBuildRunner.ts +33 -6
  8. package/artifact/implicitRootLayout.test.ts +6 -11
  9. package/artifact/implicitRootLayout.ts +0 -15
  10. package/artifact/routeSeedIndex.test.ts +7 -6
  11. package/biome.base.json +6 -1
  12. package/capacitorApp.test.ts +28 -2
  13. package/capacitorApp.ts +25 -4
  14. package/cloud/constants.ts +23 -0
  15. package/cloud/globalConfig.ts +13 -0
  16. package/codeAgent/agent/AkanCodeServices.ts +38 -12
  17. package/codeAgent/agent/CodeAgent.ts +94 -30
  18. package/codeAgent/agent/CodeAgentAsks.ts +3 -1
  19. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
  20. package/codeAgent/agent/CodeAgentProxy.ts +91 -0
  21. package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
  22. package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
  23. package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
  24. package/codeAgent/agent/akanCodeModel.ts +1 -1
  25. package/codeAgent/agent/akanCodePaths.ts +23 -3
  26. package/codeAgent/agent/akanContextFiles.test.ts +2 -1
  27. package/codeAgent/agent/akanSystemPrompt.ts +5 -0
  28. package/codeAgent/agent/codeAgentContract.test.ts +17 -0
  29. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  30. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  31. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  32. package/codeAgent/skills/akan-code/SKILL.md +122 -0
  33. package/codeAgent/skills/akan-module/SKILL.md +2 -2
  34. package/codeAgent/skills/akan-store/SKILL.md +6 -2
  35. package/codeAgent/tools/AkanCodePlugins.ts +19 -3
  36. package/codeAgent/tools/AskToolPack.ts +111 -0
  37. package/codeAgent/tools/McpClient.ts +51 -5
  38. package/codeAgent/tools/McpOAuth.ts +268 -0
  39. package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
  40. package/codeAgent/tools/McpServerConfig.ts +69 -34
  41. package/codeAgent/tools/McpSignIn.ts +161 -0
  42. package/codeAgent/tools/McpTokenStore.ts +92 -0
  43. package/codeAgent/tools/McpToolPack.ts +16 -3
  44. package/codeAgent/tools/askToolPack.test.ts +119 -0
  45. package/codeAgent/tools/mcpOAuth.test.ts +324 -0
  46. package/codeAgent/tools/mcpServerConfig.test.ts +106 -12
  47. package/commandDecorators/command.ts +2 -0
  48. package/commandDecorators/commandDecorators.test.ts +1 -1
  49. package/commandDecorators/helpFormatter.ts +2 -1
  50. package/dependencyScanner.ts +4 -2
  51. package/devkitUtils.test.ts +1 -1
  52. package/executors.linkFallback.test.ts +103 -0
  53. package/executors.test.ts +18 -46
  54. package/executors.ts +31 -8
  55. package/frontendBuild/autoImportSync.test.ts +13 -0
  56. package/frontendBuild/autoImportSync.ts +19 -1
  57. package/frontendBuild/buildRouteClient.test.ts +52 -1
  58. package/frontendBuild/cssImportResolver.ts +1 -1
  59. package/frontendBuild/fontOptimizer.test.ts +1 -1
  60. package/frontendBuild/frontendBuild.test.ts +19 -17
  61. package/frontendBuild/hmrWatcher.test.ts +1 -1
  62. package/frontendBuild/routeClientBuilder.ts +27 -10
  63. package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
  64. package/getDirname.test.ts +4 -2
  65. package/incrementalBuilder/devWatchBatch.test.ts +7 -6
  66. package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
  67. package/index.ts +0 -1
  68. package/integration/devStabilityHarness.test.ts +7 -2
  69. package/lint/gritRules.test.ts +2 -1
  70. package/lint/no-deep-internal-import.grit +2 -2
  71. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  72. package/linter.ts +4 -1
  73. package/package.json +2 -2
  74. package/platformTest/BunTestReport.test.ts +60 -0
  75. package/platformTest/BunTestReport.ts +65 -0
  76. package/platformTest/IdleWatchedProcess.test.ts +64 -0
  77. package/platformTest/IdleWatchedProcess.ts +77 -0
  78. package/platformTest/LinuxDockerTarget.ts +120 -0
  79. package/platformTest/PlatformTestRun.test.ts +68 -0
  80. package/platformTest/PlatformTestRun.ts +141 -0
  81. package/platformTest/PlatformTestTarget.ts +120 -0
  82. package/platformTest/TestSnapshot.test.ts +80 -0
  83. package/platformTest/TestSnapshot.ts +107 -0
  84. package/platformTest/WindowsSshTarget.ts +167 -0
  85. package/prompter.ts +0 -7
  86. package/qualityScanner.test.ts +3 -2
  87. package/qualityScanner.ts +1 -1
  88. package/routeSourceValidator.test.ts +0 -1
  89. package/routeSourceValidator.ts +0 -12
  90. package/scanInfo.ts +5 -2
  91. package/subspace.test.ts +5 -2
  92. package/subspace.ts +19 -8
  93. package/workflow/executor.ts +52 -0
  94. package/workflow/rolloutGate.test.ts +1 -1
  95. package/workflow/source.ts +19 -13
  96. package/guideline.ts +0 -19
package/executors.test.ts CHANGED
@@ -312,9 +312,17 @@ describe("Workspace and app executor environment contracts", () => {
312
312
  expect(env.AKAN_PUBLIC_SERVER_PORT).toBe("8285");
313
313
  expect(env.EXTRA).toBe("ok");
314
314
 
315
+ const shellOperationMode = process.env.AKAN_PUBLIC_OPERATION_MODE;
316
+ process.env.AKAN_PUBLIC_OPERATION_MODE = "local";
315
317
  const prepared = await app.prepareCommand("build");
318
+ const bakedOperationMode = process.env.AKAN_PUBLIC_OPERATION_MODE;
319
+ if (shellOperationMode === undefined) delete process.env.AKAN_PUBLIC_OPERATION_MODE;
320
+ else process.env.AKAN_PUBLIC_OPERATION_MODE = shellOperationMode;
321
+ expect(bakedOperationMode).toBeUndefined();
316
322
  expect(prepared.env.AKAN_COMMAND_TYPE).toBe("build");
317
323
  expect(prepared.env.AKAN_PUBLIC_BASE_PATHS).toBe("admin");
324
+ expect(prepared.env.AKAN_DATABASE_MODE).toBe("single");
325
+ expect(prepared.env.AKAN_DATABASE_MODES).toBe("single");
318
326
  // Bundling reads `process.env` through getPublicEnv and `define`s every AKAN_PUBLIC_* into a literal, so a
319
327
  // dev port published here would be baked into the artifact and outrank the PORT the container is run with.
320
328
  expect(process.env.AKAN_PUBLIC_APP_NAME).toBe("demo");
@@ -727,7 +735,7 @@ describe("Workspace and app executor environment contracts", () => {
727
735
  });
728
736
  });
729
737
 
730
- test("accepts metadata route exports during page key discovery", async () => {
738
+ test("accepts head route exports during page key discovery", async () => {
731
739
  const root = await makeTempRoot();
732
740
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
733
741
  process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
@@ -737,24 +745,19 @@ describe("Workspace and app executor environment contracts", () => {
737
745
  await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
738
746
  await writeFile(
739
747
  path.join(root, "apps/demo/page/_layout.tsx"),
740
- [
741
- "export const head = null;",
742
- "export const metadata = { title: 'Root' };",
743
- "export default function Layout({ children }) { return children; }",
744
- "",
745
- ].join("\n"),
748
+ ["export const head = null;", "export default function Layout({ children }) { return children; }", ""].join("\n"),
746
749
  );
747
750
  await writeFile(
748
751
  path.join(root, "apps/demo/page/docs/_layout.tsx"),
749
752
  [
750
- "export async function generateMetadata() { return { title: 'Docs' }; }",
753
+ "export async function generateHead() { return null; }",
751
754
  "export default function Layout({ children }) { return children; }",
752
755
  "",
753
756
  ].join("\n"),
754
757
  );
755
758
  await writeFile(
756
759
  path.join(root, "apps/demo/page/docs/intro.tsx"),
757
- ["export const metadata = { title: 'Intro' };", "export default function Page() { return null; }", ""].join("\n"),
760
+ ["export const head = null;", "export default function Page() { return null; }", ""].join("\n"),
758
761
  );
759
762
 
760
763
  const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
@@ -767,7 +770,7 @@ describe("Workspace and app executor environment contracts", () => {
767
770
  ]);
768
771
  });
769
772
 
770
- test("rejects conflicting metadata route exports during page key discovery", async () => {
773
+ test("rejects a metadata route export during page key discovery", async () => {
771
774
  const root = await makeTempRoot();
772
775
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
773
776
  process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
@@ -776,47 +779,16 @@ describe("Workspace and app executor environment contracts", () => {
776
779
  await mkdir(path.join(root, "apps/demo/page"), { recursive: true });
777
780
  await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
778
781
  await writeFile(
779
- path.join(root, "apps/demo/page/conflict.tsx"),
780
- [
781
- "export const metadata = { title: 'Conflict' };",
782
- "export function generateMetadata() { return { title: 'Conflict' }; }",
783
- "export default function Page() { return null; }",
784
- "",
785
- ].join("\n"),
782
+ path.join(root, "apps/demo/page/legacy.tsx"),
783
+ ["export const metadata = { title: 'Legacy' };", "export default function Page() { return null; }", ""].join(
784
+ "\n",
785
+ ),
786
786
  );
787
787
 
788
788
  const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
789
789
  const app = AppExecutor.from(workspace, "demo");
790
790
 
791
- await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
792
- "metadata and generateMetadata cannot both be exported",
793
- );
794
- });
795
-
796
- test("rejects mixed head and metadata route export channels during page key discovery", async () => {
797
- const root = await makeTempRoot();
798
- process.env.AKAN_PUBLIC_REPO_NAME = "repo";
799
- process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
800
- process.env.AKAN_PUBLIC_ENV = "local";
801
- await writeJson(path.join(root, "package.json"), rootPackageJson());
802
- await mkdir(path.join(root, "apps/demo/page"), { recursive: true });
803
- await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
804
- await writeFile(
805
- path.join(root, "apps/demo/page/mixed.tsx"),
806
- [
807
- "export const head = null;",
808
- "export const metadata = { title: 'Mixed' };",
809
- "export default function Page() { return null; }",
810
- "",
811
- ].join("\n"),
812
- );
813
-
814
- const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
815
- const app = AppExecutor.from(workspace, "demo");
816
-
817
- await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
818
- "head/generateHead and metadata/generateMetadata cannot both be exported",
819
- );
791
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow('unsupported export "metadata"');
820
792
  });
821
793
 
822
794
  test("assigns start command ports from sorted app order", async () => {
package/executors.ts CHANGED
@@ -11,6 +11,7 @@ import { readFileSync } from "node:fs";
11
11
  import {
12
12
  copyFile,
13
13
  cp as cpEntry,
14
+ lstat,
14
15
  mkdir,
15
16
  readdir as readDirEntries,
16
17
  realpath,
@@ -96,7 +97,9 @@ const staticTemplateFileExtensions = new Set([
96
97
  ".xml",
97
98
  ]);
98
99
 
99
- const formatCommandArg = (value: string) => (/^[\w@%+=:,./-]+$/.test(value) ? value : JSON.stringify(value));
100
+ //? A backslash is a path separator on Windows but an escape in a POSIX shell, so only there does it force quoting.
101
+ const plainCommandArgPattern = process.platform === "win32" ? /^[\w@%+=:,./\\-]+$/ : /^[\w@%+=:,./-]+$/;
102
+ const formatCommandArg = (value: string) => (plainCommandArgPattern.test(value) ? value : JSON.stringify(value));
100
103
 
101
104
  const formatCommandForDisplay = (command: string, args: string[] = []) =>
102
105
  [command, ...args].map(formatCommandArg).join(" ");
@@ -475,8 +478,10 @@ export class Executor {
475
478
  const isDirectory = (await stat(src)).isDirectory();
476
479
  if (!(await FileSys.exists(dest)) && isDirectory) await mkdir(dest, { recursive: true });
477
480
  //* `cp -r` keeps symlinks on GNU coreutils but follows them on macOS, so anything that must land as
478
- //* real files regardless of platform has to say so explicitly.
479
- if (dereference) await cpEntry(src, dest, { recursive: isDirectory, dereference: true, force: true });
481
+ //* real files regardless of platform has to say so explicitly. Windows has no `cp -r <dir>/.` and a
482
+ //* symlink there needs a privilege most accounts lack, so it always lands as real files.
483
+ if (dereference || process.platform === "win32")
484
+ await cpEntry(src, dest, { recursive: isDirectory, dereference: true, force: true });
480
485
  else await $`cp -r ${src}${isDirectory ? "/." : ""} ${dest}`;
481
486
  }
482
487
  log(msg: string) {
@@ -1405,14 +1410,21 @@ export class AppExecutor extends SysExecutor {
1405
1410
  ...env,
1406
1411
  };
1407
1412
  }
1413
+ /** The database mode a command runs this app in, and the modes its build carries — the same rule the server boots by. */
1414
+ async getDatabaseModeEnv() {
1415
+ const akanConfig = await this.getConfig();
1416
+ return {
1417
+ AKAN_DATABASE_MODE: akanConfig.resolveDatabaseMode(),
1418
+ AKAN_DATABASE_MODES: akanConfig.database.modes.join(","),
1419
+ };
1420
+ }
1408
1421
  async prepareCommand(type: "build" | "start") {
1409
1422
  const akanConfig = await this.getConfig();
1410
- const databaseMode = process.env.AKAN_DATABASE_MODE ?? akanConfig.defaultDatabaseMode ?? "single";
1411
1423
  const routeEnv = {
1412
1424
  AKAN_PUBLIC_BASE_PATHS: [...akanConfig.basePaths].join(","),
1413
1425
  AKAN_PUBLIC_API_PREFIX: akanConfig.api.prefix,
1414
1426
  AKAN_PUBLIC_WS_PREFIX: akanConfig.api.websocketPrefix,
1415
- AKAN_DATABASE_MODE: databaseMode,
1427
+ ...(await this.getDatabaseModeEnv()),
1416
1428
  };
1417
1429
  Object.assign(process.env, routeEnv);
1418
1430
  if (type === "build") {
@@ -1457,11 +1469,14 @@ export class AppExecutor extends SysExecutor {
1457
1469
  // this does not leak non-public env.
1458
1470
  // The port keys are this machine's dev allocation, and `define` turns an `AKAN_PUBLIC_*` into a literal the
1459
1471
  // artifact can never be run with a different value for — a baked port would outrank the `PORT` the container
1460
- // is started with and send every SSR self-call to a port nothing bound.
1472
+ // is started with and send every SSR self-call to a port nothing bound. The operation mode likewise: one image
1473
+ // serves an edge site and a cloud cluster, and a shell's `local` would pin every artifact to it.
1461
1474
  if (type === "build") {
1462
1475
  const buildEnv = { ...env };
1463
1476
  delete buildEnv.AKAN_PUBLIC_CLIENT_PORT;
1464
1477
  delete buildEnv.AKAN_PUBLIC_SERVER_PORT;
1478
+ delete buildEnv.AKAN_PUBLIC_OPERATION_MODE;
1479
+ delete process.env.AKAN_PUBLIC_OPERATION_MODE;
1465
1480
  Object.assign(process.env, buildEnv);
1466
1481
  }
1467
1482
  return { env };
@@ -1507,7 +1522,9 @@ export class AppExecutor extends SysExecutor {
1507
1522
  const owners = new Map<string, { absPath: string; fromLib: boolean }>();
1508
1523
  const devOnlyKeys = new Set<string>();
1509
1524
  const devOnlyDirs: string[] = [];
1510
- for (const root of await this.getPageRoots()) {
1525
+ const pageRoots = await this.getPageRoots();
1526
+ const libKeyPrefixes = pageRoots.map((root) => root.keyPrefix).filter(Boolean);
1527
+ for (const root of pageRoots) {
1511
1528
  if (!(await FileSys.dirExists(root.dir))) continue;
1512
1529
  for await (const rel of glob.scan({
1513
1530
  cwd: root.dir,
@@ -1517,6 +1534,8 @@ export class AppExecutor extends SysExecutor {
1517
1534
  const segments = rel.split(path.sep);
1518
1535
  if (segments.some((s) => s === "node_modules")) continue;
1519
1536
  const posix = `${root.keyPrefix}${segments.join("/")}`;
1537
+ //* A lib folder that is a copy rather than a link (the Windows fallback) is walked into from `page` too.
1538
+ if (!root.keyPrefix && libKeyPrefixes.some((prefix) => posix.startsWith(prefix))) continue;
1520
1539
  const absPath = path.join(root.dir, ...segments);
1521
1540
  validatePageSourceFile(posix, { filePath: absPath });
1522
1541
  if (!isRouteSourceFile(posix)) continue;
@@ -1619,7 +1638,11 @@ export class AppExecutor extends SysExecutor {
1619
1638
  )
1620
1639
  ).flat();
1621
1640
  const wanted = parents.flatMap((parent) => libs.map((lib) => `${parent}${AppExecutor.#pageLibsDir}/(${lib})/`));
1622
- if (linked.sort().join(",") === wanted.sort().join(",")) return false;
1641
+ //* A copy — the Windows fallback when a junction is refused — does not follow its lib, so only links are current.
1642
+ const isLink = async (key: string) =>
1643
+ (await lstat(`${this.cwdPath}/page/${key.slice(0, -1)}`).catch(() => null))?.isSymbolicLink() ?? false;
1644
+ if (linked.sort().join(",") === wanted.sort().join(",") && (await Promise.all(linked.map(isLink))).every(Boolean))
1645
+ return false;
1623
1646
  await Promise.all(
1624
1647
  parents.map((parent) => this.removeDir(`${this.cwdPath}/page/${parent}${AppExecutor.#pageLibsDir}`)),
1625
1648
  );
@@ -161,6 +161,19 @@ describe("transformSource — client role (package client entry)", () => {
161
161
  );
162
162
  });
163
163
 
164
+ test("never claims fetch from a file that calls the platform fetch", () => {
165
+ const globalOnly = `"use client";\n\nexport const toBlob = async (url: string) => await (await fetch(url)).blob();\n`;
166
+ const mixed = `"use client";\n\nexport const C = async (url: string) => (await fetch(url)).ok && fetch.me() && st.foo;\n`;
167
+ const passed = `"use client";\n\nexport const load = (get = fetch) => get("/a");\n`;
168
+
169
+ expect(transformSource(globalOnly, "CropImage.tsx", clientLibCtx)).toBeNull();
170
+ expect(transformSource(mixed, "C.Zone.tsx", clientLibCtx)).toBe(
171
+ `"use client";\nimport { st } from "@libs/shared/client";\n\nexport const C = async (url: string) => (await fetch(url)).ok && fetch.me() && st.foo;\n`,
172
+ );
173
+ expect(transformSource(passed, "load.ts", clientLibCtx)).toBeNull();
174
+ expect(transformSource(globalOnly, "x.store.ts", storeCtx)).toBeNull();
175
+ });
176
+
164
177
  test("handles webkit .ts files (no jsx) the same as tsx", () => {
165
178
  const source = `import { getEnv } from "akanjs/base";\n\nexport const load = () => fetch.me();\n`;
166
179
  const out = transformSource(source, "cookie.ts", clientLibCtx);
@@ -161,6 +161,10 @@ const LIB_BARRELS: Record<string, { barrel: string; kind: ImportKind }> = {
161
161
  cnst: { barrel: "cnst", kind: "namespace" },
162
162
  srv: { barrel: "srv", kind: "namespace" },
163
163
  };
164
+ //* Registry names that are also runtime globals. An unbound `fetch(url)` is the platform's fetch, and importing Akan's
165
+ //* over it silently rebinds the call, so such a name is claimed only when every reference has the Akan member shape
166
+ //* (`fetch.viewX()`); one bare use leaves the file alone.
167
+ const GLOBAL_NAMES = new Set(["fetch"]);
164
168
  //* ECMAScript/TS structural globals never resolved as domain symbols, so that a package model named
165
169
  //* e.g. `Map` cannot shadow the JS `Map` used in `new Map()`. Domain-y globals like `File` are allowed
166
170
  //* on purpose (they are real models here).
@@ -378,13 +382,14 @@ export const transformSource = (
378
382
  const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKindFor(fileName));
379
383
  const bound = collectBoundNames(sf);
380
384
  const used = collectUsedReferences(sf);
385
+ const bareGlobals = collectBareGlobalReferences(sf);
381
386
 
382
387
  // Group the needed symbols by their import target: framework registry first, then dynamic resolver.
383
388
  // Named groups map each name to whether it is type-only, so we can emit `import type`/inline `type`.
384
389
  const namedBySpecifier = new Map<string, Map<string, boolean>>();
385
390
  const namespaceImports: { name: string; specifier: string }[] = [];
386
391
  for (const name of used) {
387
- if (bound.has(name)) continue;
392
+ if (bound.has(name) || bareGlobals.has(name)) continue;
388
393
  const target = targetFor(name, ctx) ?? resolveExtra?.(name) ?? null;
389
394
  if (!target) continue;
390
395
  if (target.kind === "namespace") namespaceImports.push({ name, specifier: target.specifier });
@@ -478,6 +483,19 @@ const collectUsedReferences = (sf: ts.SourceFile): Set<string> => {
478
483
  return used;
479
484
  };
480
485
 
486
+ const collectBareGlobalReferences = (sf: ts.SourceFile): Set<string> => {
487
+ const bare = new Set<string>();
488
+ const visit = (node: ts.Node) => {
489
+ if (ts.isIdentifier(node) && GLOBAL_NAMES.has(node.text) && isReferencePosition(node)) {
490
+ const parent = node.parent;
491
+ if (!(parent && ts.isPropertyAccessExpression(parent) && parent.expression === node)) bare.add(node.text);
492
+ }
493
+ ts.forEachChild(node, visit);
494
+ };
495
+ ts.forEachChild(sf, visit);
496
+ return bare;
497
+ };
498
+
481
499
  //* True when the identifier reads a binding, as opposed to being a member name, property key, or
482
500
  //* declaration name. e.g. in `cnst.Coordinate`, `cnst` is a reference but `Coordinate` is not.
483
501
  const isReferencePosition = (node: ts.Identifier): boolean => {
@@ -27,7 +27,7 @@ afterEach(async () => {
27
27
 
28
28
  describe("route client store bootstrap", () => {
29
29
  test("wraps client entries with app client bootstrap before re-exporting components", () => {
30
- const original = "/repo/pkgs/akanjs/ui/Model/NewWrapper_Client.tsx";
30
+ const original = path.resolve("/repo/pkgs/akanjs/ui/Model/NewWrapper_Client.tsx");
31
31
  const source = RouteClientBuilder.createStoreBootstrapEntrySource({
32
32
  appName: "akan",
33
33
  originalEntry: original,
@@ -147,6 +147,57 @@ describe("route client store bootstrap", () => {
147
147
  );
148
148
  });
149
149
 
150
+ test("bundles every route's entries into one graph so a page never loads a module twice", async () => {
151
+ const root = await makeTempRoot();
152
+ const appDir = path.join(root, "apps/demo");
153
+ const pageA = path.join(appDir, "page/a.tsx");
154
+ const pageB = path.join(appDir, "page/b.tsx");
155
+ const entryA = path.join(appDir, "ui/A.tsx");
156
+ const entryB = path.join(appDir, "ui/B.tsx");
157
+ const shared = path.join(appDir, "common/shared.ts");
158
+ await write(pageA, 'import { A } from "../ui/A";\nexport default A;\n');
159
+ await write(pageB, 'import { B } from "../ui/B";\nexport default B;\n');
160
+ await write(entryA, '"use client";\nimport { mark } from "../common/shared";\nexport const A = () => mark;\n');
161
+ await write(entryB, '"use client";\nimport { mark } from "../common/shared";\nexport const B = () => mark;\n');
162
+ await write(shared, 'export const mark = "shared-module-marker";\n');
163
+ const app = {
164
+ name: "demo",
165
+ cwdPath: appDir,
166
+ dist: { cwdPath: path.join(root, "dist/apps/demo") },
167
+ workspace: { workspaceRoot: root },
168
+ getConfig: async () => ({ barrelImports: [], optimizeImports: [] }),
169
+ getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
170
+ getPublicEnv: () => ({}),
171
+ } as never;
172
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, async () => null);
173
+ const build = (seeds: string[], knownEntries: Set<string>) =>
174
+ new RouteClientBuilder({
175
+ app,
176
+ seeds,
177
+ graphSeeds: [pageA, pageB],
178
+ knownEntries,
179
+ discovery,
180
+ artifact: {} as never,
181
+ }).build();
182
+
183
+ const first = await build([pageA], new Set());
184
+ const second = await build([pageB], new Set(first.newEntries));
185
+
186
+ expect(first.newEntries).toEqual([entryA, entryB]);
187
+ expect(first.discoveredEntries).toEqual([entryA]);
188
+ expect(first.clientDeps).not.toContain(entryB);
189
+ expect(Object.keys(first.clientDepsByEntry ?? {}).sort()).toEqual([entryA, entryB]);
190
+ expect(second.newEntries).toEqual([]);
191
+ expect(second.discoveredEntries).toEqual([entryB]);
192
+ expect(Object.keys(second.manifestDelta)).toEqual([]);
193
+ const clientOut = path.join(appDir, ".akan/artifact/client");
194
+ const outputs = [...new Bun.Glob("**/*.js").scanSync(clientOut)];
195
+ const carriers = await Promise.all(
196
+ outputs.map(async (file) => (await Bun.file(path.join(clientOut, file)).text()).includes("shared-module-marker")),
197
+ );
198
+ expect(carriers.filter(Boolean)).toHaveLength(1);
199
+ });
200
+
150
201
  test("discovers client entries from installed akanjs package sources", async () => {
151
202
  const root = await makeTempRoot();
152
203
  const seed = path.join(root, "apps/demo/page/_index.tsx");
@@ -135,7 +135,7 @@ export class CssImportResolver {
135
135
 
136
136
  async #firstExisting(basePath: string): Promise<string | null> {
137
137
  for (const suffix of CSS_IMPORT_EXTS) {
138
- const candidate = `${basePath}${suffix}`;
138
+ const candidate = path.normalize(`${basePath}${suffix}`);
139
139
  if (await Bun.file(candidate).exists()) return candidate;
140
140
  }
141
141
  return null;
@@ -170,7 +170,7 @@ describe("FontOptimizer discovery", () => {
170
170
  expect(result.fonts).toEqual([]);
171
171
  expect(result.files).toEqual([]);
172
172
  expect(warnings).toHaveLength(1);
173
- expect(warnings[0]).toContain("page/_layout.tsx");
173
+ expect(warnings[0]).toContain(path.join("page", "_layout.tsx"));
174
174
  });
175
175
 
176
176
  test("warns about an entry it cannot read and keeps the ones it can", async () => {
@@ -333,13 +333,14 @@ describe("RoutesManifestArtifactSerializer", () => {
333
333
  describe("HmrChangeClassifier", () => {
334
334
  test("classifies code, css, config, and ignored files", () => {
335
335
  const classifier = new HmrChangeClassifier();
336
- expect(classifier.classify("/repo/apps/demo/page/_index.tsx")).toBe("code");
337
- expect(classifier.classify("/repo/apps/demo/page/styles.css")).toBe("css");
338
- expect(classifier.classify("/repo/apps/demo/akan.config.ts")).toBe("config");
339
- expect(classifier.classify("/repo/apps/demo/.DS_Store")).toBe("ignore");
340
- expect(classifier.classify(`/repo/apps/demo/node_modules/pkg/index.ts`)).toBe("ignore");
341
- expect(classifier.classify(`/repo/apps/demo/.akan/generated/page.tsx`)).toBe("ignore");
342
- expect(classifier.classify("/repo/apps/demo/public/logo.png")).toBe("ignore");
336
+ const app = path.resolve("/repo/apps/demo");
337
+ expect(classifier.classify(path.join(app, "page/_index.tsx"))).toBe("code");
338
+ expect(classifier.classify(path.join(app, "page/styles.css"))).toBe("css");
339
+ expect(classifier.classify(path.join(app, "akan.config.ts"))).toBe("config");
340
+ expect(classifier.classify(path.join(app, ".DS_Store"))).toBe("ignore");
341
+ expect(classifier.classify(path.join(app, "node_modules/pkg/index.ts"))).toBe("ignore");
342
+ expect(classifier.classify(path.join(app, ".akan/generated/page.tsx"))).toBe("ignore");
343
+ expect(classifier.classify(path.join(app, "public/logo.png"))).toBe("ignore");
343
344
  });
344
345
  });
345
346
 
@@ -394,16 +395,16 @@ describe("DevGeneratedIndexSync", () => {
394
395
 
395
396
  describe("DevChangePlanner", () => {
396
397
  test("classifies server, client, shared, and generated barrel changes", () => {
397
- const root = "/repo";
398
+ const root = path.resolve("/repo");
398
399
  const planner = new DevChangePlanner({ workspaceRoot: root });
399
- const generatedIndex = `${root}/libs/shared/common/index.ts`;
400
+ const generatedIndex = path.join(root, "libs/shared/common/index.ts");
400
401
  const plan = planner.plan({
401
402
  generation: 7,
402
403
  files: [
403
- `${root}/libs/shared/lib/admin/admin.service.ts`,
404
- `${root}/libs/shared/lib/admin/Admin.Template.tsx`,
405
- `${root}/libs/shared/lib/admin/admin.constant.ts`,
406
- `${root}/libs/shared/common/foo.ts`,
404
+ path.join(root, "libs/shared/lib/admin/admin.service.ts"),
405
+ path.join(root, "libs/shared/lib/admin/Admin.Template.tsx"),
406
+ path.join(root, "libs/shared/lib/admin/admin.constant.ts"),
407
+ path.join(root, "libs/shared/common/foo.ts"),
407
408
  ],
408
409
  kinds: ["code"],
409
410
  generatedFiles: [generatedIndex],
@@ -429,21 +430,22 @@ describe("DevChangePlanner", () => {
429
430
  });
430
431
 
431
432
  test("recycles builder for macro-backed dictionary and signal metadata changes", () => {
432
- const root = "/repo";
433
+ const root = path.resolve("/repo");
433
434
  const planner = new DevChangePlanner({ workspaceRoot: root });
435
+ const dictionaryFile = path.join(root, "apps/demo/lib/_demo/demo.dictionary.ts");
434
436
  const dictionaryPlan = planner.plan({
435
437
  generation: 3,
436
- files: [`${root}/apps/demo/lib/_demo/demo.dictionary.ts`],
438
+ files: [dictionaryFile],
437
439
  kinds: ["code"],
438
440
  });
439
441
  const signalPlan = planner.plan({
440
442
  generation: 4,
441
- files: [`${root}/libs/shared/lib/admin/admin.signal.ts`],
443
+ files: [path.join(root, "libs/shared/lib/admin/admin.signal.ts")],
442
444
  kinds: ["code"],
443
445
  });
444
446
 
445
447
  expect(dictionaryPlan.actions).toEqual(["rebuild-client", "restart-backend", "restart-builder"]);
446
- expect(dictionaryPlan.reasonByFile[`${root}/apps/demo/lib/_demo/demo.dictionary.ts`]).toContain("runtime-metadata");
448
+ expect(dictionaryPlan.reasonByFile[dictionaryFile]).toContain("runtime-metadata");
447
449
  expect(signalPlan.actions).toContain("restart-builder");
448
450
  });
449
451
  });
@@ -161,7 +161,7 @@ describe("HmrWatcher", () => {
161
161
  TEST_TIMEOUT_MS,
162
162
  );
163
163
 
164
- test.skipIf(process.getuid?.() === 0)(
164
+ test.skipIf(process.getuid?.() === 0 || process.platform === "win32")(
165
165
  "warns at startup when part of the tree cannot be read",
166
166
  async () => {
167
167
  const root = await makeRoot();
@@ -28,6 +28,15 @@ const SSR_CLIENT_ALIAS_EXTERNALS = [
28
28
  export interface BuildRouteClientOptions {
29
29
  app: App;
30
30
  seeds: string[];
31
+ /**
32
+ * Seeds of every route, so a build that has to run at all bundles the whole app's client entries.
33
+ *
34
+ * A page loads entries first built for other routes, and entries from two `Bun.build` runs each carry
35
+ * their own copy of every app and lib module — one page evaluated a lib's module-scope registration
36
+ * twice. The whole app costs about what one route did, since every entry already imports the client
37
+ * barrel, and content-hashed chunk names keep unchanged chunks at the URLs the browser already holds.
38
+ */
39
+ graphSeeds?: string[];
31
40
  artifact: BaseBuildArtifact;
32
41
  knownEntries?: Set<string>;
33
42
  routeId?: string;
@@ -39,13 +48,13 @@ export interface BuildRouteClientOptions {
39
48
  * `AllRoutesBuilder` passes every route's entries at once so they share one `Bun.build`. Chunk
40
49
  * splitting is scoped to a single invocation, so a dependency reachable from entries spread across
41
50
  * several invocations is emitted once per invocation — mermaid landed in `apps/akan` four times that
42
- * way. Dev keeps one build per route and leaves this unset.
51
+ * way. Dev passes `graphSeeds` instead, for the same reason.
43
52
  */
44
53
  entries?: string[];
45
54
  }
46
55
 
47
56
  export interface BuildRouteClientResult {
48
- /** Newly-emitted manifest rows keyed by `${absEntry}#${exportName}`. */
57
+ /** Newly-emitted manifest rows keyed by `${absEntry}#${exportName}`, for every entry this build bundled. */
49
58
  manifestDelta: ClientManifest;
50
59
  /** Newly-emitted ssrManifest rows keyed by entry URL. */
51
60
  ssrManifestDelta: SsrManifest;
@@ -53,7 +62,7 @@ export interface BuildRouteClientResult {
53
62
  newEntries: string[];
54
63
  /** Absolute paths of all `"use client"` leaves discovered from this route's seeds. */
55
64
  discoveredEntries?: string[];
56
- /** Absolute source files included in the browser bundles for new entries. */
65
+ /** Absolute source files included in the browser bundles for this route's bundled entries. */
57
66
  clientDeps: string[];
58
67
  /** Absolute source files included in the browser bundle, grouped by original client entry. */
59
68
  clientDepsByEntry?: Record<string, string[]>;
@@ -67,6 +76,7 @@ interface BootstrapEntries {
67
76
  export class RouteClientBuilder {
68
77
  #app: App;
69
78
  #seeds: string[];
79
+ #graphSeeds?: string[];
70
80
  #knownEntries: Set<string>;
71
81
  #command: "build" | "start";
72
82
  #discovery?: ClientEntryDiscovery;
@@ -75,6 +85,7 @@ export class RouteClientBuilder {
75
85
  constructor(options: BuildRouteClientOptions) {
76
86
  this.#app = options.app;
77
87
  this.#seeds = options.seeds;
88
+ this.#graphSeeds = options.graphSeeds;
78
89
  this.#knownEntries = options.knownEntries ?? new Set<string>();
79
90
  this.#command = options.command ?? "start";
80
91
  this.#discovery = options.discovery;
@@ -82,17 +93,18 @@ export class RouteClientBuilder {
82
93
  }
83
94
 
84
95
  async build(): Promise<BuildRouteClientResult> {
85
- const discovered =
86
- this.#entries ??
87
- (await (this.#discovery ?? (await GraphClientEntryDiscovery.create(this.#app))).discover(this.#seeds));
88
- const entries = discovered.filter((e) => !this.#knownEntries.has(e));
89
- if (entries.length === 0) return this.#emptyResult(discovered);
96
+ const discovered = this.#entries ?? (await (await this.#getDiscovery()).discover(this.#seeds));
97
+ if (discovered.every((e) => this.#knownEntries.has(e))) return this.#emptyResult(discovered);
98
+ const entries = this.#graphSeeds
99
+ ? [...new Set([...discovered, ...(await (await this.#getDiscovery()).discover(this.#graphSeeds))])].sort()
100
+ : discovered.filter((e) => !this.#knownEntries.has(e));
90
101
 
91
102
  const bootstrapEntries = await this.#createBootstrapEntries(entries);
92
103
  const browserBundle = await this.#buildBrowserBundle(bootstrapEntries);
93
104
  const ssrBundle = await this.#buildSsrBundle(bootstrapEntries);
94
105
 
95
106
  const acceptedEntries = new Set(entries);
107
+ const routeEntries = new Set(discovered);
96
108
  const manifestDelta: ClientManifest = {};
97
109
  const ssrModuleMap: SsrManifest["moduleMap"] = {};
98
110
  const clientDeps = new Set<string>();
@@ -122,19 +134,24 @@ export class RouteClientBuilder {
122
134
  for (const dep of browserBundle.entryDepsByAbsPath.get(buildEntry) ?? []) deps.add(path.resolve(dep));
123
135
  const sortedDeps = [...deps].sort();
124
136
  clientDepsByEntry[originalEntry] = sortedDeps;
125
- for (const dep of sortedDeps) clientDeps.add(dep);
137
+ if (routeEntries.has(originalEntry)) for (const dep of sortedDeps) clientDeps.add(dep);
126
138
  }
127
139
 
128
140
  return {
129
141
  manifestDelta,
130
142
  ssrManifestDelta: { moduleLoading: null, moduleMap: ssrModuleMap },
131
- newEntries: entries,
143
+ newEntries: entries.filter((e) => !this.#knownEntries.has(e)),
132
144
  discoveredEntries: discovered,
133
145
  clientDeps: [...clientDeps].sort(),
134
146
  clientDepsByEntry,
135
147
  };
136
148
  }
137
149
 
150
+ async #getDiscovery(): Promise<ClientEntryDiscovery> {
151
+ this.#discovery ??= await GraphClientEntryDiscovery.create(this.#app);
152
+ return this.#discovery;
153
+ }
154
+
138
155
  #emptyResult(discoveredEntries: string[] = []): BuildRouteClientResult {
139
156
  return {
140
157
  manifestDelta: {},
@@ -249,11 +249,12 @@ describe("SourceMtimeIndex", () => {
249
249
  describe("a directory it cannot read", () => {
250
250
  /**
251
251
  * `chmod 000` does not stop root, so these would assert the opposite of what they mean when the suite
252
- * runs as root (CI containers commonly do).
252
+ * runs as root (CI containers commonly do). On Windows it only sets the read-only attribute, which leaves
253
+ * a directory listable.
253
254
  */
254
- const asRoot = process.getuid?.() === 0;
255
+ const cannotLock = process.getuid?.() === 0 || process.platform === "win32";
255
256
 
256
- test.skipIf(asRoot)("is reported as a gap instead of silently skipped", async () => {
257
+ test.skipIf(cannotLock)("is reported as a gap instead of silently skipped", async () => {
257
258
  const root = await makeRoot();
258
259
  await seed(root, "open/a.ts");
259
260
  const hidden = await seed(root, "locked/b.ts");
@@ -269,7 +270,7 @@ describe("SourceMtimeIndex", () => {
269
270
  expect(index.coverageGaps).toEqual([{ path: path.dirname(hidden), code: "EACCES" }]);
270
271
  });
271
272
 
272
- test.skipIf(asRoot)("becomes visible again once it is readable, and clears the gap", async () => {
273
+ test.skipIf(cannotLock)("becomes visible again once it is readable, and clears the gap", async () => {
273
274
  const root = await makeRoot();
274
275
  const hidden = await seed(root, "locked/b.ts");
275
276
  const locked = path.dirname(hidden);
@@ -287,7 +288,7 @@ describe("SourceMtimeIndex", () => {
287
288
  expect(await index.collectChanges()).toEqual([hidden]);
288
289
  });
289
290
 
290
- test.skipIf(asRoot)("does not report the files under it as deleted while it is unreadable", async () => {
291
+ test.skipIf(cannotLock)("does not report the files under it as deleted while it is unreadable", async () => {
291
292
  const root = await makeRoot();
292
293
  const abs = await seed(root, "locked/b.ts");
293
294
  const locked = path.dirname(abs);
@@ -1,12 +1,14 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
3
4
  import { getDirname } from "./getDirname";
4
5
 
5
6
  describe("getDirname", () => {
6
7
  test("converts file URLs to filesystem paths", () => {
7
- const dirname = getDirname("file:///tmp/akan%20workspace/index.ts");
8
+ const filePath = path.resolve("/tmp/akan workspace/index.ts");
9
+ const dirname = getDirname(pathToFileURL(filePath).href);
8
10
 
9
- expect(dirname).toBe(path.join("/tmp", "akan workspace"));
11
+ expect(dirname).toBe(path.dirname(filePath));
10
12
  });
11
13
 
12
14
  test("keeps Windows drive paths valid when running on Windows", () => {