@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.
- package/CHANGELOG.md +233 -11
- package/agentAssets.ts +1 -1
- package/akanApp/akanApp.host.test.ts +127 -0
- package/akanApp/akanApp.host.ts +50 -57
- package/akanApp/devHostPolicy.test.ts +11 -11
- package/akanApp/devHostPolicy.ts +2 -4
- package/akanContext.ts +2 -5
- package/akanMcpContract.ts +21 -10
- package/akanMcpToolError.ts +16 -0
- package/apiReport/apiDeclarationPrinter.ts +120 -0
- package/apiReport/apiReport.test.ts +497 -0
- package/apiReport/apiReport.ts +232 -0
- package/apiReport/apiReportDiff.ts +126 -0
- package/apiReport/apiReportPolicy.ts +65 -0
- package/apiReport/apiTargets.ts +311 -0
- package/apiReport/changesetBump.ts +19 -0
- package/applicationBuildReporter.test.ts +21 -0
- package/applicationBuildReporter.ts +8 -0
- package/applicationBuildRunner.test.ts +67 -0
- package/applicationBuildRunner.ts +6 -1
- package/artifact/implicitRootLayout.test.ts +4 -0
- package/artifact/implicitRootLayout.ts +13 -1
- package/artifact/storeBootstrap.ts +3 -2
- package/bareErrorScanner.ts +126 -0
- package/biome.base.json +2 -1
- package/codeAgent/skills/akan-debug/SKILL.md +7 -3
- package/codeAgent/skills/akan-err/SKILL.md +7 -0
- package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
- package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
- package/codeAgent/skills/akan-transport/SKILL.md +2 -0
- package/codeAgent/skills/akan-validate/SKILL.md +1 -0
- package/codeAgent/tools/McpClient.ts +28 -6
- package/codeAgent/tools/McpToolPack.ts +1 -1
- package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
- package/commandDecorators/argMeta.ts +4 -0
- package/commandDecorators/cliError.ts +72 -0
- package/commandDecorators/command.ts +186 -96
- package/commandDecorators/commandDecorators.test.ts +240 -17
- package/commandDecorators/exitCodeError.ts +6 -7
- package/commandDecorators/index.ts +1 -0
- package/commandDecorators/targetMeta.ts +3 -0
- package/conventionSet.test.ts +5 -2
- package/csrE2e/csrE2eHarness.fixture.ts +1 -1
- package/errorCodeReference.test.ts +282 -0
- package/errorCodeReference.ts +71 -0
- package/errorCodeScan.ts +551 -0
- package/frontendBuild/allRoutesBuilder.test.ts +16 -4
- package/frontendBuild/allRoutesBuilder.ts +24 -6
- package/frontendBuild/buildRouteClient.test.ts +43 -0
- package/frontendBuild/bundleDefine.ts +2 -0
- package/frontendBuild/clientBuildTypes.ts +5 -2
- package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
- package/frontendBuild/clientEntryDiscovery.ts +53 -9
- package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
- package/frontendBuild/csrArtifactBuilder.ts +9 -3
- package/frontendBuild/csrDevBundler.ts +12 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
- package/frontendBuild/csrDevModuleCompiler.ts +37 -8
- package/frontendBuild/csrDevPatcher.test.ts +89 -2
- package/frontendBuild/csrDevPatcher.ts +47 -7
- package/frontendBuild/csrDevTypes.ts +16 -0
- package/frontendBuild/cssCompiler.test.ts +63 -1
- package/frontendBuild/devBarrelImports.ts +132 -0
- package/frontendBuild/devRegistryBundler.ts +19 -4
- package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
- package/frontendBuild/nativeCore.test.ts +148 -0
- package/frontendBuild/precompressArtifacts.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
- package/frontendBuild/ssrDevBundler.ts +13 -2
- package/frontendBuild/vendorImportScanner.test.ts +24 -3
- package/frontendBuild/vendorImportScanner.ts +25 -10
- package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
- package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
- package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
- package/lint/no-import-common-err.grit +31 -0
- package/lint/no-init-fetch-in-client.grit +1 -1
- package/lint/no-web-only-api-outside-webkit.grit +3 -3
- package/maintainabilityBaseline.test.ts +49 -0
- package/maintainabilityBaseline.ts +73 -8
- package/maintainabilityContract.test.ts +302 -1
- package/maintainabilityContract.ts +87 -22
- package/maintainabilityExceptionScanner.test.ts +14 -0
- package/maintainabilityExceptionScanner.ts +5 -1
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.ts +20 -3
- package/moduleFieldNames.test.ts +56 -0
- package/moduleFieldNames.ts +25 -0
- package/package.json +3 -3
- package/preview/PreviewView.ts +32 -11
- package/preview/previewView.test.ts +76 -9
- package/routeJsBudget.test.ts +47 -11
- package/routeJsBudget.ts +88 -25
- package/transforms/barrelAnalyzer.ts +46 -1
- package/transforms/barrelImportsPlugin.ts +61 -45
- package/transforms/transforms.test.ts +67 -1
- package/typecheck/projectTypecheck.ts +5 -1
- package/typecheck/typecheck.proc.ts +3 -2
- package/typecheck/typecheckFailure.ts +6 -1
- package/workflow/artifacts.ts +25 -7
- package/workflow/fieldType.ts +92 -0
- package/workflow/index.ts +2 -0
- package/workflow/plainName.ts +28 -0
- package/workflow/plan.ts +67 -22
- package/workflow/source.ts +0 -5
- package/workspaceGraph.test.ts +200 -3
- 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
|
|
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:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 =
|
|
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([
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
27
|
-
const
|
|
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,
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
#
|
|
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.#
|
|
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.#
|
|
210
|
+
if (this.#manualStop || this.#restart.pending) return;
|
|
214
211
|
this.#status = "restarting";
|
|
215
|
-
const
|
|
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
|
-
}
|
|
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)
|
|
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
|
-
|
|
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
|