@akanjs/devkit 3.1.2-rc.4 → 3.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +353 -0
  2. package/abstractDoc.ts +1 -1
  3. package/agentAssets.ts +2 -2
  4. package/akanConfig/akanConfig.ts +2 -2
  5. package/akanMcpContract.ts +1 -1
  6. package/apiReference.test.ts +37 -0
  7. package/apiReference.ts +240 -0
  8. package/applicationBuildReporter.test.ts +25 -3
  9. package/applicationBuildReporter.ts +8 -1
  10. package/applicationBuildRunner.test.ts +34 -14
  11. package/applicationBuildRunner.ts +76 -14
  12. package/artifact/implicitRootLayout.test.ts +42 -0
  13. package/artifact/implicitRootLayout.ts +5 -6
  14. package/artifact/storeBootstrap.ts +24 -0
  15. package/cloud/cloudApi.test.ts +70 -7
  16. package/cloud/cloudApi.ts +10 -4
  17. package/cloud/constants.ts +3 -0
  18. package/cloud/globalConfig.test.ts +36 -0
  19. package/cloud/globalConfig.ts +10 -1
  20. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  21. package/codeAgent/skills/akan-page/SKILL.md +16 -7
  22. package/codeAgent/skills/akan-service/SKILL.md +48 -19
  23. package/commandDecorators/command.ts +55 -9
  24. package/commandDecorators/commandDecorators.test.ts +131 -2
  25. package/createTunnel.test.ts +12 -2
  26. package/declarationReader.ts +211 -0
  27. package/executors.test.ts +65 -8
  28. package/executors.ts +5 -3
  29. package/frameworkGraph.test.ts +121 -0
  30. package/frameworkGraph.ts +165 -0
  31. package/frontendBuild/allRoutesBuilder.test.ts +84 -0
  32. package/frontendBuild/allRoutesBuilder.ts +32 -12
  33. package/frontendBuild/buildRouteClient.test.ts +192 -2
  34. package/frontendBuild/bundleDefine.ts +4 -0
  35. package/frontendBuild/clientBuildTypes.ts +7 -1
  36. package/frontendBuild/clientEntriesBundler.test.ts +82 -0
  37. package/frontendBuild/clientEntriesBundler.ts +55 -4
  38. package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
  39. package/frontendBuild/csrArtifactBuilder.ts +2 -1
  40. package/frontendBuild/fontOptimizer.test.ts +54 -0
  41. package/frontendBuild/fontOptimizer.ts +5 -4
  42. package/frontendBuild/index.ts +3 -0
  43. package/frontendBuild/routeClientBuilder.ts +6 -2
  44. package/frontendBuild/routeModulePreloads.test.ts +59 -0
  45. package/frontendBuild/routeModulePreloads.ts +52 -0
  46. package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
  47. package/frontendBuild/ssrDevBundler.test.ts +26 -0
  48. package/frontendBuild/ssrDevBundler.ts +2 -1
  49. package/frontendBuild/vendorImportScanner.test.ts +71 -0
  50. package/frontendBuild/vendorImportScanner.ts +80 -0
  51. package/frontendBuild/vendorNarrowing.test.ts +103 -0
  52. package/frontendBuild/vendorNarrowing.ts +64 -0
  53. package/frontendBuild/vendorSpecifiers.ts +13 -0
  54. package/gitRevision.ts +6 -2
  55. package/incrementalBuilder/builderGraphPayloads.ts +60 -0
  56. package/incrementalBuilder/incrementalBuilder.proc.test.ts +65 -1
  57. package/incrementalBuilder/incrementalBuilder.proc.ts +5 -36
  58. package/integration/devStability.integration.test.ts +1 -1
  59. package/integration/devStabilityHarness.test.ts +1 -1
  60. package/lint/no-deep-internal-import.grit +1 -1
  61. package/maintainabilityBaseline.ts +7 -2
  62. package/maintainabilityContract.test.ts +183 -0
  63. package/maintainabilityContract.ts +145 -30
  64. package/maintainabilityExceptionScanner.test.ts +13 -2
  65. package/maintainabilityExceptionScanner.ts +16 -6
  66. package/maintainabilityLimits.ts +12 -0
  67. package/maintainabilityReport.test.ts +35 -0
  68. package/maintainabilityReport.ts +29 -8
  69. package/mobile/nativePublicEnv.test.ts +5 -1
  70. package/package.json +3 -3
  71. package/preview/PreviewChrome.ts +3 -3
  72. package/preview/PreviewView.ts +26 -8
  73. package/preview/previewView.test.ts +87 -3
  74. package/routeJsBudget.test.ts +177 -0
  75. package/routeJsBudget.ts +222 -0
  76. package/structureScanner.test.ts +167 -0
  77. package/structureScanner.ts +164 -0
  78. package/transforms/barrelAnalyzer.ts +10 -3
  79. package/transforms/barrelImportsPlugin.ts +13 -7
  80. package/transforms/serverEnvPlugin.test.ts +1 -1
  81. package/transforms/serverEnvPlugin.ts +1 -1
  82. package/transforms/transforms.test.ts +64 -12
  83. package/transforms/tsconfigPackageResolver.test.ts +12 -0
  84. package/workflow/plan.ts +79 -17
  85. package/workflow/types.ts +1 -0
  86. package/workspaceGraph.test.ts +58 -0
  87. package/workspaceGraph.ts +32 -3
  88. package/workspaceLayout.ts +1 -0
  89. /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
@@ -2,6 +2,7 @@ import { mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { BaseBuildArtifact, ClientManifest, ClientManifestEntry, SsrManifest } from "akanjs/server";
4
4
  import { SSR_DEV_CHUNK, SSR_DEV_ID_PREFIX } from "akanjs/server/hmr/csrDevManifest";
5
+ import { StoreBootstrap } from "../artifact/storeBootstrap";
5
6
  import type { App } from "../commandDecorators";
6
7
  import { createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
7
8
  import { loaderFor } from "../transforms/moduleSyntax";
@@ -26,7 +27,7 @@ export interface BuildRouteClientOptions {
26
27
  seeds: string[];
27
28
  /** Every route's seeds: a build that runs at all bundles the whole app, since two builds each copy shared modules. */
28
29
  graphSeeds?: string[];
29
- artifact: BaseBuildArtifact;
30
+ artifact?: BaseBuildArtifact;
30
31
  knownEntries?: Set<string>;
31
32
  routeId?: string;
32
33
  command?: "build" | "start";
@@ -46,6 +47,8 @@ export interface BuildRouteClientResult {
46
47
  clientDepsByEntry?: Record<string, string[]>;
47
48
  /** Registry mode: the entries the manifest rows name, which the SSR dev registry must hold before they are served. */
48
49
  registryEntries?: string[];
50
+ /** Chunks mode: the importmap specifiers each browser chunk imports statically. */
51
+ externalsByChunkUrl?: Record<string, string[]>;
49
52
  }
50
53
 
51
54
  interface BootstrapEntries {
@@ -133,6 +136,7 @@ export class RouteClientBuilder {
133
136
  clientDeps: [...clientDeps].sort(),
134
137
  clientDepsByEntry,
135
138
  ...(registry ? { registryEntries: [...acceptedEntries].sort() } : {}),
139
+ ...(browserBundle ? { externalsByChunkUrl: Object.fromEntries(browserBundle.externalsByChunkUrl) } : {}),
136
140
  };
137
141
  }
138
142
 
@@ -317,7 +321,7 @@ export class RouteClientBuilder {
317
321
  const originalEntry = JSON.stringify(path.resolve(args.originalEntry));
318
322
  const namedExports = args.exportNames.filter((name) => name !== "default");
319
323
  const lines = [
320
- `import ${JSON.stringify(`@apps/${args.appName}/client`)};`,
324
+ ...StoreBootstrap.importLines(args.appName),
321
325
  ...(namedExports.length > 0 ? [`export { ${namedExports.join(", ")} } from ${originalEntry};`] : []),
322
326
  ];
323
327
  if (args.exportNames.includes("default")) lines.push(`export { default } from ${originalEntry};`);
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { RouteModulePreloads } from "./routeModulePreloads";
4
+
5
+ describe("RouteModulePreloads", () => {
6
+ const root = "/repo";
7
+ const entry = (name: string) => path.join(root, `apps/demo/ui/${name}.tsx`);
8
+ const row = (...chunks: string[]) => ({
9
+ id: chunks[0] ?? "",
10
+ chunks: chunks.flatMap((chunk) => [chunk, chunk]),
11
+ name: "default",
12
+ });
13
+ const preloads = new RouteModulePreloads({
14
+ runtimeChunks: {
15
+ runtime: ["/_akan/client/rsc.js"],
16
+ vendors: { react: ["/_akan/client/react.js"], "akanjs/store": ["/_akan/client/store.js"] },
17
+ },
18
+ entriesByRoute: new Map([
19
+ ["/:lang", [entry("Home")]],
20
+ ["/:lang/memo", [entry("Home"), entry("Memo")]],
21
+ ]),
22
+ clientManifest: {
23
+ "apps/demo/ui/Home.tsx#default": row("/_akan/client/home.js", "/_akan/client/chunks/s.js"),
24
+ "apps/demo/ui/Memo.tsx#default": row("/_akan/client/memo.js", "/_akan/client/chunks/s.js"),
25
+ },
26
+ externalsByChunkUrl: {
27
+ "/_akan/client/home.js": ["react"],
28
+ "/_akan/client/memo.js": ["akanjs/store", "node:fs"],
29
+ "/_akan/client/chunks/s.js": [],
30
+ },
31
+ workspaceRoot: root,
32
+ });
33
+
34
+ test("keeps only importmap specifiers, and only for chunks that import one", () => {
35
+ expect(preloads.table.chunkVendors).toEqual({
36
+ "/_akan/client/home.js": ["react"],
37
+ "/_akan/client/memo.js": ["akanjs/store"],
38
+ });
39
+ });
40
+
41
+ test("lists what each route can load: the runtime, its vendors, then its chunks", () => {
42
+ expect(preloads.modulesByRoute()).toEqual({
43
+ "/:lang": [
44
+ "/_akan/client/rsc.js",
45
+ "/_akan/client/react.js",
46
+ "/_akan/client/home.js",
47
+ "/_akan/client/chunks/s.js",
48
+ ],
49
+ "/:lang/memo": [
50
+ "/_akan/client/rsc.js",
51
+ "/_akan/client/react.js",
52
+ "/_akan/client/store.js",
53
+ "/_akan/client/home.js",
54
+ "/_akan/client/chunks/s.js",
55
+ "/_akan/client/memo.js",
56
+ ],
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,52 @@
1
+ import type { ClientManifest } from "akanjs/server";
2
+ import { ModulePreloadPlan, type ModulePreloadTable } from "akanjs/server/artifact/modulePreloadPlan";
3
+ import { toClientReferencePath } from "../transforms/rscUseClientTransform";
4
+
5
+ export type RuntimeChunks = Pick<ModulePreloadTable, "runtime" | "vendors">;
6
+
7
+ export interface RouteModulePreloadsInput {
8
+ runtimeChunks: RuntimeChunks;
9
+ entriesByRoute: Map<string, string[]>;
10
+ clientManifest: ClientManifest;
11
+ externalsByChunkUrl: Record<string, string[]>;
12
+ workspaceRoot: string;
13
+ }
14
+
15
+ export class RouteModulePreloads {
16
+ readonly table: ModulePreloadTable;
17
+ #input: RouteModulePreloadsInput;
18
+ #chunksByReferenceId = new Map<string, Set<string>>();
19
+
20
+ constructor(input: RouteModulePreloadsInput) {
21
+ this.#input = input;
22
+ for (const [key, row] of Object.entries(input.clientManifest)) {
23
+ const chunks = this.#chunksByReferenceId.getOrInsert(key.slice(0, key.lastIndexOf("#")), new Set());
24
+ for (const url of row.chunks) chunks.add(url);
25
+ }
26
+ this.table = {
27
+ ...input.runtimeChunks,
28
+ chunkVendors: Object.fromEntries(
29
+ Object.entries(input.externalsByChunkUrl)
30
+ .map(([url, specifiers]): [string, string[]] => [
31
+ url,
32
+ specifiers.filter((specifier) => specifier in input.runtimeChunks.vendors),
33
+ ])
34
+ .filter(([, specifiers]) => specifiers.length > 0),
35
+ ),
36
+ };
37
+ }
38
+
39
+ modulesByRoute(): Record<string, string[]> {
40
+ const plan = new ModulePreloadPlan(this.table);
41
+ return Object.fromEntries(
42
+ [...this.#input.entriesByRoute].map(([routeId, entries]) => [
43
+ routeId,
44
+ plan.urlsOf(
45
+ entries.flatMap((entry) => [
46
+ ...(this.#chunksByReferenceId.get(toClientReferencePath(entry, this.#input.workspaceRoot)) ?? []),
47
+ ]),
48
+ ),
49
+ ]),
50
+ );
51
+ }
52
+ }
@@ -12,13 +12,16 @@ import { DictionaryAssetBuilder } from "./dictionaryAssetBuilder";
12
12
  import { FontOptimizer } from "./fontOptimizer";
13
13
  import { PagesBundleBuilder } from "./pagesBundleBuilder";
14
14
  import { RouteClientBuilder } from "./routeClientBuilder";
15
- import { VENDOR_SPECIFIERS, type VendorSpecifier } from "./vendorSpecifiers";
15
+ import type { RuntimeChunks } from "./routeModulePreloads";
16
+ import { VendorNarrowing } from "./vendorNarrowing";
17
+ import type { VendorSpecifier } from "./vendorSpecifiers";
16
18
 
17
19
  export interface BuildSsrBaseArtifactResult {
18
20
  artifact: BaseBuildArtifact;
19
21
  seedIndex: RouteSeedIndex;
20
22
  cssCompiler: CssCompiler;
21
23
  optimizedFonts: Awaited<ReturnType<FontOptimizer["optimize"]>>;
24
+ runtimeChunks: RuntimeChunks;
22
25
  }
23
26
 
24
27
  export function prepareCssAsset(command: "build" | "start", basePath: string, cssText: string): string {
@@ -32,12 +35,24 @@ export class SsrBaseArtifactBuilder {
32
35
  #absArtifactDir: string;
33
36
  #started = Date.now();
34
37
  #serverEnv: BunPlugin | undefined;
38
+ #vendorExports: Partial<Record<VendorSpecifier, readonly string[]>>;
35
39
 
36
- /** `serverEnv`: the build's server env plugin, so the route bundle carries the env files the backend does and no more. */
37
- constructor(app: App, command: "build" | "start" = "start", { serverEnv }: { serverEnv?: BunPlugin } = {}) {
40
+ /**
41
+ * `serverEnv`: the build's server env plugin, so the route bundle carries the env files the backend does and no more.
42
+ * `vendorExports`: the only names a vendor entry exports, for a build whose every client module is already bundled.
43
+ */
44
+ constructor(
45
+ app: App,
46
+ command: "build" | "start" = "start",
47
+ {
48
+ serverEnv,
49
+ vendorExports = {},
50
+ }: { serverEnv?: BunPlugin; vendorExports?: Partial<Record<VendorSpecifier, readonly string[]>> } = {},
51
+ ) {
38
52
  this.#app = app;
39
53
  this.#command = command;
40
54
  this.#serverEnv = serverEnv;
55
+ this.#vendorExports = vendorExports;
41
56
  this.#artifactDir = `${command === "build" ? app.dist.cwdPath : app.cwdPath}/.akan/artifact`;
42
57
  this.#absArtifactDir = path.resolve(this.#artifactDir);
43
58
  }
@@ -50,7 +65,7 @@ export class SsrBaseArtifactBuilder {
50
65
 
51
66
  async build(): Promise<BuildSsrBaseArtifactResult> {
52
67
  const akanConfig = await this.#app.getConfig();
53
- const { rscClientUrl, rscRuntimeClientManifest, rscRuntimeSsrManifest, vendorMap } =
68
+ const { rscClientUrl, rscRuntimeClientManifest, rscRuntimeSsrManifest, vendorMap, runtimeChunks } =
54
69
  await this.#buildRuntimeClientEntries();
55
70
  const pageKeys = await this.#app.getPageKeys();
56
71
  this.#app.verbose(`[base-artifact] discovered ${pageKeys.length} route files under ${this.#app.cwdPath}/page`);
@@ -106,23 +121,28 @@ export class SsrBaseArtifactBuilder {
106
121
  await Bun.write(path.join(this.#absArtifactDir, "base-artifact.json"), `${JSON.stringify(artifact, null, 2)}\n`);
107
122
  this.#app.verbose(`[base-artifact] complete in ${Date.now() - this.#started}ms`);
108
123
 
109
- return { artifact, seedIndex, cssCompiler, optimizedFonts };
124
+ return { artifact, seedIndex, cssCompiler, optimizedFonts, runtimeChunks };
110
125
  }
111
126
 
112
127
  async #buildRuntimeClientEntries(): Promise<
113
128
  Pick<BaseBuildArtifact, "rscClientUrl" | "rscRuntimeClientManifest" | "rscRuntimeSsrManifest"> & {
114
129
  vendorMap: Record<VendorSpecifier, string>;
130
+ runtimeChunks: RuntimeChunks;
115
131
  }
116
132
  > {
117
133
  const akanServerPath = await this.#resolveAkanServerPath();
118
134
  const rscClientEntry = path.resolve(akanServerPath, "rscClient.tsx");
119
135
  const rscSegmentOutletEntry = path.resolve(akanServerPath, "rscSegmentOutlet.tsx");
120
- const vendorEntries = VENDOR_SPECIFIERS.map((specifier) => ({
121
- specifier,
122
- absPath: path.resolve(akanServerPath, "vendor", `${specifier.replaceAll("/", "-").replaceAll(".", "-")}.ts`),
123
- }));
136
+ const vendorEntries = VendorNarrowing.entriesIn(akanServerPath);
124
137
  const entries = [rscClientEntry, rscSegmentOutletEntry, ...vendorEntries.map((v) => v.absPath)];
125
- const clientBundle = await new ClientEntriesBundler({ app: this.#app, entries, command: this.#command }).bundle();
138
+ const narrowing = new VendorNarrowing(path.dirname(akanServerPath), this.#vendorExports);
139
+ const clientBundle = await new ClientEntriesBundler({
140
+ app: this.#app,
141
+ entries,
142
+ command: this.#command,
143
+ entryExports: narrowing.entryExports(vendorEntries),
144
+ plugins: await narrowing.plugins(),
145
+ }).bundle();
126
146
  const ssrBundle = await new ClientEntriesBundler({
127
147
  app: this.#app,
128
148
  entries: [rscSegmentOutletEntry],
@@ -158,11 +178,16 @@ export class SsrBaseArtifactBuilder {
158
178
  vendorEntries.map(({ specifier, absPath }) => [specifier, clientBundle.entryUrlsByAbsPath.get(absPath) ?? ""]),
159
179
  ) as Record<VendorSpecifier, string>;
160
180
  this.#app.verbose(`[base-artifact] rscClientUrl=${rscClientUrl} vendors=${Object.keys(vendorMap).length}`);
181
+ const chunksOf = (absPath: string) => clientBundle.entryChunkUrlsByAbsPath.get(absPath) ?? [];
161
182
  return {
162
183
  rscClientUrl,
163
184
  rscRuntimeClientManifest: clientBundle.manifest,
164
185
  rscRuntimeSsrManifest,
165
186
  vendorMap,
187
+ runtimeChunks: {
188
+ runtime: [...new Set([...chunksOf(rscClientEntry), ...chunksOf(rscSegmentOutletEntry)])],
189
+ vendors: Object.fromEntries(vendorEntries.map(({ specifier, absPath }) => [specifier, chunksOf(absPath)])),
190
+ },
166
191
  };
167
192
  }
168
193
  async #resolveAkanServerPath() {
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { tempDirs, writeText as write } from "../testHelpers";
4
+ import { SsrDevBundler } from "./ssrDevBundler";
5
+
6
+ const makeTempRoot = tempDirs("akan-ssr-dev-bundler-");
7
+
8
+ describe("SsrDevBundler bootstrap", () => {
9
+ const bootstrapOf = async (root: string) => {
10
+ const appDir = path.join(root, "apps/demo");
11
+ const app = { name: "demo", cwdPath: appDir, workspace: { workspaceRoot: root } } as never;
12
+ const { files } = await new SsrDevBundler(app).writeEntries();
13
+ return await Bun.file(files[SsrDevBundler.bootstrapKey] ?? "").text();
14
+ };
15
+
16
+ test("runs the client runtime and the store, not the client barrel", async () => {
17
+ const root = await makeTempRoot();
18
+ await write(path.join(root, "apps/demo/lib/st.ts"), "export const st = {};\n");
19
+
20
+ expect(await bootstrapOf(root)).toBe('import "@apps/demo/lib/useClient";\nimport "@apps/demo/lib/st";\n');
21
+ });
22
+
23
+ test("runs nothing for an app without a store", async () => {
24
+ expect(await bootstrapOf(await makeTempRoot())).toBe("export {};\n");
25
+ });
26
+ });
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { SSR_DEV_DIRNAME, SSR_DEV_ROUTE_PREFIX } from "akanjs/server/hmr/csrDevManifest";
4
4
  import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
5
  import { computeRouteSeedIndex } from "../artifact/routeSeedIndex";
6
+ import { StoreBootstrap } from "../artifact/storeBootstrap";
6
7
  import type { App } from "../commandDecorators";
7
8
  import { bundleDefine } from "./bundleDefine";
8
9
  import type { ClientEntryDiscovery } from "./clientBuildTypes";
@@ -58,7 +59,7 @@ export class SsrDevBundler extends DevRegistryBundler {
58
59
  await mkdir(this.entryDir, { recursive: true });
59
60
  const file = path.join(this.entryDir, "bootstrap.ts");
60
61
  const hasStore = await Bun.file(path.join(this.app.cwdPath, "lib", "st.ts")).exists();
61
- const source = hasStore ? `import ${JSON.stringify(`@apps/${this.app.name}/client`)};\n` : "export {};\n";
62
+ const source = hasStore ? StoreBootstrap.source(this.app.name) : "export {};\n";
62
63
  const unchanged = (await Bun.file(file).exists()) && (await Bun.file(file).text()) === source;
63
64
  if (!unchanged) await Bun.write(file, source);
64
65
  const entryFile = CsrDevPaths.realpath(file);
@@ -0,0 +1,71 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { tempDirs, writeText as write } from "../testHelpers";
4
+ import { VendorImportScanner } from "./vendorImportScanner";
5
+
6
+ const makeTempRoot = tempDirs("akan-vendor-imports-");
7
+ const specifiers = ["akanjs/store", "akanjs/common", "akanjs/fetch", "tailwind-merge"];
8
+ const scan = (...sources: string[]) => {
9
+ const scanner = new VendorImportScanner(specifiers);
10
+ for (const source of sources) scanner.scan(source);
11
+ return scanner.usedExports();
12
+ };
13
+
14
+ describe("VendorImportScanner", () => {
15
+ test("reads the imported names, not the local aliases, of minified and plain imports", () => {
16
+ const used = scan(
17
+ 'import{capitalize as He,Logger as K}from"akanjs/common";import{st}from"akanjs/store";',
18
+ 'import { formSetterNames, Logger } from "akanjs/common";\nimport "akanjs/fetch";\n',
19
+ );
20
+
21
+ expect(used).toEqual({
22
+ "akanjs/store": ["st"],
23
+ "akanjs/common": ["Logger", "capitalize", "formSetterNames"],
24
+ "akanjs/fetch": [],
25
+ "tailwind-merge": [],
26
+ });
27
+ });
28
+
29
+ test("leaves out a specifier read whole: a namespace import, an export star or a dynamic import", () => {
30
+ expect(scan('import*as s from"akanjs/store";import{isEmail}from"akanjs/common";')).not.toHaveProperty(
31
+ "akanjs/store",
32
+ );
33
+ expect(scan('export*from"akanjs/store";')).not.toHaveProperty("akanjs/store");
34
+ expect(scan('export*as s from"akanjs/store";')).not.toHaveProperty("akanjs/store");
35
+ expect(scan('const load=()=>import("akanjs/store");')).not.toHaveProperty("akanjs/store");
36
+ expect(scan('export{twMerge as m}from"tailwind-merge";')["tailwind-merge"]).toEqual(["twMerge"]);
37
+ });
38
+
39
+ test("reads nothing from a require, which a browser chunk carries as Bun's shim and never resolves", () => {
40
+ const used = scan('function C9(){let{untrackedHeaders:n,untrackedRequest:d}=ii("akanjs/fetch");return d}');
41
+
42
+ expect(used["akanjs/fetch"]).toEqual([]);
43
+ });
44
+
45
+ test("ignores a specifier quoted in a string, as a docs page shows code", () => {
46
+ const used = scan(
47
+ 'const sample="import { st } from \\"akanjs/store\\"";const tpl=`export * from "akanjs/store"`;console.info("akanjs/store");',
48
+ );
49
+
50
+ expect(used["akanjs/store"]).toEqual([]);
51
+ });
52
+
53
+ test("collects across every .js file of a directory", async () => {
54
+ const dir = await makeTempRoot();
55
+ await write(path.join(dir, "entry-a.js"), 'import{st}from"akanjs/store";');
56
+ await write(path.join(dir, "chunks/lazy.js"), 'import{RootStore}from"akanjs/store";');
57
+ await write(path.join(dir, "chunks/lazy.js.map"), 'import{ignored}from"akanjs/store";');
58
+
59
+ const used = await VendorImportScanner.exportsUsedIn(dir, ["akanjs/store"]);
60
+
61
+ expect(used).toEqual({ "akanjs/store": ["RootStore", "st"] });
62
+ });
63
+
64
+ test("reads a client output directory a build never wrote as importing nothing", async () => {
65
+ const dir = await makeTempRoot();
66
+
67
+ expect(await VendorImportScanner.exportsUsedIn(path.join(dir, "client"), ["akanjs/store"])).toEqual({
68
+ "akanjs/store": [],
69
+ });
70
+ });
71
+ });
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+
5
+ export class VendorImportScanner {
6
+ readonly #specifiers: Set<string>;
7
+ readonly #names = new Map<string, Set<string>>();
8
+ readonly #whole = new Set<string>();
9
+
10
+ constructor(specifiers: readonly string[]) {
11
+ this.#specifiers = new Set(specifiers);
12
+ for (const specifier of specifiers) this.#names.set(specifier, new Set());
13
+ }
14
+
15
+ /** The names each specifier's importers read across a directory's `.js` files; a specifier read whole is left out. */
16
+ static async exportsUsedIn(dir: string, specifiers: readonly string[]): Promise<Record<string, string[]>> {
17
+ const scanner = new VendorImportScanner(specifiers);
18
+ if (!fs.existsSync(dir)) return scanner.usedExports();
19
+ for await (const relative of new Bun.Glob("**/*.js").scan({ cwd: dir }))
20
+ scanner.scan(await Bun.file(path.join(dir, relative)).text(), relative);
21
+ return scanner.usedExports();
22
+ }
23
+
24
+ scan(source: string, fileName = "chunk.js"): this {
25
+ if (![...this.#specifiers].some((specifier) => source.includes(specifier))) return this;
26
+ const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS);
27
+ const visit = (node: ts.Node): void => {
28
+ if (ts.isImportDeclaration(node)) this.#readImport(node);
29
+ else if (ts.isExportDeclaration(node)) this.#readExport(node);
30
+ else if (ts.isCallExpression(node)) this.#readCall(node);
31
+ ts.forEachChild(node, visit);
32
+ };
33
+ visit(file);
34
+ return this;
35
+ }
36
+
37
+ usedExports(): Record<string, string[]> {
38
+ return Object.fromEntries(
39
+ [...this.#names]
40
+ .filter(([specifier]) => !this.#whole.has(specifier))
41
+ .map(([specifier, names]) => [specifier, [...names].sort()]),
42
+ );
43
+ }
44
+
45
+ #specifierOf(node: ts.Expression | undefined): string | null {
46
+ if (!node || !ts.isStringLiteralLike(node)) return null;
47
+ return this.#specifiers.has(node.text) ? node.text : null;
48
+ }
49
+
50
+ #readImport(node: ts.ImportDeclaration): void {
51
+ const specifier = this.#specifierOf(node.moduleSpecifier);
52
+ const clause = node.importClause;
53
+ if (!specifier || !clause) return;
54
+ if (clause.name) this.#add(specifier, "default");
55
+ const bindings = clause.namedBindings;
56
+ if (!bindings) return;
57
+ if (ts.isNamespaceImport(bindings)) this.#whole.add(specifier);
58
+ else for (const element of bindings.elements) this.#add(specifier, (element.propertyName ?? element.name).text);
59
+ }
60
+
61
+ #readExport(node: ts.ExportDeclaration): void {
62
+ const specifier = this.#specifierOf(node.moduleSpecifier);
63
+ if (!specifier) return;
64
+ const clause = node.exportClause;
65
+ if (!clause || ts.isNamespaceExport(clause)) this.#whole.add(specifier);
66
+ else for (const element of clause.elements) this.#add(specifier, (element.propertyName ?? element.name).text);
67
+ }
68
+
69
+ //? Only `import()` reaches the import map: a `require` left in a browser chunk is Bun's shim, which throws there.
70
+ #readCall(node: ts.CallExpression): void {
71
+ if (node.expression.kind !== ts.SyntaxKind.ImportKeyword) return;
72
+ const specifier = this.#specifierOf(node.arguments[0]);
73
+ if (specifier) this.#whole.add(specifier);
74
+ }
75
+
76
+ #add(specifier: string, name: string): void {
77
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) this.#whole.add(specifier);
78
+ else this.#names.get(specifier)?.add(name);
79
+ }
80
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { tempDirs, writeText as write } from "../testHelpers";
5
+ import { ClientEntriesBundler } from "./clientEntriesBundler";
6
+ import { VendorNarrowing } from "./vendorNarrowing";
7
+
8
+ const makeTempRoot = tempDirs("akan-vendor-narrowing-");
9
+ const akanjsRoot = path.resolve(import.meta.dir, "../../../akanjs");
10
+ const vendorFile = (name: string) => path.join(akanjsRoot, "server/vendor", `${name}.ts`);
11
+ const agentOnlyText = "path must be an internal path starting with /.";
12
+
13
+ const staticClosure = (absPath: string, seen = new Set<string>()): Set<string> => {
14
+ if (seen.has(absPath)) return seen;
15
+ seen.add(absPath);
16
+ const transpiler = new Bun.Transpiler({ loader: "js" });
17
+ for (const { path: imported, kind } of transpiler.scanImports(readFileSync(absPath, "utf8")))
18
+ if (kind !== "dynamic-import" && imported.startsWith("."))
19
+ staticClosure(path.resolve(path.dirname(absPath), imported), seen);
20
+ return seen;
21
+ };
22
+ const closureText = (absPath: string) =>
23
+ [...staticClosure(absPath)].map((file) => readFileSync(file, "utf8")).join("\n");
24
+
25
+ const buildVendors = async (storeNames: string[], plugins: Awaited<ReturnType<VendorNarrowing["plugins"]>>) => {
26
+ const root = await makeTempRoot();
27
+ const storeAlias = path.join(root, "store.ts");
28
+ const agentAlias = path.join(root, "agent.ts");
29
+ await write(storeAlias, ClientEntriesBundler.narrowedAliasSource(vendorFile("akanjs-store"), storeNames));
30
+ await write(agentAlias, `export * from ${JSON.stringify(vendorFile("akanjs-store-agent"))};\n`);
31
+ const result = await Bun.build({
32
+ entrypoints: [storeAlias, agentAlias],
33
+ outdir: path.join(root, "out"),
34
+ splitting: true,
35
+ target: "browser",
36
+ format: "esm",
37
+ plugins,
38
+ });
39
+ expect(result.logs.map((log) => log.message)).toEqual([]);
40
+ const outputOf = (name: string) => {
41
+ const output = result.outputs.find((o) => o.kind === "entry-point" && path.basename(o.path) === `${name}.js`);
42
+ if (!output) throw new Error(`no ${name}.js output`);
43
+ return output.path;
44
+ };
45
+ return { store: closureText(outputOf("store")), agent: closureText(outputOf("agent")) };
46
+ };
47
+
48
+ describe("VendorNarrowing", () => {
49
+ test("maps each narrowed specifier's vendor entry to its names and leaves the rest whole", () => {
50
+ const narrowing = new VendorNarrowing(akanjsRoot, { "akanjs/store": ["st"], "akanjs/base": [] });
51
+
52
+ expect(
53
+ narrowing.entryExports([
54
+ { specifier: "akanjs/store", absPath: "/v/akanjs-store.ts" },
55
+ { specifier: "akanjs/base", absPath: "/v/akanjs-base.ts" },
56
+ { specifier: "akanjs/common", absPath: "/v/akanjs-common.ts" },
57
+ ]),
58
+ ).toEqual({ "/v/akanjs-store.ts": ["st"], "/v/akanjs-base.ts": [] });
59
+ });
60
+
61
+ test("keeps the deprecated agent re-export when a route imports one of its names, or reads the store whole", async () => {
62
+ expect(await new VendorNarrowing(akanjsRoot, { "akanjs/store": ["AgentContext", "st"] }).plugins()).toEqual([]);
63
+ expect(await new VendorNarrowing(akanjsRoot, {}).plugins()).toEqual([]);
64
+ });
65
+
66
+ test("a store vendor no route reads an agent name from loads no agent runtime; the agent vendor does", async () => {
67
+ const narrowing = new VendorNarrowing(akanjsRoot, { "akanjs/store": ["StoreRegistry", "store"] });
68
+ const plugins = await narrowing.plugins();
69
+ expect(plugins).toHaveLength(1);
70
+
71
+ const { store, agent } = await buildVendors(["StoreRegistry", "store"], plugins);
72
+
73
+ expect(store).not.toContain(agentOnlyText);
74
+ expect(agent).toContain(agentOnlyText);
75
+ }, 60_000);
76
+
77
+ test("without the blanking, the deprecated re-export drags the agent runtime into the store vendor", async () => {
78
+ const { store } = await buildVendors(["StoreRegistry", "store"], []);
79
+
80
+ expect(store).toContain(agentOnlyText);
81
+ }, 60_000);
82
+
83
+ test("an akanjs older than the agent split keeps whole vendors and gets no agent entry", async () => {
84
+ const root = await makeTempRoot();
85
+ for (const name of ["akanjs-store", "akanjs-base"])
86
+ await write(path.join(root, "server/vendor", `${name}.ts`), `export * from "${name}";\n`);
87
+ const entries = VendorNarrowing.entriesIn(path.join(root, "server"));
88
+ const narrowing = new VendorNarrowing(root, { "akanjs/store": ["st"], "akanjs/base": [] });
89
+
90
+ expect(entries.map(({ specifier }) => specifier)).toEqual(["akanjs/store", "akanjs/base"]);
91
+ expect(narrowing.entryExports(entries)).toEqual({});
92
+ expect(await narrowing.plugins()).toEqual([]);
93
+ expect(VendorNarrowing.entriesIn(path.join(akanjsRoot, "server")).map(({ specifier }) => specifier)).toContain(
94
+ "akanjs/store/agent",
95
+ );
96
+ });
97
+
98
+ test("an alias narrowed to no names still loads its entry, so the entry's side effects run", () => {
99
+ expect(ClientEntriesBundler.narrowedAliasSource("/v/akanjs-fetch.ts", [])).toBe(
100
+ `export {} from ${JSON.stringify(path.resolve("/v/akanjs-fetch.ts"))};\n`,
101
+ );
102
+ });
103
+ });
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { BunPlugin } from "bun";
4
+ import { VENDOR_SPECIFIERS, type VendorSpecifier } from "./vendorSpecifiers";
5
+
6
+ //? Bun puts a module in the chunk of every entry that reaches it, read or not, so a deprecated re-export would keep
7
+ //? its target in a vendor every page loads; a narrowed build blanks it unless a route imports one of its names.
8
+ const deprecatedReexports: Partial<Record<VendorSpecifier, string>> = { "akanjs/store": "store/deprecatedAgent.ts" };
9
+
10
+ export interface VendorEntry {
11
+ specifier: VendorSpecifier;
12
+ absPath: string;
13
+ }
14
+
15
+ export class VendorNarrowing {
16
+ readonly #akanjsRoot: string;
17
+ readonly #vendorExports: Partial<Record<VendorSpecifier, readonly string[]>>;
18
+
19
+ //? A devkit newer than the installed akanjs finds no agent vendor or deprecated re-export there, and builds that
20
+ //? akanjs's vendors whole, as the devkit it shipped with did.
21
+ static entriesIn(akanServerPath: string): VendorEntry[] {
22
+ return VENDOR_SPECIFIERS.map((specifier) => ({
23
+ specifier,
24
+ absPath: path.resolve(akanServerPath, "vendor", `${specifier.replaceAll("/", "-").replaceAll(".", "-")}.ts`),
25
+ })).filter(({ absPath }) => fs.existsSync(absPath));
26
+ }
27
+
28
+ constructor(akanjsRoot: string, vendorExports: Partial<Record<VendorSpecifier, readonly string[]>>) {
29
+ this.#akanjsRoot = akanjsRoot;
30
+ const current = Object.values(deprecatedReexports).every((file) => fs.existsSync(path.join(akanjsRoot, file)));
31
+ this.#vendorExports = current ? vendorExports : {};
32
+ }
33
+
34
+ entryExports(vendorEntries: VendorEntry[]): Record<string, readonly string[]> {
35
+ return Object.fromEntries(
36
+ vendorEntries.flatMap(({ specifier, absPath }) => {
37
+ const names = this.#vendorExports[specifier];
38
+ return names ? [[absPath, names]] : [];
39
+ }),
40
+ );
41
+ }
42
+
43
+ async plugins(): Promise<BunPlugin[]> {
44
+ const blanked: string[] = [];
45
+ for (const [specifier, file] of Object.entries(deprecatedReexports) as [VendorSpecifier, string][]) {
46
+ const used = this.#vendorExports[specifier];
47
+ if (!used) continue;
48
+ const source = await Bun.file(path.join(this.#akanjsRoot, file)).text();
49
+ const names = new Bun.Transpiler({ loader: "ts" }).scan(source).exports;
50
+ if (!names.some((name) => used.includes(name))) blanked.push(`akanjs/${file}`);
51
+ }
52
+ if (blanked.length === 0) return [];
53
+ const escaped = blanked.map((file) => file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll("/", "[\\\\/]"));
54
+ const filter = new RegExp(`[\\\\/](?:${escaped.join("|")})$`);
55
+ return [
56
+ {
57
+ name: "akan-blank-deprecated-reexports",
58
+ setup(build) {
59
+ build.onLoad({ filter }, () => ({ contents: "export {};\n", loader: "ts" }));
60
+ },
61
+ },
62
+ ];
63
+ }
64
+ }
@@ -8,6 +8,7 @@ export const VENDOR_SPECIFIERS = [
8
8
  "scheduler",
9
9
  "react-server-dom-webpack/client.browser",
10
10
  "akanjs/store",
11
+ "akanjs/store/agent",
11
12
  "akanjs/base",
12
13
  "akanjs/common",
13
14
  "akanjs/constant",
@@ -16,3 +17,15 @@ export const VENDOR_SPECIFIERS = [
16
17
  ] as const;
17
18
 
18
19
  export type VendorSpecifier = (typeof VENDOR_SPECIFIERS)[number];
20
+
21
+ //? The vendor entries a build narrows to the names its client code imports: each is an ESM `export *`, while React's
22
+ //? entries list a CommonJS module's names by hand.
23
+ export const NARROWABLE_VENDOR_SPECIFIERS = [
24
+ "akanjs/store",
25
+ "akanjs/store/agent",
26
+ "akanjs/base",
27
+ "akanjs/common",
28
+ "akanjs/constant",
29
+ "akanjs/fetch",
30
+ "tailwind-merge",
31
+ ] as const satisfies readonly VendorSpecifier[];