@akanjs/devkit 3.1.2-rc.4 → 3.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +353 -0
- package/abstractDoc.ts +1 -1
- package/agentAssets.ts +2 -2
- package/akanConfig/akanConfig.ts +2 -2
- package/akanMcpContract.ts +1 -1
- package/apiReference.test.ts +37 -0
- package/apiReference.ts +240 -0
- package/applicationBuildReporter.test.ts +25 -3
- package/applicationBuildReporter.ts +8 -1
- package/applicationBuildRunner.test.ts +34 -14
- package/applicationBuildRunner.ts +76 -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 +16 -7
- package/codeAgent/skills/akan-service/SKILL.md +48 -19
- package/commandDecorators/command.ts +55 -9
- package/commandDecorators/commandDecorators.test.ts +131 -2
- package/createTunnel.test.ts +12 -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.ts +6 -2
- package/incrementalBuilder/builderGraphPayloads.ts +60 -0
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +65 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +5 -36
- package/integration/devStability.integration.test.ts +1 -1
- package/integration/devStabilityHarness.test.ts +1 -1
- package/lint/no-deep-internal-import.grit +1 -1
- 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/preview/PreviewChrome.ts +3 -3
- package/preview/PreviewView.ts +26 -8
- package/preview/previewView.test.ts +87 -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/workflow/plan.ts +79 -17
- package/workflow/types.ts +1 -0
- package/workspaceGraph.test.ts +58 -0
- package/workspaceGraph.ts +32 -3
- package/workspaceLayout.ts +1 -0
- /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
package/executors.ts
CHANGED
|
@@ -156,7 +156,7 @@ const parseEnvValue = (value: string) => {
|
|
|
156
156
|
return inner.replace(/\\(.)/g, (pair, char: string) => (char === "n" ? "\n" : char === "r" ? "\r" : pair));
|
|
157
157
|
};
|
|
158
158
|
|
|
159
|
-
const parseEnvFile = (envPath: string): Record<string, string> => {
|
|
159
|
+
export const parseEnvFile = (envPath: string): Record<string, string> => {
|
|
160
160
|
const env: Record<string, string> = {};
|
|
161
161
|
const content = (() => {
|
|
162
162
|
try {
|
|
@@ -683,13 +683,15 @@ export class WorkspaceExecutor extends Executor {
|
|
|
683
683
|
} = {}) {
|
|
684
684
|
return new WorkspaceExecutor({ workspaceRoot, repoName });
|
|
685
685
|
}
|
|
686
|
-
/** The public env a native build was told to reproduce (`--public-env`): it outranks
|
|
686
|
+
/** The public env a native build was told to reproduce (`--public-env`): it outranks the shell, which outranks `.env`. */
|
|
687
687
|
static publicEnvPin: Record<string, string> | null = null;
|
|
688
688
|
static readonly localServeDomain = "localhost";
|
|
689
689
|
static getBaseDevEnv(envPath?: string) {
|
|
690
690
|
// Bun auto-loads .env, so we use process.env directly
|
|
691
691
|
const sourceEnv = {
|
|
692
|
-
...(envPath ?
|
|
692
|
+
...(envPath ? parseEnvFile(envPath) : {}),
|
|
693
|
+
//? An empty export reads as unset, unlike in dotenv, so it can't blank a key the .env sets.
|
|
694
|
+
...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value)),
|
|
693
695
|
...WorkspaceExecutor.publicEnvPin,
|
|
694
696
|
};
|
|
695
697
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { FrameworkGraph } from "./frameworkGraph";
|
|
6
|
+
|
|
7
|
+
const workspaceRoot = path.resolve(import.meta.dir, "../../..");
|
|
8
|
+
//? The other end of these lies outside the source read here: @akanjs/native's desktop shell answers file and secret
|
|
9
|
+
//? requests, AkanApp's child reports a boot error from a code string, and the browser tab sends `sync-navigation` and
|
|
10
|
+
//? handles every HmrMessage in a script string. `ws.*` and HMR `ok` have no site.
|
|
11
|
+
const unrecognised = [
|
|
12
|
+
"AkanIpcMessage ws.opened sender",
|
|
13
|
+
"AkanIpcMessage ws.opened handler",
|
|
14
|
+
"AkanIpcMessage ws.closed sender",
|
|
15
|
+
"AkanIpcMessage ws.closed handler",
|
|
16
|
+
"AkanIpcMessage file.resolve handler",
|
|
17
|
+
"AkanIpcMessage file.resolved sender",
|
|
18
|
+
"AkanIpcMessage secret.request handler",
|
|
19
|
+
"AkanIpcMessage secret.answer sender",
|
|
20
|
+
"AkanIpcMessage error sender",
|
|
21
|
+
"HmrMessage sync-navigation sender",
|
|
22
|
+
"HmrMessage ok sender",
|
|
23
|
+
];
|
|
24
|
+
const handledInBrowser = new Set(["HmrMessage"]);
|
|
25
|
+
|
|
26
|
+
const withWorkspace = async (files: Record<string, string>, run: (root: string) => Promise<void>) => {
|
|
27
|
+
const root = await mkdtemp(path.join(tmpdir(), "akan-framework-"));
|
|
28
|
+
try {
|
|
29
|
+
for (const [file, text] of Object.entries(files)) {
|
|
30
|
+
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
|
|
31
|
+
await writeFile(path.join(root, file), text);
|
|
32
|
+
}
|
|
33
|
+
await run(root);
|
|
34
|
+
} finally {
|
|
35
|
+
await rm(root, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const installed = (version: string) => ({
|
|
40
|
+
"node_modules/akanjs/package.json": JSON.stringify({
|
|
41
|
+
name: "akanjs",
|
|
42
|
+
version,
|
|
43
|
+
exports: { ".": "./index.ts", "./package.json": "./package.json" },
|
|
44
|
+
}),
|
|
45
|
+
"node_modules/akanjs/index.ts": `export * from "./base";\n`,
|
|
46
|
+
"node_modules/akanjs/base/scalar.ts": "export class Scalar {}\n",
|
|
47
|
+
"node_modules/akanjs/native/bridge.ts": `import { Native } from "../vendor/@akanjs/native/index";\nexport class Bridge {}\n`,
|
|
48
|
+
"node_modules/akanjs/vendor/@akanjs/native/index.ts": "export class Native {}\n",
|
|
49
|
+
"node_modules/akanjs/vendor/use-agentic/index.ts": "export class Agentic {}\n",
|
|
50
|
+
"node_modules/akanjs/types/base/index.ts": "export class Declared {}\n",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("FrameworkGraph", () => {
|
|
54
|
+
it(`keeps ${FrameworkGraph.architectureFile} current with the source it describes`, async () => {
|
|
55
|
+
const architecture = await FrameworkGraph.architecture(workspaceRoot);
|
|
56
|
+
expect(architecture).not.toBeNull();
|
|
57
|
+
if (!architecture) return;
|
|
58
|
+
const { map } = architecture;
|
|
59
|
+
expect(map.missing).toEqual([]);
|
|
60
|
+
expect(map.cycles).toEqual([]);
|
|
61
|
+
expect(architecture.unknownClasses).toEqual([]);
|
|
62
|
+
const unmatched = map.channels.flatMap(({ name, messages }) =>
|
|
63
|
+
messages.flatMap(({ type, senders, handlers }) => [
|
|
64
|
+
...(senders.length ? [] : [`${name} ${type} sender`]),
|
|
65
|
+
...(handlers.length || handledInBrowser.has(name) ? [] : [`${name} ${type} handler`]),
|
|
66
|
+
]),
|
|
67
|
+
);
|
|
68
|
+
expect(unmatched).toEqual(unrecognised);
|
|
69
|
+
if (architecture.next !== architecture.current)
|
|
70
|
+
throw new Error(`${FrameworkGraph.architectureFile} is stale. Regenerate it: bun run akan sync-architecture`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("reads a consumer's installed akanjs without its bundled vendor and declaration folders", async () => {
|
|
74
|
+
await withWorkspace({ "package.json": "{}" }, async (root) => {
|
|
75
|
+
expect(await FrameworkGraph.build(root)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
await withWorkspace(installed("3.1.0"), async (root) => {
|
|
78
|
+
expect(await FrameworkGraph.packagesOf(root)).toEqual([
|
|
79
|
+
{ name: "akanjs", root: "node_modules/akanjs", split: true },
|
|
80
|
+
]);
|
|
81
|
+
const map = await FrameworkGraph.build(root);
|
|
82
|
+
expect(map?.facets.map(({ id, layer, classes }) => `${id}:${layer}:${classes.map(({ name }) => name)}`)).toEqual([
|
|
83
|
+
"akanjs/base:0:Scalar",
|
|
84
|
+
"akanjs/native:0:Bridge",
|
|
85
|
+
]);
|
|
86
|
+
expect(await FrameworkGraph.architecture(root)).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rebuilds an installed map on a new version, and an in-repo one on a save under it", async () => {
|
|
91
|
+
await withWorkspace(installed("3.1.0"), async (root) => {
|
|
92
|
+
const first = await FrameworkGraph.cached(root);
|
|
93
|
+
FrameworkGraph.touched(root, [path.join(root, "node_modules/akanjs/base/scalar.ts")]);
|
|
94
|
+
expect(await FrameworkGraph.cached(root)).toBe(first);
|
|
95
|
+
await writeFile(path.join(root, "node_modules/akanjs/package.json"), JSON.stringify({ version: "3.1.1" }));
|
|
96
|
+
expect(await FrameworkGraph.cached(root)).not.toBe(first);
|
|
97
|
+
});
|
|
98
|
+
await withWorkspace({ "pkgs/akanjs/base/scalar.ts": "export class Scalar {}\n" }, async (root) => {
|
|
99
|
+
const first = await FrameworkGraph.cached(root);
|
|
100
|
+
FrameworkGraph.touched(root, [path.join(root, "apps/demo/page/_index.tsx")]);
|
|
101
|
+
expect(await FrameworkGraph.cached(root)).toBe(first);
|
|
102
|
+
FrameworkGraph.touched(root, [path.join(root, "pkgs/akanjs/base/scalar.ts")]);
|
|
103
|
+
expect(await FrameworkGraph.cached(root)).not.toBe(first);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reads a document checked out with CRLF line endings as current", async () => {
|
|
108
|
+
const files = {
|
|
109
|
+
"pkgs/akanjs/base/scalar.ts": "export class Scalar {}\n",
|
|
110
|
+
[FrameworkGraph.architectureFile]: "# A\n",
|
|
111
|
+
};
|
|
112
|
+
await withWorkspace(files, async (root) => {
|
|
113
|
+
const first = await FrameworkGraph.architecture(root);
|
|
114
|
+
if (!first) throw new Error("no architecture");
|
|
115
|
+
await writeFile(path.join(root, FrameworkGraph.architectureFile), first.next.replace(/\n/g, "\r\n"));
|
|
116
|
+
const second = await FrameworkGraph.architecture(root);
|
|
117
|
+
expect(second?.current).toBe(first.next);
|
|
118
|
+
expect(second?.next).toBe(first.next);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -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,
|