@akanjs/devkit 3.2.0-rc.1 → 3.2.0-rc.10

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 (108) hide show
  1. package/CHANGELOG.md +233 -11
  2. package/agentAssets.ts +1 -1
  3. package/akanApp/akanApp.host.test.ts +127 -0
  4. package/akanApp/akanApp.host.ts +50 -57
  5. package/akanApp/devHostPolicy.test.ts +11 -11
  6. package/akanApp/devHostPolicy.ts +2 -4
  7. package/akanContext.ts +2 -5
  8. package/akanMcpContract.ts +21 -10
  9. package/akanMcpToolError.ts +16 -0
  10. package/apiReport/apiDeclarationPrinter.ts +120 -0
  11. package/apiReport/apiReport.test.ts +497 -0
  12. package/apiReport/apiReport.ts +232 -0
  13. package/apiReport/apiReportDiff.ts +126 -0
  14. package/apiReport/apiReportPolicy.ts +65 -0
  15. package/apiReport/apiTargets.ts +311 -0
  16. package/apiReport/changesetBump.ts +19 -0
  17. package/applicationBuildReporter.test.ts +21 -0
  18. package/applicationBuildReporter.ts +8 -0
  19. package/applicationBuildRunner.test.ts +67 -0
  20. package/applicationBuildRunner.ts +6 -1
  21. package/artifact/implicitRootLayout.test.ts +4 -0
  22. package/artifact/implicitRootLayout.ts +13 -1
  23. package/artifact/storeBootstrap.ts +3 -2
  24. package/bareErrorScanner.ts +126 -0
  25. package/biome.base.json +2 -1
  26. package/codeAgent/skills/akan-debug/SKILL.md +7 -3
  27. package/codeAgent/skills/akan-err/SKILL.md +7 -0
  28. package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
  29. package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-transport/SKILL.md +2 -0
  31. package/codeAgent/skills/akan-validate/SKILL.md +1 -0
  32. package/codeAgent/tools/McpClient.ts +28 -6
  33. package/codeAgent/tools/McpToolPack.ts +1 -1
  34. package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
  35. package/commandDecorators/argMeta.ts +4 -0
  36. package/commandDecorators/cliError.ts +72 -0
  37. package/commandDecorators/command.ts +186 -96
  38. package/commandDecorators/commandDecorators.test.ts +240 -17
  39. package/commandDecorators/exitCodeError.ts +6 -7
  40. package/commandDecorators/index.ts +1 -0
  41. package/commandDecorators/targetMeta.ts +3 -0
  42. package/conventionSet.test.ts +5 -2
  43. package/csrE2e/csrE2eHarness.fixture.ts +1 -1
  44. package/errorCodeReference.test.ts +282 -0
  45. package/errorCodeReference.ts +71 -0
  46. package/errorCodeScan.ts +551 -0
  47. package/frontendBuild/allRoutesBuilder.test.ts +16 -4
  48. package/frontendBuild/allRoutesBuilder.ts +24 -6
  49. package/frontendBuild/buildRouteClient.test.ts +43 -0
  50. package/frontendBuild/bundleDefine.ts +2 -0
  51. package/frontendBuild/clientBuildTypes.ts +5 -2
  52. package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
  53. package/frontendBuild/clientEntryDiscovery.ts +53 -9
  54. package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
  55. package/frontendBuild/csrArtifactBuilder.ts +9 -3
  56. package/frontendBuild/csrDevBundler.ts +12 -2
  57. package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
  58. package/frontendBuild/csrDevModuleCompiler.ts +37 -8
  59. package/frontendBuild/csrDevPatcher.test.ts +89 -2
  60. package/frontendBuild/csrDevPatcher.ts +47 -7
  61. package/frontendBuild/csrDevTypes.ts +16 -0
  62. package/frontendBuild/cssCompiler.test.ts +63 -1
  63. package/frontendBuild/devBarrelImports.ts +132 -0
  64. package/frontendBuild/devRegistryBundler.ts +19 -4
  65. package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
  66. package/frontendBuild/nativeCore.test.ts +148 -0
  67. package/frontendBuild/precompressArtifacts.ts +1 -1
  68. package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
  69. package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
  70. package/frontendBuild/ssrDevBundler.ts +13 -2
  71. package/frontendBuild/vendorImportScanner.test.ts +24 -3
  72. package/frontendBuild/vendorImportScanner.ts +25 -10
  73. package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
  74. package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
  75. package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
  76. package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
  77. package/lint/no-import-common-err.grit +31 -0
  78. package/lint/no-init-fetch-in-client.grit +1 -1
  79. package/lint/no-web-only-api-outside-webkit.grit +3 -3
  80. package/maintainabilityBaseline.test.ts +49 -0
  81. package/maintainabilityBaseline.ts +73 -8
  82. package/maintainabilityContract.test.ts +302 -1
  83. package/maintainabilityContract.ts +87 -22
  84. package/maintainabilityExceptionScanner.test.ts +14 -0
  85. package/maintainabilityExceptionScanner.ts +5 -1
  86. package/maintainabilityLimits.ts +12 -0
  87. package/maintainabilityReport.ts +20 -3
  88. package/moduleFieldNames.test.ts +56 -0
  89. package/moduleFieldNames.ts +25 -0
  90. package/package.json +3 -3
  91. package/preview/PreviewView.ts +32 -11
  92. package/preview/previewView.test.ts +76 -9
  93. package/routeJsBudget.test.ts +47 -11
  94. package/routeJsBudget.ts +88 -25
  95. package/transforms/barrelAnalyzer.ts +46 -1
  96. package/transforms/barrelImportsPlugin.ts +61 -45
  97. package/transforms/transforms.test.ts +67 -1
  98. package/typecheck/projectTypecheck.ts +5 -1
  99. package/typecheck/typecheck.proc.ts +3 -2
  100. package/typecheck/typecheckFailure.ts +6 -1
  101. package/workflow/artifacts.ts +25 -7
  102. package/workflow/fieldType.ts +92 -0
  103. package/workflow/index.ts +2 -0
  104. package/workflow/plainName.ts +28 -0
  105. package/workflow/plan.ts +67 -22
  106. package/workflow/source.ts +0 -5
  107. package/workspaceGraph.test.ts +200 -3
  108. package/workspaceGraph.ts +46 -8
@@ -343,6 +343,49 @@ describe("route client store bootstrap", () => {
343
343
  expect(bundled).not.toContain("lib-ui-marker");
344
344
  });
345
345
 
346
+ test("a route's bundle runs the client runtime and the stores before the UI it renders, which reads them on load", async () => {
347
+ const { appDir, app } = await writeBarrelFixture();
348
+ const record = (label: string) =>
349
+ `((globalThis as { __akanOrder?: string[] }).__akanOrder ??= []).push("${label}");`;
350
+ await write(
351
+ path.join(appDir, "lib/useClient.ts"),
352
+ `${record("runtime")}\nexport const { msg, Err, usePage, fetch, sig } = { usePage: () => "page" } as Record<string, unknown>;\n`,
353
+ );
354
+ await write(
355
+ path.join(appDir, "../../libs/kit/lib/st.ts"),
356
+ `${record("lib-store")}\nexport class RootStore {}\nexport const st = Object.freeze({ mark: "lib-store-marker" });\n`,
357
+ );
358
+ await write(
359
+ path.join(appDir, "lib/st.ts"),
360
+ `import { RootStore as kit } from "@libs/kit/client";\n${record("store")}\nexport class RootStore {}\nexport const st = Object.freeze({ mark: "store-marker", kit });\n`,
361
+ );
362
+ await write(
363
+ path.join(appDir, "lib/memo/Memo.Zone.tsx"),
364
+ `"use client";\nimport { Memo, st } from "@apps/demo/client";\n${record("memo-ui")}\nexport const seen = [st.mark, typeof Memo];\n`,
365
+ );
366
+
367
+ await new RouteClientBuilder({
368
+ app,
369
+ seeds: [],
370
+ entries: [path.join(appDir, "lib/memo/Memo.Zone.tsx")],
371
+ artifact: {} as never,
372
+ browser: "chunks",
373
+ }).build();
374
+
375
+ const clientOut = path.join(appDir, ".akan/artifact/client");
376
+ const entries = [...new Bun.Glob("*.js").scanSync(clientOut)];
377
+ expect(entries).toHaveLength(1);
378
+ const loaded = (await import(path.join(clientOut, entries[0] ?? ""))) as { seen?: unknown };
379
+
380
+ expect(loaded.seen).toEqual(["store-marker", "object"]);
381
+ expect((globalThis as { __akanOrder?: string[] }).__akanOrder).toEqual([
382
+ "runtime",
383
+ "lib-store",
384
+ "store",
385
+ "memo-ui",
386
+ ]);
387
+ });
388
+
346
389
  test("a route reaching a lib's cnst through its client barrel carries no module UI", async () => {
347
390
  const { root, appDir, app } = await writeBarrelFixture();
348
391
 
@@ -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
 
@@ -2,6 +2,7 @@ import { afterAll, describe, expect, test } from "bun:test";
2
2
  import { mkdtemp, rm } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { createTsconfigPackageResolver } from "../transforms/barrelImportsPlugin";
5
6
  import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
6
7
 
7
8
  const dirs: string[] = [];
@@ -57,6 +58,28 @@ describe("GraphClientEntryDiscovery", () => {
57
58
  expect(await discovery.discover([file("S.tsx")])).toEqual([file("ui/Card.ts"), file("ui/Chart/index.tsx")]);
58
59
  });
59
60
 
61
+ test("an export moved into a client file of a barrel is found through the importer that names it", async () => {
62
+ const file = await tempDir();
63
+ await Bun.write(file("ui/index.ts"), 'export * from "./Foo";\nexport * from "./Other";\n');
64
+ await Bun.write(file("ui/Foo.tsx"), "export const Foo = 1;\nexport const Bar = () => null;\n");
65
+ await Bun.write(file("ui/Other.tsx"), '"use client";\nexport const Other = () => null;\n');
66
+ await Bun.write(file("S.tsx"), 'import { Bar } from "@demo/ui";\nexport const s = Bar;\n');
67
+ const resolvePackage = async (pkgName: string) => {
68
+ const leaf = pkgName === "@demo/ui" ? "index.ts" : `${pkgName.slice("@demo/ui/".length)}.tsx`;
69
+ return { pkgName, entryFile: file(`ui/${leaf}`), pkgDir: file("ui") };
70
+ };
71
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: ["@demo/ui"] }, resolvePackage as never);
72
+ expect(await discovery.discover([file("S.tsx")])).toEqual([]);
73
+
74
+ await Bun.write(file("ui/Foo.tsx"), "export const Foo = 1;\n");
75
+ await Bun.write(
76
+ file("ui/Other.tsx"),
77
+ '"use client";\nexport const Other = () => null;\nexport const Bar = () => null;\n',
78
+ );
79
+ discovery.invalidate([file("ui/Foo.tsx"), file("ui/Other.tsx")]);
80
+ expect(await discovery.discover([file("S.tsx")])).toEqual([file("ui/Other.tsx")]);
81
+ });
82
+
60
83
  test("a resolution still in flight when its target appears is not what the next walk reads", async () => {
61
84
  const file = await tempDir();
62
85
  await Bun.write(file("S.tsx"), 'import "./B";\n');
@@ -100,6 +123,35 @@ describe("GraphClientEntryDiscovery", () => {
100
123
  expect(await discovery.discover([file("M5.tsx")])).toEqual([file("C.tsx")]);
101
124
  });
102
125
 
126
+ test("a root layout importing System by its file under a tsconfig wildcard reaches System's client entries", async () => {
127
+ const file = await tempDir();
128
+ const workspaceRoot = path.dirname(file("x"));
129
+ await Bun.write(
130
+ file("pkgs/akanjs/ui/System/index.tsx"),
131
+ 'import SSR from "./SSR";\nexport const System = { SSR };\n',
132
+ );
133
+ await Bun.write(
134
+ file("pkgs/akanjs/ui/System/SSR.tsx"),
135
+ 'import { ClientWrapper } from "./Client";\nexport default ClientWrapper;\n',
136
+ );
137
+ await Bun.write(
138
+ file("pkgs/akanjs/ui/System/Client.tsx"),
139
+ '"use client";\nexport const ClientWrapper = () => null;\n',
140
+ );
141
+ await Bun.write(
142
+ file("root/__root_layout.tsx"),
143
+ 'import { System } from "akanjs/ui/System/index.tsx";\nexport default System;\n',
144
+ );
145
+ const resolvePackage = await createTsconfigPackageResolver({
146
+ workspace: { workspaceRoot },
147
+ getTsConfig: async () => ({ compilerOptions: { paths: { "akanjs/ui/*": ["./pkgs/akanjs/ui/*"] } } }),
148
+ } as never);
149
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, resolvePackage);
150
+ expect(await discovery.discover([file("root/__root_layout.tsx")])).toEqual([
151
+ file("pkgs/akanjs/ui/System/Client.tsx"),
152
+ ]);
153
+ });
154
+
103
155
  test("a file walked inside an import cycle keeps no partial result for a walk that starts at it", async () => {
104
156
  const file = await tempDir();
105
157
  await Bun.write(file("A.tsx"), 'import "./B";\nimport "./C";\n');
@@ -109,4 +161,32 @@ describe("GraphClientEntryDiscovery", () => {
109
161
  expect(await discovery.discover([file("A.tsx")])).toEqual([file("C.tsx")]);
110
162
  expect(await discovery.discover([file("B.tsx")])).toEqual([file("C.tsx")]);
111
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
+ });
112
192
  });
@@ -2,9 +2,9 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { SOURCE_EXTS } from "../akanApp/devHostPolicy";
4
4
  import type { App } from "../commandDecorators";
5
- import { BarrelAnalyzer } from "../transforms/barrelAnalyzer";
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,8 +31,13 @@ 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
 
38
+ const exportsKeyOf = (map: BarrelExportMap | null): string =>
39
+ JSON.stringify(map ? [...map].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) : null);
40
+
36
41
  const shouldSkipNodeModule = (absPath: string) => NODE_MODULES_RE.test(absPath) && !AKANJS_NODE_MODULE_RE.test(absPath);
37
42
 
38
43
  /** `"use client"` discovery over the import graph (dynamic imports included), flattening barrels as the bundler does. */
@@ -47,7 +52,14 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
47
52
  #resolvedFileCache = new Map<string, Lookup<string | null>>();
48
53
  #resolvedSpecifierCache = new Map<string, Lookup<string | null>>();
49
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>>>();
50
57
  #readFiles = new Set<string>();
58
+ #barrelImporters = new Map<string, Set<string>>();
59
+ //? Barrels a save went beside, by the analysis before it: a save of any component re-reads its barrel, and only an
60
+ //? export that moved sends that barrel's importers back to the disk.
61
+ #reread = new Map<string, Promise<BarrelExportMap | null>>();
62
+ #settled: Promise<void> = Promise.resolve();
51
63
  //? Bumped by every invalidation: a walk that began before one computed from facts it may have read stale, so it
52
64
  //? answers its caller but caches nothing (a registry check walks this instance beside the slow lane's route builds).
53
65
  #epoch = 0;
@@ -62,15 +74,29 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
62
74
  return new GraphClientEntryDiscovery(await app.getConfig(), await createTsconfigPackageResolver(app));
63
75
  }
64
76
 
65
- async discover(seeds: string[]): Promise<string[]> {
77
+ async discover(seeds: string[], { importsAs }: DiscoverOptions = {}): Promise<string[]> {
78
+ this.#settled = this.#settled.then(() => this.#dropRenarrowed());
79
+ await this.#settled;
66
80
  const entries = new Set<string>();
81
+ const reachable = importsAs ? this.#swappedEntriesCache(importsAs) : this.#reachableEntriesCache;
67
82
  for (const seed of seeds) {
68
- 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 };
69
84
  for (const entry of await this.#discoverFromFile(seed, walk)) entries.add(entry);
70
85
  }
71
86
  return Array.from(entries).sort();
72
87
  }
73
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
+
74
100
  invalidate(files: string[]): void {
75
101
  if (files.length === 0) return;
76
102
  let moved = false;
@@ -85,6 +111,9 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
85
111
  this.#epoch += 1;
86
112
  // Reachable-entry results are transitive, so a changed child invalidates every ancestor.
87
113
  this.#reachableEntriesCache.clear();
114
+ this.#swappedEntriesCaches = new WeakMap();
115
+ for (const [barrel, before] of this.#analyzer.forget(files))
116
+ if (!this.#reread.has(barrel)) this.#reread.set(barrel, before);
88
117
  //? A file gone, or one never read, can leave a found resolution on its old target (`Chart.tsx` to
89
118
  //? `Chart/index.tsx`), so a move drops them all; a save of a file already read keeps them (a walk re-resolving
90
119
  //? every package cost 3x). Misses lapse with the epoch on their own (#lookup).
@@ -94,6 +123,17 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
94
123
  this.#resolvedSpecifierCache.clear();
95
124
  }
96
125
 
126
+ //? An importer's facts hold the files its barrel imports narrowed to, which an export moved between them changes.
127
+ async #dropRenarrowed(): Promise<void> {
128
+ const reread = [...this.#reread];
129
+ this.#reread.clear();
130
+ for (const [barrel, before] of reread) {
131
+ const [was, now] = await Promise.all([before, this.#analyzer.analyze(barrel)]);
132
+ if (exportsKeyOf(was) === exportsKeyOf(now)) continue;
133
+ for (const importer of this.#barrelImporters.get(barrel) ?? []) this.#factsCache.delete(importer);
134
+ }
135
+ }
136
+
97
137
  //? A found answer outlives later epochs; a miss, or a lookup still in flight, answers only walks of its own epoch, so
98
138
  //? a file created meanwhile is looked up again.
99
139
  #lookup<T>(cache: Map<string, Lookup<T>>, key: string, run: () => Promise<T>, isFound: (value: T) => boolean) {
@@ -133,15 +173,17 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
133
173
  if (content === null) return null;
134
174
  this.#readFiles.add(absPath);
135
175
  if (USE_CLIENT_RE.test(content)) return { isClientEntry: true, imports: [] };
136
- return { isClientEntry: false, imports: this.#scanImports(await this.#rewrite(content)) };
176
+ return { isClientEntry: false, imports: this.#scanImports(await this.#rewrite(content, absPath)) };
137
177
  })();
138
178
  this.#factsCache.set(absPath, cached);
139
179
  }
140
180
  return cached;
141
181
  }
142
182
 
143
- async #rewrite(content: string): Promise<string> {
183
+ async #rewrite(content: string, absPath: string): Promise<string> {
144
184
  if (this.#akanConfig.barrelImports.length === 0) return content;
185
+ for (const barrel of this.#akanConfig.barrelImports.filter((barrel) => content.includes(barrel)))
186
+ this.#barrelImporters.set(barrel, (this.#barrelImporters.get(barrel) ?? new Set()).add(absPath));
145
187
  try {
146
188
  return (await rewriteBarrelImports(content, this.#akanConfig.barrelImports, this.#analyzer)) ?? content;
147
189
  } catch {
@@ -198,7 +240,8 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
198
240
  async #discoverFromFile(file: string, walk: DiscoveryWalk): Promise<Set<string>> {
199
241
  const epoch = this.#epoch;
200
242
  const absPath = path.resolve(file);
201
- const cached = this.#reachableEntriesCache.get(absPath);
243
+ const importsAs = walk.importsAs?.get(absPath);
244
+ const cached = walk.reachable.get(absPath);
202
245
  if (cached) return new Set(cached);
203
246
  const memo = walk.memo.get(absPath);
204
247
  if (memo) {
@@ -228,7 +271,8 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
228
271
 
229
272
  const importerDir = path.dirname(absPath);
230
273
  for (const imp of facts.imports) {
231
- const spec = imp.path;
274
+ const swapped = importsAs?.get(imp.path);
275
+ const spec = swapped === undefined ? imp.path : swapped;
232
276
  if (!spec) continue;
233
277
  if (NON_SOURCE_EXT_RE.test(spec)) continue;
234
278
  const resolved = await this.#resolveSpecifier(spec, importerDir);
@@ -243,7 +287,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
243
287
  #finishDiscovery(absPath: string, walk: DiscoveryWalk, entries: Set<string>, epoch: number): Set<string> {
244
288
  walk.visiting.delete(absPath);
245
289
  walk.memo.set(absPath, entries);
246
- 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);
247
291
  return new Set(entries);
248
292
  }
249
293
  }
@@ -125,3 +125,51 @@ console.info(await result.outputs[0].text());
125
125
  expect(stdout.trim()).toBe("a");
126
126
  });
127
127
  });
128
+
129
+ describe("CsrArtifactBuilder.barrelImportsPlugin", () => {
130
+ test("narrows an app's client barrel to the runtime, the store and the modules a page names", async () => {
131
+ const root = await makeTempRoot();
132
+ const appDir = path.join(root, "apps/demo");
133
+ const paths = { "@apps/*": ["./apps/*"] };
134
+ await writeText(path.join(root, "tsconfig.json"), JSON.stringify({ compilerOptions: { baseUrl: ".", paths } }));
135
+ await writeText(
136
+ path.join(appDir, "client.ts"),
137
+ [
138
+ 'export { usePage } from "./lib/useClient";',
139
+ 'export { st } from "./lib/st";',
140
+ 'export { Memo } from "./lib/memo";',
141
+ 'export { Other } from "./lib/other";',
142
+ "",
143
+ ].join("\n"),
144
+ );
145
+ await writeText(path.join(appDir, "lib/useClient.ts"), 'console.info("runtime");\nexport const usePage = 1;\n');
146
+ await writeText(path.join(appDir, "lib/st.ts"), 'console.info("store");\nexport const st = 2;\n');
147
+ await writeText(path.join(appDir, "lib/memo/index.ts"), 'console.info("memo-ui");\nexport const Memo = 3;\n');
148
+ await writeText(path.join(appDir, "lib/other/index.ts"), 'console.info("other-ui");\nexport const Other = 4;\n');
149
+ await writeText(path.join(appDir, "page.ts"), 'import { Memo } from "@apps/demo/client";\nconsole.info(Memo);\n');
150
+ const app = {
151
+ workspace: { workspaceRoot: root },
152
+ getConfig: async () => ({ barrelImports: ["akanjs/ui", "@apps/demo/client"] }),
153
+ getTsConfig: async () => ({ compilerOptions: { paths } }),
154
+ getPublicEnv: () => ({}),
155
+ };
156
+
157
+ const options = CsrArtifactBuilder.bundleOptions(app as never, "build");
158
+ const result = await Bun.build({
159
+ ...options,
160
+ plugins: [...options.plugins, await CsrArtifactBuilder.barrelImportsPlugin(app as never)],
161
+ entrypoints: [path.join(appDir, "page.ts")],
162
+ });
163
+ expect(result.success).toBe(true);
164
+ await Bun.write(path.join(root, "out.js"), (await result.outputs[0]?.text()) ?? "");
165
+ const proc = Bun.spawn([process.execPath, "out.js"], { cwd: root, stdout: "pipe", stderr: "pipe" });
166
+ const [stdout, stderr] = await Promise.all([
167
+ new Response(proc.stdout).text(),
168
+ new Response(proc.stderr).text(),
169
+ proc.exited,
170
+ ]);
171
+
172
+ expect(stderr).toBe("");
173
+ expect(stdout.trim().split("\n")).toEqual(["runtime", "store", "memo-ui", "3"]);
174
+ });
175
+ });
@@ -3,7 +3,11 @@ import path from "node:path";
3
3
  import type { BaseBuildArtifact } from "akanjs/server";
4
4
  import { type PageEntry, resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
5
  import type { App } from "../commandDecorators";
6
- import { type BarrelImportsPluginOptions, createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
6
+ import {
7
+ type BarrelImportsPluginOptions,
8
+ createBarrelImportsPlugin,
9
+ workspaceBarrels,
10
+ } from "../transforms/barrelImportsPlugin";
7
11
  import { createBrowserDirnamePlugin } from "../transforms/browserDirnamePlugin";
8
12
  import { SideEffectImportOrder } from "../transforms/sideEffectImportOrder";
9
13
  import { bundleConditions, bundleDefine } from "./bundleDefine";
@@ -121,9 +125,11 @@ export class CsrArtifactBuilder {
121
125
  };
122
126
  }
123
127
 
124
- //? Whole, the akanjs/ui barrel bundles every component and the optional peers (react-icons) they import.
128
+ //? Whole, the akanjs/ui barrel bundles every component and the optional peers (react-icons) they import, and an app or
129
+ //? lib client barrel every module's UI.
125
130
  static async barrelImportsPlugin(app: App, pipeAfter?: BarrelImportsPluginOptions["pipeAfter"]) {
126
- return await createBarrelImportsPlugin(app, { barrels: ["akanjs/ui"], pipeAfter });
131
+ const barrels = ["akanjs/ui", ...workspaceBarrels((await app.getConfig()).barrelImports)];
132
+ return await createBarrelImportsPlugin(app, { barrels, pipeAfter });
127
133
  }
128
134
 
129
135
  /** Where a build records the `AKAN_PUBLIC_*` it inlined, which nothing in the minified bundle names. */
@@ -4,6 +4,7 @@ import { CSR_DEV_DIRNAME, CSR_DEV_ROUTE_PREFIX } from "akanjs/server/hmr/csrDevM
4
4
  import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
5
  import type { App } from "../commandDecorators";
6
6
  import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
7
+ import { workspaceBarrels } from "../transforms/barrelImportsPlugin";
7
8
  import { bundleDefine } from "./bundleDefine";
8
9
  import { CsrDevPaths } from "./csrDevPaths";
9
10
  import type { CsrDevContext } from "./csrDevTypes";
@@ -13,7 +14,7 @@ import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
13
14
 
14
15
  //* Dev-only CSR as a module registry, under `.akan/artifact/csr-dev`: one entry per basePath boots every page.
15
16
  export class CsrDevBundler extends DevRegistryBundler {
16
- static readonly #formatVersion = 4;
17
+ static readonly #formatVersion = 6;
17
18
  readonly reloadsOnEntryChange = true;
18
19
  //? Detecting a page's async default parses it with TypeScript: on apps/akan every page each save cost ~100ms.
19
20
  readonly #asyncDefaults = new Map<string, { mtimeMs: number; detected: Promise<boolean> }>();
@@ -30,8 +31,16 @@ export class CsrDevBundler extends DevRegistryBundler {
30
31
  const htmlBasePaths = basePaths.length > 0 ? basePaths : [""];
31
32
  const define = bundleDefine(this.app, "start", "csr");
32
33
  const optimizeImports = [...akanConfig.optimizeImports];
34
+ const barrelImports = workspaceBarrels(akanConfig.barrelImports);
33
35
  const configKey = Bun.hash(
34
- JSON.stringify([CsrDevBundler.#formatVersion, Bun.version, define, optimizeImports, htmlBasePaths]),
36
+ JSON.stringify([
37
+ CsrDevBundler.#formatVersion,
38
+ Bun.version,
39
+ define,
40
+ optimizeImports,
41
+ htmlBasePaths,
42
+ barrelImports,
43
+ ]),
35
44
  ).toString(36);
36
45
  return {
37
46
  pageEntries,
@@ -41,6 +50,7 @@ export class CsrDevBundler extends DevRegistryBundler {
41
50
  optimizeImports,
42
51
  configKey,
43
52
  refreshFile: this.#resolveRefreshRuntime(),
53
+ barrelImports,
44
54
  };
45
55
  }
46
56
 
@@ -291,3 +291,100 @@ describe("CsrDevModuleCompiler", () => {
291
291
  expect(String((failure as AggregateError).errors[0])).toContain('"await" can only be used');
292
292
  });
293
293
  });
294
+
295
+ describe("CsrDevModuleCompiler barrel narrowing", () => {
296
+ let root: string;
297
+ const file = (relative: string) => path.join(root, relative);
298
+ const paths = { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] };
299
+ const compile = async (relative: string, { prepass }: { prepass: boolean }) => {
300
+ const narrowing = { ...context, barrelImports: ["@apps/demo/client"] };
301
+ const devPaths = new CsrDevPaths(root);
302
+ const resolver = new CsrDevResolver({ paths: devPaths, context: narrowing, entryFiles: [file(relative)] });
303
+ if (prepass) await resolver.prepass();
304
+ const compiler = new CsrDevModuleCompiler({
305
+ app: {
306
+ verbose: () => undefined,
307
+ logger: { error: () => undefined },
308
+ workspace: { workspaceRoot: root },
309
+ getTsConfig: async () => ({ compilerOptions: { paths } }),
310
+ } as unknown as App,
311
+ paths: devPaths,
312
+ resolver,
313
+ context: narrowing,
314
+ outDir: file("out"),
315
+ routePrefix: "/_akan/registry/",
316
+ });
317
+ return await compiler.compile([file(relative)], new Set(), { refusePrepass: !prepass });
318
+ };
319
+
320
+ beforeAll(async () => {
321
+ root = CsrDevPaths.realpath(await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-barrels-")));
322
+ await Bun.write(file("tsconfig.json"), JSON.stringify({ compilerOptions: { baseUrl: ".", paths } }));
323
+ await Bun.write(
324
+ file("apps/demo/client.ts"),
325
+ [
326
+ 'export * as cnst from "./lib/cnst";',
327
+ 'export { msg, usePage } from "./lib/useClient";',
328
+ 'export { st, RootStore } from "./lib/st";',
329
+ 'export { Memo } from "./lib/memo";',
330
+ 'export { Other } from "./lib/other";',
331
+ "",
332
+ ].join("\n"),
333
+ );
334
+ await Bun.write(file("apps/demo/lib/cnst.ts"), "export const Sample = 1;\n");
335
+ await Bun.write(file("apps/demo/lib/useClient.ts"), "export const msg = 1;\nexport const usePage = () => msg;\n");
336
+ await Bun.write(file("apps/demo/lib/st.ts"), "export class RootStore {}\nexport const st = new RootStore();\n");
337
+ for (const name of ["Memo", "Other"]) {
338
+ await Bun.write(
339
+ file(`apps/demo/lib/${name.toLowerCase()}/index.ts`),
340
+ `import * as Zone from "./${name}.Zone";\nexport const ${name} = { Zone };\n`,
341
+ );
342
+ await Bun.write(
343
+ file(`apps/demo/lib/${name.toLowerCase()}/${name}.Zone.tsx`),
344
+ `export const Card = () => "${name}";\n`,
345
+ );
346
+ }
347
+ await Bun.write(
348
+ file("apps/demo/page/home.tsx"),
349
+ 'import { Memo, st } from "@apps/demo/client";\nexport const Page = () => [Memo.Zone.Card, st];\n',
350
+ );
351
+ await Bun.write(
352
+ file("apps/demo/page/added.tsx"),
353
+ 'import { Other } from "@apps/demo/client";\nexport const Page = () => Other.Zone.Card;\n',
354
+ );
355
+ });
356
+
357
+ afterAll(async () => {
358
+ await rm(root, { recursive: true, force: true });
359
+ });
360
+
361
+ test("a page compiles the client runtime, the store and the modules it names, never the barrel or another module", async () => {
362
+ const { modules } = await compile("apps/demo/page/home.tsx", { prepass: true });
363
+ const ids = modules.map((module) => module.id).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
364
+
365
+ expect(ids).toEqual([
366
+ "apps/demo/lib/memo/Memo.Zone.tsx",
367
+ "apps/demo/lib/memo/index.ts",
368
+ "apps/demo/lib/st.ts",
369
+ "apps/demo/lib/useClient.ts",
370
+ "apps/demo/page/home.tsx",
371
+ ]);
372
+ const page = modules.find((module) => module.id === "apps/demo/page/home.tsx");
373
+ const [sourceContent] = (JSON.parse(page?.sourceMap ?? "{}") as { sourcesContent?: string[] }).sourcesContent ?? [];
374
+ expect(sourceContent?.split("\n")).toHaveLength(3);
375
+ expect(sourceContent).toContain('import "../lib/useClient.ts"; import "../lib/st.ts";');
376
+ });
377
+
378
+ test("a save that adds a barrel import compiles without the resolution build a new bare import needs", async () => {
379
+ const { modules, unresolved } = await compile("apps/demo/page/added.tsx", { prepass: false });
380
+
381
+ expect(unresolved).toEqual([]);
382
+ expect(modules.map((module) => module.id).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
383
+ "apps/demo/lib/other/Other.Zone.tsx",
384
+ "apps/demo/lib/other/index.ts",
385
+ "apps/demo/lib/st.ts",
386
+ "apps/demo/lib/useClient.ts",
387
+ "apps/demo/page/added.tsx",
388
+ ]);
389
+ });
390
+ });