@akanjs/devkit 3.2.0-rc.3 → 3.2.0-rc.6

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 (44) hide show
  1. package/CHANGELOG.md +80 -11
  2. package/akanApp/akanApp.host.test.ts +51 -0
  3. package/akanApp/akanApp.host.ts +3 -3
  4. package/akanContext.ts +2 -3
  5. package/apiReport/apiDeclarationPrinter.ts +120 -0
  6. package/apiReport/apiReport.test.ts +483 -0
  7. package/apiReport/apiReport.ts +232 -0
  8. package/apiReport/apiReportDiff.ts +126 -0
  9. package/apiReport/apiReportPolicy.ts +64 -0
  10. package/apiReport/apiTargets.ts +311 -0
  11. package/apiReport/changesetBump.ts +19 -0
  12. package/applicationBuildReporter.test.ts +21 -0
  13. package/applicationBuildReporter.ts +8 -0
  14. package/applicationBuildRunner.test.ts +67 -0
  15. package/applicationBuildRunner.ts +6 -1
  16. package/artifact/implicitRootLayout.ts +12 -1
  17. package/codeAgent/skills/akan-debug/SKILL.md +6 -2
  18. package/codeAgent/skills/akan-transport/SKILL.md +2 -0
  19. package/commandDecorators/argMeta.ts +4 -0
  20. package/commandDecorators/command.ts +51 -33
  21. package/commandDecorators/commandDecorators.test.ts +74 -9
  22. package/frontendBuild/allRoutesBuilder.test.ts +16 -4
  23. package/frontendBuild/allRoutesBuilder.ts +24 -6
  24. package/frontendBuild/bundleDefine.ts +2 -0
  25. package/frontendBuild/clientBuildTypes.ts +5 -2
  26. package/frontendBuild/clientEntryDiscovery.test.ts +28 -0
  27. package/frontendBuild/clientEntryDiscovery.ts +25 -6
  28. package/frontendBuild/nativeCore.test.ts +148 -0
  29. package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
  30. package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
  31. package/frontendBuild/vendorImportScanner.test.ts +24 -3
  32. package/frontendBuild/vendorImportScanner.ts +25 -10
  33. package/incrementalBuilder/incrementalBuilder.host.test.ts +5 -1
  34. package/incrementalBuilder/incrementalBuilder.host.ts +2 -1
  35. package/lint/no-web-only-api-outside-webkit.grit +3 -3
  36. package/{constantFieldNames.test.ts → moduleFieldNames.test.ts} +24 -2
  37. package/moduleFieldNames.ts +25 -0
  38. package/package.json +3 -3
  39. package/preview/PreviewView.ts +32 -13
  40. package/preview/previewView.test.ts +33 -3
  41. package/routeJsBudget.test.ts +47 -11
  42. package/routeJsBudget.ts +88 -25
  43. package/workflow/plainName.ts +16 -3
  44. package/constantFieldNames.ts +0 -18
@@ -9,7 +9,15 @@ import { Logger } from "akanjs/common";
9
9
  import chalk from "chalk";
10
10
  import { type Command, program } from "commander";
11
11
  import { AppSelectionMemory } from "../appSelectionMemory";
12
- import { AppExecutor, Executor, LibExecutor, ModuleExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
12
+ import {
13
+ AppExecutor,
14
+ Executor,
15
+ LibExecutor,
16
+ ModuleExecutor,
17
+ PkgExecutor,
18
+ type SysExecutor,
19
+ WorkspaceExecutor,
20
+ } from "../executors";
13
21
  // Never the root barrel: it drags ink, ssh2 and the cloud stack into every command process.
14
22
  import { FileSys, getDirname } from "../fileSys";
15
23
  import type { PackageJson } from "../types";
@@ -311,6 +319,17 @@ export const getInternalArgumentValue = async (
311
319
  if (destructive) throw refusalOf(problemOf(kind), "this command deletes files and never picks its target", refusal);
312
320
  assertPromptable(problemOf(kind), refusal);
313
321
  };
322
+ const takeOnly = <Target>(kind: string, names: readonly string[], take: (name: string) => Target) => {
323
+ const [only] = names;
324
+ if (value || destructive || process.stdin.isTTY || names.length !== 1 || !only) return null;
325
+ const how = rerun ? `: ${rerun(only)}` : "";
326
+ Logger.rawLog(
327
+ `No ${kind} named, so this runs on '${only}', the only one in this workspace${how}`,
328
+ undefined,
329
+ "error",
330
+ );
331
+ return take(only);
332
+ };
314
333
  if (sysType === "sys" || sysType === "exec") {
315
334
  const pkgChoices = sysType === "exec" ? pkgNames : [];
316
335
  const execOf = (name: string) => {
@@ -326,17 +345,8 @@ export const getInternalArgumentValue = async (
326
345
  const [kind, placeholder] =
327
346
  sysType === "exec" ? ["app, lib or pkg", "<app-lib-or-pkg>"] : ["app or lib", "<app-or-lib>"];
328
347
  const choices = [...appNames, ...libNames, ...pkgChoices];
329
- const only = choices.length === 1 ? execOf(choices[0] ?? "") : null;
330
- //? Not for Lib, Pkg or Module, which name what a command acts on: `akan remove-library` would delete the only one.
331
- if (!value && !destructive && only && !process.stdin.isTTY) {
332
- const how = rerun ? `: ${rerun(only.name)}` : "";
333
- Logger.rawLog(
334
- `No ${kind} named, so this runs on '${only.name}', the only one in this workspace${how}`,
335
- undefined,
336
- "error",
337
- );
338
- return only;
339
- }
348
+ const only = takeOnly(kind, choices, execOf);
349
+ if (only) return only;
340
350
  assertAskable(kind, choices, placeholder);
341
351
  const name = await select<string>({
342
352
  message: sysType === "exec" ? `Select the App or Lib or Pkg name` : `Select the App or Lib name`,
@@ -353,40 +363,48 @@ export const getInternalArgumentValue = async (
353
363
  return AppExecutor.from(workspace, appName);
354
364
  } else if (sysType === "lib") {
355
365
  if (value && libNames.includes(value)) return LibExecutor.from(workspace, value);
366
+ const only = takeOnly("lib", libNames, (name) => LibExecutor.from(workspace, name));
367
+ if (only) return only;
356
368
  assertAskable("lib", libNames, "<lib>");
357
369
  const libName = await select<string>({ message: `Select the ${sysType} name`, choices: libNames });
358
370
  return LibExecutor.from(workspace, libName);
359
371
  } else if (sysType === "pkg") {
360
372
  const pkgs = await workspace.getPkgs();
361
373
  if (value && pkgs.includes(value)) return PkgExecutor.from(workspace, value);
374
+ const only = takeOnly("pkg", pkgs, (name) => PkgExecutor.from(workspace, name));
375
+ if (only) return only;
362
376
  assertAskable("pkg", pkgs, "<pkg>");
363
377
  const pkgName = await select<string>({ message: `Select the ${sysType} name`, choices: pkgs });
364
378
  return PkgExecutor.from(workspace, pkgName);
365
379
  } else if (sysType === "module") {
366
- if (value) {
367
- const [sysName, moduleName] = value.split(":");
368
- if (!sysName || !moduleName) throw new Error(`Invalid module name: ${value}`);
369
- if (appNames.includes(sysName)) {
370
- const app = AppExecutor.from(workspace, sysName);
371
- const modules = await app.getModules();
372
- if (modules.includes(moduleName)) return ModuleExecutor.from(app, moduleName);
373
- else throw new Error(`Invalid module name: ${moduleName}`);
374
- } else if (libNames.includes(sysName)) {
375
- const lib = LibExecutor.from(workspace, sysName);
376
- const modules = await lib.getModules();
377
- if (modules.includes(moduleName)) return ModuleExecutor.from(lib, moduleName);
378
- } else throw new Error(`Invalid system name: ${sysName}`);
379
- }
380
380
  const systems = [
381
381
  ...appNames.map((name) => AppExecutor.from(workspace, name)),
382
382
  ...libNames.map((name) => LibExecutor.from(workspace, name)),
383
383
  ];
384
- const choices =
385
- process.stdin.isTTY && !destructive
386
- ? []
387
- : (
388
- await Promise.all(systems.map(async (sys) => (await sys.getModules()).map((m) => `${sys.name}:${m}`)))
389
- ).flat();
384
+ const modulesOf = async (sys: SysExecutor) => [
385
+ ...(await sys.getModules()),
386
+ ...(argMeta.option?.services ? await sys.getServiceModules() : []),
387
+ ];
388
+ const listModules = async () =>
389
+ (await Promise.all(systems.map(async (sys) => (await modulesOf(sys)).map((m) => `${sys.name}:${m}`)))).flat();
390
+ if (value) {
391
+ const [sysName, moduleName] = value.split(":");
392
+ if (!sysName || !moduleName) throw new Error(`Invalid module name: ${value}`);
393
+ const sys = systems.find((each) => each.name === sysName);
394
+ if (!sys) throw new Error(`Invalid system name: ${sysName}`);
395
+ if ((await modulesOf(sys)).includes(moduleName)) return ModuleExecutor.from(sys, moduleName);
396
+ throw refusalOf(problemOf("module"), `${sysName} has no such module`, {
397
+ choices: await listModules(),
398
+ ...rerunRemedyOf(rerun, "<sys:module>"),
399
+ });
400
+ }
401
+ const choices = process.stdin.isTTY && !destructive ? [] : await listModules();
402
+ const only = takeOnly("module", choices, (label) => {
403
+ const [sysName, moduleName = ""] = label.split(":");
404
+ const sys = systems.find((each) => each.name === sysName);
405
+ return sys ? ModuleExecutor.from(sys, moduleName) : null;
406
+ });
407
+ if (only) return only;
390
408
  assertAskable("module", choices, "<sys:module>");
391
409
  const { type, name } = await select<{ type: "app" | "lib"; name: string }>({
392
410
  message: `select the App or Lib name`,
@@ -396,7 +414,7 @@ export const getInternalArgumentValue = async (
396
414
  ],
397
415
  });
398
416
  const executor = type === "app" ? AppExecutor.from(workspace, name) : LibExecutor.from(workspace, name);
399
- const modules = await executor.getModules();
417
+ const modules = await modulesOf(executor);
400
418
  const moduleName = await select<string>({
401
419
  message: `Select the module name`,
402
420
  choices: modules.map((name) => ({ name: `${executor.name}:${name}`, value: name })),
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import path from "node:path";
3
- import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
3
+ import { AppExecutor, LibExecutor, ModuleExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
4
4
  import { makeCliTempWorkspace, tempRoots, writeText } from "../testHelpers";
5
5
  import {
6
6
  App,
@@ -440,6 +440,7 @@ describe("enum arg type inference", () => {
440
440
 
441
441
  describe("a Sys that defaults to the only app", () => {
442
442
  const ownIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
443
+ const ownCwd = process.cwd();
443
444
  const asked: unknown[] = [];
444
445
  let answer = "";
445
446
  const setTTY = (isTTY: boolean | undefined) =>
@@ -455,6 +456,7 @@ describe("a Sys that defaults to the only app", () => {
455
456
  });
456
457
  afterEach(() => {
457
458
  CommandContainer.clear();
459
+ process.chdir(ownCwd);
458
460
  if (ownIsTTY) Object.defineProperty(process.stdin, "isTTY", ownIsTTY);
459
461
  else Reflect.deleteProperty(process.stdin, "isTTY");
460
462
  });
@@ -476,6 +478,21 @@ describe("a Sys that defaults to the only app", () => {
476
478
  if (!meta) throw new Error(`no Sys meta on ${key}`);
477
479
  return meta;
478
480
  };
481
+ const track = tempRoots();
482
+ //? From the workspace root, as the CLI runs: getDirInModule stats its entries relative to the process directory.
483
+ const moduleWorkspace = async () => {
484
+ const { root, workspace } = track(await makeCliTempWorkspace());
485
+ process.chdir(root);
486
+ for (const [sys, name] of [
487
+ ["apps/web", "card"],
488
+ ["libs/util", "photo"],
489
+ ] as const) {
490
+ await writeText(path.join(root, sys, "akan.config.ts"), "export default {};\n");
491
+ await writeText(path.join(root, sys, "lib", name, `${name}.constant.ts`), "export {};\n");
492
+ }
493
+ await writeText(path.join(root, "apps/web/lib/_billing/billing.service.ts"), "export {};\n");
494
+ return workspace;
495
+ };
479
496
 
480
497
  test("the opted-in command takes a one-app workspace's app without asking, even on a piped stdin", async () => {
481
498
  setTTY(undefined);
@@ -525,19 +542,24 @@ describe("a Sys that defaults to the only app", () => {
525
542
  expect(asked).toEqual([]);
526
543
  });
527
544
 
528
- test("without a terminal, the only lib, pkg or module is not taken, as a command may be removing it", async () => {
545
+ test("without a terminal, a command that deletes nothing takes the only lib, pkg or module; a terminal is asked", async () => {
529
546
  setTTY(undefined);
530
547
  const workspace = workspaceWith([]);
531
548
  workspace.getExecs = async () => [[], ["util"], []];
532
549
  workspace.getPkgs = async () => ["tools"];
533
- const rerunOf = (name: string) => rerunCommandOf([name], [undefined], 0);
534
- await expect(
535
- getInternalArgumentValue({ key: "", idx: 0, type: "Lib" }, undefined, workspace, rerunOf("remove-library")),
536
- ).rejects.toThrow("so rerun it as `akan remove-library <lib>` (choices: util).");
537
- await expect(
538
- getInternalArgumentValue({ key: "", idx: 0, type: "Pkg" }, undefined, workspace, rerunOf("remove-package")),
539
- ).rejects.toThrow("so rerun it as `akan remove-package <pkg>` (choices: tools).");
550
+ workspace.getDirInModule = async (basePath) => (basePath === "libs/util/lib" ? ["photo"] : []);
551
+ const lib = await getInternalArgumentValue({ key: "", idx: 0, type: "Lib" }, undefined, workspace);
552
+ expect(lib instanceof LibExecutor ? lib.name : null).toBe("util");
553
+ const pkg = await getInternalArgumentValue({ key: "", idx: 0, type: "Pkg" }, undefined, workspace);
554
+ expect(pkg instanceof PkgExecutor ? pkg.name : null).toBe("tools");
555
+ const mod = await getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, undefined, workspace);
556
+ expect(mod instanceof ModuleExecutor ? `${mod.sys.name}:${mod.name}` : null).toBe("util:photo");
540
557
  expect(asked).toEqual([]);
558
+
559
+ setTTY(true);
560
+ answer = "util";
561
+ await getInternalArgumentValue({ key: "", idx: 0, type: "Lib" }, undefined, workspace);
562
+ expect(asked).toEqual([["util"]]);
541
563
  });
542
564
 
543
565
  test("without a terminal, a named target the workspace lacks is refused, not swapped for the only one", async () => {
@@ -608,6 +630,49 @@ describe("a Sys that defaults to the only app", () => {
608
630
  expect(taken instanceof AppExecutor ? taken.name : null).toBe("web");
609
631
  expect(asked).toEqual([]);
610
632
  });
633
+
634
+ test("a module an app or a lib lacks is refused with the modules there are, in a terminal too", async () => {
635
+ setTTY(true);
636
+ answer = "util";
637
+ const workspace = await moduleWorkspace();
638
+ for (const value of ["util:nope", "web:nope"]) {
639
+ const sysName = value.split(":")[0];
640
+ await expect(
641
+ getInternalArgumentValue(
642
+ { key: "", idx: 0, type: "Module" },
643
+ value,
644
+ workspace,
645
+ rerunCommandOf(["create-view", value], [value], 0),
646
+ ),
647
+ ).rejects.toThrow(
648
+ `Unknown module '${value}': ${sysName} has no such module, so rerun it as \`akan create-view <sys:module>\` (choices: web:card, util:photo).`,
649
+ );
650
+ }
651
+ expect(asked).toEqual([]);
652
+ const photo = await getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, "util:photo", workspace);
653
+ expect(photo instanceof ModuleExecutor ? `${photo.sys.name}:${photo.name}` : null).toBe("util:photo");
654
+ });
655
+
656
+ test("a service module is taken by its exact name where the target opts in, as remove-module does", async () => {
657
+ setTTY(true);
658
+ const workspace = await moduleWorkspace();
659
+ const services = { key: "", idx: 0, type: "Module", option: { services: true } } as const;
660
+ const billing = await getInternalArgumentValue(services, "web:_billing", workspace, undefined, {
661
+ destructive: true,
662
+ });
663
+ expect(billing instanceof ModuleExecutor ? `${billing.sys.name}:${billing.name}` : null).toBe("web:_billing");
664
+ await expect(
665
+ getInternalArgumentValue(services, undefined, workspace, rerunCommandOf(["remove-module"], [undefined], 0), {
666
+ destructive: true,
667
+ }),
668
+ ).rejects.toThrow(
669
+ "so rerun it as `akan remove-module <sys:module>` (choices: web:card, web:_billing, util:photo).",
670
+ );
671
+ await expect(
672
+ getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, "web:_billing", workspace),
673
+ ).rejects.toThrow("Unknown module 'web:_billing': web has no such module");
674
+ expect(asked).toEqual([]);
675
+ });
611
676
  });
612
677
 
613
678
  describe("the line a refused target is rerun with", () => {
@@ -32,14 +32,18 @@ const writeApp = async () => {
32
32
  );
33
33
  await write(
34
34
  path.join(appDir, "page/memo.tsx"),
35
- 'import { Memo } from "../ui/Memo";\nexport default function Page() {\n return <Memo />;\n}\n',
35
+ 'import { System } from "akanjs/ui/System";\nimport { Memo } from "../ui/Memo";\nexport default function Page() {\n return <><Memo /><System.ThemeToggle /></>;\n}\n',
36
+ );
37
+ await write(
38
+ path.join(appDir, "page/shell.tsx"),
39
+ 'import { CSR } from "akanjs/ui/System/CSR";\nexport default function Page() {\n return <CSR />;\n}\n',
36
40
  );
37
41
  const app = {
38
42
  name: "demo",
39
43
  cwdPath: appDir,
40
44
  dist: { cwdPath: path.join(root, "dist/apps/demo") },
41
45
  workspace: { workspaceRoot: root },
42
- getPageKeys: async () => ["./_index.tsx", "./memo.tsx"],
46
+ getPageKeys: async () => ["./_index.tsx", "./memo.tsx", "./shell.tsx"],
43
47
  getConfig: async () => ({ basePaths: [], optimizeImports: [], barrelImports: [] }),
44
48
  getTsConfig: async () => ({ compilerOptions: { paths } }),
45
49
  getPublicEnv: () => ({}),
@@ -51,12 +55,12 @@ const writeApp = async () => {
51
55
  };
52
56
 
53
57
  describe("AllRoutesBuilder", () => {
54
- test("writes the route manifest, then the preload table once the runtime chunks are known, and each route's modules", async () => {
58
+ test("writes the route manifest, then the preload table once the runtime chunks are known, and each route's modules without System's CSR and MCP App providers unless the page imports one", async () => {
55
59
  const app = await writeApp();
56
60
  const routes = new AllRoutesBuilder(app as never, "build");
57
61
  const { manifest, manifestPath } = await routes.build();
58
62
 
59
- expect(new Set(manifest.routeIds)).toEqual(new Set(["/:lang", "/:lang/memo"]));
63
+ expect(new Set(manifest.routeIds)).toEqual(new Set(["/:lang", "/:lang/memo", "/:lang/shell"]));
60
64
  expect(await Bun.file(manifestPath).json()).not.toHaveProperty("modulePreloads");
61
65
 
62
66
  const runtimeChunks = { runtime: ["/_akan/client/rsc.js"], vendors: { react: ["/_akan/client/react.js"] } };
@@ -70,8 +74,14 @@ describe("AllRoutesBuilder", () => {
70
74
  const [home] = chunksOf("apps/demo/ui/Home.tsx#Home");
71
75
  const [memo] = chunksOf("apps/demo/ui/Memo.tsx#Memo");
72
76
  const shell = chunksOf("akanjs/ui/System/Client.tsx#Client");
77
+ const [csrShell] = chunksOf("akanjs/ui/System/CSR.tsx#CSR");
78
+ const [mcpAppShell] = chunksOf("akanjs/ui/System/App.tsx#AppProvider");
79
+ const [themeToggle] = chunksOf("akanjs/ui/System/ThemeToggle.tsx#ThemeToggle");
73
80
  const modules = preloads.modulesByRoute();
74
81
  expect(shell.length).toBeGreaterThan(0);
82
+ expect([csrShell, mcpAppShell, themeToggle]).not.toContain(undefined);
83
+ expect(modules["/:lang/memo"]).toContain(themeToggle);
84
+ expect(modules["/:lang/shell"]).toContain(csrShell);
75
85
  for (const [routeId, own, other] of [
76
86
  ["/:lang", home, memo],
77
87
  ["/:lang/memo", memo, home],
@@ -79,6 +89,8 @@ describe("AllRoutesBuilder", () => {
79
89
  expect(modules[routeId]?.slice(0, 2)).toEqual(["/_akan/client/rsc.js", "/_akan/client/react.js"]);
80
90
  expect(modules[routeId]).toEqual(expect.arrayContaining([own, ...shell]));
81
91
  expect(modules[routeId]).not.toContain(other);
92
+ expect(modules[routeId]).not.toContain(csrShell);
93
+ expect(modules[routeId]).not.toContain(mcpAppShell);
82
94
  }
83
95
  }, 30_000);
84
96
  });
@@ -1,6 +1,11 @@
1
1
  import path from "node:path";
2
2
  import type { RoutesManifest } from "akanjs/server";
3
- import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
3
+ import {
4
+ isImplicitRootLayoutKey,
5
+ type PageEntry,
6
+ ROOT_LAYOUT_SYSTEM,
7
+ resolveSsrPageEntriesForApp,
8
+ } from "../artifact/implicitRootLayout";
4
9
  import { computeRouteSeedIndex, type RouteSeedIndex } from "../artifact/routeSeedIndex";
5
10
  import type { App } from "../commandDecorators";
6
11
  import type { ClientEntryDiscovery } from "./clientBuildTypes";
@@ -26,7 +31,7 @@ export class AllRoutesBuilder {
26
31
  };
27
32
  #knownSet = new Set<string>();
28
33
  #routeIds: string[] = [];
29
- #entriesByRoute = new Map<string, string[]>();
34
+ #ssrEntriesByRoute = new Map<string, string[]>();
30
35
  #externalsByChunkUrl: Record<string, string[]> = {};
31
36
  #discovery: ClientEntryDiscovery | null = null;
32
37
 
@@ -41,15 +46,17 @@ export class AllRoutesBuilder {
41
46
  const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
42
47
  const seedIndex = computeRouteSeedIndex(pageEntries);
43
48
  this.#app.verbose(`[build-all] discovered ${seedIndex.entries.length} routes`);
44
- this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
49
+ const discovery = await GraphClientEntryDiscovery.create(this.#app);
50
+ this.#discovery = discovery;
51
+ const importsAs = await this.#ssrImportsAs(discovery, pageEntries);
45
52
 
46
53
  // Every route's entries go into one Bun.build: chunk splitting only dedupes within a single build.
47
54
  const allEntries = new Set<string>();
48
55
  for (const entry of seedIndex.entries) {
49
56
  const seeds = Array.from(new Set([...seedIndex.globalLayoutFiles, ...entry.seeds]));
50
- const routeEntries = await this.#discovery.discover(seeds);
57
+ const routeEntries = await discovery.discover(seeds);
51
58
  for (const discovered of routeEntries) allEntries.add(discovered);
52
- this.#entriesByRoute.set(entry.routeId, routeEntries);
59
+ this.#ssrEntriesByRoute.set(entry.routeId, await discovery.discover(seeds, { importsAs }));
53
60
  this.#routeIds.push(entry.routeId);
54
61
  }
55
62
  this.#app.verbose(`[build-all] ${allEntries.size} client entries across ${this.#routeIds.length} routes`);
@@ -71,10 +78,21 @@ export class AllRoutesBuilder {
71
78
  return { manifest, manifestPath, seedIndex };
72
79
  }
73
80
 
81
+ async #ssrImportsAs(discovery: GraphClientEntryDiscovery, pageEntries: PageEntry[]) {
82
+ const { specifier, ssrShell, importsNoSsrPageRenders } = ROOT_LAYOUT_SYSTEM;
83
+ const importsAs = new Map<string, ReadonlyMap<string, string | null>>();
84
+ const toShell = new Map([[specifier, ssrShell]]);
85
+ for (const { key, moduleAbsPath } of pageEntries)
86
+ if (isImplicitRootLayoutKey(key)) importsAs.set(path.resolve(moduleAbsPath), toShell);
87
+ const system = await discovery.resolveImport(specifier, this.#app.cwdPath);
88
+ if (system) importsAs.set(system, new Map(importsNoSsrPageRenders.map((spec) => [spec, null])));
89
+ return importsAs;
90
+ }
91
+
74
92
  async writeModulePreloads(runtimeChunks: RuntimeChunks): Promise<RouteModulePreloads> {
75
93
  const preloads = new RouteModulePreloads({
76
94
  runtimeChunks,
77
- entriesByRoute: this.#entriesByRoute,
95
+ entriesByRoute: this.#ssrEntriesByRoute,
78
96
  clientManifest: this.#merged.clientManifest,
79
97
  externalsByChunkUrl: this.#externalsByChunkUrl,
80
98
  workspaceRoot: this.#app.workspace.workspaceRoot,
@@ -9,6 +9,8 @@ export const bundleDefine = (app: App, command: "build" | "start", renderEnv: "s
9
9
  return {
10
10
  "process.env.NODE_ENV": JSON.stringify(nodeEnv),
11
11
  "process.env.AKAN_PUBLIC_RENDER_ENV": JSON.stringify(renderEnv),
12
+ //? NODE_ENV is production under `NODE_ENV=production akan start` too; this says the page's vendors were narrowed.
13
+ "process.env.AKAN_BUNDLE_COMMAND": JSON.stringify(command),
12
14
  //? Sorted: Windows hands a child its environment sorted, while keys the config sets at runtime trail the parent's,
13
15
  //? and the dev registries hash this object to tell a worker's build from the builder's.
14
16
  ...Object.fromEntries(
@@ -38,8 +38,12 @@ export const CLIENT_BUNDLE_NAMING = {
38
38
  asset: "assets/[hash].[ext]",
39
39
  } as const;
40
40
 
41
+ export interface DiscoverOptions {
42
+ importsAs?: ReadonlyMap<string, ReadonlyMap<string, string | null>>;
43
+ }
44
+
41
45
  export interface ClientEntryDiscovery {
42
- discover(seeds: string[]): Promise<string[]>;
46
+ discover(seeds: string[], options?: DiscoverOptions): Promise<string[]>;
43
47
  invalidate?(files: string[]): void;
44
48
  }
45
49
 
@@ -59,7 +63,6 @@ export interface BundleClientEntriesInternalOptions extends BundleClientEntriesO
59
63
  externalSubpaths?: readonly string[];
60
64
  externalAliases?: Partial<Record<string, string>>;
61
65
  command?: "build" | "start";
62
- /** Per absolute entry, the only names its alias re-exports, in place of every name the entry declares. */
63
66
  entryExports?: Partial<Record<string, readonly string[]>>;
64
67
  }
65
68
 
@@ -161,4 +161,32 @@ describe("GraphClientEntryDiscovery", () => {
161
161
  expect(await discovery.discover([file("A.tsx")])).toEqual([file("C.tsx")]);
162
162
  expect(await discovery.discover([file("B.tsx")])).toEqual([file("C.tsx")]);
163
163
  });
164
+
165
+ test("a file walked with its imports swapped or dropped keeps that walk's answer out of every other walk", async () => {
166
+ const file = await tempDir();
167
+ await Bun.write(file("Layout.tsx"), 'import "./Both";\n');
168
+ await Bun.write(file("Page.tsx"), 'import "./Layout";\n');
169
+ await Bun.write(file("Both.tsx"), 'import "./Server";\nimport "./Browser";\n');
170
+ await Bun.write(file("Server.tsx"), 'import "./S";\n');
171
+ await Bun.write(file("Browser.tsx"), '"use client";\nexport const Browser = () => null;\n');
172
+ await Bun.write(file("S.tsx"), '"use client";\nexport const S = () => null;\n');
173
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
174
+ const importsAs = new Map([[file("Layout.tsx"), new Map([["./Both", "./Server"]])]]);
175
+
176
+ expect(await discovery.discover([file("Page.tsx")])).toEqual([file("Browser.tsx"), file("S.tsx")]);
177
+ expect(await discovery.discover([file("Page.tsx")], { importsAs })).toEqual([file("S.tsx")]);
178
+ expect(await discovery.discover([file("Layout.tsx")], { importsAs })).toEqual([file("S.tsx")]);
179
+ expect(await discovery.discover([file("Page.tsx")])).toEqual([file("Browser.tsx"), file("S.tsx")]);
180
+ const dropped = new Map([[file("Both.tsx"), new Map([["./Browser", null]])]]);
181
+ expect(await discovery.discover([file("Page.tsx")], { importsAs: dropped })).toEqual([file("S.tsx")]);
182
+
183
+ const fresh = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
184
+ expect(await fresh.discover([file("Page.tsx")], { importsAs })).toEqual([file("S.tsx")]);
185
+ expect(await fresh.discover([file("Page.tsx")])).toEqual([file("Browser.tsx"), file("S.tsx")]);
186
+ expect(await fresh.discover([file("Layout.tsx")])).toEqual([file("Browser.tsx"), file("S.tsx")]);
187
+
188
+ await Bun.write(file("Server.tsx"), "export const server = 1;\n");
189
+ fresh.invalidate([file("Server.tsx")]);
190
+ expect(await fresh.discover([file("Page.tsx")], { importsAs })).toEqual([]);
191
+ });
164
192
  });
@@ -4,7 +4,7 @@ import { SOURCE_EXTS } from "../akanApp/devHostPolicy";
4
4
  import type { App } from "../commandDecorators";
5
5
  import { BarrelAnalyzer, type BarrelExportMap } from "../transforms/barrelAnalyzer";
6
6
  import { createTsconfigPackageResolver, rewriteBarrelImports } from "../transforms/barrelImportsPlugin";
7
- import type { AkanConfig, ClientEntryDiscovery, ScannedImport } from "./clientBuildTypes";
7
+ import type { AkanConfig, ClientEntryDiscovery, DiscoverOptions, ScannedImport } from "./clientBuildTypes";
8
8
 
9
9
  const USE_CLIENT_RE = /^\s*(?:\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*|\/\/[^\n]*\n\s*)*["']use client["']/;
10
10
  const NODE_MODULES_RE = /[\\/]node_modules[\\/]/;
@@ -31,6 +31,8 @@ interface DiscoveryWalk {
31
31
  //? Every file's result within this walk, partial ones included: the file the cycle went back to is an ancestor here
32
32
  //? and brings the rest itself, and re-walking each path through a dense cycle grows exponentially.
33
33
  memo: Map<string, Set<string>>;
34
+ importsAs?: DiscoverOptions["importsAs"];
35
+ reachable: Map<string, Set<string>>;
34
36
  }
35
37
 
36
38
  const exportsKeyOf = (map: BarrelExportMap | null): string =>
@@ -50,6 +52,8 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
50
52
  #resolvedFileCache = new Map<string, Lookup<string | null>>();
51
53
  #resolvedSpecifierCache = new Map<string, Lookup<string | null>>();
52
54
  #reachableEntriesCache = new Map<string, Set<string>>();
55
+ //? Keyed by a walk's `importsAs`: a file's answer under swapped imports holds only for walks swapping the same ones.
56
+ #swappedEntriesCaches = new WeakMap<object, Map<string, Set<string>>>();
53
57
  #readFiles = new Set<string>();
54
58
  #barrelImporters = new Map<string, Set<string>>();
55
59
  //? Barrels a save went beside, by the analysis before it: a save of any component re-reads its barrel, and only an
@@ -70,17 +74,29 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
70
74
  return new GraphClientEntryDiscovery(await app.getConfig(), await createTsconfigPackageResolver(app));
71
75
  }
72
76
 
73
- async discover(seeds: string[]): Promise<string[]> {
77
+ async discover(seeds: string[], { importsAs }: DiscoverOptions = {}): Promise<string[]> {
74
78
  this.#settled = this.#settled.then(() => this.#dropRenarrowed());
75
79
  await this.#settled;
76
80
  const entries = new Set<string>();
81
+ const reachable = importsAs ? this.#swappedEntriesCache(importsAs) : this.#reachableEntriesCache;
77
82
  for (const seed of seeds) {
78
- const walk: DiscoveryWalk = { visiting: new Set(), partial: new Set(), memo: new Map() };
83
+ const walk: DiscoveryWalk = { visiting: new Set(), partial: new Set(), memo: new Map(), importsAs, reachable };
79
84
  for (const entry of await this.#discoverFromFile(seed, walk)) entries.add(entry);
80
85
  }
81
86
  return Array.from(entries).sort();
82
87
  }
83
88
 
89
+ async resolveImport(spec: string, importerDir: string): Promise<string | null> {
90
+ const resolved = await this.#resolveSpecifier(spec, importerDir);
91
+ return resolved ? path.resolve(resolved) : null;
92
+ }
93
+
94
+ #swappedEntriesCache(importsAs: object): Map<string, Set<string>> {
95
+ const cache = this.#swappedEntriesCaches.get(importsAs) ?? new Map<string, Set<string>>();
96
+ this.#swappedEntriesCaches.set(importsAs, cache);
97
+ return cache;
98
+ }
99
+
84
100
  invalidate(files: string[]): void {
85
101
  if (files.length === 0) return;
86
102
  let moved = false;
@@ -95,6 +111,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
95
111
  this.#epoch += 1;
96
112
  // Reachable-entry results are transitive, so a changed child invalidates every ancestor.
97
113
  this.#reachableEntriesCache.clear();
114
+ this.#swappedEntriesCaches = new WeakMap();
98
115
  for (const [barrel, before] of this.#analyzer.forget(files))
99
116
  if (!this.#reread.has(barrel)) this.#reread.set(barrel, before);
100
117
  //? A file gone, or one never read, can leave a found resolution on its old target (`Chart.tsx` to
@@ -223,7 +240,8 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
223
240
  async #discoverFromFile(file: string, walk: DiscoveryWalk): Promise<Set<string>> {
224
241
  const epoch = this.#epoch;
225
242
  const absPath = path.resolve(file);
226
- const cached = this.#reachableEntriesCache.get(absPath);
243
+ const importsAs = walk.importsAs?.get(absPath);
244
+ const cached = walk.reachable.get(absPath);
227
245
  if (cached) return new Set(cached);
228
246
  const memo = walk.memo.get(absPath);
229
247
  if (memo) {
@@ -253,7 +271,8 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
253
271
 
254
272
  const importerDir = path.dirname(absPath);
255
273
  for (const imp of facts.imports) {
256
- const spec = imp.path;
274
+ const swapped = importsAs?.get(imp.path);
275
+ const spec = swapped === undefined ? imp.path : swapped;
257
276
  if (!spec) continue;
258
277
  if (NON_SOURCE_EXT_RE.test(spec)) continue;
259
278
  const resolved = await this.#resolveSpecifier(spec, importerDir);
@@ -268,7 +287,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
268
287
  #finishDiscovery(absPath: string, walk: DiscoveryWalk, entries: Set<string>, epoch: number): Set<string> {
269
288
  walk.visiting.delete(absPath);
270
289
  walk.memo.set(absPath, entries);
271
- if (epoch === this.#epoch && !walk.partial.has(absPath)) this.#reachableEntriesCache.set(absPath, entries);
290
+ if (epoch === this.#epoch && !walk.partial.has(absPath)) walk.reachable.set(absPath, entries);
272
291
  return new Set(entries);
273
292
  }
274
293
  }