@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
@@ -177,6 +177,7 @@ describe("McpAppViewBuilder.build", () => {
177
177
  dist: { cwdPath: file("dist/apps/demo") },
178
178
  workspace: { workspaceRoot: root },
179
179
  getTsConfig: async () => ({ compilerOptions: {} }),
180
+ getConfig: async () => ({ barrelImports: [] }),
180
181
  getPublicEnv: () => ({}),
181
182
  logger: { warn: () => undefined },
182
183
  };
@@ -195,6 +196,72 @@ describe("McpAppViewBuilder.build", () => {
195
196
  });
196
197
  });
197
198
 
199
+ describe("McpAppViewBuilder.build with an app barrel", () => {
200
+ test("narrows the app's client barrel to the module the trimmed page names, never another module's UI", async () => {
201
+ const root = await makeTempRoot();
202
+ const file = (rel: string) => path.join(root, rel);
203
+ const paths = { "@apps/*": ["./apps/*"] };
204
+ await write(file("tsconfig.json"), JSON.stringify({ compilerOptions: { baseUrl: ".", paths } }));
205
+ await write(
206
+ file("node_modules/akanjs/package.json"),
207
+ JSON.stringify({ name: "akanjs", exports: { "./webkit": "./webkit.ts" } }),
208
+ );
209
+ await write(
210
+ file("node_modules/akanjs/webkit.ts"),
211
+ "export const McpAppBoot = { run: (view: unknown) => console.info(view) };\n",
212
+ );
213
+ await write(
214
+ file("apps/demo/client.ts"),
215
+ [
216
+ 'export { usePage } from "./lib/useClient";',
217
+ 'export { st } from "./lib/st";',
218
+ 'export { Memo } from "./lib/memo";',
219
+ 'export { Report } from "./lib/report";',
220
+ "",
221
+ ].join("\n"),
222
+ );
223
+ await write(
224
+ file("apps/demo/lib/useClient.ts"),
225
+ 'console.info("the client runtime loaded");\nexport const usePage = () => null;\n',
226
+ );
227
+ await write(file("apps/demo/lib/st.ts"), 'console.info("the store loaded");\nexport const st = null;\n');
228
+ await write(file("apps/demo/lib/memo/index.ts"), 'export const Memo = "a memo card";\n');
229
+ await write(
230
+ file("apps/demo/lib/report/index.ts"),
231
+ 'console.info("the report module loaded");\nexport const Report = "a report card";\n',
232
+ );
233
+ await write(
234
+ file("apps/demo/page/home.tsx"),
235
+ [
236
+ 'import { Memo, Report } from "@apps/demo/client";',
237
+ "const page = () => ({ app: (name: string, description: string, draw: () => unknown) => ({ render: (render: () => unknown) => [name, description, draw, render] }) });",
238
+ 'export default page().app("showHome", "Show home.", () => Memo).render(() => Report);',
239
+ ].join("\n"),
240
+ );
241
+ const app = {
242
+ cwdPath: file("apps/demo"),
243
+ dist: { cwdPath: file("dist/apps/demo") },
244
+ workspace: { workspaceRoot: root },
245
+ getTsConfig: async () => ({ compilerOptions: { paths } }),
246
+ getConfig: async () => ({ barrelImports: ["@apps/demo/client"] }),
247
+ getPublicEnv: () => ({}),
248
+ logger: { warn: () => undefined },
249
+ };
250
+
251
+ await new McpAppViewBuilder(app as never, "build").build(
252
+ [{ key: "./home.tsx", moduleAbsPath: file("apps/demo/page/home.tsx") }],
253
+ { basePaths: [], cssAssets: {}, options: CsrArtifactBuilder.bundleOptions(app as never, "build") },
254
+ );
255
+
256
+ const view = await Bun.file(file("dist/apps/demo/mcp-apps/showHome.html")).text();
257
+ expect(view).toContain("a memo card");
258
+ expect(view).toContain("the client runtime loaded");
259
+ expect(view).toContain("the store loaded");
260
+ expect(view).not.toContain("a report card");
261
+ expect(view).not.toContain("the report module loaded");
262
+ });
263
+ });
264
+
198
265
  describe("McpAppViewBuilder.entryOf", () => {
199
266
  test("boots the one app with its page, the root layout and overrides it sits under, by relative path", () => {
200
267
  const view = {
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, realpath, symlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { tempDirs, writeText as write } from "../testHelpers";
6
+ import { createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
7
+
8
+ const makeTempRoot = tempDirs("akan-native-core-");
9
+ const akanjsRoot = path.resolve(import.meta.dir, "../../../akanjs");
10
+ const repoRoot = path.resolve(akanjsRoot, "../..");
11
+ const frameworkBarrels = ["akanjs/webkit", "akanjs/common", "akanjs/ui", "akanjs/server"];
12
+ const reactExternals = [
13
+ "react",
14
+ "react-dom",
15
+ "react-dom/client",
16
+ "react/jsx-runtime",
17
+ "react/jsx-dev-runtime",
18
+ "scheduler",
19
+ "react-server-dom-webpack/client.browser",
20
+ ];
21
+ const pageRuntime = [
22
+ path.join(akanjsRoot, "server/rscClient.tsx"),
23
+ path.join(akanjsRoot, "server/rscSegmentOutlet.tsx"),
24
+ path.join(akanjsRoot, "ui/System/Client.tsx"),
25
+ ];
26
+
27
+ interface PageBuild {
28
+ workspaceRoot: string;
29
+ paths: Record<string, string[]>;
30
+ barrelImports: string[];
31
+ entries: string[];
32
+ }
33
+
34
+ const eagerInputsOf = async ({ workspaceRoot, paths, barrelImports, entries }: PageBuild) => {
35
+ const app = {
36
+ name: "demo",
37
+ cwdPath: path.join(workspaceRoot, "apps/demo"),
38
+ workspace: { workspaceRoot },
39
+ getConfig: async () => ({ barrelImports }),
40
+ getTsConfig: async () => ({ compilerOptions: { paths } }),
41
+ };
42
+ const result = await Bun.build({
43
+ entrypoints: entries,
44
+ target: "browser",
45
+ format: "esm",
46
+ splitting: true,
47
+ metafile: true,
48
+ throw: true,
49
+ conditions: ["production"],
50
+ external: reactExternals,
51
+ plugins: [await createBarrelImportsPlugin(app as never)],
52
+ });
53
+ const outputs = result.metafile?.outputs ?? {};
54
+ const eager = new Set<string>();
55
+ const load = (outputPath: string) => {
56
+ if (eager.has(outputPath) || !outputs[outputPath]) return;
57
+ eager.add(outputPath);
58
+ for (const imp of outputs[outputPath].imports) if (imp.kind === "import-statement") load(imp.path);
59
+ };
60
+ //? A dynamically imported module gets an entry chunk of its own too: only the page's entries start the walk.
61
+ for (const [outputPath, output] of Object.entries(outputs))
62
+ if (output.entryPoint && entries.includes(path.resolve(output.entryPoint))) load(outputPath);
63
+ return {
64
+ eager: [...eager].flatMap((outputPath) => Object.keys(outputs[outputPath]?.inputs ?? {})),
65
+ all: Object.keys(result.metafile?.inputs ?? {}),
66
+ };
67
+ };
68
+
69
+ const eagerInputsOfAPage = async (store: string) => {
70
+ //? Real path: Bun names entry points through /private on macOS, where the temp dir is a symlink.
71
+ const root = await realpath(await makeTempRoot());
72
+ await mkdir(path.join(root, "node_modules"), { recursive: true });
73
+ await symlink(akanjsRoot, path.join(root, "node_modules/akanjs"));
74
+ const paths = { "@apps/*": ["./apps/*"] };
75
+ await write(path.join(root, "tsconfig.json"), JSON.stringify({ compilerOptions: { paths } }));
76
+ await write(path.join(root, "apps/demo/lib/st.ts"), store);
77
+ return await eagerInputsOf({
78
+ workspaceRoot: root,
79
+ paths,
80
+ barrelImports: frameworkBarrels,
81
+ entries: [...pageRuntime, path.join(akanjsRoot, "ui/Link/Anchor.tsx"), path.join(root, "apps/demo/lib/st.ts")],
82
+ });
83
+ };
84
+
85
+ const isCore = (input: string) => /native\/packages\/core\//.test(input);
86
+
87
+ //? `akan sync` generates these, which a fresh checkout lacks; CI's sync job sets REQUIRE_SYNCED_LIBS after syncing.
88
+ const syncedLibFiles = [
89
+ ...["shared", "util"].flatMap((lib) =>
90
+ [
91
+ "client.ts",
92
+ "server.ts",
93
+ "index.ts",
94
+ "ui/index.ts",
95
+ "webkit/index.ts",
96
+ "common/index.ts",
97
+ "lib/cnst.ts",
98
+ "lib/st.ts",
99
+ "lib/useClient.ts",
100
+ ].map((file) => `libs/${lib}/${file}`),
101
+ ),
102
+ ...["user", "admin"].map((module) => `libs/shared/lib/${module}/index.ts`),
103
+ ];
104
+ const libsSynced = syncedLibFiles.every((file) => existsSync(path.join(repoRoot, file)));
105
+
106
+ describe("@akanjs/native/core on a web page", () => {
107
+ test("the runtime, the system shell, Link and a store reading the host stay off it", async () => {
108
+ const { eager, all } = await eagerInputsOfAPage(
109
+ 'import { isNativeShell, router } from "akanjs/client";\nimport { store } from "akanjs/store";\nexport const st = { isNativeShell, router, store };\n',
110
+ );
111
+
112
+ expect(eager.filter(isCore)).toEqual([]);
113
+ expect(all.some(isCore)).toBe(true);
114
+ });
115
+
116
+ test("a store importing akanjs/client/native statically puts it on the page", async () => {
117
+ const { eager } = await eagerInputsOfAPage(
118
+ 'import { isNativeShell } from "akanjs/client/native";\nexport const st = { isNativeShell };\n',
119
+ );
120
+
121
+ expect(eager.some(isCore)).toBe(true);
122
+ });
123
+
124
+ test.skipIf(!libsSynced && !process.env.REQUIRE_SYNCED_LIBS)(
125
+ "a page drawing a shared Field and User template, on a store merging shared, stays off it",
126
+ async () => {
127
+ const { eager, all } = await eagerInputsOf({
128
+ workspaceRoot: repoRoot,
129
+ paths: (await Bun.file(path.join(repoRoot, "tsconfig.json")).json()).compilerOptions.paths,
130
+ barrelImports: [
131
+ ...frameworkBarrels,
132
+ ...["shared", "util"].flatMap((lib) =>
133
+ ["ui", "webkit", "common", "client", "server"].map((facet) => `@libs/${lib}/${facet}`),
134
+ ),
135
+ ],
136
+ entries: [
137
+ ...pageRuntime,
138
+ ...["lib/useClient.ts", "lib/st.ts", "ui/Field.tsx", "lib/user/User.Template.tsx"].map((file) =>
139
+ path.join(repoRoot, "libs/shared", file),
140
+ ),
141
+ ],
142
+ });
143
+
144
+ expect(eager.filter(isCore)).toEqual([]);
145
+ expect(all.some(isCore)).toBe(true);
146
+ },
147
+ );
148
+ });
@@ -22,7 +22,7 @@ export interface PrecompressArtifactsResult {
22
22
  }
23
23
 
24
24
  export async function precompressArtifacts(app: App): Promise<PrecompressArtifactsResult> {
25
- //* WebRouter serves client/, styles/ and dict/ through the same sidecar-aware #fileResponse.
25
+ //* WebRouter serves client/, styles/ and dict/ through the same sidecar-aware WebRouteContext.fileResponse.
26
26
  const artifactDir = path.join(app.dist.cwdPath, ".akan/artifact");
27
27
  const roots = [
28
28
  { root: path.join(artifactDir, "client") },
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SsrBaseArtifactBuilder } from "./ssrBaseArtifactBuilder";
3
+
4
+ describe("SsrBaseArtifactBuilder.outletModuleMap", () => {
5
+ const outlet = "pkgs/akanjs/server/rscSegmentOutlet.tsx";
6
+ const ssrOutput = "/dist/client-ssr/rscSegmentOutlet.js";
7
+ const manifest = {
8
+ [`${outlet}#AkanSegmentOutlet`]: { id: "/_akan/client/outlet.js", chunks: [], name: "AkanSegmentOutlet" },
9
+ [`${outlet}#AkanRouteHead`]: { id: "/_akan/client/outlet.js", chunks: [], name: "AkanRouteHead" },
10
+ "pkgs/akanjs/ui/Other.tsx#Other": { id: "/_akan/client/other.js", chunks: [], name: "Other" },
11
+ };
12
+
13
+ test("keeps every export of the outlet module under its one client id, each served by the SSR build", () => {
14
+ expect(SsrBaseArtifactBuilder.outletModuleMap(manifest, outlet, ssrOutput)).toEqual({
15
+ "/_akan/client/outlet.js": {
16
+ AkanSegmentOutlet: { id: ssrOutput, chunks: [ssrOutput, ssrOutput], name: "AkanSegmentOutlet", async: true },
17
+ AkanRouteHead: { id: ssrOutput, chunks: [ssrOutput, ssrOutput], name: "AkanRouteHead", async: true },
18
+ },
19
+ });
20
+ });
21
+
22
+ test("is empty without the outlet's SSR build or client reference", () => {
23
+ expect(SsrBaseArtifactBuilder.outletModuleMap(manifest, outlet, undefined)).toEqual({});
24
+ expect(SsrBaseArtifactBuilder.outletModuleMap(manifest, undefined, ssrOutput)).toEqual({});
25
+ });
26
+ });
@@ -153,25 +153,10 @@ export class SsrBaseArtifactBuilder {
153
153
  const rscClientUrl = clientBundle.entryUrlsByAbsPath.get(rscClientEntry) ?? "";
154
154
  const rscRuntimeSsrManifest = {
155
155
  moduleLoading: null,
156
- moduleMap: Object.fromEntries(
157
- Object.entries(clientBundle.manifest)
158
- .map(([key, row]) => {
159
- const ssrOutput = ssrBundle.entryOutputAbsByAbsPath.get(rscSegmentOutletEntry);
160
- if (
161
- !ssrOutput ||
162
- key !== `${clientBundle.clientReferenceIdByAbsPath.get(rscSegmentOutletEntry)}#${row.name}`
163
- ) {
164
- return null;
165
- }
166
- return [
167
- row.id,
168
- { [row.name]: { id: ssrOutput, chunks: [ssrOutput, ssrOutput], name: row.name, async: true } },
169
- ];
170
- })
171
- .filter(
172
- (entry): entry is [string, Record<string, { id: string; chunks: string[]; name: string; async: true }>] =>
173
- Boolean(entry),
174
- ),
156
+ moduleMap: SsrBaseArtifactBuilder.outletModuleMap(
157
+ clientBundle.manifest,
158
+ clientBundle.clientReferenceIdByAbsPath.get(rscSegmentOutletEntry),
159
+ ssrBundle.entryOutputAbsByAbsPath.get(rscSegmentOutletEntry),
175
160
  ),
176
161
  };
177
162
  const vendorMap = Object.fromEntries(
@@ -190,6 +175,25 @@ export class SsrBaseArtifactBuilder {
190
175
  },
191
176
  };
192
177
  }
178
+
179
+ /** Every export of the outlet module shares one client id, so each joins that id's row instead of replacing it. */
180
+ static outletModuleMap(
181
+ manifest: NonNullable<BaseBuildArtifact["rscRuntimeClientManifest"]>,
182
+ outletReferenceId: string | undefined,
183
+ ssrOutput: string | undefined,
184
+ ) {
185
+ const moduleMap: Record<string, Record<string, { id: string; chunks: string[]; name: string; async: true }>> = {};
186
+ if (!ssrOutput || !outletReferenceId) return moduleMap;
187
+ for (const [key, row] of Object.entries(manifest)) {
188
+ if (key !== `${outletReferenceId}#${row.name}`) continue;
189
+ moduleMap[row.id] = {
190
+ ...moduleMap[row.id],
191
+ [row.name]: { id: ssrOutput, chunks: [ssrOutput, ssrOutput], name: row.name, async: true },
192
+ };
193
+ }
194
+ return moduleMap;
195
+ }
196
+
193
197
  async #resolveAkanServerPath() {
194
198
  const candidates: string[] = [];
195
199
  try {
@@ -5,6 +5,7 @@ import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
5
  import { computeRouteSeedIndex } from "../artifact/routeSeedIndex";
6
6
  import { StoreBootstrap } from "../artifact/storeBootstrap";
7
7
  import type { App } from "../commandDecorators";
8
+ import { workspaceBarrels } from "../transforms/barrelImportsPlugin";
8
9
  import { bundleDefine } from "./bundleDefine";
9
10
  import type { ClientEntryDiscovery } from "./clientBuildTypes";
10
11
  import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
@@ -17,7 +18,7 @@ import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
17
18
  //* (the app's client runtime) and every `"use client"` entry; React and the akanjs vendor facets stay the import map's,
18
19
  //* so the page keeps one React and one store registry.
19
20
  export class SsrDevBundler extends DevRegistryBundler {
20
- static readonly #formatVersion = 2;
21
+ static readonly #formatVersion = 4;
21
22
  static readonly bootstrapKey = "";
22
23
  readonly reloadsOnEntryChange = false;
23
24
  #discovery: ClientEntryDiscovery | null = null;
@@ -33,8 +34,17 @@ export class SsrDevBundler extends DevRegistryBundler {
33
34
  const akanConfig = await this.app.getConfig();
34
35
  const define = bundleDefine(this.app, "start", "ssr");
35
36
  const optimizeImports = [...akanConfig.optimizeImports];
37
+ const barrelImports = workspaceBarrels(akanConfig.barrelImports);
36
38
  const configKey = Bun.hash(
37
- JSON.stringify(["ssr", SsrDevBundler.#formatVersion, Bun.version, define, optimizeImports, VENDOR_SPECIFIERS]),
39
+ JSON.stringify([
40
+ "ssr",
41
+ SsrDevBundler.#formatVersion,
42
+ Bun.version,
43
+ define,
44
+ optimizeImports,
45
+ VENDOR_SPECIFIERS,
46
+ barrelImports,
47
+ ]),
38
48
  ).toString(36);
39
49
  return {
40
50
  pageEntries: [],
@@ -45,6 +55,7 @@ export class SsrDevBundler extends DevRegistryBundler {
45
55
  configKey,
46
56
  refreshFile: null,
47
57
  externals: VENDOR_SPECIFIERS,
58
+ barrelImports,
48
59
  };
49
60
  }
50
61
 
@@ -50,22 +50,43 @@ describe("VendorImportScanner", () => {
50
50
  expect(used["akanjs/store"]).toEqual([]);
51
51
  });
52
52
 
53
+ test("reads the import at the end of a minified body too long for the call stack to walk", () => {
54
+ const body = Array.from({ length: 40_000 }, (_, i) => `a${i % 26}(${i})`).join(",");
55
+
56
+ const used = scan(`import{st}from"akanjs/store";function f(){${body},import("akanjs/fetch")}`);
57
+
58
+ expect(used).toEqual({ "akanjs/store": ["st"], "akanjs/common": [], "tailwind-merge": [] });
59
+ });
60
+
53
61
  test("collects across every .js file of a directory", async () => {
54
62
  const dir = await makeTempRoot();
55
63
  await write(path.join(dir, "entry-a.js"), 'import{st}from"akanjs/store";');
56
64
  await write(path.join(dir, "chunks/lazy.js"), 'import{RootStore}from"akanjs/store";');
57
65
  await write(path.join(dir, "chunks/lazy.js.map"), 'import{ignored}from"akanjs/store";');
58
66
 
59
- const used = await VendorImportScanner.exportsUsedIn(dir, ["akanjs/store"]);
67
+ const used = await VendorImportScanner.exportsUsedIn(dir, ["akanjs/store"], ["/_akan/client/entry-a.js"]);
60
68
 
61
69
  expect(used).toEqual({ "akanjs/store": ["RootStore", "st"] });
62
70
  });
63
71
 
64
- test("reads a client output directory a build never wrote as importing nothing", async () => {
72
+ test("reads a client output directory a build never wrote as importing nothing when the routes have no client entry", async () => {
65
73
  const dir = await makeTempRoot();
66
74
 
67
- expect(await VendorImportScanner.exportsUsedIn(path.join(dir, "client"), ["akanjs/store"])).toEqual({
75
+ expect(await VendorImportScanner.exportsUsedIn(path.join(dir, "client"), ["akanjs/store"], [])).toEqual({
68
76
  "akanjs/store": [],
69
77
  });
70
78
  });
79
+
80
+ test("fails, naming the entry, when a route client entry is not in the directory it scans", async () => {
81
+ const dir = await makeTempRoot();
82
+ await write(path.join(dir, "chunks/shared.js"), 'import{st}from"akanjs/store";');
83
+ const entries = ["/_akan/client/page-a1.js", "/_akan/client/page-a1.js", "/_akan/client/chunks/shared.js"];
84
+
85
+ await expect(VendorImportScanner.exportsUsedIn(dir, ["akanjs/store"], entries)).rejects.toThrow(
86
+ `no file under ${dir} for the route client entries /_akan/client/page-a1.js`,
87
+ );
88
+ await expect(
89
+ VendorImportScanner.exportsUsedIn(path.join(dir, "missing"), ["akanjs/store"], ["/_akan/client/page-a1.js"]),
90
+ ).rejects.toThrow("/_akan/client/page-a1.js");
91
+ });
71
92
  });
@@ -12,25 +12,40 @@ export class VendorImportScanner {
12
12
  for (const specifier of specifiers) this.#names.set(specifier, new Set());
13
13
  }
14
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[]>> {
15
+ static async exportsUsedIn(
16
+ dir: string,
17
+ specifiers: readonly string[],
18
+ entryUrls: readonly string[],
19
+ ): Promise<Record<string, string[]>> {
17
20
  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
+ const scanned: string[] = [];
22
+ if (fs.existsSync(dir))
23
+ for await (const relative of new Bun.Glob("**/*.js").scan({ cwd: dir })) {
24
+ scanner.scan(await Bun.file(path.join(dir, relative)).text(), relative);
25
+ scanned.push(`/${relative.split(path.sep).join("/")}`);
26
+ }
27
+ const unread = [...new Set(entryUrls)].filter((url) => !scanned.some((relative) => url.endsWith(relative)));
28
+ if (unread.length > 0) {
29
+ const named = `${unread.slice(0, 3).join(", ")}${unread.length > 3 ? `, and ${unread.length - 3} more` : ""}`;
30
+ throw new Error(
31
+ `[VendorImportScanner] no file under ${dir} for the route client entries ${named}; narrowing the vendor entries without them would drop names those pages import`,
32
+ );
33
+ }
21
34
  return scanner.usedExports();
22
35
  }
23
36
 
24
37
  scan(source: string, fileName = "chunk.js"): this {
25
38
  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 => {
39
+ //? A stack, not recursion: minified code folds a long body into one comma chain, deeper than the call stack.
40
+ const stack: ts.Node[] = [ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS)];
41
+ for (let node = stack.pop(); node; node = stack.pop()) {
28
42
  if (ts.isImportDeclaration(node)) this.#readImport(node);
29
43
  else if (ts.isExportDeclaration(node)) this.#readExport(node);
30
44
  else if (ts.isCallExpression(node)) this.#readCall(node);
31
- ts.forEachChild(node, visit);
32
- };
33
- visit(file);
45
+ ts.forEachChild(node, (child) => {
46
+ stack.push(child);
47
+ });
48
+ }
34
49
  return this;
35
50
  }
36
51
 
@@ -68,6 +68,31 @@ describe("IncrementalBuilderHost", () => {
68
68
  host.stop();
69
69
  });
70
70
 
71
+ test("a stop while a restart is pending leaves the builder down once the delay passes", async () => {
72
+ const { spawns, host } = startRecordingHost();
73
+ spawns[0]?.options.onExit?.();
74
+ expect(host.status).toBe("restarting");
75
+
76
+ host.stop();
77
+ await wait(1_050);
78
+ expect(spawns).toHaveLength(1);
79
+ expect(host.status).toBe("stopped");
80
+ });
81
+
82
+ test("a builder that crashes again after coming back ready waits the first delay again", async () => {
83
+ const { spawns, host } = startRecordingHost();
84
+ spawns[0]?.options.onExit?.();
85
+ await wait(1_050);
86
+ expect(spawns).toHaveLength(2);
87
+
88
+ spawns[1]?.options.ipc?.({ type: "builder-ready" });
89
+ spawns[1]?.options.onExit?.();
90
+ await wait(1_050);
91
+ expect(spawns).toHaveLength(3);
92
+
93
+ host.stop();
94
+ });
95
+
71
96
  test("recycles a ready builder gracefully and replaces it immediately", async () => {
72
97
  const spawns = mockSpawns();
73
98
  const onRestartReady = mock();
@@ -123,18 +148,21 @@ describe("IncrementalBuilderHost", () => {
123
148
  id: 1,
124
149
  ok: false,
125
150
  error: "builder exited to release bundler memory before answering; reload to retry",
151
+ kind: "unavailable",
126
152
  },
127
153
  {
128
154
  type: "build-csr-res",
129
155
  id: 2,
130
156
  ok: false,
131
157
  error: "builder exited to release bundler memory before answering; reload to retry",
158
+ kind: "unavailable",
132
159
  },
133
160
  {
134
161
  type: "build-graph-res",
135
162
  id: 4,
136
163
  ok: false,
137
164
  error: "builder exited to release bundler memory before answering; reload to retry",
165
+ kind: "unavailable",
138
166
  },
139
167
  ]);
140
168
 
@@ -159,6 +187,7 @@ describe("IncrementalBuilderHost", () => {
159
187
  id: 1,
160
188
  ok: false,
161
189
  error: "builder exited unexpectedly before answering; reload once it is back",
190
+ kind: "unavailable",
162
191
  },
163
192
  ]);
164
193
 
@@ -168,7 +197,7 @@ describe("IncrementalBuilderHost", () => {
168
197
  expect(host.send({ type: "build-route", id: 2, routeId: "b", seeds: [], knownEntries: [] })).toBe(true);
169
198
  host.stop();
170
199
  expect(messages).toEqual([
171
- { type: "build-route-res", id: 2, ok: false, error: "builder was stopped before answering" },
200
+ { type: "build-route-res", id: 2, ok: false, error: "builder was stopped before answering", kind: "unavailable" },
172
201
  ]);
173
202
  });
174
203
 
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { Logger } from "akanjs/common";
3
+ import { Logger, RestartSchedule } from "akanjs/common";
4
4
  import type { BuilderMessage, BuilderRequest } from "akanjs/server";
5
5
  import { CSR_DEV_DIRNAME, CSR_DEV_PATCHING_MARKER, SSR_DEV_DIRNAME } from "akanjs/server/hmr/csrDevManifest";
6
6
  import { MemoryLimit } from "akanjs/server/memoryLimit";
@@ -49,8 +49,6 @@ interface IncrementalBuilderStartOptions {
49
49
  }
50
50
 
51
51
  export class IncrementalBuilderHost {
52
- static readonly #restartBaseDelayMs = 1_000;
53
- static readonly #restartMaxDelayMs = 30_000;
54
52
  static readonly #recycleDrainTimeoutMs = 30_000;
55
53
  // apps/akan's builder peaks near 950MB holding the SSR registry's patcher through a route build, and arming CSR adds
56
54
  // a second patcher: this recycles only a builder grown well past both.
@@ -65,8 +63,7 @@ export class IncrementalBuilderHost {
65
63
  readonly #onOutput: ((kind: "stdout" | "stderr", text: string) => void) | null;
66
64
  #proc: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
67
65
  #status: IncrementalBuilderStatus = "stopped";
68
- #restartAttempts = 0;
69
- #restartTimer: ReturnType<typeof setTimeout> | null = null;
66
+ readonly #restart = new RestartSchedule({ min: 1_000, max: 30_000 });
70
67
  #recycleTimer: ReturnType<typeof setTimeout> | null = null;
71
68
  #recycleRequested: boolean = false;
72
69
  #spawnAfterRecycle: boolean = false;
@@ -136,7 +133,7 @@ export class IncrementalBuilderHost {
136
133
  if (msg.type === "builder-ready" && !this.ready) {
137
134
  this.ready = true;
138
135
  this.#status = "ready";
139
- this.#restartAttempts = 0;
136
+ this.#restart.reset();
140
137
  if (isRestart) this.#startOptions.onRestartReady?.();
141
138
  else this.#startOptions.onReady?.();
142
139
  }
@@ -210,20 +207,13 @@ export class IncrementalBuilderHost {
210
207
  }
211
208
  }
212
209
  #scheduleRestart() {
213
- if (this.#manualStop || this.#restartTimer) return;
210
+ if (this.#manualStop || this.#restart.pending) return;
214
211
  this.#status = "restarting";
215
- const attempt = this.#restartAttempts;
216
- const delay = Math.min(
217
- IncrementalBuilderHost.#restartBaseDelayMs * 2 ** attempt,
218
- IncrementalBuilderHost.#restartMaxDelayMs,
219
- );
220
- this.#restartAttempts = attempt + 1;
221
- this.logger.warn(`builder exited after ready; restarting in ${delay}ms (attempt ${this.#restartAttempts})`);
222
- this.#restartTimer = setTimeout(() => {
223
- this.#restartTimer = null;
212
+ const delay = this.#restart.schedule(() => {
224
213
  if (this.#manualStop) return;
225
214
  this.#spawn(true);
226
- }, delay);
215
+ });
216
+ this.logger.warn(`builder exited after ready; restarting in ${delay}ms (attempt ${this.#restart.attempts})`);
227
217
  }
228
218
  /** Drain-and-exit rather than `kill()`, so no rebuild is truncated; false when the request was not sent. */
229
219
  recycle(reason: string): boolean {
@@ -287,16 +277,14 @@ export class IncrementalBuilderHost {
287
277
  const lost = [...this.#inFlight];
288
278
  this.#inFlight.clear();
289
279
  this.logger.warn(`failing ${lost.length} unanswered builder request(s): ${reason}`);
290
- for (const [id, type] of lost) this.#onMessage({ type: `${type}-res`, id, ok: false, error: reason });
280
+ for (const [id, type] of lost)
281
+ this.#onMessage({ type: `${type}-res`, id, ok: false, error: reason, kind: "unavailable" });
291
282
  }
292
283
  stop() {
293
284
  this.#manualStop = true;
294
285
  this.#clearRecycle();
295
286
  this.#failInFlight("builder was stopped before answering");
296
- if (this.#restartTimer) {
297
- clearTimeout(this.#restartTimer);
298
- this.#restartTimer = null;
299
- }
287
+ this.#restart.cancel();
300
288
  if (this.#proc) this.#proc.kill();
301
289
  this.#proc = null;
302
290
  this.ready = false;
@@ -0,0 +1,13 @@
1
+ import { Err } from "akanjs/common"; // @flag
2
+ import { Err as Aliased } from "akanjs/common"; // @flag
3
+ import { Err as Mixed, Logger } from "akanjs/common"; // @flag
4
+ import { type ErrPayload, Err as MixedWithType } from "akanjs/common"; // @flag
5
+ import { Err as SingleQuoted } from 'akanjs/common'; // @flag
6
+ import { Err as DeepWithExtension } from "akanjs/common/err.ts"; // @flag
7
+ import { Err as ThroughIndex } from "akanjs/common/index.ts"; // @flag
8
+ import {
9
+ hasErrKey,
10
+ Err as MultiLine, // @flag
11
+ } from "akanjs/common";
12
+ export { Err as Reexported } from "akanjs/common"; // @flag
13
+ export { Err as DeepReexported } from "akanjs/common/err.ts"; // @flag
@@ -0,0 +1,12 @@
1
+ import { Err } from "../dict"; // @ok
2
+ import { Err as ClientErr } from "@libs/shared/client"; // @ok
3
+ import { Err as AppErr } from "@apps/demo/client"; // @ok
4
+ import type { Err as TypeOnly } from "akanjs/common"; // @ok
5
+ import { type Err as InlineType, Logger } from "akanjs/common"; // @ok
6
+ import type { Err as DeepType } from "akanjs/common/err.ts"; // @ok
7
+ import { hasErrKey } from "akanjs/common"; // @ok
8
+ import type { ErrConstructor, ErrPayload } from "akanjs/common"; // @ok
9
+ import { Err as NearName } from "akanjs/common/hasErrKey.ts"; // @ok
10
+ export type { Err as ReexportedType } from "akanjs/common"; // @ok
11
+ export { Logger as Log } from "akanjs/common"; // @ok
12
+ export const quotesTheRule = 'import { Err } from "akanjs/common"'; // @ok