@akanjs/devkit 3.1.2-rc.3 → 3.2.0-rc.0
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 +348 -0
- package/abstractDoc.ts +1 -1
- package/agentAssets.ts +2 -2
- package/akanConfig/akanConfig.ts +2 -2
- package/akanMcpContract.ts +2 -2
- package/apiReference.test.ts +37 -0
- package/apiReference.ts +240 -0
- package/applicationBuildReporter.test.ts +54 -0
- package/applicationBuildReporter.ts +12 -0
- package/applicationBuildRunner.test.ts +34 -14
- package/applicationBuildRunner.ts +77 -14
- package/artifact/implicitRootLayout.test.ts +42 -0
- package/artifact/implicitRootLayout.ts +5 -6
- package/artifact/storeBootstrap.ts +24 -0
- package/cloud/cloudApi.test.ts +70 -7
- package/cloud/cloudApi.ts +10 -4
- package/cloud/constants.ts +3 -0
- package/cloud/globalConfig.test.ts +36 -0
- package/cloud/globalConfig.ts +10 -1
- package/codeAgent/skills/akan-debug/SKILL.md +2 -1
- package/codeAgent/skills/akan-page/SKILL.md +30 -2
- package/codeAgent/skills/akan-service/SKILL.md +77 -1
- package/commandDecorators/command.ts +55 -9
- package/commandDecorators/commandDecorators.test.ts +131 -2
- package/declarationReader.ts +211 -0
- package/executors.test.ts +65 -8
- package/executors.ts +5 -3
- package/frameworkGraph.test.ts +121 -0
- package/frameworkGraph.ts +165 -0
- package/frontendBuild/allRoutesBuilder.test.ts +84 -0
- package/frontendBuild/allRoutesBuilder.ts +32 -12
- package/frontendBuild/buildRouteClient.test.ts +192 -2
- package/frontendBuild/bundleDefine.ts +4 -0
- package/frontendBuild/clientBuildTypes.ts +7 -1
- package/frontendBuild/clientEntriesBundler.test.ts +82 -0
- package/frontendBuild/clientEntriesBundler.ts +55 -4
- package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
- package/frontendBuild/csrArtifactBuilder.ts +2 -1
- package/frontendBuild/fontOptimizer.test.ts +54 -0
- package/frontendBuild/fontOptimizer.ts +5 -4
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/routeClientBuilder.ts +6 -2
- package/frontendBuild/routeModulePreloads.test.ts +59 -0
- package/frontendBuild/routeModulePreloads.ts +52 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
- package/frontendBuild/ssrDevBundler.test.ts +26 -0
- package/frontendBuild/ssrDevBundler.ts +2 -1
- package/frontendBuild/vendorImportScanner.test.ts +71 -0
- package/frontendBuild/vendorImportScanner.ts +80 -0
- package/frontendBuild/vendorNarrowing.test.ts +103 -0
- package/frontendBuild/vendorNarrowing.ts +64 -0
- package/frontendBuild/vendorSpecifiers.ts +13 -0
- package/gitRevision.test.ts +82 -0
- package/gitRevision.ts +128 -0
- package/incrementalBuilder/builderGraphPayloads.ts +60 -0
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +105 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +6 -10
- package/integration/devStability.integration.test.ts +1 -1
- package/integration/devStabilityHarness.test.ts +1 -1
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +4 -2
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +2 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -2
- package/lint/no-daisyui-legacy-class.grit +9 -7
- package/lint/no-deep-internal-import.grit +1 -1
- package/lint/no-js-private-class-method.grit +9 -28
- package/maintainabilityBaseline.ts +7 -2
- package/maintainabilityContract.test.ts +183 -0
- package/maintainabilityContract.ts +145 -30
- package/maintainabilityExceptionScanner.test.ts +13 -2
- package/maintainabilityExceptionScanner.ts +16 -6
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.test.ts +35 -0
- package/maintainabilityReport.ts +29 -8
- package/mobile/nativePublicEnv.test.ts +5 -1
- package/package.json +3 -3
- package/routeJsBudget.test.ts +177 -0
- package/routeJsBudget.ts +222 -0
- package/structureScanner.test.ts +167 -0
- package/structureScanner.ts +164 -0
- package/transforms/barrelAnalyzer.ts +10 -3
- package/transforms/barrelImportsPlugin.ts +13 -7
- package/transforms/serverEnvPlugin.test.ts +1 -1
- package/transforms/serverEnvPlugin.ts +1 -1
- package/transforms/transforms.test.ts +64 -12
- package/transforms/tsconfigPackageResolver.test.ts +12 -0
- package/typeChecker.test.ts +100 -0
- package/typeChecker.ts +14 -2
- package/workflow/plan.ts +79 -17
- package/workflow/types.ts +1 -0
- package/workspaceGraph.test.ts +121 -0
- package/workspaceGraph.ts +149 -16
- package/workspaceLayout.ts +1 -0
- /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type FrameworkChannelSpec,
|
|
5
|
+
FrameworkMapBuilder,
|
|
6
|
+
FrameworkMapDoc,
|
|
7
|
+
type FrameworkSourcePackage,
|
|
8
|
+
} from "@akanjs/language";
|
|
9
|
+
import type { FrameworkMap } from "akanjs/common";
|
|
10
|
+
import ts from "typescript";
|
|
11
|
+
|
|
12
|
+
const packageSpecs: Omit<FrameworkSourcePackage, "root">[] = [
|
|
13
|
+
{ name: "akanjs", split: true },
|
|
14
|
+
{ name: "@akanjs/devkit", split: false },
|
|
15
|
+
];
|
|
16
|
+
const channelSpecs: FrameworkChannelSpec[] = [
|
|
17
|
+
{ file: "service/ipcTypes.ts", union: "AkanIpcMessage", label: "gateway ⇄ child" },
|
|
18
|
+
{ file: "server/artifact/ipcTypes.ts", union: "BuilderMessage", label: "dev host ⇄ builder, relayed to the child" },
|
|
19
|
+
{ file: "server/rscWorker.tsx", union: "InMsg", label: "server → RSC worker" },
|
|
20
|
+
{ file: "server/rscWorkerHost.ts", union: "RscInMsg", label: "RSC worker → server" },
|
|
21
|
+
{ file: "server/hmr/wsHub.ts", union: "HmrMessage", label: "dev server → browser tab", relays: ["HmrWsHub"] },
|
|
22
|
+
];
|
|
23
|
+
//? A published akanjs bundles use-agentic and @akanjs/native under `vendor/` and its declarations under `types/`;
|
|
24
|
+
//? devkit's `integration`, `csrE2e` and `platformTest` are test harnesses that run in no framework process.
|
|
25
|
+
const skippedDirs = new Set(["node_modules", "vendor", "types", "integration", "csrE2e", "platformTest"]);
|
|
26
|
+
|
|
27
|
+
export interface FrameworkArchitecture {
|
|
28
|
+
file: string;
|
|
29
|
+
current: string;
|
|
30
|
+
next: string;
|
|
31
|
+
unknownClasses: string[];
|
|
32
|
+
map: FrameworkMap;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class FrameworkGraph {
|
|
36
|
+
static readonly architectureFile = "pkgs/akanjs/ARCHITECTURE.md";
|
|
37
|
+
static readonly #cache = new Map<string, Promise<FrameworkMap | null>>();
|
|
38
|
+
static readonly #edits = new Map<string, number>();
|
|
39
|
+
|
|
40
|
+
static async packagesOf(workspaceRoot: string): Promise<FrameworkSourcePackage[]> {
|
|
41
|
+
const found = await Promise.all(
|
|
42
|
+
packageSpecs.map(async ({ name, split }) => {
|
|
43
|
+
const root = (await FrameworkGraph.#isDir(path.join(workspaceRoot, "pkgs", name)))
|
|
44
|
+
? `pkgs/${name}`
|
|
45
|
+
: await FrameworkGraph.#installedRoot(workspaceRoot, name);
|
|
46
|
+
return root ? [{ name, root, split }] : [];
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
return found.flat();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static async build(workspaceRoot: string): Promise<FrameworkMap | null> {
|
|
53
|
+
const packages = await FrameworkGraph.packagesOf(workspaceRoot);
|
|
54
|
+
const akanjs = packages.find(({ name }) => name === "akanjs");
|
|
55
|
+
if (!akanjs) return null;
|
|
56
|
+
const files = (
|
|
57
|
+
await Promise.all(packages.map(({ root }) => FrameworkGraph.#filesUnder(workspaceRoot, root)))
|
|
58
|
+
).flat();
|
|
59
|
+
const texts = await Promise.all(
|
|
60
|
+
files.map(async (file) => [file, await readFile(path.join(workspaceRoot, file), "utf8")] as [string, string]),
|
|
61
|
+
);
|
|
62
|
+
return new FrameworkMapBuilder({
|
|
63
|
+
ts,
|
|
64
|
+
packages,
|
|
65
|
+
files: texts,
|
|
66
|
+
channels: channelSpecs.map((spec) => ({ ...spec, file: `${akanjs.root}/${spec.file}` })),
|
|
67
|
+
}).build();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
//? Only the framework's own repository holds the document; a consumer's `node_modules/akanjs` is never rewritten.
|
|
71
|
+
static async architecture(workspaceRoot: string): Promise<FrameworkArchitecture | null> {
|
|
72
|
+
const file = path.join(workspaceRoot, FrameworkGraph.architectureFile);
|
|
73
|
+
const current = await readFile(file, "utf8")
|
|
74
|
+
.then((text) => text.replace(/\r\n/g, "\n"))
|
|
75
|
+
.catch(() => null);
|
|
76
|
+
const map = current === null ? null : await FrameworkGraph.build(workspaceRoot);
|
|
77
|
+
if (current === null || !map || map.packages.find(({ name }) => name === "akanjs")?.root !== "pkgs/akanjs")
|
|
78
|
+
return null;
|
|
79
|
+
const next = FrameworkMapDoc.splice(
|
|
80
|
+
current,
|
|
81
|
+
FrameworkMapDoc.render(map, path.posix.dirname(FrameworkGraph.architectureFile)),
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
file,
|
|
85
|
+
current,
|
|
86
|
+
next,
|
|
87
|
+
unknownClasses: FrameworkMapDoc.unknownClasses(next, map),
|
|
88
|
+
map,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static touched(workspaceRoot: string, files: string[]) {
|
|
93
|
+
const roots = packageSpecs.map(({ name }) => path.join(workspaceRoot, "pkgs", name) + path.sep);
|
|
94
|
+
if (files.some((file) => roots.some((root) => path.resolve(workspaceRoot, file).startsWith(root))))
|
|
95
|
+
FrameworkGraph.#edits.set(workspaceRoot, (FrameworkGraph.#edits.get(workspaceRoot) ?? 0) + 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
//? Only an install rewrites `node_modules/akanjs`, and an install changes its version.
|
|
99
|
+
static async cached(workspaceRoot: string): Promise<FrameworkMap | null> {
|
|
100
|
+
const packages = await FrameworkGraph.packagesOf(workspaceRoot);
|
|
101
|
+
const stamps = await Promise.all(
|
|
102
|
+
packages.map(async ({ name, root }) =>
|
|
103
|
+
root.startsWith("pkgs/")
|
|
104
|
+
? `${name}#${FrameworkGraph.#edits.get(workspaceRoot) ?? 0}`
|
|
105
|
+
: `${name}@${await FrameworkGraph.#versionOf(path.join(workspaceRoot, root))}`,
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
const cacheKey = [workspaceRoot, ...stamps].join("\n");
|
|
109
|
+
const existing = FrameworkGraph.#cache.get(cacheKey);
|
|
110
|
+
if (existing) return await existing;
|
|
111
|
+
const built = FrameworkGraph.build(workspaceRoot);
|
|
112
|
+
built.catch(() => FrameworkGraph.#cache.delete(cacheKey));
|
|
113
|
+
FrameworkGraph.#cache.clear();
|
|
114
|
+
FrameworkGraph.#cache.set(cacheKey, built);
|
|
115
|
+
return await built;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
//? Bun resolves a bare name from its global cache when no `node_modules` exists, so one must; an install linked from
|
|
119
|
+
//? outside the workspace is skipped, as its files have no workspace-relative path.
|
|
120
|
+
static async #installedRoot(workspaceRoot: string, name: string): Promise<string | null> {
|
|
121
|
+
if (!(await FrameworkGraph.#isDir(path.join(workspaceRoot, "node_modules")))) return null;
|
|
122
|
+
const resolved = await (async () => {
|
|
123
|
+
try {
|
|
124
|
+
return path.dirname(await realpath(Bun.resolveSync(`${name}/package.json`, workspaceRoot)));
|
|
125
|
+
} catch {
|
|
126
|
+
const dir = path.join(workspaceRoot, "node_modules", name);
|
|
127
|
+
return (await FrameworkGraph.#isDir(dir)) ? await realpath(dir) : null;
|
|
128
|
+
}
|
|
129
|
+
})();
|
|
130
|
+
if (!resolved) return null;
|
|
131
|
+
const root = path
|
|
132
|
+
.relative(await realpath(workspaceRoot), resolved)
|
|
133
|
+
.split(path.sep)
|
|
134
|
+
.join("/");
|
|
135
|
+
return root && !root.startsWith("..") && !path.isAbsolute(root) ? root : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
static async #versionOf(dir: string) {
|
|
139
|
+
return await readFile(path.join(dir, "package.json"), "utf8")
|
|
140
|
+
.then((text) => (JSON.parse(text) as { version?: string }).version ?? "")
|
|
141
|
+
.catch(() => "");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
static async #isDir(dir: string) {
|
|
145
|
+
return await stat(dir)
|
|
146
|
+
.then((info) => info.isDirectory())
|
|
147
|
+
.catch(() => false);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static async #filesUnder(workspaceRoot: string, root: string): Promise<string[]> {
|
|
151
|
+
const entries = await readdir(path.join(workspaceRoot, root), { withFileTypes: true });
|
|
152
|
+
const files: string[] = [];
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (entry.name.startsWith(".") || skippedDirs.has(entry.name)) continue;
|
|
155
|
+
if (entry.isFile()) files.push(`${root}/${entry.name}`);
|
|
156
|
+
else if (entry.isDirectory())
|
|
157
|
+
for await (const file of new Bun.Glob("**/*.{ts,tsx}").scan({
|
|
158
|
+
cwd: path.join(workspaceRoot, root, entry.name),
|
|
159
|
+
}))
|
|
160
|
+
if (!file.split(path.sep).includes("node_modules"))
|
|
161
|
+
files.push(`${root}/${entry.name}/${file.split(path.sep).join("/")}`);
|
|
162
|
+
}
|
|
163
|
+
return files.filter((file) => FrameworkMapBuilder.isSourcePath(file)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, realpath, symlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs, writeText as write } from "../testHelpers";
|
|
5
|
+
import { AllRoutesBuilder } from "./allRoutesBuilder";
|
|
6
|
+
|
|
7
|
+
const makeTempRoot = tempDirs("akan-all-routes-");
|
|
8
|
+
const akanjsRoot = path.resolve(import.meta.dir, "../../../akanjs");
|
|
9
|
+
|
|
10
|
+
const linkPackage = async (root: string, name: string, dir: string) => {
|
|
11
|
+
await mkdir(path.join(root, "node_modules"), { recursive: true });
|
|
12
|
+
await symlink(dir, path.join(root, "node_modules", name));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
//? As this monorepo maps it, so the generated root layout's `akanjs/ui/System/index.tsx` resolves through a wildcard.
|
|
16
|
+
const paths = { "@apps/*": ["./apps/*"], "akanjs/*": ["./node_modules/akanjs/*"] };
|
|
17
|
+
|
|
18
|
+
const writeApp = async () => {
|
|
19
|
+
const root = await realpath(await makeTempRoot());
|
|
20
|
+
const appDir = path.join(root, "apps/demo");
|
|
21
|
+
await linkPackage(root, "akanjs", akanjsRoot);
|
|
22
|
+
await linkPackage(root, "react", path.dirname(Bun.resolveSync("react/package.json", akanjsRoot)));
|
|
23
|
+
await write(path.join(root, "tsconfig.json"), JSON.stringify({ compilerOptions: { paths } }));
|
|
24
|
+
await write(path.join(appDir, "client.ts"), "export {};\n");
|
|
25
|
+
await write(path.join(appDir, "env/env.client.ts"), "export const env = {};\n");
|
|
26
|
+
await write(path.join(appDir, "lib/dict.ts"), "export const getAllDictionary = () => ({});\n");
|
|
27
|
+
await write(path.join(appDir, "ui/Home.tsx"), '"use client";\nexport const Home = () => "home-marker";\n');
|
|
28
|
+
await write(path.join(appDir, "ui/Memo.tsx"), '"use client";\nexport const Memo = () => "memo-marker";\n');
|
|
29
|
+
await write(
|
|
30
|
+
path.join(appDir, "page/_index.tsx"),
|
|
31
|
+
'import { Home } from "../ui/Home";\nexport default function Page() {\n return <Home />;\n}\n',
|
|
32
|
+
);
|
|
33
|
+
await write(
|
|
34
|
+
path.join(appDir, "page/memo.tsx"),
|
|
35
|
+
'import { Memo } from "../ui/Memo";\nexport default function Page() {\n return <Memo />;\n}\n',
|
|
36
|
+
);
|
|
37
|
+
const app = {
|
|
38
|
+
name: "demo",
|
|
39
|
+
cwdPath: appDir,
|
|
40
|
+
dist: { cwdPath: path.join(root, "dist/apps/demo") },
|
|
41
|
+
workspace: { workspaceRoot: root },
|
|
42
|
+
getPageKeys: async () => ["./_index.tsx", "./memo.tsx"],
|
|
43
|
+
getConfig: async () => ({ basePaths: [], optimizeImports: [], barrelImports: [] }),
|
|
44
|
+
getTsConfig: async () => ({ compilerOptions: { paths } }),
|
|
45
|
+
getPublicEnv: () => ({}),
|
|
46
|
+
log: () => undefined,
|
|
47
|
+
verbose: () => undefined,
|
|
48
|
+
logger: { warn: () => undefined },
|
|
49
|
+
};
|
|
50
|
+
return app;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe("AllRoutesBuilder", () => {
|
|
54
|
+
test("writes the route manifest, then the preload table once the runtime chunks are known, and each route's modules", async () => {
|
|
55
|
+
const app = await writeApp();
|
|
56
|
+
const routes = new AllRoutesBuilder(app as never, "build");
|
|
57
|
+
const { manifest, manifestPath } = await routes.build();
|
|
58
|
+
|
|
59
|
+
expect(new Set(manifest.routeIds)).toEqual(new Set(["/:lang", "/:lang/memo"]));
|
|
60
|
+
expect(await Bun.file(manifestPath).json()).not.toHaveProperty("modulePreloads");
|
|
61
|
+
|
|
62
|
+
const runtimeChunks = { runtime: ["/_akan/client/rsc.js"], vendors: { react: ["/_akan/client/react.js"] } };
|
|
63
|
+
const preloads = await routes.writeModulePreloads(runtimeChunks);
|
|
64
|
+
expect(preloads.table).toMatchObject(runtimeChunks);
|
|
65
|
+
expect(new Set(Object.values(preloads.table.chunkVendors).flat())).toEqual(new Set(["react"]));
|
|
66
|
+
expect((await Bun.file(manifestPath).json()).modulePreloads).toEqual(preloads.table);
|
|
67
|
+
|
|
68
|
+
const chunksOf = (suffix: string) =>
|
|
69
|
+
Object.entries(manifest.clientManifest).find(([key]) => key.endsWith(suffix))?.[1].chunks ?? [];
|
|
70
|
+
const [home] = chunksOf("apps/demo/ui/Home.tsx#Home");
|
|
71
|
+
const [memo] = chunksOf("apps/demo/ui/Memo.tsx#Memo");
|
|
72
|
+
const shell = chunksOf("akanjs/ui/System/Client.tsx#Client");
|
|
73
|
+
const modules = preloads.modulesByRoute();
|
|
74
|
+
expect(shell.length).toBeGreaterThan(0);
|
|
75
|
+
for (const [routeId, own, other] of [
|
|
76
|
+
["/:lang", home, memo],
|
|
77
|
+
["/:lang/memo", memo, home],
|
|
78
|
+
] as const) {
|
|
79
|
+
expect(modules[routeId]?.slice(0, 2)).toEqual(["/_akan/client/rsc.js", "/_akan/client/react.js"]);
|
|
80
|
+
expect(modules[routeId]).toEqual(expect.arrayContaining([own, ...shell]));
|
|
81
|
+
expect(modules[routeId]).not.toContain(other);
|
|
82
|
+
}
|
|
83
|
+
}, 30_000);
|
|
84
|
+
});
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import type {
|
|
2
|
+
import type { RoutesManifest } from "akanjs/server";
|
|
3
3
|
import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
4
4
|
import { computeRouteSeedIndex, type RouteSeedIndex } from "../artifact/routeSeedIndex";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
6
|
import type { ClientEntryDiscovery } from "./clientBuildTypes";
|
|
7
7
|
import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
|
|
8
|
-
import { RouteClientBuilder } from "./routeClientBuilder";
|
|
8
|
+
import { type BuildRouteClientResult, RouteClientBuilder } from "./routeClientBuilder";
|
|
9
|
+
import { RouteModulePreloads, type RuntimeChunks } from "./routeModulePreloads";
|
|
9
10
|
import { RoutesManifestArtifactSerializer } from "./routesManifestArtifactSerializer";
|
|
10
11
|
|
|
11
12
|
export interface BuildAllRoutesResult {
|
|
@@ -16,7 +17,6 @@ export interface BuildAllRoutesResult {
|
|
|
16
17
|
|
|
17
18
|
export class AllRoutesBuilder {
|
|
18
19
|
#app: App;
|
|
19
|
-
#artifact: BaseBuildArtifact;
|
|
20
20
|
#command: "build" | "start";
|
|
21
21
|
#artifactDir: string;
|
|
22
22
|
#merged: Pick<RoutesManifest, "clientManifest" | "ssrManifest" | "knownEntries"> = {
|
|
@@ -26,11 +26,12 @@ export class AllRoutesBuilder {
|
|
|
26
26
|
};
|
|
27
27
|
#knownSet = new Set<string>();
|
|
28
28
|
#routeIds: string[] = [];
|
|
29
|
+
#entriesByRoute = new Map<string, string[]>();
|
|
30
|
+
#externalsByChunkUrl: Record<string, string[]> = {};
|
|
29
31
|
#discovery: ClientEntryDiscovery | null = null;
|
|
30
32
|
|
|
31
|
-
constructor(app: App,
|
|
33
|
+
constructor(app: App, command: "build" | "start" = "start") {
|
|
32
34
|
this.#app = app;
|
|
33
|
-
this.#artifact = artifact;
|
|
34
35
|
this.#command = command;
|
|
35
36
|
this.#artifactDir = `${command === "build" ? app.dist.cwdPath : app.cwdPath}/.akan/artifact`;
|
|
36
37
|
}
|
|
@@ -46,7 +47,9 @@ export class AllRoutesBuilder {
|
|
|
46
47
|
const allEntries = new Set<string>();
|
|
47
48
|
for (const entry of seedIndex.entries) {
|
|
48
49
|
const seeds = Array.from(new Set([...seedIndex.globalLayoutFiles, ...entry.seeds]));
|
|
49
|
-
|
|
50
|
+
const routeEntries = await this.#discovery.discover(seeds);
|
|
51
|
+
for (const discovered of routeEntries) allEntries.add(discovered);
|
|
52
|
+
this.#entriesByRoute.set(entry.routeId, routeEntries);
|
|
50
53
|
this.#routeIds.push(entry.routeId);
|
|
51
54
|
}
|
|
52
55
|
this.#app.verbose(`[build-all] ${allEntries.size} client entries across ${this.#routeIds.length} routes`);
|
|
@@ -54,11 +57,33 @@ export class AllRoutesBuilder {
|
|
|
54
57
|
const delta = await this.#buildEntries([...allEntries]);
|
|
55
58
|
this.#mergeDelta(delta);
|
|
56
59
|
this.#merged.knownEntries = Array.from(this.#knownSet);
|
|
60
|
+
this.#externalsByChunkUrl = delta.externalsByChunkUrl ?? {};
|
|
57
61
|
|
|
58
62
|
const manifest: RoutesManifest = {
|
|
59
63
|
routeIds: this.#routeIds,
|
|
60
64
|
...this.#merged,
|
|
61
65
|
};
|
|
66
|
+
const manifestPath = await this.#writeManifest(manifest);
|
|
67
|
+
this.#app.verbose(
|
|
68
|
+
`[build-all] wrote ${manifestPath} routes=${this.#routeIds.length} entries=${this.#merged.knownEntries.length}`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return { manifest, manifestPath, seedIndex };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async writeModulePreloads(runtimeChunks: RuntimeChunks): Promise<RouteModulePreloads> {
|
|
75
|
+
const preloads = new RouteModulePreloads({
|
|
76
|
+
runtimeChunks,
|
|
77
|
+
entriesByRoute: this.#entriesByRoute,
|
|
78
|
+
clientManifest: this.#merged.clientManifest,
|
|
79
|
+
externalsByChunkUrl: this.#externalsByChunkUrl,
|
|
80
|
+
workspaceRoot: this.#app.workspace.workspaceRoot,
|
|
81
|
+
});
|
|
82
|
+
await this.#writeManifest({ routeIds: this.#routeIds, ...this.#merged, modulePreloads: preloads.table });
|
|
83
|
+
return preloads;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async #writeManifest(manifest: RoutesManifest) {
|
|
62
87
|
const manifestPath = path.join(path.resolve(this.#artifactDir), "routes-manifest.json");
|
|
63
88
|
await Bun.write(
|
|
64
89
|
manifestPath,
|
|
@@ -70,11 +95,7 @@ export class AllRoutesBuilder {
|
|
|
70
95
|
2,
|
|
71
96
|
)}\n`,
|
|
72
97
|
);
|
|
73
|
-
|
|
74
|
-
`[build-all] wrote ${manifestPath} routes=${this.#routeIds.length} entries=${this.#merged.knownEntries.length}`,
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
return { manifest, manifestPath, seedIndex };
|
|
98
|
+
return manifestPath;
|
|
78
99
|
}
|
|
79
100
|
|
|
80
101
|
async #buildEntries(entries: string[]): Promise<BuildRouteClientResult> {
|
|
@@ -91,7 +112,6 @@ export class AllRoutesBuilder {
|
|
|
91
112
|
app: this.#app,
|
|
92
113
|
seeds: [],
|
|
93
114
|
entries,
|
|
94
|
-
artifact: this.#artifact,
|
|
95
115
|
knownEntries: this.#knownSet,
|
|
96
116
|
discovery: this.#discovery,
|
|
97
117
|
command: this.#command,
|
|
@@ -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
|
|
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/
|
|
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;
|