@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
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { realpath } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { tempDirs, writeText as write } from "../testHelpers";
4
5
  import { createTsconfigPackageResolver } from "../transforms/barrelImportsPlugin";
@@ -9,8 +10,86 @@ import { RouteClientBuilder } from "./routeClientBuilder";
9
10
 
10
11
  const makeTempRoot = tempDirs("akan-client-entry-");
11
12
 
13
+ const fixtureBarrels = ["@apps/demo/client", "@libs/kit/client"];
14
+
15
+ const writeBarrelFixture = async () => {
16
+ const root = await makeTempRoot();
17
+ const appDir = path.join(root, "apps/demo");
18
+ const libDir = path.join(root, "libs/kit");
19
+ const paths = { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] };
20
+ await write(path.join(root, "tsconfig.json"), JSON.stringify({ compilerOptions: { baseUrl: ".", paths } }));
21
+ const writeSys = async (dir: string, prefix: string, modules: string[]) => {
22
+ await write(
23
+ path.join(dir, "client.ts"),
24
+ [
25
+ 'export * as cnst from "./lib/cnst";',
26
+ 'export { msg, Err, usePage, fetch, sig } from "./lib/useClient";',
27
+ 'export { st, RootStore } from "./lib/st";',
28
+ 'export * as store from "./lib/st";',
29
+ ...modules.map((name) => `export { ${name} } from "./lib/${name.toLowerCase()}";`),
30
+ "",
31
+ ].join("\n"),
32
+ );
33
+ await write(
34
+ path.join(dir, "lib/useClient.ts"),
35
+ `const runtime = Object.freeze({ mark: "${prefix}runtime-marker", msg: 1, Err: 2, usePage: 3, fetch: 4, sig: 5 });\nexport const { msg, Err, usePage, fetch, sig } = runtime;\n`,
36
+ );
37
+ await write(
38
+ path.join(dir, "lib/cnst.ts"),
39
+ `export const Sample = Object.freeze({ mark: "${prefix}cnst-marker" });\n`,
40
+ );
41
+ for (const name of modules)
42
+ await write(
43
+ path.join(dir, `lib/${name.toLowerCase()}/index.ts`),
44
+ `import * as Zone from "./${name}.Zone";\nexport const ${name} = { Zone };\n`,
45
+ );
46
+ };
47
+ await writeSys(libDir, "lib-", ["Widget"]);
48
+ await write(
49
+ path.join(libDir, "lib/st.ts"),
50
+ 'export class RootStore {}\nexport const st = Object.freeze({ mark: "lib-store-marker" });\n',
51
+ );
52
+ await write(
53
+ path.join(libDir, "lib/widget/Widget.Zone.tsx"),
54
+ '"use client";\nexport const Card = Object.freeze({ mark: "lib-ui-marker" });\n',
55
+ );
56
+ await write(
57
+ path.join(libDir, "ui/Field.tsx"),
58
+ '"use client";\nimport { cnst as kitCnst } from "@libs/kit/client";\nexport const Field = () => [kitCnst.Sample, "field-marker"];\n',
59
+ );
60
+ await writeSys(appDir, "", ["Memo", "Other"]);
61
+ await write(
62
+ path.join(appDir, "lib/st.ts"),
63
+ 'import { RootStore as kit } from "@libs/kit/client";\nexport class RootStore {}\nexport const st = Object.freeze({ mark: "store-marker", kit });\n',
64
+ );
65
+ await write(
66
+ path.join(appDir, "lib/memo/Memo.Zone.tsx"),
67
+ '"use client";\nimport { type cnst, Memo, st } from "@apps/demo/client";\nexport const Card = (props: { sample?: typeof cnst.Sample }) => [st, Memo, props, "memo-ui-marker"];\n',
68
+ );
69
+ await write(
70
+ path.join(appDir, "lib/other/Other.Zone.tsx"),
71
+ '"use client";\nexport const Card = Object.freeze({ mark: "other-ui-marker" });\n',
72
+ );
73
+ await write(path.join(appDir, "ui/A.tsx"), '"use client";\nexport const A = () => "a";\n');
74
+ const app = {
75
+ name: "demo",
76
+ cwdPath: appDir,
77
+ dist: { cwdPath: path.join(root, "dist/apps/demo") },
78
+ workspace: { workspaceRoot: root },
79
+ getConfig: async () => ({ barrelImports: fixtureBarrels, optimizeImports: [] }),
80
+ getTsConfig: async () => ({ compilerOptions: { paths } }),
81
+ getPublicEnv: () => ({}),
82
+ } as never;
83
+ return { root, appDir, app };
84
+ };
85
+
86
+ const bundledText = async (dir: string) =>
87
+ (
88
+ await Promise.all([...new Bun.Glob("**/*.js").scanSync(dir)].map((file) => Bun.file(path.join(dir, file)).text()))
89
+ ).join("\n");
90
+
12
91
  describe("route client store bootstrap", () => {
13
- test("wraps client entries with app client bootstrap before re-exporting components", () => {
92
+ test("wraps client entries with the client runtime and store bootstrap before re-exporting components", () => {
14
93
  const original = path.resolve("/repo/pkgs/akanjs/ui/Model/NewWrapper_Client.tsx");
15
94
  const source = RouteClientBuilder.createStoreBootstrapEntrySource({
16
95
  appName: "akan",
@@ -20,7 +99,8 @@ describe("route client store bootstrap", () => {
20
99
 
21
100
  expect(source).toBe(
22
101
  [
23
- 'import "@apps/akan/client";',
102
+ 'import "@apps/akan/lib/useClient";',
103
+ 'import "@apps/akan/lib/st";',
24
104
  `export { NewWrapper_Client } from ${JSON.stringify(original)};`,
25
105
  `export { default } from ${JSON.stringify(original)};`,
26
106
  "",
@@ -183,6 +263,116 @@ describe("route client store bootstrap", () => {
183
263
  expect(carriers.filter(Boolean)).toHaveLength(1);
184
264
  });
185
265
 
266
+ test("names each entry's static chunks and the importmap specifiers every chunk imports", async () => {
267
+ const root = await realpath(await makeTempRoot());
268
+ const appDir = path.join(root, "apps/demo");
269
+ const entryA = path.join(appDir, "ui/A.tsx");
270
+ const entryB = path.join(appDir, "ui/B.tsx");
271
+ await write(
272
+ entryA,
273
+ '"use client";\nimport { useState } from "react";\nimport { mark } from "../common/shared";\nexport const A = () => [useState, mark];\n',
274
+ );
275
+ await write(
276
+ entryB,
277
+ '"use client";\nimport { mark } from "../common/shared";\nexport const B = () => import("../common/lazy").then(() => mark);\n',
278
+ );
279
+ await write(
280
+ path.join(appDir, "common/shared.ts"),
281
+ 'import { tag } from "akanjs/base";\nexport const mark = tag;\n',
282
+ );
283
+ await write(path.join(appDir, "common/lazy.ts"), 'import "tailwind-merge";\nexport const lazy = 1;\n');
284
+ const app = {
285
+ name: "demo",
286
+ cwdPath: appDir,
287
+ dist: { cwdPath: path.join(root, "dist/apps/demo") },
288
+ workspace: { workspaceRoot: root },
289
+ getConfig: async () => ({ barrelImports: [], optimizeImports: [] }),
290
+ getPublicEnv: () => ({}),
291
+ } as never;
292
+
293
+ const bundle = await new ClientEntriesBundler({
294
+ app,
295
+ entries: [entryA, entryB],
296
+ external: ["react", "akanjs/base", "tailwind-merge"],
297
+ command: "build",
298
+ }).bundle();
299
+
300
+ const [urlA, sharedA] = bundle.entryChunkUrlsByAbsPath.get(entryA) ?? [];
301
+ const [urlB, sharedB] = bundle.entryChunkUrlsByAbsPath.get(entryB) ?? [];
302
+ expect(bundle.entryChunkUrlsByAbsPath.get(entryA)).toHaveLength(2);
303
+ expect(bundle.entryChunkUrlsByAbsPath.get(entryB)).toHaveLength(2);
304
+ expect(sharedA).toBe(sharedB);
305
+ expect(bundle.externalsByChunkUrl.get(urlA ?? "")).toEqual(["react"]);
306
+ expect(bundle.externalsByChunkUrl.get(urlB ?? "")).toEqual([]);
307
+ expect(bundle.externalsByChunkUrl.get(sharedA ?? "")).toEqual(["akanjs/base"]);
308
+ expect([...bundle.externalsByChunkUrl.values()].flat()).toContain("tailwind-merge");
309
+ });
310
+
311
+ test("a route carries the client runtime and stores, but no module UI it never imports", async () => {
312
+ const { appDir, app } = await writeBarrelFixture();
313
+
314
+ await new RouteClientBuilder({
315
+ app,
316
+ seeds: [],
317
+ entries: [path.join(appDir, "ui/A.tsx")],
318
+ artifact: {} as never,
319
+ browser: "chunks",
320
+ }).build();
321
+
322
+ const bundled = await bundledText(path.join(appDir, ".akan/artifact/client"));
323
+ for (const marker of ["runtime-marker", "store-marker", "lib-runtime-marker", "lib-store-marker"])
324
+ expect(bundled).toContain(marker);
325
+ for (const marker of ["memo-ui-marker", "other-ui-marker", "lib-ui-marker"]) expect(bundled).not.toContain(marker);
326
+ });
327
+
328
+ test("a route rendering one module's UI carries no other module's or lib's UI", async () => {
329
+ const { appDir, app } = await writeBarrelFixture();
330
+
331
+ await new RouteClientBuilder({
332
+ app,
333
+ seeds: [],
334
+ entries: [path.join(appDir, "lib/memo/Memo.Zone.tsx")],
335
+ artifact: {} as never,
336
+ browser: "chunks",
337
+ }).build();
338
+
339
+ const bundled = await bundledText(path.join(appDir, ".akan/artifact/client"));
340
+ expect(bundled).toContain("memo-ui-marker");
341
+ expect(bundled).toContain("store-marker");
342
+ expect(bundled).not.toContain("other-ui-marker");
343
+ expect(bundled).not.toContain("lib-ui-marker");
344
+ });
345
+
346
+ test("a route reaching a lib's cnst through its client barrel carries no module UI", async () => {
347
+ const { root, appDir, app } = await writeBarrelFixture();
348
+
349
+ await new RouteClientBuilder({
350
+ app,
351
+ seeds: [],
352
+ entries: [path.join(root, "libs/kit/ui/Field.tsx")],
353
+ artifact: {} as never,
354
+ browser: "chunks",
355
+ }).build();
356
+
357
+ const bundled = await bundledText(path.join(appDir, ".akan/artifact/client"));
358
+ expect(bundled).toContain("field-marker");
359
+ expect(bundled).toContain("lib-cnst-marker");
360
+ for (const marker of ["memo-ui-marker", "other-ui-marker", "lib-ui-marker"]) expect(bundled).not.toContain(marker);
361
+ });
362
+
363
+ test("an import of the store through a client barrel discovers no module UI as a client entry", async () => {
364
+ const { appDir, app } = await writeBarrelFixture();
365
+ const seed = path.join(appDir, "page/_index.tsx");
366
+ await write(seed, 'import { st } from "@apps/demo/client";\nexport default function Page() { return st; }\n');
367
+
368
+ const discovery = new GraphClientEntryDiscovery(
369
+ { barrelImports: fixtureBarrels },
370
+ await createTsconfigPackageResolver(app),
371
+ );
372
+
373
+ expect(await discovery.discover([seed])).toEqual([]);
374
+ });
375
+
186
376
  test("discovers client entries from installed akanjs package sources", async () => {
187
377
  const root = await makeTempRoot();
188
378
  const seed = path.join(root, "apps/demo/page/_index.tsx");
@@ -1,5 +1,9 @@
1
1
  import type { App } from "../commandDecorators";
2
2
 
3
+ //? A package's `default` export can pick its build by NODE_ENV at run time (Lexical), which keeps every export of that
4
+ //? build; its `production` export is the build itself, so what a page leaves unused is dropped.
5
+ export const bundleConditions = (command: "build" | "start") => (command === "build" ? ["production"] : []);
6
+
3
7
  export const bundleDefine = (app: App, command: "build" | "start", renderEnv: "ssr" | "csr") => {
4
8
  const nodeEnv = command === "build" ? "production" : (process.env.NODE_ENV ?? "development");
5
9
  return {
@@ -59,6 +59,8 @@ export interface BundleClientEntriesInternalOptions extends BundleClientEntriesO
59
59
  externalSubpaths?: readonly string[];
60
60
  externalAliases?: Partial<Record<string, string>>;
61
61
  command?: "build" | "start";
62
+ /** Per absolute entry, the only names its alias re-exports, in place of every name the entry declares. */
63
+ entryExports?: Partial<Record<string, readonly string[]>>;
62
64
  }
63
65
 
64
66
  export interface BundleClientEntriesResult {
@@ -67,12 +69,16 @@ export interface BundleClientEntriesResult {
67
69
  entryUrlsByAbsPath: Map<string, string>;
68
70
  entryOutputAbsByAbsPath: Map<string, string>;
69
71
  entryDepsByAbsPath: Map<string, string[]>;
72
+ /** Each entry's served URL and the chunks it imports statically, the entry first. */
73
+ entryChunkUrlsByAbsPath: Map<string, string[]>;
74
+ externalsByChunkUrl: Map<string, string[]>;
70
75
  /** Workspace-relative client reference id per absolute source entry. */
71
76
  clientReferenceIdByAbsPath: Map<string, string>;
72
77
  }
73
78
 
74
79
  export type AkanConfig = Awaited<ReturnType<App["getConfig"]>>;
75
- export type ScannedImport = { path: string; kind?: string };
80
+ export type ScannedImport = { path: string; kind?: string; external?: boolean };
81
+ export type MetafileInput = { imports?: ScannedImport[] };
76
82
  export type MetafileOutput = {
77
83
  imports: ScannedImport[];
78
84
  entryPoint?: string;
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import fs from "node:fs";
3
+ import { realpath } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { tempDirs, writeText } from "../testHelpers";
6
+ import { ClientEntriesBundler } from "./clientEntriesBundler";
7
+
8
+ const makeTempRoot = tempDirs("akan-client-entries-");
9
+ //? Bun's metafile names real paths, and macOS's temp dir sits behind the /var symlink.
10
+ const realTempRoot = async () => fs.realpathSync(await makeTempRoot());
11
+
12
+ const vendorApp = async () => {
13
+ const root = await realTempRoot();
14
+ const appDir = path.join(root, "apps/demo");
15
+ const vendor = path.join(root, "vendor/facet.ts");
16
+ await writeText(path.join(root, "vendor/lib.ts"), "export const real = 1;\nexport const other = 2;\n");
17
+ await writeText(vendor, 'export * from "./lib";\n');
18
+ const app = {
19
+ name: "demo",
20
+ cwdPath: appDir,
21
+ dist: { cwdPath: path.join(root, "dist/apps/demo") },
22
+ workspace: { workspaceRoot: root },
23
+ getConfig: async () => ({ optimizeImports: [] }),
24
+ getPublicEnv: () => ({}),
25
+ } as never;
26
+ const bundle = (command: "build" | "start", entryExports?: Record<string, string[]>) =>
27
+ new ClientEntriesBundler({ app, entries: [vendor], command, entryExports }).bundle();
28
+ return { appDir, vendor, bundle };
29
+ };
30
+
31
+ describe("ClientEntriesBundler narrowed entries", () => {
32
+ test("fails the build, naming each name a route imports that its vendor does not export", async () => {
33
+ const { vendor, bundle } = await vendorApp();
34
+
35
+ await expect(bundle("build", { [vendor]: ["real", "notThere"] })).rejects.toThrow("facet.ts: notThere");
36
+ await expect(bundle("build", { [vendor]: ["real"] })).resolves.toBeDefined();
37
+ }, 30_000);
38
+
39
+ test("writes a narrowed alias apart from the whole one `akan start` keeps for the same entry", async () => {
40
+ const { appDir, vendor, bundle } = await vendorApp();
41
+ await bundle("start");
42
+ await bundle("build", { [vendor]: ["real"] });
43
+
44
+ const aliasDir = path.join(appDir, ".akan/generated/client-entry-alias/client");
45
+ const aliases = await Promise.all(
46
+ [...new Bun.Glob("*.tsx").scanSync(aliasDir)].map((file) => Bun.file(path.join(aliasDir, file)).text()),
47
+ );
48
+
49
+ expect(aliases).toHaveLength(2);
50
+ expect(aliases).toContain(ClientEntriesBundler.narrowedAliasSource(vendor, ["real"]));
51
+ }, 30_000);
52
+ });
53
+
54
+ describe("ClientEntriesBundler", () => {
55
+ test("takes a package's production build in a build and its default one under start", async () => {
56
+ const root = await realpath(await makeTempRoot());
57
+ await writeText(
58
+ path.join(root, "node_modules/twobuild/package.json"),
59
+ '{ "name": "twobuild", "exports": { ".": { "production": "./prod.js", "default": "./switch.js" } } }\n',
60
+ );
61
+ await writeText(path.join(root, "node_modules/twobuild/prod.js"), 'export const mark = "prod-build";\n');
62
+ await writeText(path.join(root, "node_modules/twobuild/switch.js"), 'export const mark = "switch-build";\n');
63
+ const entry = path.join(root, "Mark.tsx");
64
+ await writeText(entry, '"use client";\nimport { mark } from "twobuild";\nexport const Mark = () => mark;\n');
65
+ const app = {
66
+ name: "probe",
67
+ cwdPath: path.join(root, "app"),
68
+ dist: { cwdPath: path.join(root, "dist") },
69
+ workspace: { workspaceRoot: root },
70
+ getConfig: async () => ({ optimizeImports: [] }),
71
+ getPublicEnv: () => ({}),
72
+ };
73
+ const bundle = async (command: "build" | "start") => {
74
+ const result = await new ClientEntriesBundler({ app: app as never, entries: [entry], command }).bundle();
75
+ const output = result.entryOutputAbsByAbsPath.get(entry);
76
+ return output ? await Bun.file(output).text() : "";
77
+ };
78
+
79
+ expect(await bundle("build")).toContain("prod-build");
80
+ expect(await bundle("start")).toContain("switch-build");
81
+ });
82
+ });
@@ -3,13 +3,14 @@ import path from "node:path";
3
3
  import type { SsrManifest, SsrManifestEntry } from "akanjs/server";
4
4
  import type { BunPlugin } from "bun";
5
5
  import { scanUseClientExports, toClientReferencePath } from "../transforms/rscUseClientTransform";
6
- import { bundleDefine } from "./bundleDefine";
6
+ import { bundleConditions, bundleDefine } from "./bundleDefine";
7
7
  import {
8
8
  type BundleClientEntriesInternalOptions,
9
9
  type BundleClientEntriesResult,
10
10
  CLIENT_BUNDLE_NAMING,
11
11
  type ClientBundleTarget,
12
12
  type ClientManifest,
13
+ type MetafileInput,
13
14
  type MetafileOutput,
14
15
  type OpaqueEntryAliases,
15
16
  } from "./clientBuildTypes";
@@ -21,6 +22,7 @@ export class ClientEntriesBundler {
21
22
  #external: readonly string[];
22
23
  #externalSubpaths: readonly string[];
23
24
  #externalAliases: Partial<Record<string, string>>;
25
+ #entryExports: Partial<Record<string, readonly string[]>>;
24
26
  #command: "build" | "start";
25
27
  #target: ClientBundleTarget;
26
28
  #outputSubdir: string;
@@ -32,9 +34,11 @@ export class ClientEntriesBundler {
32
34
  #entryUrlsByAbsPath = new Map<string, string>();
33
35
  #entryOutputAbsByAbsPath = new Map<string, string>();
34
36
  #entryDepsByAbsPath = new Map<string, string[]>();
37
+ #entryChunkUrlsByAbsPath = new Map<string, string[]>();
35
38
  #clientReferenceIdByAbsPath = new Map<string, string>();
36
39
  #opaqueEntries: OpaqueEntryAliases | null = null;
37
40
  #metafileOutputsByAbs = new Map<string, MetafileOutput>();
41
+ #metafileInputs: Record<string, MetafileInput> = {};
38
42
 
39
43
  constructor(options: BundleClientEntriesInternalOptions) {
40
44
  this.#app = options.app;
@@ -43,6 +47,7 @@ export class ClientEntriesBundler {
43
47
  this.#external = options.external ?? [];
44
48
  this.#externalSubpaths = options.externalSubpaths ?? [];
45
49
  this.#externalAliases = options.externalAliases ?? {};
50
+ this.#entryExports = options.entryExports ?? {};
46
51
  this.#command = options.command ?? "start";
47
52
  this.#target = options.target ?? "browser";
48
53
  this.#outputSubdir = options.outputSubdir ?? "client";
@@ -64,6 +69,7 @@ export class ClientEntriesBundler {
64
69
  naming: CLIENT_BUNDLE_NAMING,
65
70
  metafile: true,
66
71
  define: bundleDefine(this.#app, this.#command, "ssr"),
72
+ conditions: bundleConditions(this.#command),
67
73
  minify: this.#command === "build",
68
74
  optimizeImports: akanConfig.optimizeImports,
69
75
  reactFastRefresh: this.#command === "start" && this.#outputSubdir === "client" && this.#reactFastRefresh,
@@ -81,9 +87,11 @@ export class ClientEntriesBundler {
81
87
  this.#metafileOutputsByAbs = new Map<string, MetafileOutput>(
82
88
  Object.entries(metafile.outputs).map(([k, v]) => [this.#absFromOutdir(k), v as MetafileOutput]),
83
89
  );
90
+ this.#metafileInputs = metafile.inputs as Record<string, MetafileInput>;
84
91
 
85
92
  for (const artifact of result.outputs.filter((a) => a.kind === "entry-point"))
86
93
  this.#addEntryArtifact(artifact.path);
94
+ this.#assertNarrowedExports();
87
95
 
88
96
  return {
89
97
  manifest: this.#manifest,
@@ -91,6 +99,8 @@ export class ClientEntriesBundler {
91
99
  entryUrlsByAbsPath: this.#entryUrlsByAbsPath,
92
100
  entryOutputAbsByAbsPath: this.#entryOutputAbsByAbsPath,
93
101
  entryDepsByAbsPath: this.#entryDepsByAbsPath,
102
+ entryChunkUrlsByAbsPath: this.#entryChunkUrlsByAbsPath,
103
+ externalsByChunkUrl: this.#collectExternalsByChunkUrl(),
94
104
  clientReferenceIdByAbsPath: this.#clientReferenceIdByAbsPath,
95
105
  };
96
106
  }
@@ -102,11 +112,15 @@ export class ClientEntriesBundler {
102
112
  const aliasedEntries = await Promise.all(
103
113
  this.#entries.map(async (entry) => {
104
114
  const absEntry = path.resolve(entry);
105
- const hash = Bun.hash(`${this.#app.name}\n${this.#outputSubdir}\n${absEntry}`).toString(36);
106
- const aliasPath = path.join(aliasDir, `${hash}.tsx`);
115
+ const narrowed = this.#entryExports[absEntry];
116
+ //? `akan build` and `akan start` share this directory, so a narrowed alias never takes the whole one's file.
117
+ const key = `${this.#app.name}\n${this.#outputSubdir}\n${absEntry}${narrowed ? "\nnarrowed" : ""}`;
118
+ const aliasPath = path.join(aliasDir, `${Bun.hash(key).toString(36)}.tsx`);
107
119
  await Bun.write(
108
120
  aliasPath,
109
- this.#createOpaqueEntryAliasSource(absEntry, await this.#scanEntryExportNames(absEntry)),
121
+ narrowed
122
+ ? ClientEntriesBundler.narrowedAliasSource(absEntry, narrowed)
123
+ : this.#createOpaqueEntryAliasSource(absEntry, await this.#scanEntryExportNames(absEntry)),
110
124
  );
111
125
  originalByAlias.set(path.resolve(aliasPath), absEntry);
112
126
  return aliasPath;
@@ -124,6 +138,27 @@ export class ClientEntriesBundler {
124
138
  return `${lines.join("\n")}\n`;
125
139
  }
126
140
 
141
+ //? `export {} from` still loads the entry, so a module only ever imported for its side effects keeps them.
142
+ static narrowedAliasSource(absEntry: string, names: readonly string[]): string {
143
+ const list = names.length > 0 ? `{ ${names.join(", ")} }` : "{}";
144
+ return `export ${list} from ${JSON.stringify(path.resolve(absEntry))};\n`;
145
+ }
146
+
147
+ //? Bun silently drops a name `export { … } from` cannot find behind an `export *`.
148
+ #assertNarrowedExports(): void {
149
+ const missing = Object.entries(this.#entryExports).flatMap(([absEntry, names = []]) => {
150
+ const absOut = this.#entryOutputAbsByAbsPath.get(absEntry);
151
+ if (!absOut) return [`${path.basename(absEntry)}: no output`];
152
+ const exported = new Set(this.#metafileOutputsByAbs.get(absOut)?.exports);
153
+ const absent = names.filter((name) => !exported.has(name));
154
+ return absent.length > 0 ? [`${path.basename(absEntry)}: ${absent.join(", ")}`] : [];
155
+ });
156
+ if (missing.length > 0)
157
+ throw new Error(
158
+ `[ClientEntriesBundler] client code imports names its vendor does not export (${missing.join("; ")})`,
159
+ );
160
+ }
161
+
127
162
  async #scanEntryExportNames(absEntry: string): Promise<string[]> {
128
163
  return scanUseClientExports(await Bun.file(absEntry).text(), absEntry, this.#app.workspace.workspaceRoot);
129
164
  }
@@ -220,6 +255,21 @@ export class ClientEntriesBundler {
220
255
  return urls;
221
256
  }
222
257
 
258
+ //? Bun's metafile lists an external import on the input that wrote it, never on the output chunk.
259
+ #collectExternalsByChunkUrl(): Map<string, string[]> {
260
+ const externalsByChunkUrl = new Map<string, string[]>();
261
+ if (this.#command !== "build" || this.#target !== "browser") return externalsByChunkUrl;
262
+ for (const [absOut, info] of this.#metafileOutputsByAbs) {
263
+ if (!absOut.endsWith(".js")) continue;
264
+ const externals = new Set<string>();
265
+ for (const input of Object.keys(info.inputs ?? {}))
266
+ for (const imp of this.#metafileInputs[input]?.imports ?? [])
267
+ if (imp.external && imp.kind !== "dynamic-import" && !/^[./]/.test(imp.path)) externals.add(imp.path);
268
+ externalsByChunkUrl.set(this.#toServeUrl(absOut), [...externals].sort());
269
+ }
270
+ return externalsByChunkUrl;
271
+ }
272
+
223
273
  #collectClientDeps(absOutPath: string, visited = new Set<string>()): Set<string> {
224
274
  if (visited.has(absOutPath)) return new Set();
225
275
  visited.add(absOutPath);
@@ -256,6 +306,7 @@ export class ClientEntriesBundler {
256
306
  this.#entryUrlsByAbsPath.set(entryPointAbs, entryUrl);
257
307
  this.#entryOutputAbsByAbsPath.set(entryPointAbs, absOut);
258
308
  this.#entryDepsByAbsPath.set(entryPointAbs, [...this.#collectClientDeps(absOut)].sort());
309
+ this.#entryChunkUrlsByAbsPath.set(entryPointAbs, chunkUrls);
259
310
  const clientReferenceId = toClientReferencePath(entryPointAbs, this.#app.workspace.workspaceRoot);
260
311
  this.#clientReferenceIdByAbsPath.set(entryPointAbs, clientReferenceId);
261
312
 
@@ -62,6 +62,29 @@ console.info(await result.outputs[0].text());
62
62
  expect(firstOutput).not.toContain(path.basename(first));
63
63
  });
64
64
 
65
+ test("takes a package's production build in a build and its default one under start", async () => {
66
+ const root = await makeTempRoot();
67
+ const app = { getPublicEnv: () => ({ AKAN_PUBLIC_ENV: "main" }) };
68
+ await writeText(
69
+ path.join(root, "node_modules/twobuild/package.json"),
70
+ '{ "name": "twobuild", "exports": { ".": { "production": "./prod.js", "default": "./switch.js" } } }\n',
71
+ );
72
+ await writeText(path.join(root, "node_modules/twobuild/prod.js"), 'export const mark = "prod-build";\n');
73
+ await writeText(path.join(root, "node_modules/twobuild/switch.js"), 'export const mark = "switch-build";\n');
74
+ await writeText(path.join(root, "entry.ts"), 'import { mark } from "twobuild";\nconsole.info(mark);\n');
75
+ const bundle = async (command: "build" | "start") => {
76
+ const result = await Bun.build({
77
+ ...CsrArtifactBuilder.bundleOptions(app as never, command),
78
+ entrypoints: [path.join(root, "entry.ts")],
79
+ });
80
+ expect(result.success).toBe(true);
81
+ return (await result.outputs[0]?.text()) ?? "";
82
+ };
83
+
84
+ expect(await bundle("build")).toContain("prod-build");
85
+ expect(await bundle("start")).toContain("switch-build");
86
+ });
87
+
65
88
  //? The shape of `@lexical/code-prism` under a `lazy()` editor, which threw in every view and CSR shell carrying it.
66
89
  test("runs a lazily imported module's CommonJS side-effect import before the imports after it", async () => {
67
90
  const root = await makeTempRoot();
@@ -6,7 +6,7 @@ import type { App } from "../commandDecorators";
6
6
  import { type BarrelImportsPluginOptions, createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
7
7
  import { createBrowserDirnamePlugin } from "../transforms/browserDirnamePlugin";
8
8
  import { SideEffectImportOrder } from "../transforms/sideEffectImportOrder";
9
- import { bundleDefine } from "./bundleDefine";
9
+ import { bundleConditions, bundleDefine } from "./bundleDefine";
10
10
  import { CsrEntryFiles } from "./csrEntryFiles";
11
11
  import { type McpAppView, McpAppViewBuilder } from "./mcpAppViewBuilder";
12
12
  import { PagesBundleBuilder } from "./pagesBundleBuilder";
@@ -114,6 +114,7 @@ export class CsrArtifactBuilder {
114
114
  splitting: false,
115
115
  minify: true,
116
116
  define: bundleDefine(app, command, "csr"),
117
+ conditions: bundleConditions(command),
117
118
  optimizeImports,
118
119
  // A raw `.css` in the route graph is Tailwind source; the base artifact's compiled sheet is the only stylesheet.
119
120
  plugins: [PagesBundleBuilder.createCssStubPlugin(), createBrowserDirnamePlugin(), SideEffectImportOrder.plugin()],
@@ -58,6 +58,18 @@ const chainFontList = (extra = "") => `[\n ${chainFontEntry(extra)},\n ]`;
58
58
 
59
59
  const optimize = (app: App) => new FontOptimizer(app, "start").optimize();
60
60
 
61
+ const sizeAdjusts = (css: string) => [...css.matchAll(/size-adjust: ([^;]+);/g)].map((match) => match[1]);
62
+
63
+ const renameSfntTable = (bytes: Uint8Array, from: string, to: string) => {
64
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
65
+ for (let index = 0; index < view.getUint16(4); index++) {
66
+ const offset = 12 + index * 16;
67
+ if (new TextDecoder().decode(bytes.subarray(offset, offset + 4)) === from)
68
+ bytes.set(new TextEncoder().encode(to), offset);
69
+ }
70
+ return bytes;
71
+ };
72
+
61
73
  describe("FontOptimizer cache", () => {
62
74
  test("reuses subset output instead of resubsetting an unchanged font", async () => {
63
75
  const { app } = await makeApp(layoutWith());
@@ -121,6 +133,48 @@ describe("FontOptimizer cache", () => {
121
133
  });
122
134
  });
123
135
 
136
+ describe("FontOptimizer fallback", () => {
137
+ test("writes a metric-adjusted fallback face next to the optimized face", async () => {
138
+ const { app } = await makeApp(layoutWith());
139
+
140
+ const { css } = await optimize(app);
141
+
142
+ expect(css).toContain('font-family: "Assistant fallback"');
143
+ expect(sizeAdjusts(css).length).toBeGreaterThan(0);
144
+ expect(css).toMatch(/ascent-override: [\d.]+%/);
145
+ expect(css).toMatch(/descent-override: [\d.]+%/);
146
+ });
147
+
148
+ test("sizes the fallback from the source font, so a subset without Latin letters keeps the same size", async () => {
149
+ const { app: latinApp } = await makeApp(layoutWith());
150
+ const { app: digitsApp } = await makeApp(layoutWith(`\n subsets: [],\n subsetText: "0123456789",`));
151
+
152
+ const latin = sizeAdjusts((await optimize(latinApp)).css);
153
+ const digits = sizeAdjusts((await optimize(digitsApp)).css);
154
+
155
+ expect(latin.length).toBeGreaterThan(0);
156
+ expect(digits).toEqual(latin);
157
+ });
158
+
159
+ test("still writes the face, with no fallback, when the source font's metrics cannot be read", async () => {
160
+ const { app, cwdPath } = await makeApp(`
161
+ export const fonts = [{ name: "Gilda", paths: [{ src: "/fonts/Gilda.ttf", weight: 400 }] }];
162
+ export default function Layout() {
163
+ return null;
164
+ }
165
+ `);
166
+ const source = await Bun.file(path.resolve(path.dirname(SOURCE_FONT), "GildaDisplay-Regular.ttf")).bytes();
167
+ // capsize reads the average width from the OS/2 table, so without one the metrics read throws.
168
+ await Bun.write(path.join(cwdPath, "public/fonts/Gilda.ttf"), renameSfntTable(source, "OS/2", "XS/2"));
169
+
170
+ const result = await optimize(app);
171
+
172
+ expect(result.files).toHaveLength(1);
173
+ expect(result.css).toContain('font-family: "Gilda"');
174
+ expect(result.css).not.toContain('font-family: "Gilda fallback"');
175
+ });
176
+ });
177
+
124
178
  describe("FontOptimizer discovery", () => {
125
179
  test("reads the .fonts() stage of a rootLayout() chain", async () => {
126
180
  const { app, warnings } = await makeApp(chainLayoutWith(chainFontList()));
@@ -39,7 +39,7 @@ export class FontOptimizer {
39
39
  #woff2Ready: Promise<void> | null = null;
40
40
 
41
41
  static #ksX1001Text: string | null = null;
42
- static readonly #cacheVersion = 2;
42
+ static readonly #cacheVersion = 3;
43
43
 
44
44
  constructor(app: App, command: FontOptimizerCommand = "start") {
45
45
  this.#app = app;
@@ -163,7 +163,7 @@ export class FontOptimizer {
163
163
  this.#files.push(outputPath);
164
164
 
165
165
  faceCss.push(this.#buildOptimizedFontFaceRule(font, face));
166
- const fallbackCss = await this.#buildFontaineFallbackCss(font, face, outputPath);
166
+ const fallbackCss = await this.#buildFontaineFallbackCss(font, face, sourcePath);
167
167
  if (fallbackCss) faceCss.push(fallbackCss);
168
168
  }
169
169
 
@@ -468,10 +468,11 @@ export class FontOptimizer {
468
468
  return `@font-face {\n${declarations.map(([prop, value]) => ` ${prop}: ${value};`).join("\n")}\n}`;
469
469
  }
470
470
 
471
- async #buildFontaineFallbackCss(font: ReactFont, face: ReactFontFace, outputPath: string) {
471
+ async #buildFontaineFallbackCss(font: ReactFont, face: ReactFontFace, sourcePath: string) {
472
472
  if (font.adjustFontFallback === false) return "";
473
473
  const { generateFontFace, getMetricsForFamily, readMetrics, resolveCategoryFallbacks } = await import("fontaine");
474
- const metrics = await readMetrics(outputPath).catch(() => null);
474
+ // The source, not the subset: without Latin glyphs a subset measures .notdef, so xWidthAvg reads as a full em.
475
+ const metrics = await readMetrics(Bun.pathToFileURL(sourcePath)).catch(() => null);
475
476
  if (!metrics) return "";
476
477
  const fallbacks = resolveCategoryFallbacks({
477
478
  fontFamily: font.name,
@@ -18,6 +18,7 @@ export * from "./pagesBundleBuilder";
18
18
  export * from "./pagesEntrySourceGenerator";
19
19
  export * from "./precompressArtifacts";
20
20
  export * from "./routeClientBuilder";
21
+ export * from "./routeModulePreloads";
21
22
  export * from "./routesManifestArtifactSerializer";
22
23
  export * from "./serverGraphFile";
23
24
  export * from "./sourceMtimeIndex";
@@ -25,5 +26,7 @@ export * from "./ssrBaseArtifactBuilder";
25
26
  export * from "./ssrDevBundler";
26
27
  export * from "./styleContract";
27
28
  export * from "./themeValidator";
29
+ export * from "./vendorImportScanner";
30
+ export * from "./vendorNarrowing";
28
31
  export * from "./vendorSpecifiers";
29
32
  export * from "./watchRootResolver";